feat(linux): add Ubuntu 24.04 template VM automation scripts

Deploy-LinuxBuild2404.ps1 (host-side):
- Downloads Ubuntu 24.04 cloud VMDK (cached in F:\CI\ISO\)
- Generates cloud-init seed ISO (nocloud + guestinfo backup datasource)
- user-data: user+SSH key only  no packages/runcmd, all provisioning
  delegated to Prepare/Setup (removed -MinimalConfig, now always minimal)
- Assembles VMX, powers on VM, waits for cloud-init completion via SSH
- Removes seed ISO from VMX after first boot (ide1:0 disabled)
- Next-steps message uses correct -VMIPAddress parameter

Prepare-LinuxBuild2404.ps1 (host-side):
- Powers on template VM, waits for SSH readiness
- Copies Setup-LinuxBuild2404.sh into guest, runs it with sudo
- Validates setup result (all tools in PATH, dirs, sshd, swap off)
- Takes snapshot BaseClean-Linux (cold, VM shut down before snapshot)
- -NoMingw switch to skip mingw-w64 cross-compiler installation

Setup-LinuxBuild2404.sh (guest-side):
- apt update/upgrade, build-essential, gcc, g++, clang, cmake, ninja
- python3, pip3, venv; git, p7zip-full, curl, wget, ca-certificates
- Optional .NET SDK (--dotnet), mingw-w64 cross-compiler (--no-mingw to skip)
- /opt/ci/{build,output,scripts,cache} dirs (ci_build ownership)
- SSH hardening (PasswordAuthentication no, PubkeyAuthentication yes)
- Swap disable (swapoff -a + fstab), MAC-agnostic netplan for linked clones
- ci-report-ip systemd service (guestinfo.ci-ip reporter)
- apt auto-update disabled; full validation gate at end
This commit is contained in:
Simone
2026-05-11 18:34:38 +02:00
parent fee5963a65
commit 9fcc46afd6
3 changed files with 1383 additions and 0 deletions
+677
View File
@@ -0,0 +1,677 @@
#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 Setup-LinuxBuild2404.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'
# -----------------------------------------------------------------------------
# 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
+436
View File
@@ -0,0 +1,436 @@
#Requires -Version 5.1
<#
.SYNOPSIS
HOST-side script: delivers and runs Setup-LinuxBuild2404.sh inside the Linux template VM.
.DESCRIPTION
Automates provisioning of the Ubuntu 24.04 template VM from the host machine via SSH/SCP:
1. Pre-flight validation: ssh/scp in PATH, SSH key present, VMX exists (if supplied),
IPv4 address valid, TCP/22 reachable
2. Auto power-on + guest IP detection via vmrun getGuestIPAddress (when -VMXPath provided)
3. Add host key to known_hosts (idempotent, uses StrictHostKeyChecking=accept-new)
4. Copy Setup-LinuxBuild2404.sh to /tmp on guest via scp + validate file size
5. Execute Setup-LinuxBuild2404.sh via SSH (stdout/stderr streamed to host console)
6. Post-setup remote validation batch check (user, sudo, tools, CI dirs, swap, sshd)
7. Graceful shutdown + vmrun snapshot (when -TakeSnapshot or -VMXPath provided)
Final: print summary
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
The snapshot is only taken after this script exits 0.
PREREQUISITES (do these manually before running this script):
a. The VM was created by Deploy-LinuxBuild2404.ps1 and cloud-init has completed
b. The ci_build SSH public key ($SshKeyPath.pub) is already authorised in the guest
(cloud-init in Deploy-LinuxBuild2404.ps1 injects the public key automatically)
c. The VM NIC is on VMnet8 (NAT) with internet access
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
If -TakeSnapshot was NOT set and -VMXPath was NOT provided:
1. Shut down the VM manually
2. Take a snapshot named '$SnapshotName' in VMware Workstation
3. Use -SnapshotName '$SnapshotName' when calling New-BuildVM.ps1
.PARAMETER VMXPath
Full path to the template VM's .vmx file.
When provided:
- If the VM is powered off, the script starts it automatically via vmrun.
- The guest IP is auto-detected via vmrun getGuestIPAddress (requires open-vm-tools).
- At the end, the VM is shut down and a snapshot is taken automatically.
Either -VMXPath or -VMIPAddress (or both) must be supplied.
.PARAMETER VMIPAddress
IP address of the template VM on VMnet8 NAT.
Optional when -VMXPath is provided (IP is auto-detected via open-vm-tools).
Required when -VMXPath is omitted.
Example: 192.168.79.131
.PARAMETER CloudUser
SSH username in the guest VM. Default: ci_build.
.PARAMETER SshKeyPath
Path to the SSH private key on the host. Default: F:\CI\keys\ci_linux.
The corresponding public key is expected at $SshKeyPath.pub.
.PARAMETER SnapshotName
Name of the snapshot to create. Default: BaseClean-Linux.
Must match the snapshot name used in New-BuildVM.ps1.
.PARAMETER SkipUpdate
Pass --skip-update to Setup-LinuxBuild2404.sh (skips apt-get upgrade).
.PARAMETER InstallDotNet
Pass --dotnet to Setup-LinuxBuild2404.sh (installs .NET SDK).
.PARAMETER NoMingw
Pass --no-mingw to Setup-LinuxBuild2404.sh (skips MinGW cross-compiler).
MinGW is installed by default; use this only to skip it.
.PARAMETER TakeSnapshot
Shut down the VM and take a vmrun snapshot after provisioning completes.
Implied automatically when -VMXPath is provided (snapshot is always taken
when the script manages the VM lifecycle via vmrun).
.PARAMETER VMwareWorkstationDir
Installation directory of VMware Workstation on the host.
Default: C:\Program Files (x86)\VMware\VMware Workstation
.EXAMPLE
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
.\Prepare-LinuxBuild2404.ps1 -VMXPath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx'
# Explicit IP, skip update (VM already running, no snapshot):
.\Prepare-LinuxBuild2404.ps1 -VMIPAddress '192.168.79.131' -SkipUpdate
# VMX + .NET SDK + explicit snapshot:
.\Prepare-LinuxBuild2404.ps1 `
-VMXPath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' `
-InstallDotNet -TakeSnapshot
.NOTES
Requires:
- OpenSSH client on the host (ssh.exe + scp.exe in PATH)
- VMware Workstation with vmrun.exe (only needed when -VMXPath is supplied)
- open-vm-tools installed in the guest (for vmrun getGuestIPAddress)
- The SSH private key at $SshKeyPath with correct permissions
#>
[CmdletBinding()]
param(
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
[string] $VMXPath = '',
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via open-vm-tools).
[string] $VMIPAddress = '',
[string] $CloudUser = 'ci_build',
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
# Snapshot name (case-sensitive — must match the name used in New-BuildVM.ps1)
[string] $SnapshotName = 'BaseClean-Linux',
[switch] $SkipUpdate,
[switch] $InstallDotNet,
[switch] $NoMingw,
# Shut down VM and take snapshot after provisioning (implied when -VMXPath is provided)
[switch] $TakeSnapshot,
[string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
$vmrunExe = Join-Path $VMwareWorkstationDir 'vmrun.exe'
if (-not (Test-Path $vmrunExe)) {
# Fallback: try the 64-bit install path or PATH
$alt64 = 'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
if (Test-Path $alt64) {
$vmrunExe = $alt64
} else {
$vmrunInPath = Get-Command vmrun -ErrorAction SilentlyContinue
if ($vmrunInPath) {
$vmrunExe = $vmrunInPath.Source
} else {
throw "[Prepare] vmrun.exe not found at '$vmrunExe'. Install VMware Workstation or add it to PATH."
}
}
}
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
throw "[Prepare] Supply -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
}
# ── Auto power-on + IP detection (when VMXPath provided) ─────────────────────
if ($VMXPath -ne '') {
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
$runningList = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } |
ForEach-Object { $_.Trim() })
if ($runningList -notcontains $VMXPath) {
Write-Host "[Prepare] VM not running — starting: $VMXPath"
& $vmrunExe start $VMXPath nogui
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start failed (exit $LASTEXITCODE)." }
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
} else {
Write-Host "[Prepare] VM already running: $VMXPath"
}
if ($VMIPAddress -eq '') {
Write-Host "[Prepare] Detecting guest IP via open-vm-tools (getGuestIPAddress -wait)..."
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure open-vm-tools is running in the guest."
}
$VMIPAddress = $detectedIP
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
}
}
# ── Helper functions ──────────────────────────────────────────────────────────
function Assert-Step {
param(
[Parameter(Mandatory)] [string] $StepName,
[Parameter(Mandatory)] [string] $Description,
[Parameter(Mandatory)] [scriptblock] $Test
)
$ok = $false
try { $ok = [bool](& $Test) }
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
if (-not $ok) { throw "[Prepare/$StepName] Validation failed: $Description" }
Write-Host " [OK] $Description" -ForegroundColor Green
}
function Invoke-SshCommand {
param(
[Parameter(Mandatory)] [string] $IP,
[Parameter(Mandatory)] [string] $User,
[Parameter(Mandatory)] [string] $KeyPath,
[Parameter(Mandatory)] [string] $Command,
[switch] $PassThru # return stdout instead of printing
)
$sshArgs = @(
'-i', $KeyPath,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=NUL',
'-o', 'LogLevel=ERROR',
'-o', 'ConnectTimeout=10',
'-o', 'BatchMode=yes',
"$User@$IP",
$Command
)
if ($PassThru) {
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
$out = & ssh @sshArgs 2>&1
$sshRc = $LASTEXITCODE
$ErrorActionPreference = $savedEap
if ($sshRc -ne 0) { throw "[SSH] Command failed (exit $sshRc): $Command`nOutput: $out" }
return ($out | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] })
} else {
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
& ssh @sshArgs
$sshRc = $LASTEXITCODE
$ErrorActionPreference = $savedEap
if ($sshRc -ne 0) { throw "[SSH] Command failed (exit $sshRc): $Command" }
}
}
# ── Step 1 — Pre-flight validation ───────────────────────────────────────────
Write-Host "[Prepare] Step 1 — Pre-flight validation"
Assert-Step 'PreFlight' 'ssh.exe found in PATH' {
$null -ne (Get-Command ssh -ErrorAction SilentlyContinue)
}
Assert-Step 'PreFlight' 'scp.exe found in PATH' {
$null -ne (Get-Command scp -ErrorAction SilentlyContinue)
}
Assert-Step 'PreFlight' "SSH private key exists: $SshKeyPath" {
Test-Path $SshKeyPath -PathType Leaf
}
Assert-Step 'PreFlight' "SSH public key exists: $SshKeyPath.pub" {
Test-Path "$SshKeyPath.pub" -PathType Leaf
}
if ($VMXPath -ne '') {
Assert-Step 'PreFlight' "VMX file exists: $VMXPath" {
Test-Path $VMXPath -PathType Leaf
}
}
# ── Step 2 — Validate IP address ─────────────────────────────────────────────
Write-Host "[Prepare] Step 2 — Validate guest IP address"
Assert-Step 'IPValidation' "VMIPAddress '$VMIPAddress' matches IPv4 pattern" {
$VMIPAddress -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
}
Write-Host " Guest IP: $VMIPAddress"
# ── Step 3 — Wait for SSH port 22 ────────────────────────────────────────────
Write-Host "[Prepare] Step 3 — Waiting for SSH port 22 on $VMIPAddress (timeout 180 s)..."
$sshDeadline = (Get-Date).AddSeconds(180)
$sshReady = $false
while ((Get-Date) -lt $sshDeadline) {
if (Test-NetConnection -ComputerName $VMIPAddress -Port 22 `
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
$sshReady = $true
break
}
Start-Sleep -Seconds 10
Write-Host " ... waiting for SSH..."
}
Assert-Step 'SSHPort' "$VMIPAddress TCP/22 reachable" { $sshReady }
# ── Step 4 — Add host key to known_hosts ─────────────────────────────────────
Write-Host "[Prepare] Step 4 — Adding host key to known_hosts (StrictHostKeyChecking=accept-new)..."
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
& ssh -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o LogLevel=ERROR -o ConnectTimeout=10 `
-o BatchMode=yes "$CloudUser@$VMIPAddress" 'echo connected' 2>&1 | Out-Null
$ErrorActionPreference = $savedEap
if ($LASTEXITCODE -ne 0) {
throw "[Prepare Step 4] SSH connectivity test failed (exit $LASTEXITCODE). Check key, user, and IP."
}
Write-Host " [OK] SSH connectivity confirmed, host key added."
# ── Step 5 — Copy Setup-LinuxBuild2404.sh into VM ────────────────────────────
Write-Host "[Prepare] Step 5 — Copying Setup-LinuxBuild2404.sh to guest..."
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$setupScript = Join-Path $scriptDir 'Setup-LinuxBuild2404.sh'
$guestScript = '/tmp/Setup-LinuxBuild2404.sh'
if (-not (Test-Path $setupScript -PathType Leaf)) {
throw "[Prepare Step 5] Setup-LinuxBuild2404.sh not found alongside this script: $setupScript"
}
# Strip UTF-8 BOM and normalize CRLF→LF before copying.
# PS 5.1 / VS Code on Windows may save .sh files with BOM or CRLF; both break bash.
$shBytes = [System.IO.File]::ReadAllBytes($setupScript)
if ($shBytes.Length -ge 3 -and $shBytes[0] -eq 0xEF -and $shBytes[1] -eq 0xBB -and $shBytes[2] -eq 0xBF) {
Write-Host " [WARN] UTF-8 BOM detected in Setup-LinuxBuild2404.sh — stripping before copy"
$shBytes = $shBytes[3..($shBytes.Length - 1)]
}
$shText = [System.Text.Encoding]::UTF8.GetString($shBytes) -replace "`r`n", "`n" -replace "`r", "`n"
$tmpScript = Join-Path $env:TEMP 'Setup-LinuxBuild2404-ci.sh'
[System.IO.File]::WriteAllText($tmpScript, $shText, [System.Text.UTF8Encoding]::new($false))
$setupScript = $tmpScript
Write-Host " Source: $setupScript (BOM/CRLF-cleaned)"
Write-Host " Dest: $guestScript"
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
& scp -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o LogLevel=ERROR `
$setupScript "${CloudUser}@${VMIPAddress}:${guestScript}" 2>&1 | Out-Null
$scpRc = $LASTEXITCODE
$ErrorActionPreference = $savedEap
if ($scpRc -ne 0) { throw "[Prepare Step 5] scp failed (exit $scpRc)." }
# Validate: compare local size vs remote size (allow +-2 bytes for LF/CRLF variance)
$localSize = (Get-Item $setupScript).Length
$remoteSizeRaw = (Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
-Command "stat -c %s $guestScript" -PassThru).Trim()
$remoteSize = [int]$remoteSizeRaw
Assert-Step 'FileCopy' "guest script size matches local ($localSize bytes, remote $remoteSize bytes, tolerance 2)" {
[Math]::Abs($remoteSize - $localSize) -le 2
}
# ── Step 6 — Execute setup script ────────────────────────────────────────────
Write-Host "[Prepare] Step 6 — Running Setup-LinuxBuild2404.sh in guest (may take several minutes)..."
$setupFlags = [System.Collections.Generic.List[string]]::new()
if ($SkipUpdate) { $setupFlags.Add('--skip-update') }
if ($InstallDotNet) { $setupFlags.Add('--dotnet') }
if ($NoMingw) { $setupFlags.Add('--no-mingw') }
$flagsStr = $setupFlags -join ' '
$remoteCmd = "chmod +x $guestScript && sudo $guestScript $flagsStr".TrimEnd()
Write-Host " Command: $remoteCmd"
Write-Host "------------------------------------------------------------"
& ssh -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o ConnectTimeout=5 `
-o BatchMode=yes "$CloudUser@$VMIPAddress" $remoteCmd
$setupExitCode = $LASTEXITCODE
Write-Host "------------------------------------------------------------"
if ($setupExitCode -ne 0) {
throw "[Prepare Step 6] Setup-LinuxBuild2404.sh exited $setupExitCode — see output above."
}
Write-Host "[Prepare] Setup script completed (exit 0)." -ForegroundColor Green
# ── Step 7 — Post-setup remote validation ────────────────────────────────────
Write-Host "[Prepare] Step 7 — Running post-setup validation..."
$validationScript = @'
set -e
( id ci_build | grep -q sudo || sudo -l -U ci_build 2>/dev/null | grep -q NOPASSWD ) && echo "UserSudo:OK" || echo "UserSudo:FAIL"
sudo -n true 2>/dev/null && echo "SudoNoPass:OK" || echo "SudoNoPass:FAIL"
for t in gcc g++ clang cmake python3 git 7z; do command -v "$t" > /dev/null && echo "Tool:$t:OK" || echo "Tool:$t:FAIL"; done
test -d /opt/ci/build && echo "Dir:build:OK" || echo "Dir:build:FAIL"
test -d /opt/ci/output && echo "Dir:output:OK" || echo "Dir:output:FAIL"
test -d /opt/ci/scripts && echo "Dir:scripts:OK" || echo "Dir:scripts:FAIL"
swapon --show | grep -q . && echo "Swap:ACTIVE:FAIL" || echo "Swap:off:OK"
systemctl is-active ssh > /dev/null && echo "SSHD:active:OK" || echo "SSHD:inactive:FAIL"
grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config && echo "SSHPwdAuth:disabled:OK" || echo "SSHPwdAuth:enabled:FAIL"
systemctl is-enabled ci-report-ip.service > /dev/null 2>&1 && echo "CIReportIP:enabled:OK" || echo "CIReportIP:enabled:FAIL"
test -x /usr/local/bin/ci-report-ip.sh && echo "CIReportIPScript:exists:OK" || echo "CIReportIPScript:exists:FAIL"
'@
$results = Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
-Command $validationScript -PassThru
Write-Host "[Prepare] Validation results:"
$results | ForEach-Object { Write-Host " $_" }
$failures = @($results | Where-Object { $_ -match ':FAIL' })
if ($failures.Count -gt 0) {
throw "[Prepare Step 7] Post-setup validation failed:`n$($failures -join "`n")"
}
Write-Host "[Prepare] All post-setup checks passed." -ForegroundColor Green
# ── Step 8 — Shutdown + snapshot ─────────────────────────────────────────────
if ($TakeSnapshot -or $VMXPath -ne '') {
Write-Host "[Prepare] Step 8 — Sending graceful shutdown to guest..."
try {
Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
-Command 'sudo shutdown -h now' -PassThru | Out-Null
} catch {
Write-Warning "[Prepare] Shutdown command may have failed (VM may have already started shutting down): $_"
}
if ($VMXPath -ne '') {
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
$deadline = (Get-Date).AddSeconds(120)
$poweredOff = $false
while ((Get-Date) -lt $deadline) {
$listOut = & $vmrunExe list 2>&1 | Out-String
if ($listOut -notmatch [regex]::Escape($VMXPath)) {
$poweredOff = $true
break
}
Start-Sleep -Seconds 5
Write-Host " ... waiting for shutdown..."
}
if (-not $poweredOff) { throw "[Prepare Step 8] VM did not power off within 120 s." }
# Delete existing snapshot with the same name so vmrun can create a fresh one.
$existingSnaps = (& $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String)
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
Write-Host "[Prepare] Snapshot '$SnapshotName' already exists — deleting before recreating..."
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare Step 8] vmrun deleteSnapshot failed (exit $LASTEXITCODE)." }
Write-Host "[Prepare] Old snapshot deleted."
}
Write-Host "[Prepare] Taking snapshot '$SnapshotName'..."
& $vmrunExe snapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare Step 8] vmrun snapshot failed (exit $LASTEXITCODE)." }
Assert-Step 'Snapshot' "snapshot '$SnapshotName' listed in vmrun listSnapshots" {
$snaps = (& $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String)
$snaps -match [regex]::Escape($SnapshotName)
}
Write-Host "[Prepare] Snapshot '$SnapshotName' created." -ForegroundColor Green
} else {
Write-Host "[Prepare] -VMXPath not provided — snapshot skipped. Shut down and snapshot manually."
}
} else {
Write-Host "[Prepare] -TakeSnapshot not set — leaving VM running. Shut down and snapshot manually when ready."
}
# ── Final — Print summary ─────────────────────────────────────────────────────
Write-Host ""
Write-Host "[Prepare] Summary" -ForegroundColor Cyan
Write-Host " Guest IP: $VMIPAddress"
Write-Host " SSH key: $SshKeyPath"
if ($VMXPath -ne '') { Write-Host " VMX: $VMXPath" }
if ($TakeSnapshot -or $VMXPath -ne '') {
Write-Host " Snapshot: $SnapshotName — ready for New-BuildVM.ps1 -SnapshotName '$SnapshotName'"
} else {
Write-Host " Snapshot: not taken — run manually then use -SnapshotName '$SnapshotName'"
}
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# Setup-LinuxBuild2404.sh
# Guest-side CI toolchain setup for Ubuntu 24.04 LTS build template.
# Run as ci_build (passwordless sudo) via: sudo /tmp/Setup-LinuxBuild2404.sh
# Called by: template/Prepare-LinuxBuild2404.ps1 (host-side)
#
# Usage:
# sudo /tmp/Setup-LinuxBuild2404.sh [--skip-update] [--dotnet] [--mingw]
#
# Options:
# --skip-update Skip apt update + upgrade (faster re-runs)
# --dotnet Install .NET SDK 10.0 (optional, adds ~500 MB)
# --no-mingw Skip mingw-w64 cross-compiler (installed by default)
#
# Exit codes:
# 0 All steps passed, system ready for snapshot
# 1 One or more steps failed (output shows which step and why)
set -euo pipefail
# --- Option parsing ---
SKIP_UPDATE=0
INSTALL_DOTNET=0
INSTALL_MINGW=1
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-update) SKIP_UPDATE=1 ;;
--dotnet) INSTALL_DOTNET=1 ;;
--no-mingw) INSTALL_MINGW=0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
shift
done
# --- Assert helper ---
STEP=0
assert_step() {
local desc="$1"
shift
STEP=$((STEP + 1))
if "$@"; then
echo " [OK] [Step ${STEP}] ${desc}"
else
echo " [FAIL] [Step ${STEP}] ${desc}" >&2
exit 1
fi
}
# --- Step 1: apt update + upgrade ---
echo ""
echo "=== Step 1: apt update + upgrade ==="
if [ "${SKIP_UPDATE:-0}" = "0" ]; then
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
assert_step "apt update + upgrade completed" true
else
echo " [SKIP] --skip-update flag set"
fi
# --- Step 2: build essentials ---
echo ""
echo "=== Step 2: build essentials ==="
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential gcc g++ clang \
make cmake ninja-build \
pkg-config autoconf automake libtool
assert_step "gcc available" command -v gcc
assert_step "g++ available" command -v g++
assert_step "clang available" command -v clang
assert_step "cmake available" command -v cmake
assert_step "make available" command -v make
# --- Step 3: Python ---
echo ""
echo "=== Step 3: Python ==="
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
sudo ln -sf /usr/bin/python3 /usr/local/bin/python
assert_step "python3 available" command -v python3
assert_step "pip3 available" command -v pip3
# --- Step 4: Tier-1 toolchain ---
echo ""
echo "=== Step 4: Tier-1 toolchain (git, 7-zip, curl, wget) ==="
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git p7zip-full curl wget ca-certificates
assert_step "git available" command -v git
assert_step "7z available" command -v 7z
assert_step "curl available" command -v curl
# --- Step 4b: .NET SDK (optional) ---
echo ""
echo "=== Step 4b: .NET SDK (optional) ==="
if [ "${INSTALL_DOTNET:-0}" = "1" ]; then
curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh \
| sudo bash -s -- --channel 10.0 --install-dir /usr/local/dotnet
sudo ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet
assert_step "dotnet available" command -v dotnet
else
echo " [SKIP] --dotnet not specified"
fi
# --- Step 4c: mingw-w64 cross-compiler (default ON: required by ns7zip Linux build) ---
echo ""
echo "=== Step 4c: mingw-w64 cross-compiler ==="
if [ "$INSTALL_MINGW" = "1" ]; then
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
binutils-mingw-w64-i686 binutils-mingw-w64-x86-64 \
gcc-mingw-w64-i686 gcc-mingw-w64-x86-64 \
g++-mingw-w64-i686 g++-mingw-w64-x86-64
assert_step "i686-w64-mingw32-gcc available" command -v i686-w64-mingw32-gcc
assert_step "x86_64-w64-mingw32-gcc available" command -v x86_64-w64-mingw32-gcc
assert_step "i686-w64-mingw32-g++ available" command -v i686-w64-mingw32-g++
assert_step "x86_64-w64-mingw32-g++ available" command -v x86_64-w64-mingw32-g++
else
echo " [SKIP] --no-mingw passed, skipping mingw-w64"
fi
# --- Step 5: CI directories + sudo group ---
echo ""
echo "=== Step 5: CI directories ==="
sudo mkdir -p /opt/ci/{build,output,scripts,cache}
sudo chown -R ci_build:ci_build /opt/ci
# Ensure ci_build is in the sudo group (belt-and-suspenders alongside sudoers.d entry)
sudo usermod -aG sudo ci_build
assert_step "/opt/ci/build exists" test -d /opt/ci/build
assert_step "/opt/ci/output exists" test -d /opt/ci/output
assert_step "/opt/ci/scripts exists" test -d /opt/ci/scripts
assert_step "/opt/ci/cache exists" test -d /opt/ci/cache
assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build"
# --- Step 6: SSH hardening ---
echo ""
echo "=== Step 6: SSH hardening ==="
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
sudo systemctl restart ssh
assert_step "PasswordAuthentication no in sshd_config" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config
assert_step "sshd service active" systemctl is-active ssh
# --- Step 7: disable swap ---
echo ""
echo "=== Step 7: disable swap ==="
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]'
# --- Step 7a: MAC-agnostic netplan for linked clones ---
# Cloud-init generates /etc/netplan/50-cloud-init.yaml with the template's MAC
# pinned via `match: macaddress: ...`. When VMware creates a linked clone with
# a new MAC, that match fails and the clone has no network. We add a second
# netplan file that matches any en* interface by name, so any clone (any MAC)
# gets DHCP. The two files are merged; if 50-cloud-init also matches, fine —
# we just guarantee DHCP works regardless.
echo ""
echo "=== Step 7a: MAC-agnostic netplan for clones ==="
sudo tee /etc/netplan/99-ci-dhcp-allnics.yaml > /dev/null << 'EONETPLAN'
network:
version: 2
ethernets:
ci-all-en:
match:
name: "en*"
dhcp4: true
dhcp6: false
EONETPLAN
sudo chmod 600 /etc/netplan/99-ci-dhcp-allnics.yaml
sudo chown root:root /etc/netplan/99-ci-dhcp-allnics.yaml
sudo netplan generate
assert_step "99-ci-dhcp-allnics.yaml exists" test -f /etc/netplan/99-ci-dhcp-allnics.yaml
# --- Step 7b: CI IP reporter via VMware guestinfo ---
# The host reads the guest IP via `vmrun readVariable guestVar ci-ip`.
# IMPORTANT: vmware-rpctool sets 'guestinfo.ci-ip' but vmrun reads it as just
# 'ci-ip' under the guestVar namespace (the 'guestinfo.' prefix is implicit).
# This is the official VMware VMCI channel — works even when TCP/IP is not yet
# fully initialised on the host. Used as primary; getGuestIPAddress is fallback.
echo ""
echo "=== Step 7b: CI IP reporter ==="
sudo tee /usr/local/bin/ci-report-ip.sh > /dev/null << 'EOSCRIPT'
#!/bin/bash
# Writes the VM's primary IPv4 to VMware guestinfo.ci-ip on every boot.
# Called by ci-report-ip.service.
# Retries for up to 60s to tolerate open-vm-tools re-init after UUID change
# (linked clones get a new UUID — vmware-rpctool may fail briefly on first boot).
for i in $(seq 1 30); do
IP=$(ip -4 route get 1.0.0.0 2>/dev/null \
| awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' \
| head -1)
if [ -n "$IP" ]; then
if /usr/bin/vmware-rpctool "info-set guestinfo.ci-ip $IP" 2>/dev/null; then
echo "ci-report-ip: set guestinfo.ci-ip=$IP (attempt $i)" | systemd-cat -t ci-report-ip
exit 0
fi
fi
sleep 2
done
echo "ci-report-ip: failed to set guestinfo.ci-ip after 30 attempts" | systemd-cat -t ci-report-ip
exit 1
EOSCRIPT
sudo chmod +x /usr/local/bin/ci-report-ip.sh
sudo tee /etc/systemd/system/ci-report-ip.service > /dev/null << 'EOSERVICE'
[Unit]
Description=Report CI VM IP to VMware host via guestinfo
After=open-vm-tools.service network.target
Wants=open-vm-tools.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ci-report-ip.sh
RemainAfterExit=yes
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target
EOSERVICE
sudo systemctl daemon-reload
sudo systemctl enable ci-report-ip.service
assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service
# --- Step 8: disable apt auto-update ---
echo ""
echo "=== Step 8: disable apt auto-update ==="
sudo systemctl mask apt-daily.service apt-daily-upgrade.service \
apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y unattended-upgrades 2>/dev/null || true
assert_step "apt-daily.timer masked or inactive" bash -c \
'systemctl is-enabled apt-daily.timer 2>/dev/null | grep -qE "masked|disabled" || true'
# --- Step 9: cleanup pre-snapshot ---
echo ""
echo "=== Step 9: cleanup pre-snapshot ==="
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
sudo journalctl --vacuum-time=1d 2>/dev/null || true
sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true
history -c 2>/dev/null || true
cat /dev/null > ~/.bash_history 2>/dev/null || true
assert_step "apt cache cleaned" bash -c '[ -z "$(ls /var/cache/apt/archives/*.deb 2>/dev/null)" ]'
# --- Final Gate: pre-snapshot validation ---
echo ""
echo "=== Final Gate: pre-snapshot validation ==="
for tool in gcc g++ clang cmake make git 7z curl python3 pip3; do
assert_step "${tool} in PATH" command -v "${tool}"
done
for d in /opt/ci/build /opt/ci/output /opt/ci/scripts /opt/ci/cache; do
assert_step "${d} exists" test -d "${d}"
done
assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build"
assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]'
assert_step "sshd active" systemctl is-active ssh
assert_step "PasswordAuthentication no" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config
assert_step "root fs >= 38 GB" bash -c \
'df -BG / | awk '\''NR==2{gsub("G","",$2); if ($2+0 < 38) {print "FAIL: root fs too small ("$2"G)"; exit 1}}'\'''
assert_step "/tmp writable" bash -c 'touch /tmp/.ci_tmpcheck && rm /tmp/.ci_tmpcheck'
assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service
echo ""
echo "=== Setup-LinuxBuild2404.sh complete ==="
echo " All steps passed. VM is ready for BaseClean-Linux snapshot."
echo " Run Prepare-LinuxBuild2404.ps1 --Step7Shutdown + snapshot from host."