mirror of
https://github.com/srid/nixos-config.git
synced 2026-07-16 22:01:33 +08:00
* 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.
321 lines
10 KiB
Nix
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
|
|
'';
|
|
}
|