1#!/usr/bin/env pwsh
2
3# Copyright (c) Microsoft Corporation. All rights reserved.
4# Licensed under the MIT License.
5
6#Requires -Version 6.0
7#Requires -PSEdition Core
8#Requires -Modules @{ModuleName='Az.Accounts'; ModuleVersion='1.6.4'}
9#Requires -Modules @{ModuleName='Az.Resources'; ModuleVersion='1.8.0'}
10
11[CmdletBinding(DefaultParameterSetName = 'Default', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
12param (
13 # Limit $BaseName to enough characters to be under limit plus prefixes, and https://docs.microsoft.com/azure/architecture/best-practices/resource-naming
14 [Parameter()]
15 [ValidatePattern('^[-a-zA-Z0-9\.\(\)_]{0,80}(?<=[a-zA-Z0-9\(\)])$')]
16 [string] $BaseName,
17
18 [ValidatePattern('^[-\w\._\(\)]+$')]
19 [string] $ResourceGroupName,
20
21 [Parameter(Mandatory = $true, Position = 0)]
22 [string] $ServiceDirectory,
23
24 [Parameter()]
25 [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
26 [string] $TestApplicationId,
27
28 [Parameter()]
29 [string] $TestApplicationSecret,
30
31 [Parameter()]
32 [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
33 [string] $TestApplicationOid,
34
35 [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)]
36 [ValidateNotNullOrEmpty()]
37 [string] $TenantId,
38
39 # Azure SDK Developer Playground subscription is assumed if not set
40 [Parameter()]
41 [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
42 [string] $SubscriptionId,
43
44 [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)]
45 [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
46 [string] $ProvisionerApplicationId,
47
48 [Parameter(ParameterSetName = 'Provisioner', Mandatory = $false)]
49 [ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
50 [string] $ProvisionerApplicationOid,
51
52 [Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)]
53 [string] $ProvisionerApplicationSecret,
54
55 [Parameter()]
56 [ValidateRange(1, 7*24)]
57 [int] $DeleteAfterHours = 120,
58
59 [Parameter()]
60 [string] $Location = '',
61
62 [Parameter()]
63 [ValidateSet('AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud', 'Dogfood')]
64 [string] $Environment = 'AzureCloud',
65
66 [Parameter()]
67 [hashtable] $ArmTemplateParameters,
68
69 [Parameter()]
70 [hashtable] $AdditionalParameters,
71
72 [Parameter()]
73 [ValidateNotNull()]
74 [hashtable] $EnvironmentVariables = @{},
75
76 [Parameter()]
77 [switch] $CI = ($null -ne $env:SYSTEM_TEAMPROJECTID),
78
79 [Parameter()]
80 [switch] $Force,
81
82 [Parameter()]
83 [switch] $OutFile,
84
85 [Parameter()]
86 [switch] $SuppressVsoCommands = ($null -eq $env:SYSTEM_TEAMPROJECTID),
87
88 # Captures any arguments not declared here (no parameter errors)
89 # This enables backwards compatibility with old script versions in
90 # hotfix branches if and when the dynamic subscription configuration
91 # secrets get updated to add new parameters.
92 [Parameter(ValueFromRemainingArguments = $true)]
93 $NewTestResourcesRemainingArguments
94)
95
96. $PSScriptRoot/SubConfig-Helpers.ps1
97
98# By default stop for any error.
99if (!$PSBoundParameters.ContainsKey('ErrorAction')) {
100 $ErrorActionPreference = 'Stop'
101}
102
103function Log($Message)
104{
105 Write-Host ('{0} - {1}' -f [DateTime]::Now.ToLongTimeString(), $Message)
106}
107
108# vso commands are specially formatted log lines that are parsed by Azure Pipelines
109# to perform additional actions, most commonly marking values as secrets.
110# https://docs.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands
111function LogVsoCommand([string]$message)
112{
113 if (!$CI -or $SuppressVsoCommands) {
114 return
115 }
116 Write-Host $message
117}
118
119function Retry([scriptblock] $Action, [int] $Attempts = 5)
120{
121 $attempt = 0
122 $sleep = 5
123
124 while ($attempt -lt $Attempts) {
125 try {
126 $attempt++
127 return $Action.Invoke()
128 } catch {
129 if ($attempt -lt $Attempts) {
130 $sleep *= 2
131
132 Write-Warning "Attempt $attempt failed: $_. Trying again in $sleep seconds..."
133 Start-Sleep -Seconds $sleep
134 } else {
135 Write-Error -ErrorRecord $_
136 }
137 }
138 }
139}
140
141# NewServicePrincipalWrapper creates an object from an AAD graph or Microsoft Graph service principal object type.
142# This is necessary to work around breaking changes introduced in Az version 7.0.0:
143# https://azure.microsoft.com/en-us/updates/update-your-apps-to-use-microsoft-graph-before-30-june-2022/
144function NewServicePrincipalWrapper([string]$subscription, [string]$resourceGroup, [string]$displayName)
145{
146 if ((Get-Module Az.Resources).Version -eq "5.3.0") {
147 # https://github.com/Azure/azure-powershell/issues/17040
148 # New-AzAdServicePrincipal calls will fail with:
149 # "You cannot call a method on a null-valued expression."
150 Write-Warning "Az.Resources version 5.3.0 is not supported. Please update to >= 5.3.1"
151 Write-Warning "Update-Module Az.Resources -RequiredVersion 5.3.1"
152 exit 1
153 }
154 $servicePrincipal = Retry {
155 New-AzADServicePrincipal -Role "Owner" -Scope "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName" -DisplayName $displayName
156 }
157 $spPassword = ""
158 $appId = ""
159 if (Get-Member -Name "Secret" -InputObject $servicePrincipal -MemberType property) {
160 Write-Verbose "Using legacy PSADServicePrincipal object type from AAD graph API"
161 # Secret property exists on PSADServicePrincipal type from AAD graph in Az # module versions < 7.0.0
162 $spPassword = $servicePrincipal.Secret
163 $appId = $servicePrincipal.ApplicationId
164 } else {
165 if ((Get-Module Az.Resources).Version -eq "5.1.0") {
166 Write-Verbose "Creating password and credential for service principal via MS Graph API"
167 Write-Warning "Please update Az.Resources to >= 5.2.0 by running 'Update-Module Az'"
168 # Microsoft graph objects (Az.Resources version == 5.1.0) do not provision a secret on creation so it must be added separately.
169 # Submitting a password credential object without specifying a password will result in one being generated on the server side.
170 $password = New-Object -TypeName "Microsoft.Azure.PowerShell.Cmdlets.Resources.MSGraph.Models.ApiV10.MicrosoftGraphPasswordCredential"
171 $password.DisplayName = "Password for $displayName"
172 $credential = Retry { New-AzADSpCredential -PasswordCredentials $password -ServicePrincipalObject $servicePrincipal }
173 $spPassword = ConvertTo-SecureString $credential.SecretText -AsPlainText -Force
174 $appId = $servicePrincipal.AppId
175 } else {
176 Write-Verbose "Creating service principal credential via MS Graph API"
177 # In 5.2.0 the password credential issue was fixed (see https://github.com/Azure/azure-powershell/pull/16690) but the
178 # parameter set was changed making the above call fail due to a missing ServicePrincipalId parameter.
179 $credential = Retry { $servicePrincipal | New-AzADSpCredential }
180 $spPassword = ConvertTo-SecureString $credential.SecretText -AsPlainText -Force
181 $appId = $servicePrincipal.AppId
182 }
183 }
184
185 return @{
186 AppId = $appId
187 ApplicationId = $appId
188 # This is the ObjectId/OID but most return objects use .Id so keep it consistent to prevent confusion
189 Id = $servicePrincipal.Id
190 DisplayName = $servicePrincipal.DisplayName
191 Secret = $spPassword
192 }
193}
194
195function LoadCloudConfig([string] $env)
196{
197 $configPath = "$PSScriptRoot/clouds/$env.json"
198 if (!(Test-Path $configPath)) {
199 Write-Warning "Could not find cloud configuration for environment '$env'"
200 return @{}
201 }
202
203 $config = Get-Content $configPath | ConvertFrom-Json -AsHashtable
204 return $config
205}
206
207function MergeHashes([hashtable] $source, [psvariable] $dest)
208{
209 foreach ($key in $source.Keys) {
210 if ($dest.Value.Contains($key) -and $dest.Value[$key] -ne $source[$key]) {
211 Write-Warning ("Overwriting '$($dest.Name).$($key)' with value '$($dest.Value[$key])' " +
212 "to new value '$($source[$key])'")
213 }
214 $dest.Value[$key] = $source[$key]
215 }
216}
217
218function BuildBicepFile([System.IO.FileSystemInfo] $file)
219{
220 if (!(Get-Command bicep -ErrorAction Ignore)) {
221 Write-Error "A bicep file was found at '$($file.FullName)' but the Azure Bicep CLI is not installed. See https://aka.ms/install-bicep-pwsh"
222 throw
223 }
224
225 $tmp = $env:TEMP ? $env:TEMP : [System.IO.Path]::GetTempPath()
226 $templateFilePath = Join-Path $tmp "test-resources.$(New-Guid).compiled.json"
227
228 # Az can deploy bicep files natively, but by compiling here it becomes easier to parse the
229 # outputted json for mismatched parameter declarations.
230 bicep build $file.FullName --outfile $templateFilePath
231 if ($LASTEXITCODE) {
232 Write-Error "Failure building bicep file '$($file.FullName)'"
233 throw
234 }
235
236 return $templateFilePath
237}
238
239function BuildDeploymentOutputs([string]$serviceName, [object]$azContext, [object]$deployment) {
240 $serviceDirectoryPrefix = BuildServiceDirectoryPrefix $serviceName
241 # Add default values
242 $deploymentOutputs = [Ordered]@{
243 "${serviceDirectoryPrefix}CLIENT_ID" = $TestApplicationId;
244 "${serviceDirectoryPrefix}CLIENT_SECRET" = $TestApplicationSecret;
245 "${serviceDirectoryPrefix}TENANT_ID" = $azContext.Tenant.Id;
246 "${serviceDirectoryPrefix}SUBSCRIPTION_ID" = $azContext.Subscription.Id;
247 "${serviceDirectoryPrefix}RESOURCE_GROUP" = $resourceGroup.ResourceGroupName;
248 "${serviceDirectoryPrefix}LOCATION" = $resourceGroup.Location;
249 "${serviceDirectoryPrefix}ENVIRONMENT" = $azContext.Environment.Name;
250 "${serviceDirectoryPrefix}AZURE_AUTHORITY_HOST" = $azContext.Environment.ActiveDirectoryAuthority;
251 "${serviceDirectoryPrefix}RESOURCE_MANAGER_URL" = $azContext.Environment.ResourceManagerUrl;
252 "${serviceDirectoryPrefix}SERVICE_MANAGEMENT_URL" = $azContext.Environment.ServiceManagementUrl;
253 "AZURE_SERVICE_DIRECTORY" = $serviceName.ToUpperInvariant();
254 }
255
256 MergeHashes $EnvironmentVariables $(Get-Variable deploymentOutputs)
257
258 foreach ($key in $deployment.Outputs.Keys) {
259 $variable = $deployment.Outputs[$key]
260
261 # Work around bug that makes the first few characters of environment variables be lowercase.
262 $key = $key.ToUpperInvariant()
263
264 if ($variable.Type -eq 'String' -or $variable.Type -eq 'SecureString') {
265 $deploymentOutputs[$key] = $variable.Value
266 }
267 }
268
269 return $deploymentOutputs
270}
271
272function SetDeploymentOutputs([string]$serviceName, [object]$azContext, [object]$deployment, [object]$templateFile) {
273 $deploymentOutputs = BuildDeploymentOutputs $serviceName $azContext $deployment
274
275 if ($OutFile) {
276 if (!$IsWindows) {
277 Write-Host 'File option is supported only on Windows'
278 }
279
280 $outputFile = "$($templateFile.originalFilePath).env"
281
282 $environmentText = $deploymentOutputs | ConvertTo-Json;
283 $bytes = [System.Text.Encoding]::UTF8.GetBytes($environmentText)
284 $protectedBytes = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
285
286 Set-Content $outputFile -Value $protectedBytes -AsByteStream -Force
287
288 Write-Host "Test environment settings`n $environmentText`nstored into encrypted $outputFile"
289 } else {
290 if (!$CI) {
291 # Write an extra new line to isolate the environment variables for easy reading.
292 Log "Persist the following environment variables based on your detected shell ($shell):`n"
293 }
294
295 # Marking values as secret by allowed keys below is not sufficient, as there may be outputs set in the ARM/bicep
296 # file that re-mark those values as secret (since all user-provided deployment outputs are treated as secret by default).
297 # This variable supports a second check on not marking previously allowed keys/values as secret.
298 $notSecretValues = @()
299 foreach ($key in $deploymentOutputs.Keys) {
300 $value = $deploymentOutputs[$key]
301 $EnvironmentVariables[$key] = $value
302
303 if ($CI) {
304 if (ShouldMarkValueAsSecret $serviceName $key $value $notSecretValues) {
305 # Treat all ARM template output variables as secrets since "SecureString" variables do not set values.
306 # In order to mask secrets but set environment variables for any given ARM template, we set variables twice as shown below.
307 LogVsoCommand "##vso[task.setvariable variable=_$key;issecret=true;]$value"
308 Write-Host "Setting variable as secret '$key'"
309 } else {
310 Write-Host "Setting variable '$key': $value"
311 $notSecretValues += $value
312 }
313 LogVsoCommand "##vso[task.setvariable variable=$key;]$value"
314 } else {
315 Write-Host ($shellExportFormat -f $key, $value)
316 }
317 }
318
319 if ($key) {
320 # Isolate the environment variables for easy reading.
321 Write-Host "`n"
322 $key = $null
323 }
324 }
325
326 return $deploymentOutputs
327}
328
329# Support actions to invoke on exit.
330$exitActions = @({
331 if ($exitActions.Count -gt 1) {
332 Write-Verbose 'Running registered exit actions'
333 }
334})
335
336New-Variable -Name 'initialContext' -Value (Get-AzContext) -Option Constant
337if ($initialContext) {
338 $exitActions += {
339 Write-Verbose "Restoring initial context: $($initialContext.Account)"
340 $null = $initialContext | Select-AzContext
341 }
342}
343
344# try..finally will also trap Ctrl+C.
345try {
346
347 # Enumerate test resources to deploy. Fail if none found.
348 $repositoryRoot = "$PSScriptRoot/../../.." | Resolve-Path
349 $root = [System.IO.Path]::Combine($repositoryRoot, "sdk", $ServiceDirectory) | Resolve-Path
350 $templateFiles = @()
351
352 'test-resources.json', 'test-resources.bicep' | ForEach-Object {
353 Write-Verbose "Checking for '$_' files under '$root'"
354 Get-ChildItem -Path $root -Filter "$_" -Recurse | ForEach-Object {
355 Write-Verbose "Found template '$($_.FullName)'"
356 if ($_.Extension -eq '.bicep') {
357 $templateFile = @{originalFilePath = $_.FullName; jsonFilePath = (BuildBicepFile $_)}
358 $templateFiles += $templateFile
359 } else {
360 $templateFile = @{originalFilePath = $_.FullName; jsonFilePath = $_.FullName}
361 $templateFiles += $templateFile
362 }
363 }
364 }
365
366 if (!$templateFiles) {
367 Write-Warning -Message "No template files found under '$root'"
368 exit
369 }
370
371 $UserName = GetUserName
372
373 if (!$BaseName) {
374 if ($CI) {
375 $BaseName = 't' + (New-Guid).ToString('n').Substring(0, 16)
376 Log "Generated base name '$BaseName' for CI build"
377 } else {
378 $BaseName = GetBaseName $UserName (GetServiceLeafDirectoryName $ServiceDirectory)
379 Log "BaseName was not set. Using default base name '$BaseName'"
380 }
381 }
382
383 # Make sure pre- and post-scripts are passed formerly required arguments.
384 $PSBoundParameters['BaseName'] = $BaseName
385
386 # Try detecting repos that support OutFile and defaulting to it
387 if (!$CI -and !$PSBoundParameters.ContainsKey('OutFile') -and $IsWindows) {
388 # TODO: find a better way to detect the language
389 if (Test-Path "$repositoryRoot/eng/service.proj") {
390 $OutFile = $true
391 Log "Detected .NET repository. Defaulting OutFile to true. Test environment settings would be stored into the file so you don't need to set environment variables manually."
392 }
393 }
394
395 # If no location is specified use safe default locations for the given
396 # environment. If no matching environment is found $Location remains an empty
397 # string.
398 if (!$Location) {
399 $Location = @{
400 'AzureCloud' = 'westus2';
401 'AzureUSGovernment' = 'usgovvirginia';
402 'AzureChinaCloud' = 'chinaeast2';
403 'Dogfood' = 'westus'
404 }[$Environment]
405
406 Write-Verbose "Location was not set. Using default location for environment: '$Location'"
407 }
408
409 if (!$CI -and $PSCmdlet.ParameterSetName -ne "Provisioner") {
410 # Make sure the user is logged in to create a service principal.
411 $context = Get-AzContext;
412 if (!$context) {
413 Log 'User not logged in. Logging in now...'
414 $context = (Connect-AzAccount).Context
415 }
416
417 $currentSubcriptionId = $context.Subscription.Id
418
419 # If no subscription was specified, try to select the Azure SDK Developer Playground subscription.
420 # Ignore errors to leave the automatically selected subscription.
421 if ($SubscriptionId) {
422 if ($currentSubcriptionId -ne $SubscriptionId) {
423 Log "Selecting subscription '$SubscriptionId'"
424 $null = Select-AzSubscription -Subscription $SubscriptionId
425
426 $exitActions += {
427 Log "Selecting previous subscription '$currentSubcriptionId'"
428 $null = Select-AzSubscription -Subscription $currentSubcriptionId
429 }
430
431 # Update the context.
432 $context = Get-AzContext
433 }
434 } else {
435 if ($currentSubcriptionId -ne 'faa080af-c1d8-40ad-9cce-e1a450ca5b57') {
436 Log "Attempting to select subscription 'Azure SDK Developer Playground (faa080af-c1d8-40ad-9cce-e1a450ca5b57)'"
437 $null = Select-AzSubscription -Subscription 'faa080af-c1d8-40ad-9cce-e1a450ca5b57' -ErrorAction Ignore
438
439 # Update the context.
440 $context = Get-AzContext
441 }
442
443 $SubscriptionId = $context.Subscription.Id
444 $PSBoundParameters['SubscriptionId'] = $SubscriptionId
445 }
446
447 # Use cache of well-known team subs without having to be authenticated.
448 $wellKnownSubscriptions = @{
449 'faa080af-c1d8-40ad-9cce-e1a450ca5b57' = 'Azure SDK Developer Playground'
450 'a18897a6-7e44-457d-9260-f2854c0aca42' = 'Azure SDK Engineering System'
451 '2cd617ea-1866-46b1-90e3-fffb087ebf9b' = 'Azure SDK Test Resources'
452 }
453
454 # Print which subscription is currently selected.
455 $subscriptionName = $context.Subscription.Id
456 if ($wellKnownSubscriptions.ContainsKey($subscriptionName)) {
457 $subscriptionName = '{0} ({1})' -f $wellKnownSubscriptions[$subscriptionName], $subscriptionName
458 }
459
460 Log "Using subscription '$subscriptionName'"
461
462 # Make sure the TenantId is also updated from the current context.
463 # PSBoundParameters is not updated to avoid confusing parameter sets.
464 if (!$TenantId) {
465 $TenantId = $context.Subscription.TenantId
466 }
467 }
468
469 # If a provisioner service principal was provided, log into it to perform the pre- and post-scripts and deployments.
470 if ($ProvisionerApplicationId) {
471 $null = Disable-AzContextAutosave -Scope Process
472
473 Log "Logging into service principal '$ProvisionerApplicationId'."
474 $provisionerSecret = ConvertTo-SecureString -String $ProvisionerApplicationSecret -AsPlainText -Force
475 $provisionerCredential = [System.Management.Automation.PSCredential]::new($ProvisionerApplicationId, $provisionerSecret)
476
477 # Use the given subscription ID if provided.
478 $subscriptionArgs = if ($SubscriptionId) {
479 @{Subscription = $SubscriptionId}
480 } else {
481 @{}
482 }
483
484 $provisionerAccount = Retry {
485 Connect-AzAccount -Force:$Force -Tenant $TenantId -Credential $provisionerCredential -ServicePrincipal -Environment $Environment @subscriptionArgs
486 }
487
488 $exitActions += {
489 Write-Verbose "Logging out of service principal '$($provisionerAccount.Context.Account)'"
490
491 # Only attempt to disconnect if the -WhatIf flag was not set. Otherwise, this call is not necessary and will fail.
492 if ($PSCmdlet.ShouldProcess($ProvisionerApplicationId)) {
493 $null = Disconnect-AzAccount -AzureContext $provisionerAccount.Context
494 }
495 }
496 }
497
498 # Determine the Azure context that the script is running in.
499 $context = Get-AzContext;
500
501 # Make sure the provisioner OID is set so we can pass it through to the deployment.
502 if (!$ProvisionerApplicationId -and !$ProvisionerApplicationOid) {
503 if ($context.Account.Type -eq 'User') {
504 $user = Get-AzADUser -UserPrincipalName $context.Account.Id
505 $ProvisionerApplicationOid = $user.Id
506 } elseif ($context.Account.Type -eq 'ServicePrincipal') {
507 $sp = Get-AzADServicePrincipal -ApplicationId $context.Account.Id
508 $ProvisionerApplicationOid = $sp.Id
509 } else {
510 Write-Warning "Getting the OID for provisioner type '$($context.Account.Type)' is not supported and will not be passed to deployments (seldom required)."
511 }
512 } elseif (!$ProvisionerApplicationOid) {
513 $sp = Get-AzADServicePrincipal -ApplicationId $ProvisionerApplicationId
514 $ProvisionerApplicationOid = $sp.Id
515 }
516
517 $serviceName = GetServiceLeafDirectoryName $ServiceDirectory
518
519 $ResourceGroupName = if ($ResourceGroupName) {
520 $ResourceGroupName
521 } elseif ($CI) {
522 # Format the resource group name based on resource group naming recommendations and limitations.
523 "rg-{0}-$BaseName" -f ($serviceName -replace '[\.\\\/:]', '-').ToLowerInvariant().Substring(0, [Math]::Min($serviceName.Length, 90 - $BaseName.Length - 4)).Trim('-')
524 } else {
525 "rg-$BaseName"
526 }
527
528 $tags = @{
529 Owners = $UserName
530 ServiceDirectory = $ServiceDirectory
531 }
532
533 # Tag the resource group to be deleted after a certain number of hours.
534 Write-Warning "Any clean-up scripts running against subscription '$SubscriptionId' may delete resource group '$ResourceGroupName' after $DeleteAfterHours hours."
535 $deleteAfter = [DateTime]::UtcNow.AddHours($DeleteAfterHours).ToString('o')
536 $tags['DeleteAfter'] = $deleteAfter
537
538 if ($CI) {
539 # Add tags for the current CI job.
540 $tags += @{
541 BuildId = "${env:BUILD_BUILDID}"
542 BuildJob = "${env:AGENT_JOBNAME}"
543 BuildNumber = "${env:BUILD_BUILDNUMBER}"
544 BuildReason = "${env:BUILD_REASON}"
545 }
546
547 # Set an environment variable marking that resources have been deployed
548 # This variable can be consumed as a yaml condition in later stages of the pipeline
549 # to determine whether resources should be removed.
550 Write-Host "Setting variable 'CI_HAS_DEPLOYED_RESOURCES': 'true'"
551 LogVsoCommand "##vso[task.setvariable variable=CI_HAS_DEPLOYED_RESOURCES;]true"
552 $EnvironmentVariables['CI_HAS_DEPLOYED_RESOURCES'] = $true
553 }
554
555 Log "Creating resource group '$ResourceGroupName' in location '$Location'"
556 $resourceGroup = Retry {
557 New-AzResourceGroup -Name "$ResourceGroupName" -Location $Location -Tag $tags -Force:$Force
558 }
559
560 if ($resourceGroup.ProvisioningState -eq 'Succeeded') {
561 # New-AzResourceGroup would've written an error and stopped the pipeline by default anyway.
562 Write-Verbose "Successfully created resource group '$($resourceGroup.ResourceGroupName)'"
563 }
564 elseif (!$resourceGroup) {
565 if (!$PSCmdlet.ShouldProcess($resourceGroupName)) {
566 # If the -WhatIf flag was passed, there will be no resource group created. Fake it.
567 $resourceGroup = [PSCustomObject]@{
568 ResourceGroupName = $resourceGroupName
569 Location = $Location
570 }
571 } else {
572 Write-Error "Resource group '$ResourceGroupName' already exists." -Category ResourceExists -RecommendedAction "Delete resource group '$ResourceGroupName', or overwrite it when redeploying."
573 }
574 }
575
576 # If no test application ID was specified during an interactive session, create a new service principal.
577 if (!$CI -and !$TestApplicationId) {
578 # Cache the created service principal in this session for frequent reuse.
579 $servicePrincipal = if ($AzureTestPrincipal -and (Get-AzADServicePrincipal -ApplicationId $AzureTestPrincipal.AppId) -and $AzureTestSubscription -eq $SubscriptionId) {
580 Log "TestApplicationId was not specified; loading cached service principal '$($AzureTestPrincipal.AppId)'"
581 $AzureTestPrincipal
582 } else {
583 Log "TestApplicationId was not specified; creating a new service principal in subscription '$SubscriptionId'"
584 $suffix = (New-Guid).ToString('n').Substring(0, 4)
585
586 # Service principals in the Microsoft AAD tenant must end with an @microsoft.com domain; those in other tenants
587 # are not permitted to do so. Format the non-MS name so there's an assocation with the Azure SDK.
588 if ($TenantId -eq '72f988bf-86f1-41af-91ab-2d7cd011db47') {
589 $displayName = "test-resources-$($baseName)$suffix.microsoft.com"
590 } else {
591 $displayName = "$($baseName)$suffix.test-resources.azure.sdk"
592 }
593
594 $servicePrincipalWrapper = NewServicePrincipalWrapper -subscription $SubscriptionId -resourceGroup $ResourceGroupName -displayName $DisplayName
595
596 $global:AzureTestPrincipal = $servicePrincipalWrapper
597 $global:AzureTestSubscription = $SubscriptionId
598
599 Log "Created service principal. AppId: '$($AzureTestPrincipal.AppId)' ObjectId: '$($AzureTestPrincipal.Id)'"
600 $servicePrincipalWrapper
601 $resourceGroupRoleAssigned = $true
602 }
603
604 $TestApplicationId = $servicePrincipal.AppId
605 $TestApplicationOid = $servicePrincipal.Id
606 $TestApplicationSecret = (ConvertFrom-SecureString $servicePrincipal.Secret -AsPlainText)
607 }
608
609 # Get test application OID from ID if not already provided. This may fail if the
610 # provisioner is a service principal without permissions to query AAD. This is a
611 # critical failure, but we should prompt with possible remediation.
612 if ($TestApplicationId -and !$TestApplicationOid) {
613 Log "Attempting to query the Object ID for the test service principal"
614
615 try {
616 $testServicePrincipal = Retry {
617 Get-AzADServicePrincipal -ApplicationId $TestApplicationId
618 }
619 }
620 catch {
621 Write-Warning "The Object ID of the test application was unable to be queried. You may want to consider passing it explicitly with the 'TestApplicationOid` parameter."
622 throw $_.Exception
623 }
624
625 if ($testServicePrincipal -and $testServicePrincipal.Id) {
626 $script:TestApplicationOid = $testServicePrincipal.Id
627 }
628 }
629
630 # Make sure pre- and post-scripts are passed formerly required arguments.
631 $PSBoundParameters['TestApplicationId'] = $TestApplicationId
632 $PSBoundParameters['TestApplicationOid'] = $TestApplicationOid
633 $PSBoundParameters['TestApplicationSecret'] = $TestApplicationSecret
634
635 # If the role hasn't been explicitly assigned to the resource group and a cached service principal is in use,
636 # query to see if the grant is needed.
637 if (!$resourceGroupRoleAssigned -and $AzureTestPrincipal) {
638 $roleAssignment = Get-AzRoleAssignment -ObjectId $AzureTestPrincipal.Id -RoleDefinitionName 'Owner' -ResourceGroupName "$ResourceGroupName" -ErrorAction SilentlyContinue
639 $resourceGroupRoleAssigned = ($roleAssignment.RoleDefinitionName -eq 'Owner')
640 }
641
642 # If needed, grant the test service principal ownership over the resource group. This may fail if the provisioner
643 # is a service principal without permissions to grant RBAC roles to other service principals. That should not be
644 # considered a critical failure, as the test application may have subscription-level permissions and not require
645 # the explicit grant.
646 if (!$resourceGroupRoleAssigned) {
647 Log "Attempting to assigning the 'Owner' role for '$ResourceGroupName' to the Test Application '$TestApplicationId'"
648 $principalOwnerAssignment = New-AzRoleAssignment -RoleDefinitionName "Owner" -ApplicationId "$TestApplicationId" -ResourceGroupName "$ResourceGroupName" -ErrorAction SilentlyContinue
649
650 if ($principalOwnerAssignment.RoleDefinitionName -eq 'Owner') {
651 Write-Verbose "Successfully assigned ownership of '$ResourceGroupName' to the Test Application '$TestApplicationId'"
652 } else {
653 Write-Warning "The 'Owner' role for '$ResourceGroupName' could not be assigned. You may need to manually grant 'Owner' for the resource group to the Test Application '$TestApplicationId' if it does not have subscription-level permissions."
654 }
655 }
656
657 # Populate the template parameters and merge any additional specified.
658 $templateParameters = @{
659 baseName = $BaseName
660 testApplicationId = $TestApplicationId
661 testApplicationOid = "$TestApplicationOid"
662 }
663 if ($ProvisionerApplicationOid) {
664 $templateParameters["provisionerApplicationOid"] = "$ProvisionerApplicationOid"
665 }
666
667 if ($TenantId) {
668 $templateParameters.Add('tenantId', $TenantId)
669 }
670 if ($TestApplicationSecret) {
671 $templateParameters.Add('testApplicationSecret', $TestApplicationSecret)
672 }
673
674 $defaultCloudParameters = LoadCloudConfig $Environment
675 MergeHashes $defaultCloudParameters $(Get-Variable templateParameters)
676 MergeHashes $ArmTemplateParameters $(Get-Variable templateParameters)
677 MergeHashes $AdditionalParameters $(Get-Variable templateParameters)
678
679 # Include environment-specific parameters only if not already provided as part of the "ArmTemplateParameters"
680 if (($context.Environment.StorageEndpointSuffix) -and (-not ($templateParameters.ContainsKey('storageEndpointSuffix')))) {
681 $templateParameters.Add('storageEndpointSuffix', $context.Environment.StorageEndpointSuffix)
682 }
683
684 # Try to detect the shell based on the parent process name (e.g. launch via shebang).
685 $shell, $shellExportFormat = if (($parentProcessName = (Get-Process -Id $PID).Parent.ProcessName) -and $parentProcessName -eq 'cmd') {
686 'cmd', 'set {0}={1}'
687 } elseif (@('bash', 'csh', 'tcsh', 'zsh') -contains $parentProcessName) {
688 'shell', 'export {0}={1}'
689 } else {
690 'PowerShell', '${{env:{0}}} = ''{1}'''
691 }
692
693 # Deploy the templates
694 foreach ($templateFile in $templateFiles) {
695 # Deployment fails if we pass in more parameters than are defined.
696 Write-Verbose "Removing unnecessary parameters from template '$($templateFile.jsonFilePath)'"
697 $templateJson = Get-Content -LiteralPath $templateFile.jsonFilePath | ConvertFrom-Json
698 $templateParameterNames = $templateJson.parameters.PSObject.Properties.Name
699
700 $templateFileParameters = $templateParameters.Clone()
701 foreach ($key in $templateParameters.Keys) {
702 if ($templateParameterNames -notcontains $key) {
703 Write-Verbose "Removing unnecessary parameter '$key'"
704 $templateFileParameters.Remove($key)
705 }
706 }
707
708 $preDeploymentScript = $templateFile.originalFilePath | Split-Path | Join-Path -ChildPath 'test-resources-pre.ps1'
709 if (Test-Path $preDeploymentScript) {
710 Log "Invoking pre-deployment script '$preDeploymentScript'"
711 &$preDeploymentScript -ResourceGroupName $ResourceGroupName @PSBoundParameters
712 }
713
714 $msg = if ($templateFile.jsonFilePath -ne $templateFile.originalFilePath) {
715 "Deployment template $($templateFile.jsonFilePath) from $($templateFile.originalFilePath) to resource group $($resourceGroup.ResourceGroupName)"
716 } else {
717 "Deployment template $($templateFile.jsonFilePath) to resource group $($resourceGroup.ResourceGroupName)"
718 }
719 Log $msg
720
721 $deployment = Retry {
722 $lastDebugPreference = $DebugPreference
723 try {
724 if ($CI) {
725 $DebugPreference = 'Continue'
726 }
727 New-AzResourceGroupDeployment -Name $BaseName -ResourceGroupName $resourceGroup.ResourceGroupName -TemplateFile $templateFile.jsonFilePath -TemplateParameterObject $templateFileParameters -Force:$Force
728 } catch {
729 Write-Output @'
730#####################################################
731# For help debugging live test provisioning issues, #
732# see http://aka.ms/azsdk/engsys/live-test-help, #
733#####################################################
734'@
735 throw
736 } finally {
737 $DebugPreference = $lastDebugPreference
738 }
739 }
740
741 if ($deployment.ProvisioningState -eq 'Succeeded') {
742 # New-AzResourceGroupDeployment would've written an error and stopped the pipeline by default anyway.
743 Write-Verbose "Successfully deployed template '$($templateFile.jsonFilePath)' to resource group '$($resourceGroup.ResourceGroupName)'"
744 }
745
746 $deploymentOutputs = SetDeploymentOutputs $serviceName $context $deployment $templateFile
747
748 $postDeploymentScript = $templateFile.originalFilePath | Split-Path | Join-Path -ChildPath 'test-resources-post.ps1'
749 if (Test-Path $postDeploymentScript) {
750 Log "Invoking post-deployment script '$postDeploymentScript'"
751 &$postDeploymentScript -ResourceGroupName $ResourceGroupName -DeploymentOutputs $deploymentOutputs @PSBoundParameters
752 }
753
754 if ($templateFile.jsonFilePath.EndsWith('.compiled.json')) {
755 Write-Verbose "Removing compiled bicep file $($templateFile.jsonFilePath)"
756 Remove-Item $templateFile.jsonFilePath
757 }
758 }
759
760} finally {
761 $exitActions.Invoke()
762}
763
764# Suppress output locally
765if ($CI) {
766 return $EnvironmentVariables
767}
768
769<#
770.SYNOPSIS
771Deploys live test resources defined for a service directory to Azure.
772
773.DESCRIPTION
774Deploys live test resouces specified in test-resources.json or test-resources.bicep
775files to a new resource group.
776
777This script searches the directory specified in $ServiceDirectory recursively
778for files named test-resources.json or test-resources.bicep. All found test-resources.json
779and test-resources.bicep files will be deployed to the test resource group.
780
781If no test-resources.json or test-resources.bicep files are located the script
782exits without making changes to the Azure environment.
783
784A service principal may optionally be passed to $TestApplicationId and $TestApplicationSecret.
785Test resources will grant this service principal access to the created resources.
786If no service principal is specified, a new one will be created and assigned the
787'Owner' role for the resource group associated with the test resources.
788
789This script runs in the context of credentials already specified in Connect-AzAccount
790or those specified in $ProvisionerApplicationId and $ProvisionerApplicationSecret.
791
792.PARAMETER BaseName
793A name to use in the resource group and passed to the ARM template as 'baseName'.
794Limit $BaseName to enough characters to be under limit plus prefixes specified in
795the ARM template. See also https://docs.microsoft.com/azure/architecture/best-practices/resource-naming
796
797Note: The value specified for this parameter will be overriden and generated
798by New-TestResources.ps1 if $CI is specified.
799
800.PARAMETER ResourceGroupName
801Set this value to deploy directly to a Resource Group that has already been
802created or to create a new resource group with this name.
803
804If not specified, the $BaseName will be used to generate name for the resource
805group that will be created.
806
807.PARAMETER ServiceDirectory
808A directory under 'sdk' in the repository root - optionally with subdirectories
809specified - in which to discover ARM templates named 'test-resources.json' and
810Bicep templates named 'test-resources.bicep'. This can also be an absolute path
811or specify parent directories.
812
813.PARAMETER TestApplicationId
814Optional Azure Active Directory Application ID to authenticate the test runner
815against deployed resources. Passed to the ARM template as 'testApplicationId'.
816
817If not specified, a new AAD Application will be created and assigned the 'Owner'
818role for the resource group associated with the test resources. No permissions
819will be granted to the subscription or other resources.
820
821For those specifying a Provisioner Application principal as 'ProvisionerApplicationId',
822it will need the permission 'Application.ReadWrite.OwnedBy' for the Microsoft Graph API
823in order to create the Test Application principal.
824
825This application is used by the test runner to execute tests against the
826live test resources.
827
828.PARAMETER TestApplicationSecret
829Optional service principal secret (password) to authenticate the test runner
830against deployed resources. Passed to the ARM template as
831'testApplicationSecret'.
832
833This application is used by the test runner to execute tests against the
834live test resources.
835
836.PARAMETER TestApplicationOid
837Service Principal Object ID of the AAD Test Application. This is used to assign
838permissions to the AAD application so it can access tested features on the live
839test resources (e.g. Role Assignments on resources). It is passed as to the ARM
840template as 'testApplicationOid'
841
842If not specified, an attempt will be made to query it from the Azure Active Directory
843tenant. For those specifying a service principal as 'ProvisionerApplicationId',
844it will need the permission 'Application.Read.All' for the Microsoft Graph API
845in order to query AAD.
846
847For more information on the relationship between AAD Applications and Service
848Principals see: https://docs.microsoft.com/azure/active-directory/develop/app-objects-and-service-principals
849
850.PARAMETER TenantId
851The tenant ID of a service principal when a provisioner is specified. The same
852Tenant ID is used for Test Application and Provisioner Application.
853
854This value is passed to the ARM template as 'tenantId'.
855
856.PARAMETER SubscriptionId
857Optional subscription ID to use for new resources when logging in as a
858provisioner. You can also use Set-AzContext if not provisioning.
859
860If you do not specify a SubscriptionId and are not logged in, one will be
861automatically selected for you by the Connect-AzAccount cmdlet.
862
863Once you are logged in (or were previously), the selected SubscriptionId
864will be used for subsequent operations that are specific to a subscription.
865
866.PARAMETER ProvisionerApplicationId
867Optional Application ID of the Azure Active Directory service principal to use for
868provisioning the test resources. If not, specified New-TestResources.ps1 uses the
869context of the caller to provision.
870
871If specified, the Provisioner Application principal would benefit from the following
872permissions to the Microsoft Graph API:
873
874 - 'Application.Read.All' in order to query AAD to obtain the 'TestApplicaitonOid'
875
876 - 'Application.ReadWrite.OwnedBy' in order to create the Test Application principal
877 or grant an existing principal ownership of the resource group associated with
878 the test resources.
879
880If the provisioner does not have these permissions, it can still be used with
881New-TestResources.ps1 by specifying an existing Test Application principal, including
882its Object ID, and managing permissions to the resource group manually.
883
884This value is not passed to the ARM template.
885
886.PARAMETER ProvisionerApplicationSecret
887A service principal secret (password) used to provision test resources when a
888provisioner is specified.
889
890This value is not passed to the ARM template.
891
892.PARAMETER DeleteAfterHours
893Positive integer number of hours from the current time to set the
894'DeleteAfter' tag on the created resource group. The computed value is a
895timestamp of the form "2020-03-04T09:07:04.3083910Z".
896
897An optional cleanup process can delete resource groups whose "DeleteAfter"
898timestamp is less than the current time.
899
900This is used for CI automation.
901
902.PARAMETER Location
903Optional location where resources should be created. If left empty, the default
904is based on the cloud to which the template is being deployed:
905
906* AzureCloud -> 'westus2'
907* AzureUSGovernment -> 'usgovvirginia'
908* AzureChinaCloud -> 'chinaeast2'
909* Dogfood -> 'westus'
910
911.PARAMETER Environment
912Optional name of the cloud environment. The default is the Azure Public Cloud
913('AzureCloud')
914
915.PARAMETER AdditionalParameters
916Optional key-value pairs of parameters to pass to the ARM template(s) and pre-post scripts.
917
918.PARAMETER ArmTemplateParameters
919Optional key-value pairs of parameters to pass to the ARM template(s).
920
921.PARAMETER EnvironmentVariables
922Optional key-value pairs of parameters to set as environment variables to the shell.
923
924.PARAMETER CI
925Indicates the script is run as part of a Continuous Integration / Continuous
926Deployment (CI/CD) build (only Azure Pipelines is currently supported).
927
928.PARAMETER Force
929Force creation of resources instead of being prompted.
930
931.PARAMETER OutFile
932Save test environment settings into a .env file next to test resources template.
933The contents of the file are protected via the .NET Data Protection API (DPAPI).
934This is supported only on Windows. The environment file is scoped to the current
935service directory.
936
937The environment file will be named for the test resources template that it was
938generated for. For ARM templates, it will be test-resources.json.env. For
939Bicep templates, test-resources.bicep.env.
940
941.PARAMETER SuppressVsoCommands
942By default, the -CI parameter will print out secrets to logs with Azure Pipelines log
943commands that cause them to be redacted. For CI environments that don't support this (like
944stress test clusters), this flag can be set to $false to avoid printing out these secrets to the logs.
945
946.EXAMPLE
947Connect-AzAccount -Subscription 'REPLACE_WITH_SUBSCRIPTION_ID'
948New-TestResources.ps1 keyvault
949
950Run this in a desktop environment to create a new AAD application and Service Principal
951for running live tests against the test resources created. The principal will have ownership
952rights to the resource group and the resources that it contains, but no other resources in
953the subscription.
954
955Requires PowerShell 7 to use ConvertFrom-SecureString -AsPlainText or convert
956the SecureString to plaintext by another means.
957
958.EXAMPLE
959Connect-AzAccount -Subscription 'REPLACE_WITH_SUBSCRIPTION_ID'
960New-TestResources.ps1 `
961 -BaseName 'azsdk' `
962 -ServiceDirectory 'keyvault' `
963 -SubscriptionId 'REPLACE_WITH_SUBSCRIPTION_ID' `
964 -ResourceGroupName 'REPLACE_WITH_NAME_FOR_RESOURCE_GROUP' `
965 -Location 'eastus'
966
967Run this in a desktop environment to specify the name and location of the resource
968group that test resources are being deployed to. This will also create a new AAD
969application and Service Principal for running live tests against the rest resources
970created. The principal will have ownership rights to the resource group and the
971resources that it contains, but no other resources in the subscription.
972
973Requires PowerShell 7 to use ConvertFrom-SecureString -AsPlainText or convert
974the SecureString to plaintext by another means.
975
976.EXAMPLE
977Connect-AzAccount -Subscription 'REPLACE_WITH_SUBSCRIPTION_ID'
978New-TestResources.ps1 `
979 -BaseName 'azsdk' `
980 -ServiceDirectory 'keyvault' `
981 -SubscriptionId 'REPLACE_WITH_SUBSCRIPTION_ID' `
982 -ResourceGroupName 'REPLACE_WITH_NAME_FOR_RESOURCE_GROUP' `
983 -Location 'eastus' `
984 -TestApplicationId 'REPLACE_WITH_TEST_APPLICATION_ID' `
985 -TestApplicationSecret 'REPLACE_WITH_TEST_APPLICATION_SECRET'
986
987Run this in a desktop environment to specify the name and location of the resource
988group that test resources are being deployed to. This will grant ownership rights
989to the 'TestApplicationId' for the resource group and the resources that it contains,
990without altering its existing permissions.
991
992.EXAMPLE
993New-TestResources.ps1 `
994 -BaseName 'azsdk' `
995 -ServiceDirectory 'keyvault' `
996 -SubscriptionId 'REPLACE_WITH_SUBSCRIPTION_ID' `
997 -ResourceGroupName 'REPLACE_WITH_NAME_FOR_RESOURCE_GROUP' `
998 -Location 'eastus' `
999 -ProvisionerApplicationId 'REPLACE_WITH_PROVISIONER_APPLICATION_ID' `
1000 -ProvisionerApplicationSecret 'REPLACE_WITH_PROVISIONER_APPLICATION_ID' `
1001 -TestApplicationId 'REPLACE_WITH_TEST_APPLICATION_ID' `
1002 -TestApplicationOid 'REPLACE_WITH_TEST_APPLICATION_OBJECT_ID' `
1003 -TestApplicationSecret 'REPLACE_WITH_TEST_APPLICATION_SECRET'
1004
1005Run this in a desktop environment to specify the name and location of the resource
1006group that test resources are being deployed to. The script will be executed in the
1007context of the 'ProvisionerApplicationId' rather than the caller.
1008
1009Depending on the permissions of the Provisioner Application principal, the script may
1010grant ownership rights 'TestApplicationId' for the resource group and the resources
1011that it contains, or may emit a message indicating that it was unable to perform the grant.
1012
1013For the Provisioner Application principal to perform the grant, it will need the
1014permission 'Application.ReadWrite.OwnedBy' for the Microsoft Graph API.
1015
1016Requires PowerShell 7 to use ConvertFrom-SecureString -AsPlainText or convert
1017the SecureString to plaintext by another means.
1018
1019.EXAMPLE
1020New-TestResources.ps1 `
1021 -ServiceDirectory '$(ServiceDirectory)' `
1022 -TenantId '$(TenantId)' `
1023 -ProvisionerApplicationId '$(ProvisionerId)' `
1024 -ProvisionerApplicationSecret '$(ProvisionerSecret)' `
1025 -TestApplicationId '$(TestAppId)' `
1026 -TestApplicationSecret '$(TestAppSecret)' `
1027 -DeleteAfterHours 24 `
1028 -CI `
1029 -Force `
1030 -Verbose
1031
1032Run this in an Azure DevOps CI (with approrpiate variables configured) before
1033executing live tests. The script will output variables as secrets (to enable
1034log redaction).
1035
1036#>
View as plain text