Files
local-ci-cd-system/template/Deploy-LinuxBuild2404.ps1
T
Simone 4758fbc519 refactor(§7.5): rinomina Setup-* Install-CIToolchain-* (WinBuild2025/2022/Linux2404)
- template/Setup-WinBuild2025.ps1  Install-CIToolchain-WinBuild2025.ps1
- template/Setup-WinBuild2022.ps1  Install-CIToolchain-WinBuild2022.ps1
- template/Setup-LinuxBuild2404.sh  Install-CIToolchain-Linux2404.sh

Aggiornati tutti i riferimenti in: Prepare-WinBuild2025/2022, Prepare-LinuxBuild2404,
Deploy-WinBuild2025/2022/LinuxBuild2404, Validate-DeployState, Validate-SetupState,
README.md, TODO.md, docs/WINDOWS-TEMPLATE-SETUP.md, docs/LINUX-TEMPLATE-SETUP.md,
docs/TEST-PLAN-v1.3-to-HEAD.md, gitea/actions/local-ci-build/action.yml.

§7 chiuso (5/5 done).
2026-05-11 22:48:33 +02:00

680 lines
28 KiB
PowerShell

#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 SeedIsoPath
Path for the cloud-init seed ISO. Default: <VMDir>\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',
[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 }
} # 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