nixos-config/modules/nixos/linux/incus/container.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

80 lines
3.4 KiB
Nix

# 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
# <listen-ip>:<host-port> to <container>:8080 at deploy time. App
# modules just hardcode `services.<app>.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;
};
}