diff --git a/Tests/Lab/Acceptance/Add-OsMatrixMachine.ps1 b/Tests/Lab/Acceptance/Add-OsMatrixMachine.ps1 new file mode 100644 index 0000000..3ee1c50 --- /dev/null +++ b/Tests/Lab/Acceptance/Add-OsMatrixMachine.ps1 @@ -0,0 +1,123 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $LogPath, + [Parameter(Mandatory)] [ValidatePattern('^[A-Za-z][A-Za-z0-9-]{0,14}$')] [string] $Name, + [Parameter(Mandatory)] [string] $OperatingSystem, + [Parameter(Mandatory)] [string] $IpAddress, + [string] $LabName = 'NtfsSecurityOsMatrixLab', + [ValidateRange(2, 16)] [int] $MemoryGB = 4, + [ValidateRange(1, 8)] [int] $Processors = 2, + [ValidateRange(5, 240)] [int] $StartTimeoutMinutes = 40, + [string] $BackupRoot = 'C:\ProgramData\AutomatedLab\Backups' +) + +# Adds one machine to the already deployed matrix lab (Decision 24) and creates only that machine. AutomatedLab 5.61 has no supported way to +# extend a deployed lab: Add-LabMachineDefinition refuses while a lab is imported or exported, and Install-Lab creates every machine of the +# lab again. This script copies the lab metadata first (the copy is readable by administrators only, because the files hold the lab +# credentials), reloads the definition with Import-LabDefinition (never Import-Lab), adds the machine, exports the definition, and then runs +# the same steps Install-Lab runs for a single machine: base image, hosts entries, virtual machine, start. The other machines are neither +# created, started, nor changed. Windows PowerShell 5.1 on the host; run it elevated. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' +function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } + +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } + +($stamp -f [DateTime]::UtcNow) + " START add-os-matrix-machine lab=$LabName name=$Name os='$OperatingSystem' ip=$IpAddress" | Set-Content -LiteralPath $LogPath +$lockPath = $null +try { + Import-Module -Name AutomatedLab -ErrorAction Stop + if ((Get-Lab -List) -notcontains $LabName) { throw "The lab '$LabName' does not exist." } + if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw "A virtual machine named '$Name' exists already." } + $hostsText = Get-Content -LiteralPath (Join-Path -Path $env:SystemRoot -ChildPath 'System32\drivers\etc\hosts') -Raw + if ($hostsText -match ('(?im)^\s*[^#\s]+\s+{0}(\.|\s|$)' -f [regex]::Escape($Name)) -or $hostsText -match ('(?im)^\s*{0}\s' -f [regex]::Escape($IpAddress))) { + throw "The hosts file mentions '$Name' or $IpAddress already." + } + + $labFolder = Join-Path -Path (Get-LabConfigurationItem -Name LabAppDataRoot) -ChildPath "Labs\$LabName" + $backup = Join-Path -Path $BackupRoot -ChildPath ('{0}-{1:yyyyMMdd-HHmmss}' -f $LabName, [DateTime]::UtcNow) + $null = New-Item -ItemType Directory -Path $backup -Force + $null = & icacls.exe $backup /inheritance:r /grant:r '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' + if ($LASTEXITCODE -ne 0) { throw "icacls failed on the backup folder (exit code $LASTEXITCODE)." } + Copy-Item -LiteralPath $labFolder -Destination $backup -Recurse + Write-Step "lab metadata copied to $backup" + + Import-LabDefinition -Name $LabName + $definition = Get-LabDefinition + $before = @(Get-LabMachineDefinition | ForEach-Object -Process { $_.Name }) + $domainName = $definition.Domains[0].Name + $rootDc = Get-LabMachineDefinition | Where-Object -FilterScript { 'RootDC' -in $_.Roles.Name } | Select-Object -First 1 + $dcAddress = ($rootDc.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString + $dcPrefix = ($dcAddress -split '\.')[0..2] -join '.' + $newPrefix = ($IpAddress -split '\.')[0..2] -join '.' + if ($dcPrefix -ne $newPrefix) { throw "$IpAddress isn't in the /24 of the domain controller ($dcAddress)." } + Write-Step ("definition loaded: domain {0}; machines {1}; installation account {2}" -f $domainName, ($before -join ','), $definition.DefaultInstallationCredential.UserName) + + $parameters = @{ + Name = $Name; DomainName = $domainName; OperatingSystem = $OperatingSystem; Memory = ($MemoryGB * 1GB) + Processors = $Processors; Network = $LabName; IpAddress = $IpAddress + } + if ($OperatingSystem -like 'Windows 11*') { + $parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } + } + + Add-LabMachineDefinition @parameters + Export-LabDefinition -Force -ExportDefaultUnattendedXml + Import-Lab -Name $LabName -NoValidation -NoDisplay + $after = @(Get-LabVM -IncludeLinux | ForEach-Object -Process { $_.Name }) + $difference = @(Compare-Object -ReferenceObject ($before + $Name) -DifferenceObject $after) + if ($difference.Count -gt 0) { throw "The exported lab doesn't hold exactly the old machines plus $Name. Restore the metadata from $backup." } + Write-Step 'definition extended and exported' + + $lockPath = Get-LabConfigurationItem -Name DiskDeploymentInProgressPath + if (Test-Path -LiteralPath $lockPath) { throw "Another lab disk deployment seems to be in progress ($lockPath)." } + $null = New-Item -Path $lockPath -ItemType File -Value $LabName + New-LabBaseImages + Write-Step 'base images ready' + + $machine = Get-LabVM -ComputerName $Name + $address = ($machine.NetworkAdapters | Select-Object -First 1).Ipv4Address.IpAddress.AddressAsString + $null = Add-HostEntry -HostName $machine.Name -IpAddress $address -Section $LabName + $null = Add-HostEntry -HostName $machine.FQDN -IpAddress $address -Section $LabName + New-LabVM -Name $Name + Set-LabDefinition -Machines (Get-Lab).Machines + Export-LabDefinition -Force -ExportDefaultUnattendedXml -Silent + Write-Step 'virtual machine created and definition exported' + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + $lockPath = $null + + Start-LabVM -ComputerName $Name -ProgressIndicator 30 -TimeoutInMinutes $StartTimeoutMinutes -Wait + Write-Step 'machine started and reachable with the lab credentials' + + $userName = (Get-Lab).DefaultInstallationCredential.UserName + Invoke-LabCommand -ActivityName 'Setting PasswordNeverExpires for local deployment accounts' -ComputerName $Name -NoDisplay -Variable (Get-Variable -Name userName) -ScriptBlock { + Get-CimInstance -Query "Select * from Win32_UserAccount where name = '$userName' and localaccount='true'" | Set-CimInstance -Property @{ PasswordExpires = $false } + } + + $evidence = Invoke-LabCommand -ComputerName $Name -ActivityName 'Readiness of the new member' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $domainName -ScriptBlock { + param ($Domain) + $current = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' + [pscustomobject]@{ + Build = '{0}.{1}' -f $current.CurrentBuildNumber, $current.UBR + Product = $current.ProductName + Domain = (Get-CimInstance -ClassName Win32_ComputerSystem).Domain + SecureChannel = [bool] (Test-ComputerSecureChannel) + Verify = (@(& nltest.exe "/sc_verify:$Domain" 2>&1) -join ' | ') + } + } + Write-Step ('new member: build {0} ({1}); domain {2}; secure channel {3}; nltest: {4}' -f $evidence.Build, $evidence.Product, $evidence.Domain, $evidence.SecureChannel, $evidence.Verify) + if (-not $evidence.SecureChannel) { throw "The secure channel of $Name is broken." } + + Write-Step 'add-os-matrix-machine-DONE' + exit 0 +} +catch { + Write-Step ('add-os-matrix-machine-FAILED: {0}' -f $_) + $_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath + exit 1 +} +finally { + if ($lockPath) { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } +} diff --git a/Tests/Lab/Acceptance/Complete-OsMatrixLab.ps1 b/Tests/Lab/Acceptance/Complete-OsMatrixLab.ps1 new file mode 100644 index 0000000..14c75e6 --- /dev/null +++ b/Tests/Lab/Acceptance/Complete-OsMatrixLab.ps1 @@ -0,0 +1,91 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $LogPath, + [string] $LabName = 'NtfsSecurityOsMatrixLab', + [string[]] $Member = @('OSDC1', 'OSFile19', 'OSFile22', 'OSFile25', 'OSWin11E'), + [string[]] $LocalCredentialMember = @(), + [string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', + [string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' +) + +# Finishes machines of the matrix lab after a deployment that stopped in AutomatedLab's file server step (a job that never completed +# although its remote side was idle) or after Add-OsMatrixMachine.ps1: detaches the installation ISO from the file servers, installs +# PowerShell 7 from the MSI of the host, and copies Pester 5.7.1 into the module folders of both editions. It uses no AutomatedLab job +# (no -AsJob), only synchronous remoting. A member in -LocalCredentialMember is reached with the local installation account through a +# session, for a machine whose secure channel to the domain controller fails. Windows PowerShell 5.1 on the host; run it elevated. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +# -File passes an array as one string, so a list may arrive as 'A,B'. +$Member = @($Member | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) +$LocalCredentialMember = @($LocalCredentialMember | ForEach-Object -Process { $_ -split ',' } | Where-Object -FilterScript { $_ }) +$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' +function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } + +$installBlock = { + param ($Msi) + $msiPath = Join-Path -Path 'C:\Windows\Temp' -ChildPath $Msi + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f $msiPath), '/quiet', '/norestart', 'ADD_PATH=1', '/l*v', 'C:\Windows\Temp\pwsh-install.log') -Wait -PassThru + $pwsh = Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\7\pwsh.exe' + [pscustomobject]@{ ExitCode = $process.ExitCode; Pwsh = $(if (Test-Path -LiteralPath $pwsh) { (Get-Item -LiteralPath $pwsh).VersionInfo.ProductVersion } else { 'missing' }) } +} +$createBlock = { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } +$checkBlock = { param ($Path) '{0}: {1}' -f $env:COMPUTERNAME, (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath '5.7.1\Pester.psd1')) } + +($stamp -f [DateTime]::UtcNow) + " START complete-os-matrix-lab lab=$LabName members=$($Member -join ',') localCredential=$($LocalCredentialMember -join ',')" | Set-Content -LiteralPath $LogPath +try { + foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } + Import-Module -Name AutomatedLab -ErrorAction Stop + Import-Lab -Name $LabName -NoValidation -NoDisplay + $fileServers = @($Member | Where-Object -FilterScript { $_ -like 'OSFile*' -and $_ -notin $LocalCredentialMember }) + if ($fileServers) { + Dismount-LabIsoImage -ComputerName $fileServers -SupressOutput + Write-Step "installation ISO detached from $($fileServers -join ',')" + } + + $msiName = Split-Path -Path $PowerShell7Msi -Leaf + $domainMembers = @($Member | Where-Object -FilterScript { $_ -notin $LocalCredentialMember }) + foreach ($name in $Member) { + if ($name -in $LocalCredentialMember) { + $session = New-LabPSSession -ComputerName $name -UseLocalCredential + try { + Copy-Item -LiteralPath $PowerShell7Msi -Destination 'C:\Windows\Temp\' -ToSession $session -Force + $outcome = Invoke-Command -Session $session -ScriptBlock $installBlock -ArgumentList $msiName + foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { + $destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' + Invoke-Command -Session $session -ScriptBlock $createBlock -ArgumentList $destination + Copy-Item -LiteralPath $PesterModulePath -Destination $destination -ToSession $session -Recurse -Force + Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (Invoke-Command -Session $session -ScriptBlock $checkBlock -ArgumentList $destination)) + } + } + finally { + Remove-PSSession -Session $session -ErrorAction SilentlyContinue + } + } + else { + Copy-LabFileItem -Path $PowerShell7Msi -ComputerName $name -DestinationFolderPath 'C:\Windows\Temp' + $outcome = Invoke-LabCommand -ComputerName $name -ActivityName "Install PowerShell 7 on $name" -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $msiName -ScriptBlock $installBlock + } + + Write-Step ("PowerShell 7 on {0}: msiexec exit code {1}; pwsh {2}" -f $name, $outcome.ExitCode, $outcome.Pwsh) + if ($outcome.ExitCode -notin 0, 3010 -or $outcome.Pwsh -eq 'missing') { throw "PowerShell 7 isn't installed on $name (exit code $($outcome.ExitCode))." } + } + + if ($domainMembers) { + foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { + $destination = Join-Path -Path $modulesRoot -ChildPath 'Pester' + Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Create the Pester module directory' -NoDisplay -ErrorAction Stop -ArgumentList $destination -ScriptBlock $createBlock + Copy-LabFileItem -Path $PesterModulePath -ComputerName $domainMembers -DestinationFolderPath $destination -Recurse + $found = Invoke-LabCommand -ComputerName $domainMembers -ActivityName 'Check Pester' -NoDisplay -PassThru -ErrorAction Stop -ArgumentList $destination -ScriptBlock $checkBlock + Write-Step ("Pester 5.7.1 in {0}: {1}" -f $modulesRoot, (@($found) -join '; ')) + if (@($found | Where-Object -FilterScript { $_ -notmatch ': True$' }).Count -gt 0) { throw "Pester 5.7.1 isn't in $destination on every member." } + } + } + + Write-Step 'complete-os-matrix-lab-DONE' + exit 0 +} +catch { + Write-Step ("complete-os-matrix-lab-FAILED: {0}" -f $_) + $_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath + exit 1 +} diff --git a/Tests/Lab/Acceptance/Deploy-OsMatrixLab.ps1 b/Tests/Lab/Acceptance/Deploy-OsMatrixLab.ps1 new file mode 100644 index 0000000..5d9a1c2 --- /dev/null +++ b/Tests/Lab/Acceptance/Deploy-OsMatrixLab.ps1 @@ -0,0 +1,96 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $LogPath, + [string] $LabName = 'NtfsSecurityOsMatrixLab', + [string] $DomainName = 'osmatrix.net', + [string] $VmPath = 'V:\AutomatedLab-VMs', + [string] $AddressSpace = '192.168.12.0/24', + [string] $PesterModulePath = 'V:\Git\WindowsAccessControl\output\RequiredModules\Pester\5.7.1', + [string] $PowerShell7Msi = 'V:\LabSources\SoftwarePackages\PowerShell-7.6.3-win-x64.msi' +) + +# Deploys an isolated AutomatedLab lab for the NTFSSecurity operating-system matrix (Decision 24): one domain controller, three file +# servers (Server 2019, 2022, 2025), and a Windows 11 client, in a domain and on a switch of their own. It touches none of the +# existing labs, machines, switches, or domains, never calls Remove-Lab, and refuses to run when the lab or a machine name exists. +# The installation password is generated here, kept in memory, and stored only where AutomatedLab stores it for every lab. +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$stamp = '[{0:yyyy-MM-dd HH:mm:ss}Z]' +function Write-Step { param ([string] $Message) ($stamp -f [DateTime]::UtcNow) + ' ' + $Message | Add-Content -LiteralPath $LogPath } + +$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'This script must run in an elevated PowerShell session.' } + +($stamp -f [DateTime]::UtcNow) + " START deploy-os-matrix-lab lab=$LabName" | Set-Content -LiteralPath $LogPath +try { + Import-Module -Name AutomatedLab -ErrorAction Stop + foreach ($path in $PesterModulePath, $PowerShell7Msi) { if (-not (Test-Path -LiteralPath $path)) { throw "Missing payload: $path" } } + + $machines = @( + @{ Name = 'OSDC1'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('RootDC'); Memory = 4GB; Address = '192.168.12.10' } + @{ Name = 'OSFile25'; Os = 'Windows Server 2025 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.25' } + @{ Name = 'OSFile22'; Os = 'Windows Server 2022 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.22' } + @{ Name = 'OSFile19'; Os = 'Windows Server 2019 Datacenter (Desktop Experience)'; Roles = @('FileServer'); Memory = 3GB; Address = '192.168.12.19' } + @{ Name = 'OSWin11'; Os = 'Windows 11 Pro'; Roles = @(); Memory = 4GB; Address = '192.168.12.11' } + ) + + # Collision checks from AutomatedLab metadata and from Hyper-V; the existing labs are only read. + $existingNames = New-Object System.Collections.Generic.List[string] + $labs = @(Get-Lab -List) + if ($labs -contains $LabName) { throw "The lab '$LabName' exists already. Refusing to redefine it." } + foreach ($existing in $labs) { + Import-Lab -Name $existing -NoValidation -NoDisplay -ErrorAction Stop + foreach ($vm in Get-LabVM -IncludeLinux) { $existingNames.Add($vm.Name) } + } + foreach ($vm in Get-VM) { $existingNames.Add($vm.Name) } + $collisions = @($machines.Name | Where-Object { $_ -in $existingNames }) + if ($collisions) { throw "Machine name collision: $($collisions -join ', ')" } + if (Get-VMSwitch -Name $LabName -ErrorAction SilentlyContinue) { throw "A virtual switch named '$LabName' exists already." } + $usedAddresses = @(Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { $_.IPAddress }) + if ($usedAddresses | Where-Object { $_ -like '192.168.12.*' }) { throw 'The address space 192.168.12.0/24 is in use on the host.' } + Write-Step ('preflight ok; existing labs: {0}; existing machine names: {1}' -f ($labs -join ', '), $existingNames.Count) + + $characters = ([char[]](48..57) + [char[]](65..90) + [char[]](97..122) + '!', '#', '%', '+', '-', '=') + $password = -join (1..24 | ForEach-Object { $characters | Get-Random }) + $password = 'Aa1!' + $password + + New-LabDefinition -Name $LabName -DefaultVirtualizationEngine HyperV -VmPath $VmPath + Add-LabVirtualNetworkDefinition -Name $LabName -AddressSpace $AddressSpace + Add-LabDomainDefinition -Name $DomainName -AdminUser 'install' -AdminPassword $password + Set-LabInstallationCredential -Username 'install' -Password $password + foreach ($definition in $machines) { + $parameters = @{ + Name = $definition.Name; DomainName = $DomainName; OperatingSystem = $definition.Os; Memory = $definition.Memory + Processors = 2; Network = $LabName; IpAddress = $definition.Address + } + if ($definition.Roles.Count -gt 0) { $parameters.Roles = $definition.Roles } + if ($definition.Os -like 'Windows 11*') { + $parameters.HypervProperties = @{ EnableSecureBoot = 'On'; SecureBootTemplate = 'MicrosoftWindows'; EnableTpm = 'true' } + } + Add-LabMachineDefinition @parameters + } + Write-Step 'lab defined; installing network switches and base images' + + Install-Lab -NetworkSwitches -BaseImages + Write-Step 'network switches and base images done' + Install-Lab + Write-Step 'machines, domain, and roles done' + + $labMachines = Get-LabVM + Install-LabSoftwarePackage -ComputerName $labMachines -Path $PowerShell7Msi -CommandLine '/quiet /norestart ADD_PATH=1' -Timeout 30 + Write-Step 'PowerShell 7 installed' + foreach ($modulesRoot in 'C:\Program Files\WindowsPowerShell\Modules', 'C:\Program Files\PowerShell\Modules') { + $destination = Join-Path $modulesRoot 'Pester' + Invoke-LabCommand -ComputerName $labMachines -ActivityName 'Create the Pester module directory' -ScriptBlock { param ($Path) $null = New-Item -Path $Path -ItemType Directory -Force } -ArgumentList $destination -NoDisplay + Copy-LabFileItem -Path $PesterModulePath -ComputerName $labMachines -DestinationFolderPath $destination -Recurse + } + Write-Step 'Pester 5.7.1 copied' + Show-LabDeploymentSummary -Summary + Write-Step "deploy-os-matrix-lab-DONE" + exit 0 +} +catch { + Write-Step ("deploy-os-matrix-lab-FAILED: {0}" -f $_) + $_ | Format-List -Property * -Force | Out-String | Add-Content -LiteralPath $LogPath + exit 1 +} diff --git a/Tests/Lab/Acceptance/Export-MatrixResults.ps1 b/Tests/Lab/Acceptance/Export-MatrixResults.ps1 new file mode 100644 index 0000000..2bf98eb --- /dev/null +++ b/Tests/Lab/Acceptance/Export-MatrixResults.ps1 @@ -0,0 +1,101 @@ +[CmdletBinding()] +param ( + [Parameter(Mandatory)] [string] $OutputPrefix, + [string] $MatrixRoot, + [string] $Label, + [string[]] $LocalSuiteFolder = @(), + [string] $ReferenceMachine = 'LOCAL' +) + +# Turns the raw results of the operating-system matrix (Decision 24) into the tables of the acceptance record, in Windows PowerShell 5.1. +# -MatrixRoot and -Label name the sequences of Run-MatrixSequence.ps1 (folders