summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorsommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:37 +0100
committersommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:37 +0100
commitf25d094d0652e8dedff206ca56671b3548f755ff (patch)
tree10bdacf9ba14c03e298a196d3d23c5a4ab8fef47
parent0b0ebfe0b6a77f07fe6c92f0c9e5312a330e8196 (diff)
downloaddotfiles-f25d094d0652e8dedff206ca56671b3548f755ff.tar.gz
dotfiles-f25d094d0652e8dedff206ca56671b3548f755ff.tar.bz2
dotfiles-f25d094d0652e8dedff206ca56671b3548f755ff.zip
Add a disposable Canonical desktop test VM
-rw-r--r--.chezmoiignore1
-rw-r--r--.gitignore1
-rw-r--r--docs/canonical-vm.md166
-rw-r--r--just-lib.sh6
-rw-r--r--justfile61
-rw-r--r--nix/flake.nix9
-rw-r--r--pyrightconfig.json1
-rw-r--r--scripts/canonical-vm-guest.sh101
-rw-r--r--scripts/canonical-vm-install.sh50
-rw-r--r--scripts/canonical-vm-keyring.sh28
-rw-r--r--scripts/canonical.py36
-rw-r--r--scripts/canonical_vm.py802
-rw-r--r--tests/test_canonical.py40
-rw-r--r--tests/test_canonical_vm.py230
14 files changed, 1521 insertions, 11 deletions
diff --git a/.chezmoiignore b/.chezmoiignore
index 4c4ad4f..28568bb 100644
--- a/.chezmoiignore
+++ b/.chezmoiignore
@@ -18,6 +18,7 @@ just-lib.sh
selene.toml
selene-globals.yml
scripts/
+tmp/
tests/
docs/
canonical/
diff --git a/.gitignore b/.gitignore
index ce223a1..6d4a914 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.direnv/
!home/.lldbinit
.worktrees/
+tmp/
.ruff_cache/
node_modules/
*.swp
diff --git a/docs/canonical-vm.md b/docs/canonical-vm.md
new file mode 100644
index 0000000..502eae7
--- /dev/null
+++ b/docs/canonical-vm.md
@@ -0,0 +1,166 @@
+# Canonical Desktop Test VM
+
+This lab uses Ubuntu 26.04.1 Desktop, GNOME/GDM, and the existing `canonical`
+role. It does not enroll in Landscape or authd. Do not put the corporate
+autoinstall file or company credentials in this lab.
+
+## Host Access
+
+Run outside aibox as your normal user with access to the local LXD socket.
+LXD access is equivalent to root access. Do not mount its socket into aibox.
+Temporary group access does not require permanent group membership:
+
+```sh
+sudo -u "$USER" -g lxd -- "$HOME/.nix-profile/bin/codex" resume \
+ --sandbox danger-full-access --ask-for-approval never
+```
+
+On Arch, LXD's VM support needs `cdrtools` and its QEMU/OVMF dependencies.
+Install missing host packages with pacman before starting. Image and screenshot
+tools are in the repo's Nix development shell:
+
+```sh
+nix develop ./nix
+just canonical-vm-run
+```
+
+If the main Ubuntu download server is slow, `canonical-vm-create` accepts an
+HTTPS mirror directory as its argument. It resumes a partial ISO download and
+still checks the ISO against the signed manifest from `releases.ubuntu.com`.
+
+The lab uses `canonical-lab`, 4 CPUs, 8 GiB RAM, an 80 GiB disk, and the existing
+`default` storage pool and `lxdbr0` bridge. It does not change shared profiles,
+firewall rules, or other instances. Guests can reach the LAN and VPN through
+the existing bridge. No host home directory or credential agent is shared.
+Allow several hours for a cold run. The desktop runtimes are large, and the
+shared Nix profile can build packages from source.
+
+## Test Credentials
+
+The workflow creates test SSH and disk/login credentials under
+`tmp/canonical-vm/secrets/`. The lab directory is private and ignored by both
+Git and chezmoi. It also holds the verified ISO, private installer, and reports.
+Keep it for retries. Do not publish it or add it to Git.
+
+The VM has encrypted LVM, but its unlock key is in the unencrypted initramfs.
+It has passwordless sudo and GDM automatic login. These settings are for this
+disposable VM only. They do not provide a secure laptop configuration.
+The lab also locks `clock-show-weekday` to false to test policy handling.
+The lab keyring service reads the generated login password from a private file
+at startup. Bootstrap saves the initial empty keyring before creating the test
+keyring. It does not change the corporate role's keyring or PAM configuration.
+
+SSH uses the generated key only, with agent forwarding disabled. Its host key
+is read through the trusted local LXD agent and then pinned. A different host
+key stops the workflow. The guest creates a dummy GPG signing key.
+Its private export stays in the local secrets directory for snapshot retries.
+
+## Operations
+
+`canonical-vm-run` downloads and verifies the Ubuntu ISO, adds the lab installer
+data, installs Ubuntu, takes the `clean` snapshot, installs upstream multi-user
+Nix, takes `nix-ready`, and runs `canonical-setup` inside the guest. It then
+reboots, runs the guest checks, stops the VM, and takes `working`.
+
+The source copy includes current tracked changes and untracked, nonignored
+files. It excludes host Git state, lab data, caches, and worktrees. The guest
+gets a separate Git repository. No setup recipe runs on the host.
+
+Individual steps are available when a test fails:
+
+```sh
+just canonical-vm-status
+just canonical-vm-console
+just canonical-vm-screenshot
+just canonical-vm-logs
+just canonical-vm-stop
+just canonical-vm-restore nix-ready
+just canonical-vm-start
+just canonical-vm-sync-source
+just canonical-vm-deploy
+just canonical-vm-test
+```
+
+Restore discards changes in the marked test VM. Snapshots require a stopped VM.
+Existing snapshots are not overwritten. Commands reject an instance whose
+ownership marker does not match the local lab state.
+`canonical-vm-sync-source` discards edits in the guest source checkout. It does
+not change the guest's keys or deployed home files. Run it before deployment
+when testing a source fix after restoring `nix-ready`.
+
+`canonical-vm-delete` removes only the marked VM and its snapshots. It leaves
+the private local lab files for inspection. Remove `tmp/canonical-vm/` yourself
+after you no longer need them. Never reuse its credentials on another system.
+Remove the cached `installer.iso` before creating another VM when you change
+the installer scripts. Keep the original Ubuntu ISO to avoid another download.
+
+## Test Boundary
+
+Guest checks use the real GNOME user session. `canonical-lab-check` requires
+Ubuntu, the `canonical` role, a VM, and a root-owned lab marker. It skips company
+registration only. The normal `canonical-check` still checks Landscape.
+
+The VM cannot verify company policy, Google/authd login, normal GDM password
+login, or laptop hardware. Camera, dock, suspend, and real-account notification
+tests still need the laptop. Autologin and the test keyring service do not test
+PAM integration. A successful command alone does not prove a visible GUI action.
+
+## Test Results (2026-09-14)
+
+The installed VM boots with encrypted LVM and automatic unlock. The full Nix
+profile, Snaps, and Flatpaks installed. All configured GNOME extensions were
+active after settings deployment. The policy-lock, GPG signing, rootless Podman,
+aibox, and secret-storage checks passed. The stored secret survived a reboot. A screenshot
+confirmed the desktop panel and test notification. The source checks and all
+unit tests passed.
+
+Keybase Snap 6.5.1 revision 70 corrupted the GNOME settings database when its
+GUI started. Its `setup-env.sh` sets `XDG_RUNTIME_DIR` to its `.config`
+directory, whose `dconf/user` links to the desktop database. A controlled
+launch changed the header from `GVariant` to `G\0ariant`. On later boots,
+dconf discarded the database and the desktop extensions disappeared.
+Keybase is removed from the VM and excluded from the corporate laptop profile.
+After removal, the deployment and final reboot checks passed. All configured
+extensions stayed active without reapplying settings, and the stored test
+secret survived. The final screenshot confirmed that the panel remained visible.
+
+These limits remain:
+
+- Mattermost remains a Snap. Revision 850 lacks the keyring plug. The local
+ Mattermost-only AppArmor rule permits Secret Service access, and the app
+ reports `encryption available`. AppArmor remains enforced. The path watcher
+ restored the rule after a simulated profile replacement. Both subsequent
+ reboot checks passed with encryption available. Real-account login was not
+ tested. See [Mattermost keyring](canonical-laptop.md#mattermost-keyring).
+- Flatpak reported end-of-life KDE runtimes for Nheko (6.8) and NormCap (6.9).
+ The installed stable releases still use these runtimes.
+
+The crates.io HTTP 403 failure is fixed in the Nix package definition. Tuicr's
+crate downloads use `static.crates.io` and retain the Cargo.lock checksums.
+All 377 crate downloads were forced in the Ubuntu VM, without reusing their
+cached outputs, and passed hash verification. The Nix profile build also passed.
+No manual crate prefetch is needed. Run `just nix-crate-check` to repeat a
+forced download of one locked crate. The full download test log is
+`tmp/canonical-vm/reports/tuicr-forced-downloads.log`.
+
+The full guest `just update` passed after GitHub's API rate limit cleared.
+This included the package updates, release check, Nix flake update and
+activation, Neovim update, and GNOME extension update. No new APT packages
+were installed. The download checks above cover the crate-cache failure; they
+do not repeat the OS installation or company provisioning.
+Use login Zsh for CLI tests, as Ghostty does; Bash does not load the Home
+Manager session variables used by the parser compiler.
+
+The stopped `working` snapshot restored the guest disk state: a test file added
+after the snapshot was absent after restore. An early reboot test caused a
+GNOME shutdown timeout and activated its extension failure protection. Desktop
+readiness now waits for GNOME's extension startup check to finish. It does not
+disable that protection. The final reboot check also verifies extensions
+without applying settings again. The `working` snapshot was replaced after the
+update and reboot tests. It excludes Keybase and includes the Mattermost
+keyring workaround and the crate download fix. The VM is stopped. Logs and screenshots are under
+`tmp/canonical-vm/reports/`.
+
+References: [Ubuntu images](https://releases.ubuntu.com/26.04/),
+[autoinstall](https://canonical-subiquity.readthedocs-hosted.com/en/latest/reference/autoinstall-reference.html),
+and [LXD VMs](https://canonical.com/lxd/docs/latest/howto/instances_create/).
diff --git a/just-lib.sh b/just-lib.sh
index 5caa79f..dadf427 100644
--- a/just-lib.sh
+++ b/just-lib.sh
@@ -15,18 +15,18 @@ _find_shells() {
\( -name '*.sh' \
-o -path './dot_local/bin/executable_*' \
-o -path './dot_config/sway/executable_*' \) \
- -not -path './.git/*' -not -path './.worktrees/*'
+ -not -path './.git/*' -not -path './.worktrees/*' -not -path './tmp/*'
}
_find_by_ext() {
find . -type f -name "*.$1" \
- -not -path './.git/*' -not -path './.worktrees/*'
+ -not -path './.git/*' -not -path './.worktrees/*' -not -path './tmp/*'
}
_find_zsh() {
find . -type f \
\( -name 'dot_zshrc' -o -name 'dot_zshenv' -o -name 'dot_zprofile' \) \
- -not -path './.git/*' -not -path './.worktrees/*'
+ -not -path './.git/*' -not -path './.worktrees/*' -not -path './tmp/*'
}
_is_zsh() {
diff --git a/justfile b/justfile
index 7267729..8e4c924 100644
--- a/justfile
+++ b/justfile
@@ -28,6 +28,65 @@ nix-switch:
canonical-check:
@python3 scripts/canonical.py check
+# Disposable Ubuntu desktop VM. Never deploys onto the host.
+canonical-vm-create mirror="https://releases.ubuntu.com/26.04/":
+ @python3 scripts/canonical_vm.py create --mirror {{ quote(mirror) }}
+
+canonical-vm-status:
+ @python3 scripts/canonical_vm.py status
+
+canonical-vm-console:
+ @python3 scripts/canonical_vm.py console
+
+canonical-vm-start:
+ @python3 scripts/canonical_vm.py start
+
+canonical-vm-stop:
+ @python3 scripts/canonical_vm.py stop
+
+canonical-vm-finish-install:
+ @python3 scripts/canonical_vm.py finish-install
+
+canonical-vm-snapshot name:
+ @python3 scripts/canonical_vm.py snapshot {{ quote(name) }}
+
+canonical-vm-restore name:
+ @python3 scripts/canonical_vm.py restore {{ quote(name) }}
+
+canonical-vm-delete:
+ @python3 scripts/canonical_vm.py delete
+
+canonical-lab-check:
+ @python3 scripts/canonical.py lab-check
+
+canonical-vm-bootstrap:
+ @python3 scripts/canonical_vm.py bootstrap
+
+canonical-vm-test:
+ @python3 scripts/canonical_vm.py test
+
+canonical-vm-logs:
+ @python3 scripts/canonical_vm.py logs
+
+canonical-vm-screenshot:
+ @python3 scripts/canonical_vm.py screenshot
+
+canonical-vm-deploy:
+ @python3 scripts/canonical_vm.py deploy
+
+# Replace the marked guest's source checkout with the current host source.
+canonical-vm-sync-source:
+ @python3 scripts/canonical_vm.py sync-source
+
+# Create a new lab, test deployment, and leave it stopped with a working snapshot.
+canonical-vm-run: canonical-vm-create _canonical-vm-wait canonical-vm-finish-install canonical-vm-start canonical-vm-bootstrap _canonical-vm-reboot canonical-vm-test canonical-vm-stop (canonical-vm-snapshot "working")
+
+_canonical-vm-wait:
+ @python3 scripts/canonical_vm.py wait-install
+
+_canonical-vm-reboot:
+ @python3 scripts/canonical_vm.py reboot
+
# Install corporate packages, profile, dotfiles, and desktop settings.
canonical-setup: _require-canonical _install-hooks (pkg-apply "base") nix-switch apply canonical-system canonical-extensions canonical-desktop
@@ -1390,7 +1449,7 @@ _desktop-update:
set -eu
source just-lib.sh
[ "$(_machine_role)" = canonical ] || exit 0
- gext update --install $(sed '/^#/d; /^$/d' meta/canonical/extensions.txt)
+ gext --filesystem update --install $(sed '/^#/d; /^$/d' meta/canonical/extensions.txt)
_canonical-finish:
#!/usr/bin/env bash
diff --git a/nix/flake.nix b/nix/flake.nix
index 33dbe42..a0f392a 100644
--- a/nix/flake.nix
+++ b/nix/flake.nix
@@ -285,6 +285,7 @@
python3
just
nixfmt
+ nodejs
prettier
ruff
selene
@@ -292,6 +293,14 @@
shfmt
stylua
taplo
+ xorriso
+ spice-gtk
+ aria2
+ curl
+ gnupg
+ openssh
+ openssl
+ imagemagick
];
};
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 7b59b39..54edcb6 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -2,6 +2,7 @@
"typeCheckingMode": "standard",
"include": ["**/*.py"],
"exclude": [
+ "tmp/**",
"**/.git/**",
"**/.venv/**",
"**/node_modules/**",
diff --git a/scripts/canonical-vm-guest.sh b/scripts/canonical-vm-guest.sh
new file mode 100644
index 0000000..29caa7d
--- /dev/null
+++ b/scripts/canonical-vm-guest.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+set -euo pipefail
+test -f /etc/canonical-lab
+test "$(id -un)" = canonical-test
+# shellcheck source=/dev/null
+test "$(
+ . /etc/os-release
+ printf '%s' "$ID"
+)" = ubuntu
+systemd-detect-virt --vm --quiet
+cd "$HOME/dotfiles"
+
+check_extensions() {
+ test "$(/usr/bin/gsettings get org.gnome.shell disable-user-extensions)" = false
+ for extension in corporate-panel@dotfiles $(sed '/^#/d; /^$/d' meta/canonical/extensions.txt); do
+ gnome-extensions info "$extension"
+ gnome-extensions list --enabled | grep -Fx "$extension"
+ gnome-extensions info "$extension" | grep -Eq 'State: (ACTIVE|ENABLED)$'
+ done
+}
+
+check_mattermost_keyring() {
+ systemctl is-active dotfiles-mattermost-keyring.path
+ snap run --shell mattermost-desktop <<'EOF'
+dbus-send --session --print-reply --dest=org.freedesktop.secrets /org/freedesktop/secrets org.freedesktop.DBus.Peer.Ping
+EOF
+ grep 'Secure storage initialized' "$HOME/snap/mattermost-desktop/current/.config/Mattermost/logs/main.log" |
+ tail -n 1 | grep -F 'encryption available'
+}
+
+case "${1:?stage required}" in
+ session)
+ check_extensions
+ test "$(secret-tool lookup application canonical-lab)" = lab-value
+ check_mattermost_keyring
+ ;;
+ nix)
+ if ! test -x /nix/var/nix/profiles/default/bin/nix; then
+ curl --fail --location https://nixos.org/nix/install -o /tmp/install-nix
+ sh /tmp/install-nix --daemon --yes --no-channel-add
+ fi
+ ;;
+ identity)
+ install -d -m 700 "$HOME/.gnupg" "$HOME/.config/chezmoi"
+ if ! gpg --list-secret-keys canonical-test@example.invalid >/dev/null 2>&1; then
+ gpg --batch --pinentry-mode loopback --passphrase '' --quick-generate-key \
+ 'Canonical Test <canonical-test@example.invalid>' ed25519 sign 1y
+ fi
+ fingerprint=$(gpg --with-colons --list-secret-keys canonical-test@example.invalid | awk -F: '$1=="fpr" {print $10; exit}')
+ cat >"$HOME/.config/chezmoi/chezmoi.toml" <<EOF
+sourceDir = "$HOME/dotfiles"
+[data]
+machineRole = "canonical"
+workName = "Canonical Test"
+workEmail = "canonical-test@example.invalid"
+workSigningKey = "$fingerprint"
+EOF
+ git init -q
+ git -c user.name='Canonical Test' -c user.email=canonical-test@example.invalid \
+ -c commit.gpgsign=false add .
+ git -c core.hooksPath=/dev/null -c user.name='Canonical Test' -c user.email=canonical-test@example.invalid \
+ -c commit.gpgsign=false commit --allow-empty -qm 'Import lab source'
+ ;;
+ setup)
+ export NIX_CONFIG='experimental-features = nix-command flakes'
+ export PATH="/nix/var/nix/profiles/default/bin:$HOME/.nix-profile/bin:$PATH"
+ nix develop ./nix --command just canonical-setup
+ ;;
+ settings)
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ just canonical-desktop-restore
+ just canonical-desktop
+ test "$(gsettings get org.gnome.desktop.interface clock-show-weekday)" = false
+ ;;
+ check)
+ export PATH="$HOME/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH"
+ test -n "${WAYLAND_DISPLAY:-}"
+ test "${XDG_CURRENT_DESKTOP#*GNOME}" != "$XDG_CURRENT_DESKTOP"
+ just canonical-desktop
+ test "$(gsettings writable org.gnome.desktop.interface clock-show-weekday)" = false
+ test "$(gsettings get org.gnome.desktop.interface clock-show-weekday)" = false
+ just canonical-lab-check
+ just apply
+ # shellcheck disable=SC2016
+ "$HOME/.nix-profile/bin/zsh" -lc 'test -x "$NVIM_TREESITTER_CC"'
+ lsblk -sno TYPE "$(findmnt -no SOURCE /)" | grep -Fx crypt
+ gpg --batch --local-user canonical-test@example.invalid --output /tmp/lab-signature --detach-sign --yes README.md
+ gpg --verify /tmp/lab-signature README.md
+ podman run --rm docker.io/library/alpine:3.22 true
+ # shellcheck disable=SC2016
+ aibox -- sh -c 'test "$AIBOX" = 1 && test ! -S /var/lib/lxd/unix.socket'
+ check_extensions
+ busctl --user get-property org.freedesktop.secrets /org/freedesktop/secrets/collection/login \
+ org.freedesktop.Secret.Collection Locked | grep -Fx 'b false'
+ printf 'lab-value' | timeout 30 secret-tool store --label='Canonical lab test' application canonical-lab
+ test "$(secret-tool lookup application canonical-lab)" = lab-value
+ check_mattermost_keyring
+ notify-send 'Canonical lab' 'Notification test'
+ ;;
+ *) exit 2 ;;
+esac
diff --git a/scripts/canonical-vm-install.sh b/scripts/canonical-vm-install.sh
new file mode 100644
index 0000000..177d06e
--- /dev/null
+++ b/scripts/canonical-vm-install.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+set -euo pipefail
+systemd-detect-virt --vm --quiet
+test -f /cdrom/lab/owner
+
+mount_config() {
+ mkdir -p "$1"
+ modprobe 9pnet_virtio
+ mount -t 9p config "$1" -o access=0,trans=virtio ||
+ mount -t virtiofs config "$1"
+}
+
+if [ "${1:-}" = live ]; then
+ mount_config /mnt/lxd-agent
+ (cd /mnt/lxd-agent && ./install.sh)
+ umount /mnt/lxd-agent
+ systemctl start lxd-agent
+ exit 0
+fi
+
+test -d /target/etc
+install -m 600 /cdrom/lab/owner /target/etc/canonical-lab
+install -m 600 /cdrom/lab/disk-password /target/root/lab-unlock.key
+# The disposable guest stores its unlock key in the unencrypted initramfs.
+awk 'NF && $1 !~ /^#/ {$3="/root/lab-unlock.key"} {print}' \
+ /target/etc/crypttab >/target/etc/crypttab.lab
+mv /target/etc/crypttab.lab /target/etc/crypttab
+mkdir -p /target/etc/dracut.conf.d
+printf 'install_items+=" /root/lab-unlock.key "\n' >/target/etc/dracut.conf.d/99-canonical-lab.conf
+printf 'canonical-test ALL=(ALL:ALL) NOPASSWD:ALL\n' >/target/etc/sudoers.d/canonical-lab
+chmod 440 /target/etc/sudoers.d/canonical-lab
+printf '[daemon]\nAutomaticLoginEnable=True\nAutomaticLogin=canonical-test\n' >/target/etc/gdm3/custom.conf
+printf 'PermitRootLogin no\nPasswordAuthentication no\nAllowAgentForwarding no\n' >/target/etc/ssh/sshd_config.d/00-canonical-lab.conf
+printf 'APT::Get::Assume-Yes "true";\n' >/target/etc/apt/apt.conf.d/99canonical-lab
+mkdir -p /target/etc/dconf/profile /target/etc/dconf/db/canonical-lab.d/locks
+if ! test -f /target/etc/dconf/profile/user; then
+ printf 'user-db:user\n' >/target/etc/dconf/profile/user
+fi
+printf 'system-db:canonical-lab\n' >>/target/etc/dconf/profile/user
+printf '[org/gnome/desktop/interface]\nclock-show-weekday=false\n' >/target/etc/dconf/db/canonical-lab.d/00-settings
+printf '/org/gnome/desktop/interface/clock-show-weekday\n' >/target/etc/dconf/db/canonical-lab.d/locks/test
+curtin in-target --target=/target -- dconf update
+
+mount_config /target/mnt/lxd-agent
+curtin in-target --target=/target -- bash -c 'cd /mnt/lxd-agent && ./install.sh'
+umount /target/mnt/lxd-agent
+curtin in-target --target=/target -- update-initramfs -u -k all
+# shellcheck disable=SC2016
+curtin in-target --target=/target -- bash -ec \
+ 'for image in /boot/initrd.img-*; do lsinitrd "$image" | grep -F root/lab-unlock.key; done'
diff --git a/scripts/canonical-vm-keyring.sh b/scripts/canonical-vm-keyring.sh
new file mode 100644
index 0000000..8af901a
--- /dev/null
+++ b/scripts/canonical-vm-keyring.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+set -euo pipefail
+test "$(id -u)" = 0
+test -f /etc/canonical-lab
+systemd-detect-virt --vm --quiet
+home=/home/canonical-test
+key=$home/.local/state/canonical-lab/keyring-password
+test ! -f "$key" || exit 0
+systemctl stop gdm
+loginctl terminate-user canonical-test
+umask 077
+install -d -o canonical-test -g canonical-test "$home/.local/state/canonical-lab"
+if test -d "$home/.local/share/keyrings"; then
+ test ! -e "$home/.local/share/keyrings-before-lab-unlock"
+ mv "$home/.local/share/keyrings" "$home/.local/share/keyrings-before-lab-unlock"
+fi
+cat >"$key"
+test -s "$key"
+chown canonical-test:canonical-test "$key"
+dropin=$home/.config/systemd/user/gnome-keyring-daemon.service.d
+install -d -o canonical-test -g canonical-test "$dropin"
+cat >"$dropin/lab.conf" <<'EOF'
+[Service]
+ExecStart=
+ExecStart=/usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=%t/keyring --unlock
+StandardInput=file:%h/.local/state/canonical-lab/keyring-password
+EOF
+chown canonical-test:canonical-test "$dropin/lab.conf"
diff --git a/scripts/canonical.py b/scripts/canonical.py
index 2ff6310..db7f773 100644
--- a/scripts/canonical.py
+++ b/scripts/canonical.py
@@ -81,14 +81,28 @@ def install() -> None:
subprocess.run(command, check=True)
-def check() -> None:
+def require_lab() -> None:
+ marker = Path("/etc/canonical-lab")
+ if not marker.is_file() or marker.stat().st_uid != 0:
+ raise SystemExit("This command requires a root-owned lab marker.")
+ if subprocess.run(
+ ["systemd-detect-virt", "--vm", "--quiet"], check=False
+ ).returncode:
+ raise SystemExit("This command requires a virtual machine.")
+
+
+def check(lab: bool = False) -> None:
+ if lab:
+ require_lab()
+ print("UNTESTED: company provisioning, authd, and Landscape registration.")
commands = [
["lsb_release", "-ds"],
*[
["systemctl", "is-active", unit]
- for unit in ["display-manager", "snapd", "apparmor", "nix-daemon"]
+ for unit in ["display-manager", "snapd", "apparmor"]
],
- ["landscape-config", "--actively-registered"],
+ ["nix", "store", "ping", "--store", "daemon"],
+ *([] if lab else [["landscape-config", "--actively-registered"]]),
["snap", "connections", "thunderbird"],
["snap", "list", *packages("snap")],
["gnome-extensions", "list", "--enabled"],
@@ -113,14 +127,22 @@ def check() -> None:
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
- "action", choices=["install", "update", "flatpak-update", "extensions", "check"]
+ "action",
+ choices=[
+ "install",
+ "update",
+ "flatpak-update",
+ "extensions",
+ "check",
+ "lab-check",
+ ],
)
args = parser.parse_args()
require_canonical()
if args.action == "install":
install()
- elif args.action == "check":
- check()
+ elif args.action in {"check", "lab-check"}:
+ check(lab=args.action == "lab-check")
else:
commands = update_commands()
if args.action == "flatpak-update":
@@ -128,7 +150,7 @@ def main() -> None:
["flatpak", "update", "--user", "--assumeyes", *packages("flatpak")]
]
elif args.action == "extensions":
- commands = [["gext", "install", *packages("extensions")]]
+ commands = [["gext", "--filesystem", "install", *packages("extensions")]]
for command in commands:
subprocess.run(command, check=True)
diff --git a/scripts/canonical_vm.py b/scripts/canonical_vm.py
new file mode 100644
index 0000000..b8af4df
--- /dev/null
+++ b/scripts/canonical_vm.py
@@ -0,0 +1,802 @@
+"""Create and operate the disposable Canonical desktop test VM."""
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import secrets
+import select
+import shlex
+import shutil
+import subprocess
+import tarfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+STATE = ROOT / "tmp/canonical-vm"
+NAME = "canonical-lab"
+MARKER = "user.dotfiles-lab"
+ISO = "ubuntu-26.04.1-desktop-amd64.iso"
+RELEASE = "https://releases.ubuntu.com/26.04/"
+SIGNER = "843938DF228D22F7B3742BC0D94AA3F0EFE21092"
+
+
+def run(*args: str, **kwargs):
+ return subprocess.run(args, check=True, **kwargs)
+
+
+def output(*args: str) -> str:
+ return subprocess.check_output(args, text=True).strip()
+
+
+def lxc(*args: str, **kwargs):
+ return run("lxc", "--force-local", "--project", "default", *args, **kwargs)
+
+
+def query(path: str):
+ return json.loads(
+ output("lxc", "--force-local", "query", path + "?project=default")
+ )
+
+
+def require_owned(instance: dict, owner: str) -> None:
+ if (
+ instance.get("type") != "virtual-machine"
+ or instance.get("config", {}).get(MARKER) != owner
+ ):
+ raise RuntimeError("Refusing to change an unmarked VM.")
+
+
+def owner() -> str:
+ return (STATE / "owner").read_text().strip()
+
+
+def checked_instance():
+ instance = query("/1.0/instances/" + NAME)
+ require_owned(instance, owner())
+ return instance
+
+
+def private_file(path: Path, data: str) -> None:
+ with path.open("x", encoding="utf-8") as stream:
+ stream.write(data)
+ path.chmod(0o600)
+
+
+def prepare() -> None:
+ if os.environ.get("AIBOX"):
+ raise RuntimeError("Run the VM workflow outside aibox.")
+ run("git", "check-ignore", "-q", "tmp/canonical-vm/secrets/probe", cwd=ROOT)
+ if "tmp/" not in (ROOT / ".chezmoiignore").read_text().splitlines():
+ raise RuntimeError("The lab directory must be ignored by chezmoi.")
+ if STATE.resolve() != STATE or (STATE / "secrets").is_symlink():
+ raise RuntimeError("The lab directory must not contain redirected paths.")
+ os.umask(0o077)
+ STATE.mkdir(parents=True, exist_ok=True, mode=0o700)
+ STATE.chmod(0o700)
+ if not (STATE / "owner").exists():
+ private_file(STATE / "owner", secrets.token_hex(16))
+ (STATE / "secrets").mkdir(exist_ok=True, mode=0o700)
+ for name in ["login-password", "disk-password"]:
+ path = STATE / "secrets" / name
+ if not path.exists():
+ private_file(path, secrets.token_urlsafe(32))
+ key = STATE / "secrets/ssh"
+ if not key.exists():
+ run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", NAME, "-f", str(key))
+
+
+def download(name: str, mirror: str = RELEASE) -> None:
+ target = STATE / name
+ if not target.exists():
+ if name == ISO:
+ run(
+ "aria2c",
+ "--continue=true",
+ "--max-connection-per-server=8",
+ "--split=8",
+ "--auto-file-renaming=false",
+ "--summary-interval=60",
+ "--console-log-level=warn",
+ "--dir=" + str(STATE),
+ "--out=" + name + ".part",
+ mirror.rstrip("/") + "/" + name,
+ )
+ Path(str(target) + ".part").rename(target)
+ return
+ run(
+ "curl",
+ "--fail",
+ "--location",
+ "--retry",
+ "3",
+ "--output",
+ str(target) + ".part",
+ RELEASE + name,
+ )
+ Path(str(target) + ".part").rename(target)
+
+
+def verify_iso(mirror: str = RELEASE) -> None:
+ for name in ["SHA256SUMS", "SHA256SUMS.gpg"]:
+ download(name)
+ keyring = STATE / "ubuntu-keyring"
+ keyring.mkdir(exist_ok=True, mode=0o700)
+ run(
+ "gpg",
+ "--homedir",
+ str(keyring),
+ "--batch",
+ "--keyserver",
+ "hkps://keyserver.ubuntu.com",
+ "--recv-keys",
+ SIGNER,
+ )
+ run(
+ "gpg",
+ "--homedir",
+ str(keyring),
+ "--batch",
+ "--verify",
+ str(STATE / "SHA256SUMS.gpg"),
+ str(STATE / "SHA256SUMS"),
+ )
+ expected = next(
+ line.split()[0]
+ for line in (STATE / "SHA256SUMS").read_text().splitlines()
+ if line.split()[-1].lstrip("*") == ISO
+ )
+ download(ISO, mirror)
+ with (STATE / ISO).open("rb") as stream:
+ actual = hashlib.file_digest(stream, "sha256").hexdigest()
+ if actual != expected:
+ raise RuntimeError("Ubuntu ISO checksum does not match its signed manifest.")
+
+
+def autoinstall(password_hash: str, public_key: str, disk_password: str) -> dict:
+ return {
+ "autoinstall": {
+ "version": 1,
+ "refresh-installer": {"update": False},
+ "early-commands": [
+ ["systemd-detect-virt", "--vm", "--quiet"],
+ ["bash", "/cdrom/lab/install.sh", "live"],
+ ],
+ "locale": "en_US.UTF-8",
+ "keyboard": {"layout": "us"},
+ "timezone": "Europe/Lisbon",
+ "identity": {
+ "hostname": NAME,
+ "username": "canonical-test",
+ "realname": "Canonical Test",
+ "password": password_hash,
+ },
+ "storage": {
+ "layout": {
+ "name": "lvm",
+ "match": {"path": "/dev/sda"},
+ "password": disk_password,
+ "sizing-policy": "all",
+ }
+ },
+ "ssh": {
+ "install-server": True,
+ "allow-pw": False,
+ "authorized-keys": [public_key],
+ },
+ "packages": [
+ "openssh-server",
+ "git",
+ "curl",
+ "gnome-keyring",
+ "libsecret-tools",
+ "gnupg",
+ ],
+ "late-commands": [["bash", "/cdrom/lab/install.sh"]],
+ "shutdown": "poweroff",
+ }
+ }
+
+
+def boot_config(text: str) -> str:
+ patched, count = re.subn(
+ r"(linux\s+/casper/vmlinuz[^\n]*?)\s+---", r"\1 autoinstall ---", text
+ )
+ if not count:
+ raise RuntimeError("Unknown Ubuntu installer boot configuration.")
+ return re.sub(r"set timeout=\d+", "set timeout=1", patched)
+
+
+def installer(mirror: str = RELEASE) -> None:
+ prepare()
+ verify_iso(mirror)
+ media = STATE / "media"
+ media.mkdir(exist_ok=True, mode=0o700)
+ password_hash = (
+ subprocess.check_output(
+ ["openssl", "passwd", "-6", "-stdin"],
+ input=(STATE / "secrets/login-password").read_bytes(),
+ )
+ .decode()
+ .strip()
+ )
+ config = autoinstall(
+ password_hash,
+ (STATE / "secrets/ssh.pub").read_text().strip(),
+ (STATE / "secrets/disk-password").read_text(),
+ )
+ (media / "autoinstall.yaml").write_text(json.dumps(config, indent=2))
+ run(
+ "xorriso",
+ "-osirrox",
+ "on",
+ "-indev",
+ str(STATE / ISO),
+ "-extract",
+ "/boot/grub/grub.cfg",
+ str(media / "grub.cfg"),
+ )
+ (media / "grub.cfg").chmod(0o600)
+ (media / "grub.cfg").write_text(boot_config((media / "grub.cfg").read_text()))
+ target = STATE / "installer.iso"
+ if target.exists():
+ raise RuntimeError(
+ "Installer already exists; keep it for retries or remove it explicitly."
+ )
+ run(
+ "xorriso",
+ "-indev",
+ str(STATE / ISO),
+ "-outdev",
+ str(target) + ".part",
+ "-boot_image",
+ "any",
+ "replay",
+ "-map",
+ str(media / "grub.cfg"),
+ "/boot/grub/grub.cfg",
+ "-map",
+ str(media / "autoinstall.yaml"),
+ "/autoinstall.yaml",
+ "-map",
+ str(ROOT / "scripts/canonical-vm-install.sh"),
+ "/lab/install.sh",
+ "-map",
+ str(STATE / "secrets/disk-password"),
+ "/lab/disk-password",
+ "-map",
+ str(STATE / "owner"),
+ "/lab/owner",
+ )
+ Path(str(target) + ".part").rename(target)
+
+
+def create(mirror: str = RELEASE) -> None:
+ prepare()
+ instances = query("/1.0/instances")
+ if any(path.split("?")[0].endswith("/" + NAME) for path in instances):
+ checked_instance()
+ raise RuntimeError("Lab VM already exists; use start, status, or restore.")
+ if not (STATE / "installer.iso").exists():
+ installer(mirror)
+ lxc(
+ "init",
+ NAME,
+ "--empty",
+ "--vm",
+ "--no-profiles",
+ "-s",
+ "default",
+ "-c",
+ MARKER + "=" + owner(),
+ "-c",
+ "limits.cpu=4",
+ "-c",
+ "limits.memory=8GiB",
+ "-d",
+ "root,size=80GiB",
+ )
+ lxc("config", "device", "add", NAME, "eth0", "nic", "network=lxdbr0", "name=eth0")
+ lxc(
+ "config",
+ "device",
+ "add",
+ NAME,
+ "installer",
+ "disk",
+ "source=" + str(STATE / "installer.iso"),
+ "boot.priority=10",
+ )
+ lxc("start", NAME)
+ print("Installer started. Use canonical-vm-status or canonical-vm-console.")
+
+
+def source_allowed(path: str) -> bool:
+ return not any(
+ part in {"tmp", ".git", ".direnv", ".worktrees", "node_modules", "__pycache__"}
+ for part in Path(path).parts
+ )
+
+
+def source_archive() -> Path:
+ files = (
+ subprocess.check_output(
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
+ cwd=ROOT,
+ )
+ .decode()
+ .split("\0")
+ )
+ archive = STATE / "source.tar"
+ with tarfile.open(archive, "w") as stream:
+ for name in sorted(set(files)):
+ if name and source_allowed(name) and (ROOT / name).exists():
+ stream.add(ROOT / name, arcname=name, recursive=False)
+ return archive
+
+
+def guest(*args: str, **kwargs):
+ checked_instance()
+ return lxc("exec", NAME, "--", *args, **kwargs)
+
+
+def wait_agent() -> None:
+ checked_instance()
+ deadline = time.monotonic() + 600
+ while time.monotonic() < deadline:
+ result = subprocess.run(
+ [
+ "lxc",
+ "--force-local",
+ "--project",
+ "default",
+ "exec",
+ NAME,
+ "--",
+ "true",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ check=False,
+ )
+ if result.returncode == 0:
+ return
+ time.sleep(5)
+ raise RuntimeError("Guest agent did not become ready within ten minutes.")
+
+
+def snapshot(name: str) -> None:
+ instance = checked_instance()
+ if instance["status"] != "Stopped":
+ raise RuntimeError("Stop the lab VM before taking a snapshot.")
+ lxc("snapshot", NAME, name)
+
+
+def stop() -> None:
+ if checked_instance()["status"] == "Stopped":
+ return
+ guest("systemctl", "poweroff", "--no-block")
+ deadline = time.monotonic() + 180
+ while time.monotonic() < deadline:
+ if checked_instance()["status"] == "Stopped":
+ return
+ time.sleep(5)
+ raise RuntimeError("Guest shutdown did not finish within three minutes.")
+
+
+def reboot() -> None:
+ stop()
+ lxc("start", NAME)
+ wait_agent()
+
+
+def guest_address() -> str:
+ deadline = time.monotonic() + 120
+ while time.monotonic() < deadline:
+ state = query("/1.0/instances/" + NAME + "/state")
+ for interface in (state.get("network") or {}).values():
+ for address in interface.get("addresses", []):
+ if address["family"] == "inet" and address["scope"] == "global":
+ return address["address"]
+ time.sleep(2)
+ raise RuntimeError("Guest did not receive an IPv4 address within two minutes.")
+
+
+def ssh_command(*args: str) -> list[str]:
+ checked_instance()
+ actual_owner = output(
+ "lxc",
+ "--force-local",
+ "--project",
+ "default",
+ "exec",
+ NAME,
+ "--",
+ "cat",
+ "/etc/canonical-lab",
+ )
+ if actual_owner != owner():
+ raise RuntimeError("Guest marker does not match this lab.")
+ address = guest_address()
+ key = subprocess.check_output(
+ [
+ "lxc",
+ "--force-local",
+ "--project",
+ "default",
+ "exec",
+ NAME,
+ "--",
+ "cat",
+ "/etc/ssh/ssh_host_ed25519_key.pub",
+ ],
+ text=True,
+ ).strip()
+ known_hosts = STATE / "known_hosts"
+ expected = NAME + " " + key + "\n"
+ if known_hosts.exists() and known_hosts.read_text() != expected:
+ raise RuntimeError("Guest SSH host key changed.")
+ if not known_hosts.exists():
+ private_file(known_hosts, expected)
+ return [
+ "ssh",
+ "-F",
+ "/dev/null",
+ "-i",
+ str(STATE / "secrets/ssh"),
+ "-o",
+ "IdentityAgent=none",
+ "-o",
+ "IdentitiesOnly=yes",
+ "-o",
+ "ForwardAgent=no",
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "ConnectTimeout=15",
+ "-o",
+ "ServerAliveInterval=30",
+ "-o",
+ "ServerAliveCountMax=6",
+ "-o",
+ "StrictHostKeyChecking=yes",
+ "-o",
+ "HostKeyAlias=" + NAME,
+ "-o",
+ "UserKnownHostsFile=" + str(known_hosts),
+ "canonical-test@" + address,
+ shlex.join(args),
+ ]
+
+
+def remote(*args: str, desktop: bool = False, **kwargs):
+ if desktop:
+ args = (
+ "systemd-run",
+ "--user",
+ "--wait",
+ "--pipe",
+ "--collect",
+ "--expand-environment=no",
+ *args,
+ )
+ return run(*ssh_command(*args), **kwargs)
+
+
+def logs() -> None:
+ checked_instance()
+ directory = STATE / "reports" / time.strftime("%Y%m%d-%H%M%S")
+ directory.mkdir(parents=True, mode=0o700)
+ commands = {
+ "system-journal": ["journalctl", "-b", "-p", "warning", "--no-pager"],
+ "units": ["systemctl", "--failed", "--no-pager"],
+ "storage": ["lsblk", "-f"],
+ "desktop": ["journalctl", "-b", "_UID=1000", "--no-pager", "-n", "300"],
+ }
+ for name, args in commands.items():
+ with (directory / (name + ".log")).open("w") as stream:
+ guest(*args, stdout=stream, stderr=subprocess.STDOUT)
+ print("Reports: " + str(directory))
+
+
+def guest_stage(stage: str, desktop: bool = False) -> None:
+ if desktop:
+ wait_desktop()
+ directory = STATE / "reports"
+ directory.mkdir(exist_ok=True, mode=0o700)
+ log = directory / (time.strftime("%Y%m%d-%H%M%S-") + stage + ".log")
+ print("Running guest stage " + stage + "; log: " + str(log), flush=True)
+ code = 1
+ try:
+ with log.open("w") as stream:
+ remote(
+ "bash",
+ "/home/canonical-test/dotfiles/scripts/canonical-vm-guest.sh",
+ stage,
+ desktop=desktop,
+ stdout=stream,
+ stderr=subprocess.STDOUT,
+ timeout=14400,
+ )
+ code = 0
+ except subprocess.CalledProcessError as error:
+ code = error.returncode
+ raise
+ finally:
+ log.with_suffix(".json").write_text(
+ json.dumps({"stage": stage, "exit_code": code}) + "\n"
+ )
+
+
+def wait_desktop() -> None:
+ deadline = time.monotonic() + 180
+ while time.monotonic() < deadline:
+ try:
+ remote(
+ "sh",
+ "-c",
+ 'test -n "$WAYLAND_DISPLAY" '
+ '&& test "${XDG_CURRENT_DESKTOP#*GNOME}" != "$XDG_CURRENT_DESKTOP" '
+ "&& /usr/bin/gnome-extensions list >/dev/null "
+ '&& test ! -e "$XDG_RUNTIME_DIR/gnome-shell-disable-extensions"',
+ desktop=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=20,
+ )
+ return
+ except subprocess.CalledProcessError:
+ time.sleep(5)
+ raise RuntimeError("The GNOME user session is not ready.")
+
+
+def sync_source() -> None:
+ wait_agent()
+ archive = source_archive()
+ remote("mkdir", "-p", "/home/canonical-test/dotfiles")
+ remote("test", "!", "-L", "/home/canonical-test/dotfiles")
+ remote(
+ "find",
+ "/home/canonical-test/dotfiles",
+ "-mindepth",
+ "1",
+ "-maxdepth",
+ "1",
+ "!",
+ "-name",
+ ".git",
+ "-exec",
+ "rm",
+ "-rf",
+ "--",
+ "{}",
+ "+",
+ )
+ with archive.open("rb") as stream:
+ remote("tar", "-xf", "-", "-C", "/home/canonical-test/dotfiles", stdin=stream)
+
+
+def configure_keyring() -> None:
+ with (STATE / "secrets/login-password").open("rb") as stream:
+ guest(
+ "bash",
+ "/home/canonical-test/dotfiles/scripts/canonical-vm-keyring.sh",
+ stdin=stream,
+ )
+
+
+def deploy_guest() -> None:
+ configure_keyring()
+ reboot()
+ guest_stage("setup", desktop=True)
+
+
+def bootstrap() -> None:
+ sync_source()
+ signing_key = STATE / "secrets/signing.gpg"
+ if signing_key.exists():
+ with signing_key.open("rb") as stream:
+ remote("gpg", "--batch", "--import", stdin=stream)
+ guest_stage("identity")
+ if not signing_key.exists():
+ with signing_key.open("xb") as stream:
+ signing_key.chmod(0o600)
+ remote(
+ "gpg",
+ "--batch",
+ "--export-secret-keys",
+ "canonical-test@example.invalid",
+ stdout=stream,
+ )
+ guest_stage("nix")
+ stop()
+ snapshot("nix-ready")
+ lxc("start", NAME)
+ wait_agent()
+ deploy_guest()
+
+
+def test_guest() -> None:
+ wait_agent()
+ wait_desktop()
+ guest_stage("settings", desktop=True)
+ reboot()
+ wait_desktop()
+ guest_stage("check", desktop=True)
+ screenshot()
+ reboot()
+ wait_desktop()
+ guest_stage("session", desktop=True)
+ screenshot()
+ logs()
+
+
+def wait_install() -> None:
+ deadline = time.monotonic() + 5400
+ while time.monotonic() < deadline:
+ if checked_instance()["status"] == "Stopped":
+ return
+ time.sleep(15)
+ raise RuntimeError(
+ "Installer has not stopped after 90 minutes. Inspect the guest console."
+ )
+
+
+def screenshot() -> None:
+ for attempt in range(3):
+ try:
+ capture_screenshot()
+ return
+ except subprocess.CalledProcessError as error:
+ if error.cmd[0] != "spicy-screenshot" or attempt == 2:
+ raise
+ print("SPICE capture failed; retrying in three seconds.", flush=True)
+ time.sleep(3)
+
+
+def capture_screenshot() -> None:
+ checked_instance()
+ client = shutil.which("lxc")
+ if not client:
+ raise RuntimeError("LXD client is missing.")
+ directory = STATE / "reports"
+ directory.mkdir(exist_ok=True, mode=0o700)
+ target = directory / (time.strftime("%Y%m%d-%H%M%S") + ".ppm")
+ env = {**os.environ, "PATH": "/nonexistent"}
+ with subprocess.Popen(
+ [
+ client,
+ "--force-local",
+ "--project",
+ "default",
+ "console",
+ NAME,
+ "--type=vga",
+ ],
+ env=env,
+ cwd=STATE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ ) as proxy:
+ try:
+ assert proxy.stdout
+ deadline = time.monotonic() + 30
+ data = b""
+ while time.monotonic() < deadline:
+ if select.select([proxy.stdout], [], [], 1)[0]:
+ data += os.read(proxy.stdout.fileno(), 4096)
+ match = re.search(rb"spice\+unix://[^\s]+", data)
+ if match:
+ run(
+ "spicy-screenshot",
+ "--uri=" + match[0].decode(),
+ "--out-file=" + str(target),
+ cwd=STATE,
+ env={
+ **os.environ,
+ "SPICE_DISABLE_CHANNELS": "playback-0,record-0",
+ },
+ timeout=60,
+ )
+ run("magick", str(target), str(target.with_suffix(".png")))
+ print(target.with_suffix(".png"))
+ return
+ if proxy.poll() is not None:
+ break
+ raise RuntimeError(
+ "No guest SPICE socket returned: " + data.decode(errors="replace")
+ )
+ finally:
+ proxy.terminate()
+ try:
+ proxy.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proxy.kill()
+ proxy.wait()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "action",
+ choices=[
+ "create",
+ "status",
+ "console",
+ "start",
+ "stop",
+ "snapshot",
+ "restore",
+ "delete",
+ "finish-install",
+ "bootstrap",
+ "test",
+ "logs",
+ "screenshot",
+ "wait-install",
+ "reboot",
+ "deploy",
+ "sync-source",
+ ],
+ )
+ parser.add_argument(
+ "snapshot", nargs="?", choices=["clean", "nix-ready", "working", "diagnostic"]
+ )
+ parser.add_argument("--mirror", default=RELEASE)
+ args = parser.parse_args()
+ if args.action == "create":
+ if not args.mirror.startswith("https://"):
+ parser.error("Use an HTTPS ISO mirror.")
+ create(args.mirror)
+ return
+ instance = checked_instance()
+ if args.action == "bootstrap":
+ bootstrap()
+ elif args.action == "sync-source":
+ sync_source()
+ guest_stage("identity")
+ elif args.action == "wait-install":
+ wait_install()
+ elif args.action == "deploy":
+ deploy_guest()
+ elif args.action == "reboot":
+ reboot()
+ elif args.action == "stop":
+ stop()
+ elif args.action == "test":
+ test_guest()
+ elif args.action == "logs":
+ logs()
+ elif args.action == "screenshot":
+ screenshot()
+ elif args.action == "status":
+ print(json.dumps(instance, indent=2))
+ elif args.action == "console":
+ lxc("console", NAME, "--type=vga", cwd=STATE)
+ elif args.action == "snapshot":
+ if not args.snapshot:
+ parser.error("snapshot name required")
+ snapshot(args.snapshot)
+ elif args.action == "restore":
+ if not args.snapshot:
+ parser.error("snapshot name required")
+ if instance["status"] != "Stopped":
+ raise RuntimeError("Stop the lab VM before restore.")
+ lxc("restore", NAME, args.snapshot)
+ elif args.action == "finish-install":
+ if instance["status"] != "Stopped":
+ raise RuntimeError("Wait for the installer to power off.")
+ lxc("config", "device", "remove", NAME, "installer")
+ lxc("start", NAME)
+ wait_agent()
+ guest("test", "-f", "/etc/canonical-lab")
+ stop()
+ snapshot("clean")
+ else:
+ lxc(args.action, NAME)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_canonical.py b/tests/test_canonical.py
index b21b8f5..e1c825c 100644
--- a/tests/test_canonical.py
+++ b/tests/test_canonical.py
@@ -17,12 +17,52 @@ SPEC.loader.exec_module(canonical)
class PackageTests(unittest.TestCase):
+ def test_install_does_not_enable_experimental_snap_features(self):
+ with patch.object(canonical.subprocess, "run") as command:
+ canonical.install()
+ self.assertFalse(any("set" in call.args[0] for call in command.call_args_list))
+
def test_keybase_is_not_installed_or_autostarted(self):
self.assertNotIn("keybase", canonical.packages("snap"))
self.assertFalse(
(ROOT / "dot_config/autostart/dotfiles-keybase.desktop").exists()
)
+ def test_extensions_install_without_shell_confirmation(self):
+ with (
+ patch("sys.argv", ["canonical.py", "extensions"]),
+ patch.object(canonical, "require_canonical"),
+ patch.object(canonical.subprocess, "run") as command,
+ ):
+ canonical.main()
+ self.assertEqual(
+ command.call_args.args[0][:3], ["gext", "--filesystem", "install"]
+ )
+
+ def test_lab_check_rejects_missing_marker(self):
+ with (
+ patch.object(canonical.Path, "is_file", return_value=False),
+ self.assertRaises(SystemExit),
+ ):
+ canonical.require_lab()
+
+ def test_normal_check_keeps_company_registration(self):
+ with patch.object(canonical.subprocess, "run") as command:
+ command.return_value.returncode = 0
+ canonical.check()
+ self.assertTrue(
+ any(
+ call.args[0] == ["landscape-config", "--actively-registered"]
+ for call in command.call_args_list
+ )
+ )
+ self.assertTrue(
+ any(
+ call.args[0] == ["nix", "store", "ping", "--store", "daemon"]
+ for call in command.call_args_list
+ )
+ )
+
def test_non_corporate_role_is_rejected_before_system_access(self):
with (
patch.object(
diff --git a/tests/test_canonical_vm.py b/tests/test_canonical_vm.py
new file mode 100644
index 0000000..481242b
--- /dev/null
+++ b/tests/test_canonical_vm.py
@@ -0,0 +1,230 @@
+import importlib.util
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+ "canonical_vm", ROOT / "scripts/canonical_vm.py"
+)
+assert SPEC and SPEC.loader
+vm = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(vm)
+
+
+class LabTests(unittest.TestCase):
+ def test_reboot_checks_extensions_without_reapplying_settings(self):
+ with (
+ patch.object(vm, "wait_agent"),
+ patch.object(vm, "wait_desktop"),
+ patch.object(vm, "guest_stage") as stage,
+ patch.object(vm, "reboot"),
+ patch.object(vm, "remote"),
+ patch.object(vm, "screenshot"),
+ patch.object(vm, "logs"),
+ ):
+ vm.test_guest()
+ self.assertEqual(stage.call_args.args, ("session",))
+
+ def test_desktop_waits_for_extension_startup_check(self):
+ with patch.object(vm, "remote") as remote:
+ vm.wait_desktop()
+ command = remote.call_args.args[2]
+ self.assertIn("gnome-extensions list", command)
+ self.assertIn(
+ 'test ! -e "$XDG_RUNTIME_DIR/gnome-shell-disable-extensions"', command
+ )
+
+ def test_screenshot_retries_a_failed_spice_connection(self):
+ error = subprocess.CalledProcessError(1, ["spicy-screenshot"])
+ with (
+ patch.object(
+ vm, "capture_screenshot", side_effect=[error, None]
+ ) as capture,
+ patch.object(vm.time, "sleep"),
+ ):
+ vm.screenshot()
+ self.assertEqual(capture.call_count, 2)
+
+ def test_screenshot_retry_is_bounded(self):
+ error = subprocess.CalledProcessError(1, ["spicy-screenshot"])
+ with (
+ patch.object(vm, "capture_screenshot", side_effect=error) as capture,
+ patch.object(vm.time, "sleep"),
+ self.assertRaises(subprocess.CalledProcessError),
+ ):
+ vm.screenshot()
+ self.assertEqual(capture.call_count, 3)
+
+ def test_deploy_prepares_keyring_before_desktop_setup(self):
+ calls = []
+ with (
+ patch.object(
+ vm, "configure_keyring", side_effect=lambda: calls.append("keyring")
+ ),
+ patch.object(vm, "reboot", side_effect=lambda: calls.append("reboot")),
+ patch.object(
+ vm, "guest_stage", side_effect=lambda *a, **k: calls.append("setup")
+ ),
+ ):
+ vm.deploy_guest()
+ self.assertEqual(calls, ["keyring", "reboot", "setup"])
+
+ def test_keyring_password_is_sent_on_stdin(self):
+ with tempfile.TemporaryDirectory() as directory:
+ state = Path(directory)
+ (state / "secrets").mkdir()
+ (state / "secrets/login-password").write_bytes(b"test-password")
+ with (
+ patch.object(vm, "STATE", state),
+ patch.object(vm, "guest") as guest,
+ ):
+ guest.side_effect = lambda *args, **kwargs: self.assertEqual(
+ kwargs["stdin"].read(), b"test-password"
+ )
+ vm.configure_keyring()
+ self.assertNotIn("test-password", str(guest.call_args.args))
+
+ def test_address_waits_for_dhcp(self):
+ ready = {
+ "network": {
+ "enp5s0": {
+ "addresses": [
+ {"family": "inet", "scope": "global", "address": "192.0.2.10"}
+ ]
+ }
+ }
+ }
+ with (
+ patch.object(vm, "query", side_effect=[{"network": {}}, ready]),
+ patch.object(vm.time, "sleep") as sleep,
+ ):
+ self.assertEqual(vm.guest_address(), "192.0.2.10")
+ sleep.assert_called_once()
+
+ def test_stop_uses_guest_shutdown_instead_of_power_button(self):
+ with (
+ patch.object(
+ vm,
+ "checked_instance",
+ side_effect=[{"status": "Running"}, {"status": "Stopped"}],
+ ),
+ patch.object(vm, "guest") as guest,
+ patch.object(vm, "lxc") as lxc,
+ ):
+ vm.stop()
+ guest.assert_called_once_with("systemctl", "poweroff", "--no-block")
+ lxc.assert_not_called()
+
+ def test_desktop_commands_preserve_shell_expansion(self):
+ with (
+ patch.object(vm, "ssh_command", side_effect=lambda *args: ["ssh", *args]),
+ patch.object(vm, "run") as command,
+ ):
+ vm.remote("sh", "-c", 'test -n "$WAYLAND_DISPLAY"', desktop=True)
+ self.assertIn("--expand-environment=no", command.call_args.args)
+
+ def test_source_checks_exclude_lab_data(self):
+ with tempfile.TemporaryDirectory() as directory:
+ base = Path(directory)
+ (base / "tmp").mkdir()
+ (base / "tmp/generated.py").touch()
+ (base / "source.py").touch()
+ result = subprocess.check_output(
+ [
+ "bash",
+ "-c",
+ 'source "$1"; _find_by_ext py',
+ "bash",
+ str(ROOT / "just-lib.sh"),
+ ],
+ cwd=base,
+ text=True,
+ )
+ self.assertEqual(result.strip(), "./source.py")
+
+ def test_ownership_requires_vm_and_matching_marker(self):
+ good = {"type": "virtual-machine", "config": {vm.MARKER: "test-id"}}
+ vm.require_owned(good, "test-id")
+ for bad in [{}, {**good, "type": "container"}, {**good, "config": {}}]:
+ with self.assertRaises(RuntimeError):
+ vm.require_owned(bad, "test-id")
+
+ def test_source_filter_excludes_secrets_and_host_git(self):
+ for path in ["tmp/key", ".git/config", ".direnv/env", "x/__pycache__/x.pyc"]:
+ self.assertFalse(vm.source_allowed(path))
+ self.assertTrue(vm.source_allowed("dot_config/git/config.tmpl"))
+ self.assertTrue(vm.source_allowed("scripts/canonical_vm.py"))
+
+ def test_autoinstall_targets_only_lab_disk(self):
+ config = vm.autoinstall("hash", "ssh-ed25519 test", "disk-secret")
+ install = config["autoinstall"]
+ self.assertEqual(install["storage"]["layout"]["match"], {"path": "/dev/sda"})
+ self.assertEqual(install["storage"]["layout"]["name"], "lvm")
+ self.assertFalse(install["ssh"]["allow-pw"])
+ self.assertNotIn("landscape", str(config))
+ self.assertNotIn("interactive-sections", install)
+ self.assertEqual(
+ install["early-commands"][0], ["systemd-detect-virt", "--vm", "--quiet"]
+ )
+
+ def test_installer_boot_patch_requires_kernel(self):
+ result = vm.boot_config("set timeout=30\n linux /casper/vmlinuz quiet ---\n")
+ self.assertIn("autoinstall", result)
+ self.assertIn("set timeout=1", result)
+ with self.assertRaises(RuntimeError):
+ vm.boot_config("unknown installer")
+
+ def test_snapshot_rejects_running_vm(self):
+ with (
+ patch.object(vm, "checked_instance", return_value={"status": "Running"}),
+ patch.object(vm, "lxc") as command,
+ ):
+ with self.assertRaises(RuntimeError):
+ vm.snapshot("clean")
+ command.assert_not_called()
+
+ def test_ssh_does_not_use_host_config_or_agent(self):
+ state = {
+ "network": {
+ "enp5s0": {
+ "addresses": [
+ {"address": "192.0.2.10", "family": "inet", "scope": "global"}
+ ]
+ }
+ }
+ }
+ with (
+ tempfile.TemporaryDirectory() as directory,
+ patch.object(vm, "STATE", Path(directory)),
+ patch.object(vm, "checked_instance"),
+ patch.object(vm, "owner", return_value="test-id"),
+ patch.object(vm, "output", return_value="test-id"),
+ patch.object(vm, "query", return_value=state),
+ patch.object(
+ vm.subprocess, "check_output", return_value="ssh-ed25519 example"
+ ),
+ ):
+ command = vm.ssh_command("true")
+ self.assertEqual(command[:3], ["ssh", "-F", "/dev/null"])
+ for option in [
+ "IdentityAgent=none",
+ "ForwardAgent=no",
+ "StrictHostKeyChecking=yes",
+ "IdentitiesOnly=yes",
+ ]:
+ self.assertIn(option, command)
+ (Path(directory) / "known_hosts").write_text("changed")
+ with self.assertRaises(RuntimeError):
+ vm.ssh_command("true")
+
+ def test_private_file_does_not_replace_existing_secret(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "secret"
+ vm.private_file(path, "first")
+ with self.assertRaises(FileExistsError):
+ vm.private_file(path, "second")
+ self.assertEqual(path.read_text(), "first")
+ self.assertEqual(path.stat().st_mode & 0o777, 0o600)