Private/Machine.ps1

# A machine = machines\<name>\: machine.psd1 (Windows side), user-data.yaml (the machine's
# cloud-config), files\ (system files specific to the machine), and what machine.psd1 refers to.

function Get-MachineDir([string]$Name) { Join-Path -Path $script:MachinesDir -ChildPath $Name }

function Get-MachineName {
    if (-not (Test-Path -Path $script:MachinesDir)) { return }
    Get-ChildItem -Path $script:MachinesDir -Directory |
        Where-Object { Test-Path -Path (Join-Path -Path $_.FullName -ChildPath 'machine.psd1') } |
        ForEach-Object { $_.Name }
}

# wsl.conf values written in user-data.yaml: ws-creator reads them for the Windows side (SSH
# alias, VS Code) and checks they match the rest, instead of generating them.
function Get-UserDataValue([string]$Text) {
    $values = @{}
    if ($Text -match '(?m)^\s*hostname=(\S+)') { $values.Hostname = $Matches[1] }
    if ($Text -match '(?m)^\s*default=(\S+)') { $values.User = $Matches[1] }
    $values.Users = @([regex]::Matches($Text, '(?m)^\s*-\s*name:\s*(\S+)') | ForEach-Object { $_.Groups[1].Value })
    return $values
}

# -Dir: a machine outside WS_CREATOR_MACHINES (a template, or the copy init is preparing)
function Get-Machine([string]$Name, [string]$Dir) {
    if (-not $Name) { throw 'Machine name missing (machines\<name>\)' }
    $dir = if ($Dir) { $Dir } else { Get-MachineDir $Name }
    $psd1 = Join-Path -Path $dir -ChildPath 'machine.psd1'
    $userData = Join-Path -Path $dir -ChildPath 'user-data.yaml'
    foreach ($file in $psd1, $userData) {
        if (-not (Test-Path -Path $file)) { throw "Machine '$Name': $file not found" }
    }
    $m = Import-PowerShellDataFile -Path $psd1
    $m.Name = $Name
    $m.Dir = $dir
    $m.UserData = $userData

    foreach ($key in 'BaseDistribution', 'Location', 'CloudConfig') {
        if (-not $m.ContainsKey($key)) { throw "Machine '$Name': machine.psd1 is missing key '$key'" }
    }
    $m.Modules = @($m.Modules | Where-Object { $_ })
    foreach ($f in $m.CloudConfig) {
        if (-not (Test-Path -Path (Join-Path -Path $script:Root -ChildPath "cloud-config\$f.yaml"))) {
            throw "Machine '$Name': cloud-config\$f.yaml not found"
        }
    }
    foreach ($s in $m.Modules) {
        if (-not (Test-Path -Path (Join-Path -Path $script:Root -ChildPath "modules\$s.sh"))) {
            throw "Machine '$Name': modules\$s.sh not found"
        }
    }

    $text = Get-Content -Path $userData -Raw
    if ($text -notmatch '^#cloud-config') { throw "Machine '$Name': user-data.yaml must start with #cloud-config" }
    $v = Get-UserDataValue $text
    if ($v.Hostname -ne $Name) { throw "Machine '$Name': wsl.conf in user-data.yaml says hostname=$($v.Hostname)" }
    if (-not $v.User) { throw "Machine '$Name': wsl.conf in user-data.yaml has no [user] default=" }
    if ($v.User -notin $v.Users) { throw "Machine '$Name': default=$($v.User) is not in the users of user-data.yaml" }
    $m.User = $v.User
    if ('mise' -in $m.CloudConfig -and $text -notmatch '\.config/mise/config\.toml') {
        throw "Machine '$Name': fragment mise needs ~/.config/mise/config.toml in user-data.yaml"
    }

    if ($m.Ssh) {
        if (-not ($m.Ssh.Port -and $m.Ssh.PublicKey)) { throw "Machine '$Name': Ssh needs Port and PublicKey" }
        $pub = Join-Path -Path $script:SshDir -ChildPath $m.Ssh.PublicKey
        if (-not (Test-Path -Path $pub)) { throw "Machine '$Name': public key not found: ~\.ssh\$($m.Ssh.PublicKey)" }
    }
    # values unique across machines are in machine.psd1 (ws-creator checks them) and in
    # user-data.yaml (the machine applies them): they must match
    if ('ssh' -in $m.CloudConfig) {
        if (-not $m.Ssh) { throw "Machine '$Name': fragment ssh needs Ssh in machine.psd1" }
        if ($text -notmatch "(?m)^\s*Port\s+$($m.Ssh.Port)\s*$") {
            throw "Machine '$Name': user-data.yaml has no sshd 'Port $($m.Ssh.Port)' (machine.psd1 Ssh.Port)"
        }
        if ($text -notmatch "(?m)^\s*AllowUsers\s+$([regex]::Escape($m.User))\s*$") {
            throw "Machine '$Name': user-data.yaml has no sshd 'AllowUsers $($m.User)'"
        }
        $key = ((Get-Content -Path $pub -Raw).Trim() -split '\s+')[0..1] -join ' '   # type and key, without the comment
        if (-not $text.Contains($key)) {
            throw "Machine '$Name': ~\.ssh\$($m.Ssh.PublicKey) is not in ssh_authorized_keys of user-data.yaml"
        }
    }
    if ('podman' -in $m.CloudConfig -and $m.PodmanSubnet -and
        $text -notmatch "(?m)^\s*default_subnet\s*=\s*`"$([regex]::Escape($m.PodmanSubnet))`"") {
        throw "Machine '$Name': user-data.yaml has no podman default_subnet = `"$($m.PodmanSubnet)`" (machine.psd1 PodmanSubnet)"
    }
    if ($m.AgentInstructions -and -not (Test-Path -Path (Join-Path -Path $dir -ChildPath $m.AgentInstructions))) {
        throw "Machine '$Name': AgentInstructions file not found: $($m.AgentInstructions)"
    }
    return $m
}

# Windows and all distros share the network stack (WSL mirrored): a duplicate SSH port or
# subnet silently breaks another machine.
function Test-MachineConflict($m) {
    foreach ($other in Get-MachineName | Where-Object { $_ -ne $m.Name }) {
        $o = Import-PowerShellDataFile -Path (Join-Path -Path (Get-MachineDir $other) -ChildPath 'machine.psd1')
        $o.Name = $other
        if ($m.Ssh -and $o.Ssh -and $m.Ssh.Port -eq $o.Ssh.Port) {
            throw "Machine '$($m.Name)': Ssh.Port $($m.Ssh.Port) already used by '$other'"
        }
        if ($m.PodmanSubnet -and $m.PodmanSubnet -eq $o.PodmanSubnet) {
            throw "Machine '$($m.Name)': PodmanSubnet $($m.PodmanSubnet) already used by '$other'"
        }
        if ('podman' -in $m.CloudConfig -and 'podman' -in $o.CloudConfig -and -not ($m.PodmanSubnet -and $o.PodmanSubnet)) {
            Write-Warning "'$($m.Name)' and '$other': podman without PodmanSubnet on both, they share the default subnet"
        }
    }
}

# -Only: names of the machine's fragments or modules; without -Only, everything
function Select-MachinePart($m, [string[]]$Only) {
    foreach ($o in $Only) {
        if ($o -notin $m.CloudConfig -and $o -notin $m.Modules) { throw "'$o' is neither a fragment nor a module of machine '$($m.Name)'" }
    }
    @{
        CloudConfig = @($m.CloudConfig | Where-Object { -not $Only -or $_ -in $Only })
        Modules     = @($m.Modules | Where-Object { -not $Only -or $_ -in $Only })
    }
}