File: TestResources-Helpers.ps1

package info (click to toggle)
python-azure 20250603%2Bgit-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 851,724 kB
  • sloc: python: 7,362,925; ansic: 804; javascript: 287; makefile: 195; sh: 145; xml: 109
file content (351 lines) | stat: -rw-r--r-- 15,152 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
function Log($Message) {
    Write-Host ('{0} - {1}' -f [DateTime]::Now.ToLongTimeString(), $Message)
}

# vso commands are specially formatted log lines that are parsed by Azure Pipelines
# to perform additional actions, most commonly marking values as secrets.
# https://docs.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
function LogVsoCommand([string]$message) {
    if (!$CI -or $SuppressVsoCommands) {
        return
    }
    Write-Host $message
}

function Retry([scriptblock] $Action, [int] $Attempts = 5) {
    $attempt = 0
    $sleep = 5

    while ($attempt -lt $Attempts) {
        try {
            $attempt++
            return $Action.Invoke()
        }
        catch {
            if ($attempt -lt $Attempts) {
                $sleep *= 2

                Write-Warning "Attempt $attempt failed: $_. Trying again in $sleep seconds..."
                Start-Sleep -Seconds $sleep
            }
            else {
                throw
            }
        }
    }
}

# NewServicePrincipalWrapper creates an object from an AAD graph or Microsoft Graph service principal object type.
# This is necessary to work around breaking changes introduced in Az version 7.0.0:
# https://azure.microsoft.com/en-us/updates/update-your-apps-to-use-microsoft-graph-before-30-june-2022/
function NewServicePrincipalWrapper([string]$subscription, [string]$resourceGroup, [string]$displayName) {
    if ((Get-Module Az.Resources).Version -eq "5.3.0") {
        # https://github.com/Azure/azure-powershell/issues/17040
        # New-AzAdServicePrincipal calls will fail with:
        # "You cannot call a method on a null-valued expression."
        Write-Warning "Az.Resources version 5.3.0 is not supported. Please update to >= 5.3.1"
        Write-Warning "Update-Module Az.Resources -RequiredVersion 5.3.1"
        exit 1
    }

    try {
        $servicePrincipal = Retry {
            New-AzADServicePrincipal -Role "Owner" -Scope "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName" -DisplayName $displayName
        }
    }
    catch {
        # The underlying error "The directory object quota limit for the Principal has been exceeded" gets overwritten by the module trying
        # to call New-AzADApplication with a null object instead of stopping execution, which makes this case hard to diagnose because it prints the following:
        #      "Cannot bind argument to parameter 'ObjectId' because it is an empty string."
        # Provide a more helpful diagnostic prompt to the user if appropriate:
        $totalApps = (Get-AzADApplication -OwnedApplication).Length
        $msg = "App Registrations owned by you total $totalApps and may exceed the max quota (likely around 135)." + `
            "`nTry removing some at https://ms.portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps" + `
            " or by running the following command to remove apps created by this script:" + `
            "`n    Get-AzADApplication -DisplayNameStartsWith '$baseName' | Remove-AzADApplication" + `
            "`nNOTE: You may need to wait for the quota number to be updated after removing unused applications."
        Write-Warning $msg
        throw
    }

    $spPassword = ""
    $appId = ""
    if (Get-Member -Name "Secret" -InputObject $servicePrincipal -MemberType property) {
        Write-Verbose "Using legacy PSADServicePrincipal object type from AAD graph API"
        # Secret property exists on PSADServicePrincipal type from AAD graph in Az # module versions < 7.0.0
        $spPassword = $servicePrincipal.Secret
        $appId = $servicePrincipal.ApplicationId
    }
    else {
        if ((Get-Module Az.Resources).Version -eq "5.1.0") {
            Write-Verbose "Creating password and credential for service principal via MS Graph API"
            Write-Warning "Please update Az.Resources to >= 5.2.0 by running 'Update-Module Az'"
            # Microsoft graph objects (Az.Resources version == 5.1.0) do not provision a secret on creation so it must be added separately.
            # Submitting a password credential object without specifying a password will result in one being generated on the server side.
            $password = New-Object -TypeName "Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.MicrosoftGraphPasswordCredential"
            $password.DisplayName = "Password for $displayName"
            $credential = Retry { New-AzADSpCredential -PasswordCredentials $password -ServicePrincipalObject $servicePrincipal -ErrorAction 'Stop' }
            $spPassword = ConvertTo-SecureString $credential.SecretText -AsPlainText -Force
            $appId = $servicePrincipal.AppId
        }
        else {
            Write-Verbose "Creating service principal credential via MS Graph API"
            # In 5.2.0 the password credential issue was fixed (see https://github.com/Azure/azure-powershell/pull/16690) but the
            # parameter set was changed making the above call fail due to a missing ServicePrincipalId parameter.
            $credential = Retry { $servicePrincipal | New-AzADSpCredential -ErrorAction 'Stop' }
            $spPassword = ConvertTo-SecureString $credential.SecretText -AsPlainText -Force
            $appId = $servicePrincipal.AppId
        }
    }

    return @{
        AppId         = $appId
        ApplicationId = $appId
        # This is the ObjectId/OID but most return objects use .Id so keep it consistent to prevent confusion
        Id            = $servicePrincipal.Id
        DisplayName   = $servicePrincipal.DisplayName
        Secret        = $spPassword
    }
}

function LoadCloudConfig([string] $env) {
    $configPath = "$PSScriptRoot/clouds/$env.json"
    if (!(Test-Path $configPath)) {
        Write-Warning "Could not find cloud configuration for environment '$env'"
        return @{}
    }

    $config = Get-Content $configPath | ConvertFrom-Json -AsHashtable
    return $config
}

function MergeHashes([hashtable] $source, [psvariable] $dest) {
    foreach ($key in $source.Keys) {
        if ($dest.Value.Contains($key) -and $dest.Value[$key] -ne $source[$key]) {
            Write-Warning ("Overwriting '$($dest.Name).$($key)' with value '$($dest.Value[$key])' " +
                "to new value '$($source[$key])'")
        }
        $dest.Value[$key] = $source[$key]
    }
}

function IsBicepInstalled() {
    try {
        bicep --version | Out-Null
        return $LASTEXITCODE -eq 0
    }
    catch {
        return $false
    }
}

function IsAzCliBicepInstalled() {
    try {
        az bicep version | Out-Null
        return $LASTEXITCODE -eq 0
    }
    catch {
        return $false
    }
}

function BuildBicepFile([System.IO.FileSystemInfo] $file) {
    $useBicepCli = IsBicepInstalled

    if (!$useBicepCli -and !(IsAzCliBicepInstalled)) {
        Write-Error "A bicep file was found at '$($file.FullName)' but the Azure Bicep CLI is not installed. See https://aka.ms/bicep-install"
        throw
    }

    $tmp = $env:TEMP ? $env:TEMP : [System.IO.Path]::GetTempPath()
    $templateFilePath = Join-Path $tmp "$ResourceType-resources.$(New-Guid).compiled.json"

    # Az can deploy bicep files natively, but by compiling here it becomes easier to parse the
    # outputted json for mismatched parameter declarations.
    if ($useBicepCli) {
        bicep build $file.FullName --outfile $templateFilePath
    } else {
        az bicep build --file $file.FullName --outfile $templateFilePath
    }

    if ($LASTEXITCODE) {
        Write-Error "Failure building bicep file '$($file.FullName)'"
        throw
    }

    return $templateFilePath
}

function LintBicepFile([string] $path) {
    $useBicepCli = IsBicepInstalled

    if (!$useBicepCli -and !(IsAzCliBicepInstalled)) {
        Write-Error "A bicep file was found at '$path' but the Azure Bicep CLI is not installed. See https://aka.ms/bicep-install"
        throw
    }

    # Work around lack of config file override: https://github.com/Azure/bicep/issues/5013
    $output = bicep lint $path 2>&1

    if ($useBicepCli) {
        $output = bicep lint $path 2>&1
    } else {
        $output = az bicep lint --file $path 2>&1
    }

    if ($LASTEXITCODE) {
        Write-Error "Failed linting bicep file '$path'"
        throw
    }

    $clean = $true
    foreach ($line in $output) {
        $line = $line.ToString()

        # See https://learn.microsoft.com/azure/azure-resource-manager/bicep/bicep-config-linter for lints.
        if ($line.Contains('outputs-should-not-contain-secrets')) {
            $clean = $false
        }
        Write-Warning $line
    }

    $clean
}

function BuildDeploymentOutputs([string]$serviceName, [object]$azContext, [object]$deployment, [hashtable]$environmentVariables) {
    $serviceDirectoryPrefix = BuildServiceDirectoryPrefix $serviceName
    # Add default values
    $deploymentOutputs = [Ordered]@{
        "${serviceDirectoryPrefix}SUBSCRIPTION_ID"        = $azContext.Subscription.Id;
        "${serviceDirectoryPrefix}RESOURCE_GROUP"         = $resourceGroup.ResourceGroupName;
        "${serviceDirectoryPrefix}LOCATION"               = $resourceGroup.Location;
        "${serviceDirectoryPrefix}ENVIRONMENT"            = $azContext.Environment.Name;
        "${serviceDirectoryPrefix}AZURE_AUTHORITY_HOST"   = $azContext.Environment.ActiveDirectoryAuthority;
        "${serviceDirectoryPrefix}RESOURCE_MANAGER_URL"   = $azContext.Environment.ResourceManagerUrl;
        "${serviceDirectoryPrefix}SERVICE_MANAGEMENT_URL" = $azContext.Environment.ServiceManagementUrl;
        "AZURE_SERVICE_DIRECTORY"                         = $serviceName.ToUpperInvariant();
    }

    if ($ServicePrincipalAuth) {
        $deploymentOutputs["${serviceDirectoryPrefix}CLIENT_ID"] = $TestApplicationId;
        $deploymentOutputs["${serviceDirectoryPrefix}CLIENT_SECRET"] = $TestApplicationSecret;
        $deploymentOutputs["${serviceDirectoryPrefix}TENANT_ID"] = $azContext.Tenant.Id;
    }

    MergeHashes $environmentVariables $(Get-Variable deploymentOutputs)

    foreach ($key in $deployment.Outputs.Keys) {
        $variable = $deployment.Outputs[$key]

        # Work around bug that makes the first few characters of environment variables be lowercase.
        $key = $key.ToUpperInvariant()

        if ($variable.Type -eq 'String' -or $variable.Type -eq 'SecureString') {
            $deploymentOutputs[$key] = $variable.Value
        }
    }

    # Force capitalization of all keys to avoid Azure Pipelines confusion with
    # variable auto-capitalization and OS env var capitalization differences
    $capitalized = @{}
    foreach ($item in $deploymentOutputs.GetEnumerator()) {
        $capitalized[$item.Name.ToUpperInvariant()] = $item.Value
    }

    return $capitalized
}

function SetDeploymentOutputs(
    [string]$serviceName,
    [object]$azContext,
    [object]$deployment,
    [object]$templateFile,
    [hashtable]$environmentVariables = @{}
) {
    $deploymentEnvironmentVariables = $environmentVariables.Clone()
    $deploymentOutputs = BuildDeploymentOutputs $serviceName $azContext $deployment $deploymentEnvironmentVariables

    if ($OutFile) {
        if ($IsWindows -and $Language -eq 'dotnet') {
            $outputFile = "$($templateFile.originalFilePath).env"

            $environmentText = $deploymentOutputs | ConvertTo-Json;
            $bytes = [System.Text.Encoding]::UTF8.GetBytes($environmentText)
            $protectedBytes = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)

            Set-Content $outputFile -Value $protectedBytes -AsByteStream -Force

            Write-Host "Test environment settings`n$environmentText`nstored into encrypted $outputFile"
        }
        elseif ($templateFile.originalFilePath -and $templateFile.originalFilePath.EndsWith(".bicep")) {
            $bicepTemplateFile = $templateFile.originalFilePath

            # Make sure the file would not write secrets to .env file.
            if (!(LintBicepFile $bicepTemplateFile)) {
                Write-Error "$bicepTemplateFile may write secrets. No file written."
            }
            $outputFile = $bicepTemplateFile | Split-Path | Join-Path -ChildPath '.env'

            # Make sure the file would be ignored.
            git check-ignore -- "$outputFile" > $null
            if ($?) {
                $environmentText = foreach ($kv in $deploymentOutputs.GetEnumerator()) {
                    "$($kv.Key)=`"$($kv.Value)`""
                }

                Set-Content $outputFile -Value $environmentText -Force
                Write-Host "Test environment settings`n$environmentText`nstored in $outputFile"
            }
            else {
                Write-Error "$outputFile is not ignored by .gitignore. No file written."
            }
        }
    }
    else {
        if (!$CI) {
            # Write an extra new line to isolate the environment variables for easy reading.
            Log "Persist the following environment variables based on your detected shell ($shell):`n"
        }

        # Write overwrite warnings first, since local execution prints a runnable command to export variables
        foreach ($key in $deploymentOutputs.Keys) {
            if ([Environment]::GetEnvironmentVariable($key)) {
                Write-Warning "Deployment outputs will overwrite pre-existing environment variable '$key'"
            }
        }

        # Marking values as secret by allowed keys below is not sufficient, as there may be outputs set in the ARM/bicep
        # file that re-mark those values as secret (since all user-provided deployment outputs are treated as secret by default).
        # This variable supports a second check on not marking previously allowed keys/values as secret.
        $notSecretValues = @()
        foreach ($key in $deploymentOutputs.Keys) {
            $value = $deploymentOutputs[$key]
            $deploymentEnvironmentVariables[$key] = $value

            if ($CI) {
                if (ShouldMarkValueAsSecret $serviceName $key $value $notSecretValues) {
                    # Treat all ARM template output variables as secrets since "SecureString" variables do not set values.
                    # In order to mask secrets but set environment variables for any given ARM template, we set variables twice as shown below.
                    LogVsoCommand "##vso[task.setvariable variable=_$key;issecret=true;]$value"
                    Write-Host "Setting variable as secret '$key'"
                }
                else {
                    Write-Host "Setting variable '$key': $value"
                    $notSecretValues += $value
                }
                LogVsoCommand "##vso[task.setvariable variable=$key;]$value"
            }
            else {
                Write-Host ($shellExportFormat -f $key, $value)
            }
        }

        if ($key) {
            # Isolate the environment variables for easy reading.
            Write-Host "`n"
            $key = $null
        }
    }

    return $deploymentEnvironmentVariables, $deploymentOutputs
}