diff --git a/modules/flake-parts/incus-pet.nix b/modules/flake-parts/incus-pet.nix new file mode 100644 index 0000000..f8d9f2a --- /dev/null +++ b/modules/flake-parts/incus-pet.nix @@ -0,0 +1,14 @@ +# Expose the incus-pet CLI as a flake package. +# +# The CLI itself lives under modules/nixos/linux/incus/incus-pet/ so the +# whole incus/ tree stays a self-contained transplant unit (when it +# eventually lifts to its own repo, this flake-parts wiring stays +# behind). +{ ... }: +{ + perSystem = { pkgs, ... }: { + packages.incus-pet = pkgs.callPackage + ../nixos/linux/incus/incus-pet + { }; + }; +} diff --git a/modules/nixos/linux/incus/container.nix b/modules/nixos/linux/incus/container.nix new file mode 100644 index 0000000..52f433f --- /dev/null +++ b/modules/nixos/linux/incus/container.nix @@ -0,0 +1,80 @@ +# Container-side essentials for incus-pet — the OTHER half of this tree. +# +# default.nix configures the incus daemon ON THE HOST (bridge, UI, etc). +# THIS module is imported INSIDE each containerized app's nixosConfiguration, +# alongside the app's own nixosModules.incus. It declares the contract +# options that every app's incus module sets, and the boring NixOS-inside- +# a-container plumbing (sshd, base packages, firewall). +# +# The incus-pet CLI's marshaling flake imports this file directly by store +# path — no flake-input dance — so this file stays as a plain NixOS module. +{ config, lib, pkgs, modulesPath, ... }: +let + cfg = config.incus.container; +in +{ + # `lxc-container.nix` is the upstream nixpkgs module that turns a + # NixOS evaluation into something that boots as an LXC/incus container + # — skips the boot loader, sets `fileSystems` defaults, wires up + # systemd-networkd with eth0 DHCP, and trims a pile of host-only + # services. The official `images:nixos/25.11` image imports the same + # module via its /etc/nixos/configuration.nix; we re-import here so + # `nixos-rebuild --target-host` doesn't lose any of that when it + # replaces /etc/nixos at switch time. + imports = [ "${modulesPath}/virtualisation/lxc-container.nix" ]; + + # The CONVENTION: every containerized service binds 8080 inside its + # own netns. The host-side incus proxy device translates a unique + # : to :8080 at deploy time. App + # modules just hardcode `services..port = 8080;` — no shared + # option, no forward reference, no coupling between an app's flake + # and this tree at evaluation time. + options.incus.container = { + enable = lib.mkEnableOption "incus-pet container essentials"; + + hostname = lib.mkOption { + type = lib.types.str; + description = '' + Container hostname. Each app's nixosModules.incus sets this + with mkDefault; the operator can override per deploy if they + want a different name than the app's default. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + networking.hostName = cfg.hostname; + + # NixOS-inside-a-container essentials. The community NixOS incus + # image ships with neither sshd nor flakes; the incus-pet CLI does + # a one-time bootstrap via `incus exec` to push the operator pubkey + # and apply a temporary config that enables both. From the FIRST + # `nixos-rebuild --target-host` onwards, the deployed config (this + # module) carries those settings forward. + services.openssh = { + enable = true; + settings.PermitRootLogin = "yes"; + }; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + + # The hardcoded service port — opening it here too is cheap + # insurance against a mis-authored app module forgetting to. + networking.firewall.allowedTCPPorts = [ 8080 ]; + + environment.systemPackages = with pkgs; [ + git + vim + curl + htop + ]; + + # Workaround the same dbus-broker reload stall pureintent hits + # (NixOS#... — the broker has long-lived clients holding the bus, + # the reload step times out, switch-to-configuration exits 4 even + # though activation succeeded). Skip reload/restart at activation + # time; bus policy changes land on next container restart. + systemd.services.dbus-broker.reloadIfChanged = lib.mkForce false; + systemd.services.dbus-broker.restartIfChanged = lib.mkForce false; + }; +} diff --git a/modules/nixos/linux/incus/incus-pet/README.md b/modules/nixos/linux/incus/incus-pet/README.md new file mode 100644 index 0000000..21f4b20 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/README.md @@ -0,0 +1,184 @@ +# incus-pet + +Per-app incus container CLI ("pet PaaS"). Takes a NixOS service flake, +materialises it into a dedicated incus container on the local host, and +exposes the service on a chosen `:` that proxies +to a fixed `8080` inside the container. + +``` +incus-pet deploy github:srid/anywhen --port 7700 --listen 100.122.32.106 +incus-pet list +incus-pet rm anywhen +``` + +See `SKILL.md` for the agent-facing recipe; this README is for humans +running the CLI directly. + +## Prerequisites + +- The host runs the incus daemon (this repo's `modules/nixos/linux/incus` + enables it). +- The operator's user is in the `incus-admin` group. +- An SSH key exists at `~/.ssh/id_ed25519.pub` (or `~/.ssh/id_rsa.pub`) + that root inside the container will trust. +- The target flake ships `nixosModules.incus` (see `SKILL.md` Branch C + for what that means, or the anywhen flake for a worked example). + +## The deploy unit — what a flake must ship + +```nix +# in the app's flake.nix outputs +nixosModules.incus = { config, lib, pkgs, ... }: { + imports = [ self.nixosModules.default ]; + incus.container = { + enable = true; + hostname = lib.mkDefault "anywhen"; + }; + system.stateVersion = "25.05"; + services.anywhen = { + enable = true; + package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + host = lib.mkDefault "0.0.0.0"; + port = 8080; # the incus-pet contract — fixed + }; +}; +``` + +The CLI's marshaling flake imports this alongside `container.nix` (the +in-tree essentials) and runs `nixos-rebuild switch --target-host` +against the container. + +## Network model — fixed inside, unique outside + +This is the section worth reading twice. + +### Inside the container + +Every containerized service binds **the same port**: `8080`. This is a +convention, declared as a `readOnly` option (`incus.container.servicePort`) +in `container.nix`. App authors do not pick a port — they wire +`services..port = config.incus.container.servicePort` and move on. +Cross-app port collisions become impossible because there is only one +port to collide with, and each container has its own network namespace. + +### Outside the container (the host) + +The host runs an **incus proxy device** per container that translates a +unique `:` on the host into a connection to the +container's `127.0.0.1:8080`: + +``` +incus config device add web proxy \ + listen=tcp:: \ + connect=tcp:127.0.0.1:8080 +``` + +`incus-pet deploy` wires this for you idempotently. The `` +and `` are stored in container metadata +(`user.incus-pet.host-port`, `user.incus-pet.listen`) so re-running +`incus-pet deploy ` doesn't need the flags repeated. + +### Picking a listen IP + +`` can be any address bound on the host. The three meaningful +choices: + +| Listen IP | Reach | Firewall edit needed? | +|------------------|----------------------|---------------------------------------------| +| `0.0.0.0` | Anyone who can route to this host's NIC | Yes — open `` in `networking.firewall.allowedTCPPorts` | +| Host's LAN IP | Same as above, scoped to that NIC | Yes — same as above | +| Host's tailnet IP (e.g. `100.122.32.106`) | Tailnet only | **No** — `tailscale0` is in `networking.firewall.trustedInterfaces` (see `configurations/nixos/pureintent/default.nix:78`), so traffic on that interface bypasses the host firewall | + +For "internal apps on my box, reachable only from my tailnet" (the +common case), pick the tailscale IP. On a host where you always want +that, export the IP once: + +```sh +# in your shell profile, or via environment.variables in the host config +export INCUS_PET_LISTEN=100.122.32.106 +``` + +…and every `incus-pet deploy` binds there automatically. + +### What the container's own firewall does + +`container.nix` opens `8080` inside the container, so the proxy device's +`connect=tcp:127.0.0.1:8080` reaches the service. The container has its +own veth on `incusbr0`; nothing on the host's interfaces is involved +until traffic hits the proxy device on `:`. + +## Operator flow + +### First deploy of an app + +```sh +incus-pet deploy github:srid/anywhen --port 7700 --listen 100.122.32.106 +``` + +This: + +1. Synthesises a marshaling flake under `~/.local/state/incus-pet/anywhen/`. +2. Launches `images:nixos/25.11` as container `anywhen` (with + `security.nesting=true` so `nixos-rebuild` works inside). +3. Bootstraps sshd + your pubkey via `incus exec`. +4. Runs `nixos-rebuild switch --target-host root@`. +5. Records `--port` and `--listen` in container metadata. +6. Adds the `web` proxy device. + +### Subsequent deploys + +```sh +incus-pet deploy github:srid/anywhen # picks up new commits on the branch +``` + +No flags needed — `--port` and `--listen` are read from metadata. + +### Listing what's running + +```sh +incus-pet list +``` + +Filters `incus list` to only containers tagged with +`user.incus-pet.flake-ref`. + +### Removing + +```sh +incus-pet rm anywhen +``` + +Stops and deletes the container, removes the marshaling flake from +`~/.local/state/incus-pet/anywhen/`. Container metadata goes with the +container. + +## Failure modes + +- **`incus not in PATH`** — the host needs the incus daemon installed + and the operator's user in `incus-admin` (log out and back in after + the group is added). +- **`first deploy of needs --port N`** — pass `--port`; it will + be recorded in container metadata for next time. +- **`container did not get an IPv4 address within 30s`** — usually + means `incusbr0` didn't come up. Check `incus network list` and + `systemctl status incus-preseed`. +- **`Failed to start Firewall.` inside the container** — the launch + flag `-c security.nesting=true` is supposed to handle this; if it + recurs, see `../README.md` and lxc/incus#526. +- **SSH fails after first deploy** — the bootstrap step writes + `/etc/nixos/incus-pet-bootstrap.nix` and runs `nixos-rebuild` inside + the container; if that fails, `incus exec -- journalctl -xeu nixos-rebuild` is the place to look. + +## State + +| What | Where | +|-------------------------------|-----------------------------------------------------------------------| +| Marshaling flake + lock | `~/.local/state/incus-pet//{flake.nix, flake.lock}` | +| Operator-chosen host port | `incus config get user.incus-pet.host-port` | +| Operator-chosen listen IP | `incus config get user.incus-pet.listen` | +| Source flake ref | `incus config get user.incus-pet.flake-ref` | +| Service data | Inside the container, per the app's `services..stateDir` | + +The marshaling flake carries no app-specific values — only the +assembly. App config lives entirely in the upstream flake's +`nixosModules.incus`. diff --git a/modules/nixos/linux/incus/incus-pet/SKILL.md b/modules/nixos/linux/incus/incus-pet/SKILL.md new file mode 100644 index 0000000..61513ee --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/SKILL.md @@ -0,0 +1,136 @@ +--- +name: incus-pet +description: Deploy a NixOS service flake into a per-app incus container + ("pet PaaS") on the local host — OR add the required nixosModules.incus + contract to a flake so it becomes deployable. Use when the user says + "deploy X", "make this app deployable", "add incus to this flake", or + "publish this on my box". +argument-hint: "deploy [] [--port N] [--listen IP]" +--- + +# incus-pet + +A per-app incus container deploys a flake-shipped NixOS service onto the +local host, exposed on a chosen `:` that proxies to +a fixed `8080` inside the container. + +Pick the branch from context: + +- User in their OWN app's repo, "make this deployable" → **Branch C** +- User wants to add the contract to an UPSTREAM they can PR to + (e.g. juspay/kolu) — agent has push/PR access → **Branch C** +- Flake-ref already ships `nixosModules.incus` → **Branch A** +- Upstream won't accept a PR and user explicitly wants a local + wrapper → **Branch B** + +## Branch A — deploy a flake that ships the contract + +Run: + +``` +incus-pet deploy [] [--port N] [--listen IP] +``` + +`--port` is required on the first deploy of a given `` and recorded +in container metadata; subsequent deploys read it back. Report the +`:` URL to the user. + +## Branch B — local stand-alone wrapper (RARE; Branch C is preferred) + +Only when upstream won't accept the contract. Create a tiny wrapper flake +locally: + +- `flake.nix` exposing `nixosModules.incus` per the contract +- `service-module.nix` authoring `services.` as a NixOS system + module (mirror the upstream's home-manager module if any) +- Add the upstream as a flake input + +Then `incus-pet deploy ./path/to/wrapper `. + +## Branch C — add the contract to a flake (yours or upstream-via-PR) + +Steps: + +1. Verify the flake exposes `packages..default`. If not, stop — + prerequisite is a package derivation with `meta.mainProgram`. + +2. Verify `nixosModules.default` (the service module). If MISSING but the + flake has `homeManagerModules.default` (kolu case): + - Author `nix/nixos/module.nix` as a system NixOS module mirroring the + home-manager surface — same options (`enable`, `package`, `host`, + `port`, ...), emitting `systemd.services.` instead of a + home-manager unit. + - Expose as `nixosModules.default` in flake outputs. + + If MISSING entirely (no module at all), stop — prerequisite. + +3. Add `nixosModules.incus` to outputs (fill in ``): + + ```nix + nixosModules.incus = { config, lib, pkgs, ... }: { + imports = [ self.nixosModules.default ]; + incus.container = { + enable = true; + hostname = lib.mkDefault ""; + }; + system.stateVersion = "25.05"; + services. = { + enable = true; + package = lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + host = lib.mkDefault "0.0.0.0"; + port = 8080; # fixed by the incus-pet contract + }; + }; + ``` + +4. If `services.` doesn't expose `{host, port, package}` (or + equivalents), surface as prerequisite. Do not invent options that + aren't there — add them to the service module first. + +5. Smoke-test: + + ``` + nix flake check + nix eval .#nixosModules.incus + ``` + +6. If upstream (PR mode), open the PR. Else commit. + +7. Tell the user how to deploy: + + ``` + incus-pet deploy github:/ --port --listen + ``` + +Do NOT add `example/`-style sub-flakes or VM tests in this branch — +separate scope. + +## Contract for `nixosModules.incus` + +The module must set: + +- `incus.container.enable = true` +- `incus.container.hostname = ""` (`mkDefault` ok) +- `system.stateVersion = "25.05"` +- `services..{enable, package, host, port}` (`port = 8080`, + hardcoded — this is the incus-pet convention) + +The module must NOT set: + +- `services..port` to anything other than `8080` +- `networking.firewall.allowedTCPPorts` — `container.nix` opens 8080 + +## Idempotence + failure modes + +- Re-running `incus-pet deploy ` against an existing container + is safe: marshaling flake is rewritten and re-locked; container + bootstrap is idempotent; proxy device is `set` if present, else `add`. +- Container metadata (`user.incus-pet.{host-port,listen,flake-ref}`) is + the source of truth for the operator-chosen exposure. `incus-pet list` + filters by the `flake-ref` key. +- If `--port` is missing on the first deploy, the command fails before + launching anything. +- Network failures during `nixos-rebuild --target-host` leave the + container running its previous generation — `nixos-rebuild switch` is + itself atomic. diff --git a/modules/nixos/linux/incus/incus-pet/default.nix b/modules/nixos/linux/incus/incus-pet/default.nix new file mode 100644 index 0000000..ae38a06 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/default.nix @@ -0,0 +1,321 @@ +# 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 : 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 [] [--port N] [--listen IP] + incus-pet list + incus-pet rm + + 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@ 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" </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 .#`). + 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 + ''; +} diff --git a/modules/nixos/linux/incus/incus-pet/example/hello-web/README.md b/modules/nixos/linux/incus/incus-pet/example/hello-web/README.md new file mode 100644 index 0000000..af57845 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/example/hello-web/README.md @@ -0,0 +1,40 @@ +# hello-web — incus-pet reference example + +Smallest possible flake that satisfies the [incus-pet contract](../../SKILL.md). +A static HTTP server (`darkhttpd` wrapping a one-page `index.html`) packaged +through the full stack: + +- `packages..default` — the binary +- `nixosModules.default` — `services.hello-web.{enable, package, host, port}` + a systemd unit +- `nixosModules.incus` — the deploy contract (binds 8080 inside a `hello-web` container) + +## Build it standalone + +```sh +nix build path:./modules/nixos/linux/incus/incus-pet/example/hello-web + +# Then run it locally — listens on 0.0.0.0:8080. +./result/bin/hello-web & +curl http://127.0.0.1:8080 +``` + +## Deploy it as an incus-pet container + +From a host that has the incus daemon and the `incus-pet` CLI on PATH: + +```sh +nix run path:.#incus-pet -- deploy \ + path:./modules/nixos/linux/incus/incus-pet/example/hello-web \ + hello-web --port 8081 --listen 100.122.32.106 + +curl http://100.122.32.106:8081 +``` + +`incus-pet list` will show the running container; `incus-pet rm hello-web` +tears it down. + +## What to read next + +The flake here is meant to be a working template. Compare the three +outputs (`packages.default`, `nixosModules.default`, `nixosModules.incus`) +side-by-side with what srid/anywhen ships — same shape, scaled down. diff --git a/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.lock b/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.lock new file mode 100644 index 0000000..5966c45 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779357205, + "narHash": "sha256-cCO8aTqss5x9Ky8GWkpY0Hy5fyTZEbtifSUV8QjSzic=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "f83fc3c307e74bc5fd5adb7eb6b8b13ffd2a36e1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.nix b/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.nix new file mode 100644 index 0000000..66380c6 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/example/hello-web/flake.nix @@ -0,0 +1,115 @@ +# hello-web — minimal incus-pet example. +# +# Self-contained reference flake that ships the three outputs incus-pet +# consumes: +# +# - packages..default a tiny static-file server (darkhttpd +# wrapped to read HOST/PORT from env) +# - nixosModules.default services.hello-web.{enable, package, +# host, port} + a systemd unit +# - nixosModules.incus the deploy contract — wires the +# service to bind 8080 inside an incus +# container at hostname "hello-web" +# +# Deploy with (from a host that has the incus daemon + incus-pet CLI): +# +# nix run github:srid/nixos-config#incus-pet -- \ +# deploy --port 8081 --listen +{ + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs, ... }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + eachSystem = f: nixpkgs.lib.genAttrs systems + (system: f nixpkgs.legacyPackages.${system}); + in + { + packages = eachSystem (pkgs: + let + www = pkgs.writeTextDir "index.html" '' + + + hello-web +

Hello from incus-pet

+

Served by darkhttpd inside an incus container.

+ ''; + hello-web = pkgs.writeShellApplication { + name = "hello-web"; + runtimeInputs = [ pkgs.darkhttpd ]; + text = '' + HOST="''${HOST:-0.0.0.0}" + PORT="''${PORT:-8080}" + exec darkhttpd ${www} --addr "$HOST" --port "$PORT" + ''; + meta.mainProgram = "hello-web"; + }; + in + { + default = hello-web; + hello-web = hello-web; + }); + + nixosModules.default = { config, lib, pkgs, ... }: + let + cfg = config.services.hello-web; + in + { + options.services.hello-web = { + enable = lib.mkEnableOption "hello-web example server"; + package = lib.mkOption { + type = lib.types.package; + description = "The hello-web package to run (must expose bin/hello-web via meta.mainProgram)."; + }; + host = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "Address the HTTP server binds to."; + }; + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "Port the HTTP server listens on."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.hello-web = { + description = "hello-web example HTTP server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + environment = { + HOST = cfg.host; + PORT = toString cfg.port; + }; + serviceConfig = { + ExecStart = lib.getExe cfg.package; + Restart = "on-failure"; + RestartSec = 2; + DynamicUser = true; + }; + }; + }; + }; + + # incus-pet contract — see SKILL.md in the incus-pet tree. + nixosModules.incus = { config, lib, pkgs, ... }: { + imports = [ self.nixosModules.default ]; + + incus.container = { + enable = true; + hostname = lib.mkDefault "hello-web"; + }; + system.stateVersion = "25.05"; + + services.hello-web = { + enable = true; + package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default; + host = lib.mkDefault "0.0.0.0"; + port = 8080; # fixed by the incus-pet contract + }; + }; + + formatter = eachSystem (pkgs: pkgs.nixpkgs-fmt); + }; +} diff --git a/webapps/README.md b/webapps/README.md deleted file mode 100644 index d9a95ce..0000000 --- a/webapps/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Hosting webapps on home-server - -Host them on `pureintent` (home-server) - -Run nginx on `gate` (Hetzner VPS). - -Put the two in a Tailscale network. Profit! - -WARNING: This is not cleanly designed yet, so don't use it as a reference. - -NOTE: This model would be made redundant/simplified after https://github.com/tailscale/tailscale/issues/11563 diff --git a/webapps/default.nix b/webapps/default.nix deleted file mode 100644 index 3da8830..0000000 --- a/webapps/default.nix +++ /dev/null @@ -1,12 +0,0 @@ -{ flake, system, ... }: - -# By convention, ports 15000 and above are used for webapps -{ - /* actualism-app = rec { - port = 15001; - domain = "actualism.app"; - package = flake.inputs.actualism-app.packages.${system}.default; - exec = "${package}/bin/actualism-app ${builtins.toString port}"; - }; - */ -} diff --git a/webapps/host.nix b/webapps/host.nix deleted file mode 100644 index d771868..0000000 --- a/webapps/host.nix +++ /dev/null @@ -1,24 +0,0 @@ -# Configuration for the host on which all webapps will run. -{ flake, pkgs, lib, ... }: - -let - webapps = import ./. { inherit flake; system = pkgs.stdenv.hostPlatform.system; }; -in -{ - # Run each web app as a systemd service decided inside a container. - containers = lib.mapAttrs - (name: v: { - autoStart = true; - config = { - systemd.services.${name} = { - description = name; - wantedBy = [ "multi-user.target" ]; - serviceConfig = { - ExecStart = v.exec; - Restart = "always"; - }; - }; - }; - }) - webapps; -} diff --git a/webapps/proxy.nix b/webapps/proxy.nix deleted file mode 100644 index e51f1e8..0000000 --- a/webapps/proxy.nix +++ /dev/null @@ -1,30 +0,0 @@ -# Configuration for the VPS running nginx reverse proxy -{ flake, pkgs, lib, webapps, ... }: - -let - host = "pureintent"; # See host.nix - webapps = import ./. { inherit flake; system = pkgs.stdenv.hostPlatform.system; }; -in -{ - services.tailscale.enable = true; - - services.nginx = { - enable = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - - virtualHosts = lib.mapAttrs' - (name: v: lib.nameValuePair v.domain { - locations."/".proxyPass = "http://${host}:${builtins.toString v.port}"; - enableACME = true; - addSSL = true; - }) - webapps; - }; - - security.acme = { - acceptTerms = true; - defaults.email = "srid@srid.ca"; - }; - networking.firewall.allowedTCPPorts = [ 80 443 22 ]; -}