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 |