546 lines
29 KiB
PowerShell
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
|
|
}
|
|
}
|