diff --git a/local_build_and_install.ps1 b/local_build_and_install.ps1 new file mode 100644 index 0000000..b5a654c --- /dev/null +++ b/local_build_and_install.ps1 @@ -0,0 +1,2 @@ +Invoke-Build -File .\src\ALZ.build.ps1 +Import-Module .\src\Artifacts\ALZ.psd1 -Force diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json b/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json new file mode 100644 index 0000000..a45e5a6 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ALZ Accelerator Input Schema", + "description": "Schema providing descriptions and help links for Azure Landing Zone Accelerator bootstrap inputs", + "version": "1.0.0", + "inputs": { + "bootstrap": { + "description": "Common bootstrap configuration inputs shared across all version control systems", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#3---bootstrap-decisions", + "properties": { + "bootstrap_location": { + "description": "The Azure region where bootstrap resources like storage accounts and container instances will be created (e.g., uksouth, eastus, westeurope). See Decision 4 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-4---choose-a-region-for-the-bootstrap-resources", + "type": "string", + "required": true, + "source": "azureRegion" + }, + "root_parent_management_group_id": { + "description": "The ID of the parent management group under which the ALZ management group hierarchy will be created. See Decision 6 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-6---choose-a-parent-management-group", + "type": "string", + "required": true, + "source": "managementGroup" + }, + "subscription_ids": { + "description": "The subscription IDs for the platform landing zone subscriptions. See Decision 7 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-7---choose-the-platform-subscriptions", + "type": "object", + "required": true, + "properties": { + "management": { + "description": "The subscription ID for the Management subscription where logging, monitoring, and automation resources will be deployed", + "type": "string", + "format": "guid", + "required": true, + "source": "subscription" + }, + "identity": { + "description": "The subscription ID for the Identity subscription where identity resources like domain controllers will be deployed", + "type": "string", + "format": "guid", + "required": true, + "source": "subscription" + }, + "connectivity": { + "description": "The subscription ID for the Connectivity subscription where networking resources like hubs, firewalls, and DNS will be deployed", + "type": "string", + "format": "guid", + "required": true, + "source": "subscription" + }, + "security": { + "description": "The subscription ID for the Security subscription where security monitoring and governance resources will be deployed", + "type": "string", + "format": "guid", + "required": true, + "source": "subscription" + } + } + }, + "bootstrap_subscription_id": { + "description": "The subscription ID where bootstrap resources will be created. See Decision 8 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-8---choose-the-bootstrap-subscription", + "type": "string", + "format": "guid", + "required": true, + "source": "subscription" + }, + "service_name": { + "description": "A short name identifier for the service, used in resource naming (e.g., 'alz'). See Decision 9 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-9---choose-the-bootstrap-resource-naming", + "type": "string", + "required": true + }, + "environment_name": { + "description": "The environment name used in resource naming (e.g., 'mgmt', 'prod'). See Decision 9 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-9---choose-the-bootstrap-resource-naming", + "type": "string", + "required": true + }, + "postfix_number": { + "description": "A numeric postfix for resource naming to ensure uniqueness. See Decision 9 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-9---choose-the-bootstrap-resource-naming", + "type": "number", + "required": true + } + } + }, + "github": { + "description": "GitHub-specific configuration inputs for the alz_github bootstrap module. See Decision 11 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-11---choose--validate-your-version-control-system-specific-settings", + "properties": { + "github_personal_access_token": { + "description": "A GitHub Personal Access Token (PAT) with repo and workflow scopes for creating and managing the repository. Can also be supplied via environment variable TF_VAR_github_personal_access_token.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/github/", + "type": "string", + "required": true, + "sensitive": true + }, + "github_runners_personal_access_token": { + "description": "A GitHub Personal Access Token (PAT) for registering self-hosted runners. Can also be supplied via environment variable TF_VAR_github_runners_personal_access_token.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/github/", + "type": "string", + "required": false, + "sensitive": true + }, + "github_organization_name": { + "description": "The name of your GitHub organization or username where the repository will be created", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/github/", + "type": "string", + "required": true + }, + "use_self_hosted_runners": { + "description": "Whether to deploy self-hosted GitHub Actions runners in Azure instead of using GitHub-hosted runners. See Decision 10 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-10---choose-the-bootstrap-networking", + "type": "boolean", + "required": true + }, + "use_private_networking": { + "description": "Whether to use private networking for the bootstrap resources. When enabled, resources will use private endpoints and be isolated from the public internet. See Decision 10 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-10---choose-the-bootstrap-networking", + "type": "boolean", + "required": true + }, + "apply_approvers": { + "description": "List of GitHub usernames or email addresses who can approve Terraform apply operations in the GitHub Actions workflow", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/github/", + "type": "array", + "required": false + } + } + }, + "azure_devops": { + "description": "Azure DevOps-specific configuration inputs for the alz_azuredevops bootstrap module. See Decision 11 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-11---choose--validate-your-version-control-system-specific-settings", + "properties": { + "azure_devops_personal_access_token": { + "description": "An Azure DevOps Personal Access Token (PAT) with full access for creating and managing the project, repositories, and pipelines. Can also be supplied via environment variable TF_VAR_azure_devops_personal_access_token.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/azuredevops/", + "type": "string", + "required": true, + "sensitive": true + }, + "azure_devops_agents_personal_access_token": { + "description": "An Azure DevOps Personal Access Token (PAT) for registering self-hosted agents. Can also be supplied via environment variable TF_VAR_azure_devops_agents_personal_access_token.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/azuredevops/", + "type": "string", + "required": false, + "sensitive": true + }, + "azure_devops_organization_name": { + "description": "The name of your Azure DevOps organization (the part after dev.azure.com/ in your URL)", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/azuredevops/", + "type": "string", + "required": true + }, + "azure_devops_project_name": { + "description": "The name of the Azure DevOps project to create or use for the ALZ deployment", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/azuredevops/", + "type": "string", + "required": true + }, + "use_self_hosted_agents": { + "description": "Whether to deploy self-hosted Azure DevOps agents in Azure instead of using Microsoft-hosted agents. See Decision 10 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-10---choose-the-bootstrap-networking", + "type": "boolean", + "required": true + }, + "use_private_networking": { + "description": "Whether to use private networking for the bootstrap resources. When enabled, resources will use private endpoints and be isolated from the public internet. See Decision 10 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-10---choose-the-bootstrap-networking", + "type": "boolean", + "required": true + }, + "apply_approvers": { + "description": "List of Azure DevOps user email addresses who can approve Terraform apply operations in the Azure Pipelines", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/azuredevops/", + "type": "array", + "required": false + } + } + }, + "local": { + "description": "Local-specific configuration inputs for the alz_local bootstrap module (no CI/CD pipeline). Use this if you are using another version control system. See Decision 2 in the planning phase.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#decision-2---choose-a-version-control-system", + "properties": { + "create_bootstrap_resources_in_azure": { + "description": "Whether to create bootstrap resources (storage account, managed identities) in Azure. Set to false if you plan to set up your own identities and state storage.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/advancedscenarios/", + "type": "boolean", + "required": true + }, + "grant_permissions_to_current_user": { + "description": "Whether to grant permissions for the current Azure CLI user to be able to deploy the Platform Landing Zones. Set to false if you plan to configure a third-party Version Control System.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/advancedscenarios/", + "type": "boolean", + "required": true + }, + "target_directory": { + "description": "The target directory for generated files. Leave empty to use the standard output directory.", + "helpLink": "https://azure.github.io/Azure-Landing-Zones/accelerator/advancedscenarios/", + "type": "string", + "required": false + } + } + } + } +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/ConvertTo-AcceleratorResult.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/ConvertTo-AcceleratorResult.ps1 new file mode 100644 index 0000000..ac9e5e2 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/ConvertTo-AcceleratorResult.ps1 @@ -0,0 +1,44 @@ +function ConvertTo-AcceleratorResult { + <# + .SYNOPSIS + Creates a standardized result hashtable for accelerator configuration functions. + .DESCRIPTION + This function creates a consistent result structure used by accelerator configuration + functions to return their status and configuration data. + .PARAMETER Continue + Boolean indicating whether to continue with deployment. + .PARAMETER InputConfigFilePaths + Array of input configuration file paths. + .PARAMETER StarterAdditionalFiles + Array of additional files/folders for the starter module. + .PARAMETER OutputFolderPath + Path to the output folder. + .OUTPUTS + Returns a hashtable with Continue, InputConfigFilePaths, StarterAdditionalFiles, and OutputFolderPath keys. + .EXAMPLE + return ConvertTo-AcceleratorResult -Continue $false + .EXAMPLE + return ConvertTo-AcceleratorResult -Continue $true -InputConfigFilePaths @("config/inputs.yaml") -OutputFolderPath "~/accelerator/output" + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [bool] $Continue, + + [Parameter(Mandatory = $false)] + [array] $InputConfigFilePaths = @(), + + [Parameter(Mandatory = $false)] + [array] $StarterAdditionalFiles = @(), + + [Parameter(Mandatory = $false)] + [string] $OutputFolderPath = "" + ) + + return @{ + Continue = $Continue + InputConfigFilePaths = $InputConfigFilePaths + StarterAdditionalFiles = $StarterAdditionalFiles + OutputFolderPath = $OutputFolderPath + } +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorConfigPath.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorConfigPath.ps1 new file mode 100644 index 0000000..ed566b9 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorConfigPath.ps1 @@ -0,0 +1,48 @@ +function Get-AcceleratorConfigPath { + <# + .SYNOPSIS + Builds the input configuration file paths and additional files based on IaC type. + .DESCRIPTION + This function generates the list of configuration file paths and additional files + needed for the accelerator based on the IaC type (terraform, bicep, etc.). + .PARAMETER ConfigFolderPath + The path to the config folder containing the configuration files. + .PARAMETER IacType + The Infrastructure as Code type (terraform, bicep, or bicep-classic). + .OUTPUTS + Returns a hashtable with the following keys: + - InputConfigFilePaths: Array of input configuration file paths + - StarterAdditionalFiles: Array of additional files/folders for the starter module + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $ConfigFolderPath, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [string] $IacType + ) + + $inputConfigFilePaths = @("$ConfigFolderPath/inputs.yaml") + $starterAdditionalFiles = @() + + switch ($IacType) { + "terraform" { + $inputConfigFilePaths += "$ConfigFolderPath/platform-landing-zone.tfvars" + $libFolderPath = "$ConfigFolderPath/lib" + if (Test-Path $libFolderPath) { + $starterAdditionalFiles = @($libFolderPath) + } + } + "bicep" { + $inputConfigFilePaths += "$ConfigFolderPath/platform-landing-zone.yaml" + } + # bicep-classic and others just use inputs.yaml + } + + return @{ + InputConfigFilePaths = $inputConfigFilePaths + StarterAdditionalFiles = $starterAdditionalFiles + } +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 new file mode 100644 index 0000000..51e6fb8 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 @@ -0,0 +1,99 @@ +function Get-AcceleratorFolderConfiguration { + <# + .SYNOPSIS + Detects and validates accelerator folder configuration from existing files. + .DESCRIPTION + This function examines an existing accelerator folder to detect the IaC type, + version control system, and validate the configuration files. + .PARAMETER FolderPath + The path to the accelerator folder to analyze. + .OUTPUTS + Returns a hashtable with the following keys: + - IsValid: Boolean indicating if valid configuration was found + - IacType: Detected IaC type (terraform, bicep, or $null) + - VersionControl: Detected version control (github, azure-devops, local, or $null) + - ConfigFolderPath: Path to the config folder + - InputsYamlPath: Path to inputs.yaml + - InputsYaml: Parsed inputs.yaml content (if valid) + - InputsContent: Raw inputs.yaml content (if valid) + - ErrorMessage: Error message if validation failed + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $FolderPath + ) + + $result = @{ + FolderExists = $false + IsValid = $false + IacType = $null + VersionControl = $null + ConfigFolderPath = $null + InputsYamlPath = $null + InputsYaml = $null + InputsContent = $null + ErrorMessage = $null + } + + # Check if folder exists + if (-not (Test-Path -Path $FolderPath)) { + $result.ErrorMessage = "Folder '$FolderPath' does not exist." + return $result + } + + $result.FolderExists = $true + + $configFolderPath = Join-Path $FolderPath "config" + $inputsYamlPath = Join-Path $configFolderPath "inputs.yaml" + + $result.ConfigFolderPath = $configFolderPath + $result.InputsYamlPath = $inputsYamlPath + + # Check if config folder exists + if (-not (Test-Path -Path $configFolderPath)) { + $result.ErrorMessage = "Config folder not found at '$configFolderPath'" + return $result + } + + # Check if inputs.yaml exists + if (-not (Test-Path -Path $inputsYamlPath)) { + $result.ErrorMessage = "Required configuration file not found: inputs.yaml" + return $result + } + + # Try to read and validate inputs.yaml + try { + $inputsContent = Get-Content -Path $inputsYamlPath -Raw + $inputsYaml = $inputsContent | ConvertFrom-Yaml + + $result.InputsContent = $inputsContent + $result.InputsYaml = $inputsYaml + $result.IsValid = $true + } catch { + $result.ErrorMessage = "inputs.yaml is not valid YAML: $($_.Exception.Message)" + return $result + } + + # Detect IaC type from existing files + $tfvarsPath = Join-Path $configFolderPath "platform-landing-zone.tfvars" + $bicepYamlPath = Join-Path $configFolderPath "platform-landing-zone.yaml" + + if (Test-Path -Path $tfvarsPath) { + $result.IacType = "terraform" + } elseif (Test-Path -Path $bicepYamlPath) { + $result.IacType = "bicep" + } + + # Detect version control from bootstrap_module_name in inputs.yaml + if ($inputsYaml.bootstrap_module_name) { + $bootstrapModuleName = $inputsYaml.bootstrap_module_name + switch ($bootstrapModuleName) { + "alz_github" { $result.VersionControl = "github" } + "alz_azuredevops" { $result.VersionControl = "azure-devops" } + "alz_local" { $result.VersionControl = "local" } + } + } + + return $result +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 new file mode 100644 index 0000000..779743e --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 @@ -0,0 +1,60 @@ +function Get-AzureContext { + <# + .SYNOPSIS + Queries Azure for management groups, subscriptions, and regions available to the current user. + .DESCRIPTION + This function uses the Azure CLI to query for management groups, subscriptions, and regions + that the currently logged-in user has access to. The results are returned as a hashtable + containing arrays for use in interactive selection prompts. + Only subscriptions from the current tenant are returned. + .OUTPUTS + Returns a hashtable with the following keys: + - ManagementGroups: Array of objects with id and displayName properties + - Subscriptions: Array of objects with id and name properties + - Regions: Array of objects with name, displayName, and hasAvailabilityZones properties + #> + [CmdletBinding()] + param() + + $azureContext = @{ + ManagementGroups = @() + Subscriptions = @() + Regions = @() + } + + Write-InformationColored "Querying Azure for management groups, subscriptions, and regions..." -ForegroundColor Green -InformationAction Continue + + try { + # Get the current tenant ID + $tenantResult = az account show --query "tenantId" -o tsv 2>$null + $currentTenantId = if ($LASTEXITCODE -eq 0 -and $tenantResult) { $tenantResult.Trim() } else { $null } + + # Get management groups + $mgResult = az account management-group list --query "[].{id:name, displayName:displayName}" -o json 2>$null + if ($LASTEXITCODE -eq 0 -and $mgResult) { + $azureContext.ManagementGroups = $mgResult | ConvertFrom-Json + } + + # Get subscriptions (filtered to current tenant only, sorted by name) + if ($null -ne $currentTenantId) { + $subResult = az account list --query "sort_by([?tenantId=='$currentTenantId'].{id:id, name:name}, &name)" -o json 2>$null + } else { + $subResult = az account list --query "sort_by([].{id:id, name:name}, &name)" -o json 2>$null + } + if ($LASTEXITCODE -eq 0 -and $subResult) { + $azureContext.Subscriptions = $subResult | ConvertFrom-Json + } + + # Get regions (sorted by displayName, include availability zone support) + $regionResult = az account list-locations --query "sort_by([?metadata.regionType=='Physical'].{name:name, displayName:displayName, hasAvailabilityZones:length(availabilityZoneMappings || ``[]``) > ``0``}, &displayName)" -o json 2>$null + if ($LASTEXITCODE -eq 0 -and $regionResult) { + $azureContext.Regions = $regionResult | ConvertFrom-Json + } + + Write-InformationColored " Found $($azureContext.ManagementGroups.Count) management groups, $($azureContext.Subscriptions.Count) subscriptions, and $($azureContext.Regions.Count) regions" -ForegroundColor Gray -InformationAction Continue + } catch { + Write-InformationColored " Warning: Could not query Azure resources. You will need to enter IDs manually." -ForegroundColor Yellow -InformationAction Continue + } + + return $azureContext +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 index 6ef5c9e..a456923 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Invoke-Terraform.ps1 @@ -20,7 +20,10 @@ function Invoke-Terraform { [string] $outputFilePath = "", [Parameter(Mandatory = $false)] - [switch] $silent + [switch] $silent, + + [Parameter(Mandatory = $false)] + [string] $bootstrapSubscriptionId = "" ) # Resolve to absolute path for `terraform -chdir` switch @@ -31,9 +34,19 @@ function Invoke-Terraform { $removeSubscriptionId = $false if ($null -eq $env:ARM_SUBSCRIPTION_ID -or $env:ARM_SUBSCRIPTION_ID -eq "") { Write-Verbose "Setting environment variable ARM_SUBSCRIPTION_ID" - $subscriptionId = $(az account show --query id -o tsv) + + $subscriptionId = "" + if ($bootstrapSubscriptionId -ne "") { + # Use the provided bootstrap subscription ID + Write-Verbose "Using provided bootstrap_subscription_id: $bootstrapSubscriptionId" + $subscriptionId = $bootstrapSubscriptionId + } else { + # Fall back to az cli + $subscriptionId = $(az account show --query id -o tsv) + } + if ($null -eq $subscriptionId -or $subscriptionId -eq "") { - Write-Error "Subscription ID not found. Please ensure you are logged in to Azure and have selected a subscription. Use 'az account show' to check." + Write-Error "Subscription ID not found. Please ensure you are logged in to Azure and have selected a subscription, or provide bootstrap_subscription_id. Use 'az account show' to check." return } $env:ARM_SUBSCRIPTION_ID = $subscriptionId diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 index c324614..fa583cb 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 @@ -292,11 +292,17 @@ function New-Bootstrap { # Running terraform init and apply Write-InformationColored "Thank you for providing those inputs, we are now initializing and applying Terraform to bootstrap your environment..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + # Get bootstrap_subscription_id from inputConfig if available + $bootstrapSubscriptionId = "" + if ($null -ne $inputConfig.bootstrap_subscription_id -and $null -ne $inputConfig.bootstrap_subscription_id.Value) { + $bootstrapSubscriptionId = $inputConfig.bootstrap_subscription_id.Value + } + if ($autoApprove) { - Invoke-Terraform -moduleFolderPath $bootstrapModulePath -autoApprove -destroy:$destroy.IsPresent + Invoke-Terraform -moduleFolderPath $bootstrapModulePath -autoApprove -destroy:$destroy.IsPresent -bootstrapSubscriptionId $bootstrapSubscriptionId } else { Write-InformationColored "Once the plan is complete you will be prompted to confirm the apply." -ForegroundColor Green -NewLineBefore -InformationAction Continue - Invoke-Terraform -moduleFolderPath $bootstrapModulePath -destroy:$destroy.IsPresent + Invoke-Terraform -moduleFolderPath $bootstrapModulePath -destroy:$destroy.IsPresent -bootstrapSubscriptionId $bootstrapSubscriptionId } Write-InformationColored "Bootstrap has completed successfully! Thanks for using our tool. Head over to Phase 3 in the documentation to continue..." -ForegroundColor Green -NewLineBefore -InformationAction Continue diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 new file mode 100644 index 0000000..72d74f5 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 @@ -0,0 +1,695 @@ +function Request-ALZConfigurationValue { + <# + .SYNOPSIS + Parses configuration files and prompts the user for input values interactively. + .DESCRIPTION + This function reads the inputs.yaml file, loads the schema for descriptions and help links, + and prompts the user for values. It prompts for all inputs in inputs.yaml. + .PARAMETER ConfigFolderPath + The path to the folder containing the configuration files. + .PARAMETER IacType + The Infrastructure as Code type (terraform or bicep). + .PARAMETER VersionControl + The version control system (github, azure-devops, or local). + .PARAMETER AzureContext + A hashtable containing Azure context information including ManagementGroups, Subscriptions, and Regions arrays. + .OUTPUTS + Returns $true if configuration was updated, $false otherwise. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string] $ConfigFolderPath, + + [Parameter(Mandatory = $true)] + [string] $IacType, + + [Parameter(Mandatory = $true)] + [string] $VersionControl, + + [Parameter(Mandatory = $false)] + [hashtable] $AzureContext = @{ ManagementGroups = @(); Subscriptions = @(); Regions = @() } + ) + + # Helper function to get a property from schema info safely + function Get-SchemaProperty { + param($SchemaInfo, $PropertyName, $Default = $null) + if ($null -ne $SchemaInfo -and $SchemaInfo.PSObject.Properties.Name -contains $PropertyName) { + return $SchemaInfo.$PropertyName + } + return $Default + } + + # Helper function to validate and prompt for a value with GUID format + function Get-ValidatedGuidInput { + param($PromptText, $CurrentValue, $Indent = " ") + $guidRegex = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + $newValue = Read-Host "$PromptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + return $CurrentValue + } + while ($newValue -notmatch $guidRegex) { + Write-InformationColored "${Indent}Invalid GUID format. Please enter a valid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "$PromptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + return $CurrentValue + } + } + return $newValue + } + + # Helper function to prompt for a single input value + function Read-InputValue { + param( + $Key, + $CurrentValue, + $SchemaInfo, + $Indent = "", + $DefaultDescription = "No description available", + $Subscriptions = @(), + $ManagementGroups = @(), + $Regions = @() + ) + + $description = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "description" -Default $DefaultDescription + $helpLink = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "helpLink" + $isSensitive = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "sensitive" -Default $false + $allowedValues = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "allowedValues" + $format = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "format" + $schemaType = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "type" -Default "string" + $isRequired = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "required" -Default $false + $source = Get-SchemaProperty -SchemaInfo $SchemaInfo -PropertyName "source" + + # For sensitive inputs, check if value is set via environment variable + $envVarValue = $null + if ($isSensitive) { + $envVarName = "TF_VAR_$Key" + $envVarValue = [System.Environment]::GetEnvironmentVariable($envVarName) + if (-not [string]::IsNullOrWhiteSpace($envVarValue)) { + $CurrentValue = $envVarValue + } + } + + # Check if the current value is an array + $isArray = $schemaType -eq "array" -or $CurrentValue -is [System.Collections.IList] + + # Check if the current value is a placeholder (surrounded by angle brackets) + $isPlaceholder = $false + $hasPlaceholderItems = $false + if ($isArray) { + # Check if array contains placeholder items + if ($null -ne $CurrentValue -and $CurrentValue.Count -gt 0) { + foreach ($item in $CurrentValue) { + if ($item -is [string] -and $item -match '^\s*<.*>\s*$') { + $hasPlaceholderItems = $true + break + } + } + } + } elseif ($CurrentValue -is [string] -and $CurrentValue -match '^\s*<.*>\s*$') { + $isPlaceholder = $true + } + + # Determine effective default (don't use placeholders as defaults) + $effectiveDefault = if ($isPlaceholder) { "" } elseif ($isArray -and $hasPlaceholderItems) { @() } else { $CurrentValue } + + # Display prompt information + Write-InformationColored "`n${Indent}[$Key]" -ForegroundColor Yellow -InformationAction Continue + Write-InformationColored "${Indent} $description" -ForegroundColor White -InformationAction Continue + if ($null -ne $helpLink) { + Write-InformationColored "${Indent} Help: $helpLink" -ForegroundColor Gray -InformationAction Continue + } + if ($isRequired) { + Write-InformationColored "${Indent} Required: Yes" -ForegroundColor Magenta -InformationAction Continue + } + if ($null -ne $allowedValues) { + Write-InformationColored "${Indent} Allowed values: $($allowedValues -join ', ')" -ForegroundColor Gray -InformationAction Continue + } + if ($format -eq "guid") { + Write-InformationColored "${Indent} Format: GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)" -ForegroundColor Gray -InformationAction Continue + } + if ($schemaType -eq "number") { + Write-InformationColored "${Indent} Format: Integer number" -ForegroundColor Gray -InformationAction Continue + } + if ($schemaType -eq "boolean") { + Write-InformationColored "${Indent} Format: true or false" -ForegroundColor Gray -InformationAction Continue + } + if ($isArray) { + Write-InformationColored "${Indent} Format: Comma-separated list of values" -ForegroundColor Gray -InformationAction Continue + } + + # Helper to mask sensitive values - show first 3 and last 3 chars if long enough + function Get-MaskedValue { + param($Value) + if ([string]::IsNullOrWhiteSpace($Value)) { + return "(empty)" + } + $valueStr = $Value.ToString() + if ($valueStr.Length -ge 8) { + # Show first 3 and last 3 characters with asterisks in between + return $valueStr.Substring(0, 3) + "***" + $valueStr.Substring($valueStr.Length - 3) + } else { + # Too short to show partial, just mask completely + return "********" + } + } + + # Show current value (mask if sensitive) + if ($isArray) { + $displayCurrentValue = if ($null -eq $CurrentValue -or $CurrentValue.Count -eq 0) { + "(empty)" + } elseif ($hasPlaceholderItems) { + "$($CurrentValue -join ', ') (contains placeholders - requires input)" + } elseif ($isSensitive) { + ($CurrentValue | ForEach-Object { Get-MaskedValue -Value $_ }) -join ", " + } else { + $CurrentValue -join ", " + } + } else { + $displayCurrentValue = if ($isSensitive -and -not [string]::IsNullOrWhiteSpace($CurrentValue)) { + Get-MaskedValue -Value $CurrentValue + } elseif ($isPlaceholder) { + "$CurrentValue (placeholder - requires input)" + } elseif ($CurrentValue -is [bool]) { + # Display booleans in lowercase + if ($CurrentValue) { "true" } else { "false" } + } else { + $CurrentValue + } + } + Write-InformationColored "${Indent} Current value: $displayCurrentValue" -ForegroundColor Gray -InformationAction Continue + + # Build prompt text + if ($isArray) { + # Use effective default (empty if has placeholders) + $effectiveArrayDefault = if ($hasPlaceholderItems) { @() } else { $CurrentValue } + $currentAsString = if ($null -eq $effectiveArrayDefault -or $effectiveArrayDefault.Count -eq 0) { + "" + } elseif ($isSensitive) { + ($effectiveArrayDefault | ForEach-Object { Get-MaskedValue -Value $_ }) -join ", " + } else { + $effectiveArrayDefault -join ", " + } + $promptText = if ([string]::IsNullOrWhiteSpace($currentAsString)) { + "${Indent} Enter values (comma-separated)" + } else { + "${Indent} Enter values (comma-separated, default: $currentAsString)" + } + } else { + $displayDefault = if ($isSensitive -and -not [string]::IsNullOrWhiteSpace($effectiveDefault)) { + Get-MaskedValue -Value $effectiveDefault + } elseif ($effectiveDefault -is [bool]) { + # Display booleans in lowercase + if ($effectiveDefault) { "true" } else { "false" } + } else { + $effectiveDefault + } + $promptText = if ([string]::IsNullOrWhiteSpace($effectiveDefault) -and $effectiveDefault -isnot [bool]) { + "${Indent} Enter value" + } else { + "${Indent} Enter value (default: $displayDefault)" + } + } + + # Get new value based on input type + if ($isSensitive) { + $secureValue = Read-Host "$promptText" -AsSecureString + $newValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + ) + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue + $secureValue = Read-Host "$promptText" -AsSecureString + $newValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + ) + } + } elseif ($isArray) { + $inputValue = Read-Host "$promptText" + # Use effective default (empty array if has placeholders) + $effectiveArrayDefault = if ($hasPlaceholderItems) { @() } else { $CurrentValue } + if ([string]::IsNullOrWhiteSpace($inputValue)) { + $newValue = $effectiveArrayDefault + } else { + # Parse comma-separated values into an array + $newValue = @($inputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + } elseif ($source -eq "subscription" -and $Subscriptions.Count -gt 0) { + # Show subscription selection list + Write-InformationColored "${Indent} Available subscriptions:" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $Subscriptions.Count; $i++) { + $sub = $Subscriptions[$i] + if ($sub.id -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id)) (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($sub.name) ($($sub.id))" -ForegroundColor White -InformationAction Continue + } + } + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + + $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { + $newValue = $effectiveDefault + } elseif ($selection -eq "0") { + $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue $effectiveDefault -Indent "${Indent} " + } else { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { + $newValue = $Subscriptions[$selIndex].id + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please select a subscription." -ForegroundColor Red -InformationAction Continue + $selection = Read-Host "${Indent} Select subscription (1-$($Subscriptions.Count), 0 for manual entry)" + if ($selection -eq "0") { + $newValue = Get-ValidatedGuidInput -PromptText "${Indent} Enter subscription ID" -CurrentValue "" -Indent "${Indent} " + } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Subscriptions.Count) { + $newValue = $Subscriptions[$selIndex].id + } + } + } + } elseif ($source -eq "managementGroup" -and $ManagementGroups.Count -gt 0) { + # Show management group selection list + Write-InformationColored "${Indent} Available management groups:" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $ManagementGroups.Count; $i++) { + $mg = $ManagementGroups[$i] + if ($mg.id -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id)) (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($mg.displayName) ($($mg.id))" -ForegroundColor White -InformationAction Continue + } + } + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + Write-InformationColored "${Indent} Press Enter to leave empty (uses Tenant Root Group)" -ForegroundColor Gray -InformationAction Continue + + $selection = Read-Host "${Indent} Select management group (1-$($ManagementGroups.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { + $newValue = $effectiveDefault + } elseif ($selection -eq "0") { + $newValue = Read-Host "${Indent} Enter management group ID" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + } else { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $ManagementGroups.Count) { + $newValue = $ManagementGroups[$selIndex].id + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } + } + } elseif ($source -eq "azureRegion" -and $Regions.Count -gt 0) { + # Show region selection list + Write-InformationColored "${Indent} Available regions (AZ = Availability Zone support):" -ForegroundColor Cyan -InformationAction Continue + for ($i = 0; $i -lt $Regions.Count; $i++) { + $region = $Regions[$i] + $azIndicator = if ($region.hasAvailabilityZones) { " [AZ]" } else { "" } + if ($region.name -eq $effectiveDefault) { + Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator (current)" -ForegroundColor Green -InformationAction Continue + } else { + Write-InformationColored "${Indent} [$($i + 1)] $($region.displayName) ($($region.name))$azIndicator" -ForegroundColor White -InformationAction Continue + } + } + Write-InformationColored "${Indent} [0] Enter manually" -ForegroundColor Gray -InformationAction Continue + + $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry, or press Enter for default)" + if ([string]::IsNullOrWhiteSpace($selection)) { + $newValue = $effectiveDefault + } elseif ($selection -eq "0") { + $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + } else { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { + $newValue = $Regions[$selIndex].name + } else { + Write-InformationColored "${Indent} Invalid selection, using default" -ForegroundColor Yellow -InformationAction Continue + $newValue = $effectiveDefault + } + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please select a region." -ForegroundColor Red -InformationAction Continue + $selection = Read-Host "${Indent} Select region (1-$($Regions.Count), 0 for manual entry)" + if ($selection -eq "0") { + $newValue = Read-Host "${Indent} Enter region name (e.g., uksouth, eastus)" + } elseif (-not [string]::IsNullOrWhiteSpace($selection)) { + $selIndex = [int]$selection - 1 + if ($selIndex -ge 0 -and $selIndex -lt $Regions.Count) { + $newValue = $Regions[$selIndex].name + } + } + } + } elseif ($format -eq "guid") { + $newValue = Get-ValidatedGuidInput -PromptText $promptText -CurrentValue $effectiveDefault -Indent "${Indent} " + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue + $newValue = Get-ValidatedGuidInput -PromptText $promptText -CurrentValue $effectiveDefault -Indent "${Indent} " + } + } elseif ($schemaType -eq "number") { + $newValue = Read-Host "$promptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + # Validate integer format and require if required + $intResult = 0 + # Check if effective default is valid, if not clear it + if (-not [string]::IsNullOrWhiteSpace($newValue)) { + $valueToCheck = if ($newValue -is [int]) { $newValue.ToString() } else { $newValue } + while (-not [int]::TryParse($valueToCheck, [ref]$intResult)) { + Write-InformationColored "${Indent} Invalid format. Please enter an integer number." -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "${Indent} Enter value" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = "" + break + } + $valueToCheck = $newValue + } + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "${Indent} Enter value" + # Re-validate integer format + if (-not [string]::IsNullOrWhiteSpace($newValue)) { + while (-not [int]::TryParse($newValue, [ref]$intResult)) { + Write-InformationColored "${Indent} Invalid format. Please enter an integer number." -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "${Indent} Enter value" + if ([string]::IsNullOrWhiteSpace($newValue)) { + break + } + } + } + } + # Convert to integer if we have a valid value + if (-not [string]::IsNullOrWhiteSpace($newValue) -and [int]::TryParse($newValue.ToString(), [ref]$intResult)) { + $newValue = $intResult + } + } elseif ($schemaType -eq "boolean") { + $newValue = Read-Host "$promptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + # Validate and convert boolean + if (-not [string]::IsNullOrWhiteSpace($newValue)) { + $validBooleans = @('true', 'false', 'yes', 'no', '1', '0') + $valueStr = $newValue.ToString().ToLower() + while ($validBooleans -notcontains $valueStr) { + Write-InformationColored "${Indent} Invalid format. Please enter true or false." -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "$promptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + break + } + $valueStr = $newValue.ToString().ToLower() + } + # Convert to actual boolean + if (-not [string]::IsNullOrWhiteSpace($newValue)) { + $valueStr = $newValue.ToString().ToLower() + $newValue = $valueStr -in @('true', 'yes', '1') + } + } + } else { + $newValue = Read-Host "$promptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + # Require value if required + while ($isRequired -and [string]::IsNullOrWhiteSpace($newValue)) { + Write-InformationColored "${Indent} This field is required. Please enter a value." -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "$promptText" + } + } + + # Validate against allowed values if specified + if ($null -ne $allowedValues -and -not [string]::IsNullOrWhiteSpace($newValue)) { + while ($allowedValues -notcontains $newValue) { + Write-InformationColored "${Indent} Invalid value. Please choose from: $($allowedValues -join ', ')" -ForegroundColor Red -InformationAction Continue + $newValue = Read-Host "$promptText" + if ([string]::IsNullOrWhiteSpace($newValue)) { + $newValue = $effectiveDefault + } + } + } + + # Return value along with sensitivity info + return @{ + Value = $newValue + IsSensitive = $isSensitive + } + } + + if ($PSCmdlet.ShouldProcess("Configuration files", "prompt for input values")) { + # Load the schema file + $schemaPath = Join-Path $PSScriptRoot "AcceleratorInputSchema.json" + if (-not (Test-Path $schemaPath)) { + Write-Warning "Schema file not found at $schemaPath. Proceeding without descriptions." + $schema = $null + } else { + $schema = Get-Content -Path $schemaPath -Raw | ConvertFrom-Json + } + + # Define the configuration files to process + $inputsYamlPath = Join-Path $ConfigFolderPath "inputs.yaml" + + $configUpdated = $false + + # Process inputs.yaml - prompt for ALL inputs + if (Test-Path $inputsYamlPath) { + Write-InformationColored "`n=== Bootstrap Configuration (inputs.yaml) ===" -ForegroundColor Cyan -InformationAction Continue + Write-InformationColored "For more information, see: https://aka.ms/alz/acc/phase0" -ForegroundColor Gray -InformationAction Continue + + # Read the raw content to preserve comments and ordering + $inputsYamlContent = Get-Content -Path $inputsYamlPath -Raw + $inputsConfig = $inputsYamlContent | ConvertFrom-Yaml -Ordered + $inputsUpdated = $false + + # Track changes to apply to the raw content + $changes = @{} + + # Get the appropriate schema sections based on version control + $bootstrapSchema = $null + $vcsSchema = $null + if ($null -ne $schema) { + $bootstrapSchema = $schema.inputs.bootstrap.properties + if ($VersionControl -eq "github") { + $vcsSchema = $schema.inputs.github.properties + } elseif ($VersionControl -eq "azure-devops") { + $vcsSchema = $schema.inputs.azure_devops.properties + } elseif ($VersionControl -eq "local") { + $vcsSchema = $schema.inputs.local.properties + } + } + + foreach ($key in @($inputsConfig.Keys)) { + $currentValue = $inputsConfig[$key] + + # Handle nested subscription_ids object (always in schema) + if ($key -eq "subscription_ids" -and $currentValue -is [System.Collections.IDictionary]) { + # Only process if subscription_ids is in the schema + if ($null -eq $bootstrapSchema -or -not ($bootstrapSchema.PSObject.Properties.Name -contains "subscription_ids")) { + continue + } + + Write-InformationColored "`n[subscription_ids]" -ForegroundColor Yellow -InformationAction Continue + Write-InformationColored " The subscription IDs for the platform landing zone subscriptions" -ForegroundColor White -InformationAction Continue + Write-InformationColored " Help: https://aka.ms/alz/acc/phase0" -ForegroundColor Gray -InformationAction Continue + + $subscriptionIdsSchema = $bootstrapSchema.subscription_ids.properties + + foreach ($subKey in @($currentValue.Keys)) { + $subCurrentValue = $currentValue[$subKey] + $subSchemaInfo = $null + + if ($null -ne $subscriptionIdsSchema -and $subscriptionIdsSchema.PSObject.Properties.Name -contains $subKey) { + $subSchemaInfo = $subscriptionIdsSchema.$subKey + } else { + # Skip subscription IDs not in schema + continue + } + + $result = Read-InputValue -Key $subKey -CurrentValue $subCurrentValue -SchemaInfo $subSchemaInfo -Indent " " -DefaultDescription "Subscription ID for $subKey" -Subscriptions $AzureContext.Subscriptions -ManagementGroups $AzureContext.ManagementGroups -Regions $AzureContext.Regions + $subNewValue = $result.Value + $subIsSensitive = $result.IsSensitive + + if ($subNewValue -ne $subCurrentValue -or $subIsSensitive) { + $currentValue[$subKey] = $subNewValue + $changes["subscription_ids.$subKey"] = @{ + OldValue = $subCurrentValue + NewValue = $subNewValue + Key = $subKey + IsNested = $true + IsSensitive = $subIsSensitive + } + $inputsUpdated = $true + } + } + continue + } + + # Skip inputs that are not in the schema + $schemaInfo = $null + $isInBootstrapSchema = $null -ne $bootstrapSchema -and $bootstrapSchema.PSObject.Properties.Name -contains $key + $isInVcsSchema = $null -ne $vcsSchema -and $vcsSchema.PSObject.Properties.Name -contains $key + + if (-not $isInBootstrapSchema -and -not $isInVcsSchema) { + # This input is not in the schema, skip it + continue + } + + # Look up schema info from bootstrap or VCS-specific schema + if ($isInBootstrapSchema) { + $schemaInfo = $bootstrapSchema.$key + } elseif ($isInVcsSchema) { + $schemaInfo = $vcsSchema.$key + } + + $result = Read-InputValue -Key $key -CurrentValue $currentValue -SchemaInfo $schemaInfo -Subscriptions $AzureContext.Subscriptions -ManagementGroups $AzureContext.ManagementGroups -Regions $AzureContext.Regions + $newValue = $result.Value + $isSensitive = $result.IsSensitive + + # Update if changed (handle array comparison) or if sensitive (always track sensitive values) + $hasChanged = $false + if ($currentValue -is [System.Collections.IList] -or $newValue -is [System.Collections.IList]) { + # Compare arrays + $currentArray = @($currentValue) + $newArray = @($newValue) + if ($currentArray.Count -ne $newArray.Count) { + $hasChanged = $true + } else { + for ($i = 0; $i -lt $currentArray.Count; $i++) { + if ($currentArray[$i] -ne $newArray[$i]) { + $hasChanged = $true + break + } + } + } + } else { + $hasChanged = $newValue -ne $currentValue + } + + if ($hasChanged -or $isSensitive) { + $inputsConfig[$key] = $newValue + $changes[$key] = @{ + OldValue = $currentValue + NewValue = $newValue + Key = $key + IsNested = $false + IsArray = $newValue -is [System.Collections.IList] + IsBoolean = $newValue -is [bool] + IsNumber = $newValue -is [int] -or $newValue -is [long] -or $newValue -is [double] + IsSensitive = $isSensitive + } + $inputsUpdated = $true + } + } + + # Save updated inputs.yaml preserving comments and ordering + if ($inputsUpdated) { + $updatedContent = $inputsYamlContent + $sensitiveEnvVars = @{} + + foreach ($changeKey in $changes.Keys) { + $change = $changes[$changeKey] + $key = $change.Key + $oldValue = $change.OldValue + $newValue = $change.NewValue + $isArray = if ($change.ContainsKey('IsArray')) { $change.IsArray } else { $false } + $isBoolean = if ($change.ContainsKey('IsBoolean')) { $change.IsBoolean } else { $false } + $isNumber = if ($change.ContainsKey('IsNumber')) { $change.IsNumber } else { $false } + $isSensitive = if ($change.ContainsKey('IsSensitive')) { $change.IsSensitive } else { $false } + + # Handle sensitive values - set as environment variable instead of in file + if ($isSensitive -and -not [string]::IsNullOrWhiteSpace($newValue)) { + $envVarName = "TF_VAR_$key" + [System.Environment]::SetEnvironmentVariable($envVarName, $newValue) + $sensitiveEnvVars[$key] = $envVarName + + # Update the config file to indicate it's set as an env var + $envVarPlaceholder = "Set via environment variable $envVarName" + $escapedOldValue = if ([string]::IsNullOrWhiteSpace($oldValue)) { "" } else { [regex]::Escape($oldValue) } + if ([string]::IsNullOrWhiteSpace($escapedOldValue)) { + $pattern = "(?m)^(\s*${key}:\s*)`"?`"?(\s*)(#.*)?$" + } else { + $pattern = "(?m)^(\s*${key}:\s*)`"?${escapedOldValue}`"?(\s*)(#.*)?$" + } + $replacement = "`${1}`"$envVarPlaceholder`"`${2}`${3}" + $updatedContent = $updatedContent -replace $pattern, $replacement + continue + } + + if ($isArray) { + # Handle array values - convert to YAML inline array format + $yamlArrayValue = "[" + (($newValue | ForEach-Object { "`"$_`"" }) -join ", ") + "]" + + # Match the existing array or empty value - use greedy match within brackets + # Pattern matches: key: [anything] with optional comment + $pattern = "(?m)^(\s*${key}:\s*)\[[^\]]*\](\s*)(#.*)?$" + $replacement = "`${1}$yamlArrayValue`${2}`${3}" + } elseif ($isBoolean) { + # Handle boolean values - no quotes, lowercase true/false + $yamlBoolValue = if ($newValue) { "true" } else { "false" } + # Match any boolean-like value (true/false/True/False/yes/no) case-insensitively + $pattern = "(?mi)^(\s*${key}:\s*)`"?(true|false)`"?(\s*)(#.*)?$" + $replacement = "`${1}$yamlBoolValue`${3}`${4}" + } elseif ($isNumber) { + # Handle numeric values - no quotes + $yamlNumValue = $newValue.ToString() + $escapedOldValue = [regex]::Escape($oldValue.ToString()) + $pattern = "(?m)^(\s*${key}:\s*)`"?${escapedOldValue}`"?(\s*)(#.*)?$" + $replacement = "`${1}$yamlNumValue`${2}`${3}" + } else { + # Handle string values + # Escape special regex characters in the old value + $escapedOldValue = [regex]::Escape($oldValue) + + # Build regex pattern to match the key-value pair + # This handles both quoted and unquoted values + if ([string]::IsNullOrWhiteSpace($oldValue)) { + # Empty value - match key followed by colon and optional whitespace/quotes + $pattern = "(?m)^(\s*${key}:\s*)`"?`"?(\s*)(#.*)?$" + $replacement = "`${1}`"$newValue`"`${2}`${3}" + } else { + # Non-empty value - match the specific value + $pattern = "(?m)^(\s*${key}:\s*)`"?${escapedOldValue}`"?(\s*)(#.*)?$" + $replacement = "`${1}`"$newValue`"`${2}`${3}" + } + } + + $updatedContent = $updatedContent -replace $pattern, $replacement + } + + $updatedContent | Set-Content -Path $inputsYamlPath -Force -NoNewline + Write-InformationColored "`nUpdated inputs.yaml" -ForegroundColor Green -InformationAction Continue + + # Display summary of sensitive environment variables + if ($sensitiveEnvVars.Count -gt 0) { + Write-InformationColored "`nSensitive values have been set as environment variables:" -ForegroundColor Yellow -InformationAction Continue + foreach ($varKey in $sensitiveEnvVars.Keys) { + Write-InformationColored " $varKey -> $($sensitiveEnvVars[$varKey])" -ForegroundColor Gray -InformationAction Continue + } + Write-InformationColored "`nThese environment variables are set for the current process only." -ForegroundColor Gray -InformationAction Continue + Write-InformationColored "The config file contains placeholders indicating the values are set via environment variables." -ForegroundColor Gray -InformationAction Continue + } + + $configUpdated = $true + } + } + + return $configUpdated + } +} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 new file mode 100644 index 0000000..a2bb0f9 --- /dev/null +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 @@ -0,0 +1,268 @@ +function Request-AcceleratorConfigurationInput { + <# + .SYNOPSIS + Prompts the user for accelerator configuration input and creates the folder structure. + .DESCRIPTION + This function interactively prompts the user for the inputs needed to set up the accelerator folder structure, + calls New-AcceleratorFolderStructure to create the folders and configuration files, and returns the paths + needed for Deploy-Accelerator to continue. + .PARAMETER Destroy + When set, only prompts for the target folder path and validates the existing folder structure exists. + .OUTPUTS + Returns a hashtable with the following keys: + - Continue: Boolean indicating whether to continue with deployment + - InputConfigFilePaths: Array of input configuration file paths + - StarterAdditionalFiles: Array of additional files/folders for the starter module + - OutputFolderPath: Path to the output folder + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $false)] + [switch] $Destroy + ) + + if ($PSCmdlet.ShouldProcess("Accelerator folder structure setup", "prompt and create")) { + # Display appropriate header message + if ($Destroy.IsPresent) { + Write-InformationColored "Running in destroy mode. Please provide the path to your existing accelerator folder." -ForegroundColor Yellow -NewLineBefore -InformationAction Continue + } else { + Write-InformationColored "No input configuration files provided. Let's set up the accelerator folder structure first..." -ForegroundColor Green -NewLineBefore -InformationAction Continue + Write-InformationColored "For more information, see: https://aka.ms/alz/acc/phase2" -ForegroundColor Cyan -InformationAction Continue + } + + # Prompt for target folder path (first prompt for both modes) + Write-InformationColored "`nEnter the target folder path for the accelerator files (default: ~/accelerator):" -ForegroundColor Yellow -InformationAction Continue + $targetFolderPathInput = Read-Host "Target folder path" + if ([string]::IsNullOrWhiteSpace($targetFolderPathInput)) { + $targetFolderPathInput = "~/accelerator" + } + + # Normalize the path + $normalizedTargetPath = Get-NormalizedPath -Path $targetFolderPathInput + + # Analyze existing folder configuration + $folderConfig = Get-AcceleratorFolderConfiguration -FolderPath $normalizedTargetPath + $useExistingFolder = $false + $forceFlag = $false + + # If folder exists, ask about overwriting before other prompts + if ($folderConfig.FolderExists) { + # Ask about overwriting the folder + Write-InformationColored "`nTarget folder '$normalizedTargetPath' already exists." -ForegroundColor Yellow -InformationAction Continue + $forceResponse = Read-Host "Do you want to overwrite it? (y/N)" + if ($forceResponse -eq "y" -or $forceResponse -eq "Y") { + $forceFlag = $true + } else { + # User wants to keep existing folder + $useExistingFolder = $true + + # Validate config files exist + if (-not $folderConfig.IsValid) { + if (-not (Test-Path -Path $folderConfig.ConfigFolderPath)) { + Write-InformationColored "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -ForegroundColor Red -InformationAction Continue + } elseif (-not (Test-Path -Path $folderConfig.InputsYamlPath)) { + Write-InformationColored "ERROR: Required configuration file not found: inputs.yaml" -ForegroundColor Red -InformationAction Continue + } + Write-InformationColored "Please overwrite the folder structure by choosing 'y', or run New-AcceleratorFolderStructure manually." -ForegroundColor Yellow -InformationAction Continue + return New-AcceleratorResult -Continue $false + } + } + } + + # Handle destroy mode - validate existing folder and return early + if ($Destroy.IsPresent) { + if (-not $folderConfig.FolderExists) { + Write-InformationColored "ERROR: Target folder '$normalizedTargetPath' does not exist." -ForegroundColor Red -InformationAction Continue + Write-InformationColored "Cannot destroy a deployment that doesn't exist. Please check the path and try again." -ForegroundColor Yellow -InformationAction Continue + return New-AcceleratorResult -Continue $false + } + + if (-not (Test-Path -Path $folderConfig.ConfigFolderPath)) { + Write-InformationColored "ERROR: Config folder not found at '$($folderConfig.ConfigFolderPath)'" -ForegroundColor Red -InformationAction Continue + Write-InformationColored "Cannot destroy a deployment without configuration files." -ForegroundColor Yellow -InformationAction Continue + return New-AcceleratorResult -Continue $false + } + + if (-not (Test-Path -Path $folderConfig.InputsYamlPath)) { + Write-InformationColored "ERROR: Required configuration file not found: inputs.yaml" -ForegroundColor Red -InformationAction Continue + Write-InformationColored "Cannot destroy a deployment without inputs.yaml." -ForegroundColor Yellow -InformationAction Continue + return New-AcceleratorResult -Continue $false + } + + # Build input config file paths based on detected IaC type + $configPaths = Get-AcceleratorConfigPaths -IacType $folderConfig.IacType -ConfigFolderPath $folderConfig.ConfigFolderPath + $resolvedTargetPath = (Resolve-Path -Path $normalizedTargetPath).Path + + Write-InformationColored "Using existing folder: $resolvedTargetPath" -ForegroundColor Green -InformationAction Continue + Write-InformationColored "`nProceeding with destroy..." -ForegroundColor Yellow -InformationAction Continue + + return New-AcceleratorResult -Continue $true ` + -InputConfigFilePaths $configPaths.InputConfigFilePaths ` + -StarterAdditionalFiles $configPaths.StarterAdditionalFiles ` + -OutputFolderPath "$resolvedTargetPath/output" + } + + # Set selected values from detected values (for use existing folder case) + $selectedIacType = $folderConfig.IacType + $selectedVersionControl = $folderConfig.VersionControl + $selectedScenarioNumber = 1 + + # Only prompt for IaC type, version control, and scenario if creating new folder or overwriting + if (-not $useExistingFolder) { + # Prompt for IaC type with detected value as default + $iacTypeOptions = @("terraform", "bicep") + $defaultIacTypeIndex = if ($null -ne $folderConfig.IacType) { + [Math]::Max(0, $iacTypeOptions.IndexOf($folderConfig.IacType)) + } else { 0 } + + $selectedIacType = Read-MenuSelection ` + -Title "`nSelect the Infrastructure as Code (IaC) type:" ` + -Options $iacTypeOptions ` + -DefaultIndex $defaultIacTypeIndex + + # Prompt for version control with detected value as default + $versionControlOptions = @("github", "azure-devops", "local") + $defaultVcsIndex = if ($null -ne $folderConfig.VersionControl) { + [Math]::Max(0, $versionControlOptions.IndexOf($folderConfig.VersionControl)) + } else { 0 } + + $selectedVersionControl = Read-MenuSelection ` + -Title "`nSelect the Version Control System:" ` + -Options $versionControlOptions ` + -DefaultIndex $defaultVcsIndex + + # Prompt for scenario number (Terraform only) + if ($selectedIacType -eq "terraform") { + $scenarioDescriptions = @( + "Full Multi-Region - Hub and Spoke VNet", + "Full Multi-Region - Virtual WAN", + "Full Multi-Region NVA - Hub and Spoke VNet", + "Full Multi-Region NVA - Virtual WAN", + "Management Only", + "Full Single-Region - Hub and Spoke VNet", + "Full Single-Region - Virtual WAN", + "Full Single-Region NVA - Hub and Spoke VNet", + "Full Single-Region NVA - Virtual WAN" + ) + $scenarioNumbers = 1..$scenarioDescriptions.Count + + $selectedScenarioNumber = Read-MenuSelection ` + -Title "`nSelect the Terraform scenario (see https://aka.ms/alz/acc/scenarios):" ` + -Options $scenarioNumbers ` + -OptionDescriptions $scenarioDescriptions ` + -DefaultIndex 0 + } + } + + # Create folder structure if needed + if (-not $folderConfig.FolderExists -or $forceFlag) { + New-AcceleratorFolderStructure ` + -iacType $selectedIacType ` + -versionControl $selectedVersionControl ` + -scenarioNumber $selectedScenarioNumber ` + -targetFolderPath $targetFolderPathInput ` + -force:$forceFlag + + Write-InformationColored "`nFolder structure created at: $normalizedTargetPath" -ForegroundColor Green -InformationAction Continue + } + + # Resolve the path after folder creation or validation + $resolvedTargetPath = (Resolve-Path -Path $normalizedTargetPath).Path + $configFolderPath = if ($useExistingFolder) { + $folderConfig.ConfigFolderPath + } else { + Join-Path $resolvedTargetPath "config" + } + + if ($useExistingFolder) { + Write-InformationColored "`nUsing existing folder structure at: $resolvedTargetPath" -ForegroundColor Green -InformationAction Continue + } + Write-InformationColored "Config folder: $configFolderPath" -ForegroundColor Cyan -InformationAction Continue + + # Offer to configure inputs interactively (default is Yes) + $configureNowResponse = Read-Host "`nWould you like to configure the input values interactively now? (Y/n)" + if ($configureNowResponse -ne "n" -and $configureNowResponse -ne "N") { + $azureContext = Get-AzureContext + + Request-ALZConfigurationValue ` + -ConfigFolderPath $configFolderPath ` + -IacType $selectedIacType ` + -VersionControl $selectedVersionControl ` + -AzureContext $azureContext + } + + # Check for VS Code or VS Code Insiders and offer to open the config folder + $vsCodeCommand = $null + $vsCodeName = $null + + if (Get-Command "code-insiders" -ErrorAction SilentlyContinue) { + $vsCodeCommand = "code-insiders" + $vsCodeName = "VS Code Insiders" + } elseif (Get-Command "code" -ErrorAction SilentlyContinue) { + $vsCodeCommand = "code" + $vsCodeName = "VS Code" + } + + if ($null -ne $vsCodeCommand) { + $openInVsCodeResponse = Read-Host "`nWould you like to open the config folder in $($vsCodeName)? (Y/n)" + if ($openInVsCodeResponse -ne "n" -and $openInVsCodeResponse -ne "N") { + Write-InformationColored "Opening config folder in $vsCodeName..." -ForegroundColor Green -InformationAction Continue + & $vsCodeCommand $configFolderPath + } + } + + Write-InformationColored "`nPlease check and update the configuration files in the config folder before continuing:" -ForegroundColor Yellow -InformationAction Continue + Write-InformationColored " - inputs.yaml: Bootstrap configuration (required)" -ForegroundColor White -InformationAction Continue + + if ($selectedIacType -eq "terraform") { + Write-InformationColored " - platform-landing-zone.tfvars: Platform configuration (required)" -ForegroundColor White -InformationAction Continue + Write-InformationColored " - starter_locations: Enter the regions for you platform landing zone (required)" -ForegroundColor White -InformationAction Continue + Write-InformationColored " - defender_email_security_contact: Enter the email security contact for Microsoft Defender for Cloud (required)" -ForegroundColor White -InformationAction Continue + Write-InformationColored " - lib/: Library customizations (optional)" -ForegroundColor White -InformationAction Continue + } elseif ($selectedIacType -eq "bicep") { + Write-InformationColored " - platform-landing-zone.yaml: Platform configuration (required)" -ForegroundColor White -InformationAction Continue + Write-InformationColored " - starter_locations: Enter the regions for you platform landing zone (required)" -ForegroundColor White -InformationAction Continue + } + + Write-InformationColored "`nFor more details, see: https://azure.github.io/Azure-Landing-Zones/accelerator/configuration-files/" -ForegroundColor Cyan -InformationAction Continue + + # Prompt to continue or exit + $continueResponse = Read-Host "`nHave you checked and updated the configuration files? Enter 'yes' to continue with deployment, or 'no' to exit and configure later" + if ($continueResponse -ne "yes") { + Write-InformationColored "`nTo continue later, run Deploy-Accelerator with the following parameters:" -ForegroundColor Green -InformationAction Continue + + if ($selectedIacType -eq "terraform") { + Write-InformationColored @" +Deploy-Accelerator `` + -inputs "$configFolderPath/inputs.yaml", "$configFolderPath/platform-landing-zone.tfvars" `` + -starterAdditionalFiles "$configFolderPath/lib" `` + -output "$resolvedTargetPath/output" +"@ -ForegroundColor Cyan -InformationAction Continue + } elseif ($selectedIacType -eq "bicep") { + Write-InformationColored @" +Deploy-Accelerator `` + -inputs "$configFolderPath/inputs.yaml", "$configFolderPath/platform-landing-zone.yaml" `` + -output "$resolvedTargetPath/output" +"@ -ForegroundColor Cyan -InformationAction Continue + } else { + Write-InformationColored @" +Deploy-Accelerator `` + -inputs "$configFolderPath/inputs.yaml" `` + -output "$resolvedTargetPath/output" +"@ -ForegroundColor Cyan -InformationAction Continue + } + + return New-AcceleratorResult -Continue $false + } + + # Build the result for continuing with deployment + $configPaths = Get-AcceleratorConfigPaths -IacType $selectedIacType -ConfigFolderPath $configFolderPath + + Write-InformationColored "`nContinuing with deployment..." -ForegroundColor Green -InformationAction Continue + + return New-AcceleratorResult -Continue $true ` + -InputConfigFilePaths $configPaths.InputConfigFilePaths ` + -StarterAdditionalFiles $configPaths.StarterAdditionalFiles ` + -OutputFolderPath "$resolvedTargetPath/output" + } +} diff --git a/src/ALZ/Private/Shared/Get-NormalizedPath.ps1 b/src/ALZ/Private/Shared/Get-NormalizedPath.ps1 new file mode 100644 index 0000000..5cbeacd --- /dev/null +++ b/src/ALZ/Private/Shared/Get-NormalizedPath.ps1 @@ -0,0 +1,25 @@ +function Get-NormalizedPath { + <# + .SYNOPSIS + Normalizes a file path, expanding home directory shortcuts. + .DESCRIPTION + This function normalizes a path by expanding the ~/ shortcut to the user's home directory. + .PARAMETER Path + The path to normalize. + .OUTPUTS + Returns the normalized path string. + .EXAMPLE + $normalizedPath = Get-NormalizedPath -Path "~/accelerator" + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ($Path.StartsWith("~/")) { + return Join-Path $HOME $Path.Replace("~/", "") + } + + return $Path +} diff --git a/src/ALZ/Private/Shared/Read-MenuSelection.ps1 b/src/ALZ/Private/Shared/Read-MenuSelection.ps1 new file mode 100644 index 0000000..626fca2 --- /dev/null +++ b/src/ALZ/Private/Shared/Read-MenuSelection.ps1 @@ -0,0 +1,67 @@ +function Read-MenuSelection { + <# + .SYNOPSIS + Displays a menu of options and prompts the user to select one. + .DESCRIPTION + This function displays a numbered list of options and prompts the user to select one. + It validates the selection and returns the selected option value. + .PARAMETER Title + The title/prompt to display before the menu options. + .PARAMETER Options + An array of option values to display. + .PARAMETER DefaultIndex + The zero-based index of the default option (default: 0). + .PARAMETER OptionDescriptions + Optional descriptions for options. Can be either: + - A hashtable mapping option values to descriptions + - An array of descriptions matching the Options array by index + .OUTPUTS + Returns the selected option value. + .EXAMPLE + $selection = Read-MenuSelection -Title "Select IaC type:" -Options @("terraform", "bicep") -DefaultIndex 0 + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] $Title, + + [Parameter(Mandatory = $true)] + [array] $Options, + + [Parameter(Mandatory = $false)] + [int] $DefaultIndex = 0, + + [Parameter(Mandatory = $false)] + $OptionDescriptions = $null + ) + + Write-InformationColored $Title -ForegroundColor Yellow -InformationAction Continue + + for ($i = 0; $i -lt $Options.Count; $i++) { + $option = $Options[$i] + $default = if ($i -eq $DefaultIndex) { " (Default)" } else { "" } + + # Get description based on whether it's a hashtable or array + $description = "" + if ($null -ne $OptionDescriptions) { + if ($OptionDescriptions -is [hashtable] -and $OptionDescriptions.ContainsKey($option)) { + $description = " - $($OptionDescriptions[$option])" + } elseif ($OptionDescriptions -is [array] -and $i -lt $OptionDescriptions.Count) { + $description = " - $($OptionDescriptions[$i])" + } + } + + Write-InformationColored " [$($i + 1)] $option$description$default" -ForegroundColor White -InformationAction Continue + } + + do { + $selection = Read-Host "Enter selection (1-$($Options.Count), default: $($DefaultIndex + 1))" + if ([string]::IsNullOrWhiteSpace($selection)) { + $selectedIndex = $DefaultIndex + } else { + $selectedIndex = [int]$selection - 1 + } + } while ($selectedIndex -lt 0 -or $selectedIndex -ge $Options.Count) + + return $Options[$selectedIndex] +} diff --git a/src/ALZ/Private/Tools/Test-Tooling.ps1 b/src/ALZ/Private/Tools/Test-Tooling.ps1 index 771a900..4ce700a 100644 --- a/src/ALZ/Private/Tools/Test-Tooling.ps1 +++ b/src/ALZ/Private/Tools/Test-Tooling.ps1 @@ -6,11 +6,14 @@ function Test-Tooling { [Parameter(Mandatory = $false)] [switch]$checkYamlModule, [Parameter(Mandatory = $false)] - [switch]$skipYamlModuleInstall + [switch]$skipYamlModuleInstall, + [Parameter(Mandatory = $false)] + [switch]$skipAzureLoginCheck ) $checkResults = @() $hasFailure = $false + $azCliInstalledButNotLoggedIn = $false # Check if PowerShell is the correct version Write-Verbose "Checking PowerShell version" @@ -134,25 +137,33 @@ function Test-Tooling { message = "Azure CLI is installed." result = "Success" } - } else { - $checkResults += @{ - message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" - result = "Failure" - } - $hasFailure = $true - } - # Check if Azure CLI is logged in - Write-Verbose "Checking Azure CLI login status" - $azCliAccount = $(az account show -o json) | ConvertFrom-Json - if ($azCliAccount) { - $checkResults += @{ - message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" - result = "Success" + # Check if Azure CLI is logged in + Write-Verbose "Checking Azure CLI login status" + $azCliAccount = $(az account show -o json 2>$null) | ConvertFrom-Json + if ($azCliAccount) { + $checkResults += @{ + message = "Azure CLI is logged in. Tenant ID: $($azCliAccount.tenantId), Subscription: $($azCliAccount.name) ($($azCliAccount.id))" + result = "Success" + } + } else { + $azCliInstalledButNotLoggedIn = $true + if ($skipAzureLoginCheck.IsPresent) { + $checkResults += @{ + message = "Azure CLI is not logged in. Login will be prompted later." + result = "Warning" + } + } else { + $checkResults += @{ + message = "Azure CLI is not logged in. Please login to Azure CLI using 'az login -t `"00000000-0000-0000-0000-000000000000`"', replacing the empty GUID with your tenant ID." + result = "Failure" + } + $hasFailure = $true + } } } else { $checkResults += @{ - message = "Azure CLI is not logged in. Please login to Azure CLI using 'az login -t `"00000000-0000-0000-0000-000000000000}`"', replacing the empty GUID with your tenant ID." + message = "Azure CLI is not installed. Follow the instructions here: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" result = "Failure" } $hasFailure = $true @@ -261,11 +272,15 @@ function Test-Tooling { $e = [char]27 "$e[${color}m$($_.result)${e}[0m" } - }, @{ Label = "Check Details"; Expression = {$_.message} } -AutoSize -Wrap + }, @{ Label = "Check Details"; Expression = {$_.message} } -AutoSize -Wrap | Out-Host if($hasFailure) { Write-InformationColored "Accelerator software requirements have no been met, please review and install the missing software." -ForegroundColor Red -InformationAction Continue Write-InformationColored "Cannot continue with Deployment..." -ForegroundColor Red -InformationAction Continue throw "Accelerator software requirements have no been met, please review and install the missing software." } + + return @{ + AzCliInstalledButNotLoggedIn = $azCliInstalledButNotLoggedIn + } } diff --git a/src/ALZ/Public/Deploy-Accelerator.ps1 b/src/ALZ/Public/Deploy-Accelerator.ps1 index d554540..889623f 100644 --- a/src/ALZ/Public/Deploy-Accelerator.ps1 +++ b/src/ALZ/Public/Deploy-Accelerator.ps1 @@ -178,40 +178,92 @@ function Deploy-Accelerator { $ProgressPreference = "SilentlyContinue" - # Determine if any input files are YAML to check for powershell-yaml module - $hasYamlFiles = $false - $pathsToCheck = @() + # Check if we need to prompt for folder structure creation (which creates YAML files) + $needsFolderStructureSetup = $false + $envInputConfigPaths = $env:ALZ_input_config_path - if ($inputConfigFilePaths.Length -gt 0) { - $pathsToCheck = $inputConfigFilePaths - } else { - # Check environment variable if no paths provided - $envInputConfigPaths = $env:ALZ_input_config_path - if ($null -ne $envInputConfigPaths -and $envInputConfigPaths -ne "") { - $pathsToCheck = $envInputConfigPaths -split "," | Where-Object { $_ -and $_.Trim() } - } + if ($inputConfigFilePaths.Length -eq 0 -and ($null -eq $envInputConfigPaths -or $envInputConfigPaths -eq "")) { + $needsFolderStructureSetup = $true } - foreach ($path in $pathsToCheck) { - if ($null -ne $path -and $path.Trim() -ne "") { - try { - $extension = [System.IO.Path]::GetExtension($path).ToLower() - if ($extension -eq ".yml" -or $extension -eq ".yaml") { - $hasYamlFiles = $true - break + # Determine if YAML module check is needed + $checkYamlModule = $needsFolderStructureSetup # Always need YAML if prompting for folder structure + if (-not $checkYamlModule) { + # Check if any supplied input files are YAML + $pathsToCheck = if ($inputConfigFilePaths.Length -gt 0) { + $inputConfigFilePaths + } else { + $envInputConfigPaths -split "," | Where-Object { $_ -and $_.Trim() } + } + foreach ($path in $pathsToCheck) { + if ($null -ne $path -and $path.Trim() -ne "") { + try { + $extension = [System.IO.Path]::GetExtension($path).ToLower() + if ($extension -eq ".yml" -or $extension -eq ".yaml") { + $checkYamlModule = $true + break + } + } catch { + continue } - } catch { - # Ignore invalid paths - they will be caught later during config file validation - continue } } } + # Check software requirements first before any prompting + $toolingResult = $null if ($skip_requirements_check.IsPresent) { Write-InformationColored "WARNING: Skipping the software requirements check..." -ForegroundColor Yellow -InformationAction Continue } else { Write-InformationColored "Checking the software requirements for the Accelerator..." -ForegroundColor Green -InformationAction Continue - Test-Tooling -skipAlzModuleVersionCheck:$skip_alz_module_version_requirements_check.IsPresent -checkYamlModule:$hasYamlFiles -skipYamlModuleInstall:$skip_yaml_module_install.IsPresent + $toolingResult = Test-Tooling -skipAlzModuleVersionCheck:$skip_alz_module_version_requirements_check.IsPresent -checkYamlModule:$checkYamlModule -skipYamlModuleInstall:$skip_yaml_module_install.IsPresent -skipAzureLoginCheck:$needsFolderStructureSetup + } + + # If az cli is installed but not logged in, prompt for tenant ID and login with device code + if ($needsFolderStructureSetup -and $toolingResult -and $toolingResult.AzCliInstalledButNotLoggedIn) { + Write-InformationColored "`nAzure CLI is installed but not logged in. Let's log you in..." -ForegroundColor Yellow -InformationAction Continue + Write-InformationColored "You'll need your Azure Tenant ID. You can find this in the Azure Portal under Microsoft Entra ID > Overview." -ForegroundColor Cyan -InformationAction Continue + + $tenantId = "" + $guidRegex = "^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$" + do { + $tenantId = Read-Host "`nEnter your Azure Tenant ID (GUID)" + if ($tenantId -notmatch $guidRegex) { + Write-InformationColored "Invalid Tenant ID format. Please enter a valid GUID (e.g., 00000000-0000-0000-0000-000000000000)" -ForegroundColor Red -InformationAction Continue + } + } while ($tenantId -notmatch $guidRegex) + + Write-InformationColored "`nLogging in to Azure using device code authentication..." -ForegroundColor Green -InformationAction Continue + Write-InformationColored "Opening browser to https://microsoft.com/devicelogin for you to authenticate..." -ForegroundColor Cyan -InformationAction Continue + + try { + Start-Process "https://microsoft.com/devicelogin" + } catch { + Write-InformationColored "Could not open browser automatically. Please navigate to https://microsoft.com/devicelogin manually." -ForegroundColor Yellow -InformationAction Continue + } + az login --allow-no-subscriptions --use-device-code --tenant $tenantId + if ($LASTEXITCODE -ne 0) { + Write-InformationColored "Azure login failed. Please try again or login manually using 'az login --tenant $tenantId'." -ForegroundColor Red -InformationAction Continue + throw "Azure login failed." + } + + Write-InformationColored "Successfully logged in to Azure!" -ForegroundColor Green -InformationAction Continue + } + + # If no inputs provided, prompt user for folder structure setup + if ($needsFolderStructureSetup) { + $setupResult = Request-AcceleratorConfigurationInput -Destroy:$destroy.IsPresent + + if (-not $setupResult.Continue) { + return + } + + # Set the parameters from the setup result + $inputConfigFilePaths = $setupResult.InputConfigFilePaths + if ($setupResult.StarterAdditionalFiles.Count -gt 0) { + $starter_additional_files = $setupResult.StarterAdditionalFiles + } + $output_folder_path = $setupResult.OutputFolderPath } Write-InformationColored "Getting ready to deploy the accelerator with you..." -ForegroundColor Green -NewLineBefore -InformationAction Continue diff --git a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 index 48af704..3959602 100644 --- a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 +++ b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 @@ -39,7 +39,11 @@ function New-AcceleratorFolderStructure { if(Test-Path -Path $targetFolderPath) { if($force.IsPresent) { Write-Host "Force flag is set, removing existing target folder at $targetFolderPath" - Remove-Item -Recurse -Force -Path $targetFolderPath | Write-Verbose | Out-Null + try { + Remove-Item -Recurse -Force -Path $targetFolderPath -ErrorAction Stop | Write-Verbose | Out-Null + } catch { + throw "Failed to remove existing folder at '$targetFolderPath'. The folder may be locked by another process or you may not have permission to remove it. Please close any applications that may be using files in this folder and try again. Error: $($_.Exception.Message)" + } } else { throw "Target folder $targetFolderPath already exists. Please specify a different folder path or remove the existing folder." }