nixos-config/modules/nixos/linux/incus/incus-pet/default.nix
Sridhar Ratnakumar 0794bcc151
incus-pet: per-app incus container CLI; drop webapps/ (#118)
* incus-pet: per-app incus container CLI ("pet PaaS"); drop webapps/

Phase 1 of the incus-pet design. Adds the deployment infrastructure
under modules/nixos/linux/incus/ — a CLI, a container-essentials NixOS
module, a SKILL.md for agent-driven adoption — and deletes the webapps/
tree (nspawn-based, weaker isolation defaults, no live consumers).

Pairs with srid/anywhen#15 (nixosModules.incus on the anywhen flake)
for end-to-end deployment of anywhen as an incus container.

Surface added:

- modules/nixos/linux/incus/container.nix    container essentials
  (sshd, hostname, flakes, firewall, base packages); imported by the
  marshaling flake incus-pet generates per app
- modules/nixos/linux/incus/incus-pet/       CLI tree:
    default.nix   writeShellApplication; subcommands deploy/list/rm
    SKILL.md      3-branch agent recipe (deploy / wrapper / add
                  contract upstream)
    README.md     human docs with the full network-model section
- modules/flake-parts/incus-pet.nix          exposes packages.incus-pet

Surface removed:

- modules/nixos/linux/anywhen.nix            anywhen runs as a
  container now, not as a host-installed service
- configurations/nixos/pureintent            drops the anywhen import
  and the services.anywhen.host wiring
- webapps/                                   deleted entirely

Port convention: every containerized service binds 8080 inside its
own netns. The host-side <listen-ip>:<host-port> is unique per app,
chosen at first deploy via --port + --listen (or INCUS_PET_LISTEN
env), and recorded in container metadata so re-deploys are flagless.

Run:
  nix run .#incus-pet -- deploy github:srid/anywhen \
    --port 7700 --listen 100.122.32.106

See modules/nixos/linux/incus/incus-pet/README.md for the operator
flow and the network model in full.

* incus-pet: fixes discovered while deploying the hello-web example

Three small fixes that fall out of doing the first live deploy on
pureintent. None change the design — they make the documented flow
actually work against the real images:nixos/25.11 container image and
the real `nixos-rebuild --target-host` activation path.

* container.nix imports `${modulesPath}/virtualisation/lxc-container.nix`
  so the rebuilt config knows it's a container — without it,
  `nixos-rebuild` fails the `boot.loader.grub.devices` / `fileSystems`
  assertions. Same module the upstream NixOS incus image's
  /etc/nixos/configuration.nix imports.

* container.nix carries the same dbus-broker reload workaround
  pureintent already has (NixOS#180175-ish symptom — broker has long-
  lived clients holding the bus, reload times out,
  switch-to-configuration exits 4 despite activation succeeding).

* incus-pet/default.nix:
  - bootstrap step simplified: the official nixos/25.11 image already
    runs sshd via systemd socket activation on :22 — we only need to
    push the operator's pubkey. The earlier in-container
    `nixos-rebuild switch` is unnecessary (and breaks because the
    image ships without a nixpkgs channel).
  - `NIX_SSHOPTS='-o StrictHostKeyChecking=accept-new'` for the
    --target-host rebuild (fresh containers have fresh host keys).
  - `incus config set <name> key=value` form (the space-separated
    form deprecated in incus 6.x; was emitting a warning per metadata
    key on every deploy).
  - Image baseline `images:nixos/25.05` → `images:nixos/25.11`
    (25.05 isn't published on the LXC image server; 25.11 is the
    current stable).

End-to-end verified: `incus-pet deploy github:srid/anywhen` deploys
into a container reachable on the host's tailscale IP; the same path
works for the hello-web example (stacked PR).

* incus-pet: hello-web example flake — minimal contract demonstration

Self-contained reference under
modules/nixos/linux/incus/incus-pet/example/hello-web/ that satisfies
the incus-pet contract in the smallest possible form:

  - packages.<sys>.default    a darkhttpd wrapper that serves a
                              one-page index.html, reading HOST/PORT
                              from the environment
  - nixosModules.default      services.hello-web.{enable, package,
                              host, port} + a DynamicUser=true systemd
                              unit
  - nixosModules.incus        the deploy contract: services.hello-web
                              bound to 8080, hostname "hello-web"

Useful as a copy-paste template for new apps. Less moving parts than
the anywhen reference (no bun, no SQLite, no state dir, no e2e tests
to keep green) — just three flake outputs and a static HTML response.

Live-deployed end-to-end on pureintent during this PR's bring-up:

  $ incus-pet deploy path:./.../example/hello-web hello-web \
      --port 8081 --listen 100.122.32.106
  $ curl http://100.122.32.106:8081/
  <!doctype html>
  <h1>Hello from incus-pet</h1>

Idempotent re-deploy verified (no flags needed — host-port + listen
read back from container metadata; container name auto-detected from
incus.container.hostname).

* incus-pet: drop anywhen wiring from this PR — defer to follow-up

Per request: keep this PR strictly to the incus-pet infrastructure +
the hello-web example. The anywhen migration (replacing the host-
installed services.anywhen on pureintent with an incus-pet container)
lands in a separate follow-up PR after srid/anywhen#15 merges.

Restored from master:
- modules/nixos/linux/anywhen.nix             (was deleted)
- configurations/nixos/pureintent/default.nix (had anywhen import
                                              + services.anywhen.host
                                              removed; now back)
- flake.nix anywhen.url                       (was repinned to
                                              incus-contract; back to
                                              abject-turn)
- flake.lock anywhen entry                    (matches the abject-turn
                                              pin again)

The hello-web example remains the live verification for this PR.
2026-05-23 16:28:46 -04:00

321 lines
10 KiB
Nix

# incus-pet — per-app incus container CLI ("pet PaaS").
#
# Subcommands: deploy, list, rm.
#
# Container port is fixed at 8080 (declared by ../container.nix); the
# host-side <listen-ip>:<host-port> is unique per app and stored in
# container metadata so re-deploys are flagless. See ./README.md for
# the network model and operator flow.
{ writeShellApplication
, nix
, nixos-rebuild
, openssh
, jq
, coreutils
, gnused
, ...
}:
let
# The container essentials module — baked in at build time as a store
# path. The marshaling flake imports it directly so we don't have to
# turn the incus/ tree into a flake.
essentialsModule = ../container.nix;
in
writeShellApplication {
name = "incus-pet";
runtimeInputs = [ nix nixos-rebuild openssh jq coreutils gnused ];
meta.description = "Deploy a NixOS service flake into a per-app incus container (pet PaaS).";
text = ''
set -euo pipefail
ESSENTIALS_MODULE='${essentialsModule}'
CONTAINER_PORT=8080
STATE_ROOT="''${XDG_STATE_HOME:-$HOME/.local/state}/incus-pet"
INCUS_IMAGE="images:nixos/25.11"
log() { printf '[incus-pet] %s\n' "$*" >&2; }
die() { log "error: $*"; exit 1; }
usage() {
cat >&2 <<'EOF'
Usage:
incus-pet deploy <flake-ref> [<name>] [--port N] [--listen IP]
incus-pet list
incus-pet rm <name>
Environment:
INCUS_PET_LISTEN default listen IP for the host-side proxy device
(overridden by --listen)
First-time `deploy` requires --port; subsequent deploys read it from
container metadata. See README.md for the network model.
EOF
exit 2
}
# ----- helpers -----
ensure_incus() {
command -v incus >/dev/null 2>&1 || die "incus not in PATH (host needs the incus daemon)"
}
operator_pubkey() {
# The key root@<container> will trust. Try ed25519 then rsa.
local f
for f in "$HOME/.ssh/id_ed25519.pub" "$HOME/.ssh/id_rsa.pub"; do
if [ -r "$f" ]; then cat "$f"; return; fi
done
die "no ssh public key found at ~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub"
}
container_exists() {
incus info "$1" >/dev/null 2>&1
}
container_ipv4() {
# Poll briefly fresh containers take a second to get DHCP.
local name="$1" tries=0 ip=""
while [ "$tries" -lt 30 ]; do
ip=$(incus list "$name" --format=json 2>/dev/null \
| jq -r '.[0].state.network.eth0.addresses[]? | select(.family=="inet") | .address' \
| head -n1 || true)
if [ -n "$ip" ] && [ "$ip" != "null" ]; then
printf '%s\n' "$ip"; return 0
fi
sleep 1
tries=$((tries + 1))
done
die "container $name did not get an IPv4 address within 30s"
}
config_get() {
# Echo empty string if the key is unset (incus returns empty + 0).
incus config get "$1" "$2" 2>/dev/null || true
}
write_marshaling_flake() {
local name="$1" flake_ref="$2" pubkey="$3"
local dir="$STATE_ROOT/$name"
mkdir -p "$dir"
# Copy the essentials module into the marshaling flake dir as
# ./container.nix and import it via relative path keeps the
# generated flake pure-evaluable (pure-eval forbids absolute
# /nix/store imports not declared as flake inputs).
cp -f "$ESSENTIALS_MODULE" "$dir/container.nix"
cat > "$dir/flake.nix" <<EOF
# Generated by incus-pet do not edit. Re-run \`incus-pet deploy\` to refresh.
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
inputs.target.url = "$flake_ref";
outputs = { nixpkgs, target, ... }: {
nixosConfigurations."$name" = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./container.nix
target.nixosModules.incus
({ ... }: {
users.users.root.openssh.authorizedKeys.keys = [
"$pubkey"
];
})
];
};
};
}
EOF
printf '%s\n' "$dir"
}
precheck_target_flake() {
local flake_ref="$1"
if ! nix eval "$flake_ref#nixosModules.incus" --apply 'm: true' >/dev/null 2>&1; then
die "flake $flake_ref does not expose nixosModules.incus; see SKILL.md Branch C for how to add the contract"
fi
}
eval_hostname() {
local dir="$1" name="$2"
nix eval --raw \
"$dir#nixosConfigurations.\"$name\".config.incus.container.hostname"
}
bootstrap_container() {
# The official NixOS incus image already runs sshd via systemd
# socket activation on port 22; the only thing we need to add is
# the operator's pubkey to root's authorized_keys. Idempotent
# `tee` overwrites, so a rotated key gets picked up on next deploy.
local name="$1" pubkey="$2"
log "ensuring authorized_keys on $name"
incus exec "$name" -- mkdir -p /root/.ssh
incus exec "$name" -- chmod 700 /root/.ssh
printf '%s\n' "$pubkey" | incus exec "$name" -- tee /root/.ssh/authorized_keys >/dev/null
incus exec "$name" -- chmod 600 /root/.ssh/authorized_keys
}
wire_proxy_device() {
local name="$1" listen="$2" host_port="$3"
local devname="web"
if incus config device show "$name" 2>/dev/null | grep -q "^$devname:"; then
log "refreshing proxy device $devname on $name $listen:$host_port"
incus config device set "$name" "$devname" \
listen="tcp:$listen:$host_port" \
connect="tcp:127.0.0.1:$CONTAINER_PORT"
else
log "adding proxy device $devname on $name $listen:$host_port"
incus config device add "$name" "$devname" proxy \
listen="tcp:$listen:$host_port" \
connect="tcp:127.0.0.1:$CONTAINER_PORT"
fi
}
# ----- subcommands -----
cmd_deploy() {
local flake_ref="" name="" port="" listen=""
[ $# -ge 1 ] || usage
flake_ref="$1"; shift
# Optional positional name (anything that doesn't start with '-').
if [ $# -ge 1 ] && [ "''${1#-}" = "$1" ]; then
name="$1"; shift
fi
while [ $# -gt 0 ]; do
case "$1" in
--port) port="$2"; shift 2 ;;
--listen) listen="$2"; shift 2 ;;
*) die "unknown flag: $1" ;;
esac
done
ensure_incus
# Normalize `.` to an absolute path so nix flake commands work
# from anywhere downstream.
if [ "$flake_ref" = "." ]; then
flake_ref="path:$(pwd)"
fi
precheck_target_flake "$flake_ref"
local pubkey
pubkey=$(operator_pubkey)
# We need the hostname to know the container name, but the hostname
# lives in the evaluated config which means we need to write the
# marshaling flake first. If --name wasn't passed, use a temporary
# placeholder; we'll re-evaluate after.
local tentative_name="''${name:-_probe}"
local dir
dir=$(write_marshaling_flake "$tentative_name" "$flake_ref" "$pubkey")
log "locking marshaling flake at $dir"
( cd "$dir" && nix flake lock )
if [ -z "$name" ]; then
name=$(eval_hostname "$dir" "_probe")
log "container name from flake: $name"
# Re-emit the marshaling flake under the real name (the
# nixosConfigurations attr key must match `--flake .#<name>`).
rm -rf "$STATE_ROOT/_probe"
dir=$(write_marshaling_flake "$name" "$flake_ref" "$pubkey")
( cd "$dir" && nix flake lock )
fi
# Resolve host port (CLI flag > container metadata > error).
if [ -z "$port" ]; then
if container_exists "$name"; then
port=$(config_get "$name" user.incus-pet.host-port)
fi
[ -n "$port" ] || die "first deploy of $name needs --port N (host-side port)"
fi
# Resolve listen IP (CLI flag > env > container metadata > 0.0.0.0).
if [ -z "$listen" ]; then
listen="''${INCUS_PET_LISTEN:-}"
fi
if [ -z "$listen" ] && container_exists "$name"; then
listen=$(config_get "$name" user.incus-pet.listen)
fi
: "''${listen:=0.0.0.0}"
# Ensure container exists.
if ! container_exists "$name"; then
log "launching container $name from $INCUS_IMAGE"
incus launch "$INCUS_IMAGE" "$name" -c security.nesting=true
fi
local ip
ip=$(container_ipv4 "$name")
log "container $name has ipv4 $ip"
bootstrap_container "$name" "$pubkey"
log "activating: nixos-rebuild switch --flake $dir#$name --target-host root@$ip"
# accept-new = trust-on-first-use; reject if a known host's key
# changed. Fresh containers get a fresh host key, and the operator
# launched the container themselves, so TOFU is fine.
( cd "$dir" \
&& NIX_SSHOPTS='-o StrictHostKeyChecking=accept-new' \
nixos-rebuild switch \
--flake ".#$name" \
--target-host "root@$ip" \
--use-substitutes )
# Persist host-port + listen IP for next deploy. The `key=value`
# form is required since incus 6.x; the space-separated form is
# deprecated and warns.
incus config set "$name" "user.incus-pet.host-port=$port"
incus config set "$name" "user.incus-pet.listen=$listen"
incus config set "$name" "user.incus-pet.flake-ref=$flake_ref"
wire_proxy_device "$name" "$listen" "$port"
log "done: $name exposed on $listen:$port container:8080"
}
cmd_list() {
ensure_incus
printf '%-20s %-50s %-25s %-10s\n' "NAME" "FLAKE" "EXPOSED" "STATUS"
incus list --format=json | jq -r '.[] | select(.config["user.incus-pet.flake-ref"]) |
[
.name,
.config["user.incus-pet.flake-ref"],
(.config["user.incus-pet.listen"] + ":" + .config["user.incus-pet.host-port"]),
.status
] | @tsv' | while IFS=$'\t' read -r n f e s; do
printf '%-20s %-50s %-25s %-10s\n' "$n" "$f" "$e" "$s"
done
}
cmd_rm() {
ensure_incus
[ $# -eq 1 ] || die "rm takes exactly one argument: the container name"
local name="$1"
log "stopping + deleting container $name"
incus stop "$name" --force 2>/dev/null || true
incus delete "$name" 2>/dev/null || true
rm -rf "''${STATE_ROOT:?}/$name"
log "removed $name"
}
# ----- dispatch -----
[ $# -ge 1 ] || usage
sub="$1"; shift
case "$sub" in
deploy) cmd_deploy "$@" ;;
list) cmd_list "$@" ;;
rm) cmd_rm "$@" ;;
-h|--help) usage ;;
*) usage ;;
esac
'';
}