#Requires -Version 5.1 <# .SYNOPSIS Deploys an Ubuntu 24.04 LTS build VM on VMware Workstation using a cloud VMDK and a cloud-init seed ISO. .DESCRIPTION Host-side orchestrator. Produces a ready-to-use Linux template VM: Step 1 — Validate prerequisites + download cloud VMDK if not cached Step 2 — Generate cloud-init user-data + meta-data files Step 3 — Build cloud-init seed ISO (nocloud datasource, label: cidata) Step 4 — Copy and resize the cloud VMDK Step 5 — Generate the .vmx (EFI, SCSI disk, NAT vmxnet3, seed ISO) Step 6 — Power on the VM (vmrun start gui/nogui -- async) Step 7 — Detect guest IP via vmrun getGuestIPAddress (polls up to 5 min) Step 8 — Poll SSH port 22 until ready Step 9 — Verify cloud-init completed (boot-finished flag) Step 10 — Remove seed ISO from VMX (prevent re-run on clone) Final — Print summary cloud-init nocloud datasource: Two files (user-data + meta-data) are written to a staging directory and burned into a small ISO labelled "cidata". On first boot, cloud-init reads both files and creates the $CloudUser account with the SSH public key. No packages are installed and no runcmd runs: all provisioning (apt, toolchain, SSH hardening, swap) is handled by Prepare-LinuxBuild2404.ps1 via Install-CIToolchain-Linux2404.sh. Step 9 waits for /var/lib/cloud/instance/boot-finished before proceeding. NIC: vmxnet3 + NAT. Ubuntu 24.04 cloud images ship with open-vm-tools which provides the guest IP reporting used in Step 7. Disk: Official Ubuntu 24.04 Server cloud VMDK from cloud-images.ubuntu.com. Downloaded and cached to $VmdkCachePath on first run. Expanded to $DiskSizeGB GB in Step 4 using vmware-vdiskmanager. .PARAMETER VMXPath Full path of the .vmx file to create. The folder is created if missing. .PARAMETER VMName Display name in the Workstation library. .PARAMETER CloudUser Username for the cloud-init created account. Default: ci_build. .PARAMETER Hostname Guest hostname embedded in cloud-init meta-data and user-data. .PARAMETER Timezone IANA timezone string for the guest. Default: Europe/Rome. .PARAMETER DiskSizeGB Target disk size in GB after resize. Default: 40. .PARAMETER MemoryMB Guest RAM in MiB. Default: 4096. .PARAMETER VCPUCount Total vCPU count. Default: 4. .PARAMETER CoresPerSocket Cores per socket. Default: 2. .PARAMETER SnapshotName Snapshot name recorded in the summary. The snapshot itself is taken by Prepare-LinuxBuild2404.ps1. .PARAMETER VMwareWorkstationDir VMware Workstation install directory. .PARAMETER SshKeyPath Path to the SSH private key. The public key is inferred as SshKeyPath.pub. .PARAMETER VmdkUrl URL to download the Ubuntu 24.04 cloud VMDK. .PARAMETER VmdkCachePath Local cache path for the downloaded VMDK. Reused across runs. .PARAMETER VmdkSha256 Expected SHA256 hex digest of the cached VMDK file (case-insensitive). When non-empty the script throws if the computed hash does not match. Leave empty to skip hash verification (useful during development). .PARAMETER SeedIsoPath Path for the cloud-init seed ISO. Default: \cloud-init-seed.iso. .PARAMETER SshTimeoutSeconds Total time in seconds to wait for SSH port 22 to become reachable. Default: 300. .PARAMETER SshPollIntervalSeconds Seconds between SSH port poll attempts. Default: 10. .PARAMETER ShowGui When set, vmrun powers on the VM with the Workstation GUI visible. Default: headless (nogui) for unattended pipelines. .PARAMETER Force Allow overwriting an existing VM directory. Without this switch, the script aborts if the VMX destination folder already exists. .PARAMETER DiagPassword If specified, adds a chpasswd block to cloud-init user-data that sets this plaintext password on the 'ubuntu' account. Allows console login for diagnostics. Do NOT use in production; remove or omit once the template is verified. .PARAMETER StartFromStep Skip straight to this step number. All derived paths are still computed; artifacts from skipped steps must already exist on disk. Steps: 1=prereqs+vmdk 2=cloud-init files 3=seed ISO 4=vmdk copy+resize 5=VMX 6=power on 7=guest IP 8=SSH port 9=cloud-init verify 10=detach seed ISO .NOTES Does not require elevation. Assumes the host has vmrun.exe and vmware-vdiskmanager.exe in the VMware Workstation directory, and ssh.exe in PATH (OpenSSH for Windows or Git for Windows). .EXAMPLE .\Deploy-LinuxBuild2404.ps1 .EXAMPLE .\Deploy-LinuxBuild2404.ps1 -StartFromStep 6 #> [CmdletBinding()] param( [string] $VMXPath = 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx', [string] $VMName = 'LinuxBuild2404', [string] $CloudUser = 'ci_build', [string] $Hostname = 'ci-linux-template', [string] $Timezone = 'Europe/Rome', [int] $DiskSizeGB = 40, [int] $MemoryMB = 4096, [int] $VCPUCount = 4, [int] $CoresPerSocket = 2, [string] $SnapshotName = 'PostInstall-Linux', [string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation', [string] $SshKeyPath = 'F:\CI\keys\ci_linux', [string] $VmdkUrl = 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.vmdk', [string] $VmdkCachePath = 'F:\CI\ISO\noble-server-cloudimg-amd64.vmdk', # Expected SHA256 of the downloaded VMDK (hex, case-insensitive). # Leave empty to skip hash verification (not recommended for production). [string] $VmdkSha256 = '', [string] $SeedIsoPath = '', [int] $SshTimeoutSeconds = 300, [int] $SshPollIntervalSeconds = 10, [switch] $ShowGui, [switch] $Force, [string] $DiagPassword = '', [ValidateRange(1,10)] [int] $StartFromStep = 1 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # suppress Write-Progress (Test-NetConnection, IWR, etc.) # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- function Write-Step { param([string] $Message, [int] $Step = 0) $skipped = $Step -gt 0 -and $script:StartFromStep -gt $Step if ($skipped) { Write-Host "`n=== $Message === [SKIP]" -ForegroundColor DarkGray } else { Write-Host "`n=== $Message ===" -ForegroundColor Cyan } } function Assert-Step { param( [Parameter(Mandatory)] [string] $StepName, [Parameter(Mandatory)] [string] $Description, [Parameter(Mandatory)] [scriptblock] $Test ) $ok = $false try { $ok = [bool](& $Test) } catch { throw "[$StepName] Validation threw on '$Description': $_" } if (-not $ok) { throw "[$StepName] Validation failed: $Description" } Write-Host " [OK] $Description" -ForegroundColor Green } # IMAPI2FS-based ISO writer. No external deps. Inline C# bridges IStream -> file. $isoWriterCs = @" using System; using System.IO; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; public class IsoStreamWriter2 { [DllImport("shlwapi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] static extern int SHCreateStreamOnFileEx(string pszFile, uint grfMode, uint dwAttributes, bool fCreate, IntPtr pstmTemplate, out IStream ppstm); public static IStream OpenFileAsStream(string path) { IStream s; int hr = SHCreateStreamOnFileEx(path, 0x20, 0, false, IntPtr.Zero, out s); if (hr != 0) throw new Exception("SHCreateStreamOnFileEx 0x" + hr.ToString("X")); return s; } public static void Save(object streamObj, string path) { IStream istream = streamObj as IStream; if (istream == null) { IntPtr unk = Marshal.GetIUnknownForObject(streamObj); try { Guid iid = new Guid("0000000C-0000-0000-C000-000000000046"); IntPtr pStream; int hr = Marshal.QueryInterface(unk, ref iid, out pStream); if (hr != 0) throw new InvalidCastException("QueryInterface(IStream) failed: 0x" + hr.ToString("X")); try { istream = (IStream)Marshal.GetObjectForIUnknown(pStream); } finally { Marshal.Release(pStream); } } finally { Marshal.Release(unk); } } using (FileStream fs = File.Open(path, FileMode.Create, FileAccess.Write)) { byte[] buf = new byte[2048 * 16]; IntPtr pcb = Marshal.AllocHGlobal(8); try { int n; do { istream.Read(buf, buf.Length, pcb); n = Marshal.ReadInt32(pcb); if (n > 0) fs.Write(buf, 0, n); } while (n > 0); } finally { Marshal.FreeHGlobal(pcb); } } } } "@ if (-not ([System.Management.Automation.PSTypeName]'IsoStreamWriter2').Type) { Add-Type -TypeDefinition $isoWriterCs -Language CSharp } function New-IsoFromFolder { param( [Parameter(Mandatory)] [string] $SourceFolder, [Parameter(Mandatory)] [string] $IsoPath, [Parameter(Mandatory)] [string] $VolumeLabel ) if (Test-Path $IsoPath) { Remove-Item $IsoPath -Force } $fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage $result = $null $stream = $null try { # FileSystemsToCreate = 3 = ISO9660 + Joliet (no UDF). # With UDF present (=7), Linux blkid reads the UDF superblock first; # IMAPI2FS leaves the UDF volume label empty, so blkid reports no label # and cloud-init's nocloud detector never finds the 'cidata' device. # ISO9660+Joliet only: blkid reads the ISO9660 PVD label ('CIDATA' after # ISO9660 uppercasing) which cloud-init accepts. # ChooseImageDefaultsForMediaType is intentionally omitted -- it overrides # FileSystemsToCreate and re-enables UDF on some IMAPI versions. $fsi.FileSystemsToCreate = 3 # ISO9660 | Joliet $fsi.VolumeName = $VolumeLabel $fsi.Root.AddTree($SourceFolder, $false) $result = $fsi.CreateResultImage() $stream = $result.ImageStream [IsoStreamWriter2]::Save($stream, $IsoPath) } finally { # Release in reverse order. ImageStream holds file handles to source # files added via AddTree -- must be released before the source folder # can be deleted. if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null } if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null } [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() } } function Invoke-Vmrun { param([Parameter(Mandatory)] [string[]] $Arguments, [switch] $IgnoreErrors, [switch] $Async) $vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe' if ($Async) { # vmrun start can block indefinitely on some Workstation versions; fire async. Start-Process -FilePath $vmrun -ArgumentList $Arguments -NoNewWindow return } $stdout = & $vmrun @Arguments 2>&1 $code = $LASTEXITCODE if ($code -ne 0 -and -not $IgnoreErrors) { throw "vmrun $($Arguments -join ' ') failed (exit $code): $stdout" } return $stdout } function Set-VmxKey { param( [Parameter(Mandatory)] [string] $VmxPath, [Parameter(Mandatory)] [string] $Key, [Parameter(Mandatory)] [string] $Value ) $lines = Get-Content -LiteralPath $VmxPath $pattern = "^\s*$([regex]::Escape($Key))\s*=" $found = $false $newLines = foreach ($line in $lines) { if ($line -match $pattern) { $found = $true; "$Key = `"$Value`"" } else { $line } } if (-not $found) { $newLines = @($newLines) + "$Key = `"$Value`"" } Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII } function Test-VmInList { param([Parameter(Mandatory)] [string] $VmxPath) $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors $forward = $VmxPath -replace '\\', '/' $back = $VmxPath -replace '/', '\' return ($list -match [regex]::Escape($forward)) -or ($list -match [regex]::Escape($back)) } # ----------------------------------------------------------------------------- # Derived paths -- always computed regardless of -StartFromStep # ----------------------------------------------------------------------------- $vmrunExe = Join-Path $VMwareWorkstationDir 'vmrun.exe' $vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe' $vmDir = Split-Path -Parent $VMXPath if ([string]::IsNullOrWhiteSpace($SeedIsoPath)) { $SeedIsoPath = Join-Path $vmDir 'cloud-init-seed.iso' } $stagingDir = Join-Path $vmDir '_cloud_init_staging' $destVmdk = Join-Path $vmDir 'LinuxBuild2404.vmdk' $sshPubKeyPath = $SshKeyPath + '.pub' $guestIP = $null # set in Step 7; pre-init for strict-mode safety # guestinfo base64 payloads -- populated in Step 2, consumed in Step 5. # Initialized here so Set-StrictMode does not throw when Step 2 is skipped. $guestUserDataB64 = '' $guestMetaDataB64 = '' if ($StartFromStep -gt 1) { Write-Host " [SKIP] Steps 1..$($StartFromStep - 1) -- starting from Step $StartFromStep" -ForegroundColor Yellow } # ============================================================================= # Step 1: validate prerequisites + download cloud VMDK # ============================================================================= Write-Step 'Step 1: validate prerequisites + download cloud VMDK' -Step 1 if ($StartFromStep -le 1) { Assert-Step 'PreFlight' "vmrun.exe found at $vmrunExe" { Test-Path $vmrunExe } Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr } Assert-Step 'PreFlight' 'ssh.exe found in PATH' { $null -ne (Get-Command ssh -ErrorAction SilentlyContinue) } Assert-Step 'PreFlight' "SSH private key exists: $SshKeyPath" { Test-Path $SshKeyPath } Assert-Step 'PreFlight' "SSH public key exists: $sshPubKeyPath" { Test-Path $sshPubKeyPath } if (Test-Path $vmDir) { if (-not $Force) { throw "[PreFlight] VM directory already exists: $vmDir`n Use -Force to overwrite." } Write-Host " [WARN] -Force set -- existing VM directory will be reused: $vmDir" -ForegroundColor Yellow } else { New-Item -ItemType Directory -Path $vmDir -Force | Out-Null } Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir } # Step 1b -- Download cloud VMDK if (-not (Test-Path $VmdkCachePath)) { Write-Host "[Deploy] VMDK not found, downloading from $VmdkUrl ..." New-Item -ItemType Directory -Force (Split-Path $VmdkCachePath) | Out-Null $tmp = "$VmdkCachePath.part" try { Invoke-WebRequest -Uri $VmdkUrl -OutFile $tmp -UseBasicParsing Move-Item $tmp $VmdkCachePath Write-Host "[Deploy] Download complete: $VmdkCachePath" } catch { Remove-Item $tmp -ErrorAction SilentlyContinue throw "[Deploy] VMDK download failed: $_" } } else { Write-Host "[Deploy] VMDK found in cache: $VmdkCachePath" } Assert-Step 'PreFlight' "Cloud VMDK cache present: $VmdkCachePath" { Test-Path $VmdkCachePath } # Step 1c -- Verify VMDK SHA256 (when caller provides expected hash) if ($VmdkSha256 -ne '') { Write-Host "[Deploy] Verifying VMDK SHA256 ..." $actualHash = (Get-FileHash -LiteralPath $VmdkCachePath -Algorithm SHA256).Hash if ($actualHash -ne $VmdkSha256.ToUpper()) { throw "[Deploy] VMDK SHA256 mismatch!`n Expected: $($VmdkSha256.ToUpper())`n Actual: $actualHash" } Write-Host "[Deploy] VMDK SHA256 OK: $actualHash" } } # end Step 1 # ============================================================================= # Step 2: generate cloud-init user-data + meta-data # ============================================================================= Write-Step 'Step 2: generate cloud-init user-data + meta-data' -Step 2 if ($StartFromStep -le 2) { $pubKeyContent = (Get-Content -LiteralPath $sshPubKeyPath -Raw).Trim() if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force } New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null $metaData = @" instance-id: linuxbuild-001 local-hostname: $Hostname "@ $userData = @" #cloud-config hostname: $Hostname users: - name: $CloudUser shell: /bin/bash sudo: 'ALL=(ALL) NOPASSWD:ALL' ssh_authorized_keys: - $pubKeyContent "@ # Append chpasswd block when DiagPassword is provided (diagnostic use only). if ($DiagPassword -ne '') { Write-Host ' [WARN] -DiagPassword set: console login enabled for ubuntu user (diagnostic mode)' -ForegroundColor Yellow $userData += "`nchpasswd:`n list: |`n ubuntu:$DiagPassword`n expire: false`n" } $metaDataPath = Join-Path $stagingDir 'meta-data' $userDataPath = Join-Path $stagingDir 'user-data' # PS 5.1 Set-Content -Encoding UTF8 writes a UTF-8 BOM. cloud-init checks that # user-data starts with exactly "#cloud-config"; a BOM prefix breaks this check # and the config is silently ignored. Write without BOM and with LF line endings. $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($metaDataPath, ($metaData -replace "`r`n","`n"), $utf8NoBom) [System.IO.File]::WriteAllText($userDataPath, ($userData -replace "`r`n","`n"), $utf8NoBom) # Pre-encode as base64 for guestinfo (used in Step 5 as backup datasource). $utf8NoBomEnc = [System.Text.UTF8Encoding]::new($false) $script:guestMetaDataB64 = [Convert]::ToBase64String($utf8NoBomEnc.GetBytes(($metaData -replace "`r`n","`n"))) $script:guestUserDataB64 = [Convert]::ToBase64String($utf8NoBomEnc.GetBytes(($userData -replace "`r`n","`n"))) Assert-Step 'CloudInit' "meta-data written: $metaDataPath" { Test-Path $metaDataPath } Assert-Step 'CloudInit' "user-data written: $userDataPath" { Test-Path $userDataPath } Assert-Step 'CloudInit' 'user-data contains SSH public key' { $snippet = $pubKeyContent.Substring(0, [Math]::Min(30, $pubKeyContent.Length)) (Get-Content -LiteralPath $userDataPath -Raw) -match [regex]::Escape($snippet) } } # end Step 2 # ============================================================================= # Step 3: build cloud-init seed ISO (nocloud, volume label: cidata) # ============================================================================= Write-Step 'Step 3: build cloud-init seed ISO (nocloud, label: cidata)' -Step 3 if ($StartFromStep -le 3) { New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $SeedIsoPath -VolumeLabel 'cidata' Assert-Step 'SeedIso' "Seed ISO created: $SeedIsoPath" { Test-Path $SeedIsoPath } Assert-Step 'SeedIso' 'Seed ISO non-empty (> 4 KB)' { (Get-Item $SeedIsoPath).Length -gt 4096 } } # end Step 3 # ============================================================================= # Step 4: copy + resize cloud VMDK # ============================================================================= Write-Step "Step 4: copy + resize cloud VMDK to ${DiskSizeGB} GB" -Step 4 if ($StartFromStep -le 4) { Write-Host " Converting $VmdkCachePath -> $destVmdk (streamOptimized -> monolithicSparse) ..." & $vdiskMgr -r $VmdkCachePath -t 0 $destVmdk if ($LASTEXITCODE -ne 0) { throw "[Deploy Step 4] vmware-vdiskmanager convert failed (exit $LASTEXITCODE)" } Assert-Step 'Vmdk' 'VMDK converted (size > 1 MB)' { (Get-Item $destVmdk).Length -gt 1MB } Write-Host " Resizing VMDK to ${DiskSizeGB} GB ..." & $vdiskMgr -x "${DiskSizeGB}GB" $destVmdk if ($LASTEXITCODE -ne 0) { throw "[Deploy Step 4] vmware-vdiskmanager resize failed (exit $LASTEXITCODE)" } Assert-Step 'Vmdk' "VMDK present after resize: $destVmdk" { Test-Path $destVmdk } } # end Step 4 # ============================================================================= # Step 5: generate VMX # ============================================================================= Write-Step 'Step 5: generate VMX' -Step 5 if ($StartFromStep -le 5) { $vmxLines = @( '.encoding = "UTF-8"', 'config.version = "8"', 'virtualHW.version = "19"', "displayName = `"$VMName`"", 'guestOS = "ubuntu-64"', 'firmware = "efi"', "memsize = `"$MemoryMB`"", "numvcpus = `"$VCPUCount`"", "cpuid.coresPerSocket = `"$CoresPerSocket`"", 'scsi0.present = "TRUE"', 'scsi0.virtualDev = "lsilogic"', 'scsi0:0.present = "TRUE"', 'scsi0:0.fileName = "LinuxBuild2404.vmdk"', 'scsi0:0.deviceType = "scsi-hardDisk"', 'ethernet0.present = "TRUE"', 'ethernet0.connectionType = "nat"', 'ethernet0.virtualDev = "vmxnet3"', 'ethernet0.wakeOnPcktRcv = "FALSE"', 'ethernet0.addressType = "generated"', 'ide1:0.present = "TRUE"', "ide1:0.fileName = `"$SeedIsoPath`"", 'ide1:0.deviceType = "cdrom-image"', 'ide1:0.startConnected = "TRUE"', 'pciBridge0.present = "TRUE"', 'pciBridge4.present = "TRUE"', 'pciBridge4.virtualDev = "pcieRootPort"', 'pciBridge4.functions = "8"', 'pciBridge5.present = "TRUE"', 'pciBridge5.virtualDev = "pcieRootPort"', 'pciBridge5.functions = "8"', 'pciBridge6.present = "TRUE"', 'pciBridge6.virtualDev = "pcieRootPort"', 'pciBridge6.functions = "8"', 'pciBridge7.present = "TRUE"', 'pciBridge7.virtualDev = "pcieRootPort"', 'pciBridge7.functions = "8"', 'floppy0.present = "FALSE"', 'usb.present = "FALSE"', 'sound.present = "FALSE"', 'tools.syncTime = "TRUE"', 'uuid.action = "create"' ) # Belt-and-suspenders: embed cloud-init payload as VMware guestinfo properties. # cloud-init DataSourceVMware reads these via open-vm-tools (pre-installed on # the Ubuntu 24.04 cloud image). This ensures provisioning works even if the # nocloud ISO is not detected by blkid (e.g. label case or filesystem type # mismatch). guestinfo is only added when the base64 strings were computed in # Step 2 (i.e. not skipped). if ($guestUserDataB64 -ne '' -and $guestMetaDataB64 -ne '') { $vmxLines += "guestinfo.userdata = `"$guestUserDataB64`"" $vmxLines += 'guestinfo.userdata.encoding = "base64"' $vmxLines += "guestinfo.metadata = `"$guestMetaDataB64`"" $vmxLines += 'guestinfo.metadata.encoding = "base64"' Write-Host " [INFO] guestinfo cloud-init properties embedded in VMX (backup datasource)." } Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath } } # end Step 5 # ============================================================================= # Step 6: power on VM # ============================================================================= Write-Step 'Step 6: power on VM' -Step 6 if ($StartFromStep -le 6) { $guiMode = if ($ShowGui) { 'gui' } else { 'nogui' } Invoke-Vmrun @('-T','ws','start',$VMXPath,$guiMode) -Async Write-Host "[Deploy] VM powered on (async)." Start-Sleep -Seconds 10 Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath } } # end Step 6 # ============================================================================= # Step 7: detect guest IP via vmrun getGuestIPAddress (poll, max 5 min) # ============================================================================= Write-Step 'Step 7: detect guest IP via vmrun getGuestIPAddress (max 5 min)' -Step 7 if ($StartFromStep -le 7) { $ipDeadline = (Get-Date).AddMinutes(5) $guestIP = $null while ((Get-Date) -lt $ipDeadline) { $ip = & $vmrunExe getGuestIPAddress $VMXPath 2>&1 if ($ip -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { $guestIP = $ip.Trim(); break } Write-Host " ... waiting for guest IP (open-vm-tools)..." Start-Sleep -Seconds 15 } if (-not $guestIP) { throw "[Deploy Step 7] Timed out waiting for guest IP." } Write-Host " [OK] Guest IP: $guestIP" -ForegroundColor Green } # end Step 7 # ============================================================================= # Step 8: poll SSH port 22 until ready # ============================================================================= Write-Step "Step 8: poll SSH port 22 (max ${SshTimeoutSeconds}s)" -Step 8 if ($StartFromStep -le 8) { if (-not $guestIP) { throw "[Deploy Step 8] Guest IP not available. Run from Step 7 or earlier to detect it." } $sshDeadline = (Get-Date).AddSeconds($SshTimeoutSeconds) $sshReady = $false while ((Get-Date) -lt $sshDeadline) { $tc = Test-NetConnection -ComputerName $guestIP -Port 22 -WarningAction SilentlyContinue if ($tc.TcpTestSucceeded) { $sshReady = $true; break } Write-Host " ... waiting for SSH port 22 on $guestIP ..." Start-Sleep -Seconds $SshPollIntervalSeconds } Assert-Step 'SshPort' "SSH port 22 open on $guestIP" { $sshReady } } # end Step 8 # ============================================================================= # Step 9: verify cloud-init completed (boot-finished flag) # ============================================================================= Write-Step 'Step 9: verify cloud-init completed (boot-finished flag)' -Step 9 if ($StartFromStep -le 9) { if (-not $guestIP) { throw "[Deploy Step 9] Guest IP not available. Run from Step 7 or earlier to detect it." } # TCP:22 may open before cloud-init has finished creating ci_build and injecting # the SSH key. With package_update:true, apt can take 3-5 minutes. # Poll until SSH as ci_build succeeds AND boot-finished flag is present. $ciDeadline = (Get-Date).AddSeconds(600) $ciDone = $false Write-Host " Polling cloud-init completion (max 600s, package_update may take a few minutes) ..." while ((Get-Date) -lt $ciDeadline) { # In PS 5.1 with $ErrorActionPreference=Stop, any ssh stderr output (including # the benign "Permanently added" warning) throws NativeCommandError before the # assignment can capture it. Suppressing at the ssh level with LogLevel=ERROR # eliminates the warning entirely; temporarily lowering EAP to Continue means # auth failures ("permission denied") during the cloud-init bootstrap phase # are treated as non-terminating so the loop can keep retrying. $savedEap = $ErrorActionPreference $ErrorActionPreference = 'Continue' $rawOut = (& ssh -i $SshKeyPath ` -o StrictHostKeyChecking=no ` -o UserKnownHostsFile=NUL ` -o LogLevel=ERROR ` -o ConnectTimeout=5 ` -o BatchMode=yes ` "${CloudUser}@${guestIP}" ` "test -f /var/lib/cloud/instance/boot-finished && echo OK" 2>&1) $sshExit = $LASTEXITCODE $ErrorActionPreference = $savedEap $result = ($rawOut | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | Out-String).Trim() $errText = ($rawOut | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | Out-String).Trim() if ($result -eq 'OK') { $ciDone = $true; break } $diagMsg = if ($errText) { $errText } elseif ($result) { $result } else { '(no output)' } Write-Host " ... not ready yet (ssh exit ${sshExit}: $diagMsg) -- retrying in 15s ..." Start-Sleep -Seconds 15 } if (-not $ciDone) { throw "[Deploy Step 9] Timed out (600s) waiting for cloud-init to complete." } Write-Host " [OK] cloud-init completed." -ForegroundColor Green } # end Step 9 # ============================================================================= # Step 10: remove seed ISO from VMX (prevent re-run on clone) # ============================================================================= Write-Step 'Step 10: remove seed ISO from VMX (prevent re-run on clone)' -Step 10 if ($StartFromStep -le 10) { Set-VmxKey -VmxPath $VMXPath -Key 'ide1:0.present' -Value 'FALSE' Set-VmxKey -VmxPath $VMXPath -Key 'ide1:0.startConnected' -Value 'FALSE' Assert-Step 'SeedDetach' 'ide1:0.present = FALSE in VMX' { (Get-Content -LiteralPath $VMXPath) -match '^\s*ide1:0\.present\s*=\s*"FALSE"' } Assert-Step 'SeedDetach' 'ide1:0.startConnected = FALSE in VMX' { (Get-Content -LiteralPath $VMXPath) -match '^\s*ide1:0\.startConnected\s*=\s*"FALSE"' } } # end Step 10 # ============================================================================= # Cleanup staging dir # ============================================================================= [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue # ============================================================================= # Final summary # ============================================================================= Write-Step 'Done' Write-Host "" Write-Host "[Deploy] Summary" Write-Host " VMX: $VMXPath" Write-Host " Guest IP: $guestIP" Write-Host " SSH key: $SshKeyPath" Write-Host " Snapshot name: $SnapshotName (run Prepare-LinuxBuild2404.ps1 next)" Write-Host "" Write-Host " SSH: ssh -i $SshKeyPath ${CloudUser}@${guestIP}" Write-Host "" Write-Host " Next steps (from template\ dir):" Write-Host " .\Prepare-LinuxBuild2404.ps1 -VMXPath '$VMXPath' -VMIPAddress $guestIP" Write-Host "" Write-Host " VM is powered on. cloud-init provisioning complete." -ForegroundColor Green