9de86ac289
Task 4.1: Invoke-CIJob.ps1 pre-clone disk gate (needs 10 GB free), JobId/ExtraGuestEnv
pattern validation, redacted command logger (already included via _Common.psm1).
Task 4.2: Get-GuestDiagnostics helper invoked from catch block (best-effort, no throw).
Task 4.3: UseSharedCache wired through composite action and orchestrator.
Task 4.4: SSH known_hosts in F:\CI\State\known_hosts for CI jobs; accept-new for templates.
Task 4.5: Deploy-LinuxBuild2404.ps1 verifies Ubuntu VMDK SHA256 before import.
Task 4.6: RUNBOOK.md consolidated template-refresh section: stop runner, backup, boot,
KMS reactivation, Prepare-*, shutdown, snapshot, Validate-DeployState, smoke,
update config, retain prior snapshot 7 days, rollback procedure.
Validate-SetupState.ps1: minor lint fixes and docblock updates.
Wait-VMReady.ps1: Get-BuildArtifacts.ps1: hardening alignment with Sprint 4 objectives.
698 lines
29 KiB
PowerShell
698 lines
29 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 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: <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',
|
|
# 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
|
|
|