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"

# where region-agnostic resources are defined
$DefaultRegion = "West US 2"

# string preceding a random string on underlying resource names
$DefaultResourcePrefix = "cluster"

# name of the blob storage container containing service artifacts
$ArtifactContainerName = "artifacts"

# name of the blob storage container containing VM images
$ImageContainerName = "images"





<#
.SYNOPSIS
Writes formatted execution status messages to the Information stream

.DESCRIPTION
Prepends message lines with execution information and timestamp

.PARAMETER Message
The message(s) logged to the Information stream. Objects are serialized before writing.

.EXAMPLE
"Hello", "World" | Write-Log

#>

function Write-Log {
    Param(
        [Parameter(ValueFromPipeline)]
        $Message
    )

    # create the values used to prefix log lines
    begin {
        # Remove unhelpful calls in the callstack
        $stack = Get-PSCallStack | % {$_.Command} | ? {$_ -notin ("<ScriptBlock>", "Write-Log")}
        if ($stack) {
            [array]::reverse($stack)
            $stack = " | $($stack -join " > ")"
        }
        $timestamp = Get-Date -Format "T"
    }

    # write the input $Message with formatting to the Information stream
    process {
        $Message = ($Message | Format-List | Out-String) -split "[\r\n]+" | ? {$_}
        $Message | % {Write-Information "[$timestamp$stack] $_" -InformationAction Continue}
    }

}






class ClusterResourceGroup {

    # Indentity vector (path through the service tree)
    [string[]]$Identity


    # 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 = $script: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 $script:ArtifactContainerName
        New-AzureStorageContainer `
            -Context $this.GetStorageContext() `
            -Name $script: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 $script:ImageContainerName
    }


    # pushes Artifacts from this service tree node to its descendants
    [void] PropagateArtifacts() {
        $this.PropagateBlobs($script:ArtifactContainerName)
    }

    # pushes Images from this service tree node to its descendants
    [void] PropagateImages() {
        $this.PropagateBlobs($script: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 $script: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 - $script:DefaultResourcePrefix.Length) `
            | % {Get-Random -Maximum $allowedChars.Length} `
            | % {$allowedChars[$_]}
        return $script: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 $script: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
    }

}