Public/Deploy-AGInfrastructure.ps1

function Deploy-AGInfrastructure {
    <#
    .SYNOPSIS
    Deploys a three-VM Windows Server 2022 Standard Desktop Experience AD CS lab.
 
    .DESCRIPTION
    Defines a domain controller, certification authority, and privileged access
    workstation with AutomatedLab and Hyper-V. Prompts for each VM's startup
    memory and processor count. Enter accepts the suggestion. Dynamic memory
    uses a 2 GB minimum and the greater of 4 GB or startup memory as its maximum.
    Displays effective resources before deployment.
 
    .PARAMETER Name
    Lab name, up to 11 word characters. Defaults to ADCSGoat.
 
    .PARAMETER Domain
    Root domain name. Defaults to adcs.goat and must contain a dot.
 
    .PARAMETER ExternalSwitch
    Hyper-V external switch name. Interactive deployment can create it.
 
    .PARAMETER Sources
    LabSources root used for the VM tools path. Defaults to AutomatedLab's location.
 
    .PARAMETER LabsRoot
    Legacy path shown in verbose configuration. Does not change AutomatedLab storage.
 
    .PARAMETER Confirm
    Legacy switch that skips final deployment confirmation, not resource prompts.
 
    .PARAMETER VMResources
    Per-VM overrides keyed by DC, CA, and PAW. Each dictionary accepts Memory
    in bytes (2 GB to 128 GB) and Processors (1 to 64). Unspecified values use
    4 GB startup memory and 2 processors. For example: @{ DC = @{ Memory = 8GB } }.
    Interactive prompts start from these overrides and can change them.
 
    .PARAMETER NonInteractive
    Accepts effective resource values and skips all prompts. A duplicate lab
    name or domain, a missing switch, or unprepared host remoting terminates
    with an error. Host remoting must be configured separately.
 
    .EXAMPLE
    Deploy-AGInfrastructure
    Prompts for resources and confirms deployment using the suggested profile.
 
    .EXAMPLE
    Deploy-AGInfrastructure -Name Goat2022 -Domain goat2022.test -ExternalSwitch 'External Switch' -VMResources @{ DC = @{ Memory = 8GB; Processors = 4 } } -NonInteractive
    Deploys without input requests, using an existing switch and per-VM overrides.
 
    .OUTPUTS
    None. Writes deployment status and configuration to the host.
 
    .NOTES
    Requires an administrative Hyper-V host and media that enumerates as
    Windows Server 2022 Standard (Desktop Experience). Evaluation images have
    a different identifier. Memory units use PowerShell's binary GB constant.
    AutomatedLab and PSFramework load as module requirements.
    #>


    [CmdletBinding()]
    param (
        [PsfValidatePattern('^\w{1,11}$', ErrorMessage = 'Lab name must be no longer than 11 characters and only contain letters and numbers.')]
        $Name = 'ADCSGoat',
        [PsfValidatePattern('\.', ErrorMessage = 'Domain must contain at least one dot.')]
        $Domain = 'adcs.goat',
        $ExternalSwitch = 'External Switch',
        $Sources = (Get-LabSourcesLocation),
        $LabsRoot = "$((Get-PSFConfig -Module AutomatedLab -Name LabAppDataRoot).Value)\Labs", # Not currently needed, but I like it.,
        [switch]$Confirm,
        [ValidateNotNull()]
        [hashtable]$VMResources = @{},
        [switch]$NonInteractive
    )

    $roles = @('DC', 'CA', 'PAW')
    $effectiveResources = @{}
    foreach ($role in $roles) {
        $effectiveResources[$role] = @{ Memory = 4GB; Processors = 2 }
    }

    foreach ($role in $VMResources.Keys) {
        $resourceError = $null
        $overrides = $VMResources[$role]
        if ($role -notin $roles) {
            $resourceError = "Unknown VM role '$role'. Use DC, CA, or PAW."
        } elseif ($overrides -isnot [System.Collections.IDictionary]) {
            $resourceError = "VMResources '$role' must be a dictionary of Memory and Processors."
        } else {
            foreach ($field in $overrides.Keys) {
                [long]$value = 0
                if ($field -notin @('Memory', 'Processors')) {
                    $resourceError = "Unknown resource '$field' for '$role'. Use Memory or Processors."
                } elseif (-not [long]::TryParse([string]$overrides[$field], [ref]$value)) {
                    $resourceError = "Resource '$field' for '$role' must be an integer. Specify Memory in bytes, for example 8GB."
                } elseif ($field -eq 'Memory' -and ($value -lt 2GB -or $value -gt 128GB)) {
                    $resourceError = "Startup memory for '$role' must be between 2 GB and 128 GB."
                } elseif ($field -eq 'Processors' -and ($value -lt 1 -or $value -gt 64)) {
                    $resourceError = "Processors for '$role' must be between 1 and 64."
                } else {
                    $effectiveResources[$role][$field] = $value
                }

                if ($resourceError) { break }
            }
        }

        if ($resourceError) {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.ArgumentException]::new($resourceError),
                'InvalidVMResources',
                [System.Management.Automation.ErrorCategory]::InvalidArgument,
                $overrides
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }

    if ($NonInteractive.IsPresent -and -not (Test-LabHostRemoting -ErrorAction Stop)) {
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new('Prepare host remoting for AutomatedLab before using -NonInteractive.'),
            'HostRemotingNotReady',
            [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
            $env:COMPUTERNAME
        )
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    Write-Verbose -Message @"
 
----------------------------------------------------
| Initial Configuration |
----------------------------------------------------
 
Name = $Name
Domain = $Domain
ExternalSwitch = $ExternalSwitch
Sources = $Sources
LabRoot = $LabsRoot
"@


    # Confirm lab name is unique on this host.
    while ((Get-Lab -List) -contains $Name) {
        if ($NonInteractive.IsPresent) {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new("A lab named '$Name' already exists. Specify a unique -Name."),
                'LabNameInUse',
                [System.Management.Automation.ErrorCategory]::ResourceExists,
                $Name
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        Write-Host
        Write-Warning -Message "A lab named `"$Name`" already exists on this host."
        Write-Host "Please select a new lab name: " -NoNewline
        $Name = Read-Host
    }

    # Import existing labs and add their domains to an array.
    Write-Host "`nImporting existing labs to confirm the new root domain name `"$Domain`" is unique."
    $ExistingDomains = Get-Lab -List | ForEach-Object {
        Import-Lab -Name $_
        Get-LabVM | Select-Object DomainName
    }

    $ExistingDomains = $ExistingDomains | Sort-Object -Property DomainName -Unique
    if ($ExistingDomains) { Write-Verbose "Existing Domains: $($ExistingDomains.DomainName)" }

    # Confirm root domain name is unique on this host.
    while ($ExistingDomains.DomainName -contains $Domain) {
        if ($NonInteractive.IsPresent) {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new("Domain '$Domain' is already used by another lab. Specify a unique -Domain."),
                'DomainInUse',
                [System.Management.Automation.ErrorCategory]::ResourceExists,
                $Domain
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        Write-Host
        Write-Warning -Message "A lab using the domain `"$Domain`" already exists on this host."
        Write-Host "Please select a new root domain name: " -NoNewline
        $Domain = Read-Host
    }

    # Create a Hyper-V External Switch if none exists.
    while (-not (Get-VMSwitch | Where-Object Name -EQ $ExternalSwitch)) {
        if ($NonInteractive.IsPresent) {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new("External switch '$ExternalSwitch' does not exist. Create it before noninteractive deployment."),
                'ExternalSwitchNotFound',
                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                $ExternalSwitch
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        #region Select NetAdapter for Use in Lab
        $netIPAddressCollection = Get-NetIPAddress | Where-Object {
            $_.IPAddress -notmatch '^169.254|^127.0.0' -and
            $_.InterfaceAlias -notmatch 'VMware' -and
            $_.AddressFamily -eq 'IPv4' -and
            $_.PrefixLength -eq 24 -and
            $_.PrefixOrigin -eq 'Dhcp'
        }

        Write-Host @"
This script is designed to use a single network adapter with the following configuration:
 
- has an IPv4 address
- does not have an IP address in a link-local block
- is configured for DHCP
- has a subnet mask of /24
 
Only network adapters meeting this configuration are shown below.
 
Select the network adapter you'd like to use in your lab.
"@


        # Enumerate network adapters on the host.
        $i = 0
        $netIPAddressCollection | ForEach-Object {
            $i++
            Write-Host " ${i}: $($_.InterfaceAlias) ($($_.IPAddress))"
        }
        [int]$adapterIndex = Read-Host -Prompt "Please enter a number `[1-$i`]"

        $adapterIndex = $adapterIndex - 1

        $NetAdapterName = $netIPAddressCollection[$($adapterIndex)].InterfaceAlias
        #endregion Select NetAdapter for Use in Lab

        # Create a new External Switch named $ExternalSwitch aka 'vEthernet ($ExternalSwitch)'
        try {
            New-VMSwitch -Name $ExternalSwitch -NetAdapterName $NetAdapterName -ErrorAction Stop
            Start-Sleep -Seconds 5
        } catch {
            throw $_
        }
    }

    # Get IP Address of External Switch
    [string]$NetAdapterIP = (Get-NetIPConfiguration -InterfaceAlias "vEthernet ($ExternalSwitch)").IPv4Address

    # Create required addresses
    if ($NetAdapterIP -match '(?:\d{1,3}\.){3}') {
        $BaseAddress = $matches[0]
        $NetworkAddress = $BaseAddress + '0'
        $Gateway = $BaseAddress + '1'
    }

    # Get IP Address of other machines in subnet
    $ExistingIPs = Get-VM |
    Where-Object State -EQ Running |
    Select-Object -ExpandProperty NetworkAdapters |
    Select-Object -ExpandProperty IPAddresses |
    ForEach-Object {
        if ($_ -match '(?:\d{1,3}\.){3}') { $_ }
    }

    # Pick IP Addresses for new VMs
    $NewIPs = @{}
    $RoleIndex = 0

    for ($i = 3; $i -lt 255 -and $RoleIndex -lt $Roles.Count; $i++) {
        $CandidateIP = "$BaseAddress$i"
        if ($ExistingIPs -notcontains $CandidateIP) {
            $NewIPs[$Roles[$RoleIndex]] = $CandidateIP
            $RoleIndex++
        }
    }

    # Create IPs for each role.
    $Roles | ForEach-Object {
        New-Variable -Name "${_}IP" -Value $NewIPs[$_]
    }

    if (-not $NonInteractive.IsPresent) {
        foreach ($role in $roles) {
            foreach ($field in @('Memory', 'Processors')) {
                $label = if ($field -eq 'Memory') { 'startup memory in GB' } else { 'processors' }
                $suggestion = if ($field -eq 'Memory') {
                    $effectiveResources[$role].Memory / 1GB
                } else {
                    $effectiveResources[$role].Processors
                }
                $minimum = if ($field -eq 'Memory') { 2 } else { 1 }
                $maximum = if ($field -eq 'Memory') { 128 } else { 64 }

                while ($true) {
                    $inputValue = Read-Host -Prompt "$role $label [$suggestion]"
                    if ([string]::IsNullOrWhiteSpace($inputValue)) { break }

                    [long]$enteredValue = 0
                    if ([long]::TryParse($inputValue, [ref]$enteredValue) -and
                        $enteredValue -ge $minimum -and $enteredValue -le $maximum) {
                        $effectiveResources[$role][$field] = if ($field -eq 'Memory') {
                            $enteredValue * 1GB
                        } else {
                            $enteredValue
                        }
                        break
                    }

                    Write-Warning -Message "$role $label must be a whole number between $minimum and $maximum."
                }
            }
        }
    }

    foreach ($role in $roles) {
        $effectiveResources[$role].MinMemory = 2GB
        $effectiveResources[$role].MaxMemory = [Math]::Max([long]4GB, [long]$effectiveResources[$role].Memory)
    }

    Write-PSFHostColor @"
----------------------------------------------------
| Lab Configuration |
----------------------------------------------------
Name: <c='em'>$Name</c>
Root Domain: <c='em'>$Domain</c>
Network Address: <c='em'>$NetworkAddress</c>
Gateway: <c='em'>$Gateway</c>
Domain Controller IP: <c='em'>$DCIP</c>
Certification Authority IP: <c='em'>$CAIP</c>
Privileged Access Workstation IP: <c='em'>$PAWIP</c>
"@


    Write-Host "`nVM resources (dynamic memory):"
    foreach ($role in $roles) {
        $resources = $effectiveResources[$role]
        Write-Host ('{0,-16} Min {1:g} GB | Startup {2:g} GB | Max {3:g} GB | CPUs {4}' -f
            "$Name-$role", ($resources.MinMemory / 1GB), ($resources.Memory / 1GB),
            ($resources.MaxMemory / 1GB), $resources.Processors)
    }

    if (-not $Confirm.IsPresent -and -not $NonInteractive.IsPresent) {
        $Answer = Get-PSFUserChoice -Caption 'Continue with deployment?' -Options Yes, No
        if ($Answer -eq 1) { return }
    }

    # Define the lab + hypervisor
    New-LabDefinition -Name $Name -DefaultVirtualizationEngine HyperV

    # Use existing External Switch created or discovered above
    Add-LabVirtualNetworkDefinition -Name $ExternalSwitch -AddressSpace "$NetAdapterIP/24"

    # Set default parameters for all machines in the lab
    $PSDefaultParameterValues = @{
        'Add-LabMachineDefinition:Network'         = $ExternalSwitch
        'Add-LabMachineDefinition:ToolsPath'       = "$Sources\Tools"
        'Add-LabMachineDefinition:DomainName'      = $Domain
        'Add-LabMachineDefinition:Gateway'         = $Gateway
        'Add-LabMachineDefinition:DnsServer1'      = $DCIP
        'Add-LabMachineDefinition:OperatingSystem' = 'Windows Server 2022 Standard (Desktop Experience)'
    }

    $dcResources = $effectiveResources['DC']
    $caResources = $effectiveResources['CA']
    $pawResources = $effectiveResources['PAW']
    Add-LabMachineDefinition -Name "$Name-DC" -Roles RootDC -IpAddress $DCIP @dcResources
    Add-LabMachineDefinition -Name "$Name-CA" -Roles CaRoot -IpAddress $CAIP @caResources
    Add-LabMachineDefinition -Name "$Name-PAW" -IpAddress $PAWIP @pawResources

    Install-Lab

    Install-LabWindowsFeature -FeatureName RSAT -ComputerName "$Name-PAW" -IncludeAllSubFeature

    Show-LabDeploymentSummary
}