This commit is contained in:
Sridhar Ratnakumar 2026-07-07 20:43:41 -04:00
commit 4c85b9d8af
45 changed files with 2188 additions and 456 deletions

View file

@ -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
{ };
};
}

View file

@ -16,7 +16,6 @@
"nix-darwin"
# "nixos-hardware"
"nix-index-database"
"nixvim"
];
};
};

View file

@ -19,6 +19,15 @@
# CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING = "1";
CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
};
# Stop Claude from crawling /nix (the store is huge and ripgrep/find
# over it wedges sessions).
permissions.deny = [
"Bash(bfs /nix*)"
"Bash(grep * /nix*)"
"Bash(rg * /nix*)"
"Bash(find /nix*)"
"Bash(fd * /nix*)"
];
};
};
}

View file

@ -17,29 +17,22 @@ in
gnumake
killall
television
yt-dlp
gh
# Broken, https://github.com/NixOS/nixpkgs/issues/299680
# ncdu
# Useful for Nix development
ci
omnix
nixpkgs-fmt
just
watchexec
fswatch
eternal-terminal
lima
# AI
google-cloud-sdk
# Publishing
asciinema
ispell
pandoc
# Dev
fuckport
@ -56,7 +49,8 @@ in
hledger
gnupg
compress-video
# Temporarily disabled: pulls in ffmpeg-full which fails to build (kvazaar test failures)
# compress-video
];
fonts.fontconfig.enable = true;

View file

@ -1,8 +1,15 @@
{
home.stateVersion = "24.05";
home.sessionVariables = {
DO_NOT_TRACK = "1";
# Disabled, see
# https://github.com/anthropics/claude-code/issues/69358#issuecomment-4755677033
# DO_NOT_TRACK = "1";
};
# Home-manager generates an options manpage (`home-configuration.nix(5)`) by
# default, which evaluates the doc string of every HM option. Options are
# searched online, not via `man`, so skip it — measurable eval-time win.
manual.manpages.enable = false;
imports = [
./cli/tmux.nix
./editors/neovim

View file

@ -1,127 +1,11 @@
{ flake, pkgs, ... }:
let
inherit (flake) inputs;
in
{ ... }:
{
imports = [
inputs.nixvim.homeModules.nixvim
];
programs.nixvim = {
programs.neovim = {
enable = true;
defaultEditor = true;
imports = [
./nvim-tree.nix
./lazygit.nix
];
# Theme
colorschemes.rose-pine.enable = true;
# Settings
opts = {
expandtab = true;
shiftwidth = 2;
smartindent = true;
tabstop = 2;
number = true;
clipboard = "unnamedplus";
termguicolors = true;
};
# Keymaps
globals = {
mapleader = " ";
};
extraPlugins = [
pkgs.vimPlugins.outline-nvim
];
plugins = {
# UI
web-devicons.enable = true;
lualine.enable = true;
bufferline.enable = true;
treesitter = {
enable = true;
};
haskell-scope-highlighting.enable = true;
which-key = {
enable = true;
};
noice = {
# WARNING: This is considered experimental feature, but provides nice UX
enable = true;
settings.presets = {
bottom_search = true;
command_palette = true;
long_message_to_split = true;
#inc_rename = false;
#lsp_doc_border = false;
};
};
telescope = {
enable = true;
keymaps = {
"<leader>ff" = {
options.desc = "file finder";
action = "find_files";
};
"<leader>fr" = {
options.desc = "recent files";
action = "oldfiles";
};
"<leader>fg" = {
options.desc = "find via grep";
action = "live_grep";
};
"<leader>T" = {
options.desc = "switch colorscheme";
action = "colorscheme";
};
};
extensions = {
file-browser.enable = true;
ui-select.enable = true;
frecency.enable = true;
fzf-native.enable = true;
};
};
# LSP
# https://github.com/nix-community/nixvim/blob/main/plugins/lsp/default.nix
lsp = {
enable = true;
keymaps = {
lspBuf = {
"gd" = "definition";
"gD" = "references";
"gt" = "type_definition";
"gi" = "implementation";
"K" = "hover";
"<leader>A" = "code_action";
};
diagnostic = {
"<leader>k" = "goto_prev";
"<leader>j" = "goto_next";
};
};
servers = {
hls = {
enable = true;
installGhc = false;
};
marksman.enable = true;
nil_ls.enable = true;
rust_analyzer = {
# enable = true;
installCargo = false;
installRustc = false;
};
};
};
};
vimAlias = true;
viAlias = true;
withRuby = false;
withPython3 = false;
};
}

View file

@ -1,10 +0,0 @@
{
plugins.lazygit.enable = true;
keymaps = [
{
action = "<cmd>LazyGit<CR>";
key = "<leader>gg";
}
];
}

View file

@ -1,13 +0,0 @@
{
plugins.nvim-tree.enable = true;
keymaps = [
{
action = "<cmd>NvimTreeFindFileToggle<CR>";
key = "<leader>tt";
}
{
action = "<cmd>NvimTreeFindFile<CR>";
key = "<leader>tf";
}
];
}

View file

@ -0,0 +1,19 @@
{ flake, pkgs, ... }:
let
inherit (flake) inputs;
in
{
imports = [
inputs.drishti.homeManagerModules.default
];
# Generic enabler. The host list is machine-specific (zest watches the
# tailnet boxes; pureintent watches its pu-managed CI fleet), so each
# consuming config sets `services.drishti.hosts`.
services.drishti = {
enable = true;
package = inputs.drishti.packages.${pkgs.stdenv.hostPlatform.system}.default;
port = 7720;
};
}

View file

@ -15,14 +15,14 @@ in
];
services.emanote = {
enable = true;
enable = false;
notes = [ myVault ];
port = 7001;
package = inputs.emanote.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
services.imako = {
enable = true;
enable = false;
package = inputs.imako.packages.${pkgs.stdenv.hostPlatform.system}.default;
vaultDir = myVault;
port = 7002;

View file

@ -15,7 +15,7 @@ in
programs.jumphost = {
enable = true;
host = "nix-infra@dosa.tail12b27.ts.net";
host = "srid@vanjaram.tail12b27.ts.net";
sshHosts = {
"ssh.bitbucket.juspay.net".user = "git";

View file

@ -0,0 +1,44 @@
# Juspay's opencode *configuration* (not the package), via the home-manager
# module exposed upstream by juspay/AI (homeModules.opencode).
#
# The config points opencode at Juspay's LLM gateway and authenticates with
# JUSPAY_API_KEY, which we source from the agenix-managed secret and export
# into interactive shells below.
{ flake, pkgs, config, ... }:
let
homeMod = flake.inputs.self + /modules/home;
in
{
imports = [
flake.inputs.juspay-ai.homeModules.opencode
"${homeMod}/agenix.nix"
];
programs.opencode-juspay.enable = true;
# YOLO mode: auto-approve all permission prompts in the TUI (opencode has no
# --dangerously-skip-permissions flag for the TUI, only for `run`; see
# https://github.com/anomalyco/opencode/issues/8463). Explicit "deny" rules
# would still win.
programs.opencode-juspay.settings.permission = "allow";
# The upstream module is config-only by design; install the binary from
# llm-agents (numtide/llm-agents.nix) so the Juspay config above is usable
# out of the box.
home.packages = [
flake.inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system}.opencode
];
# The Juspay litellm provider authenticates with JUSPAY_API_KEY at runtime.
# Decrypt the secret via agenix and export it into interactive shells.
age.secrets.juspay-anthropic-api-key.file =
flake.inputs.self + /secrets/juspay-anthropic-api-key.age;
programs.zsh.initContent = ''
export JUSPAY_API_KEY="$(cat "${config.age.secrets.juspay-anthropic-api-key.path}")"
'';
programs.bash.initExtra = ''
export JUSPAY_API_KEY="$(cat "${config.age.secrets.juspay-anthropic-api-key.path}")"
'';
}

View file

@ -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
# <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;
};
}

View file

@ -0,0 +1,197 @@
# 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 `<listen-ip>:<host-port>` 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.<app>.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 `<listen-ip>:<host-port>` on the host into a connection to the
container's `127.0.0.1:8080`:
```
incus config device add <name> web proxy \
listen=tcp:<listen-ip>:<host-port> \
connect=tcp:127.0.0.1:8080
```
`incus-pet deploy` wires this for you idempotently. The `<host-port>`
and `<listen-ip>` are stored in container metadata
(`user.incus-pet.host-port`, `user.incus-pet.listen`) so re-running
`incus-pet deploy <flake-ref>` doesn't need the flags repeated.
### Picking a listen IP
`<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 `<host-port>` 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 `<listen-ip>:<host-port>`.
## 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/`
exposing `nixosModules.default` (essentials + app's
`nixosModules.incus` + operator pubkey overlay) — no nixpkgs pin in
per-deploy state.
2. Launches `images:nixos/25.11` as container `anywhen` with
`-c security.nesting=true`.
3. Pushes your `~/.ssh/id_ed25519.pub` into the container's
`/root/.ssh/authorized_keys` (the image already runs sshd via systemd
socket activation).
4. Builds the system toplevel against `github:nixos/nixpkgs/nixos-25.11`
(stable, matching the image baseline) via `nix build --impure --expr`.
5. Copies the closure with `nix copy --to ssh://root@<container-ip>
--substitute-on-destination`.
6. SSHes to set the system profile and run `switch-to-configuration switch`.
7. Records `--port` and `--listen` in container metadata.
8. 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 <name> 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.
- **`switch-to-configuration` exits 4** — "some units failed to
reload/restart". Treated as success: the activation itself
completed, only a unit reload (typically dbus-broker) failed. The
service is up.
- **`switch-to-configuration` exits 11 / "Could not acquire lock"** —
a previous activation is still running (often hung). `incus exec
<name> -- pkill -9 -f switch-to-configuration` clears it.
## State
| What | Where |
|-------------------------------|-----------------------------------------------------------------------|
| Marshaling flake + lock | `~/.local/state/incus-pet/<name>/{flake.nix, flake.lock}` |
| Operator-chosen host port | `incus config get <name> user.incus-pet.host-port` |
| Operator-chosen listen IP | `incus config get <name> user.incus-pet.listen` |
| Source flake ref | `incus config get <name> user.incus-pet.flake-ref` |
| Service data | Inside the container, per the app's `services.<app>.stateDir` |
The marshaling flake carries no app-specific values — only the
assembly. App config lives entirely in the upstream flake's
`nixosModules.incus`.

View file

@ -0,0 +1,140 @@
---
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 <flake-ref> [<name>] [--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 `<listen-ip>:<host-port>` 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 <flake-ref> [<name>] [--port N] [--listen IP]
```
`--port` is required on the first deploy of a given `<name>` and recorded
in container metadata; subsequent deploys read it back. Report the
`<listen>:<port>` 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.<app>` 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 <app>`.
## Branch C — add the contract to a flake (yours or upstream-via-PR)
Steps:
1. Verify the flake exposes `packages.<system>.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.<app>` 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 `<app>`):
```nix
nixosModules.incus = { config, lib, pkgs, ... }: {
imports = [ self.nixosModules.default ];
incus.container = {
enable = true;
hostname = lib.mkDefault "<app>";
};
system.stateVersion = "25.05";
services.<app> = {
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.<app>` 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:<owner>/<repo> --port <chosen> --listen <ip>
```
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 = "<app>"` (`mkDefault` ok)
- `system.stateVersion = "25.05"`
- `services.<app>.{enable, package, host, port}` (`port = 8080`,
hardcoded — this is the incus-pet convention)
The module must NOT set:
- `services.<app>.port` to anything other than `8080`
- `networking.firewall.allowedTCPPorts``container.nix` opens 8080
## Idempotence + failure modes
- Re-running `incus-pet deploy <flake-ref>` 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.
- Activation is two ssh steps: `nix-env --set` (atomic pointer swap on
`/nix/var/nix/profiles/system`) then `switch-to-configuration switch`.
If the second fails partway, the system profile is already at the new
toplevel; running deploy again finishes the switch.
- Exit 4 from `switch-to-configuration` is treated as success (a unit
failed to reload — typically dbus-broker — but activation itself
completed).

View file

@ -0,0 +1,423 @@
# 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.
#
# Marshaling-flake-as-module design (vs. nixos-rebuild --target-host):
# the per-deploy state-dir flake exposes ONLY `nixosModules.default`
# (combined container essentials + app's nixosModules.incus +
# operator-key overlay). The CLI picks nixpkgs at build time (pinned
# to nixos-25.11, matching the LXC image baseline) and assembles the
# nixosSystem via `nix build --impure --expr`. Closure copy + activation
# happen via `nix copy --to ssh://…` + ssh-driven
# switch-to-configuration. Lets us dodge unstable nixpkgs regressions
# (e.g. the dbus-broker reload stall) without touching the marshaling
# flake's shape.
{ writeShellApplication
, nix
, 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 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"
# Pinned to stable to match the LXC image baseline. Unstable triggers
# a dbus-broker reload stall in switch-to-configuration (~Jul 2026
# systemd/dbus-broker regression).
NIXPKGS_REF="github:nixos/nixpkgs/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() {
# Per-deploy state-dir flake. Exposes ONLY nixosModules.default —
# the CLI assembles the full nixosSystem at build time, picking
# nixpkgs (so we can pin stable to dodge unstable regressions and
# not bake the choice into per-deploy state).
local flake_ref="$1" pubkey="$2"
local dir="$STATE_ROOT/$3" # caller-chosen subdir (probe or real 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.target.url = "$flake_ref";
outputs = { target, ... }: {
nixosModules.default = { ... }: {
imports = [
./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
}
# Boilerplate that assembles the full nixosSystem on demand. Pinned
# nixpkgs (NIXPKGS_REF) is fetched via builtins.getFlake; --impure
# is required because builtins.getFlake on a URL isn't
# pure-evaluable. The marshaling flake is loaded by absolute store
# path so the operator's machine doesn't re-fetch it.
sys_expr() {
local dir="$1"
printf 'let
marshaling = builtins.getFlake "path:%s";
nixpkgs = builtins.getFlake "%s";
in nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ marshaling.nixosModules.default ];
}' "$dir" "$NIXPKGS_REF"
}
eval_config_attr() {
local dir="$1" attr="$2"
nix eval --raw --impure --expr "($(sys_expr "$dir")).config.$attr"
}
build_toplevel() {
local dir="$1"
nix build --print-out-paths --no-link --impure --expr \
"($(sys_expr "$dir")).config.system.build.toplevel"
}
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 (for interactive
# ssh debugging — the deploy path uses `incus exec`, not ssh).
# 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
}
# Mount the host's /nix/store into the container as read-only —
# `shift=true` idmaps host UIDs into the unprivileged container.
# Idempotent; takes effect on next container start. With this in
# place, every container shares the host's /nix/store on disk —
# zero duplication of nixpkgs closures across containers.
ensure_nix_store_mount() {
local name="$1"
if incus config device show "$name" 2>/dev/null | grep -q '^nix-store:'; then
return 0
fi
log "adding shared /nix/store mount on $name (host's store, read-only)"
incus config device add "$name" nix-store disk \
source=/nix/store \
path=/nix/store \
readonly=true \
shift=true
}
# Pin the toplevel against host-side GC. The container's nix DB
# doesn't know about the path (it lives behind the shared mount),
# so without this the host could nix-collect-garbage the running
# system out from under the container. `nix-store --add-root
# --indirect` creates an indirect root: a user-writable symlink
# (in our state dir, no sudo) plus a tracked entry in
# /nix/var/nix/gcroots/auto/. Idempotent; overwrites prior.
register_host_gcroot() {
local name="$1" toplevel="$2"
local link="$STATE_ROOT/$name/system"
mkdir -p "$STATE_ROOT/$name"
nix-store --add-root "$link" --indirect --realise "$toplevel" >/dev/null
}
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 "$flake_ref" "$pubkey" "$tentative_name")
log "locking marshaling flake at $dir"
# `nix flake update` (not `lock`) so every deploy bumps the target
# input to the current tip of its ref (otherwise re-running `deploy
# github:srid/anywhen` would stay pinned at the first commit we
# saw). `--refresh` bypasses nix's fetcher cache so the resolution
# is fresh, not a 5-min-stale entry.
( cd "$dir" && nix flake update --refresh )
if [ -z "$name" ]; then
name=$(eval_config_attr "$dir" "incus.container.hostname")
log "container name from flake: $name"
# Re-emit the marshaling flake under the real name so the
# state-dir matches what `incus-pet list` shows.
rm -rf "$STATE_ROOT/_probe"
dir=$(write_marshaling_flake "$flake_ref" "$pubkey" "$name")
# `nix flake update` (not `lock`) so every deploy bumps the target
# input to the current tip of its ref (otherwise re-running `deploy
# github:srid/anywhen` would stay pinned at the first commit we
# saw). `--refresh` bypasses nix's fetcher cache so the resolution
# is fresh, not a 5-min-stale entry.
( cd "$dir" && nix flake update --refresh )
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 "building toplevel (pinned $NIXPKGS_REF)"
local toplevel
toplevel=$(build_toplevel "$dir")
log "toplevel: $toplevel"
log "pinning host gcroot for $name $toplevel"
register_host_gcroot "$name" "$toplevel"
# Update the container's system profile FIRST (while it can still
# see its own /nix/store), then add the shared-mount device if
# missing, then restart so the new init picks up via the shared
# mount. Order matters: if we added the mount before updating
# the profile, the container's existing /sbin/init -> old toplevel
# would point at a path masked by the mount and the restart would
# fail.
log "setting system profile on $name $toplevel"
incus exec "$name" -- ln -sfn "$toplevel" /nix/var/nix/profiles/system
# The NixOS LXC image hard-links /sbin/init to the image's own
# toplevel; re-pointing it through the profile symlink is what
# makes "update profile + restart" actually pick up the new
# toplevel's init (which sets up /etc, runs activation, then
# execs systemd). NixOS's switch-to-configuration normally does
# this — we have to do it ourselves since we skip s-t-c.
# Idempotent across deploys.
incus exec "$name" -- ln -sfn /nix/var/nix/profiles/system/init /sbin/init
ensure_nix_store_mount "$name"
log "restarting $name to boot via shared /nix/store"
incus restart "$name"
ip=$(container_ipv4 "$name")
log "$name back up at $ip"
# 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
'';
}

View file

@ -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.<sys>.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.

View file

@ -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
}

View file

@ -0,0 +1,115 @@
# hello-web — minimal incus-pet example.
#
# Self-contained reference flake that ships the three outputs incus-pet
# consumes:
#
# - packages.<sys>.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 <path-to-this-flake> --port 8081 --listen <ip>
{
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" ''
<!doctype html>
<meta charset="utf-8">
<title>hello-web</title>
<h1>Hello from incus-pet</h1>
<p>Served by darkhttpd inside an incus container.</p>
'';
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);
};
}

View file

@ -0,0 +1,11 @@
# Loosen kernel hardening so LLM agents can debug without sudo.
#
# NixOS defaults `kernel.dmesg_restrict` to 1, so reading the kernel ring
# buffer (`dmesg`) requires CAP_SYSLOG — i.e. sudo. The kernel log is
# read-only, and handing an agent sudo just to tail it is a far bigger
# hammer than the problem. Setting this to 0 lets unprivileged users (and
# the agents running as them) run `dmesg` directly.
{ ... }:
{
boot.kernel.sysctl."kernel.dmesg_restrict" = 0;
}