Files
pikasTech-unidesk/scripts/assets/platform-infra/hyperv-vm-bootstrap.ps1
T
2026-07-20 03:17:57 +02:00

546 lines
29 KiB
PowerShell

param(
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Net.Http
function Read-Config {
if (-not (Test-Path -LiteralPath $ConfigPath)) {
throw "config-not-found: $ConfigPath"
}
return Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json
}
$Config = Read-Config
$StateDir = [string]$Config.host.stateDir
$StatusPath = Join-Path $StateDir 'status.json'
$LogPath = Join-Path $StateDir 'install.log'
$LockPath = Join-Path $StateDir 'bootstrap.lock'
$script:LockStream = $null
$StartedAt = [DateTimeOffset]::UtcNow
$CurrentStage = 'initializing'
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
function Acquire-BootstrapLock {
try {
$script:LockStream = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
$bytes = [Text.Encoding]::UTF8.GetBytes("pid=$PID`nstartedAt=$($StartedAt.ToString('o'))`n")
$script:LockStream.Write($bytes, 0, $bytes.Length)
$script:LockStream.Flush($true)
} catch [System.IO.IOException] {
$owner = if (Test-Path -LiteralPath $LockPath) { Get-Content -Raw -LiteralPath $LockPath -ErrorAction SilentlyContinue } else { 'unknown' }
if ($owner -match 'pid=([0-9]+)' -and (Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue)) {
throw "bootstrap-already-running: $owner"
}
Remove-Item -Force -LiteralPath $LockPath -ErrorAction SilentlyContinue
$script:LockStream = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
$bytes = [Text.Encoding]::UTF8.GetBytes("pid=$PID`nstartedAt=$($StartedAt.ToString('o'))`n")
$script:LockStream.Write($bytes, 0, $bytes.Length)
$script:LockStream.Flush($true)
}
}
function Rotate-Log {
if (-not (Test-Path -LiteralPath $LogPath)) { return }
$maxBytes = [int64]$Config.status.logMaxBytes
if ((Get-Item -LiteralPath $LogPath).Length -lt $maxBytes) { return }
$backups = [int]$Config.status.logBackups
for ($index = $backups - 1; $index -ge 1; $index--) {
$source = "$LogPath.$index"
$destination = "$LogPath.$($index + 1)"
if (Test-Path -LiteralPath $source) { Move-Item -Force -LiteralPath $source -Destination $destination }
}
Move-Item -Force -LiteralPath $LogPath -Destination "$LogPath.1"
}
function Write-Log([string]$Message) {
Rotate-Log
$line = '{0} {1}' -f ([DateTimeOffset]::Now.ToString('yyyy-MM-dd HH:mm:ss zzz')), $Message
Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
}
function Write-State {
param(
[string]$Stage,
[string]$State = 'running',
[string]$Message = '',
[hashtable]$Extra = @{}
)
$script:CurrentStage = $Stage
$now = [DateTimeOffset]::UtcNow
$payload = [ordered]@{
ok = $State -eq 'completed'
state = $State
stage = $Stage
message = $Message
targetId = [string]$Config.targetId
vmName = [string]$Config.vm.name
startedAt = $StartedAt.ToString('o')
updatedAt = $now.ToString('o')
elapsedSeconds = [math]::Round(($now - $StartedAt).TotalSeconds, 1)
logPath = $LogPath
errorCode = $null
recoveryHint = $null
}
foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] }
$temporary = "$StatusPath.tmp"
[System.IO.File]::WriteAllText($temporary, ($payload | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
Move-Item -Force -LiteralPath $temporary -Destination $StatusPath
Write-Log "stage=$Stage state=$State message=$Message"
}
function Assert-Administrator {
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'administrator-required: approve the Windows UAC prompt'
}
}
function Select-ImageSource {
Write-State -Stage 'selecting-image-source' -Message 'Testing YAML-declared Ubuntu image sources'
foreach ($source in @($Config.image.sources)) {
try {
$request = [System.Net.HttpWebRequest]::Create([string]$source.url)
$request.Method = 'GET'
$request.UserAgent = 'Mozilla/5.0 UniDesk-HyperV/1.0'
$request.AddRange(0, 0)
$request.Timeout = 15000
$response = $request.GetResponse()
$contentRange = [string]$response.Headers['Content-Range']
$length = if ($contentRange -match '/([0-9]+)$') { [int64]$Matches[1] } else { [int64]$response.ContentLength }
$response.Close()
if ($length -gt 0) {
Write-Log "selected_image_source=$($source.id) region=$($source.region) bytes=$length"
return [ordered]@{ id = [string]$source.id; url = [string]$source.url; bytes = $length }
}
} catch {
Write-Log "image_source_unavailable=$($source.id) error=$($_.Exception.Message)"
}
}
throw 'image-source-unavailable: no YAML-declared Ubuntu image source is reachable'
}
function Download-FileWithMetrics {
param(
[string]$Url,
[string]$Destination,
[int64]$ExpectedBytes,
[string]$ExpectedHash,
[string]$HashAlgorithm,
[string]$Stage,
[string]$Message
)
if (Test-Path -LiteralPath $Destination) {
$existingHash = (Get-FileHash -Algorithm $HashAlgorithm -LiteralPath $Destination).Hash.ToLowerInvariant()
if ($existingHash -eq $ExpectedHash.ToLowerInvariant()) {
Write-Log 'download_cache_hit=true'
return
}
Remove-Item -Force -LiteralPath $Destination
}
Write-State -Stage $Stage -Message $Message -Extra @{ sourceUrl = $Url; downloadedBytes = 0; totalBytes = $ExpectedBytes; bytesPerSecond = 0; etaSeconds = $null }
$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromSeconds([int]$Config.status.downloadTimeoutSeconds)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('Mozilla/5.0 UniDesk-HyperV/1.0')
$response = $client.GetAsync($Url, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult()
$response.EnsureSuccessStatusCode()
$total = if ($response.Content.Headers.ContentLength) { [int64]$response.Content.Headers.ContentLength } else { $ExpectedBytes }
$input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
$output = [System.IO.File]::Open($Destination, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try {
$buffer = New-Object byte[] (4MB)
$downloaded = [int64]0
$watch = [Diagnostics.Stopwatch]::StartNew()
$lastReport = 0.0
while (($read = $input.Read($buffer, 0, $buffer.Length)) -gt 0) {
$output.Write($buffer, 0, $read)
$downloaded += $read
if (($watch.Elapsed.TotalSeconds - $lastReport) -ge 1) {
$seconds = [math]::Max($watch.Elapsed.TotalSeconds, 0.001)
$speed = [int64]($downloaded / $seconds)
$eta = if ($speed -gt 0 -and $total -gt $downloaded) { [int][math]::Ceiling(($total - $downloaded) / $speed) } else { 0 }
Write-State -Stage $Stage -Message $Message -Extra @{ sourceUrl = $Url; downloadedBytes = $downloaded; totalBytes = $total; bytesPerSecond = $speed; etaSeconds = $eta; downloadElapsedSeconds = [math]::Round($seconds, 1) }
$lastReport = $watch.Elapsed.TotalSeconds
}
}
$output.Flush($true)
} finally {
$output.Dispose()
$input.Dispose()
$response.Dispose()
$client.Dispose()
}
$hash = (Get-FileHash -Algorithm $HashAlgorithm -LiteralPath $Destination).Hash.ToLowerInvariant()
if ($hash -ne $ExpectedHash.ToLowerInvariant()) {
throw "download-hash-mismatch: algorithm=$HashAlgorithm expected=$ExpectedHash actual=$hash"
}
}
function Ensure-HostKey {
Write-State -Stage 'ensuring-host-key' -Message 'Ensuring the dedicated D601-VM SSH key'
$keyPath = Join-Path $StateDir 'id_ed25519'
if (-not (Test-Path -LiteralPath $keyPath)) {
& ssh-keygen.exe -q -t ed25519 -N '""' -C 'unidesk-d601-vm' -f $keyPath
if ($LASTEXITCODE -ne 0) { throw "ssh-keygen-failed: exit=$LASTEXITCODE" }
}
return [ordered]@{ private = $keyPath; public = (Get-Content -Raw -LiteralPath "$keyPath.pub").Trim() }
}
function Ensure-QemuImg {
$binary = [string]$Config.image.conversion.binary
if (Test-Path -LiteralPath $binary) { return $binary }
$installer = $Config.image.conversion.installer
$installerPath = Join-Path $StateDir ([string]$installer.fileName)
Download-FileWithMetrics -Url ([string]$installer.url) -Destination $installerPath -ExpectedBytes 0 -ExpectedHash ([string]$installer.sha512) -HashAlgorithm SHA512 -Stage 'downloading-qemu' -Message 'Downloading the private Windows QEMU converter'
Write-State -Stage 'installing-qemu' -Message 'Installing the private Windows QEMU converter'
New-Item -ItemType Directory -Force -Path ([string]$Config.image.conversion.installDir) | Out-Null
$installerProcess = Start-Process -FilePath $installerPath -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/DIR=$($Config.image.conversion.installDir)" -Wait -PassThru
if ($installerProcess.ExitCode -ne 0) { throw "qemu-installer-failed: exit=$($installerProcess.ExitCode)" }
if (-not (Test-Path -LiteralPath $binary)) { throw "qemu-img-missing-after-install: $binary" }
return $binary
}
function Ensure-Network {
Write-State -Stage 'configuring-network' -Message 'Configuring isolated Hyper-V NAT network'
$switchName = [string]$Config.network.switchName
if (-not (Get-VMSwitch -Name $switchName -ErrorAction SilentlyContinue)) {
New-VMSwitch -Name $switchName -SwitchType Internal | Out-Null
}
$adapter = Get-NetAdapter | Where-Object Name -eq "vEthernet ($switchName)"
if (-not $adapter) { throw "hyperv-nat-adapter-missing: vEthernet ($switchName)" }
$gateway = [string]$Config.network.gateway
$prefixLength = [int]$Config.network.prefixLength
if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object IPAddress -eq $gateway)) {
Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -IPAddress $gateway -PrefixLength $prefixLength | Out-Null
}
$natName = [string]$Config.network.natName
$nat = Get-NetNat -Name $natName -ErrorAction SilentlyContinue
if ($nat -and $nat.InternalIPInterfaceAddressPrefix -ne [string]$Config.network.prefix) {
throw "hyperv-nat-prefix-drift: actual=$($nat.InternalIPInterfaceAddressPrefix) desired=$($Config.network.prefix)"
}
if (-not $nat) { New-NetNat -Name $natName -InternalIPInterfaceAddressPrefix ([string]$Config.network.prefix) | Out-Null }
}
function New-SeedDisk {
param([string]$SeedPath, [string]$AuthorizedKey)
if (Test-Path -LiteralPath $SeedPath) { Remove-Item -Force -LiteralPath $SeedPath }
New-VHD -Path $SeedPath -Dynamic -SizeBytes 128MB | Out-Null
$mounted = Mount-VHD -Path $SeedPath -PassThru
try {
$disk = $mounted | Get-Disk
Initialize-Disk -Number $disk.Number -PartitionStyle MBR | Out-Null
$partition = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter
Format-Volume -Partition $partition -FileSystem FAT32 -NewFileSystemLabel CIDATA -Confirm:$false | Out-Null
$drive = "$($partition.DriveLetter):\"
$dns = @($Config.network.dns) | ForEach-Object { " - $_" }
$userData = @"
#cloud-config
hostname: $($Config.ubuntu.hostname)
manage_etc_hosts: true
timezone: $($Config.ubuntu.timezone)
locale: $($Config.ubuntu.locale)
users:
- default
- name: $($Config.ubuntu.user)
groups: [adm, sudo, docker]
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- $AuthorizedKey
ssh_pwauth: false
disable_root: true
package_update: true
apt:
preserve_sources_list: false
primary:
- arches: [default]
uri: $($Config.ubuntu.aptMirror)
security:
- arches: [default]
uri: $($Config.ubuntu.aptMirror)
packages:
- openssh-server
- qemu-guest-agent
- curl
- ca-certificates
- docker.io
- docker-compose-v2
write_files:
- path: /etc/docker/daemon.json
permissions: '0644'
content: |
{"registry-mirrors":["$($Config.docker.registryMirrors[0])"],"log-driver":"local","log-opts":{"max-size":"20m","max-file":"5"}}
runcmd:
- [systemctl, enable, --now, ssh]
- [systemctl, enable, --now, docker]
- [systemctl, enable, --now, qemu-guest-agent]
- [bash, -lc, "mkdir -p /home/$($Config.ubuntu.user)/.unidesk/host-ssh /home/$($Config.ubuntu.user)/unidesk-provider && chown -R $($Config.ubuntu.user):$($Config.ubuntu.user) /home/$($Config.ubuntu.user)/.unidesk /home/$($Config.ubuntu.user)/unidesk-provider"]
final_message: "UNIDESK_CLOUD_INIT_COMPLETE"
"@
$metaData = @"
instance-id: $($Config.targetId.ToLowerInvariant())
local-hostname: $($Config.ubuntu.hostname)
"@
$networkConfig = @"
version: 2
ethernets:
eth0:
match:
driver: hv_netvsc
set-name: eth0
dhcp4: false
addresses:
- $($Config.network.address)/$($Config.network.prefixLength)
routes:
- to: default
via: $($Config.network.gateway)
nameservers:
addresses:
$($dns -join "`n")
"@
[System.IO.File]::WriteAllText((Join-Path $drive 'user-data'), $userData, [Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText((Join-Path $drive 'meta-data'), $metaData, [Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText((Join-Path $drive 'network-config'), $networkConfig, [Text.UTF8Encoding]::new($false))
} finally {
Dismount-VHD -Path $SeedPath -ErrorAction SilentlyContinue
}
}
function Repair-BrokenVmDiskChain {
$name = [string]$Config.vm.name
$vm = Get-VM -Name $name -ErrorAction SilentlyContinue
if (-not $vm) { return }
$snapshots = @(Get-VMSnapshot -VMName $name -ErrorAction SilentlyContinue)
$differencingDisks = @(Get-VMHardDiskDrive -VMName $name -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.EndsWith('.avhdx', [StringComparison]::OrdinalIgnoreCase) })
if ($snapshots.Count -eq 0 -and $differencingDisks.Count -eq 0) { return }
Write-State -Stage 'repairing-vm-disk-chain' -Message 'Removing failed automatic checkpoints before recreating the VM configuration'
if ($vm.State -ne 'Off') { Stop-VM -Name $name -TurnOff -Force -ErrorAction SilentlyContinue }
Remove-VM -Name $name -Force
Get-ChildItem -Path ([string]$Config.host.vmRoot) -Recurse -Filter '*.avhdx' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
}
function Ensure-VirtualMachine {
param([string]$DiskPath, [string]$SeedPath)
Write-State -Stage 'creating-vm' -Message 'Creating or reconciling Hyper-V virtual machine'
$name = [string]$Config.vm.name
$vm = Get-VM -Name $name -ErrorAction SilentlyContinue
if ($vm -and (Test-VirtualMachineNeedsRecreate -DiskPath $DiskPath -SeedPath $SeedPath)) {
if ($vm.State -ne 'Off') { Stop-VM -Name $name -TurnOff -Force }
Remove-VM -Name $name -Force
$vm = $null
}
if (-not $vm) {
$vm = New-VM -Name $name -Generation ([int]$Config.vm.generation) -MemoryStartupBytes ([int64]$Config.vm.memoryStartupBytes) -VHDPath $DiskPath -SwitchName ([string]$Config.network.switchName) -Path ([string]$Config.host.vmRoot)
if ([int]$Config.vm.generation -eq 2) {
Add-VMHardDiskDrive -VMName $name -ControllerType SCSI -ControllerNumber 0 -ControllerLocation 1 -Path $SeedPath
Set-VMFirmware -VMName $name -EnableSecureBoot On -SecureBootTemplate MicrosoftUEFICertificateAuthority
} else {
Add-VMHardDiskDrive -VMName $name -ControllerType IDE -ControllerNumber 0 -ControllerLocation 1 -Path $SeedPath
}
} elseif ($vm.State -ne 'Off') {
Stop-VM -Name $name -TurnOff -Force
}
Set-VMMemory -VMName $name -DynamicMemoryEnabled ([bool]$Config.vm.dynamicMemoryEnabled) -StartupBytes ([int64]$Config.vm.memoryStartupBytes) -MinimumBytes ([int64]$Config.vm.memoryMinimumBytes) -MaximumBytes ([int64]$Config.vm.memoryMaximumBytes) -Buffer ([int]$Config.vm.memoryBufferPercent)
Set-VMProcessor -VMName $name -Count ([int]$Config.vm.processorCount)
Set-VM -Name $name -AutomaticStartAction ([string]$Config.vm.automaticStartAction) -AutomaticStopAction ([string]$Config.vm.automaticStopAction) -AutomaticStartDelay ([int]$Config.vm.automaticStartDelaySeconds) -AutomaticCheckpointsEnabled $false -CheckpointType Disabled
Set-VMComPort -VMName $name -Number 1 -Path $null -ErrorAction SilentlyContinue
Start-VM -Name $name | Out-Null
}
function Test-VirtualMachineNeedsRecreate {
param([string]$DiskPath, [string]$SeedPath)
$name = [string]$Config.vm.name
$vm = Get-VM -Name $name -ErrorAction SilentlyContinue
if (-not $vm) { return $true }
if ([int]$vm.Generation -ne [int]$Config.vm.generation) { return $true }
$adapter = Get-VMNetworkAdapter -VMName $name | Select-Object -First 1
if (-not $adapter -or [string]$adapter.SwitchName -ne [string]$Config.network.switchName) { return $true }
$disks = @(Get-VMHardDiskDrive -VMName $name -ErrorAction SilentlyContinue)
$systemDisk = $disks | Where-Object { [string]$_.Path -ieq $DiskPath }
$seedDisk = $disks | Where-Object { [string]$_.Path -ieq $SeedPath }
if (-not $systemDisk -or -not $seedDisk) { return $true }
if ([int]$Config.vm.generation -eq 2 -and ([string]$seedDisk.ControllerType -ne 'SCSI' -or [int]$seedDisk.ControllerNumber -ne 0 -or [int]$seedDisk.ControllerLocation -ne 1)) { return $true }
return $false
}
function Wait-For-Ssh {
param([string]$KeyPath)
Write-State -Stage 'waiting-for-cloud-init' -Message 'Waiting for Ubuntu SSH and cloud-init'
$deadline = [DateTimeOffset]::UtcNow.AddSeconds([int]$Config.status.sshTimeoutSeconds)
while ([DateTimeOffset]::UtcNow -lt $deadline) {
$previousErrorPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
& ssh.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 "$($Config.ubuntu.user)@$($Config.network.address)" 'test -f /var/lib/cloud/instance/boot-finished' 2>$null
$sshExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorPreference
if ($sshExitCode -eq 0) { return }
Start-Sleep -Seconds ([int]$Config.status.heartbeatSeconds)
Write-State -Stage 'waiting-for-cloud-init' -Message 'Ubuntu is booting or cloud-init is still running'
}
throw 'cloud-init-timeout: inspect the Hyper-V console and install.log'
}
function Install-Provider {
param([string]$KeyPath)
Write-State -Stage 'installing-provider' -Message 'Installing provider-gateway inside Ubuntu'
$bundle = [string]$Config.artifacts.providerBundlePath
if (-not (Test-Path -LiteralPath $bundle)) { throw "provider-bundle-missing: $bundle" }
$target = "$($Config.ubuntu.user)@$($Config.network.address)"
& scp.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $bundle "${target}:/tmp/unidesk-provider-bundle.tar.gz"
if ($LASTEXITCODE -ne 0) { throw "provider-bundle-scp-failed: exit=$LASTEXITCODE" }
$command = "rm -rf ~/unidesk-provider/* && tar -xzf /tmp/unidesk-provider-bundle.tar.gz -C ~/unidesk-provider && chmod +x ~/unidesk-provider/install-provider.sh && sudo ~/unidesk-provider/install-provider.sh"
& ssh.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15 $target $command
if ($LASTEXITCODE -ne 0) { throw "provider-install-failed: exit=$LASTEXITCODE" }
}
function Install-K3s {
param([string]$KeyPath)
Write-State -Stage 'installing-k3s' -Message 'Installing the YAML-pinned k3s artifacts copied from the control plane'
$binary = [string]$Config.artifacts.k3sBinaryPath
$installer = [string]$Config.artifacts.k3sInstallerPath
if (-not (Test-Path -LiteralPath $binary)) { throw "k3s-binary-missing: $binary" }
if (-not (Test-Path -LiteralPath $installer)) { throw "k3s-installer-missing: $installer" }
$target = "$($Config.ubuntu.user)@$($Config.network.address)"
& scp.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $binary "${target}:/tmp/k3s"
if ($LASTEXITCODE -ne 0) { throw "k3s-binary-scp-failed: exit=$LASTEXITCODE" }
& scp.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $installer "${target}:/tmp/install-k3s.sh"
if ($LASTEXITCODE -ne 0) { throw "k3s-installer-scp-failed: exit=$LASTEXITCODE" }
$exec = "server --disable traefik --disable servicelb --node-name $($Config.k3s.nodeName) --node-label unidesk.ai/node-id=$($Config.targetId) --node-label unidesk.ai/provider-id=$($Config.provider.id) --write-kubeconfig-mode 644 --kubelet-arg max-pods=$($Config.k3s.maxPods)"
$command = "sudo install -m 0755 /tmp/k3s /usr/local/bin/k3s && sudo chmod 0755 /tmp/install-k3s.sh && sudo env INSTALL_K3S_SKIP_DOWNLOAD=true INSTALL_K3S_VERSION='$($Config.k3s.version)' INSTALL_K3S_EXEC='$exec' sh /tmp/install-k3s.sh"
& ssh.exe -i $KeyPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15 $target $command
if ($LASTEXITCODE -ne 0) { throw "k3s-install-failed: exit=$LASTEXITCODE" }
}
try {
Acquire-BootstrapLock
Write-State -Stage 'preflight' -Message 'Checking administrator, Hyper-V and host capacity'
Assert-Administrator
Import-Module Hyper-V
if (-not (Get-Service vmms -ErrorAction SilentlyContinue)) { throw 'hyperv-service-missing: enable Microsoft Hyper-V first' }
$totalMemory = [int64](Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory
if (($totalMemory - [int64]$Config.vm.memoryMaximumBytes) -lt [int64]$Config.vm.hostReservedMemoryBytes) {
throw "host-memory-reserve-insufficient: total=$totalMemory vmMax=$($Config.vm.memoryMaximumBytes) reserve=$($Config.vm.hostReservedMemoryBytes)"
}
$driveName = ([System.IO.Path]::GetPathRoot([string]$Config.host.vmRoot)).TrimEnd('\')
$drive = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$driveName'"
if (-not $drive -or [int64]$drive.FreeSpace -lt [int64]$Config.host.minimumFreeBytesBeforeInstall) {
throw "host-disk-space-insufficient: drive=$driveName free=$($drive.FreeSpace) required=$($Config.host.minimumFreeBytesBeforeInstall)"
}
$source = Select-ImageSource
$key = Ensure-HostKey
$imagePath = Join-Path $StateDir ([string]$Config.image.fileName)
Download-FileWithMetrics -Url $source.url -Destination $imagePath -ExpectedBytes $source.bytes -ExpectedHash ([string]$Config.image.sha256) -HashAlgorithm SHA256 -Stage 'downloading-image' -Message 'Downloading the Canonical generic Ubuntu image'
$qemuImg = Ensure-QemuImg
Write-State -Stage 'preparing-disk' -Message 'Converting the Canonical generic cloud image to VHDX'
$vmRoot = [string]$Config.host.vmRoot
New-Item -ItemType Directory -Force -Path $vmRoot | Out-Null
$diskPath = Join-Path $vmRoot "$($Config.vm.name).vhdx"
$diskMarkerPath = "$diskPath.source-sha256"
$installedImageSha = if (Test-Path -LiteralPath $diskMarkerPath) { (Get-Content -Raw -LiteralPath $diskMarkerPath).Trim() } else { '' }
if ((Test-Path -LiteralPath $diskPath) -and $installedImageSha -ne [string]$Config.image.sha256) {
Write-State -Stage 'replacing-base-image' -Message 'Replacing the Azure-specific disk with the Canonical generic cloud image'
$staleVm = Get-VM -Name ([string]$Config.vm.name) -ErrorAction SilentlyContinue
if ($staleVm) {
if ($staleVm.State -ne 'Off') { Stop-VM -Name $staleVm.Name -TurnOff -Force }
Remove-VM -Name $staleVm.Name -Force
}
Get-ChildItem -Path $vmRoot -Recurse -Filter '*.avhdx' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -Force -LiteralPath $diskPath -ErrorAction SilentlyContinue
Remove-Item -Force -LiteralPath (Join-Path $vmRoot "$($Config.vm.name)-cidata.vhdx") -ErrorAction SilentlyContinue
Remove-Item -Force -LiteralPath $diskMarkerPath -ErrorAction SilentlyContinue
}
$diskReady = (Test-Path -LiteralPath $diskPath) -and ($installedImageSha -eq [string]$Config.image.sha256)
if (-not $diskReady) {
if (Test-Path -LiteralPath $diskPath) { Remove-Item -Force -LiteralPath $diskPath }
$conversionArguments = @('convert', '-f', [string]$Config.image.format, '-O', [string]$Config.image.conversion.outputFormat, '-o', 'subformat=dynamic', '-S', '0', $imagePath, $diskPath)
$previousErrorPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$conversionOutput = (& $qemuImg @conversionArguments 2>&1 | Out-String).Trim()
$conversionExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorPreference
if ($conversionExitCode -ne 0) { throw "image-conversion-failed: exit=$conversionExitCode output=$conversionOutput" }
Write-State -Stage 'desparsifying-disk' -Message 'Removing the NTFS sparse-file flag for Hyper-V compatibility'
$previousErrorPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$sparseOutput = (& fsutil.exe sparse setflag $diskPath 0 2>&1 | Out-String).Trim()
$sparseExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorPreference
if ($sparseExitCode -ne 0) { throw "disk-desparse-failed: exit=$sparseExitCode output=$sparseOutput" }
Write-Log "disk_sparse_flag_cleared=true output=$sparseOutput"
Resize-VHD -Path $diskPath -SizeBytes ([int64]$Config.vm.diskBytes)
[IO.File]::WriteAllText($diskMarkerPath, "$($Config.image.sha256)`n", [Text.UTF8Encoding]::new($false))
}
Ensure-Network
$seedPath = Join-Path $vmRoot "$($Config.vm.name)-cidata.vhdx"
$needsRecreate = Test-VirtualMachineNeedsRecreate -DiskPath $diskPath -SeedPath $seedPath
if (-not $needsRecreate) {
Write-State -Stage 'resuming-running-vm' -Message 'The VM is already running; preserving guest state and resuming readiness checks'
} else {
Write-State -Stage 'reconciling-vm-shape' -Message 'Existing VM shape differs from YAML; recreating the VM definition'
Repair-BrokenVmDiskChain
New-SeedDisk -SeedPath $seedPath -AuthorizedKey $key.public
Ensure-VirtualMachine -DiskPath $diskPath -SeedPath $seedPath
}
Wait-For-Ssh -KeyPath $key.private
if ([string]$Config.k3s.management.mode -eq 'external-cluster') {
Write-State -Stage 'staging-k3s-agent' -Message 'Pinned k3s artifacts are staged; the YAML-controlled cluster manager owns the agent role'
} else {
throw "unsupported-k3s-management-mode: $($Config.k3s.management.mode)"
}
Install-Provider -KeyPath $key.private
Write-State -Stage 'completed' -State 'completed' -Message 'Ubuntu VM, Docker, pinned k3s artifacts and provider-gateway are ready for the external cluster manager' -Extra @{
address = [string]$Config.network.address
providerId = [string]$Config.provider.id
processorCount = [int]$Config.vm.processorCount
memoryStartupBytes = [int64]$Config.vm.memoryStartupBytes
memoryMaximumBytes = [int64]$Config.vm.memoryMaximumBytes
diskBytes = [int64]$Config.vm.diskBytes
}
} catch {
$errorCode = if ($_.Exception.Message -match '^([^:]+):') { $Matches[1] } else { 'hyperv-vm-bootstrap-failed' }
$extra = @{ errorCode = $errorCode; recoveryHint = 'Read install.log, correct the reported stage, then rerun the same YAML-controlled apply command.' }
try {
$failedVm = Get-VM -Name ([string]$Config.vm.name) -ErrorAction SilentlyContinue
if ($failedVm) {
$extra.vmState = [string]$failedVm.State
$extra.vmStatus = [string]$failedVm.Status
$extra.memoryAssignedBytes = [int64]$failedVm.MemoryAssigned
$extra.memoryDemandBytes = [int64]$failedVm.MemoryDemand
$extra.vmOperationalStatus = @($failedVm.OperationalStatus | ForEach-Object { [string]$_ })
$failedMemory = Get-VMMemory -VMName ([string]$Config.vm.name)
$extra.vmMemory = [ordered]@{
dynamic = [bool]$failedMemory.DynamicMemoryEnabled
minimumBytes = [int64]$failedMemory.Minimum
startupBytes = [int64]$failedMemory.Startup
maximumBytes = [int64]$failedMemory.Maximum
}
$extra.vmDisks = @(Get-VMHardDiskDrive -VMName ([string]$Config.vm.name) | ForEach-Object { [ordered]@{ path = $_.Path; controllerType = [string]$_.ControllerType; controllerNumber = $_.ControllerNumber; controllerLocation = $_.ControllerLocation } })
}
} catch {}
try {
$since = [DateTime]::Now.AddMinutes(-10)
$events = @()
foreach ($eventLog in @('Microsoft-Windows-Hyper-V-Worker-Admin', 'Microsoft-Windows-Hyper-V-VMMS-Admin')) {
$events += @(Get-WinEvent -FilterHashtable @{ LogName = $eventLog; StartTime = $since; Level = 2 } -ErrorAction SilentlyContinue | Select-Object -First 4 | ForEach-Object { $message = [string]$_.Message; [ordered]@{ log = $eventLog; id = $_.Id; level = $_.LevelDisplayName; message = $message.Substring(0, [Math]::Min(1000, $message.Length)) } })
}
$extra.hyperVEvents = $events
} catch {}
Write-State -Stage $CurrentStage -State 'failed' -Message $_.Exception.Message -Extra $extra
Write-Log ($_ | Out-String)
exit 1
} finally {
if ($script:LockStream) {
$script:LockStream.Dispose()
Remove-Item -Force -LiteralPath $LockPath -ErrorAction SilentlyContinue
}
}