public/Invoke-AzBootstrap.ps1
function Invoke-AzBootstrap { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( # # These are mandatory, but may be specified by running Az-Bootstrap in "interactive mode" (without parameters). # [string]$TemplateRepoUrl, [string]$TargetRepoName, [string]$Location, # # optional parameters # [string]$TargetDirectory, # if not specified, the repo will be cloned to ./$TargetRepoName [string]$InitialEnvironmentName = "dev", # github repo parameters [string]$GitHubOwner, # if not specified, the current user will be used [ValidateSet('private', 'public', 'internal')] [string]$GitHubVisibility, # azure parameters [string]$ResourceGroupName, [string]$PlanManagedIdentityName, [string]$ApplyManagedIdentityName, # github branch protection [string]$ProtectedBranchName = 'default-branch-protection', [int]$BranchRequiredApprovals = 1, [bool]$BranchDismissStaleReviews = $true, [bool]$BranchRequireCodeOwnerReview = $false, [bool]$BranchRequireLastPushApproval = $false, [bool]$BranchRequireThreadResolution = $true, [string[]]$BranchAllowedMergeMethods = @("squash"), [bool]$BranchEnableCopilotReview = $true, # deployment reviewers [string[]]$ApplyEnvironmentUserReviewers, [string[]]$ApplyEnvironmentTeamReviewers, [bool]$AddOwnerAsReviewer = $true, # optional storage account for terraform state [string]$TerraformStateStorageAccountName = "", # skips the prompt that asks for confirmation before proceeding [switch]$SkipConfirmation ) # Attempt to resolve template URL (handles aliases and GitHub shorthand) $TemplateRepoUrl = Resolve-TemplateRepoUrl -TemplateRepoUrl $TemplateRepoUrl # If location is not provided, try to get it from the config if (-not $Location -or [string]::IsNullOrWhiteSpace($Location)) { $config = Get-AzBootstrapConfig if ($config.ContainsKey('defaultLocation') -and -not [string]::IsNullOrWhiteSpace($config.defaultLocation)) { $Location = $config.defaultLocation Write-Verbose "Using default location '$Location' from config file." } else { $Location = "australiaeast" Write-Verbose "No location specified, using default '$Location'." } } #region:check parameters # Check if we're in interactive mode (not all required parameters have been provided) $isInteractiveMode = [string]::IsNullOrWhiteSpace($TemplateRepoUrl) -or [string]::IsNullOrWhiteSpace($TargetRepoName) -or [string]::IsNullOrWhiteSpace($Location) if ($isInteractiveMode) { Write-Verbose "[az-bootstrap] No required parameters provided, entering interactive mode." # Prepare defaults for interactive mode $defaults = @{ InitialEnvironmentName = $InitialEnvironmentName TemplateRepoUrl = $TemplateRepoUrl TargetRepoName = $TargetRepoName Location = $Location ResourceGroupName = $ResourceGroupName PlanManagedIdentityName = $PlanManagedIdentityName ApplyManagedIdentityName = $ApplyManagedIdentityName TerraformStateStorageAccountName = $TerraformStateStorageAccountName } $interactiveParams = Start-AzBootstrapInteractiveMode -Defaults $defaults # Apply interactive params to our current parameters $TemplateRepoUrl = $interactiveParams.TemplateRepoUrl $TargetRepoName = $interactiveParams.TargetRepoName $Location = $interactiveParams.Location $ResourceGroupName = $interactiveParams.ResourceGroupName $PlanManagedIdentityName = $interactiveParams.PlanManagedIdentityName $ApplyManagedIdentityName = $interactiveParams.ApplyManagedIdentityName $TerraformStateStorageAccountName = $interactiveParams.TerraformStateStorageAccountName } else { # set up the defaults $ResourceGroupName = if (-not [string]::IsNullOrWhiteSpace($ResourceGroupName)) { $ResourceGroupName } else { "rg-$TargetRepoName-$InitialEnvironmentName" } $PlanManagedIdentityName = Get-ManagedIdentityName -BaseName $TargetRepoName -Environment $InitialEnvironmentName -Type 'plan' -Override $PlanManagedIdentityName $ApplyManagedIdentityName = Get-ManagedIdentityName -BaseName $TargetRepoName -Environment $InitialEnvironmentName -Type 'apply' -Override $ApplyManagedIdentityName $initialPlanEnvName = Get-EnvironmentName -EnvironmentName $InitialEnvironmentName -Type 'plan' $initialApplyEnvName = Get-EnvironmentName -EnvironmentName $InitialEnvironmentName -Type 'apply' # interactive mode checks this during user input if (-not [string]::IsNullOrWhiteSpace($TerraformStateStorageAccountName)) { Test-AzStorageAccountName -StorageAccountName $TerraformStateStorageAccountName } } # az boostrap expects an empty target directory if (-not $TargetDirectory -or [string]::IsNullOrWhiteSpace($TargetDirectory)) { $TargetDirectory = Join-Path -Path (Get-Location) -ChildPath $TargetRepoName } if (Test-Path $TargetDirectory) { throw "Target directory '$TargetDirectory' already exists. Please specify a new directory." } #endregion #region: check CLI tools Write-BootstrapLog "Checking GitHub CLI authentication status..." if (-not (Test-GitHubCLI)) { throw "GitHub CLI is not authenticated. Please run 'gh auth login' to authenticate." } Write-Verbose "[az-bootstrap] Retrieving current Azure subscription and tenant details..." $azContext = Get-AzCliContext $currentArmSubscriptionId = $azContext.SubscriptionId $currentArmTenantId = $azContext.TenantId if (-not $currentArmSubscriptionId -or -not $currentArmTenantId) { throw "Failed to retrieve current Azure Subscription ID and Tenant ID. Ensure you are logged in with 'az login' and a default subscription is set." } #endregion # GitHub repo $TemplateRepoUrl = Resolve-TemplateRepoUrl -TemplateRepoUrl $TemplateRepoUrl $ownerArg = if ($GitHubOwner) { "--owner $GitHubOwner" } else { "" } $visibilityArg = switch ($GitHubVisibility) { "private" { "--private" } "internal" { "--internal" } Default { "--public" } } # Determine the actual owner if not provided $actualOwner = if ($GitHubOwner) { $GitHubOwner } else { # Try to get the current user/org from gh CLI $user = gh auth status --show-token 2>$null | Select-String 'Logged in to github.com account (.*) \(' | ForEach-Object { $_.Matches.Groups[1].Value } if ($user) { $user } else { throw "Could not determine GitHub owner. Please specify -Owner." } } # Check if the resource group already exists Write-BootstrapLog "Checking if Azure resource group '$ResourceGroupName' already exists..." if (Test-AzResourceGroupExists -ResourceGroupName $ResourceGroupName) { throw "Azure resource group '$ResourceGroupName' already exists. Please choose a different name." } # Check if the GitHub repository already exists Write-BootstrapLog "Checking if GitHub repo '$actualOwner/$TargetRepoName' already exists..." if (Test-GitHubRepositoryExists -Owner $actualOwner -Repo $TargetRepoName) { throw "GitHub repository '$actualOwner/$TargetRepoName' already exists. Please choose a different name." } if (-not $SkipConfirmation) { Write-Host "`n--- Configuration Summary ---" -ForegroundColor Green Write-Host "Template Repository URL : $TemplateRepoUrl" Write-Host "Target Repository : $actualOwner/$TargetRepoName" Write-Host "Azure Location : $Location" Write-Host "Resource Group Name : $ResourceGroupName" Write-Host "Plan Managed Identity Name : $PlanManagedIdentityName" Write-Host "Apply Managed Identity Name : $ApplyManagedIdentityName" Write-Host "Terraform State Storage Name : $($TerraformStateStorageAccountName -eq '' ? 'Not required' : $TerraformStateStorageAccountName)" Write-Host "----------------------------`n" -ForegroundColor Green $confirm = Read-Host "Proceed with bootstrap? (y/N)" if ($confirm -notin 'y','Y') { Write-Host "Bootstrap operation cancelled." -ForegroundColor Yellow return } } # # end checks - now we start making things # Write-BootstrapLog "Creating new GitHub repo '$TargetRepoName' from template: $TemplateRepoUrl" $cmd = "gh repo create $TargetRepoName --template $TemplateRepoUrl $visibilityArg $ownerArg" Invoke-Expression $cmd if ($LASTEXITCODE -ne 0) { throw "Failed to create new GitHub repository from template." } $repoUrl = "https://github.com/$actualOwner/$TargetRepoName.git" Write-BootstrapLog "Cloning new repo '$actualOwner/$TargetRepoName' to $TargetDirectory" git clone $repoUrl $TargetDirectory if ($LASTEXITCODE -ne 0) { throw "Failed to clone new repository from $repoUrl." } Push-Location $TargetDirectory try { $RepoInfo = Get-GitHubRepositoryInfo -OverrideOwner $actualOwner -OverrideRepo $TargetRepoName if (-not $RepoInfo) { throw "Could not determine GitHub repository information. Ensure you are in a git repository or provide the -Owner parameter." } Write-BootstrapLog "Setting up branch protection for '$($RepoInfo.Owner)/$($RepoInfo.Repo)' on branch '$ProtectedBranchName'..." New-GitHubBranchRuleset -Owner $RepoInfo.Owner ` -Repo $RepoInfo.Repo ` -RulesetName "default-branch-protection" ` -TargetPattern $ProtectedBranchName ` -RequiredApprovals $BranchRequiredApprovals ` -DismissStaleReviews $BranchDismissStaleReviews ` -RequireCodeOwnerReview $BranchRequireCodeOwnerReview ` -RequireLastPushApproval $BranchRequireLastPushApproval ` -RequireThreadResolution $BranchRequireThreadResolution ` -AllowedMergeMethods $BranchAllowedMergeMethods ` -EnableCopilotReview $BranchEnableCopilotReview Write-BootstrapLog "Creating initial environment '$InitialEnvironmentName'..." $addEnvParams = @{ EnvironmentName = $InitialEnvironmentName ResourceGroupName = $ResourceGroupName Location = $Location PlanManagedIdentityName = $PlanManagedIdentityName ApplyManagedIdentityName = $ApplyManagedIdentityName ArmTenantId = $currentArmTenantId ArmSubscriptionId = $currentArmSubscriptionId GitHubOwner = $RepoInfo.Owner GitHubRepo = $RepoInfo.Repo PlanEnvName = $initialPlanEnvName ApplyEnvName = $initialApplyEnvName ApplyEnvironmentUserReviewers = $ApplyEnvironmentUserReviewers ApplyEnvironmentTeamReviewers = $ApplyEnvironmentTeamReviewers AddOwnerAsReviewer = $AddOwnerAsReviewer TerraformStateStorageAccountName = $TerraformStateStorageAccountName } $DeploymentEnv = Add-AzBootstrapEnvironment @addEnvParams # The configuration file is created by Add-AzBootstrapEnvironment if it can determine the repository path } catch { Write-Error "Failed to add initial environment '$InitialEnvironmentName': $_" throw } finally { Pop-Location } Write-BootstrapLog "Repository : 'https://github.com/$($RepoInfo.Owner)/$($RepoInfo.Repo)'." Write-BootstrapLog "...cloned to: '$($TargetDirectory)'." Write-BootstrapLog "Azure Bootstrap complete. 🎉" } Export-ModuleMember -Function Invoke-AzBootstrap |