AutomatedLab.Recipe.psm1

# Idea from http://stackoverflow.com/questions/7468707/deep-copy-a-dictionary-hashtable-in-powershell
function Get-ClonedObject
{
    [CmdletBinding()]
    param
    (
        [object]
        $DeepCopyObject
    )
    $memStream = New-Object -TypeName IO.MemoryStream
    $formatter = New-Object -TypeName Runtime.Serialization.Formatters.Binary.BinaryFormatter
    $formatter.Serialize($memStream, $DeepCopyObject)
    $memStream.Position = 0
    $formatter.Deserialize($memStream)
}


# From https://stackoverflow.com/a/13350764
function Get-TopologicalSort
{
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [hashtable]
        $EdgeList
    )
  
    # Make sure we can use HashSet
    Add-Type -AssemblyName System.Core
  
    # Clone it so as to not alter original
    $currentEdgeList = [hashtable] (Get-ClonedObject $edgeList)
  
    # algorithm from http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
    $topologicallySortedElements = New-Object System.Collections.ArrayList
    $setOfAllNodesWithNoIncomingEdges = New-Object System.Collections.Queue
  
    $fasterEdgeList = @{}
  
    # Keep track of all nodes in case they put it in as an edge destination but not source
    $allNodes = New-Object -TypeName System.Collections.Generic.HashSet[object] -ArgumentList (, [object[]] $currentEdgeList.Keys)
  
    foreach ($currentNode in $currentEdgeList.Keys)
    {
        $currentDestinationNodes = [array] $currentEdgeList[$currentNode]
        if ($currentDestinationNodes.Length -eq 0)
        {
            $setOfAllNodesWithNoIncomingEdges.Enqueue($currentNode)
        }
  
        foreach ($currentDestinationNode in $currentDestinationNodes)
        {
            if (!$allNodes.Contains($currentDestinationNode))
            {
                [void] $allNodes.Add($currentDestinationNode)
            }
        }
  
        # Take this time to convert them to a HashSet for faster operation
        $currentDestinationNodes = New-Object -TypeName System.Collections.Generic.HashSet[object] -ArgumentList (, [object[]] $currentDestinationNodes )
        [void] $fasterEdgeList.Add($currentNode, $currentDestinationNodes)        
    }
  
    # Now let's reconcile by adding empty dependencies for source nodes they didn't tell us about
    foreach ($currentNode in $allNodes)
    {
        if (!$currentEdgeList.ContainsKey($currentNode))
        {
            [void] $currentEdgeList.Add($currentNode, (New-Object -TypeName System.Collections.Generic.HashSet[object]))
            $setOfAllNodesWithNoIncomingEdges.Enqueue($currentNode)
        }
    }
  
    $currentEdgeList = $fasterEdgeList
  
    while ($setOfAllNodesWithNoIncomingEdges.Count -gt 0)
    {        
        $currentNode = $setOfAllNodesWithNoIncomingEdges.Dequeue()
        [void] $currentEdgeList.Remove($currentNode)
        [void] $topologicallySortedElements.Add($currentNode)
  
        foreach ($currentEdgeSourceNode in $currentEdgeList.Keys)
        {
            $currentNodeDestinations = $currentEdgeList[$currentEdgeSourceNode]
            if ($currentNodeDestinations.Contains($currentNode))
            {
                [void] $currentNodeDestinations.Remove($currentNode)
  
                if ($currentNodeDestinations.Count -eq 0)
                {
                    [void] $setOfAllNodesWithNoIncomingEdges.Enqueue($currentEdgeSourceNode)
                }                
            }
        }
    }
  
    if ($currentEdgeList.Count -gt 0)
    {
        throw "Graph has at least one cycle!"
    }
  
    return $topologicallySortedElements
}


function Export-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [string[]]
        $DependsOn,

        [switch]
        $MetaData
    )

    process
    {
        $schnippet = Get-LabSnippet -Name $Name
        $type = $schnippet.Name.Split('.')[1]
        $useAzure = Get-PSFConfigValue -FullName AutomatedLab.Recipe.UseAzureBlobStorage
        $location = Get-PSFConfigValue -FullName AutomatedLab.Recipe.SnippetStore
        $filePath = Join-Path -Path $location -ChildPath "$($schnippet.Name).ps1"
        $metaPath = Join-Path -Path $location -ChildPath "$($schnippet.Name).psd1"
        

        if ($useAzure)
        {
            if (-not (Test-LabAzureModuleAvailability))
            {                
                Write-ScreenInfo -Type Error -Message "Az.Storage is missing. To use Azure, try Install-LabAzureRequiredModule"
                return
            }

            if (-not (Get-AzContext))
            {
                Write-ScreenInfo -Type Error -Message "No Azure context. Please follow the on-screen instructions to log in."
                $null = Connect-AzAccount -UseDeviceAuthentication -WarningAction Continue
            }

            $account = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.AccountName
            $rg = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.ResourceGroupName
            $container = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.ContainerName
            
            if (-not $account -or -not $rg -or -not $container)
            {
                Write-ScreenInfo -Type Error -Message "Unable to upload to storage account, parameters missing. You provided AzureBlobStorage.AccountName as '$account', AzureBlobStorage.ResourceGroupName as '$rg' and AzureBlobStorage.ContainerName as '$container'"
                return
            }
            
            $ctx = (Get-AzStorageAccount -ResourceGroupName $rg -Name $account -ErrorAction SilentlyContinue).Context

            if (-not $ctx)
            {
                Write-ScreenInfo -Type Error -Message "Unable to establish storage context with account $account. Does it exist?"
                return
            }

            
            if (-not (Get-AzStorageContainer -Name $container -Context $ctx))
            {
                $null = New-AzStorageContainer -Name $container -Context $ctx
            }
        }

        if (-not $useAzure -and -not (Test-Path -Path $location))
        {
            $null = New-Item -Path $location -ItemType Directory -Force
        }

        if (-not $useAzure -and -not $MetaData.IsPresent)
        {
            Set-Content -Path $filePath -Value $schnippet.ScriptBlock.ToString() -Encoding Unicode -Force
        }
        
        if ($useAzure -and -not $MetaData.IsPresent)
        {
            $tmpFile = New-TemporaryFile
            Set-Content -Path $tmpFile.FullName -Value $schnippet.ScriptBlock.ToString() -Encoding Unicode -Force
            $null = Set-AzStorageBlobContent -File $tmpFile.FullName -Container $container -Blob "$($type)/$($schnippet.Name).ps1" -Context $ctx
            $tmpFile | Remove-Item
        }

        $metaContent = @"
@{
    Name = '$Name'
    Type = '$Type'
    Tag = @(
        $(($Tag | ForEach-Object {"'$_'"}) -join ",")
    )
    DependsOn = @(
        $(($DependsOn | ForEach-Object {"'$_'"}) -join ",")
    )
    Description = '$($Description.Replace("'", "''"))'
}
"@
 

        if ($useAzure)
        {
            $tmpFile = New-TemporaryFile
            Set-Content -Path $tmpFile.FullName -Value $metaContent -Encoding Unicode -Force
            $null = Set-AzStorageBlobContent -File $tmpFile.FullName -Container $container -Blob "$($type)/$($schnippet.Name).psd1" -Context (Get-AzStorageAccount -ResourceGroupName $rg -Name $account).Context
            $tmpFile | Remove-Item
        }
        else
        {
            $metaContent | Set-Content -Path $metaPath -Force
        }
    }
}

function Get-LabRecipe
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [string[]]
        $Name,

        [Parameter()]
        [scriptblock]
        $RecipeContent
    )

    if ($RecipeContent)
    {
        if ($Name.Count -gt 1)
        {
            Write-PSFMessage -Level Warning -Message "Provided more than one name when using RecipeContent. Ignoring every value but the first."
        }

        $newScript = "[hashtable]@{$($RecipeContent.ToString())}"
        $newScriptBlock = [scriptblock]::Create($newScript)
        $table = & $newScriptBlock
        $mandatoryKeys = @(
            'Name'
            'DeployRole'
        )
        $allowedKeys = @(
            'Name'
            'Description'
            'RequiredProductIsos'
            'DeployRole'
            'DefaultVirtualizationEngine'
            'DefaultDomainName'
            'DefaultAddressSpace'
            'DefaultOperatingSystem'
            'VmPrefix'
        )

        $table.Name = $Name[0]
        $allowedKeys.ForEach({if (-not $table.ContainsKey($_)){$table.Add($_, $null)}})
        [bool] $shouldAlsoDeploySql = ($table.DeployRole -match 'CI_CD|DSCPull').Count -gt 0
        [bool] $shouldAlsoDeployDomain = ($table.DeployRole -match 'Exchange|PKI|DSCPull').Count -gt 0
        [bool] $shouldAlsoDeployPki = ($table.DeployRole -match 'CI_CD|DSCPull').Count -gt 0

        [string[]]$roles = $table.DeployRole.Clone()
        if ($shouldAlsoDeploySql -and $table.DeployRole -notcontains 'SQL') {$roles += 'SQL'}
        if ($shouldAlsoDeployDomain -and $table.DeployRole -notcontains 'Domain') {$roles += 'Domain'}
        if ($shouldAlsoDeployPki -and $table.DeployRole -notcontains 'PKI') {$roles += 'PKI'}
        $table.DeployRole = $roles

        $test = Test-HashtableKeys -Hashtable $table -ValidKeys $allowedKeys -MandatoryKeys $mandatoryKeys -Quiet

        if (-not $test) {}

        return ([pscustomobject]$table)
    }

    $recipePath = Join-Path -Path $HOME -ChildPath 'automatedLab\recipes'
    if (-not (Test-Path -Path $recipePath))
    {
        $null = New-Item -ItemType Directory -Path $recipePath
    }

    $recipes = Get-ChildItem -Path $recipePath

    if ($Name)
    {
        $recipes = $recipes | Where-Object -Property BaseName -in $Name
    }

    foreach ($recipe in $recipes)
    {
        $recipe | Get-Content -Raw | ConvertFrom-Json
    }
}


function Get-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]]
        $Name = '*',

        [string]
        $Description,

        [ValidateSet('Sample', 'Snippet', 'CustomRole')]
        [string]
        $Type = '*',

        [string[]]
        $Tag,

        [switch]
        $Syntax
    )

    process
    {
        foreach ($snippetName in $Name)
        {
            $scriptblockName = 'AutomatedLab.{0}.{1}' -f $Type, $snippetName

            $block = Get-PSFScriptblock -Name $scriptblockName -Description $Description -Tag $Tag
            $parameters = $block.ScriptBlock.Ast.ParamBlock.Parameters.Name.VariablePath.UserPath
            if ($parameters)
            {
                $block | Add-Member -NotePropertyName Parameters -NotePropertyValue $parameters -Force
            }

            if ($Syntax -and $block)
            {
                foreach ($blk in $block)
                {
                    $flatName = $blk.Name -replace '^AutomatedLab\..*\.'
                    "Invoke-LabSnippet -Name $($flatName) -LabParameter @{`r`n`0`0`0`0$($blk.Parameters -join `"='value'`r`n`0`0`0`0")='value'`r`n}`r`n"
                }
                continue
            }

            $block
        }
    }
}


function Invoke-LabRecipe
{
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline, ParameterSetName = 'ByName')]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByRecipe')]
        [object]
        $Recipe,

        [Parameter()]
        [ValidateSet('HyperV', 'Azure', 'VMWare')]
        [string]
        $DefaultVirtualizationEngine,

        [Parameter()]
        [pscredential]
        $LabCredential,

        [Parameter()]
        [AutomatedLab.OperatingSystem]
        $DefaultOperatingSystem,

        [Parameter()]
        [AutomatedLab.IpNetwork]
        $DefaultAddressSpace,

        [Parameter()]
        [string]
        $DefaultDomainName,

        [Parameter()]
        [string]
        $OutFile,

        [Parameter()]
        [switch]
        $PassThru,

        [Parameter()]
        [switch]
        $NoDeploy
    )

    process
    {
        if ($Name)
        {
            $Recipe = Get-LabRecipe -Name $Name
        }

        $Recipe.DefaultVirtualizationEngine = if ($DefaultVirtualizationEngine) {$DefaultVirtualizationEngine} elseif ($null -ne $Recipe.DefaultVirtualizationEngine) {$Recipe.DefaultVirtualizationEngine} else {'HyperV'}
        $Recipe.DefaultDomainName = if ($DefaultDomainName) {$DefaultDomainName} elseif ($null -ne $Recipe.DefaultDomainName) {$Recipe.DefaultDomainName} else {'contoso.com'}
        $Recipe.DefaultAddressSpace = if ($DefaultAddressSpace) {$DefaultAddressSpace.ToString()} elseif ($null -ne $Recipe.DefaultAddressSpace) {$Recipe.DefaultAddressSpace} else {(Get-LabAvailableAddresseSpace).ToString()}
        $Recipe.DefaultOperatingSystem = if ($DefaultOperatingSystem) {$DefaultOperatingSystem.OperatingSystemName} elseif ($null -ne $Recipe.DefaultOperatingSystem) {$Recipe.DefaultOperatingSystem} else {'Windows Server 2016 Datacenter'}
        $Recipe.VmPrefix = if ($VmPrefix) {$VmPrefix} elseif ($null -ne $Recipe.VmPrefix) {$Recipe.VmPrefix} else {(1..4 | ForEach-Object { [char[]](65..90) | Get-Random }) -join ''}

        $scriptContent = [System.Text.StringBuilder]::new()
        $null = $scriptContent.AppendLine("New-LabDefinition -Name $($Recipe.Name) -DefaultVirtualizationEngine $($Recipe.DefaultVirtualizationEngine)")
        $null = $scriptContent.AppendLine("Add-LabVirtualNetworkDefinition -Name $($Recipe.Name) -AddressSpace $($Recipe.DefaultAddressSpace)")
        $null = $scriptContent.AppendLine("`$PSDefaultParameterValues.Clear()")
        $null = $scriptContent.AppendLine("`$PSDefaultParameterValues.Add('Add-LabMachineDefinition:Network', '$($Recipe.Name)')")
        $null = $scriptContent.AppendLine("`$PSDefaultParameterValues.Add('Add-LabMachineDefinition:OperatingSystem', '$($Recipe.DefaultOperatingSystem)')")

        foreach ($requiredIso in $Recipe.RequiredProductIsos)
        {
            switch ($requiredIso)
            {
                'CI_CD' {$isoPattern = 'team_foundation'; $isoName = 'Tfs2017'}
                'SQL' {$isoPattern = 'sql_server_2017'; $isoName = 'SQLServer2017'}
            }

            $isoFile = Get-ChildItem -Path "$(Get-LabSourcesLocationInternal -Local)\ISOs\*$isoPattern*" | Sort-Object -Property CreationTime | Select-Object -Last 1 -ExpandProperty FullName
            if (-not $isoFile)
            {
                $isoFile = Read-Host -Prompt "Please provide the full path to an ISO for $isoName"
            }

            $null = $scriptContent.AppendLine("Add-LabIsoImageDefinition -Name $isoName -Path $isoFile")
        }

        if (-not $Credential)
        {
            $Credential = New-Object -TypeName pscredential -ArgumentList 'Install', ('Somepass1' | ConvertTo-SecureString -AsPlainText -Force)
        }

        $null = $scriptContent.AppendLine("Set-LabInstallationCredential -UserName $($Credential.UserName) -Password $($Credential.GetNetworkCredential().Password)")

        if ($Recipe.DeployRole -contains 'Domain' -or $Recipe.DeployRole -contains 'Exchange')
        {
            $null = $scriptContent.AppendLine("Add-LabDomainDefinition -Name $($Recipe.DefaultDomainName) -AdminUser $($Credential.UserName) -AdminPassword $($Credential.GetNetworkCredential().Password)")
            $null = $scriptContent.AppendLine("`$PSDefaultParameterValues.Add('Add-LabMachineDefinition:DomainName', '$($Recipe.DefaultDomainName)')")
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)DC1 -Roles RootDC")
        }

        if ($Recipe.DeployRole -contains 'PKI')
        {
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)CA1 -Roles CARoot")
        }

        if ($Recipe.DeployRole -contains 'Exchange')
        {
            $null = $scriptContent.AppendLine('$role = Get-LabPostInstallationActivity -CustomRole Exchange2016')
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)EX1 -PostInstallationActivity `$role")
        }

        if ($Recipe.DeployRole -contains 'SQL' -or $Recipe.DeployRole -contains 'CI/CD')
        {
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)SQL1 -Roles SQLServer2017")
        }

        if ($Recipe.DeployRole -contains 'CI/CD')
        {
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)CICD1 -Roles Tfs2017")
        }

        if ($Recipe.DeployRole -contains 'DSCPull')
        {
            $engine = if ($Recipe.DefaultOperatingSystem -like '*2019*') {'sql'} else {'mdb'}
            $null = $scriptContent.AppendLine("`$role = Get-LabMachineRoleDefinition -Role DSCPullServer -Properties @{DoNotPushLocalModules = 'true'; DatabaseEngine = '$engine'; SqlServer = '$($Recipe.VmPrefix)SQL1'; DatabaseName = 'DSC' }")
            $null = $scriptContent.AppendLine("Add-LabMachineDefinition -Name $($Recipe.VmPrefix)PULL01 -Roles `$role")
        }

        $null = $scriptContent.AppendLine('Install-Lab')
        $null = $scriptContent.AppendLine('Show-LabDeploymentSummary -Detailed')
        $labBlock = [scriptblock]::Create($scriptContent.ToString())

        if ($OutFile)
        {
            $scriptContent.ToString() | Set-Content -Path $OutFile -Force -Encoding UTF8
        }

        if ($PassThru) {$labBlock}
        if ($NoDeploy) { return }

        if ($PSCmdlet.ShouldProcess($Recipe.Name, "Deploying lab"))
        {
            & $labBlock
        }
    }
}


function Invoke-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]]
        $Name,

        [hashtable]
        $LabParameter = @{}
    )

    begin
    {
        $scriptBlockOrder = @{}
        try { [AutomatedLab.LabTelemetry]::Instance.FunctionCalled($PSCmdlet.MyInvocation.InvocationName) } catch {}
    }

    process
    {
        foreach ($snip in $Name)
        {
            $snip = $snip -replace 'AutomatedLab\..*\.'
            $schnippet = Get-LabSnippet -Name $snip
            [string[]]$dependencies = ($schnippet.Tag | Where-Object { $_.StartsWith('DependsOn_') }) -replace 'DependsOn_'
            $scriptBlockOrder[($schnippet.Name -replace 'AutomatedLab\..*\.')] = $dependencies
        }
    }

    end
    {
        try
        {
            $order = Get-TopologicalSort -EdgeList $scriptBlockOrder -ErrorAction Stop
            Write-PSFMessage -Message "Calculated dependency graph: $($order -join ',')"
        }
        catch
        {
            Write-Error -ErrorRecord $_
            return
        }

        $snippets = Get-LabSnippet -Name $order
        if ($snippets.Count -ne $order.Count)
        {
            Write-PSFMessage -Level Error -Message "Missing dependencies in graph: $($order -join ',')"
        }

        foreach ($blockName in $order)
        {
            $schnippet = Get-LabSnippet -Name $blockName
            $block = $schnippet.ScriptBlock
            $clonedParam = $LabParameter.Clone()
            $commonParameters = [System.Management.Automation.Internal.CommonParameters].GetProperties().Name
            $commandParameterKeys = $schnippet.Parameters
            $parameterKeys = $clonedParam.Keys.GetEnumerator() | ForEach-Object { $_ }
            
            [string[]]$keysToRemove = if ($parameterKeys -and $commandParameterKeys)
            {
                Compare-Object -ReferenceObject $commandParameterKeys -DifferenceObject $parameterKeys |
                Select-Object -ExpandProperty InputObject
            }
            else
            {
                @()
            }

            $keysToRemove = $keysToRemove + $commonParameters | Select-Object -Unique #remove the common parameters
            
            foreach ($key in $keysToRemove)
            {
                $clonedParam.Remove($key)
            }

            . $block @clonedParam
        }
    }
}

function New-LabRecipe
{
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        # Name of the lab and recipe
        [Parameter(Mandatory)]
        [string]
        $Name,

        # Description of lab
        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [string]
        $VmPrefix,

        # Roles this recipe deploys
        [Parameter(Mandatory)]
        [ValidateSet(
            'Domain',
            'PKI',
            'SQL',
            'Exchange',
            'CI_CD',
            'DSCPull'
        )]
        [string[]]
        $DeployRole,

        [Parameter()]
        [ValidateSet('HyperV', 'Azure', 'VMWare')]
        [string]
        $DefaultVirtualizationEngine,

        [Parameter()]
        [string]
        $DefaultDomainName,

        [Parameter()]
        [AutomatedLab.IPNetwork]
        $DefaultAddressSpace,

        [Parameter()]
        [AutomatedLab.OperatingSystem]
        $DefaultOperatingSystem,

        [switch]
        $Force,

        [switch]
        $PassThru
    )

    $labContent = @{
        Name                        = $Name
        Description                 = $Description
        RequiredProductIsos         = @()
        DeployRole                  = $DeployRole
        DefaultVirtualizationEngine = if ($DefaultVirtualizationEngine) {$DefaultVirtualizationEngine} else {'HyperV'}
        DefaultDomainName           = if ($DefaultDomainName) {$DefaultDomainName} else {'contoso.com'}
        DefaultAddressSpace         = if ($DefaultAddressSpace) {$DefaultAddressSpace.ToString()} else {'192.168.99.99/24'}
        DefaultOperatingSystem      = if ($DefaultOperatingSystem) {$DefaultOperatingSystem.OperatingSystemName} else {'Windows Server 2016 Datacenter'}
        VmPrefix                    = if ($VmPrefix) {$VmPrefix} else {(1..4 | ForEach-Object { [char[]](65..90) | Get-Random }) -join ''}
    }

    [bool] $shouldAlsoDeploySql = ($DeployRole -match 'CI_CD|DSCPull').Count -gt 0
    [bool] $shouldAlsoDeployDomain = ($DeployRole -match 'Exchange|PKI|DSCPull').Count -gt 0
    [bool] $shouldAlsoDeployPki = ($DeployRole -match 'CI_CD|DSCPull').Count -gt 0

    $roles = $DeployRole.Clone()
    if ($shouldAlsoDeploySql -and $DeployRole -notcontains 'SQL') {$roles += 'SQL'}
    if ($shouldAlsoDeployDomain -and $DeployRole -notcontains 'Domain') {$roles += 'Domain'}
    if ($shouldAlsoDeployPki -and $DeployRole -notcontains 'PKI') {$roles += 'PKI'}
    $labContent.DeployRole = $roles

    foreach ($role in $roles)
    {
        if ($role -notin 'Domain', 'PKI', 'DscPull')
        {
            $labContent.RequiredProductIsos += $role
        }
    }

    if (-not (Test-Path -Path (Join-Path -Path $HOME -ChildPath 'automatedlab\recipes')))
    {
        $null = New-Item -ItemType Directory -Path (Join-Path -Path $HOME -ChildPath 'automatedlab\recipes')
    }
    $recipeFileName = Join-Path -Path $HOME -ChildPath "automatedLab\recipes\$Name.json"

    if ((Test-Path -Path $recipeFileName) -and -not $Force.IsPresent)
    {
        Write-PSFMessage -Level Warning -Message "$recipeFileName exists and -Force was not used. Not storing recipe."
        return
    }

    if ($PSCmdlet.ShouldProcess($recipeFileName, 'Storing recipe'))
    {
        $labContent | ConvertTo-Json | Set-Content -Path $recipeFileName -NoNewline -Force
    }

    if ($PassThru) {$labContent}
}


function New-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [string]
        $Name,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Description,

        [Parameter(Mandatory)]
        [ValidateSet('Sample', 'Snippet', 'CustomRole')]
        [string]
        $Type,

        [string[]]
        $Tag,

        [Parameter(Mandatory)]
        [scriptblock]
        $ScriptBlock,

        [string[]]
        $DependsOn,

        [switch]
        $Force,

        [switch]
        $NoExport
    )

    $existingSnippet = Get-LabSnippet -Name $Name
    try { [AutomatedLab.LabTelemetry]::Instance.FunctionCalled($PSCmdlet.MyInvocation.InvocationName) } catch {}

    if ($existingSnippet -and -not $Force)
    {
        Write-PSFMessage -Level Error -Message "$Type $Name already exists. Use -Force to overwrite."
        return
    }

    foreach ($dependency in $DependsOn)
    {
        if ($Tag -notcontains "DependsOn_$($dependency)")
        {
            $Tag += "DependsOn_$($dependency)"
        }

        if (Get-LabSnippet -Name $dependency) { continue }
        Write-PSFMessage -Level Warning -Message "Snippet dependency $dependency has not been registered."
    }

    $scriptblockName = 'AutomatedLab.{0}.{1}' -f $Type, $Name
    Set-PSFScriptblock -ScriptBlock $ScriptBlock -Name $scriptblockName -Tag $Tag -Description $Description -Global

    if ($NoExport) { return }

    Export-LabSnippet -Name $Name -DependsOn $DependsOn
}


function Remove-LabRecipe
{
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline, ParameterSetName = 'ByName')]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByRecipe')]
        [System.Management.Automation.PSCustomObject]
        $Recipe
    )

    begin
    {
        $recipePath = Join-Path -Path $HOME -ChildPath 'automatedLab\recipes'
    }

    process
    {
        if (-not $Name)
        {
            $Name = $Recipe.Name
        }

        Get-ChildItem -File -Filter *.json -Path $recipePath | Where-Object -Property BaseName -eq $Name | Remove-Item
    }
}


function Remove-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]]
        $Name
    )

    process
    {
        foreach ($snip in $Name)
        {
            $snip = $snip -replace 'AutomatedLab\..*\.'
            $schnippet = Get-LabSnippet -Name $snip
            if (-not $schnippet)
            {
                Write-PSFMessage -Level Warning -Message "Snippet $snip not found"
                break
            }

            $location = Get-PSFConfigValue -FullName AutomatedLab.Recipe.SnippetStore
            $filePath = Join-Path -Path $location -ChildPath "$($schnippet.Name).ps1"
            $metaPath = Join-Path -Path $location -ChildPath "$($schnippet.Name).psd1"

            Remove-Item -Path $filePath, $metaPath -ErrorAction SilentlyContinue
        }
    }
}


function Save-LabRecipe
{
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline)]
        [pscustomobject]
        $Recipe
    )

    $recipeFileName = Join-Path -Path $HOME -ChildPath "automatedLab\recipes\$($Recipe.Name).json"

    if ($PSCmdlet.ShouldProcess($recipeFileName, 'Storing recipe'))
    {
        $Recipe | ConvertTo-Json | Set-Content -Path $recipeFileName -NoNewline -Force
    }
}


function Set-LabSnippet
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [string[]]
        $DependsOn,

        [ValidateSet('Sample','Snippet', 'CustomRole')]
        [string]
        $Type,

        [string[]]
        $Tag,

        [scriptblock]
        $ScriptBlock,

        [switch]
        $NoExport
    )

    process
    {
        $schnippet = Get-LabSnippet -Name $Name
        if (-not $schnippet)
        {
            Write-PSFMessage -Level Warning -Message "Snippet $Name not found"
            break
        }

        if (-not $Tag)
        {
            $Tag = $schnippet.Tag
        }

        foreach ($dependency in $DependsOn)
        {
            if ($Tag -contains "DependsOn_$($dependency)") { Continue }

            $Tag += "DependsOn_$($dependency)"
        }

        if (-not $Description)
        {
            $Description = $schnippet.Description
        }

        if (-not $ScriptBlock)
        {
            $ScriptBlock = $schnippet.ScriptBlock
        }

        Set-PSFScriptblock -Name $Name -Description $Description -Tag $Tag -Scriptblock $ScriptBlock -Global
        
        if ($NoExport) { return }

        Export-LabSnippet -Name $Name -DependsOn $DependsOn
    }
}

function Update-LabSnippet
{
    [CmdletBinding()]
    param ( )

    # Register all sample scripts
    $location = Join-Path -Path (Get-LabSourcesLocation -Local) -ChildPath 'SampleScripts'
    if (-not (Test-Path -Path $location)) { return }
    foreach ($samplescript in (Get-ChildItem -Recurse -Path $location -File -Filter *.ps1))
    {
        $sampleMeta = [IO.Path]::ChangeExtension($samplescript.FullName, 'psd1')
        $metadata = @{
            Description = "Sample script $($samplescript.BaseName)"
            Name        = $samplescript.BaseName -replace '\.', '-' -replace '[^\w\-]'
        }
    
        if (Test-Path -Path $sampleMeta)
        {
            $metadata = Import-PowerShellDataFile -Path $sampleMeta -ErrorAction SilentlyContinue
        }

        $scriptblock = [scriptblock]::Create((Get-Content -Path $samplescript.FullName -Raw))

        New-LabSnippet -Name $metadata.Name -Description $metadata.Description -Tag $metadata.Tag -Type 'Sample' -ScriptBlock $scriptblock -NoExport -Force
    }

    # Register all custom roles
    $location = Join-Path -Path (Get-LabSourcesLocation -Local) -ChildPath 'CustomRoles'
    if (-not (Test-Path -Path $location)) { return }
    foreach ($customrole in (Get-ChildItem -Path $location -Directory))
    {
        $customroleMeta = Join-Path -Path $customrole.FullName -ChildPath "$($customRole.Name).psd1"
        $scriptfile = Join-Path -Path $customrole.FullName -ChildPath HostStart.ps1

        if (-not (Test-Path -Path $scriptFile)) { continue }

        $metadata = @{
            Description = "Custom role to deploy $($customRole.Name)"
        }
    
        if (Test-Path -Path $customroleMeta)
        {
            $metadata = Import-PowerShellDataFile -Path $customroleMeta -ErrorAction SilentlyContinue
        }

        $scriptblock = [scriptblock]::Create((Get-Content -Path $scriptfile -Raw))

        New-LabSnippet -Name $customrole.Name -Description $metadata.Description -Tag $metadata.Tag -Type 'CustomRole' -ScriptBlock $scriptblock -NoExport -Force
    }

    # Register all user-defined blocks
    $location = Get-PSFConfigValue -FullName AutomatedLab.Recipe.SnippetStore
    $useAzure = Get-PSFConfigValue -FullName AutomatedLab.Recipe.UseAzureBlobStorage
        
    if ($useAzure -and -not (Get-Command -Name Set-AzStorageBlobContent -ErrorAction SilentlyContinue))
    {                
        Write-ScreenInfo -Type Error -Message "Az.Storage is missing. To use Azure, ensure that the module Az is installed."
        return
    }
    
    if ($useAzure -and -not (Get-AzContext))
    {                
        Write-ScreenInfo -Type Error -Message "No Azure context. Please follow the on-screen instructions to log in."
        $null = Connect-AzAccount -UseDeviceAuthentication -WarningAction Continue
    }

    if ($useAzure)
    {
        $account = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.AccountName
        $rg = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.ResourceGroupName
        $container = Get-PSFConfigValue -FullName AutomatedLab.Recipe.AzureBlobStorage.ContainerName

        if (-not $account -or -not $container -or -not $rg)
        {
            Write-PSFMessage -Level Warning -Message "Skipping import of Azure snippets since parameters were missing"
            return
        }

        $blobs = [System.Collections.ArrayList]::new()
        try { $blobs.AddRange((Get-AzStorageBlob -Blob [sS]nippet/*.ps*1 -Container $container -Context (Get-AzStorageAccount -ResourceGroupName $rg -Name $account).Context)) } catch {}
        try { $blobs.AddRange((Get-AzStorageBlob -Blob [sS]ample/*.ps*1 -Container $container -Context (Get-AzStorageAccount -ResourceGroupName $rg -Name $account).Context)) } catch {}

        if ($blobs.Count -eq 0) { return }
        Push-Location # Super ugly...
        $location = Join-Path -Path $env:TEMP -ChildPath snippetcache
        if (-not (Test-Path -Path $location)) { $null = New-Item -ItemType Directory -Path $location }
        Get-ChildItem -Path $location -Recurse -File | Remove-Item
        Set-Location -Path $location
        $null = $blobs | Get-AzStorageBlobContent
        Pop-Location
    }

    if (-not (Test-Path -Path $location)) { return }
    foreach ($meta in (Get-ChildItem -Path $location -File -Recurse -Filter AutomatedLab.*.*.psd1))
    {
        $metadata = Import-PowerShellDataFile -Path $meta.FullName -ErrorAction SilentlyContinue
        $scriptfile = [IO.Path]::ChangeExtension($meta.FullName, 'ps1')
        $scriptblock = [scriptblock]::Create((Get-Content -Path $scriptfile -Raw))
        if (-not $metadata) { continue }

        New-LabSnippet -Name $metadata.Name -Description $metadata.Description -Tag $metadata.Tag -Type $metadata.Type -ScriptBlock $scriptblock -NoExport -Force
    }

}

# Initialize settings
Set-PSFConfig -Module AutomatedLab.Recipe -Name SnippetStore -Value (Join-Path -Path $HOME -ChildPath 'automatedlab/snippets') -Validation string -Initialize -Description 'Snippet and recipe storage location'
Set-PSFConfig -Module AutomatedLab.Recipe -Name UseAzureBlobStorage -Value $false -Validation bool -Description 'Use Azure instead of local store. Required directories in container: Snippet, Sample. Custom roles currently not supported' -Initialize
Set-PSFConfig -Module AutomatedLab.Recipe -Name AzureBlobStorage.AccountName -Value "" -Validation string -Description "Storage account name to use" -Initialize
Set-PSFConfig -Module AutomatedLab.Recipe -Name AzureBlobStorage.ResourceGroupName -Value "" -Validation string -Description "ResourceGroupName to use" -Initialize
Set-PSFConfig -Module AutomatedLab.Recipe -Name AzureBlobStorage.ContainerName -Value "" -Validation string -Description "Storage container to use" -Initialize

Update-LabSnippet

$snippet = {
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [string]
        $Name,

        [Parameter(Mandatory)]
        [string]
        $DefaultVirtualizationEngine,

        [Parameter(Mandatory)]
        [AutomatedLab.IpNetwork]
        $MachineNetwork,

        [string]
        $VmPath,

        [int]
        $ReferenceDiskSizeInGB,

        [long]
        $MaxMemory,

        [string]
        $Notes,

        [switch]
        $UseAllMemory,

        [switch]
        $UseStaticMemory,

        [string]
        $SubscriptionName,

        [string]
        $DefaultLocationName,

        [string]
        $DefaultResourceGroupName,

        [timespan]
        $AutoShutdownTime,

        [timezoneinfo]
        $AutoShutdownTimeZone,

        [switch]
        $AllowBastionHost,

        [pscredential]
        $AdminCredential,

        [ValidateLength(1,10)]
        [string]
        $VmNamePrefix
    )

    $defParam = Sync-Parameter -Command (Get-Command New-LabDefinition) -Parameters $PSBoundParameters
    New-LabDefinition @defParam

    $PSDefaultParameterValues['Add-LabMachineDefinition:Network'] = $Name

    if (-not $VmNamePrefix)
    {
        $VmNamePrefix = $Name.ToUpper()
    }
    
    $AutomatedLabVmNamePrefix = $VmNamePrefix
    

    if ($SubscriptionName)
    {
        $azParam = Sync-Parameter -Command (Get-Command Add-LabAzureSubscription) -Parameters $PSBoundParameters
        Add-LabAzureSubscription @azParam
    }

    if ($MachineNetwork)
    {
        Add-LabVirtualNetworkDefinition -Name $Name -AddressSpace $MachineNetwork
    }

    if ($AdminCredential)
    {
        Set-LabInstallationCredential -Username $AdminCredential.UserName -Password $AdminCredential.GetNetworkCredential().Password
    }
}

New-LabSnippet -Name LabDefinition -Description 'Basic snippet to create a new labdefinition' -Tag Definition -Type Snippet -ScriptBlock $snippet -NoExport -Force


$snippet = {
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [string]
        $DomainName,

        [Parameter(Mandatory)]
        [pscredential]
        $AdminCredential,

        [uint16]
        $DomainControllerCount,

        [uint16]
        $RodcCount,

        [switch]
        $IsSubDomain
    )

    if (-not $AutomatedLabFirstRoot)
    {
        $AutomatedLabFirstRoot = $DomainName
    }

    if (-not $globalDcCount)
    {
        $globalDcCount = 1
    }

    Add-LabDomainDefinition -Name $DomainName -AdminUser $AdminCredential.UserName -AdminPassword $AdminCredential.GetNetworkCredential().Password

    $role = if ($IsSubDomain) { Get-LabMachineRoleDefinition -Role 'FirstChildDc' } else { Get-LabMachineRoleDefinition -Role 'RootDc' }
    Add-LabMachineDefinition -Name ('{0}DC{1:d2}' -f $AutomatedLabVmNamePrefix, $globalDcCount) -Roles $role -DomainName $DomainName
    $globalDcCount++

    if ($DomainControllerCount -gt 0)
    {
        foreach ($count in 1..$DomainControllerCount)
        {
            Add-LabMachineDefinition -Name ('{0}DC{1:d2}' -f $AutomatedLabVmNamePrefix, $globalDcCount) -Roles DC -DomainName $DomainName
            $globalDcCount++
        }
    }

    if ($RodcCount -gt 0)
    {
        foreach ($count in 1..$RodcCount)
        {
            $role = Get-LabMachineRoleDefinition -Role DC -Properties @{ IsReadOnly = '1' }
            Add-LabMachineDefinition -Name ('{0}DC{1:d2}' -f $AutomatedLabVmNamePrefix, $globalDcCount) -Roles $role -DomainName $DomainName
            $globalDcCount++
        }
    }
}

New-LabSnippet -Name Domain -Description 'Basic snippet to add one or more domains' -Tag Domain -Type Snippet -ScriptBlock $snippet -DependsOn LabDefinition -Force -NoExport


$snippet = {
    param
    (
        [Parameter(Mandatory, ParameterSetName = 'NoDefaultSwitch')]
        [switch]
        $NoDefaultSwitch,

        [Parameter(Mandatory, ParameterSetName = 'NoDefaultSwitch')]
        [string]
        $AdapterName
    )

    $externalNetworkName, $adapter = if ($NoDefaultSwitch)
    {
        '{0}EXT' -f (Get-LabDefinition).Name
        $AdapterName
    }
    else
    {
        'Default switch'
        'Ethernet' # unnecessary but required
    }

    Add-LabVirtualNetworkDefinition -Name $externalNetworkName -HyperVProperties @{ SwitchType = 'External'; AdapterName = $adapter }

    $adapters = @(
        New-LabNetworkAdapterDefinition -VirtualSwitch (Get-LabDefinition).Name
        New-LabNetworkAdapterDefinition -VirtualSwitch $externalNetworkName -UseDhcp
    )

    $router = Add-LabMachineDefinition -Name ('{0}GW01' -f $AutomatedLabVmNamePrefix) -Roles Routing -NetworkAdapter $adapters -PassThru
    $PSDefaultParameterValues['Add-LabMachineDefinition:Gateway'] = $router.NetworkAdapters.Where( { $_.VirtualSwitch.ResourceName -eq (Get-LabDefinition).Name }).Ipv4Address.IpAddress.ToString()
}

New-LabSnippet -Name InternetConnectivity -Description 'Basic snippet to add a router and external switch to the lab' -Tag Definition, Routing, Internet -Type Snippet -ScriptBlock $snippet -DependsOn LabDefinition -NoExport -Force