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"




    # 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 that has been provisioned in Azure
    [ClusterResourceGroup[]] GetDescendants() {
        $children = $this.GetChildren()
        return $children + ($children | % {$_.GetDescendants()})
    }

    # lazily instantiates an Azure Storage Context for use with the Azure.Storage module
    [IStorageContext]$_StorageContext
    [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) {
        $children = $this.GetDescendants()
        if (-not $children) {
            return
        }
        $childContexts = $children.GetStorageContext()
        $artifactNames = Get-AzureStorageBlob `
            -Container $Container `
            -Context $this.GetStorageContext() `
            | % {$_.Name}
        
        # async start copying blobs
        $pendingBlobs = [ArrayList]::new()
        foreach ($childContext in $childContexts) {
            foreach ($artifactName in $artifactNames) {
                $childBlob = Get-AzureStorageBlob `
                    -Context $childContext `
                    -Container $Container `
                    -Blob $artifactName `
                    -ErrorAction SilentlyContinue
                if (-not $childBlob) {
                    $childBlob = Start-AzureStorageBlobCopy `
                        -Context $this.GetStorageContext() `
                        -DestContext $childContext `
                        -SrcContainer $Container `
                        -DestContainer $Container `
                        -SrcBlob $artifactName `
                        -DestBlob $artifactName
                    $pendingBlobs.Add($childBlob)
                }
            }
        }

        # block until all copies are complete
        foreach ($blob in $pendingBlobs) {
            Get-AzureStorageBlobCopyState `
                -Context $blob.Context `
                -Container $Container `
                -Blob $blob.Name `
                -WaitForComplete
        }

        # push from the children node to their children
        $children.PropagateBlobs($Container)
    }


    # pushes Azure Key Vault Secrets from this service tree node to its descendants
    [void] PropagateSecrets() {
        $children = $this.GetChildren()
        if (-not $children) { 
            return
        }
        $keyVaultName = (Get-AzureRmKeyVault -ResourceGroupName $this).VaultName
        $childKeyVaultNames = $children `
            | % {Get-AzureRmKeyVault -ResourceGroupName $_} `
            | % {$_.VaultName}
        $secretNames = (Get-AzureKeyVaultSecret -VaultName $keyVaultName).Name
        foreach ($childKeyVaultName in $childKeyVaultNames) {
            foreach ($secretName in $secretNames) {
                $secret = Get-AzureKeyVaultSecret `
                    -VaultName $keyVaultName `
                    -Name $secretName
                Set-AzureKeyVaultSecret `
                    -VaultName $childKeyVaultName `
                    -Name $secretName `
                    -SecretValue $secret.SecretValue `
                    -ContentType $secret.Attributes.ContentType
            }
        }
        $children.PropagateSecrets()
    }


    # 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 (module declared) 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

    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
    }

}





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() {
        ([ClusterResourceGroup]$this).Create()
        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/"
        $sasToken = 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          = $sasToken
        }

        # 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
        $images = Get-AzureStorageBlob `
            -Context $this.GetStorageContext() `
            -Container ([ClusterResourceGroup]::ImageContainerName)
        if ($images) {
            $imageName = $images | Sort LastModified -Descending | Select -First 1 | % Name
            $sasToken = New-AzureStorageContainerSASToken `
                -Context $context `
                -Container "images" `
                -Permission "r" `
                -ExpiryTime $expiry
            $deploymentParams["ImageUrl"] = "$($context.BlobEndPoint)images/$imageName$sasToken"
        }

        # deploy template
        return New-AzureRmResourceGroupDeployment `
            -Name ((Get-Date -Format "s") -replace "[^\d]") `
            @deploymentParams `
            -Verbose `
            -Force
    }

}