diff --git a/research-spoke/ResearchSpoke.Doc.ps1 b/research-spoke/ResearchSpoke.Doc.ps1 new file mode 100644 index 0000000..2e447a8 --- /dev/null +++ b/research-spoke/ResearchSpoke.Doc.ps1 @@ -0,0 +1,134 @@ +<# +.SYNOPSIS + A function to break out parameters from an ARM template. +#> +function global:GetTemplateParameter { + param ( + [Parameter(Mandatory = $True)] + [String]$Path + ) + process { + $template = Get-Content $Path | ConvertFrom-Json; + foreach ($property in $template.parameters.PSObject.Properties) { + [PSCustomObject]@{ + Name = $property.Name + Description = $property.Value.metadata.description + Type = $property.Value.type + Required = !("defaultValue" -in $property.Value.PSObject.Properties.Name -or $property.Value.nullable) + DefaultValue = if ("defaultValue" -in $property.Value.PSObject.Properties.Name) { + if ($property.Value.defaultValue) { $property.Value.defaultValue } else { + switch ($property.Value.type) { + 'string' { '''''' } + 'object' { '{ }' } + 'array' { '()' } + 'bool' { 'false' } + 'securestring' { '''''' } + } + } + } + AllowedValues = if ($property.Value.allowedValues) { + "``$($property.Value.allowedValues -join '`, `')``" + } + MinLength = $property.Value.minLength + MaxLength = $property.Value.maxLength + } + } + } +} + +<# +.SYNOPSIS + A function to import metadata from the ARM template file. +#> +function global:GetTemplateMetadata { + param ( + [Parameter(Mandatory = $True)] + [String]$Path + ) + process { + $template = Get-Content $Path | ConvertFrom-Json; + return $template.metadata; + } +} + +<# +.SYNOPSIS + A function to import outputs from the ARM template file. +#> +function global:GetTemplateOutput { + param ( + [Parameter(Mandatory = $True)] + [String]$Path + ) + process { + $template = Get-Content $Path | ConvertFrom-Json; + foreach ($property in $template.outputs.PSObject.Properties) { + [PSCustomObject]@{ + Name = $property.Name + Description = $property.Value.metadata.description + Type = $property.Value.type + } + } + } +} + +Document Research-Spoke { + # Read the ARM template file + $metadata = GetTemplateMetadata -Path $PSScriptRoot/main.json; + $parameters = GetTemplateParameter -Path $PSScriptRoot/main.json; + $outputs = global:GetTemplateOutput -Path $PSScriptRoot/main.json; + + Title $metadata.name + + $metadata.description + + Section 'Table of Contents' { + "[Parameters](#parameters)" + "[Outputs](#outputs)" + "[Use the template](#use-the-template)" + } + + # Add each parameter to a table + Section 'Parameters' { + $parameters | Table -Property @{ Name = 'Parameter name'; Expression = { "[$($_.Name)](#$($_.Name.ToLower()))" } }, Required, Description + + $parameters | ForEach-Object { + Section $_.Name { + if ($_.Required) { + "![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square)" + } + else { + "![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square)" + } + $_.Description + + $Details = "Metadata | Value`n---- | ----`nType | $($_.Type)" + if ($_.DefaultValue) { + $Details += "`nDefault value | ``$($_.DefaultValue)``" + } + if ($_.AllowedValues) { + $Details += "`nAllowed values | $($_.AllowedValues)" + } + if ($_.MinLength) { + $Details += "`nMinimum length | $($_.MinLength)" + } + if ($_.MaxLength) { + $Details += "`nMaximum length | $($_.MaxLength)" + } + $Details + } + } + } + + # Add outputs + Section 'Outputs' { + $outputs | Table -Property Name, Type, Description + } + + # Add sample + Section 'Use the template' { + Section 'PowerShell' { + '`./deploy.ps1 -TemplateParameterFile ''./main.prj.bicepparam'' -TargetSubscriptionId ''00000000-0000-0000-0000-000000000000'' -Location ''eastus''`' + } + } +} \ No newline at end of file diff --git a/research-spoke/deploy.ps1 b/research-spoke/deploy.ps1 index 02a4feb..e3af622 100644 --- a/research-spoke/deploy.ps1 +++ b/research-spoke/deploy.ps1 @@ -28,8 +28,7 @@ ./deploy.ps1 '.\main.prj.bicepparam' '00000000-0000-0000-0000-000000000000' 'eastus' #> -# LATER: Be more specific about the required modules; it will speed up the initial call -#Requires -Modules "Az" +#Requires -Modules Az.Resources #Requires -PSEdition Core [CmdletBinding()] @@ -86,7 +85,7 @@ $DeploymentResult = New-AzDeployment @CmdLetParameters if ($DeploymentResult.ProvisioningState -eq 'Succeeded') { Write-Host "🔥 Deployment succeeded." - if($DeleteJsonParameterFileAfterDeployment) { + if ($DeleteJsonParameterFileAfterDeployment) { Write-Verbose "Deleting template parameter JSON file '$TemplateParameterJsonFile'." Remove-Item -Path $TemplateParameterJsonFile -Force } diff --git a/research-spoke/docs/Research-Spoke.md b/research-spoke/docs/Research-Spoke.md new file mode 100644 index 0000000..2fcb8ca --- /dev/null +++ b/research-spoke/docs/Research-Spoke.md @@ -0,0 +1,590 @@ +# Research Spoke + +Deploys a research spoke associated with a previously deployed research hub. + +## Table of Contents + +[Parameters](#parameters) + +[Outputs](#outputs) + +[Use the template](#use-the-template) + +## Parameters + +Parameter name | Required | Description +-------------- | -------- | ----------- +[location](#location) | True | The Azure region where the spoke will be deployed. +[workloadName](#workloadname) | True | The name of the research project for the spoke. +[environment](#environment) | False | A maximum four-letter moniker for the environment type, such as 'dev', 'test', etc. +[tags](#tags) | False | Tags to apply to each deployed Azure resource. +[sequence](#sequence) | False | The deployment sequence. Each new sequence number will create a new deployment. +[namingConvention](#namingconvention) | False | The naming convention to use for Azure resource names. Can contain placeholders for {rtype}, {workloadName}, {location}, {env}, and {seq}. The only supported segment separator is '-'. +[deploymentTime](#deploymenttime) | False | Do not specify. Date and time will be used to create unique deployment names. +[encryptionKeyExpirySeed](#encryptionkeyexpiryseed) | False | The date and time seed for the expiration of the encryption keys. +[networkAddressSpaces](#networkaddressspaces) | True | Format: `[ "192.168.0.0/24", "192.168.10.0/24" ]` +[hubFirewallIp](#hubfirewallip) | True | The private IP address of the hub firewall. +[customDnsIps](#customdnsips) | False | The DNS IP addresses to use for the virtual network. Defaults to the hub firewall IP. +[hubVNetResourceId](#hubvnetresourceid) | True | The Azure resource ID of the hub virtual network to peer with. +[hubPrivateDnsZonesResourceGroupId](#hubprivatednszonesresourcegroupid) | True | The resource ID of the resource group in the hub subscription where storage account-related private DNS zones live. +[additionalSubnets](#additionalsubnets) | False | The definition of additional subnets that have been manually created. +[desktopAppGroupFriendlyName](#desktopappgroupfriendlyname) | False | Name of the Desktop application group shown to users in the AVD client. +[workspaceFriendlyName](#workspacefriendlyname) | False | Name of the Workspace shown to users in the AVD client. +[createPolicyExemptions](#createpolicyexemptions) | False | Experimental. If true, will create policy exemptions for resources and policy definitions that are not compliant due to issues with common Azure built-in compliance policy initiatives. +[policyAssignmentId](#policyassignmentid) | False | Required if policy exemptions must be created. +[sessionHostLocalAdminUsername](#sessionhostlocaladminusername) | False | The username for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`). +[sessionHostLocalAdminPassword](#sessionhostlocaladminpassword) | False | The password for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`). +[logonType](#logontype) | True | Specifies if logons to virtual machines should use AD or Entra ID. +[domainJoinUsername](#domainjoinusername) | False | The username of a domain user or service account to use to join the Active Directory domain. Use UPN notation. Required if using AD join. +[domainJoinPassword](#domainjoinpassword) | False | The password of the domain user or service account to use to join the Active Directory domain. Required if using AD join. +[filesIdentityType](#filesidentitytype) | True | The identity type to use for Azure Files. Use `AADKERB` for Entra ID Kerberos, `AADDS` for Entra Domain Services, or `None` for ADDS. +[adDomainFqdn](#addomainfqdn) | False | The fully qualified DNS name of the Active Directory domain to join. Required if using AD join. +[adOuPath](#adoupath) | False | Optional. The OU path in LDAP notation to use when joining the session hosts. +[storageAccountOuPath](#storageaccountoupath) | False | Optional. The OU Path in LDAP notation to use when joining the storage account. Defaults to the same OU as the session hosts. +[sessionHostCount](#sessionhostcount) | False | Optional. The number of Azure Virtual Desktop session hosts to create in the pool. Defaults to 1. +[sessionHostNamePrefix](#sessionhostnameprefix) | False | The prefix used for the computer names of the session host(s). Maximum 11 characters. +[sessionHostSize](#sessionhostsize) | False | A valid Azure Virtual Machine size. Use `az vm list-sizes --location ""` to retrieve a list for the selected location +[useSessionHostAsResearchVm](#usesessionhostasresearchvm) | False | If true, will configure the deployment of AVD to make the AVD session hosts usable as research VMs. This will give full desktop access, flow the AVD traffic through the firewall, etc. +[researcherEntraIdObjectId](#researcherentraidobjectid) | True | Entra ID object ID of the user or group (researchers) to assign permissions to access the AVD application groups and storage. +[adminEntraIdObjectId](#adminentraidobjectid) | True | Entra ID object ID of the admin user or group to assign permissions to administer the AVD session hosts, storage, etc. +[isAirlockReviewCentralized](#isairlockreviewcentralized) | False | If true, airlock reviews will take place centralized in the hub. If true, the hub* parameters must be specified also. +[airlockApproverEmail](#airlockapproveremail) | True | The email address of the reviewer for this project. +[allowedIngestFileExtensions](#allowedingestfileextensions) | False | The allowed file extensions for ingest. +[centralAirlockStorageAccountId](#centralairlockstorageaccountid) | True | The full Azure resource ID of the hub's airlock review storage account. +[centralAirlockFileShareName](#centralairlockfilesharename) | True | The file share name for airlock reviews. +[centralAirlockKeyVaultId](#centralairlockkeyvaultid) | True | The name of the Key Vault in the research hub containing the airlock review storage account's connection string as a secret. +[publicStorageAccountAllowedIPs](#publicstorageaccountallowedips) | False | The list of allowed IP addresses or ranges for ingest and approved export pickup purposes. +[complianceTarget](#compliancetarget) | False | The Azure built-in regulatory compliance framework to target. This will affect whether or not customer-managed keys, private endpoints, etc. are used. This will *not* deploy a policy assignment. +[hubManagementVmId](#hubmanagementvmid) | False | The Azure resource ID of the management VM in the hub. Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. +[hubManagementVmUamiPrincipalId](#hubmanagementvmuamiprincipalid) | False | The Entra ID object ID of the user-assigned managed identity of the management VM. This will be given the necessary role assignment to perform a domain join on the storage account(s). Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. +[hubManagementVmUamiClientId](#hubmanagementvmuamiclientid) | False | The client ID of the user-assigned managed identity of the management VM. Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. +[debugMode](#debugmode) | False | Set to `true` to enable debug mode of the spoke. Debug mode will allow remote access to storage, etc. Should be not be used for production deployments. +[debugRemoteIp](#debugremoteip) | False | Used when `debugMode = true`. The IP address to allow access to storage, Key Vault, etc. +[debugPrincipalId](#debugprincipalid) | False | The object ID of the user or group to assign permissions. Only used when `debugMode = true`. + +### location + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The Azure region where the spoke will be deployed. + +Metadata | Value +---- | ---- +Type | string + +### workloadName + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The name of the research project for the spoke. + +Metadata | Value +---- | ---- +Type | string + +### environment + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +A maximum four-letter moniker for the environment type, such as 'dev', 'test', etc. + +Metadata | Value +---- | ---- +Type | string +Default value | `dev` +Allowed values | `dev`, `test`, `demo`, `prod` +Maximum length | 4 + +### tags + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Tags to apply to each deployed Azure resource. + +Metadata | Value +---- | ---- +Type | object +Default value | `` + +### sequence + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The deployment sequence. Each new sequence number will create a new deployment. + +Metadata | Value +---- | ---- +Type | int +Default value | `1` + +### namingConvention + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The naming convention to use for Azure resource names. Can contain placeholders for {rtype}, {workloadName}, {location}, {env}, and {seq}. The only supported segment separator is '-'. + +Metadata | Value +---- | ---- +Type | string +Default value | `{workloadName}-{subWorkloadName}-{env}-{rtype}-{loc}-{seq}` + +### deploymentTime + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Do not specify. Date and time will be used to create unique deployment names. + +Metadata | Value +---- | ---- +Type | string +Default value | `[utcNow()]` + +### encryptionKeyExpirySeed + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The date and time seed for the expiration of the encryption keys. + +Metadata | Value +---- | ---- +Type | string +Default value | `[utcNow()]` + +### networkAddressSpaces + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +Format: `[ "192.168.0.0/24", "192.168.10.0/24" ]` + +Metadata | Value +---- | ---- +Type | array +Minimum length | 1 + +### hubFirewallIp + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The private IP address of the hub firewall. + +Metadata | Value +---- | ---- +Type | string + +### customDnsIps + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The DNS IP addresses to use for the virtual network. Defaults to the hub firewall IP. + +Metadata | Value +---- | ---- +Type | array +Default value | `[parameters('hubFirewallIp')]` + +### hubVNetResourceId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The Azure resource ID of the hub virtual network to peer with. + +Metadata | Value +---- | ---- +Type | string + +### hubPrivateDnsZonesResourceGroupId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The resource ID of the resource group in the hub subscription where storage account-related private DNS zones live. + +Metadata | Value +---- | ---- +Type | string + +### additionalSubnets + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The definition of additional subnets that have been manually created. + +Metadata | Value +---- | ---- +Type | array +Default value | `()` + +### desktopAppGroupFriendlyName + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Name of the Desktop application group shown to users in the AVD client. + +Metadata | Value +---- | ---- +Type | string +Default value | `N/A` + +### workspaceFriendlyName + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Name of the Workspace shown to users in the AVD client. + +Metadata | Value +---- | ---- +Type | string +Default value | `N/A` + +### createPolicyExemptions + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Experimental. If true, will create policy exemptions for resources and policy definitions that are not compliant due to issues with common Azure built-in compliance policy initiatives. + +Metadata | Value +---- | ---- +Type | bool +Default value | `false` + +### policyAssignmentId + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Required if policy exemptions must be created. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### sessionHostLocalAdminUsername + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The username for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`). + +Metadata | Value +---- | ---- +Type | securestring +Default value | `''` + +### sessionHostLocalAdminPassword + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The password for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`). + +Metadata | Value +---- | ---- +Type | securestring +Default value | `''` + +### logonType + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +Specifies if logons to virtual machines should use AD or Entra ID. + +Metadata | Value +---- | ---- +Type | string +Allowed values | `ad`, `entraID` + +### domainJoinUsername + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The username of a domain user or service account to use to join the Active Directory domain. Use UPN notation. Required if using AD join. + +Metadata | Value +---- | ---- +Type | securestring +Default value | `''` + +### domainJoinPassword + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The password of the domain user or service account to use to join the Active Directory domain. Required if using AD join. + +Metadata | Value +---- | ---- +Type | securestring +Default value | `''` + +### filesIdentityType + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The identity type to use for Azure Files. Use `AADKERB` for Entra ID Kerberos, `AADDS` for Entra Domain Services, or `None` for ADDS. + +Metadata | Value +---- | ---- +Type | string +Allowed values | `AADKERB`, `AADDS`, `None` + +### adDomainFqdn + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The fully qualified DNS name of the Active Directory domain to join. Required if using AD join. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### adOuPath + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Optional. The OU path in LDAP notation to use when joining the session hosts. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### storageAccountOuPath + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Optional. The OU Path in LDAP notation to use when joining the storage account. Defaults to the same OU as the session hosts. + +Metadata | Value +---- | ---- +Type | string +Default value | `[parameters('adOuPath')]` + +### sessionHostCount + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Optional. The number of Azure Virtual Desktop session hosts to create in the pool. Defaults to 1. + +Metadata | Value +---- | ---- +Type | int +Default value | `1` + +### sessionHostNamePrefix + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The prefix used for the computer names of the session host(s). Maximum 11 characters. + +Metadata | Value +---- | ---- +Type | string +Default value | `N/A` +Maximum length | 11 + +### sessionHostSize + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +A valid Azure Virtual Machine size. Use `az vm list-sizes --location ""` to retrieve a list for the selected location + +Metadata | Value +---- | ---- +Type | string +Default value | `N/A` + +### useSessionHostAsResearchVm + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +If true, will configure the deployment of AVD to make the AVD session hosts usable as research VMs. This will give full desktop access, flow the AVD traffic through the firewall, etc. + +Metadata | Value +---- | ---- +Type | bool +Default value | `True` + +### researcherEntraIdObjectId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +Entra ID object ID of the user or group (researchers) to assign permissions to access the AVD application groups and storage. + +Metadata | Value +---- | ---- +Type | string + +### adminEntraIdObjectId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +Entra ID object ID of the admin user or group to assign permissions to administer the AVD session hosts, storage, etc. + +Metadata | Value +---- | ---- +Type | string + +### isAirlockReviewCentralized + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +If true, airlock reviews will take place centralized in the hub. If true, the hub* parameters must be specified also. + +Metadata | Value +---- | ---- +Type | bool +Default value | `false` + +### airlockApproverEmail + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The email address of the reviewer for this project. + +Metadata | Value +---- | ---- +Type | string + +### allowedIngestFileExtensions + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The allowed file extensions for ingest. + +Metadata | Value +---- | ---- +Type | array +Default value | `()` + +### centralAirlockStorageAccountId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The full Azure resource ID of the hub's airlock review storage account. + +Metadata | Value +---- | ---- +Type | string + +### centralAirlockFileShareName + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The file share name for airlock reviews. + +Metadata | Value +---- | ---- +Type | string + +### centralAirlockKeyVaultId + +![Parameter Setting](https://img.shields.io/badge/parameter-required-orange?style=flat-square) + +The name of the Key Vault in the research hub containing the airlock review storage account's connection string as a secret. + +Metadata | Value +---- | ---- +Type | string + +### publicStorageAccountAllowedIPs + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The list of allowed IP addresses or ranges for ingest and approved export pickup purposes. + +Metadata | Value +---- | ---- +Type | array +Default value | `()` + +### complianceTarget + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The Azure built-in regulatory compliance framework to target. This will affect whether or not customer-managed keys, private endpoints, etc. are used. This will *not* deploy a policy assignment. + +Metadata | Value +---- | ---- +Type | string +Default value | `NIST80053R5` +Allowed values | `NIST80053R5`, `HIPAAHITRUST`, `CMMC2L2`, `NIST800171R2` + +### hubManagementVmId + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The Azure resource ID of the management VM in the hub. Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### hubManagementVmUamiPrincipalId + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The Entra ID object ID of the user-assigned managed identity of the management VM. This will be given the necessary role assignment to perform a domain join on the storage account(s). Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### hubManagementVmUamiClientId + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The client ID of the user-assigned managed identity of the management VM. Required if using AD join for Azure Files (`filesIdentityType = 'None'`). This value is output by the hub deployment. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### debugMode + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Set to `true` to enable debug mode of the spoke. Debug mode will allow remote access to storage, etc. Should be not be used for production deployments. + +Metadata | Value +---- | ---- +Type | bool +Default value | `false` + +### debugRemoteIp + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +Used when `debugMode = true`. The IP address to allow access to storage, Key Vault, etc. + +Metadata | Value +---- | ---- +Type | string +Default value | `''` + +### debugPrincipalId + +![Parameter Setting](https://img.shields.io/badge/parameter-optional-green?style=flat-square) + +The object ID of the user or group to assign permissions. Only used when `debugMode = true`. + +Metadata | Value +---- | ---- +Type | string +Default value | `[deployer().objectId]` + +## Outputs + +Name | Type | Description +---- | ---- | ----------- +recoveryServicesVaultId | string | The Azure resource ID of the spoke's Recovery Services Vault. Used in service module templates to add additional resources to the vault. +vmBackupPolicyName | string | The name of the backup policy used for Azure VM backups in the spoke. +diskEncryptionSetId | string | The Azure resource ID of the disk encryption set used for customer-managed key encryption of managed disks in the spoke. +computeSubnetId | string | The Azure resource ID of the ComputeSubnet. +computeResourceGroupName | string | The resource group name of the compute resource group. +shortcutTargetPath | string | The UNC path to the 'shared' file share in the spoke's private storage account. + +## Use the template + +### PowerShell + +`./deploy.ps1 -TemplateParameterFile './main.prj.bicepparam' -TargetSubscriptionId '00000000-0000-0000-0000-000000000000' -Location 'eastus'` diff --git a/research-spoke/main.bicep b/research-spoke/main.bicep index c33c8cc..0b5df99 100644 --- a/research-spoke/main.bicep +++ b/research-spoke/main.bicep @@ -1,5 +1,8 @@ targetScope = 'subscription' +metadata description = 'Deploys a research spoke associated with a previously deployed research hub.' +metadata name = 'Research Spoke' + //------------------------------ START PARAMETERS ------------------------------ @description('The Azure region where the spoke will be deployed.') @@ -25,13 +28,14 @@ param sequence int = 1 @description('The naming convention to use for Azure resource names. Can contain placeholders for {rtype}, {workloadName}, {location}, {env}, and {seq}. The only supported segment separator is \'-\'.') param namingConvention string = '{workloadName}-{subWorkloadName}-{env}-{rtype}-{loc}-{seq}' +@description('Do not specify. Date and time will be used to create unique deployment names.') param deploymentTime string = utcNow() @description('The date and time seed for the expiration of the encryption keys.') param encryptionKeyExpirySeed string = utcNow() // Network parameters -@description('Format: [ "192.168.0.0/24", "192.168.10.0/24" ]') +@description('Format: `[ "192.168.0.0/24", "192.168.10.0/24" ]`') @minLength(1) param networkAddressSpaces array @description('The private IP address of the hub firewall.') @@ -58,13 +62,15 @@ param workspaceFriendlyName string = 'N/A' // @description('The Azure resource ID of the standalone image to use for new session hosts. If blank, will use the Windows 11 23H2 O365 Gen 2 Marketplace image.') // param sessionHostVmImageResourceId string = '' -@description('If true, will create policy exemptions for resources and policy definitions that are not compliant due to issues with common Azure built-in compliance policy initiatives.') +@description('Experimental. If true, will create policy exemptions for resources and policy definitions that are not compliant due to issues with common Azure built-in compliance policy initiatives.') param createPolicyExemptions bool = false @description('Required if policy exemptions must be created.') param policyAssignmentId string = '' +@description('The username for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`).') @secure() param sessionHostLocalAdminUsername string = '' +@description('The password for the local user account on the session hosts. Required if when deploying AVD session hosts in the hub (`useSessionHostAsResearchVm = false`).') @secure() param sessionHostLocalAdminPassword string = '' @description('Specifies if logons to virtual machines should use AD or Entra ID.') @@ -77,6 +83,7 @@ param domainJoinUsername string = '' @secure() param domainJoinPassword string = '' +@description('The identity type to use for Azure Files. Use `AADKERB` for Entra ID Kerberos, `AADDS` for Entra Domain Services, or `None` for ADDS.') @allowed(['AADKERB', 'AADDS', 'None']) param filesIdentityType string @@ -129,13 +136,19 @@ param publicStorageAccountAllowedIPs array = [] // Default to the strictest supported compliance framework param complianceTarget string = 'NIST80053R5' +@description('The Azure resource ID of the management VM in the hub. Required if using AD join for Azure Files (`filesIdentityType = \'None\'`). This value is output by the hub deployment.') param hubManagementVmId string = '' +@description('The Entra ID object ID of the user-assigned managed identity of the management VM. This will be given the necessary role assignment to perform a domain join on the storage account(s). Required if using AD join for Azure Files (`filesIdentityType = \'None\'`). This value is output by the hub deployment.') param hubManagementVmUamiPrincipalId string = '' +@description('The client ID of the user-assigned managed identity of the management VM. Required if using AD join for Azure Files (`filesIdentityType = \'None\'`). This value is output by the hub deployment.') param hubManagementVmUamiClientId string = '' +@description('Set to `true` to enable debug mode of the spoke. Debug mode will allow remote access to storage, etc. Should be not be used for production deployments.') param debugMode bool = false +@description('Used when `debugMode = true`. The IP address to allow access to storage, Key Vault, etc.') param debugRemoteIp string = '' -param debugPrincipalId string = '' +@description('The object ID of the user or group to assign permissions. Only used when `debugMode = true`.') +param debugPrincipalId string = az.deployer().objectId //----------------------------- END PARAMETERS ----------------------------- @@ -672,12 +685,19 @@ resource avdConnectionPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06- scope: hubDnsZoneResourceGroup } +@description('The Azure resource ID of the spoke\'s Recovery Services Vault. Used in service module templates to add additional resources to the vault.') output recoveryServicesVaultId string = recoveryServicesVaultModule.outputs.id +@description('The name of the backup policy used for Azure VM backups in the spoke.') output vmBackupPolicyName string = recoveryServicesVaultModule.outputs.vmBackupPolicyName +@description('The Azure resource ID of the disk encryption set used for customer-managed key encryption of managed disks in the spoke.') output diskEncryptionSetId string = diskEncryptionSetModule.outputs.id +@description('The Azure resource ID of the ComputeSubnet.') output computeSubnetId string = networkModule.outputs.createdSubnets.computeSubnet.id +@description('The resource group name of the compute resource group.') output computeResourceGroupName string = computeRg.name + // Double up the \ in the output so it can be pasted easily into a bicepparam file +@description('The UNC path to the \'shared\' file share in the spoke\'s private storage account.') output shortcutTargetPath string = replace( '${storageModule.outputs.storageAccountFileShareBaseUncPath}${fileShareNames.shared}', '\\', diff --git a/research-spoke/spoke-modules/storage/main.bicep b/research-spoke/spoke-modules/storage/main.bicep index c484637..04fbbb8 100644 --- a/research-spoke/spoke-modules/storage/main.bicep +++ b/research-spoke/spoke-modules/storage/main.bicep @@ -36,7 +36,7 @@ param storageAccountPrivateEndpointGroups array = [ 'file' ] -@description('Role assignements to create on the storage account.') +@description('Role assignments to create on the storage account.') param storageAccountRoleAssignments roleAssignmentType import { roleAssignmentType } from '../../../shared-modules/types/roleAssignment.bicep' diff --git a/scripts/PowerShell/Modules/AzSubscriptionManagement.psm1 b/scripts/PowerShell/Modules/AzSubscriptionManagement.psm1 index 081e705..242c92b 100644 --- a/scripts/PowerShell/Modules/AzSubscriptionManagement.psm1 +++ b/scripts/PowerShell/Modules/AzSubscriptionManagement.psm1 @@ -1,8 +1,41 @@ @{ - ModuleVersion = '0.0.1' + ModuleVersion = '0.0.2' } <# +.SYNOPSIS + Sets the Azure environment and subscription context for the current session. + +.DESCRIPTION + Sets the Azure environment and subscription context for the current session. + +.PARAMETER SubscriptionId + The Azure subscription ID to switch to. + +.PARAMETER Environment + The Azure environment to switch to. Default is 'AzureCloud'. + +.PARAMETER Tenant + The Azure tenant ID to switch to. Default is the current tenant. + +.NOTES + You must already be signed in to Azure using `Connect-AzAccount` before calling this function. + +.EXAMPLE + PS> Set-AzContextWrapper -SubscriptionId '00000000-0000-0000-0000-000000000000' + + This example switches the current session to the subscription with the ID '00000000-0000-0000-0000-000000000000'. + +.EXAMPLE + PS> Set-AzContextWrapper -SubscriptionId '00000000-0000-0000-0000-000000000000' -Environment 'AzureUSGovernment' + + This example switches the current session to the subscription with the ID '00000000-0000-0000-0000-000000000000' in Azure US Government. + +.INPUTS + None. + +.OUTPUTS + None. #> Function Set-AzContextWrapper { [CmdletBinding()] @@ -10,7 +43,9 @@ Function Set-AzContextWrapper { [Parameter(Mandatory, Position = 0)] [string]$SubscriptionId, [Parameter(Position = 1)] - [string]$Environment = 'AzureCloud' + [string]$Environment = 'AzureCloud', + [Parameter(Position = 2)] + [string]$Tenant = (Get-AzContext).Tenant.Id ) # Because this function is in a module, $VerbosePreference doesn't carry over from the caller @@ -19,11 +54,12 @@ Function Set-AzContextWrapper { $VerbosePreference = $PSCmdlet.GetVariableValue('VerbosePreference') } - # Determine if a cloud context switch is required $AzContext = Get-AzContext + + # Determine if a cloud context switch is required if ($AzContext.Environment.Name -ne $Environment) { Write-Warning "Current Environment: '$($AzContext.Environment.Name)'. Switching to $Environment" - Connect-AzAccount -Environment $Environment + Connect-AzAccount -Environment $Environment -Tenant $Tenant $AzContext = Get-AzContext } else { @@ -33,7 +69,7 @@ Function Set-AzContextWrapper { # Determine if a subscription switch is required if ($SubscriptionId -ne (Get-AzContext).Subscription.Id) { Write-Verbose "Current subscription: '$($AzContext.Subscription.Id)'. Switching subscription." - Select-AzSubscription $SubscriptionId + Select-AzSubscription $SubscriptionId -Tenant $Tenant $AzContext = Get-AzContext } else { @@ -44,14 +80,25 @@ Function Set-AzContextWrapper { } <# - .SYNOPSIS +.SYNOPSIS Registers an Azure subscription for a resource provider feature. - .DESCRIPTION +.DESCRIPTION Determines if the specified feature for the specified resource provider namespace is registered. If not, it will register the feature and wait for registration to complete. - .NOTES +.NOTES The current Azure context will be used to determine the subscription to register the feature in. + +.PARAMETER ProviderNamespace + The namespace of the resource provider to register the feature for. + +.PARAMETER FeatureName + The name of the feature to register. + +.EXAMPLE + PS> Register-AzProviderFeatureWrapper -ProviderNamespace "Microsoft.Compute" -FeatureName "EncryptionAtHost" + + This example registers the 'EncryptionAtHost' feature for the 'Microsoft.Compute' resource provider namespace in the current subscription. #> Function Register-AzProviderFeatureWrapper { [CmdletBinding()] @@ -103,14 +150,22 @@ Function Register-AzProviderFeatureWrapper { } <# - .SYNOPSIS - Registers an Azure subscription for a resource provider feature. +.SYNOPSIS + Registers an Azure subscription for a resource provider. - .DESCRIPTION - Determines if the specified feature for the specified resource provider namespace is registered. If not, it will register the feature and wait for registration to complete. +.DESCRIPTION + Determines if the specified resource provider namespace is registered. If not, it will register the provider and wait for the registration to finish. - .NOTES - The current Azure context will be used to determine the subscription to register the feature in. +.NOTES + The current Azure context will be used to determine the subscription to register the provider in. + +.EXAMPLE + PS> Register-AzResourceProviderWrapper -ProviderNamespace "Microsoft.Network" + + This example registers the 'Microsoft.Network' resource provider in the current subscription. + +.PARAMETER ProviderNamespace + The namespace of the resource provider to register. #> Function Register-AzResourceProviderWrapper { [CmdletBinding()] diff --git a/scripts/PowerShell/Modules/docs/Register-AzProviderFeatureWrapper.md b/scripts/PowerShell/Modules/docs/Register-AzProviderFeatureWrapper.md new file mode 100644 index 0000000..9e789ee --- /dev/null +++ b/scripts/PowerShell/Modules/docs/Register-AzProviderFeatureWrapper.md @@ -0,0 +1,90 @@ +--- +external help file: AzSubscriptionManagement-help.xml +Module Name: AzSubscriptionManagement +online version: +schema: 2.0.0 +--- + +# Register-AzProviderFeatureWrapper + +## SYNOPSIS +Registers an Azure subscription for a resource provider feature. + +## SYNTAX + +``` +Register-AzProviderFeatureWrapper [-ProviderNamespace] [-FeatureName] + [-ProgressAction ] [] +``` + +## DESCRIPTION +Determines if the specified feature for the specified resource provider namespace is registered. +If not, it will register the feature and wait for registration to complete. + +## EXAMPLES + +### EXAMPLE 1 +``` +Register-AzProviderFeatureWrapper -ProviderNamespace "Microsoft.Compute" -FeatureName "EncryptionAtHost" +``` + +This example registers the 'EncryptionAtHost' feature for the 'Microsoft.Compute' resource provider namespace in the current subscription. + +## PARAMETERS + +### -ProviderNamespace +The namespace of the resource provider to register the feature for. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -FeatureName +The name of the feature to register. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 3 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +The current Azure context will be used to determine the subscription to register the feature in. + +## RELATED LINKS diff --git a/scripts/PowerShell/Modules/docs/Register-AzResourceProviderWrapper.md b/scripts/PowerShell/Modules/docs/Register-AzResourceProviderWrapper.md new file mode 100644 index 0000000..ab3a8b2 --- /dev/null +++ b/scripts/PowerShell/Modules/docs/Register-AzResourceProviderWrapper.md @@ -0,0 +1,75 @@ +--- +external help file: AzSubscriptionManagement-help.xml +Module Name: AzSubscriptionManagement +online version: +schema: 2.0.0 +--- + +# Register-AzResourceProviderWrapper + +## SYNOPSIS +Registers an Azure subscription for a resource provider. + +## SYNTAX + +``` +Register-AzResourceProviderWrapper [-ProviderNamespace] [-ProgressAction ] + [] +``` + +## DESCRIPTION +Determines if the specified resource provider namespace is registered. +If not, it will register the provider and wait for the registration to finish. + +## EXAMPLES + +### EXAMPLE 1 +``` +Register-AzResourceProviderWrapper -ProviderNamespace "Microsoft.Network" +``` + +This example registers the 'Microsoft.Network' resource provider in the current subscription. + +## PARAMETERS + +### -ProviderNamespace +The namespace of the resource provider to register. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +The current Azure context will be used to determine the subscription to register the provider in. + +## RELATED LINKS diff --git a/scripts/PowerShell/Modules/docs/Set-AzContextWrapper.md b/scripts/PowerShell/Modules/docs/Set-AzContextWrapper.md new file mode 100644 index 0000000..5c93b47 --- /dev/null +++ b/scripts/PowerShell/Modules/docs/Set-AzContextWrapper.md @@ -0,0 +1,115 @@ +--- +external help file: AzSubscriptionManagement-help.xml +Module Name: AzSubscriptionManagement +online version: +schema: 2.0.0 +--- + +# Set-AzContextWrapper + +## SYNOPSIS +Sets the Azure environment and subscription context for the current session. + +## SYNTAX + +``` +Set-AzContextWrapper [-SubscriptionId] [[-Environment] ] [[-Tenant] ] + [-ProgressAction ] [] +``` + +## DESCRIPTION +Sets the Azure environment and subscription context for the current session. + +## EXAMPLES + +### EXAMPLE 1 +``` +Set-AzContextWrapper -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +This example switches the current session to the subscription with the ID '00000000-0000-0000-0000-000000000000'. + +### EXAMPLE 2 +``` +Set-AzContextWrapper -SubscriptionId '00000000-0000-0000-0000-000000000000' -Environment 'AzureUSGovernment' +``` + +This example switches the current session to the subscription with the ID '00000000-0000-0000-0000-000000000000' in Azure US Government. + +## PARAMETERS + +### -SubscriptionId +The Azure subscription ID to switch to. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Environment +The Azure environment to switch to. +Default is 'AzureCloud'. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 3 +Default value: AzureCloud +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Tenant +The Azure tenant ID to switch to. +Default is the current tenant. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 4 +Default value: (Get-AzContext).Tenant.Id +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None. +## OUTPUTS + +### None. +## NOTES +You must already be signed in to Azure using \`Connect-AzAccount\` before calling this function. + +## RELATED LINKS diff --git a/scripts/PowerShell/Modules/en-US/about_AzSubscriptionManagement.help.txt b/scripts/PowerShell/Modules/en-US/about_AzSubscriptionManagement.help.txt new file mode 100644 index 0000000..f3da83d --- /dev/null +++ b/scripts/PowerShell/Modules/en-US/about_AzSubscriptionManagement.help.txt @@ -0,0 +1,5 @@ +TOPIC + about_AzSubscriptionManagement + +SHORT DESCRIPTION + Helper functions to work with subscriptions in PowerShell. \ No newline at end of file diff --git a/scripts/PowerShell/Scripts/DataFactory/Remove-ManagedPrivateEndpoints.ps1 b/scripts/PowerShell/Scripts/DataFactory/Remove-ManagedPrivateEndpoints.ps1 new file mode 100644 index 0000000..c8e535b --- /dev/null +++ b/scripts/PowerShell/Scripts/DataFactory/Remove-ManagedPrivateEndpoints.ps1 @@ -0,0 +1,43 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param ( + [Parameter(Mandatory)] + [string]$DataFactoryName, + [Parameter(Mandatory)] + [string]$ResourceGroupName, + [Parameter(Mandatory)] + [string]$SubscriptionId +) + +# From research-spoke/spoke-modules/airlock/adf.bicep +[string]$DataFactoryApiVersion = '2018-06-01' + +$RestMethodParameters = @{ + Method = 'GET' + SubscriptionId = $SubscriptionId + ResourceGroupName = $ResourceGroupName + ResourceProviderName = 'Microsoft.DataFactory' + ResourceType = @('factories', 'managedVirtualNetworks', 'managedPrivateEndpoints') + # 'default' is hardcoded because it's the only possible name for a managed virtual network + Name = @($DataFactoryName, 'default') + ApiVersion = $DataFactoryApiVersion +} + +$ManagedPrivateEndpoints = ((Invoke-AzRestMethod @RestMethodParameters).Content | ConvertFrom-Json).value +Write-Host "Deleting $($ManagedPrivateEndpoints.Count) managed private endpoints in Data Factory '$DataFactoryName' in resource group '$ResourceGroupName'..." + +# If there are any managed private endpoints +if ($ManagedPrivateEndpoints -and $ManagedPrivateEndpoints.Count -gt 0) { + # Update the REST method parameters for the DELETE request + $RestMethodParameters.Method = 'DELETE' + + foreach ($ManagedPrivateEndpoint in $ManagedPrivateEndpoints) { + # Update the REST method parameter to specify the name of the endpoint to be deleted + $RestMethodParameters.Name = @($DataFactoryName, 'default', $ManagedPrivateEndpoint.Name) + + if ($PSCmdlet.ShouldProcess($ManagedPrivateEndpoint.Name, "DELETE")) { + # Debug Note: This call returns HTTP status code 200 even if the delete failed due to a resource lock + $Response = Invoke-AzRestMethod @RestMethodParameters + Write-Verbose "DELETE HTTP request returned status code: $($Response.StatusCode)" + } + } +} diff --git a/scripts/PowerShell/Scripts/Invoke-Docs.ps1 b/scripts/PowerShell/Scripts/Invoke-Docs.ps1 new file mode 100644 index 0000000..0fa7876 --- /dev/null +++ b/scripts/PowerShell/Scripts/Invoke-Docs.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Generates documentation for select PowerShell modules and ARM templates in this repo. +#> + +#Requires -Modules platyPS, Az.Resources, PSDocs.Azure + +# Generate markdown help for the AzSubscriptionManagement module using platyPS +try { + Import-Module platyPS + Import-Module ../Modules/AzSubscriptionManagement.psm1 -ErrorAction Continue + + New-MarkDownHelp -Module AzSubscriptionManagement -OutputFolder ../Modules/docs -Force +} +finally { + Remove-Module AzSubscriptionManagement + Remove-Module platyPS +} + +# Generate markdown help for the research hub module using PSDocs +try { + bicep build ../../../research-spoke/main.bicep + Invoke-PSDocument -Path ../../../research-spoke/ -OutputPath ./docs +} +finally { + Remove-Item -Path ../../../research-spoke/main.json -Force + Remove-Module PSDocs.Azure +} + +# TODO: Generate docs for research hub template \ No newline at end of file diff --git a/scripts/PowerShell/Scripts/Recovery/Remove-rsv.ps1 b/scripts/PowerShell/Scripts/Recovery/Remove-rsv.ps1 index 7a43421..7a33c3f 100644 --- a/scripts/PowerShell/Scripts/Recovery/Remove-rsv.ps1 +++ b/scripts/PowerShell/Scripts/Recovery/Remove-rsv.ps1 @@ -5,7 +5,9 @@ param ( [Parameter(Mandatory)] [string]$ResourceGroup, [Parameter(Mandatory)] - [string]$SubscriptionId + [string]$SubscriptionId, + [Parameter()] + [string]$Tenant = (Get-AzContext).Tenant.Id ) # LATER: Add PS version check (7) @@ -28,9 +30,11 @@ if ($NWversion -lt "4.15.0") { Install-Module -Name Az.Network -Repository PSGallery -Force -AllowClobber } -Select-AzSubscription $SubscriptionId +Select-AzSubscription $SubscriptionId -Tenant $Tenant | Out-Null $VaultToDelete = Get-AzRecoveryServicesVault -Name $VaultName -ResourceGroupName $ResourceGroup -Set-AzRecoveryServicesAsrVaultContext -Vault $VaultToDelete +# Ignore WhatIfPreference here because future cmdlets will fail without this being set +# This should have no side effects +Set-AzRecoveryServicesAsrVaultContext -Vault $VaultToDelete -WhatIf:$false $UpdatedVault = Update-AzRecoveryServicesVault -ResourceGroupName $VaultToDelete.ResourceGroupName -Name $VaultToDelete.Name -ImmutabilityState "Disabled" Write-Host "Immutability state set to $($UpdatedVault.Properties.ImmutabilitySettings.ImmutabilityState)" @@ -147,11 +151,11 @@ if ($null -ne $fabricObjects) { } $NetworkObjects = Get-AzRecoveryServicesAsrNetwork -Fabric $fabricObject foreach ($networkObject in $NetworkObjects) { - #Get the PrimaryNetwork + # Get the PrimaryNetwork $PrimaryNetwork = Get-AzRecoveryServicesAsrNetwork -Fabric $fabricObject -FriendlyName $networkObject $NetworkMappings = Get-AzRecoveryServicesAsrNetworkMapping -Network $PrimaryNetwork foreach ($networkMappingObject in $NetworkMappings) { - #Get the Neetwork Mappings + # Get the Network Mappings $NetworkMapping = Get-AzRecoveryServicesAsrNetworkMapping -Name $networkMappingObject.Name -Network $PrimaryNetwork Remove-AzRecoveryServicesAsrNetworkMapping -InputObject $NetworkMapping } @@ -171,7 +175,7 @@ foreach ($item in $pvtendpoints) { } Write-Host "Removed Private Endpoints" -#Recheck ASR items in vault +# Recheck ASR items in vault $fabricCount = 0 $ASRProtectedItems = 0 $ASRPolicyMappings = 0 @@ -192,6 +196,7 @@ if ($null -ne $fabricObjects) { $fabricCount++ } } + #Recheck presence of backup items in vault $backupItemsVMFin = Get-AzRecoveryServicesBackupItem -BackupManagementType AzureVM -WorkloadType AzureVM -VaultId $VaultToDelete.ID $backupItemsSQLFin = Get-AzRecoveryServicesBackupItem -BackupManagementType AzureWorkload -WorkloadType MSSQL -VaultId $VaultToDelete.ID @@ -223,17 +228,27 @@ if ($ASRPolicyMappings -ne 0) { Write-Host $ASRPolicyMappings "ASR policy mappin if ($fabricCount -ne 0) { Write-Host $fabricCount "ASR Fabrics are still present in the vault. Remove the same for successful vault deletion." -ForegroundColor Red } if ($pvtendpointsFin.count -ne 0) { Write-Host $pvtendpointsFin.count "Private endpoints are still linked to the vault. Remove the same for successful vault deletion." -ForegroundColor Red } -$accesstoken = Get-AzAccessToken -AsSecureString -$token = $accesstoken.Token -$authHeader = @{ - 'Content-Type' = 'application/json' - 'Authorization' = 'Bearer ' + (ConvertFrom-SecureString $token -AsPlainText) -} -$restUri = "https://management.azure.com//subscriptions/" + $SubscriptionId + '/resourcegroups/' + $ResourceGroup + '/providers/Microsoft.RecoveryServices/vaults/' + $VaultName + '?api-version=2021-06-01&operation=DeleteVaultUsingPS' -$response = Invoke-RestMethod -Uri $restUri -Headers $authHeader -Method DELETE +if ($PSCmdlet.ShouldProcess($VaultName, "DELETE")) { + $RestMethodParameters = @{ + Method = 'DELETE' + SubscriptionId = $SubscriptionId + ResourceGroupName = $ResourceGroup + ResourceProviderName = 'Microsoft.RecoveryServices' + ResourceType = 'vaults' + Name = $VaultName + ApiVersion = '2024-04-01' + } -$VaultDeleted = Get-AzRecoveryServicesVault -Name $VaultName -ResourceGroupName $ResourceGroup -erroraction 'silentlycontinue' -if ($null -eq $VaultDeleted) { - Write-Host "Recovery Services Vault" $VaultName "successfully deleted" -} + $Response = Invoke-AzRestMethod @RestMethodParameters + + Write-Verbose "DELETE HTTP request returned status code: $($Response.StatusCode)" + $VaultDeleted = Get-AzRecoveryServicesVault -Name $VaultName -ResourceGroupName $ResourceGroup -ErrorAction 'SilentlyContinue' + + if ($null -eq $VaultDeleted) { + Write-Host "Recovery Services Vault '$VaultName' successfully deleted." + } + else { + Write-Error "Recovery Services Vault '$VaultName' was not successfully deleted. Status code: $($Response.StatusCode)." + } +} diff --git a/scripts/PowerShell/Scripts/Remove-Spoke.ps1 b/scripts/PowerShell/Scripts/Remove-Spoke.ps1 new file mode 100644 index 0000000..385964a --- /dev/null +++ b/scripts/PowerShell/Scripts/Remove-Spoke.ps1 @@ -0,0 +1,234 @@ +<# +.SYNOPSIS + Deletes all Azure resources for the specified research spoke. + +.PARAMETER TemplateParameterFile + The path to the template parameter file, in bicepparam format, that was used to create the spoke to be deleted. + +.PARAMETER TargetSubscriptionId + The subscription ID where the spoke was created. + +.PARAMETER CloudEnvironment + The Azure environment where the spoke was created. Default is 'AzureCloud'. + +.PARAMETER Tenant + The Azure tenant ID where the spoke was created. Default is the current tenant. + +.PARAMETER Force + DANGER: Forces the deletion of the spoke resources without prompting for confirmation. + +.EXAMPLE + PS> ./deploy.ps1 -TemplateParameterFile '.\main.hub.bicepparam' -TargetSubscriptionId '00000000-0000-0000-0000-000000000000' + +.EXAMPLE + PS> ./deploy.ps1 '.\main.hub.bicepparam' '00000000-0000-0000-0000-000000000000' + +.EXAMPLE + PS> ./deploy.ps1 '.\main.hub.bicepparam' '00000000-0000-0000-0000-000000000000' 'AzureUSGovernment' +#> + +#Requires -Modules Az.Resources, Az.RecoveryServices, Az.Network, Az.DataFactory +#Requires -PSEdition Core + +[CmdletBinding(SupportsShouldProcess = $true)] +param ( + [Parameter(Mandatory, Position = 0)] + [string]$TemplateParameterFile, + [Parameter(Mandatory, Position = 1)] + [string]$TargetSubscriptionId, + [Parameter(Position = 2)] + [string]$CloudEnvironment = 'AzureCloud', + [Parameter(Position = 3)] + [string]$Tenant = (Get-AzContext).Tenant.Id, + [Parameter()] + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +################################################################################ +# PREPARE VARIABLES +################################################################################ + +# Process the template parameter file and read relevant values for use here +Write-Verbose "Using template parameter file '$TemplateParameterFile'" +[string]$TemplateParameterJsonFile = [System.IO.Path]::ChangeExtension($TemplateParameterFile, 'json') +bicep build-params $TemplateParameterFile --outfile $TemplateParameterJsonFile + +# Read the values from the parameters file, to use when generating the $DeploymentName value +$ParameterFileContents = (Get-Content $TemplateParameterJsonFile | ConvertFrom-Json) +[string]$WorkloadName = $ParameterFileContents.parameters.workloadName.value +[string]$Location = $ParameterFileContents.parameters.location.value +[int]$Sequence = $ParameterFileContents.parameters.sequence.value +[string]$Environment = $ParameterFileContents.parameters.environment.value +[string]$NamingConvention = $ParameterFileContents.parameters.namingConvention.value +[string]$HubVirtualNetworkId = $ParameterFileContents.parameters.hubVNetResourceId.value + +# Taken from research-spoke/main.bicep +[string]$SequenceFormat = "00" + +# This here for DRY +# Replaces the placeholders {workloadName}, {location}, {env}, and {loc} with the actual values from the parameter file +[string]$IntermediateResourceNamePattern = $NamingConvention.Replace("{workloadName}", $WorkloadName).Replace("{location}", $Location).Replace("{env}", $Environment).Replace("{loc}", $Location) + +# Replace the {seq} placeholder by the formatted sequence number and the placeholder for Azure Backup's sequence +# Replace the resource type placeholder by the resource group type and subtype for backup +[string]$BackupResourceGroupNamePattern = $IntermediateResourceNamePattern.Replace("{seq}", "$($Sequence.ToString($SequenceFormat))-*").Replace("{rtype}", "rg-backup").Replace("-{subWorkloadName}", "") + +# Replace the {seq} placeholder, keeping the {subWorkloadName} placeholder +[string]$ResourceNamePatternSubWorkload = $IntermediateResourceNamePattern.Replace("{seq}", $Sequence.ToString($SequenceFormat)) +# Remove the {subWorkloadName} placeholder +[string]$ResourceNamePattern = $ResourceNamePatternSubWorkload.Replace("-{subWorkloadName}", "") +# Create a wildcard pattern for resource group names (resource type is "rg") +[string]$ResourceGroupNamePattern = $ResourceNamePattern.Replace("{rtype}", "rg-*") + +Write-Verbose "Looking for resource groups matching pattern '$ResourceGroupNamePattern'." + +try { + ################################################################################ + # SET AZURE CONTEXT AND CHECK RESOURCE EXISTENCE + ################################################################################ + + # Import the Azure subscription management module + Import-Module ..\Modules\AzSubscriptionManagement.psm1 + + $OriginalContext = Get-AzContext + # Determine if a cloud context switch is required + $AzContext = Set-AzContextWrapper -SubscriptionId $TargetSubscriptionId -Environment $CloudEnvironment -Tenant $Tenant + + # Check if any resource groups exist that match the pattern + $ResourceGroups = Get-AzResourceGroup -Name $ResourceGroupNamePattern + # Get a list of Azure Backup resource groups used for holding restore collections + $BackupResourceGroups = Get-AzResourceGroup -Name $BackupResourceGroupNamePattern + + if ($ResourceGroups.Count -eq 0) { + Write-Warning "No resource groups found matching pattern '$ResourceGroupNamePattern' in subscription '$((Get-AzContext).Subscription.Name)'." + exit + } + + $Msg1 = "Found $($ResourceGroups.Count) resource groups matching pattern '$ResourceGroupNamePattern' in subscription '$((Get-AzContext).Subscription.Name)'.`nFound $($BackupResourceGroups.Count) Azure Backup resource groups matching pattern '$BackupResourceGroupNamePattern'." + $Msg = "$Msg1`nAny resource locks will be deleted.`nThese actions cannot be undone and data loss might occur. Do you want to continue removing this spoke?" + + if (-not ($WhatIfPreference -or $Force -or $PSCmdlet.ShouldContinue($Msg, 'Confirm Spoke Removal'))) { + exit + } + + # If -WhatIf is used, output the number of resource groups found + if ($WhatIfPreference) { + Write-Host $Msg1 + } + + if ($Force) { + Write-Verbose "Force switch specified. Proceeding with deletion of resources." + } + + ################################################################################ + # REMOVE ANY RESOURCE LOCKS + ################################################################################ + + Write-Host "`n1️⃣: Removing resource locks..." + $ResourceGroups | ForEach-Object { + Get-AzResourceLock -ResourceGroupName $_.ResourceGroupName | Remove-AzResourceLock -Force | Out-Null + } + + ################################################################################ + # REMOVE THE RECOVERY SERVICES VAULT + ################################################################################ + + # Check if the expected Recovery Services Vault exists in the expected resource group + [string]$BackupResourceGroupName = $ResourceGroupNamePattern.Replace("*", "backup") + [string]$RecoveryServicesVaultName = $ResourceNamePattern.Replace("{rtype}", "rsv") + + $Vault = Get-AzRecoveryServicesVault -ResourceGroupName $BackupResourceGroupName -Name $RecoveryServicesVaultName -ErrorAction SilentlyContinue + + if ($Vault) { + Write-Host "`n2️⃣: Removing Recovery Services Vault '$RecoveryServicesVaultName' in resource group '$BackupResourceGroupName'..." + & ./Recovery/Remove-rsv.ps1 -VaultName $RecoveryServicesVaultName ` + -ResourceGroup $BackupResourceGroupName -SubscriptionId $TargetSubscriptionId ` + -WhatIf:$WhatIfPreference -Verbose:$VerbosePreference + } + + ################################################################################ + # REMOVE THE DATA FACTORY MANAGED PRIVATE ENDPOINTS + ################################################################################ + + [string]$StorageResourceGroupName = $ResourceGroupNamePattern.Replace('*', 'storage') + [string]$DataFactoryName = $ResourceNamePatternSubWorkload.Replace('{rtype}', 'adf').Replace('{subWorkloadName}', 'airlock') + + # Check if the expected Data Factory exists in the expected resource group + $Factory = Get-AzDataFactoryV2 -ResourceGroupName $StorageResourceGroupName -Name $DataFactoryName -ErrorAction SilentlyContinue + + if ($Factory) { + Write-Host "`n3️⃣: Removing managed private endpoints from Data Factory '$DataFactoryName' in resource group '$StorageResourceGroupName'..." + & ./DataFactory/Remove-ManagedPrivateEndpoints.ps1 -DataFactoryName $DataFactoryName ` + -ResourceGroup $StorageResourceGroupName -SubscriptionId $TargetSubscriptionId ` + -WhatIf:$WhatIfPreference -Verbose:$VerbosePreference + } + + ################################################################################ + # REMOVE THE RESOURCE GROUPS + ################################################################################ + + Write-Host "`n4️⃣: Removing resource groups..." + + # Two separate commands needed because -AsJob does not support specifying a variable + if ($PSCmdlet.ShouldProcess("spoke resource groups", "Remove")) { + $Jobs = @() + $Jobs += $ResourceGroups | Remove-AzResourceGroup -AsJob -Force -Verbose:$VerbosePreference + + # Remove any Azure Backup resource groups used for holding restore collections + $Jobs += $BackupResourceGroups | Remove-AzResourceGroup -AsJob -Force -Verbose:$VerbosePreference + + Write-Host "Waiting for $($Jobs.Count) resource groups to be deleted..." + $Jobs | Get-Job | Wait-Job | Select-Object -Property Id, StatusMessage, Name | Format-Table -AutoSize + } + else { + $ResourceGroups | Remove-AzResourceGroup -WhatIf | Out-Null + $BackupResourceGroups | Remove-AzResourceGroup -WhatIf | Out-Null + } + + ################################################################################ + # REMOVE THE DISCONNECTED PEERING FROM THE RESEARCH HUB VIRTUAL NETWORK + ################################################################################ + + [string]$VNetResourceIDPattern = "/subscriptions/(?[^/]+)/resourceGroups/(?[^/]+)/providers/Microsoft.Network/virtualNetworks/(?[^/]+)" + + # If there is a valid hub virtual network resource ID specified (there should be) + if ($HubVirtualNetworkId -match $VNetResourceIDPattern) { + [string]$HubSubscriptionId = $Matches['subscriptionId'] + [string]$HubResourceGroupName = $Matches['resourceGroupName'] + [string]$HubVirtualNetworkName = $Matches['resourceName'] + + # We could get the virtual network ID from the spoke resources, but it's possible that the virtual network was already deleted but the peering wasn't + [string]$SpokeVirtualNetworkName = $ResourceNamePattern.Replace("{rtype}", "vnet") + [string]$NetworkResourceGroupName = $ResourceGroupNamePattern.Replace('*', 'network') + [string]$SpokeVirtualNetworkResourceId = "/subscriptions/$TargetSubscriptionId/resourceGroups/$NetworkResourceGroupName/providers/Microsoft.Network/virtualNetworks/$SpokeVirtualNetworkName" + + Write-Host "`n5️⃣: Checking disconnected peering to spoke network '$SpokeVirtualNetworkName' from hub virtual network '$HubVirtualNetworkName' in resource group '$HubResourceGroupName' in subscription '$HubSubscriptionId'..." + + $AzContext = Set-AzContextWrapper -SubscriptionId $HubSubscriptionId -Environment $CloudEnvironment -Tenant $Tenant + + # Remove peering explicitly from hub + Get-AzVirtualNetworkPeering -ResourceGroupName $HubResourceGroupName -VirtualNetworkName $HubVirtualNetworkName | ` + # Find the peering using the peering state (to confirm it's disconnected) and the remote virtual network ID + Where-Object { $_.PeeringState -eq 'Disconnected' -and $_.RemoteVirtualNetwork.Id -eq $SpokeVirtualNetworkResourceId } | ` + Remove-AzVirtualNetworkPeering -Force -Verbose:$VerbosePreference + } + else { + Write-Warning "The value found in the parameter file for 'hubVNetResourceId' ('$HubVirtualNetworkId') is not a valid Azure virtual network resource ID." + } + + Write-Host "`n🔥 Script completed successfully!" +} +catch { + Write-Host "`n❌ An error occurred: $($_)" + Write-Host $_.ScriptStackTrace + Write-Host "In context $AzContext" +} +finally { + Write-Verbose "Setting Azure context back to the original subscription..." + $AzContext = Set-AzContextWrapper -SubscriptionId $OriginalContext.Subscription.Id -Environment $OriginalContext.Environment.Name -Tenant $OriginalContext.Tenant.Id + + # Remove the module from the session + Remove-Module AzSubscriptionManagement -WhatIf:$false +} \ No newline at end of file diff --git a/shared-modules/dns/allPrivateDnsZones.jsonc b/shared-modules/dns/allPrivateDnsZones.jsonc index e8a97d1..c91d03d 100644 --- a/shared-modules/dns/allPrivateDnsZones.jsonc +++ b/shared-modules/dns/allPrivateDnsZones.jsonc @@ -3,7 +3,9 @@ * Missing: * - SQL Managed Instance: requires knowing the DNS prefix of the SQL MI. * - Azure Batch, AKS, ACR, Recovery Services Vaults, Azure File Sync, ADX: requires knowing the region. + TODO: Add region placeholder for these zones and dynamically replace at runtime (per ALZ model). * - Static Web Apps: Requires knowing the partition ID. + TODO: See ALZ approach. */ { "AzureCloud": [ diff --git a/shared-modules/recovery/recoveryServicesVault.bicep b/shared-modules/recovery/recoveryServicesVault.bicep index e7d47c8..326bc59 100644 --- a/shared-modules/recovery/recoveryServicesVault.bicep +++ b/shared-modules/recovery/recoveryServicesVault.bicep @@ -190,6 +190,9 @@ resource backupConfig 'Microsoft.RecoveryServices/vaults/backupconfig@2024-04-01 } // Break up the naming convention on the sequence placeholder to use for the backup RG name +// The "n" in the backup resource group is another sequence number determined by Azure Backup +// The end result is that the backup resource group name will be contain "{seq}-n" where +// other resource group names will have "{seq} var processNamingConventionPlaceholders = replace( replace( replace(