Types.ps1
<## # Types.ps1 ## # Assumptions: # - AzureRM context is initialized # Design Considerations: # - Resources with globally unique names are given random IDs and should be accessed by resource group, not name # - Defining a class "Environment" will cause issues with System.Environment #> using namespace Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels using namespace Microsoft.Azure.Commands.Common.Authentication.Abstractions using namespace System.Collections # required for importing types Import-Module "AzureRm", "$PSScriptRoot\AzureBakery" class ClusterResourceGroup { # Indentity vector (path through the service tree) [string[]]$Identity # where region-agnostic resources are defined static [string] $DefaultRegion = "West US 2" # string preceding a random string on underlying resource names static [string] $DefaultResourcePrefix = "cluster" # name of the blob storage container containing service artifacts static [string] $ArtifactContainerName = "artifacts" # name of the blob storage container containing VM images static [string] $ImageContainerName = "images" # underlying property for lazily instantiating Azure Storage Contexts hidden [IStorageContext] $_StorageContext # create a ClusterResourceGroup model object from its resource group name ClusterResourceGroup([string] $resourceGroupName) { $this.Identity = $resourceGroupName -split "-" } # use the values encapsulated in this model object to provision Azure resources [void] Create() { if ($this.Exists()) { throw "Resource Group '$this' already exists" } # determine if this resource group has a speified region (for Environments and Clusters) $region = @{ $True = $this.Identity[2] $False = [ClusterResourceGroup]::DefaultRegion }[$this.Identity.Count -ge 3] # create and initialize the Azure resources New-AzureRmResourceGroup -Name $this -Location $region New-AzureRmStorageAccount ` -ResourceGroupName $this ` -Name ([ClusterResourceGroup]::NewResourceName()) ` -Location $region ` -Type "Standard_LRS" ` -EnableEncryptionService "blob" ` -EnableHttpsTrafficOnly $true New-AzureRmKeyVault ` -VaultName ([ClusterResourceGroup]::NewResourceName()) ` -ResourceGroupName $this ` -Location $region New-AzureStorageContainer ` -Context $this.GetStorageContext() ` -Name ([ClusterResourceGroup]::ArtifactContainerName) New-AzureStorageContainer ` -Context $this.GetStorageContext() ` -Name ([ClusterResourceGroup]::ImageContainerName) # if the resource group has a parent (isn't a 'Service'), propagate assets from parent to this $parentId = ($this.Identity | Select -SkipLast 1) -join "-" if ($parentId) { $parent = [ClusterResourceGroup]::new($parentId) $parent.PropagateArtifacts() $parent.PropagateImages() $parent.PropagateSecrets() } } # returns whether this model's Azure resources have been created [bool] Exists() { $resourceGroup = Get-AzureRmResourceGroup ` -ResourceGroupName $this ` -ErrorAction SilentlyContinue return $resourceGroup -as [bool] } # returns a model for each child service tree node that has been provisioned in Azure [ClusterResourceGroup[]] GetChildren() { $children = Get-AzureRmResourceGroup ` | % {$_.ResourceGroupName} ` | ? {$_ -match "^$this-[^-]+$"} ` | % {[ClusterResourceGroup]::new($_)} return @($children) } # returns a model for each descendant service tree node (not leaves/Clusters) that has been provisioned in Azure [ClusterResourceGroup[]] GetDescendantNodes() { $descendants = Get-AzureRmResourceGroup ` | % {$_.ResourceGroupName} ` | ? {$_ -like "$this*" -and ($_ -split '-').Count -le 3} ` | % {[ClusterResourceGroup]::new($_)} return @($descendants) } # lazily instantiates an Azure Storage Context for use with the Azure.Storage module [IStorageContext] GetStorageContext() { if (-not $this._StorageContext) { $storageAccount = Get-AzureRmStorageAccount -ResourceGroupName $this $this._StorageContext = $storageAccount.Context } return $this._StorageContext } # uses the AzureBakery nested module for creating a generalized Windows VHD containing the specified Windows Features [void] NewImage([string[]] $WindowsFeature) { New-BakedImage ` -StorageContext $this.GetStorageContext() ` -WindowsFeature $WindowsFeature ` -StorageContainer ([ClusterResourceGroup]::ImageContainerName) } # pushes Artifacts from this service tree node to its descendants [void] PropagateArtifacts() { $this.PropagateBlobs([ClusterResourceGroup]::ArtifactContainerName) } # pushes Images from this service tree node to its descendants [void] PropagateImages() { $this.PropagateBlobs([ClusterResourceGroup]::ImageContainerName) } # pushes Blobs in the specified container from this service tree node to its descendants [void] PropagateBlobs([string] $Container) { $descendants = $this.GetDescendantNodes() if (-not $descendants) { return } $descendantContexts = $descendants.GetStorageContext() $artifactNames = Get-AzureStorageBlob ` -Container $Container ` -Context $this.GetStorageContext() ` | % {$_.Name} # async start copying blobs $pendingBlobs = [ArrayList]::new() foreach ($descendantContext in $descendantContexts) { foreach ($artifactName in $artifactNames) { $descendantBlob = Get-AzureStorageBlob ` -Context $descendantContext ` -Container $Container ` -Blob $artifactName ` -ErrorAction SilentlyContinue if (-not $descendantBlob) { $descendantBlob = Start-AzureStorageBlobCopy ` -Context $this.GetStorageContext() ` -DestContext $descendantContext ` -SrcContainer $Container ` -DestContainer $Container ` -SrcBlob $artifactName ` -DestBlob $artifactName $pendingBlobs.Add($descendantBlob) } } } # block until all copies are complete foreach ($blob in $pendingBlobs) { Get-AzureStorageBlobCopyState ` -Context $blob.Context ` -Container $Container ` -Blob $blob.Name ` -WaitForComplete } } # pushes Azure Key Vault Secrets from this service tree node to its descendants [void] PropagateSecrets() { $descendants = $this.GetDescendantNodes() if (-not $descendants) { return } $keyVaultName = (Get-AzureRmKeyVault -ResourceGroupName $this).VaultName $descendantKeyVaultNames = $descendants ` | % {Get-AzureRmKeyVault -ResourceGroupName $_} ` | % {$_.VaultName} $secretNames = (Get-AzureKeyVaultSecret -VaultName $keyVaultName).Name foreach ($childKeyVaultName in $descendantKeyVaultNames) { foreach ($secretName in $secretNames) { $secret = Get-AzureKeyVaultSecret ` -VaultName $keyVaultName ` -Name $secretName Set-AzureKeyVaultSecret ` -VaultName $childKeyVaultName ` -Name $secretName ` -SecretValue $secret.SecretValue ` -ContentType $secret.Attributes.ContentType } } } # returns this model's associated resource group name [string] ToString() { return $this.Identity -join "-" } # determines the service tree node type (discouraged as it inherently breaks linting) [Reflection.TypeInfo] InferType() { switch ($this.Identity.Count) { 1 {return [ClusterService]} 2 {return [ClusterFlightingRing]} 3 {return [ClusterEnvironment]} 4 {return [Cluster]} } throw "Cannot infer type of '$this'" return [void] # return value to not break linting } # uploads an Artifact (file required for the VM/Container/etc to initialize) to the service tree node [void] UploadArtifact([string] $ArtifactPath) { Set-AzureStorageBlobContent ` -File $ArtifactPath ` -Container ([ClusterResourceGroup]::ArtifactContainerName) ` -Blob (Split-Path -Path $ArtifactPath -Leaf) ` -Context $this.GetStorageContext() ` -Force } # creates a base36 GUID with a prefix and valid length for creating globally unique Azure resource names static [string] NewResourceName() { $Length = 24 $allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789" $chars = 1..($Length - [ClusterResourceGroup]::DefaultResourcePrefix.Length) ` | % {Get-Random -Maximum $allowedChars.Length} ` | % {$allowedChars[$_]} return [ClusterResourceGroup]::DefaultResourcePrefix + ($chars -join '') } } class ClusterService : ClusterResourceGroup { [ValidatePattern("^[A-Z][A-z0-9]+$")] [string]$Service ClusterService([string] $resourceGroupName) : base($resourceGroupName) { $this.Service = $this.Identity } } class ClusterFlightingRing : ClusterResourceGroup { [ValidateNotNullOrEmpty()] [ClusterService]$Service [ValidatePattern("^[A-Z]{3,6}$")] [string]$FlightingRing ClusterFlightingRing([string] $resourceGroupName) : base($resourceGroupName) { $this.Service = [ClusterService]::new($this.Identity[0]) $this.FlightingRing = $this.Identity | Select -Last 1 } } class ClusterEnvironment : ClusterResourceGroup { [ValidateNotNullOrEmpty()] [ClusterFlightingRing]$FlightingRing [ValidatePattern("^[A-z][A-z0-9 ]+$")] [string]$Region static [int] $TTL = 5 static [string] $MonitorPath = "/" ClusterEnvironment([string] $resourceGroupName) : base($resourceGroupName) { $this.FlightingRing = [ClusterFlightingRing]::new($this.Identity[0..1] -join "-") $this.Region = $this.Identity | Select -Last 1 } # creates a Cluster Azure resource group group that is a child of this model and returns the Cluster's model [Cluster] NewChildCluster() { $indexes = ($this.GetChildren() | % {[Cluster]::new($_)}).Index # get currently used indexes for ($index = 0; $index -in $indexes; $index++) {} # determine lowest available index $cluster = [Cluster]::new("$this-$index") # create a Cluster model with the index $cluster.Create() # create the Azure resources from the Cluster model return $cluster # return the Cluster model } [void] Create() { ([ClusterResourceGroup]$this).Create() New-AzureRmTrafficManagerProfile ` -Name "Profile" ` -ResourceGroupName $this ` -TrafficRoutingMethod Weighted ` -RelativeDnsName ([ClusterResourceGroup]::NewResourceName()) ` -Ttl ([ClusterEnvironment]::TTL) ` -MonitorProtocol HTTP ` -MonitorPort 80 ` -MonitorPath ([ClusterEnvironment]::MonitorPath) } } class Cluster : ClusterResourceGroup { [ValidateNotNullOrEmpty()] [ClusterEnvironment]$Environment [ValidateRange(0, 255)] [int]$Index Cluster([string] $resourceGroupName) : base($resourceGroupName) { $this.Environment = [ClusterEnvironment]::new($this.Identity[0..2] -join "-") $this.Index = $this.Identity | Select -Last 1 } # create a service tree node resource group and resources, with additional Clsuter-specific resources [void] Create() { New-AzureRmResourceGroup -Name $this -Location $this.Environment.Region New-AzureRmStorageAccount ` -ResourceGroupName $this ` -Name ([ClusterResourceGroup]::NewResourceName()) ` -Location $this.Environment.Region ` -Type "Standard_LRS" ` -EnableEncryptionService "blob" ` -EnableHttpsTrafficOnly $true New-AzureStorageContainer ` -Context $this.GetStorageContext() ` -Name "configuration" New-AzureStorageContainer ` -Context $this.GetStorageContext() ` -Name "disks" } # uses the Cluster configuration inheritence model (see README) to identify the most specific config with the specified extension [string] GetConfig([string]$DefinitionsContainer, [string]$FileExtension) { ($service, $flightingRing, $region, $index) = $this.Identity $config = $service, "Default" ` | % {"$_.$flightingRing.$region", "$_.$flightingRing", $_} ` | % {"$DefinitionsContainer\$_.$FileExtension"} ` | ? {Test-Path $_} ` | Select -First 1 return $config } [PSResourceGroupDeployment] PublishConfiguration([string]$DefinitionsContainer, [datetime]$Expiry) { $context = $this.GetStorageContext() # build url components $vhdContainer = "$($context.BlobEndpoint)disks/" $configurationSasToken = New-AzureStorageContainerSASToken ` -Context $context ` -Container "configuration" ` -Permission "r" ` -ExpiryTime $expiry # template deployment parameters $deploymentParams = @{ ResourceGroupName = $this TemplateFile = $this.GetConfig($DefinitionsContainer, "template.json") Environment = $this.Environment VhdContainer = $vhdContainer SasToken = $configurationSasToken } # package and upload DSC $dscFile = $this.GetConfig($DefinitionsContainer, "dsc.ps1") if ($dscFile) { $publishDscParams = @{ ConfigurationPath = $dscFile OutputArchivePath = "$env:TEMP\dsc.zip" Force = $true } $dscConfigDataFile = $this.GetConfig($DefinitionsContainer, "dsc.psd1") if ($dscConfigDataFile) { $publishDscParams["ConfigurationDataPath"] = $dscConfigDataFile } Publish-AzureRmVMDscConfiguration @publishDscParams Set-AzureStorageBlobContent ` -File "$env:TEMP\dsc.zip" ` -Container "configuration" ` -Blob "dsc.zip" ` -Context $context ` -Force $deploymentParams["DscUrl"] = "$($context.BlobEndpoint)configuration/dsc.zip" $deploymentParams["DscFileName"] = Split-Path -Path $dscFile -Leaf $deploymentParams["DscHash"] = (Get-FileHash "$env:TEMP\dsc.zip").Hash.Substring(0, 50) } # package and upload CSE $cseFile = $this.GetConfig($DefinitionsContainer, "cse.ps1") if ($cseFile) { Set-AzureStorageBlobContent ` -File $cseFile ` -Container "configuration" ` -Blob "cse.ps1" ` -Context $context ` -Force $deploymentParams["CseUrl"] = "$($context.BlobEndPoint)configuration/cse.ps1" } # template parameters $templateParameterFile = $this.GetConfig($DefinitionsContainer, "parameters.json") if ($templateParameterFile) { $deploymentParams["TemplateParameterFile"] = $templateParameterFile } # freeform json passed to the DSC $configJsonFile = $this.GetConfig($DefinitionsContainer, "config.json") if ($configJsonFile) { $deploymentParams["ConfigJson"] = Get-Content $configJsonFile -Raw } # baked Windows Image URL (from parent Environment) $environmentContext = $this.Environment.GetStorageContext() $images = Get-AzureStorageBlob ` -Context $environmentContext ` -Container ([ClusterResourceGroup]::ImageContainerName) if ($images) { $imageName = $images | Sort LastModified -Descending | Select -First 1 | % Name $deploymentParams["ImageUrl"] = "$($environmentContext.BlobEndPoint)images/$imageName" } # deploy template $deploymentErrors = $null # redundantly define for linting $deployment = New-AzureRmResourceGroupDeployment ` -Name ((Get-Date -Format "s") -replace "[^\d]") ` @deploymentParams ` -Verbose ` -ErrorVariable deploymentErrors ` -Force if ($deploymentErrors) { throw $deploymentErrors } return $deployment } } |