diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..905b17e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Nix embeds the contents of these files verbatim into derivation build +# scripts. A CR that survives into such a script reaches bash as a literal +# $'\r' command and the build dies with exit code 127, so a Windows clone made +# with the Git default core.autocrlf=true cannot build the images at all. +# Normalise everything to LF in the working tree, not just in the blobs. +* text=auto eol=lf + +# The CMD entry points are the exception: cmd.exe parses multi-line blocks +# reliably only with CRLF. +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..558eaaf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + nix: + name: flake check, manifest sync, home seed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@main + + - name: nix flake check + run: nix flake check --no-build -L + + - name: manifest.json matches Nix + run: | + nix build .#qubix-manifest-json --out-link manifest.generated + if ! diff -u manifest.json manifest.generated; then + echo "::error::manifest.json is stale. Run tools/update-manifest.sh and commit the result." + exit 1 + fi + + - name: home seed image builds + run: nix build .#spotibox-home-vhdx -L --no-link + + powershell: + name: controller lint + unit checks (${{ matrix.shell }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + # Windows PowerShell 5.1 is what qubix-up.cmd runs; pwsh is the + # developer console. Both must stay happy. + shell: [powershell, pwsh] + defaults: + run: + # A step's own `shell:` accepts no contexts at all, so the matrix + # value has to arrive through the job defaults instead. + shell: ${{ matrix.shell }} + steps: + - uses: actions/checkout@v4 + + - name: PSScriptAnalyzer + if: matrix.shell == 'pwsh' + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Scope CurrentUser -Force + $results = Invoke-ScriptAnalyzer -Path tools -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + $results | Format-Table -AutoSize | Out-String | Write-Host + if ($results.Count -gt 0) { exit 1 } + + - name: unit checks + run: | + & ./tests/qubixctl.Tests.ps1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebd7ed5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: release + +# Builds the Hyper-V images with Nix and attaches them to the GitHub release +# for the tag. Windows hosts then need nothing but Hyper-V and PowerShell: +# `qubixctl -Command up` downloads these assets, no WSL involved. +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Existing tag to (re)build and publish' + required: true + type: string + +permissions: + contents: write + +jobs: + build: + name: build and publish images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Enable KVM for the Nix build sandbox + # make-disk-image builds the VHDX inside a QEMU VM; without KVM it + # would crawl through TCG emulation. + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -la /dev/kvm + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / + + - uses: DeterminateSystems/nix-installer-action@main + with: + extra-conf: | + system-features = nixos-test benchmark big-parallel kvm + + - name: Build release bundle + run: nix build .#spotibox-release -L --out-link release + + - name: Inspect assets + run: | + ls -la release/ + cat release/SHA256SUMS + for f in release/*.gz; do + size=$(stat -L -c %s "$f") + if [ "$size" -ge 2000000000 ]; then + echo "::error::$f is $size bytes, above the 2 GB GitHub release asset limit" + exit 1 + fi + done + + - name: Version stamp + run: | + tag="${{ inputs.tag || github.ref_name }}" + printf 'tag=%s\nrev=%s\n' "$tag" "$(git rev-parse HEAD)" > VERSION + cat VERSION + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: | + release/* + VERSION diff --git a/.gitignore b/.gitignore index 9be4c28..4dcdde4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ result result-* .qubix/ +manifest.generated *.vhd *.vhdx *.qcow2 diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..5bcd187 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,8 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + # qubixctl is an interactive CLI: progress lines are for the person + # watching the console, not for a pipeline. Write-Host is the tool. + 'PSAvoidUsingWriteHost' + ) +} diff --git a/README.md b/README.md index 3d24fa3..938c1c0 100644 --- a/README.md +++ b/README.md @@ -14,144 +14,306 @@ Name `Qubix` is a Qubes OS + Nix which is so obvious but fancy that I couldn't r Qubix is a small declarative appliance factory for Windows-hosted, Hyper-V-based -NixOS VMs. The MVP builds one appliance, `spotibox`, as a minimal Spotify VM with -Openbox, xrdp and the PulseAudio audio path that worked in the prototype. +NixOS VMs. The first appliance, `spotibox`, is a minimal Spotify VM with Openbox, +xrdp and the PulseAudio audio path that worked in the prototype. - -The goal is a fast local loop: +The loop, from the Windows side, is one click: ```text -Nix modules - -> nixos-generators Hyper-V VHDX - -> PowerShell recreates the Hyper-V VM - -> mstsc connects to the appliance +machines/*.nix + profiles/*.nix (source of truth, Nix) + -> GitHub Actions builds the Hyper-V VHDX + home-disk seed + -> tools\qubix-up.cmd downloads them, creates the VM once, starts it + -> mstsc opens with Spotify filling the window ``` -## What The MVP Gives You +WSL is not required on the host. It stays available as the developer loop +(`-ImageSource wsl`) for building images locally. + +## What You Get - A modular NixOS configuration for `spotibox`. - A Hyper-V Generation 2 VHDX built through `nixos-generators`. -- A Nix-generated JSON manifest consumed by PowerShell. -- A Windows PowerShell controller, `tools/qubixctl.ps1`. -- A stable xrdp audio baseline using PulseAudio, not PipeWire. -- Separate `user` and `rdp` accounts to avoid session cross-contamination. -- A basic NixOS smoke test for users, xrdp, Spotify, Openbox and Avahi. +- A **persistent home disk**: `/home` lives on its own VHDX, so replacing the + system image never logs you out of Spotify. +- A Spotify kiosk session: xrdp starts Openbox + Spotify, the Spotify window is + undecorated and maximised, quitting Spotify closes the RDP window. +- A Nix-generated JSON manifest (`manifest.json`, committed, CI-checked) + consumed by the Windows controller without WSL. +- `tools/qubixctl.ps1`, an idempotent controller (`up` is the default), and + `tools/qubix-up.cmd`, the double-click launcher that elevates itself. +- GitHub Actions: `ci` (flake check, manifest drift, PowerShell lint + unit + checks) and `release` (builds and attaches the images to a tagged release). +- A stable xrdp audio baseline using PulseAudio, not PipeWire, and xrdp built + without the MP3/Opus encoders so that mstsc negotiates PCM and actually + plays it (see *Why PCM-only audio*). +- Separate `user` and `rdp` accounts (pinned UIDs) to avoid session + cross-contamination. +- A NixOS smoke test for users, xrdp, Spotify, Openbox, Avahi and the kiosk + session wiring. + +## Quick Start (Windows, No WSL) + +Once: enable Hyper-V from an elevated PowerShell and reboot. -## Layout +```powershell +Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All +``` + +Then clone the repository to a normal Windows path and double-click +`tools\qubix-up.cmd`. It asks for elevation and runs `qubixctl -Command up`: + +1. reads `manifest.json`; +2. resolves the latest GitHub release, downloads `spotibox.vhdx.gz`, + `spotibox-home.vhdx.gz` and `SHA256SUMS` into `C:\HyperV\Qubix\images\`, + verifies and unpacks them (cached per release tag); +3. creates `qubix-spotibox` on first run only: system disk from the image, + home disk seeded once, NAT switch `spotibox-nat` with a static address; +4. starts the VM, waits for port 3389, writes `qubix-spotibox.rdp`, stores the + lab credential in Windows Credential Manager and launches `mstsc`. + +Every later click is the same command: the VM already exists, so it just +starts (or resumes) and opens the window. Spotify comes up maximised inside it. + +From a console the same thing is: + +```powershell +.\tools\qubixctl.cmd # up spotibox +.\tools\qubixctl.cmd -Command status +.\tools\qubixctl.cmd -Command stop +``` + +### Commands + +| Command | What it does | +|------------|------------------------------------------------------------------------------| +| `up` | create-if-missing, start, wait for RDP, connect (default) | +| `connect` | open the RDP window for a running VM | +| `start` | start / resume the VM | +| `stop` | graceful shutdown | +| `status` | VM state, adapters, disks, address, installed image version, cached images | +| `recreate` | replace the system disk with fresh images; **the home disk is kept** | +| `destroy` | remove VM + system disk; `-Purge` also deletes the home disk | +| `fetch` | download release images into the cache without touching the VM | +| `build` | build images in WSL (developer path) | +| `manifest` | print the resolved machine config | + +Useful switches: `-Release v0.2.0` (pin a release), `-VmRoot D:\vms`, +`-SwitchName`, `-Address 192.168.250.10`, `-NoConnect`, `-NoSavedCredential`, +`-TimeoutSeconds 600`. + +### Where Images Come From + +| `-ImageSource` | Source | Needs | +|----------------|-----------------------------------------------------------------------|------------| +| `auto` | `-ImagePath` if given, otherwise `release` (default) | | +| `release` | GitHub release assets of `PhysShell/qubix` (`latest` or `-Release`) | internet | +| `wsl` | `nix build` inside WSL; manifest regenerated from Nix on the fly | WSL + Nix | +| `file` | `-ImagePath ` plus `-HomeImagePath` for a first-time home | files | + +Release downloads use plain `https://github.com//releases/...` URLs, so a +public repository needs no token. Private repositories are not supported by the +`release` source yet; use `fetch` from a machine that can reach the assets, or +the `wsl` / `file` sources. + +## Persistence Model ```text -flake.nix -machines/ - spotibox.nix - spotibox-debug.nix -profiles/ - audio/pulseaudio-xrdp.nix - apps/spotify.nix - gui/openbox.nix - kernel/default.nix - modes/debug.nix - modes/prod.nix - security/minimal.nix -tools/ - qubixctl.ps1 -tests/ - spotibox-basic.nix +C:\HyperV\Qubix\ + images\spotibox\\ unpacked release assets (cache, safe to delete) + qubix-spotibox\ + qubix-spotibox.vhdx SYSTEM disk = Nix artifact, replaced by recreate + qubix-spotibox-home.vhdx HOME disk = the only state, never replaced + qubix-spotibox.rdp generated connection file + image-version.txt release tag / build id of the system disk ``` -## Build The Image +- The system disk is rebuilt from Nix; nothing on it is worth keeping. +- `/home` is mounted from the disk labelled `qubix-home` and is required for + boot (`profiles/storage/persistent-home.nix`). A missing home disk stops the + boot loudly instead of silently handing you an empty home. +- `recreate` deletes only the system disk. Spotify stays logged in. +- Hyper-V checkpoints are disabled on the VM: they would fork the home disk + into `.avhdx` chains the controller cannot reason about. +- User IDs are pinned (`user` = 1000, `rdp` = 1001) so a persistent home never + changes owner when the user list changes. -From Linux/WSL: +## Continuous Integration -```bash -nix build .#spotibox-vhdx -``` +`ci` runs on pull requests, on pushes to `main`, and on manual dispatch. It +does **not** run on a push to a feature branch, so work on a branch stays +unchecked until a PR exists - open one early if you want the signal. -The default package is also `spotibox-vhdx`, so this works too: +| Job | Runner | What it does | +| --- | --- | --- | +| `flake check, manifest sync, home seed` | ubuntu | `nix flake check`, fails on `manifest.json` drift, builds the home seed | +| `controller lint + unit checks (powershell)` | windows | unit checks under Windows PowerShell 5.1, the shell `qubix-up.cmd` actually uses | +| `controller lint + unit checks (pwsh)` | windows | the same checks under pwsh 7, plus PSScriptAnalyzer | + +Driving it from the terminal with the GitHub CLI: ```bash -nix build +gh run list --limit 10 # recent runs, newest first +gh run watch # follow the run for the current branch +gh run view --log-failed # only the failing steps +gh run rerun --failed # retry just the failed jobs +gh workflow run ci.yml --ref # manual dispatch +gh pr checks # per-check status for a PR ``` -The generated manifest is available as a build artifact: +A run that fails in 0s with no jobs, and a workflow listed under its file path +instead of its `name:`, means GitHub could not compile the YAML - the run log +is empty in that case, so lint locally instead: ```bash -nix build .#qubix-manifest-json -cat result +nix run nixpkgs#actionlint ``` -`result` is a symlink to the JSON file in the Nix store. The PowerShell -controller builds and reads the same artifact at runtime — you do not need to -build it manually. +`actionlint` catches the whole class of errors GitHub reports only as a failed +run, such as using a context where none is allowed. -## Recreate The Hyper-V VM +## Publishing A Release -Run PowerShell from Windows. Mutating Hyper-V commands must be run from an -elevated PowerShell session. +```bash +git tag v0.2.0 +git push origin v0.2.0 +``` -```powershell -Set-Location $HOME -$Qubix = "\\wsl.localhost\NixOS\home\nixos\Documents\repos\qubix" +The `release` workflow builds `.#spotibox-release` on GitHub Actions (KVM is +enabled on the runner so `make-disk-image` does not crawl through emulation), +checks every asset against the 2 GB GitHub limit and attaches: -& "$Qubix\tools\qubixctl.cmd" -Command recreate -Machine spotibox +```text +spotibox.vhdx.gz system image (gzip, unpacked on Windows with .NET only) +spotibox-home.vhdx.gz 16 GiB dynamic ext4 seed, a few hundred KB compressed +manifest.json the manifest the images were built with +SHA256SUMS +VERSION tag + commit ``` -`recreate` builds the Nix image and recreates the VM in one step. +`workflow_dispatch` with an existing tag rebuilds and re-attaches the assets. -The `.cmd` wrapper runs the PowerShell script with process-scoped -`-ExecutionPolicy Bypass`. This avoids changing your global execution policy and -works around Windows treating scripts under `\\wsl.localhost\...` as unsigned -remote files. Keep the PowerShell working directory on a normal Windows path -when invoking the wrapper: `cmd.exe` cannot use a UNC path as its current -directory and otherwise falls back to `C:\Windows`. In other words, assign the -UNC path to `$Qubix`, but do not `cd` into it. +## Developer Loop (WSL) -The controller defaults to: +From Linux/WSL, the usual Nix commands still work: -- WSL distro: `NixOS` -- Linux repo path: `/home/nixos/Documents/repos/qubix` -- Hyper-V switch: `Default Switch` -- VM name: `qubix-spotibox` -- VM root: `C:\HyperV\Qubix` +```bash +nix build .#spotibox-vhdx # or just `nix build` +nix build .#spotibox-home-vhdx +nix build .#spotibox-release # what CI publishes +nix flake check --no-build +``` -Override them when needed: +Whenever `machines/*.nix` or the manifest logic changes, regenerate the +committed manifest (CI fails on drift): -```powershell -& "$Qubix\tools\qubixctl.cmd" ` - -Command recreate ` - -Machine spotibox ` - -WslDistro NixOS ` - -RepoLinuxPath /home/nixos/Documents/repos/qubix ` - -VmRoot C:\HyperV\Qubix ` - -SwitchName "Default Switch" +```bash +tools/update-manifest.sh ``` -Other commands: +To test a local build on the Windows side without publishing a release: ```powershell -& "$Qubix\tools\qubixctl.cmd" -Command build # run nix build only, skip VM -& "$Qubix\tools\qubixctl.cmd" -Command status -& "$Qubix\tools\qubixctl.cmd" -Command start -& "$Qubix\tools\qubixctl.cmd" -Command stop -& "$Qubix\tools\qubixctl.cmd" -Command destroy -& "$Qubix\tools\qubixctl.cmd" -Command mstsc +# repo lives in WSL: assign the UNC path, do not cd into it +$Qubix = "\\wsl.localhost\NixOS\home\nixos\Documents\repos\qubix" +& "$Qubix\tools\qubixctl.cmd" -Command recreate -ImageSource wsl ``` -`mstsc` defaults to `spotibox.local`. If name resolution fails, pass an address: +With `-ImageSource wsl` the controller derives the distro and Linux path from +the UNC path (override with `-WslDistro` / `-RepoLinuxPath`), regenerates the +manifest from Nix, builds both images and copies them out of the store. -```powershell -& "$Qubix\tools\qubixctl.cmd" -Command mstsc -Address 192.168.192.121 +The `.cmd` wrappers run PowerShell with a process-scoped +`-ExecutionPolicy Bypass`, which also sidesteps Windows treating scripts under +`\\wsl.localhost\...` as unsigned remote files. `cmd.exe` cannot use a UNC path +as its working directory, so the wrappers `pushd` to a temporary drive letter. + +## Networking + +`machines/spotibox.nix` declares a static address: + +```nix +qubix.network = { + staticIp = "192.168.250.10"; + gateway = "192.168.250.1"; +}; +``` + +The manifest derives `gatewayIp` and `natSwitchSubnet` from it, and `qubixctl` +creates an Internal Hyper-V switch `spotibox-nat`, assigns the gateway address +to the host `vEthernet` adapter and adds a `NetNat` rule. All three steps are +idempotent. Because the image and the manifest come from the same machine file, +they cannot disagree about the address. + +Remove `qubix.network.staticIp` (set it to `null`) to fall back to DHCP on +`Default Switch`. The controller then asks Hyper-V for the address the guest +reported (`hv_kvp_daemon`) and falls back to `spotibox.local` via Avahi/mDNS. + +Windows allows a single `NetNat` instance per host. If another NAT network +already exists (Docker, a lab switch), reuse it or switch spotibox to DHCP. + +## Validation + +```bash +nix flake check --no-build # evaluates every system and the test +nix build .#checks.x86_64-linux.spotibox-basic # boots the appliance in QEMU +pwsh ./tests/qubixctl.Tests.ps1 # controller unit checks ``` -## Why VHDX Instead Of ISO Autoinstall +Manual acceptance on Windows: + +1. Double-click `tools\qubix-up.cmd`; Hyper-V shows `qubix-spotibox` running. +2. The RDP window opens as `rdp` with Spotify maximised and undecorated. +3. Audio plays through the host; `pavucontrol` (via an `xterm` from the Hyper-V + console, user `user`) shows the xrdp sink. +4. Log into Spotify, run `qubixctl -Command recreate`, click again: still + logged in. +5. Quit Spotify: the RDP window closes. + +## Design Notes + +### Why not run Spotify from WSL instead? + +WSLg would give a real native window with audio for free, but WSL is not an +isolation boundary: every distro shares one utility VM, and interop, automount +and the Windows PATH are on by default. Turning those off per distro reduces +exposure; it does not turn WSL into a VM. Since isolation is the whole point, +the appliance stays a Hyper-V VM and the "native window" is approximated by a +kiosk session in a windowed RDP client. + +### Why not App Sandbox / HCS? + +[App Sandbox](https://github.com/jamesstringer90/appsandbox) (the successor of +the archived Easy-GPU-PV) is the interesting future backend: HCS-based VMs +without the Hyper-V role, GPU-PV, snapshots, a headless API. Today it documents +Windows 11 and Ubuntu guests built from ISO, not arbitrary images such as a +NixOS VHDX, its storage path is not configurable and the daemon owns the VM +lifecycle. Nothing in it helps a Spotify appliance that Hyper-V already runs. +The manifest is deliberately backend-neutral (`gpu`, `network`, disks) so a +second backend can be added without touching the Nix side. + +### Why not Ansible for Windows? + +Ansible needs a Linux control node, which on this host means WSL, the very +dependency this change removes from the runtime path. The Windows-side work +here is a few Hyper-V cmdlets; a 20 KB PowerShell script with unit checks is +the right size for it. + +### Why no GPU-PV? + +Spotify does not need it, and GPU-PV for a Linux guest on Hyper-V means the +out-of-tree `dxgkrnl` module plus host driver files in the guest, a rabbit hole +with a Windows-update-shaped trapdoor. Off by design for this appliance. + +### Why VHDX Instead Of ISO Autoinstall The old prototype used an ISO that booted, partitioned `/dev/sda`, installed NixOS and rebooted. That works, but it keeps the slowest and most fragile part of the loop: installing an OS inside a VM every time. -Qubix MVP builds the final Hyper-V VHDX directly from Nix. Hyper-V then only has +Qubix builds the final Hyper-V VHDX directly from Nix. Hyper-V then only has to boot a ready disk. -## Why PulseAudio+xrdp +### Why PulseAudio+xrdp The prototype found a very specific failure mode: Hyper-V enhanced sessions and mstsc sessions under the same Unix user can mix `DISPLAY`, @@ -170,98 +332,78 @@ PipeWire -> disabled EasyEffects is intentionally not the active DSP baseline here. It is PipeWire-oriented, while this xrdp audio path expects PulseAudio. -## Connecting To The Appliance - -### Default: mDNS (recommended for most cases) - -The default manifest uses Hyper-V `Default Switch` with DHCP. The IP address -may change after reboot, but Avahi mDNS lets you connect by name: - -```powershell -.\tools\qubixctl.cmd -Command mstsc # resolves spotibox.local -``` - -If `spotibox.local` does not resolve, use `status` or Hyper-V Manager to find the -current IP and pass it with `-Address`. - -### Optional: Static IP with a Dedicated NAT Switch - -For a stable address that survives reboots, declare a static IP in both the -NixOS machine config and the flake manifest. - -Edit **`machines/spotibox.nix`** only — the manifest derives the values automatically: +### Why PCM-only audio -```nix -qubix.network = { - staticIp = "192.168.250.10"; - gateway = "192.168.250.1"; -}; -``` +nixpkgs builds xrdp with `--enable-mp3lame` and `--enable-opus`. With those +available, Windows' `mstsc` negotiates `WAVE_FORMAT_MPEGLAYER3` and then plays +nothing at all, while every diagnostic inside the guest looks perfectly +healthy: `xrdp-sink` is the default sink, it is not muted, it sits at 100%, it +moves between IDLE and RUNNING in time with the track, chansrv accepts the +socket and logs `round trip time 0`. Only the host is silent, and the Windows +volume mixer shows the mstsc slider with no level on it. See +[neutrinolabs/xrdp#965](https://github.com/neutrinolabs/xrdp/issues/965). -Then **recreate** as usual: +`profiles/audio/pulseaudio-xrdp.nix` therefore drops both encoders, which +leaves PCM as the only negotiable format. PCM is ~176 kB/s - irrelevant next to +the video channel. -```powershell -.\tools\qubixctl.cmd -Command recreate -Machine spotibox -``` +The override has to be a `nixpkgs.overlays` entry rather than the obvious +`services.xrdp.package`. The NixOS module declares that option but then +hardcodes `pkgs.xrdp` in the `ExecStart` of both `xrdp.service` and +`xrdp-sesman.service`, so setting it rebuilds `confDir` only and the daemons +keep running the untouched build - the option silently does nothing. A fix is open +upstream as [nixpkgs#452303](https://github.com/NixOS/nixpkgs/pull/452303); when it +lands, this overlay can become a plain `services.xrdp.package` assignment. -`qubixctl` automatically creates an Internal Hyper-V switch (`spotibox-nat`), -assigns the gateway IP to the host vEthernet adapter, and creates a `NetNat` -rule so the VM can reach the internet. All three steps are idempotent — safe -to re-run on every recreate. +### Nix-Generated JSON -Connect by fixed IP: +Nix is the source of truth for the manifest. `manifest.json` is the output of +`nix build .#qubix-manifest-json`, committed so that a Windows host without WSL +can read it, and checked for drift by CI. With `-ImageSource wsl` the +controller regenerates it live instead of reading the file. -```powershell -.\tools\qubixctl.cmd -Command mstsc -Address 192.168.250.10 -``` - -> **Note:** the NixOS image and the manifest must declare the same address. -> Qubix does not enforce this automatically — if they diverge the VM will boot -> with the wrong IP for the switch it is attached to. - -## Validation - -Evaluate the flake: - -```bash -nix flake check --no-build -``` - -Run the full smoke test when you are ready to build test VMs: +## Layout -```bash -nix build .#checks.x86_64-linux.spotibox-basic +```text +flake.nix packages, manifest, home seed, release bundle +manifest.json generated by tools/update-manifest.sh, CI-checked +machines/ + spotibox.nix + spotibox-debug.nix +modules/qubix-options.nix qubix.* options (mode, gui, audio, app, session, homeDisk, network) +profiles/ + apps/spotify.nix Spotify package, kiosk rc.xml, spotibox-session + audio/pulseaudio-xrdp.nix + gui/openbox.nix + kernel/default.nix + modes/debug.nix, prod.nix + network/default.nix + remote/xrdp.nix xrdp server, session = qubix.session.command + security/minimal.nix + storage/persistent-home.nix +tools/ + qubixctl.ps1 controller + qubixctl.cmd console wrapper + qubix-up.cmd double-click launcher (elevates, runs `up`) + update-manifest.sh +tests/ + spotibox-basic.nix NixOS VM test + qubixctl.Tests.ps1 controller unit checks +.github/workflows/ + ci.yml, release.yml ``` -Manual acceptance: - -1. `.\tools\qubixctl.cmd -Command recreate -Machine spotibox` -2. Confirm Hyper-V has a running `qubix-spotibox`. -3. Connect with mstsc as `rdp` / `1234`. -4. Start `pavucontrol` in the RDP session and confirm the window appears there. -5. Start Spotify manually and confirm audio routes through RDP. - -## Nix-Generated JSON And Future YAML - -Nix is the source of truth for the manifest. `qubixctl` builds the -`qubix-manifest-json` derivation at startup, reads the resulting store path, -and parses the JSON — no manually maintained config file. - -YAML can be generated later as a human-facing artifact using the same pattern: -describe structured data in Nix, emit JSON with `builtins.toJSON`, then convert -JSON to YAML with a tool such as `yj`. - ## TODO / Later Goals -- Spotify Openbox autostart. - Spotify network lockdown via nftables, proxy or DNS allowlist. -- Stable custom Hyper-V NAT switch. - PipeWire + EasyEffects experiment once xrdp audio is understood. - Hardening profile, possibly inspired by nix-mineral, applied carefully. -- Production image with fewer debug tools. +- Production image with fewer debug tools (drop `xterm` from prod). - Kernel profile experiments: default/latest/hardened first, custom tiny kernel later. - Hyper-V differencing disks for disposable runtime clones. -- Optional Nix-generated YAML artifact for human-facing config/docs. +- Private-repository release downloads (token-authenticated asset URLs). +- Second backend behind the same manifest (App Sandbox / HCS) once it accepts + custom images and a configurable storage path. - `backend.microvm` for headless disposable sandboxes. - `backend.nspawn` for trusted services. @@ -269,3 +411,4 @@ JSON to YAML with a tool such as `yj`. - nixos-generators: - Generating YAML files with Nix: +- App Sandbox: diff --git a/flake.nix b/flake.nix index 926875b..21cab9a 100644 --- a/flake.nix +++ b/flake.nix @@ -12,71 +12,154 @@ outputs = { self, nixpkgs, nixos-generators }: let system = "x86_64-linux"; + lib = nixpkgs.lib; pkgs = import nixpkgs { inherit system; config.allowUnfreePredicate = pkg: - builtins.elem (nixpkgs.lib.getName pkg) [ + builtins.elem (lib.getName pkg) [ "spotify" ]; }; - # Evaluate a machine's NixOS config and return its config object. - # The Hyper-V manifest is derived from evaluated machine configs so that - # settings like hostName and static networking have one source of truth: - # the machine file. No more keeping two files in sync by hand. - evalMachine = modules: (nixpkgs.lib.nixosSystem { + # GitHub repository that publishes release assets. `qubixctl -Command up` + # downloads images from here when it is not asked to build them in WSL. + releaseRepo = "PhysShell/qubix"; + + # Evaluate a machine's NixOS config. The Hyper-V manifest is derived from + # evaluated machine configs so that settings like hostName, static + # networking and the home-disk contract have one source of truth: the + # machine file. No more keeping two files in sync by hand. + # + # The Hyper-V image module is part of the evaluation so that + # `nixosConfigurations.*` describe exactly the system that ends up in the + # VHDX (root filesystem, growPartition, Hyper-V guest services included). + evalMachine = modules: lib.nixosSystem { inherit system pkgs; - modules = modules; + modules = modules ++ [ + "${nixpkgs}/nixos/modules/virtualisation/hyperv-image.nix" + ]; specialArgs = { inherit self; }; - }).config; + }; # Extract network-related manifest fields from an evaluated NixOS config. # Returns an empty attrset when no static IP is configured (DHCP mode). mkNetworkFields = cfg: let net = cfg.qubix.network; - in nixpkgs.lib.optionalAttrs (net.staticIp != null) { + in lib.optionalAttrs (net.staticIp != null) { staticIp = net.staticIp; gatewayIp = net.gateway; # Derive subnet from staticIp + prefixLength (correct for /17-/24). natSwitchSubnet = - let parts = nixpkgs.lib.splitString "." net.staticIp; + let parts = lib.splitString "." net.staticIp; in "${builtins.elemAt parts 0}.${builtins.elemAt parts 1}" + ".${builtins.elemAt parts 2}.0/${toString net.prefixLength}"; }; - spotiboxCfg = evalMachine [ ./machines/spotibox.nix ]; + # One manifest entry per machine. Everything the Windows controller needs + # to create the VM, find the images and open the RDP window lives here. + mkMachineManifest = { name, cfg }: { + # Nix package names, used by the WSL build path. + package = "${name}-vhdx"; + homePackage = "${name}-home-vhdx"; + + # Derived from the machine config — only one place to change. + vmName = "qubix-${cfg.networking.hostName}"; + hostName = cfg.networking.hostName; + + # Used in DHCP mode; ignored when staticIp is present. + switchName = "Default Switch"; + cpuCount = 2; + memoryStartupBytes = 4294967296; + maxMemoryBytes = 6442450944; + vmRoot = "C:\\HyperV\\Qubix"; + wslDistro = "NixOS"; + + homeDisk = { + inherit (cfg.qubix.homeDisk) enable label sizeMiB; + }; + + # RDP window the controller opens after boot. The lab password is + # already public in this repo; storing it in Windows Credential Manager + # for one-click connects does not make it any less public. + rdp = { + user = "rdp"; + password = cfg.qubix.labPassword; + width = 1280; + height = 800; + }; + + # GitHub release assets, as produced by the `${name}-release` package. + release = { + repo = releaseRepo; + systemAsset = "${name}.vhdx.gz"; + homeAsset = "${name}-home.vhdx.gz"; + sumsAsset = "SHA256SUMS"; + }; + + # Network fields are present only when qubix.network.staticIp is set + # in the machine file. Nothing to change here when toggling static IP. + } // mkNetworkFields cfg; + + spotibox = evalMachine [ ./machines/spotibox.nix ]; + spotiboxDebug = evalMachine [ ./machines/spotibox-debug.nix ]; qubixManifest = { - spotibox = { - package = "spotibox-vhdx"; - # Derived from the machine config — only one place to change. - vmName = "qubix-${spotiboxCfg.networking.hostName}"; - hostName = spotiboxCfg.networking.hostName; - # Used in DHCP mode; ignored when staticIp is present. - switchName = "Default Switch"; - cpuCount = 2; - memoryStartupBytes = 4294967296; - maxMemoryBytes = 6442450944; - vmRoot = "C:\\HyperV\\Qubix"; - wslDistro = "NixOS"; - # Network fields are present only when qubix.network.staticIp is set - # in the machine file. Nothing to change here when toggling static IP. - } // mkNetworkFields spotiboxCfg; + schemaVersion = 2; + machines = { + spotibox = mkMachineManifest { name = "spotibox"; cfg = spotibox.config; }; + }; }; mkHypervImage = modules: nixos-generators.nixosGenerate { - inherit system; - inherit pkgs; - lib = nixpkgs.lib; - nixosSystem = nixpkgs.lib.nixosSystem; + inherit system pkgs lib; + nixosSystem = lib.nixosSystem; format = "hyperv"; modules = modules; specialArgs = { inherit self; }; }; + + # Pre-formatted, labelled, empty ext4 disk for /home as a dynamic VHDX. + # The guest mounts it by label (profiles/storage/persistent-home.nix), the + # host copies it next to the VM once and never touches it again. + mkHomeSeed = cfg: + let hd = cfg.qubix.homeDisk; + in pkgs.runCommand "qubix-home-${cfg.networking.hostName}.vhdx" { + nativeBuildInputs = [ pkgs.e2fsprogs pkgs.qemu-utils ]; + } '' + raw="$TMPDIR/home.raw" + truncate -s ${toString hd.sizeMiB}M "$raw" + mkfs.ext4 -q -F -L ${lib.escapeShellArg hd.label} -m 0 \ + -E lazy_itable_init=1,lazy_journal_init=1 "$raw" + qemu-img convert -f raw -O vhdx -o subformat=dynamic "$raw" "$out" + ''; + + # Everything one GitHub release needs, gzip'd so that Windows can unpack + # it with nothing but .NET (no zstd/7-Zip dependency on the host). + mkReleaseBundle = { name, systemImage, homeImage, manifest }: + pkgs.runCommand "qubix-release-${name}" { + nativeBuildInputs = [ pkgs.pigz ]; + } '' + mkdir -p "$out" + vhdx=$(find -L ${systemImage} -type f -name '*.vhdx' -print -quit) + test -n "$vhdx" || { echo "no .vhdx in ${systemImage}" >&2; exit 1; } + pigz -9 -n -c "$vhdx" > "$out/${name}.vhdx.gz" + pigz -9 -n -c ${homeImage} > "$out/${name}-home.vhdx.gz" + cp ${manifest} "$out/manifest.json" + cd "$out" + sha256sum ${name}.vhdx.gz ${name}-home.vhdx.gz manifest.json > SHA256SUMS + ''; + + manifestJson = (pkgs.formats.json { }).generate "qubix-manifest.json" qubixManifest; in { + # Exposed for introspection (`nix eval`, `nixos-rebuild build-vm`, tests). + nixosConfigurations = { + spotibox = spotibox; + spotibox-debug = spotiboxDebug; + }; + packages.${system} = { spotibox-vhdx = mkHypervImage [ ./machines/spotibox.nix @@ -86,8 +169,16 @@ ./machines/spotibox-debug.nix ]; - qubix-manifest-json = pkgs.writeText "qubix-manifest.json" - (builtins.toJSON qubixManifest); + spotibox-home-vhdx = mkHomeSeed spotibox.config; + + spotibox-release = mkReleaseBundle { + name = "spotibox"; + systemImage = self.packages.${system}.spotibox-vhdx; + homeImage = self.packages.${system}.spotibox-home-vhdx; + manifest = manifestJson; + }; + + qubix-manifest-json = manifestJson; default = self.packages.${system}.spotibox-vhdx; }; diff --git a/machines/spotibox.nix b/machines/spotibox.nix index b90081f..ba92da9 100644 --- a/machines/spotibox.nix +++ b/machines/spotibox.nix @@ -4,6 +4,8 @@ imports = [ ../profiles/base.nix ../profiles/users.nix + ../profiles/storage/persistent-home.nix + ../profiles/remote/xrdp.nix ../profiles/gui/openbox.nix ../profiles/audio/pulseaudio-xrdp.nix ../profiles/apps/spotify.nix @@ -24,6 +26,7 @@ audio = "pulseaudio-xrdp"; app = "spotify"; kernel = "default"; + homeDisk.sizeMiB = 16 * 1024; network = { staticIp = "192.168.250.10"; gateway = "192.168.250.1"; }; }; } diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..86c95ed --- /dev/null +++ b/manifest.json @@ -0,0 +1,37 @@ +{ + "machines": { + "spotibox": { + "cpuCount": 2, + "gatewayIp": "192.168.250.1", + "homeDisk": { + "enable": true, + "label": "qubix-home", + "sizeMiB": 16384 + }, + "homePackage": "spotibox-home-vhdx", + "hostName": "spotibox", + "maxMemoryBytes": 6442450944, + "memoryStartupBytes": 4294967296, + "natSwitchSubnet": "192.168.250.0/24", + "package": "spotibox-vhdx", + "rdp": { + "height": 800, + "password": "1234", + "user": "rdp", + "width": 1280 + }, + "release": { + "homeAsset": "spotibox-home.vhdx.gz", + "repo": "PhysShell/qubix", + "sumsAsset": "SHA256SUMS", + "systemAsset": "spotibox.vhdx.gz" + }, + "staticIp": "192.168.250.10", + "switchName": "Default Switch", + "vmName": "qubix-spotibox", + "vmRoot": "C:\\HyperV\\Qubix", + "wslDistro": "NixOS" + } + }, + "schemaVersion": 2 +} diff --git a/modules/qubix-options.nix b/modules/qubix-options.nix index 4356589..3f8bf70 100644 --- a/modules/qubix-options.nix +++ b/modules/qubix-options.nix @@ -38,6 +38,45 @@ description = "Disposable lab password used for generated appliance users."; }; + session.command = lib.mkOption { + type = lib.types.str; + default = "xterm"; + example = "openbox-session"; + description = '' + Command that starts the graphical session for remote (xrdp) logins. + GUI profiles set a window-manager default; app profiles override it + with a purpose-built session (for example Spotify in kiosk mode). + ''; + }; + + homeDisk = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Mount /home from a separate, persistent VHDX labelled + `qubix.homeDisk.label`. The system disk is a throwaway Nix + artifact that gets replaced on every recreate; the home disk is + what survives — Spotify login, caches, user settings. + ''; + }; + + label = lib.mkOption { + type = lib.types.str; + default = "qubix-home"; + description = "ext4 filesystem label of the persistent home disk."; + }; + + sizeMiB = lib.mkOption { + type = lib.types.int; + default = 16 * 1024; + description = '' + Size of the generated home-disk seed image. The VHDX is dynamic, + so only used space is consumed on the host. + ''; + }; + }; + networkLockdown.enable = lib.mkEnableOption "restricted outbound network policy"; network = { diff --git a/profiles/apps/spotify.nix b/profiles/apps/spotify.nix index 6e91cbe..5b67210 100644 --- a/profiles/apps/spotify.nix +++ b/profiles/apps/spotify.nix @@ -1,11 +1,48 @@ { config, lib, pkgs, ... }: +let + # Stock Openbox rc.xml plus one rule: every normal Spotify window is + # undecorated and maximised. The RDP canvas then *is* the Spotify window, + # which is as close to "Spotify as a native window" as xrdp gets — xrdp has + # no RemoteApp support, so a seamless per-window mode is not on the table. + # Dialogs are excluded by type="normal" so popups keep their frames. + openboxRc = pkgs.runCommand "qubix-openbox-rc.xml" { } '' + cp ${pkgs.openbox}/etc/xdg/openbox/rc.xml $out + chmod +w $out + substituteInPlace $out --replace-fail '' ' + no + yes + yes + +' + ''; + + # Session script used by xrdp. Openbox runs in the background purely as a + # window manager; Spotify is the session. Quitting Spotify ends the session, + # so the mstsc window closes like an ordinary application window would. + session = pkgs.writeShellApplication { + name = "spotibox-session"; + runtimeInputs = [ + pkgs.openbox + pkgs.spotify + pkgs.xorg.xsetroot + ]; + text = '' + xsetroot -solid '#121212' || true + openbox --config-file /etc/qubix/openbox-rc.xml & + wm=$! + # Let the window manager come up so the first Spotify window is + # managed (and therefore maximised) instead of appearing unmanaged. + sleep 1 + spotify || true + kill "$wm" 2>/dev/null || true + ''; + }; +in lib.mkIf (config.qubix.app == "spotify") { - environment.systemPackages = with pkgs; [ - spotify - ]; + environment.systemPackages = [ pkgs.spotify ]; + + environment.etc."qubix/openbox-rc.xml".source = openboxRc; - # TODO: Add Spotify Openbox autostart after the final login/session model is - # fixed. The likely location is /etc/xdg/openbox/autostart or a user-level - # ~/.config/openbox/autostart file. + qubix.session.command = "${session}/bin/spotibox-session"; } diff --git a/profiles/audio/pulseaudio-xrdp.nix b/profiles/audio/pulseaudio-xrdp.nix index 2217d58..70d216e 100644 --- a/profiles/audio/pulseaudio-xrdp.nix +++ b/profiles/audio/pulseaudio-xrdp.nix @@ -7,15 +7,35 @@ lib.mkIf (config.qubix.audio == "pulseaudio-xrdp") { # PipeWire was deliberately not selected for this baseline because the # Hyper-V + xrdp appliance prototype produced Dummy Output / broken audio with # PipeWire while PulseAudio+xrdp worked. - services.xrdp = { - enable = true; - defaultWindowManager = "openbox-session"; - openFirewall = true; + # + # The xrdp server itself is configured in profiles/remote/xrdp.nix; this + # profile only wires the audio path into it. + services.xrdp.audio.enable = true; - audio = { - enable = true; - }; - }; + # mstsc negotiates WAVE_FORMAT_MPEGLAYER3 whenever xrdp offers it and then + # plays nothing at all. Everything inside the guest looks healthy while this + # happens - the sink runs, it is not muted, chansrv accepts the socket and + # reports a round trip time - but the host stays silent, because mstsc simply + # drops the MP3 stream (neutrinolabs/xrdp#965). Building xrdp without the MP3 + # and Opus encoders leaves PCM as the only negotiable format, and every RDP + # client decodes that. PCM costs ~176 kB/s, which is nothing next to the + # video channel this appliance already pushes. + # + # This must be an overlay, not services.xrdp.package: the NixOS xrdp module + # declares that option but hardcodes pkgs.xrdp in the ExecStart lines of both + # xrdp.service and xrdp-sesman.service, so setting the option rebuilds only + # confDir while the daemons keep running the unmodified build. A fix is + # already open upstream as https://github.com/NixOS/nixpkgs/pull/452303; + # once it lands this can go back to a plain services.xrdp.package assignment. + nixpkgs.overlays = [ + (_final: prev: { + xrdp = prev.xrdp.overrideAttrs (old: { + configureFlags = builtins.filter + (f: f != "--enable-mp3lame" && f != "--enable-opus") + old.configureFlags; + }); + }) + ]; security.rtkit.enable = true; diff --git a/profiles/gui/openbox.nix b/profiles/gui/openbox.nix index 172d0be..6fd5c74 100644 --- a/profiles/gui/openbox.nix +++ b/profiles/gui/openbox.nix @@ -8,6 +8,9 @@ lib.mkIf (config.qubix.gui == "openbox") { services.xserver.displayManager.lightdm.enable = true; services.xserver.windowManager.openbox.enable = true; + # Remote sessions get a bare Openbox unless an app profile overrides this. + qubix.session.command = lib.mkDefault "${pkgs.openbox}/bin/openbox-session"; + environment.systemPackages = with pkgs; [ openbox xterm diff --git a/profiles/remote/xrdp.nix b/profiles/remote/xrdp.nix new file mode 100644 index 0000000..6ce3488 --- /dev/null +++ b/profiles/remote/xrdp.nix @@ -0,0 +1,13 @@ +{ config, ... }: + +{ + # xrdp is the appliance's window to the Windows host. Whatever the active + # profiles put into qubix.session.command becomes the session: a plain + # window manager for generic images, or a single-app kiosk session such as + # Spotify. When that command exits, xrdp ends the session and mstsc closes. + services.xrdp = { + enable = true; + defaultWindowManager = config.qubix.session.command; + openFirewall = true; + }; +} diff --git a/profiles/storage/persistent-home.nix b/profiles/storage/persistent-home.nix new file mode 100644 index 0000000..1997ece --- /dev/null +++ b/profiles/storage/persistent-home.nix @@ -0,0 +1,26 @@ +{ config, lib, ... }: + +let + cfg = config.qubix.homeDisk; +in { + # /home lives on its own VHDX so that the system disk can be thrown away and + # rebuilt from Nix at any time without logging the user out of anything. + # + # The disk is not formatted by the guest. Qubix ships a pre-formatted, + # labelled ext4 seed image (see `spotibox-home-vhdx` in flake.nix) and the + # Windows controller copies it next to the VM on first `up`. No device-order + # guessing, no autoFormat surprises: the label either exists or boot stops. + # + # neededForBoot makes stage 1 mount it before user activation runs, so home + # directories are created on the persistent disk, not on the throwaway root. + # A missing home disk therefore fails loudly instead of silently giving you + # a fresh, empty home on every rebuild. + config = lib.mkIf cfg.enable { + fileSystems."/home" = { + device = "/dev/disk/by-label/${cfg.label}"; + fsType = "ext4"; + neededForBoot = true; + options = [ "noatime" "nodev" "nosuid" ]; + }; + }; +} diff --git a/profiles/users.nix b/profiles/users.nix index ae457ab..09e65df 100644 --- a/profiles/users.nix +++ b/profiles/users.nix @@ -1,12 +1,18 @@ { config, ... }: { + # UIDs are pinned because /home is a persistent disk that outlives the + # system image. NixOS would otherwise allocate UIDs in alphabetical order + # at activation time, and adding a user later could silently shift them — + # leaving the persistent home directories owned by the wrong account. + # Main interactive user for local/Hyper-V console work. # # This is a disposable lab image. Replace the plaintext initialPassword with # initialHashedPassword before using Qubix for anything less throwaway. users.users.user = { isNormalUser = true; + uid = 1000; initialPassword = config.qubix.labPassword; extraGroups = [ "wheel" "audio" ]; }; @@ -18,6 +24,7 @@ # on a separate Unix user gives it a separate user bus and PulseAudio world. users.users.rdp = { isNormalUser = true; + uid = 1001; initialPassword = config.qubix.labPassword; extraGroups = [ "wheel" "audio" ]; }; diff --git a/tests/qubixctl.Tests.ps1 b/tests/qubixctl.Tests.ps1 new file mode 100755 index 0000000..3dd5aa2 --- /dev/null +++ b/tests/qubixctl.Tests.ps1 @@ -0,0 +1,138 @@ +#!/usr/bin/env pwsh +<# +Unit checks for the pure parts of tools/qubixctl.ps1. + +No Hyper-V, no network, no WSL: the script is dot-sourced (which defines the +functions without running a command) and the helpers that do not touch the +host are exercised on both Linux (CI) and Windows. +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repo = Split-Path -Parent $PSScriptRoot +. (Join-Path $repo 'tools/qubixctl.ps1') + +$script:failures = 0 +$script:passes = 0 + +function Assert-True { + param([bool]$Condition, [string]$Message) + if ($Condition) { + $script:passes++ + } else { + $script:failures++ + Write-Host "FAIL: $Message" -ForegroundColor Red + } +} + +function Assert-Throw { + param([scriptblock]$Block, [string]$Pattern, [string]$Message) + try { + & $Block | Out-Null + $script:failures++ + Write-Host "FAIL: $Message (did not throw)" -ForegroundColor Red + } catch { + if ($_.Exception.Message -like $Pattern) { + $script:passes++ + } else { + $script:failures++ + Write-Host "FAIL: $Message (unexpected error: $($_.Exception.Message))" -ForegroundColor Red + } + } +} + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("qubixctl-tests-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp | Out-Null + +try { + # --- Get-Prop / Get-EffectiveValue ------------------------------------- + $obj = [PSCustomObject]@{ a = 1; nested = [PSCustomObject]@{ b = 'x' }; empty = $null } + Assert-True ((Get-Prop $obj 'a') -eq 1) 'Get-Prop returns an existing value' + Assert-True ((Get-Prop $obj 'missing' 'dflt') -eq 'dflt') 'Get-Prop falls back for missing properties' + Assert-True ((Get-Prop $obj 'empty' 'dflt') -eq 'dflt') 'Get-Prop falls back for null values' + Assert-True ((Get-Prop $null 'a' 'dflt') -eq 'dflt') 'Get-Prop tolerates a null object' + Assert-True ((Get-Prop (Get-Prop $obj 'nested') 'b') -eq 'x') 'Get-Prop composes' + Assert-True ((Get-EffectiveValue -Override '' -Default 'd') -eq 'd') 'Get-EffectiveValue uses the default for empty overrides' + Assert-True ((Get-EffectiveValue -Override 'o' -Default 'd') -eq 'o') 'Get-EffectiveValue prefers the override' + + # --- Manifest ------------------------------------------------------------ + $manifestPath = Join-Path $repo 'manifest.json' + Assert-True (Test-Path -LiteralPath $manifestPath) 'manifest.json is committed' + $manifest = Read-QubixManifest -Path $manifestPath + Assert-True ((Get-Prop $manifest 'schemaVersion') -eq 2) 'manifest.json has schemaVersion 2' + + $config = Resolve-MachineConfig -Manifest $manifest -Name 'spotibox' + Assert-True ((Get-Prop $config 'vmName') -eq 'qubix-spotibox') 'spotibox vmName is derived from hostName' + Assert-True ((Get-Prop $config 'hostName') -eq 'spotibox') 'spotibox hostName' + Assert-True ((Get-Prop (Get-Prop $config 'homeDisk') 'enable') -eq $true) 'spotibox has a persistent home disk' + Assert-True ((Get-Prop (Get-Prop $config 'release') 'systemAsset') -eq 'spotibox.vhdx.gz') 'release asset names are stable' + Assert-True ((Get-Prop (Get-Prop $config 'rdp') 'user') -eq 'rdp') 'rdp user is the dedicated account' + Assert-True (-not [string]::IsNullOrWhiteSpace((Get-Prop $config 'staticIp' ''))) 'spotibox declares a static IP' + Assert-True ((Get-Prop $config 'natSwitchSubnet' '') -like '*.0/24') 'NAT subnet is derived from the static IP' + + Assert-Throw { Resolve-MachineConfig -Manifest $manifest -Name 'nope' } '*Known machines: spotibox*' 'unknown machine names list the known ones' + + $v1 = '{"spotibox":{"vmName":"x"}}' | ConvertFrom-Json + Assert-Throw { Assert-ManifestSchema -Manifest $v1 } '*schemaVersion*' 'schema v1 manifests are rejected with a hint' + + # --- Paths -------------------------------------------------------------- + $paths = Get-QubixLayout -Config $config -VmRootOverride '' + Assert-True ($paths.VmName -eq 'qubix-spotibox') 'paths carry the VM name' + Assert-True ($paths.SystemVhdx -like '*qubix-spotibox*qubix-spotibox.vhdx') 'system disk path' + Assert-True ($paths.HomeVhdx -like '*qubix-spotibox-home.vhdx') 'home disk path' + Assert-True ($paths.RdpFile -like '*qubix-spotibox.rdp') 'rdp file path' + Assert-True ($paths.ImageCache -like '*images*spotibox') 'image cache is per machine' + $custom = Get-QubixLayout -Config $config -VmRootOverride 'D:\vms' + Assert-True ($custom.VmRoot -eq 'D:\vms') '-VmRoot override wins over the manifest' + + # --- WSL path helpers --------------------------------------------------- + Assert-True ((ConvertTo-WslMountPath -WindowsPath 'C:\Users\me\x.tmp') -eq '/mnt/c/Users/me/x.tmp') 'drive path -> /mnt path' + Assert-Throw { ConvertTo-WslMountPath -WindowsPath '\\server\share' } '*Not a drive-letter path*' 'UNC paths are rejected by the /mnt converter' + $loc = Resolve-WslRepoLocation -DistroOverride 'Ubuntu' -LinuxPathOverride '/srv/qubix' + Assert-True ($loc.Distro -eq 'Ubuntu' -and $loc.RepoPath -eq '/srv/qubix') 'explicit WSL overrides are honoured' + + # --- SHA256SUMS + gzip -------------------------------------------------- + $payload = [System.Text.Encoding]::ASCII.GetBytes(('qubix ' * 1000)) + $plain = Join-Path $tmp 'a.vhdx' + $gz = Join-Path $tmp 'a.vhdx.gz' + [System.IO.File]::WriteAllBytes($plain, $payload) + $in = [System.IO.File]::OpenRead($plain) + $out = [System.IO.File]::Create($gz) + $stream = New-Object System.IO.Compression.GZipStream($out, [System.IO.Compression.CompressionMode]::Compress) + $in.CopyTo($stream); $stream.Dispose(); $out.Dispose(); $in.Dispose() + + $hash = (Get-FileHash -LiteralPath $gz -Algorithm SHA256).Hash.ToLowerInvariant() + $sumsFile = Join-Path $tmp 'SHA256SUMS' + Set-Content -LiteralPath $sumsFile -Value @("$hash a.vhdx.gz", 'deadbeef garbage line', "$hash *b.vhdx.gz") + $sums = Read-Sha256SumFile -Path $sumsFile + Assert-True ($sums['a.vhdx.gz'] -eq $hash) 'SHA256SUMS parsing (two-space form)' + Assert-True ($sums['b.vhdx.gz'] -eq $hash) 'SHA256SUMS parsing (binary marker form)' + Assert-True (-not $sums.ContainsKey('garbage line')) 'malformed lines are ignored' + Assert-FileHash -Path $gz -Expected $hash.ToUpperInvariant() + Assert-Throw { Assert-FileHash -Path $gz -Expected ('0' * 64) } '*SHA256 mismatch*' 'hash mismatch is fatal' + + $restored = Join-Path $tmp 'restored.vhdx' + Expand-GzipFile -Path $gz -Destination $restored + Assert-True (([System.IO.File]::ReadAllBytes($restored)).Length -eq $payload.Length) 'gzip round trip restores the payload' + Assert-True (-not (Test-Path -LiteralPath "$restored.part")) 'no .part file is left behind' + + $cached = Get-ReleaseAsset -BaseUrl 'http://unused.invalid' -Dir $tmp -Asset 'restored.vhdx.gz' -Sums @{} + Assert-True ($cached -eq $restored) 'cached assets are returned without downloading' + + # --- RDP ---------------------------------------------------------------- + $rdp = Format-RdpFile -Address '192.168.250.10' -User 'rdp' -Width 1280 -Height 800 + Assert-True ($rdp -like "full address:s:192.168.250.10`r`n*") 'rdp file starts with the address' + Assert-True ($rdp -like "*username:s:rdp`r`n*") 'rdp file carries the user' + Assert-True ($rdp -like "*audiomode:i:0`r`n*") 'audio is played on the host' + Assert-True ($rdp -like "*redirectdrives:i:0`r`n*") 'drives are not redirected' + Assert-True ($rdp -like "*desktopwidth:i:1280`r`n*desktopheight:i:800`r`n*") 'window size comes from the manifest' + + Assert-True ((Get-QubixAddress -Config $config -Explicit '10.0.0.5') -eq '10.0.0.5') 'explicit address wins' + Assert-True ((Get-QubixAddress -Config $config -Explicit '') -eq (Get-Prop $config 'staticIp')) 'static IP is used without Hyper-V lookups' + Assert-True (-not (Test-TcpPort -TargetHost '127.0.0.1' -Port 1 -TimeoutMs 500)) 'closed ports are reported as closed' +} finally { + Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "qubixctl unit checks: $script:passes passed, $script:failures failed" +if ($script:failures -gt 0) { exit 1 } diff --git a/tests/spotibox-basic.nix b/tests/spotibox-basic.nix index e1c21d1..435ad3e 100644 --- a/tests/spotibox-basic.nix +++ b/tests/spotibox-basic.nix @@ -3,10 +3,15 @@ pkgs.testers.nixosTest { name = "spotibox-basic"; - nodes.machine = { + nodes.machine = { lib, ... }: { imports = [ ../machines/spotibox.nix ]; + + # The test VM has no second disk, and the persistent-home profile makes + # boot wait for one. Keep the test on a single disk; the home-disk + # contract is exercised by the real Hyper-V VM. + qubix.homeDisk.enable = lib.mkForce false; }; testScript = '' @@ -14,12 +19,16 @@ pkgs.testers.nixosTest { machine.wait_for_unit("multi-user.target") machine.succeed("test $(hostname) = spotibox") - machine.succeed("id user") - machine.succeed("id rdp") + machine.succeed("test $(id -u user) = 1000") + machine.succeed("test $(id -u rdp) = 1001") machine.succeed("command -v spotify") machine.succeed("command -v openbox-session") machine.succeed("command -v pavucontrol") machine.succeed("systemctl is-enabled xrdp") machine.succeed("systemctl is-enabled avahi-daemon") + + # The xrdp session must be the Spotify kiosk session, not a bare WM. + machine.succeed("grep -q spotibox-session /etc/xrdp/startwm.sh") + machine.succeed("grep -q 'class=\"Spotify\"' /etc/qubix/openbox-rc.xml") ''; } diff --git a/tools/qubix-up.cmd b/tools/qubix-up.cmd new file mode 100644 index 0000000..53b885b --- /dev/null +++ b/tools/qubix-up.cmd @@ -0,0 +1,28 @@ +@echo off +:: One click: elevate if needed, run `qubixctl -Command up`, keep the window +:: open only when something went wrong. Extra arguments are passed through +:: when already elevated (e.g. qubix-up.cmd -Machine spotibox -NoConnect). +setlocal + +:: pushd maps UNC paths (\\wsl.localhost\...) to a temporary drive letter, +:: which CMD.EXE requires. Without this, CMD refuses to work in UNC directories. +pushd "%~dp0" >nul 2>&1 + +net session >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo Requesting administrator rights... + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + popd + exit /b +) + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0qubixctl.ps1" -Command up %* +set "EXIT=%ERRORLEVEL%" +popd + +if %EXIT% neq 0 ( + echo. + echo qubixctl failed with exit code %EXIT%. + pause +) +exit /b %EXIT% diff --git a/tools/qubixctl.cmd b/tools/qubixctl.cmd index 41ace25..8447ce9 100644 --- a/tools/qubixctl.cmd +++ b/tools/qubixctl.cmd @@ -7,6 +7,8 @@ set "SCRIPT=%~dp0qubixctl.ps1" :: which CMD.EXE requires. Without this, CMD refuses to work in UNC directories. pushd "%~dp0" >nul 2>&1 +:: -ExecutionPolicy Bypass is process-scoped: the global policy stays untouched +:: and scripts under \\wsl.localhost\... are not treated as unsigned remote files. powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" %* set "EXIT=%ERRORLEVEL%" diff --git a/tools/qubixctl.ps1 b/tools/qubixctl.ps1 index 24bb348..a13899b 100644 --- a/tools/qubixctl.ps1 +++ b/tools/qubixctl.ps1 @@ -1,35 +1,284 @@ +<# +.SYNOPSIS + Qubix controller: turns a Nix-built appliance image into a running, + persistent Hyper-V VM and opens it as an RDP window. + +.DESCRIPTION + The default command, `up`, is idempotent and is what tools/qubix-up.cmd + runs on double-click: + + manifest.json -> images (GitHub release | WSL build | local file) + -> Hyper-V VM (created once, kept afterwards) + -> persistent home (seeded once, never replaced) + -> wait for RDP + -> mstsc with a generated .rdp file + + Nothing here needs WSL unless -ImageSource wsl is requested. The system + disk is a throwaway Nix artifact; the home disk is the only state. + +.PARAMETER Command + up create-if-missing, start, wait for RDP, connect (default) + connect open the RDP window for a running VM + start start / resume the VM + stop graceful shutdown + status VM state, disks, address, installed image version + recreate replace the system disk with fresh images, keep the home disk + destroy remove VM + system disk (add -Purge to delete the home disk too) + fetch download release images into the cache without touching the VM + build build images in WSL (developer path) + manifest print the resolved machine config + +.PARAMETER ImageSource + auto -ImagePath if given, otherwise the GitHub release (default) + release download from the GitHub release named by -Release + wsl nix build inside WSL, manifest regenerated from Nix + file use -ImagePath / -HomeImagePath + +.EXAMPLE + .\tools\qubixctl.cmd # up spotibox + .\tools\qubixctl.cmd -Command recreate -Release v0.2.0 + .\tools\qubixctl.cmd -Command up -ImageSource wsl # developer loop +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + Justification = 'Script parameters are consumed by Invoke-QubixMain; the analyzer does not follow that.')] +[CmdletBinding()] param( - [ValidateSet("build", "recreate", "start", "stop", "destroy", "status", "mstsc")] - [string]$Command = "status", - - [string]$Machine = "spotibox", - [string]$WslDistro = "", - [string]$RepoLinuxPath = "/home/nixos/Documents/repos/qubix", - [string]$VmRoot = "", - [string]$SwitchName = "", - [string]$Address = "" + [ValidateSet('up', 'connect', 'start', 'stop', 'status', 'recreate', 'destroy', 'fetch', 'build', 'manifest')] + [string]$Command = 'up', + + [string]$Machine = 'spotibox', + + [ValidateSet('auto', 'release', 'wsl', 'file')] + [string]$ImageSource = 'auto', + + # Local .vhdx files (or .vhdx.gz) used by -ImageSource file / auto. + [string]$ImagePath = '', + [string]$HomeImagePath = '', + + # Release tag, or 'latest'. + [string]$Release = 'latest', + + # Defaults to /manifest.json next to this script's parent folder. + [string]$ManifestPath = '', + + # WSL build path only. + [string]$WslDistro = '', + [string]$RepoLinuxPath = '', + + # Overrides for manifest values. + [string]$VmRoot = '', + [string]$SwitchName = '', + [string]$Address = '', + + [int]$TimeoutSeconds = 300, + [switch]$NoConnect, + [switch]$NoSavedCredential, + [switch]$Purge ) -$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# Windows PowerShell 5.1 on older builds still defaults to TLS 1.0 for .NET +# web requests; GitHub requires TLS 1.2. +try { + [System.Net.ServicePointManager]::SecurityProtocol = + [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 +} catch { + Write-Verbose "Could not enable TLS 1.2: $($_.Exception.Message)" +} + +# -------------------------------------------------------------------------- +# Small helpers +# -------------------------------------------------------------------------- + +function Get-Prop { + # Strict-mode-safe property access on objects coming from ConvertFrom-Json. + param( + [object]$Object, + [string]$Name, + [object]$Default = $null + ) + + if ($null -eq $Object) { return $Default } + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { return $Default } + return $property.Value +} + +function Get-EffectiveValue { + param( + [string]$Override, + [object]$Default + ) + + if ([string]::IsNullOrWhiteSpace($Override)) { + return [string]$Default + } + return $Override +} + +function Join-QubixPath { + # Join-Path validates the drive of its first argument against the current + # session's PSDrives, which fails for roots on a volume that is not mounted + # (or, in the unit checks, on Linux). Path.Combine only joins strings. + param( + [string]$Path, + [string]$ChildPath + ) + return [System.IO.Path]::Combine($Path, $ChildPath) +} + +function Get-DefaultManifestPath { + return Join-QubixPath (Split-Path -Parent $PSScriptRoot) 'manifest.json' +} + +# -------------------------------------------------------------------------- +# Manifest +# -------------------------------------------------------------------------- + +function Assert-ManifestSchema { + param([object]$Manifest) + + $version = Get-Prop $Manifest 'schemaVersion' 0 + if ([int]$version -ne 2) { + throw "Unsupported manifest schemaVersion '$version' (expected 2). Regenerate manifest.json with tools/update-manifest.sh." + } + if ($null -eq (Get-Prop $Manifest 'machines')) { + throw "Manifest has no 'machines' section." + } +} + +function Read-QubixManifest { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Manifest not found: $Path. Clone the full repository, or pass -ManifestPath." + } + + $manifest = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + Assert-ManifestSchema -Manifest $manifest + return $manifest +} + +function Resolve-MachineConfig { + param( + [object]$Manifest, + [string]$Name + ) + + $machines = Get-Prop $Manifest 'machines' + $config = Get-Prop $machines $Name + if ($null -eq $config) { + $known = @($machines.PSObject.Properties | ForEach-Object { $_.Name }) -join ', ' + throw "Machine '$Name' was not found in the Qubix manifest. Known machines: $known" + } + return $config +} + +function Get-QubixLayout { + param( + [object]$Config, + [string]$VmRootOverride + ) + + $vmName = [string](Get-Prop $Config 'vmName') + $root = Get-EffectiveValue -Override $VmRootOverride -Default (Get-Prop $Config 'vmRoot' 'C:\HyperV\Qubix') + $vmDir = Join-QubixPath $root $vmName + + return [PSCustomObject]@{ + VmName = $vmName + VmRoot = $root + VmDir = $vmDir + SystemVhdx = Join-QubixPath $vmDir "$vmName.vhdx" + HomeVhdx = Join-QubixPath $vmDir "$vmName-home.vhdx" + RdpFile = Join-QubixPath $vmDir "$vmName.rdp" + VersionFile = Join-QubixPath $vmDir 'image-version.txt' + ImageCache = Join-QubixPath (Join-QubixPath $root 'images') ([string](Get-Prop $Config 'hostName')) + } +} + +# -------------------------------------------------------------------------- +# Host preconditions +# -------------------------------------------------------------------------- + +function Assert-HyperVAvailable { + if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { + throw ("Hyper-V PowerShell cmdlets are not available. Enable the feature from an elevated PowerShell and reboot:`n" + + " Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All") + } +} -function New-WslTempScript { +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This command changes Hyper-V state and must run elevated. Double-click tools\qubix-up.cmd or use an elevated PowerShell." + } +} + +# -------------------------------------------------------------------------- +# WSL build path (developer loop) +# -------------------------------------------------------------------------- + +function Save-WslTempScript { param([string]$Script) # GetTempFileName creates a file like C:\Users\...\AppData\Local\Temp\tmpXXXX.tmp $winPath = [System.IO.Path]::GetTempFileName() - # Write script bytes directly — no BOM, LF-only line endings. + # Write script bytes directly - no BOM, LF-only line endings. # PowerShell's pipe (|) appends \r\n; WriteAllBytes avoids that entirely. [System.IO.File]::WriteAllBytes( $winPath, [System.Text.Encoding]::UTF8.GetBytes($Script + "`n") ) - # Convert Windows path to WSL /mnt//... path. - $wslPath = "/mnt/" + $winPath[0].ToString().ToLower() + "/" + - $winPath.Substring(3).Replace("\", "/") + return [PSCustomObject]@{ Win = $winPath; Wsl = (ConvertTo-WslMountPath -WindowsPath $winPath) } +} + +function ConvertTo-WslMountPath { + # C:\Users\x\file -> /mnt/c/Users/x/file + param([string]$WindowsPath) - return [PSCustomObject]@{ Win = $winPath; Wsl = $wslPath } + if ($WindowsPath -notmatch '^([A-Za-z]):\\(.*)$') { + throw "Not a drive-letter path: $WindowsPath" + } + return '/mnt/' + $matches[1].ToLowerInvariant() + '/' + ($matches[2] -replace '\\', '/') +} + +function Resolve-WslRepoLocation { + # Works out which distro and Linux path hold the repository this script + # lives in. \\wsl.localhost\Distro\home\me\qubix\tools -> (Distro, /home/me/qubix) + param( + [string]$DistroOverride, + [string]$LinuxPathOverride + ) + + $repoWin = Split-Path -Parent $PSScriptRoot + $distro = $DistroOverride + $linuxPath = $LinuxPathOverride + + if ($repoWin -match '^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.*)$') { + if ([string]::IsNullOrWhiteSpace($distro)) { $distro = $matches[1] } + if ([string]::IsNullOrWhiteSpace($linuxPath)) { $linuxPath = '/' + ($matches[2] -replace '\\', '/') } + } elseif ([string]::IsNullOrWhiteSpace($linuxPath) -and $repoWin -match '^[A-Za-z]:\\') { + $linuxPath = ConvertTo-WslMountPath -WindowsPath $repoWin + } + + if ([string]::IsNullOrWhiteSpace($distro)) { $distro = 'NixOS' } + if ([string]::IsNullOrWhiteSpace($linuxPath)) { + throw "Cannot determine the Linux path of the repository. Pass -RepoLinuxPath." + } + + return [PSCustomObject]@{ Distro = $distro; RepoPath = $linuxPath } +} + +function Assert-WslAvailable { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + throw "wsl.exe was not found. -ImageSource wsl needs WSL with a Nix-capable distro; use the default release images instead." + } } function Invoke-WslCapture { @@ -42,7 +291,7 @@ function Invoke-WslCapture { # Write the script to a temp file with LF-only endings and pass the path to # bash directly. This sidesteps both PowerShell's CRLF injection and all # wsl.exe argument-quoting issues with bash -c / stdin piping. - $tmp = New-WslTempScript -Script $Script + $tmp = Save-WslTempScript -Script $Script try { $output = & wsl.exe -d $Distro --cd $RepoPath -- bash -l $tmp.Wsl if ($LASTEXITCODE -ne 0) { @@ -50,7 +299,7 @@ function Invoke-WslCapture { } return ($output -join "`n").Trim() } finally { - Remove-Item $tmp.Win -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tmp.Win -ErrorAction SilentlyContinue } } @@ -61,334 +310,833 @@ function Invoke-WslInteractive { [string]$Script ) - $tmp = New-WslTempScript -Script $Script + $tmp = Save-WslTempScript -Script $Script try { & wsl.exe -d $Distro --cd $RepoPath -- bash -l $tmp.Wsl if ($LASTEXITCODE -ne 0) { throw "WSL command failed with exit code ${LASTEXITCODE}: $Script" } } finally { - Remove-Item $tmp.Win -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tmp.Win -ErrorAction SilentlyContinue } } -function Get-QubixManifest { +function Convert-LinuxPathToWindows { + param( + [string]$Distro, + [string]$LinuxPath + ) + + $output = & wsl.exe -d $Distro --cd / -- wslpath -w $LinuxPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to convert WSL path to Windows path: $LinuxPath" + } + return ($output -join "`n").Trim() +} + +function Get-QubixManifestFromWsl { param( [string]$Distro, [string]$RepoPath ) - $json = Invoke-WslCapture ` - -Distro $Distro ` - -RepoPath $RepoPath ` + $json = Invoke-WslCapture -Distro $Distro -RepoPath $RepoPath ` -Script 'manifest_path=$(nix build --no-link --print-out-paths .#qubix-manifest-json | tr -d ''\r''); cat "$manifest_path"' - return $json | ConvertFrom-Json + $manifest = $json | ConvertFrom-Json + Assert-ManifestSchema -Manifest $manifest + return $manifest } -function Resolve-MachineConfig { +function Build-QubixImagesInWsl { param( - [object]$Manifest, - [string]$Name + [object]$Config, + [string]$Distro, + [string]$RepoPath ) - $config = $Manifest.PSObject.Properties[$Name].Value - if (-not $config) { - throw "Machine '$Name' was not found in the Nix-generated Qubix manifest." + $package = [string](Get-Prop $Config 'package') + $homePackage = [string](Get-Prop $Config 'homePackage' '') + + Write-Host "=== Building .#$package in WSL distro '$Distro' ===" + Invoke-WslInteractive -Distro $Distro -RepoPath $RepoPath -Script "nix build -L .#$package" + + # --print-out-paths gives the absolute store path; wslpath -w and Copy-Item + # need absolute paths, and the 'result' symlink would resolve relative to /. + $vhdxLinux = Invoke-WslCapture -Distro $Distro -RepoPath $RepoPath ` + -Script "out=`$(nix build --no-link --print-out-paths .#$package | tr -d '\r'); find -L `"`$out`" -type f -name '*.vhdx' -print -quit" + if ([string]::IsNullOrWhiteSpace($vhdxLinux)) { + throw "Build completed, but no .vhdx file was found in the output of .#$package." } - return $config + $homeWindows = '' + if ($homePackage) { + Write-Host "=== Building .#$homePackage in WSL distro '$Distro' ===" + Invoke-WslInteractive -Distro $Distro -RepoPath $RepoPath -Script "nix build -L .#$homePackage" + $homeLinux = Invoke-WslCapture -Distro $Distro -RepoPath $RepoPath ` + -Script "nix build --no-link --print-out-paths .#$homePackage | tr -d '\r'" + $homeWindows = Convert-LinuxPathToWindows -Distro $Distro -LinuxPath $homeLinux + } + + $version = Invoke-WslCapture -Distro $Distro -RepoPath $RepoPath ` + -Script "git describe --always --dirty 2>/dev/null || echo wsl-build" + + $systemWindows = Convert-LinuxPathToWindows -Distro $Distro -LinuxPath $vhdxLinux + Write-Host "System image: $systemWindows" + if ($homeWindows) { Write-Host "Home seed: $homeWindows" } + + return @{ System = $systemWindows; Home = $homeWindows; Version = "wsl:$version" } } -function Assert-HyperVAvailable { - if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { - throw "Hyper-V PowerShell cmdlets are not available. Enable Hyper-V and run this from Windows PowerShell." +# -------------------------------------------------------------------------- +# GitHub release path (default) +# -------------------------------------------------------------------------- + +function Resolve-ReleaseTag { + param( + [string]$Repo, + [string]$Release + ) + + if ($Release -ne 'latest') { return $Release } + + # /releases/latest answers with a redirect to /releases/tag/. Read the + # Location header instead of following it: no API, no token, no JSON. + $url = "https://github.com/$Repo/releases/latest" + $request = [System.Net.HttpWebRequest]::Create($url) + $request.AllowAutoRedirect = $false + $request.Method = 'HEAD' + $request.UserAgent = 'qubixctl' + + $response = $null + try { + $response = $request.GetResponse() + } catch [System.Net.WebException] { + if ($null -eq $_.Exception.Response) { throw } + $response = $_.Exception.Response } -} -function Assert-Administrator { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw "Run this command from an elevated PowerShell session." + try { + $location = [string]$response.Headers['Location'] + } finally { + $response.Close() + } + + if ($location -notmatch '/releases/tag/([^/?#]+)') { + throw "Could not resolve the latest release of $Repo (no redirect to /releases/tag/...). Publish a release, pass -Release , or use -ImageSource wsl." } + return [uri]::UnescapeDataString($matches[1]) } -function Ensure-QubixNatSwitch { +function Invoke-Download { param( - [string]$SwitchName, - [string]$GatewayIp, - [string]$Subnet + [string]$Url, + [string]$OutFile ) - # Internal switch — host <-> VM only, no external uplink. - if (-not (Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue)) { - Write-Host "Creating Internal Switch '$SwitchName'..." - New-VMSwitch -SwitchName $SwitchName -SwitchType Internal | Out-Null - } + $partial = "$OutFile.part" + if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force } - # Assign the gateway IP to the host-side vEthernet adapter. - $adapterAlias = "vEthernet ($SwitchName)" - $prefix = [int]($Subnet.Split('/')[1]) - if (-not (Get-NetIPAddress -InterfaceAlias $adapterAlias -IPAddress $GatewayIp -ErrorAction SilentlyContinue)) { - Write-Host "Assigning $GatewayIp/$prefix to '$adapterAlias'..." - New-NetIPAddress -IPAddress $GatewayIp -PrefixLength $prefix -InterfaceAlias $adapterAlias | Out-Null + # curl.exe ships with Windows 10 1803+ and streams large files far better + # than Invoke-WebRequest in Windows PowerShell 5.1. + $curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($curl) { + & $curl.Source --fail --location --retry 3 --retry-delay 2 --progress-bar --output $partial $Url + if ($LASTEXITCODE -ne 0) { + throw "curl.exe failed with exit code $LASTEXITCODE while downloading $Url" + } + } else { + Invoke-WebRequest -Uri $Url -OutFile $partial -UseBasicParsing } - # Create the NAT rule that gives the VM outbound internet access. - $natName = "$SwitchName-nat" - if (-not (Get-NetNat -Name $natName -ErrorAction SilentlyContinue)) { - Write-Host "Creating NAT '$natName' for $Subnet..." - New-NetNat -Name $natName -InternalIPInterfaceAddressPrefix $Subnet | Out-Null + Move-Item -LiteralPath $partial -Destination $OutFile -Force +} + +function Read-Sha256SumFile { + param([string]$Path) + + $sums = @{} + foreach ($line in Get-Content -LiteralPath $Path) { + if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$') { + $sums[$matches[2]] = $matches[1].ToLowerInvariant() + } } + return $sums } -function Get-EffectiveValue { +function Assert-FileHash { param( - [string]$Override, - [object]$Default + [string]$Path, + [string]$Expected ) - if ([string]::IsNullOrWhiteSpace($Override)) { - return [string]$Default + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $Expected.ToLowerInvariant()) { + throw "SHA256 mismatch for $Path`n expected $Expected`n actual $actual" } +} - return $Override +function Expand-GzipFile { + param( + [string]$Path, + [string]$Destination + ) + + $partial = "$Destination.part" + $source = [System.IO.File]::OpenRead($Path) + try { + $gzip = New-Object System.IO.Compression.GZipStream($source, [System.IO.Compression.CompressionMode]::Decompress) + try { + $output = [System.IO.File]::Create($partial) + try { + $gzip.CopyTo($output, 4MB) + } finally { + $output.Dispose() + } + } finally { + $gzip.Dispose() + } + } finally { + $source.Dispose() + } + Move-Item -LiteralPath $partial -Destination $Destination -Force } -function Convert-LinuxPathToWindows { +function Get-ReleaseAsset { + # Returns the path of the unpacked .vhdx for one release asset, downloading, + # verifying and unpacking it only when the cache does not have it yet. param( - [string]$Distro, - [string]$LinuxPath + [string]$BaseUrl, + [string]$Dir, + [string]$Asset, + [hashtable]$Sums ) - $output = & wsl.exe -d $Distro --cd / -- wslpath -w $LinuxPath - if ($LASTEXITCODE -ne 0) { - throw "Failed to convert WSL path to Windows path: $LinuxPath" + $vhdx = Join-QubixPath $Dir ($Asset -replace '\.gz$', '') + if (Test-Path -LiteralPath $vhdx) { return $vhdx } + + $gz = Join-QubixPath $Dir $Asset + if (-not (Test-Path -LiteralPath $gz)) { + Write-Host "Downloading $BaseUrl/$Asset" + Invoke-Download -Url "$BaseUrl/$Asset" -OutFile $gz } - return ($output -join "`n").Trim() + if (-not $Sums.ContainsKey($Asset)) { + throw "SHA256SUMS has no entry for $Asset" + } + Assert-FileHash -Path $gz -Expected $Sums[$Asset] + + if ($Asset -like '*.gz') { + Write-Host "Unpacking $Asset" + Expand-GzipFile -Path $gz -Destination $vhdx + Remove-Item -LiteralPath $gz -Force + } + return $vhdx } -function Build-QubixMachine { +function Get-ReleaseImageSet { param( [object]$Config, - [string]$Distro, - [string]$RepoPath + [object]$Paths, + [string]$Release ) - Write-Host "=== Building $($Config.package) in WSL distro '$Distro' ===" - Invoke-WslInteractive -Distro $Distro -RepoPath $RepoPath -Script "nix build .#$($Config.package)" + $release = Get-Prop $Config 'release' + if ($null -eq $release) { + throw "The manifest has no 'release' section for this machine. Use -ImageSource wsl or -ImagePath." + } + + $repo = [string](Get-Prop $release 'repo') + $tag = Resolve-ReleaseTag -Repo $repo -Release $Release + $dir = Join-QubixPath $Paths.ImageCache $tag + $baseUrl = "https://github.com/$repo/releases/download/$tag" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Write-Host "Release $tag of $repo -> $dir" + + $sumsAsset = [string](Get-Prop $release 'sumsAsset' 'SHA256SUMS') + $sumsFile = Join-QubixPath $dir $sumsAsset + if (-not (Test-Path -LiteralPath $sumsFile)) { + Invoke-Download -Url "$baseUrl/$sumsAsset" -OutFile $sumsFile + } + $sums = Read-Sha256SumFile -Path $sumsFile - # realpath resolves the 'result' nix symlink to an absolute store path - # (/nix/store/...). wslpath -w and Copy-Item require an absolute path; - # a relative path gets resolved from / and the copy fails. - $vhdxLinuxPath = Invoke-WslCapture ` - -Distro $Distro ` - -RepoPath $RepoPath ` - -Script "find -L result -type f -name '*.vhdx' -print -quit | xargs -r realpath" + $system = Get-ReleaseAsset -BaseUrl $baseUrl -Dir $dir -Asset ([string](Get-Prop $release 'systemAsset')) -Sums $sums - if ([string]::IsNullOrWhiteSpace($vhdxLinuxPath)) { - throw "Build completed, but no .vhdx file was found under ./result." + $homeImage = '' + $homeEnabled = [bool](Get-Prop (Get-Prop $Config 'homeDisk') 'enable' $false) + if ($homeEnabled) { + $homeImage = Get-ReleaseAsset -BaseUrl $baseUrl -Dir $dir -Asset ([string](Get-Prop $release 'homeAsset')) -Sums $sums } - $vhdxWindowsPath = Convert-LinuxPathToWindows -Distro $Distro -LinuxPath $vhdxLinuxPath - Write-Host "VHDX: $vhdxWindowsPath" - return $vhdxWindowsPath + return @{ System = $system; Home = $homeImage; Version = $tag } } -function Recreate-QubixVm { +function Get-LocalImageSet { param( [object]$Config, - [string]$Distro, - [string]$RepoPath, - [string]$VmRootOverride, - [string]$SwitchOverride + [object]$Paths, + [string]$ImagePath, + [string]$HomeImagePath ) - Assert-HyperVAvailable - Assert-Administrator + if ([string]::IsNullOrWhiteSpace($ImagePath)) { + throw "-ImageSource file needs -ImagePath ." + } + if (-not (Test-Path -LiteralPath $ImagePath)) { + throw "Image not found: $ImagePath" + } + + $homeEnabled = [bool](Get-Prop (Get-Prop $Config 'homeDisk') 'enable' $false) + if ($homeEnabled -and -not (Test-Path -LiteralPath $Paths.HomeVhdx)) { + if ([string]::IsNullOrWhiteSpace($HomeImagePath)) { + throw "This VM has no home disk yet and needs a seed: pass -HomeImagePath (nix build .#$(Get-Prop $Config 'homePackage'))." + } + if (-not (Test-Path -LiteralPath $HomeImagePath)) { + throw "Home seed not found: $HomeImagePath" + } + } - $sourceVhdx = Build-QubixMachine -Config $Config -Distro $Distro -RepoPath $RepoPath - $vmName = [string]$Config.vmName - $effectiveVmRoot = Get-EffectiveValue -Override $VmRootOverride -Default $Config.vmRoot + $stage = Join-QubixPath $Paths.ImageCache 'local' + New-Item -ItemType Directory -Force -Path $stage | Out-Null - if (-not [string]::IsNullOrEmpty($Config.staticIp)) { - # Static IP mode: create (or reuse) a dedicated NAT switch so the VM - # gets a stable address. The switch name is derived from the hostname. - $defaultNatSwitch = "$($Config.hostName)-nat" - $effectiveSwitchName = Get-EffectiveValue -Override $SwitchOverride -Default $defaultNatSwitch - Ensure-QubixNatSwitch ` - -SwitchName $effectiveSwitchName ` - -GatewayIp ([string]$Config.gatewayIp) ` - -Subnet ([string]$Config.natSwitchSubnet) - } else { - # DHCP mode: use Default Switch (or whatever switchName says). - $effectiveSwitchName = Get-EffectiveValue -Override $SwitchOverride -Default $Config.switchName + $system = $ImagePath + if ($ImagePath -like '*.gz') { + $system = Join-QubixPath $stage ((Split-Path -Leaf $ImagePath) -replace '\.gz$', '') + Write-Host "Unpacking $ImagePath" + Expand-GzipFile -Path $ImagePath -Destination $system } - $vmPath = Join-Path $effectiveVmRoot $vmName - $vhdPath = Join-Path $vmPath "$vmName.vhdx" - Write-Host "=== Recreating Hyper-V VM '$vmName' ===" + $homeImage = $HomeImagePath + if ($HomeImagePath -and $HomeImagePath -like '*.gz') { + $homeImage = Join-QubixPath $stage ((Split-Path -Leaf $HomeImagePath) -replace '\.gz$', '') + Write-Host "Unpacking $HomeImagePath" + Expand-GzipFile -Path $HomeImagePath -Destination $homeImage + } + + return @{ System = $system; Home = $homeImage; Version = "file:$(Split-Path -Leaf $ImagePath)" } +} - $existingVm = Get-VM -Name $vmName -ErrorAction SilentlyContinue - if ($existingVm) { - if ($existingVm.State -ne 'Off') { - Write-Host "Stopping existing VM..." - Stop-VM -Name $vmName -TurnOff -Force +function Resolve-QubixImageSet { + param( + [object]$Config, + [object]$Paths, + [hashtable]$Ctx + ) + + $source = $Ctx.ImageSource + if ($source -eq 'auto') { + if ([string]::IsNullOrWhiteSpace($Ctx.ImagePath)) { $source = 'release' } else { $source = 'file' } + } + + switch ($source) { + 'release' { return Get-ReleaseImageSet -Config $Config -Paths $Paths -Release $Ctx.Release } + 'file' { return Get-LocalImageSet -Config $Config -Paths $Paths -ImagePath $Ctx.ImagePath -HomeImagePath $Ctx.HomeImagePath } + 'wsl' { + Assert-WslAvailable + return Build-QubixImagesInWsl -Config $Config -Distro $Ctx.WslDistro -RepoPath $Ctx.RepoLinuxPath } - Write-Host "Removing existing VM configuration..." - Remove-VM -Name $vmName -Force + } + throw "Unknown image source '$source'." +} + +# -------------------------------------------------------------------------- +# Hyper-V networking +# -------------------------------------------------------------------------- + +function Initialize-QubixNatSwitch { + param( + [string]$SwitchName, + [string]$GatewayIp, + [string]$Subnet + ) + + # Internal switch - host <-> VM only, no external uplink. + if (-not (Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue)) { + Write-Host "Creating Internal Switch '$SwitchName'..." + New-VMSwitch -SwitchName $SwitchName -SwitchType Internal | Out-Null + } + + # Assign the gateway IP to the host-side vEthernet adapter. + $adapterAlias = "vEthernet ($SwitchName)" + $prefix = [int]($Subnet.Split('/')[1]) + if (-not (Get-NetIPAddress -InterfaceAlias $adapterAlias -IPAddress $GatewayIp -ErrorAction SilentlyContinue)) { + Write-Host "Assigning $GatewayIp/$prefix to '$adapterAlias'..." + New-NetIPAddress -IPAddress $GatewayIp -PrefixLength $prefix -InterfaceAlias $adapterAlias | Out-Null + } + + # Create the NAT rule that gives the VM outbound internet access. + $natName = "$SwitchName-nat" + if (-not (Get-NetNat -Name $natName -ErrorAction SilentlyContinue)) { + Write-Host "Creating NAT '$natName' for $Subnet..." + New-NetNat -Name $natName -InternalIPInterfaceAddressPrefix $Subnet | Out-Null + } +} + +function Resolve-QubixSwitch { + param( + [object]$Config, + [string]$SwitchOverride + ) + + $staticIp = [string](Get-Prop $Config 'staticIp' '') + if ($staticIp) { + # Static IP mode: create (or reuse) a dedicated NAT switch so the VM + # gets a stable address. The switch name is derived from the hostname. + $name = Get-EffectiveValue -Override $SwitchOverride -Default "$(Get-Prop $Config 'hostName')-nat" + Initialize-QubixNatSwitch -SwitchName $name ` + -GatewayIp ([string](Get-Prop $Config 'gatewayIp')) ` + -Subnet ([string](Get-Prop $Config 'natSwitchSubnet')) + return $name } - if (Test-Path $vmPath) { - Write-Host "Removing old VM directory: $vmPath" - Remove-Item -Path $vmPath -Recurse -Force + # DHCP mode: use Default Switch (or whatever switchName says). + $name = Get-EffectiveValue -Override $SwitchOverride -Default (Get-Prop $Config 'switchName' 'Default Switch') + if (-not (Get-VMSwitch -Name $name -ErrorAction SilentlyContinue)) { + throw "Hyper-V switch '$name' does not exist. Pass -SwitchName or create it in Hyper-V Manager." } + return $name +} - New-Item -ItemType Directory -Force -Path $vmPath | Out-Null +# -------------------------------------------------------------------------- +# Hyper-V VM lifecycle +# -------------------------------------------------------------------------- - Write-Host "Copying VHDX to $vhdPath" - Copy-Item -Path $sourceVhdx -Destination $vhdPath -Force +function Initialize-QubixVm { + param( + [object]$Config, + [object]$Paths, + [string]$SystemImage, + [string]$HomeImage, + [string]$Version, + [string]$SwitchOverride + ) - Write-Host "Creating Generation 2 VM on switch '$effectiveSwitchName'" + $vmName = $Paths.VmName + $switch = Resolve-QubixSwitch -Config $Config -SwitchOverride $SwitchOverride + New-Item -ItemType Directory -Force -Path $Paths.VmDir | Out-Null + + Write-Host "Copying system image -> $($Paths.SystemVhdx)" + Copy-Item -LiteralPath $SystemImage -Destination $Paths.SystemVhdx -Force + + $homeEnabled = [bool](Get-Prop (Get-Prop $Config 'homeDisk') 'enable' $false) + if ($homeEnabled) { + if (Test-Path -LiteralPath $Paths.HomeVhdx) { + Write-Host "Keeping existing home disk $($Paths.HomeVhdx)" + } else { + if ([string]::IsNullOrWhiteSpace($HomeImage)) { + throw "No home seed image available and $($Paths.HomeVhdx) does not exist." + } + Write-Host "Seeding home disk -> $($Paths.HomeVhdx)" + Copy-Item -LiteralPath $HomeImage -Destination $Paths.HomeVhdx -Force + } + } + + Write-Host "Creating Generation 2 VM '$vmName' on switch '$switch'" New-VM ` -Name $vmName ` -Generation 2 ` - -MemoryStartupBytes ([UInt64]$Config.memoryStartupBytes) ` - -VHDPath $vhdPath ` - -SwitchName $effectiveSwitchName ` - -Path $vmPath | Out-Null + -MemoryStartupBytes ([UInt64](Get-Prop $Config 'memoryStartupBytes')) ` + -VHDPath $Paths.SystemVhdx ` + -SwitchName $switch ` + -Path $Paths.VmRoot | Out-Null - Set-VMProcessor -VMName $vmName -Count ([int]$Config.cpuCount) + Set-VMProcessor -VMName $vmName -Count ([int](Get-Prop $Config 'cpuCount')) Set-VMMemory ` -VMName $vmName ` -DynamicMemoryEnabled $true ` -MinimumBytes 1GB ` - -StartupBytes ([UInt64]$Config.memoryStartupBytes) ` - -MaximumBytes ([UInt64]$Config.maxMemoryBytes) + -StartupBytes ([UInt64](Get-Prop $Config 'memoryStartupBytes')) ` + -MaximumBytes ([UInt64](Get-Prop $Config 'maxMemoryBytes')) # NixOS images generated for Hyper-V boot cleanly with Secure Boot disabled. # Keeping this explicit avoids firmware surprises across Windows installs. Set-VMFirmware -VMName $vmName -EnableSecureBoot Off - Start-VM -Name $vmName - Write-Host "=== VM '$vmName' is running ===" + if ($homeEnabled) { + Add-VMHardDiskDrive -VMName $vmName -ControllerType SCSI -ControllerNumber 0 -ControllerLocation 1 -Path $Paths.HomeVhdx + } + $bootDisk = Get-VMHardDiskDrive -VMName $vmName -ControllerType SCSI -ControllerNumber 0 -ControllerLocation 0 + Set-VMFirmware -VMName $vmName -FirstBootDevice $bootDisk + + # Checkpoints would fork the home disk into .avhdx chains that `recreate` + # cannot reason about. Persistence here is the home VHDX, nothing else. + Set-VM -Name $vmName -CheckpointType Disabled + + Set-Content -LiteralPath $Paths.VersionFile -Value $Version +} + +function Get-QubixVm { + param([object]$Paths) + return Get-VM -Name $Paths.VmName -ErrorAction SilentlyContinue } -function Start-QubixVm { - param([object]$Config) +function Assert-QubixDisksPresent { + param([object]$Paths) - Assert-HyperVAvailable - Start-VM -Name ([string]$Config.vmName) + foreach ($disk in @(Get-VMHardDiskDrive -VMName $Paths.VmName)) { + if (-not (Test-Path -LiteralPath $disk.Path)) { + throw ("VM '$($Paths.VmName)' references a missing disk: $($disk.Path).`n" + + "If this is the home disk, restore it from backup; otherwise run 'destroy' and 'up' again.") + } + } } -function Stop-QubixVm { - param([object]$Config) +function Resume-QubixVm { + param([object]$Vm) - Assert-HyperVAvailable - Stop-VM -Name ([string]$Config.vmName) -Force + switch ([string]$Vm.State) { + 'Running' { Write-Host "VM '$($Vm.Name)' is already running." } + 'Paused' { Write-Host "Resuming VM '$($Vm.Name)'..."; Resume-VM -Name $Vm.Name } + default { Write-Host "Starting VM '$($Vm.Name)' (state: $($Vm.State))..."; Start-VM -Name $Vm.Name } + } } -function Destroy-QubixVm { +function Unregister-QubixVm { + param( + [object]$Paths, + [bool]$PurgeHome + ) + + $vm = Get-QubixVm -Paths $Paths + if ($vm) { + if ($vm.State -ne 'Off') { + Write-Host "Turning off VM '$($Paths.VmName)'..." + Stop-VM -Name $Paths.VmName -TurnOff -Force + } + Write-Host "Removing VM configuration '$($Paths.VmName)'..." + Remove-VM -Name $Paths.VmName -Force + } + + if ($PurgeHome) { + if (Test-Path -LiteralPath $Paths.VmDir) { + Write-Host "Purging $($Paths.VmDir) (including the home disk)" + Remove-Item -LiteralPath $Paths.VmDir -Recurse -Force + } + return + } + + foreach ($item in @($Paths.SystemVhdx, $Paths.VersionFile)) { + if (Test-Path -LiteralPath $item) { + Write-Host "Removing $item" + Remove-Item -LiteralPath $item -Force + } + } + foreach ($sub in @('Virtual Machines', 'Snapshots', 'Virtual Hard Disks')) { + $dir = Join-QubixPath $Paths.VmDir $sub + if (Test-Path -LiteralPath $dir) { + Remove-Item -LiteralPath $dir -Recurse -Force + } + } + if (Test-Path -LiteralPath $Paths.HomeVhdx) { + Write-Host "Home disk kept: $($Paths.HomeVhdx) (use -Purge to delete it)" + } +} + +# -------------------------------------------------------------------------- +# RDP +# -------------------------------------------------------------------------- + +function Get-QubixAddress { param( [object]$Config, - [string]$VmRootOverride + [string]$Explicit ) - Assert-HyperVAvailable - Assert-Administrator + if (-not [string]::IsNullOrWhiteSpace($Explicit)) { return $Explicit } - $vmName = [string]$Config.vmName - $effectiveVmRoot = Get-EffectiveValue -Override $VmRootOverride -Default $Config.vmRoot - $vmPath = Join-Path $effectiveVmRoot $vmName + $staticIp = [string](Get-Prop $Config 'staticIp' '') + if ($staticIp) { return $staticIp } - $existingVm = Get-VM -Name $vmName -ErrorAction SilentlyContinue - if ($existingVm) { - Stop-VM -Name $vmName -TurnOff -Force -ErrorAction SilentlyContinue - Remove-VM -Name $vmName -Force + # DHCP mode: the guest's hv_kvp_daemon reports its addresses to Hyper-V. + try { + foreach ($adapter in @(Get-VMNetworkAdapter -VMName ([string](Get-Prop $Config 'vmName')) -ErrorAction Stop)) { + foreach ($ip in @($adapter.IPAddresses)) { + if ($ip -match '^\d{1,3}(\.\d{1,3}){3}$' -and $ip -notlike '169.254.*') { return $ip } + } + } + } catch { + Write-Verbose "KVP address lookup failed: $($_.Exception.Message)" } - if (Test-Path $vmPath) { - Remove-Item -Path $vmPath -Recurse -Force + # Last resort: Avahi in the guest, mDNS on the host. + return "$(Get-Prop $Config 'hostName').local" +} + +function Test-TcpPort { + param( + [string]$TargetHost, + [int]$Port, + [int]$TimeoutMs = 2000 + ) + + $client = New-Object System.Net.Sockets.TcpClient + try { + $async = $client.BeginConnect($TargetHost, $Port, $null, $null) + if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return $false } + $client.EndConnect($async) + return $client.Connected + } catch { + return $false + } finally { + $client.Close() } } -function Show-QubixStatus { - param([object]$Config) +function Wait-QubixRdp { + param( + [object]$Config, + [string]$Explicit, + [int]$TimeoutSeconds + ) - Assert-HyperVAvailable + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $address = Get-QubixAddress -Config $Config -Explicit $Explicit + Write-Host "Waiting for RDP on $address (up to $TimeoutSeconds s)..." - $vmName = [string]$Config.vmName - $vm = Get-VM -Name $vmName -ErrorAction SilentlyContinue - if (-not $vm) { - Write-Host "VM '$vmName' does not exist." - return + while ((Get-Date) -lt $deadline) { + $address = Get-QubixAddress -Config $Config -Explicit $Explicit + if (Test-TcpPort -TargetHost $address -Port 3389) { + Write-Host "RDP is up at $address" + return $address + } + Start-Sleep -Seconds 3 } - $vm | Format-Table -AutoSize Name, State, CPUUsage, MemoryAssigned, Uptime, Status - Get-VMNetworkAdapter -VMName $vmName | Format-Table -AutoSize Name, SwitchName, Status, IPAddresses + throw "Timed out waiting for RDP on '$address'. Check 'qubixctl -Command status' or open the VM in Hyper-V Manager." +} + +function Format-RdpFile { + param( + [string]$Address, + [string]$User, + [int]$Width, + [int]$Height + ) + + # Windowed session, audio played on the host, clipboard shared, nothing + # else redirected. authentication level 0 accepts xrdp's self-signed cert + # without a prompt; the lab user is not a secret anyway. + $lines = @( + "full address:s:$Address", + "username:s:$User", + "screen mode id:i:1", + "desktopwidth:i:$Width", + "desktopheight:i:$Height", + "smart sizing:i:1", + "dynamic resolution:i:1", + "audiomode:i:0", + "audiocapturemode:i:0", + "redirectclipboard:i:1", + "redirectdrives:i:0", + "redirectprinters:i:0", + "redirectcomports:i:0", + "redirectsmartcards:i:0", + "authentication level:i:0", + "prompt for credentials:i:0", + "negotiate security layer:i:1", + "autoreconnection enabled:i:1", + "compression:i:1", + "bitmapcachepersistenable:i:1" + ) + return (($lines -join "`r`n") + "`r`n") } -function Open-QubixMstsc { +function Connect-QubixRdp { param( [object]$Config, - [string]$ExplicitAddress + [object]$Paths, + [string]$Address, + [bool]$SaveCredential ) - if ([string]::IsNullOrWhiteSpace($ExplicitAddress)) { - $target = "$($Config.hostName).local" - } else { - $target = $ExplicitAddress + $rdp = Get-Prop $Config 'rdp' + $user = [string](Get-Prop $rdp 'user' 'rdp') + $content = Format-RdpFile -Address $Address -User $user ` + -Width ([int](Get-Prop $rdp 'width' 1280)) -Height ([int](Get-Prop $rdp 'height' 800)) + + New-Item -ItemType Directory -Force -Path $Paths.VmDir | Out-Null + [System.IO.File]::WriteAllText($Paths.RdpFile, $content) + + if ($SaveCredential) { + $password = [string](Get-Prop $rdp 'password' '') + if ($password) { + # Windows Credential Manager entry mstsc looks up for this host. + & cmdkey.exe /generic:"TERMSRV/$Address" /user:$user /pass:$password | Out-Null + } } - Write-Host "Opening mstsc for $target" - Start-Process "mstsc.exe" -ArgumentList "/v:$target" + Write-Host "Opening $($Paths.RdpFile)" + Start-Process -FilePath 'mstsc.exe' -ArgumentList "`"$($Paths.RdpFile)`"" } -if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { - throw "wsl.exe was not found. Qubix MVP expects Windows PowerShell with WSL available." -} +# -------------------------------------------------------------------------- +# Commands +# -------------------------------------------------------------------------- + +function Invoke-QubixUp { + param( + [object]$Config, + [object]$Paths, + [hashtable]$Ctx + ) -$bootstrapDistro = $WslDistro -if ([string]::IsNullOrWhiteSpace($bootstrapDistro)) { - $bootstrapDistro = "NixOS" + Assert-HyperVAvailable + Assert-Administrator + + $vm = Get-QubixVm -Paths $Paths + if (-not $vm) { + Write-Host "=== VM '$($Paths.VmName)' does not exist yet: creating it ===" + $images = Resolve-QubixImageSet -Config $Config -Paths $Paths -Ctx $Ctx + Initialize-QubixVm -Config $Config -Paths $Paths ` + -SystemImage $images.System -HomeImage $images.Home -Version $images.Version ` + -SwitchOverride $Ctx.SwitchName + $vm = Get-QubixVm -Paths $Paths + } else { + Assert-QubixDisksPresent -Paths $Paths + $imageOptionsGiven = ($Ctx.ImageSource -ne 'auto') -or ($Ctx.Release -ne 'latest') -or + -not [string]::IsNullOrWhiteSpace($Ctx.ImagePath) + if ($imageOptionsGiven) { + Write-Host "VM '$($Paths.VmName)' already exists; image options only apply to 'recreate'." + } + } + + Resume-QubixVm -Vm $vm + + if ($Ctx.NoConnect) { return } + $address = Wait-QubixRdp -Config $Config -Explicit $Ctx.Address -TimeoutSeconds $Ctx.TimeoutSeconds + Connect-QubixRdp -Config $Config -Paths $Paths -Address $address -SaveCredential $Ctx.SaveCredential } -$manifest = Get-QubixManifest -Distro $bootstrapDistro -RepoPath $RepoLinuxPath -$config = Resolve-MachineConfig -Manifest $manifest -Name $Machine +function Invoke-QubixConnect { + param( + [object]$Config, + [object]$Paths, + [hashtable]$Ctx + ) -$effectiveDistro = Get-EffectiveValue -Override $WslDistro -Default $config.wslDistro -if ($effectiveDistro -ne $bootstrapDistro) { - $manifest = Get-QubixManifest -Distro $effectiveDistro -RepoPath $RepoLinuxPath - $config = Resolve-MachineConfig -Manifest $manifest -Name $Machine + $address = Wait-QubixRdp -Config $Config -Explicit $Ctx.Address -TimeoutSeconds $Ctx.TimeoutSeconds + Connect-QubixRdp -Config $Config -Paths $Paths -Address $address -SaveCredential $Ctx.SaveCredential } -switch ($Command) { - "build" { - Build-QubixMachine -Config $config -Distro $effectiveDistro -RepoPath $RepoLinuxPath | Out-Null - } - "recreate" { - Recreate-QubixVm ` - -Config $config ` - -Distro $effectiveDistro ` - -RepoPath $RepoLinuxPath ` - -VmRootOverride $VmRoot ` - -SwitchOverride $SwitchName +function Invoke-QubixStatus { + param( + [object]$Config, + [object]$Paths + ) + + Assert-HyperVAvailable + + $vm = Get-QubixVm -Paths $Paths + if (-not $vm) { + Write-Host "VM '$($Paths.VmName)' does not exist. Run 'up' to create it." + } else { + $vm | Format-Table -AutoSize Name, State, CPUUsage, MemoryAssigned, Uptime, Status + Get-VMNetworkAdapter -VMName $Paths.VmName | Format-Table -AutoSize Name, SwitchName, Status, IPAddresses + Get-VMHardDiskDrive -VMName $Paths.VmName | Format-Table -AutoSize ControllerLocation, Path } - "start" { - Start-QubixVm -Config $config + + if (Test-Path -LiteralPath $Paths.VersionFile) { + Write-Host "Installed image: $((Get-Content -LiteralPath $Paths.VersionFile -Raw).Trim())" } - "stop" { - Stop-QubixVm -Config $config + Write-Host "Home disk: $($Paths.HomeVhdx) $(if (Test-Path -LiteralPath $Paths.HomeVhdx) { '(present)' } else { '(absent)' })" + Write-Host "Address: $(Get-QubixAddress -Config $Config -Explicit '')" + + if (Test-Path -LiteralPath $Paths.ImageCache) { + $cached = @(Get-ChildItem -LiteralPath $Paths.ImageCache -Directory | ForEach-Object { $_.Name }) + if ($cached.Count -gt 0) { Write-Host "Cached images: $($cached -join ', ') ($($Paths.ImageCache))" } } - "destroy" { - Destroy-QubixVm -Config $config -VmRootOverride $VmRoot +} + +function Invoke-QubixRecreate { + param( + [object]$Config, + [object]$Paths, + [hashtable]$Ctx + ) + + Assert-HyperVAvailable + Assert-Administrator + + Write-Host "=== Recreating '$($Paths.VmName)' with fresh images (home disk is kept) ===" + Unregister-QubixVm -Paths $Paths -PurgeHome $false + Invoke-QubixUp -Config $Config -Paths $Paths -Ctx $Ctx +} + +function Invoke-QubixDestroy { + param( + [object]$Paths, + [bool]$PurgeHome + ) + + Assert-HyperVAvailable + Assert-Administrator + Unregister-QubixVm -Paths $Paths -PurgeHome $PurgeHome +} + +function Invoke-QubixMain { + $ctx = @{ + ImageSource = $ImageSource + ImagePath = $ImagePath + HomeImagePath = $HomeImagePath + Release = $Release + WslDistro = '' + RepoLinuxPath = '' + SwitchName = $SwitchName + Address = $Address + TimeoutSeconds = $TimeoutSeconds + NoConnect = [bool]$NoConnect + SaveCredential = -not [bool]$NoSavedCredential } - "status" { - Show-QubixStatus -Config $config + + $needsWsl = ($ImageSource -eq 'wsl') -or ($Command -eq 'build') + if ($needsWsl) { + # Developer loop: the manifest comes straight from Nix so that edits to + # machines/*.nix are honoured without regenerating manifest.json first. + Assert-WslAvailable + $wsl = Resolve-WslRepoLocation -DistroOverride $WslDistro -LinuxPathOverride $RepoLinuxPath + $ctx.WslDistro = $wsl.Distro + $ctx.RepoLinuxPath = $wsl.RepoPath + $ctx.ImageSource = 'wsl' + $manifest = Get-QubixManifestFromWsl -Distro $wsl.Distro -RepoPath $wsl.RepoPath + } else { + $manifest = Read-QubixManifest -Path (Get-EffectiveValue -Override $ManifestPath -Default (Get-DefaultManifestPath)) } - "mstsc" { - Open-QubixMstsc -Config $config -ExplicitAddress $Address + + $config = Resolve-MachineConfig -Manifest $manifest -Name $Machine + $paths = Get-QubixLayout -Config $config -VmRootOverride $VmRoot + + switch ($Command) { + 'up' { Invoke-QubixUp -Config $config -Paths $paths -Ctx $ctx } + 'connect' { Invoke-QubixConnect -Config $config -Paths $paths -Ctx $ctx } + 'start' { + Assert-HyperVAvailable + Assert-Administrator + $vm = Get-QubixVm -Paths $paths + if (-not $vm) { throw "VM '$($paths.VmName)' does not exist. Run 'up' first." } + Resume-QubixVm -Vm $vm + } + 'stop' { + Assert-HyperVAvailable + Assert-Administrator + Stop-VM -Name $paths.VmName -Force + } + 'status' { Invoke-QubixStatus -Config $config -Paths $paths } + 'recreate' { Invoke-QubixRecreate -Config $config -Paths $paths -Ctx $ctx } + 'destroy' { Invoke-QubixDestroy -Paths $paths -PurgeHome ([bool]$Purge) } + 'fetch' { + $images = Get-ReleaseImageSet -Config $config -Paths $paths -Release $Release + Write-Host "System image: $($images.System)" + if ($images.Home) { Write-Host "Home seed: $($images.Home)" } + } + 'build' { + Build-QubixImagesInWsl -Config $config -Distro $ctx.WslDistro -RepoPath $ctx.RepoLinuxPath | Out-Null + } + 'manifest' { $config | ConvertTo-Json -Depth 8 } } } + +# Dot-source the script to load the functions without running anything +# (used by the tests); every other invocation runs the command. +if ($MyInvocation.InvocationName -ne '.') { + Invoke-QubixMain +} diff --git a/tools/update-manifest.sh b/tools/update-manifest.sh new file mode 100755 index 0000000..a3a3347 --- /dev/null +++ b/tools/update-manifest.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Regenerate manifest.json from Nix. Nix stays the source of truth; the +# committed file exists so that Windows hosts without WSL can read it. +# CI fails when the committed file drifts from the Nix output. +set -euo pipefail +cd "$(dirname "$0")/.." + +out=$(nix build --no-link --print-out-paths .#qubix-manifest-json) +cp --no-preserve=mode "$out" manifest.json +echo "manifest.json updated from $out"