diff --git a/bump-blog-quine.sh b/bump-blog-quine.sh new file mode 100755 index 0000000..745c670 --- /dev/null +++ b/bump-blog-quine.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +LINUX=/root/petersweb-infra/nixos/linux.nix + +usage() { + echo "Usage: $0 " + echo " e.g. $0 sha256:2e2d92abae0ba68be780fff581523480ac05444690dbf38bf4330f1dda099e2a" + exit 1 +} + +[[ $# -eq 1 ]] || usage + +NEW_DIGEST="${1#sha256:}" # strip leading "sha256:" if provided + +# Validate: hex string of the right length +if ! [[ "$NEW_DIGEST" =~ ^[0-9a-f]{64}$ ]]; then + echo "Error: digest must be a 64-character lowercase hex string (got: $NEW_DIGEST)" >&2 + exit 1 +fi + +OLD_LINE=$(grep -n 'blog-quine@sha256:' "$LINUX") +echo "Current: $OLD_LINE" + +sed -i -E "s|(blog-quine@sha256:)[0-9a-f]{64}|\1${NEW_DIGEST}|" "$LINUX" + +NEW_LINE=$(grep -n 'blog-quine@sha256:' "$LINUX") +echo "Updated: $NEW_LINE" + +echo "Applying NixOS configuration..." +nixos-rebuild switch --flake /root/petersweb-infra/nixos#mainframe +echo "Done. Tail logs with: docker logs -f blog-quine" diff --git a/nixos/CLAUDE.md b/nixos/CLAUDE.md index 9fb7a2f..6652210 100644 --- a/nixos/CLAUDE.md +++ b/nixos/CLAUDE.md @@ -31,6 +31,7 @@ nixos-rebuild switch --flake /root/petersweb-infra/nixos#mainframe | `keys/` | SSH public keys used as age recipients | | `system/` | User definitions and home-manager config | | `pdxdestiny/` | Static site files for pdxdestiny.com | +| `vnc-desktop/` | Dockerfile + build scripts for the KDE Plasma VNC desktop container | ## Secrets (agenix) @@ -75,19 +76,32 @@ Note: `secrets/default.nix` is the agenix recipients file. Agenix looks for `sec | `gitea-runner-ubuntu.service` | Forgejo (Gitea) Actions CI runner, uses docker images | | `forgejo-arion.service` | Forgejo itself, run via Arion/Podman | | `riverside-arion.service` | Riverside app, run via Arion/Docker | -| `docker-navidrome.service` | Navidrome music server on port 4533 | -| `docker-nextcloud.service` | Nextcloud/SSH container on port 8087 | -| `docker-sync.io.service` | sync.io app on port 9090 | -| `docker-blog-quine.service` | Blog on port 3010 | -| `docker-coldairnetworks.service` | Cold Air Networks site on port 3012 | +| `podman-navidrome.service` | Navidrome music server on port 4533 | +| `podman-nextcloud.service` | Nextcloud/SSH container on port 8087 | +| `podman-sync.io.service` | sync.io app on port 9090 | +| `podman-blog-quine.service` | Blog on port 3010 | +| `podman-coldairnetworks.service` | Cold Air Networks site on port 3012 | +| `podman-vnc-desktop.service` | KDE Plasma desktop, noVNC on port 6080 (localhost only) | +| `build-vnc-image.service` | Builds the VNC desktop image from `vnc-desktop/`; runs before `podman-vnc-desktop` | | nginx | Reverse proxy + ACME certs for multiple domains | ## Virtualisation -- **Podman** is used for Forgejo (via Arion). `DOCKER_HOST` for the gitea-runner is set to `unix:///run/podman/podman.sock`. -- **Docker** is used for the OCI containers (navidrome, nextcloud, etc.) and the riverside Arion stack. +- **Podman** is used for all OCI containers (`virtualisation.oci-containers.backend = "podman"`) — navidrome, nextcloud, blog, VNC desktop, etc. — and for Forgejo via Arion. +- **Docker** is still present for the Riverside Arion stack. +- `DOCKER_HOST` for the gitea-runner is set to `unix:///run/podman/podman.sock`. - The gitea-runner runs docker images for CI jobs, so the `gitea-runner` user is in the `docker` and `podman` supplementary groups. +## VNC desktop + +`podman-vnc-desktop.service` runs a KDE Plasma desktop inside a container, accessible via noVNC at `localhost:6080` (reverse-proxied by nginx). The image is built locally — no registry involved. + +- **Image source**: `vnc-desktop/Dockerfile` (Ubuntu 24.04, TigerVNC, KDE, Firefox, patched Discover) +- **Auto-rebuild**: `build-vnc-image.service` runs on boot and on `nixos-rebuild switch` whenever `vnc-desktop/` changes. The trigger is `vncContext = builtins.path { path = ./vnc-desktop; }` — a Nix store path that invalidates when any file in the directory changes. +- **Auto-restart**: `podman-vnc-desktop.service` has `restartTriggers = [ vncContext ]`, so the container restarts automatically after a rebuild during `nixos-rebuild switch`. +- **Secrets**: `VNC_PASSWORD` and `ROOT_PASSWORD` come from `age.secrets.vnc-password`. +- **Discover logging**: `vnc-desktop/discover-logging/` contains a build-time patch (`patch.py`) that instruments `PKTransaction.cpp` with `qWarning` calls to diagnose hanging installs. Logs visible via `podman logs vnc-desktop`. + ## Networking / DNS - Dynamic DNS via `invoke-ddns` (NearlyFreeSpeech provider). diff --git a/nixos/aliases.zsh b/nixos/aliases.zsh index f211745..c1b1470 100644 --- a/nixos/aliases.zsh +++ b/nixos/aliases.zsh @@ -3,6 +3,20 @@ alias gb='git branch' alias gs='git status' alias gpcb='git push origin $(git rev-parse --abbrev-ref HEAD)' +function git() { + if [[ $1 == clone && $# -ge 2 ]]; then + local url=$2 + # expand foo/bar -> https://github.com/foo/bar.git (no protocol, no dots in host part) + if [[ $url =~ ^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+$ ]]; then + url="https://github.com/${url%.git}.git" + echo "Cloning $url" >&2 + fi + command git clone "$url" "${@:3}" + else + command git "$@" + fi +} + alias u='cd ..' alias uu='cd ../..' alias uuu='cd ../../..' diff --git a/nixos/arion-riverside/arion-compose.nix b/nixos/arion-riverside/arion-compose.nix index 94df372..710f72e 100644 --- a/nixos/arion-riverside/arion-compose.nix +++ b/nixos/arion-riverside/arion-compose.nix @@ -7,7 +7,7 @@ services = { app = { service = { - image = "forge.quinefoundation.com/ironmagma/riverside@sha256:835a2a407aa3f60193c089e2c5fd26193bd0ac90f3da6aa5e8edaa0789db15aa"; + image = "forge.quinefoundation.com/ironmagma/riverside@sha256:6ad578b0668ac91f37fc3677ce12960b5eeb23c3ba7238e1ba137d35e60fea58"; container_name = "riverside"; restart = "unless-stopped"; networks = [ "riverside" ]; diff --git a/nixos/linux.nix b/nixos/linux.nix index 5117244..b7fdde7 100644 --- a/nixos/linux.nix +++ b/nixos/linux.nix @@ -18,6 +18,8 @@ nixPkgs = specialArgs.nixPkgs; ourRustVersion = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.complete); + vncContext = builtins.path { path = ./vnc-desktop; name = "vnc-desktop-context"; }; + ourRustPlatform = nixPkgs.makeRustPlatform { rustc = ourRustVersion; cargo = ourRustVersion; @@ -170,8 +172,8 @@ in { systemd.services.arion-riverside.environment.DOCKER_HOST = "unix:///run/podman/podman.sock"; # Build the VNC desktop image locally from the Dockerfile — no registry push/pull needed. - # Nix copies the build context into the store; the hash changes when Dockerfile or - # start.sh change, triggering a rebuild on the next nixos-rebuild switch. + # vncContext is a Nix store path that changes whenever any file under vnc-desktop/ changes, + # which causes build-vnc-image to re-run and podman-vnc-desktop to restart on nixos-rebuild. systemd.services.build-vnc-image = { description = "Build VNC desktop container image from Dockerfile"; wantedBy = [ "podman-vnc-desktop.service" ]; @@ -181,9 +183,7 @@ in { RemainAfterExit = true; ExecStart = pkgs.writeShellScript "build-vnc-image" '' STAMP=/var/lib/build-vnc-image/context-hash - EXPECTED="${builtins.hashString "sha256" - (builtins.readFile ./vnc-desktop/Dockerfile + - builtins.readFile ./vnc-desktop/start.sh)}" + EXPECTED="${vncContext}" if [ -f "$STAMP" ] && [ "$(cat "$STAMP")" = "$EXPECTED" ]; then echo "VNC image is up to date, skipping build" exit 0 @@ -191,13 +191,15 @@ in { echo "Building VNC desktop image..." ${pkgs.podman}/bin/podman build \ -t forge.quinefoundation.com/ironmagma/vnc-desktop:latest \ - ${./vnc-desktop} + ${vncContext} mkdir -p "$(dirname "$STAMP")" echo "$EXPECTED" > "$STAMP" ''; }; }; + systemd.services.podman-vnc-desktop.restartTriggers = [ "${vncContext}" ]; + services.gitea-actions-runner.instances."ubuntu" = { enable = true; name = "ubuntu"; @@ -345,7 +347,7 @@ in { "blog-quine" = { autoStart = true; - image = "quineglobal/blog-quine@sha256:3c2901f772c322d81f843c04d6982b9f50ff0b46d3cc457d9f868a7ff5a15497"; + image = "quineglobal/blog-quine@sha256:88097e4867a99a375db490bf7a989c122653cdb48bfdf6d9ad5e2f6a0bfb2d38"; volumes = []; environment = {}; ports = ["3010:8080"]; diff --git a/nixos/secrets/vnc-password.age b/nixos/secrets/vnc-password.age index eed6db0..6293532 100644 --- a/nixos/secrets/vnc-password.age +++ b/nixos/secrets/vnc-password.age @@ -1,5 +1,5 @@ age-encryption.org/v1 --> ssh-ed25519 NFD/vg ZScq11dQhcK72TVjmnwo7OXG8yarNhU6XFqo+n2XvCg -eXK/3Jp5J/kjjl3sRV1L4q0ZY2SEPZSEgTczqkLONJk ---- GV0KPgO/rZpKGL+6M/JW9dUzuoNiA0e3Nm2ubBhLgUc -sC+yb ܌64c#eM'pn \ No newline at end of file +-> ssh-ed25519 NFD/vg BPXpLwp8zZADR3vYGht8wEMrKCegRiHBkO22ZgHHrXU +nh3J6CscPxGpsdaKyL0q1a5EmROX3lVtZv1A7/pvm4s +--- UGCcZKsSD4opeR41BAsJT1Hi0OPLpkHyiGLuYPH/dwA +fMN}[kVͷtЭ"׌qfƚ F/x" Lbe;j"^'ZmX \ No newline at end of file diff --git a/nixos/system/home/shared.nix b/nixos/system/home/shared.nix index dfe506a..0ef0b25 100644 --- a/nixos/system/home/shared.nix +++ b/nixos/system/home/shared.nix @@ -16,6 +16,7 @@ jq killall tmux + tree unzip vim wget diff --git a/nixos/vnc-desktop/discover-logging/build.sh b/nixos/vnc-desktop/discover-logging/build.sh new file mode 100644 index 0000000..6e0249a --- /dev/null +++ b/nixos/vnc-desktop/discover-logging/build.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Enable deb-src so apt-get source works +sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/ubuntu.sources + +apt-get update -qq + +apt-get install -y --no-install-recommends \ + dpkg-dev \ + build-essential \ + devscripts \ + python3 + +apt-get build-dep -y plasma-discover + +cd /tmp +apt-get source plasma-discover + +SRC_DIR=$(ls -d /tmp/plasma-discover-*/) + +# Apply logging patch +python3 /discover-logging/patch.py "$SRC_DIR/libdiscover/backends/PackageKitBackend/PKTransaction.cpp" + +cd "$SRC_DIR" +DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -b -uc -us -j"$(nproc)" + +# Install the rebuilt packages (packagekit-backend.so lives in plasma-discover_*.deb) +dpkg -i /tmp/plasma-discover_*.deb + +# Clean up to keep image lean +rm -rf /tmp/plasma-discover-* /tmp/*.deb /tmp/*.dsc /tmp/*.tar.* diff --git a/nixos/vnc-desktop/discover-logging/patch.py b/nixos/vnc-desktop/discover-logging/patch.py new file mode 100644 index 0000000..9a05367 --- /dev/null +++ b/nixos/vnc-desktop/discover-logging/patch.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Patch PKTransaction.cpp to add verbose logging so we can diagnose +why installs hang on "Installing..." status. +""" +import sys + +def replace_once(src, old, new, label): + count = src.count(old) + if count != 1: + print(f"ERROR: '{label}' matched {count} times (expected 1)", file=sys.stderr) + sys.exit(1) + return src.replace(old, new) + +path = sys.argv[1] +with open(path) as f: + src = f.read() + +# 1. trigger(): log what phase we're entering and with what flags +src = replace_once(src, + 'void PKTransaction::trigger(PackageKit::Transaction::TransactionFlags flags)\n{', + 'void PKTransaction::trigger(PackageKit::Transaction::TransactionFlags flags)\n{\n' + ' qWarning() << "[DISCOVER] trigger(): flags=" << flags << "role=" << role();', + 'trigger() header' +) + +# 2. statusChanged(): log the raw PK status instead of the collapsed UI status +src = replace_once(src, + 'void PKTransaction::statusChanged()\n{' + '\n setStatus(m_trans->status() == PackageKit::Transaction::StatusDownload ? Transaction::DownloadingStatus : Transaction::CommittingStatus);', + 'void PKTransaction::statusChanged()\n{' + '\n qWarning() << "[DISCOVER] statusChanged(): pk_status=" << m_trans->status()' + ' << "percentage=" << m_trans->percentage()' + ' << "lastPackage=" << m_trans->lastPackage();' + '\n setStatus(m_trans->status() == PackageKit::Transaction::StatusDownload ? Transaction::DownloadingStatus : Transaction::CommittingStatus);', + 'statusChanged() body' +) + +# 3. progressChanged(): log when percentage updates (or fails to) +src = replace_once(src, + ' auto percent = m_trans->percentage();\n if (percent == 101) {\n qWarning() << "percentage cannot be calculated";', + ' auto percent = m_trans->percentage();\n' + ' qWarning() << "[DISCOVER] progressChanged(): raw_pct=" << percent << "pk_status=" << m_trans->status();\n' + ' if (percent == 101) {\n qWarning() << "percentage cannot be calculated";', + 'progressChanged() body' +) + +# 4. cleanup(): log the exit/cancel/failed/simulate flags +src = replace_once(src, + 'void PKTransaction::cleanup(PackageKit::Transaction::Exit exit, uint runtime)\n{', + 'void PKTransaction::cleanup(PackageKit::Transaction::Exit exit, uint runtime)\n{\n' + ' const bool _simulate_flag = m_trans && (m_trans->transactionFlags() & PackageKit::Transaction::TransactionFlagSimulate);\n' + ' qWarning() << "[DISCOVER] cleanup(): exit=" << exit << "runtime=" << runtime' + ' << "simulate=" << _simulate_flag' + ' << "proceedFunctions=" << m_proceedFunctions.size();', + 'cleanup() header' +) + +# 5. errorFound(): log every error, including ones currently silently swallowed +src = replace_once(src, + 'void PKTransaction::errorFound(PackageKit::Transaction::Error err, const QString &error)\n{' + '\n if (err == PackageKit::Transaction::ErrorNoLicenseAgreement || err == PackageKit::Transaction::ErrorTransactionCancelled' + '\n || err == PackageKit::Transaction::ErrorNotAuthorized) {' + '\n return;\n }', + 'void PKTransaction::errorFound(PackageKit::Transaction::Error err, const QString &error)\n{' + '\n qWarning() << "[DISCOVER] errorFound(): err=" << err << "detail=" << error;' + '\n if (err == PackageKit::Transaction::ErrorNoLicenseAgreement || err == PackageKit::Transaction::ErrorTransactionCancelled' + '\n || err == PackageKit::Transaction::ErrorNotAuthorized) {' + '\n return;\n }', + 'errorFound() body' +) + +# 6. LocalFilePKResource path: log the .deb path being installed +src = replace_once(src, + ' m_trans = PackageKit::Daemon::installFile(QUrl(app->packageName()).toLocalFile(), flags);', + ' qWarning() << "[DISCOVER] installFile():" << QUrl(app->packageName()).toLocalFile() << "flags=" << flags;\n' + ' m_trans = PackageKit::Daemon::installFile(QUrl(app->packageName()).toLocalFile(), flags);', + 'installFile() call' +) + +with open(path, 'w') as f: + f.write(src) + +print(f"Patched {path}")