diff --git a/.envrc b/.envrc deleted file mode 100644 index 675e756..0000000 --- a/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -watch_file \ - modules/flake-parts/devshell.nix -use flake diff --git a/configurations/home/srid@zest.nix b/configurations/home/srid@zest.nix index 611a27f..c04a238 100644 --- a/configurations/home/srid@zest.nix +++ b/configurations/home/srid@zest.nix @@ -13,9 +13,11 @@ in "${homeMod}/claude-code" "${homeMod}/work/juspay.nix" + "${homeMod}/work/opencode.nix" "${homeMod}/services/obsidian.nix" "${homeMod}/services/kolu.nix" + "${homeMod}/services/drishti" # Remote builders "${homeMod}/nix/buildMachines" @@ -30,8 +32,22 @@ in services.kolu.host = "100.90.229.113"; # Tailscale IP of zest + # The pu-managed kolu-ci-* hosts are reachable only from pureintent, so they + # live in pureintent's drishti (see configurations/nixos/pureintent), not here. + services.drishti.hosts = [ + "localhost" + "sincereintent" + "pureintent" + "naiveintent" + "vanjaram.tail12b27.ts.net" + "nix-infra@rasam.tail12b27.ts.net" + ]; + home.packages = [ inputs.disc-scrape.packages.${pkgs.stdenv.hostPlatform.system}.default pkgs.zellij-one + pkgs.twitter-convert + pkgs.python3 + pkgs.portfwd ]; } diff --git a/configurations/nixos/naiveintent/default.nix b/configurations/nixos/naiveintent/default.nix index 933279b..da59c8d 100644 --- a/configurations/nixos/naiveintent/default.nix +++ b/configurations/nixos/naiveintent/default.nix @@ -6,7 +6,7 @@ let homeMod = self + /modules/home; in { - nixos-unified.sshTarget = "srid@192.168.2.219"; + nixos-unified.sshTarget = "srid@naiveintent"; nixos-unified.localPrivilegeMode = "sudo-nixos-rebuild"; security.sudo.extraRules = [ @@ -24,6 +24,7 @@ in imports = [ self.nixosModules.default ./configuration.nix + (self + /modules/nixos/linux/llm-debugging.nix) ]; users.users.${flake.config.me.username}.linger = true; @@ -31,7 +32,10 @@ in "${homeMod}/gui/1password.nix" "${homeMod}/services/kolu.nix" { - services.kolu.host = "192.168.2.219"; # Tailscale IP of pureintent + services.kolu.host = "100.78.88.70"; # Tailscale IP of naiveintent + # Browser origin differs from the Host kolu sees (served via Tailscale + # MagicDNS reverse proxy), so allow it for the CSWSH origin gate. + services.kolu.allowedOrigins = [ "https://naiveintent.rooster-blues.ts.net" ]; } "${homeMod}/nix/gc.nix" diff --git a/configurations/nixos/pureintent/configuration.nix b/configurations/nixos/pureintent/configuration.nix index 226b022..a6cf847 100644 --- a/configurations/nixos/pureintent/configuration.nix +++ b/configurations/nixos/pureintent/configuration.nix @@ -28,18 +28,29 @@ # Enable networking networking.networkmanager.enable = true; - # Wired (enp1s0) and Wi-Fi (drapeau/wlp2s0) are on the same LAN. Letting both + # Wired (enp1s0) and Wi-Fi (wlp2s0) are on the same LAN. Letting both # autoconnect causes ARP flux: the gateway's neighbor entry for our IP flips # between the two NIC MACs and the Ethernet path goes silently dead. # See docs/LINUX-INTERNET-ISSUES.md. - systemd.services.nm-drapeau-noautoconnect = { - description = "Disable autoconnect on drapeau Wi-Fi profile"; + # + # Disable autoconnect on *every* saved Wi-Fi profile (not a single hardcoded + # SSID) so a newly-joined network can't slip past and bring Wi-Fi up beside + # Ethernet. Wi-Fi stays usable on demand via `nmcli connection up `. + systemd.services.nm-wifi-noautoconnect = { + description = "Disable autoconnect on all Wi-Fi profiles (avoid dual-NIC ARP flux)"; after = [ "NetworkManager.service" ]; wants = [ "NetworkManager.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig.Type = "oneshot"; script = '' - ${pkgs.networkmanager}/bin/nmcli connection modify drapeau connection.autoconnect no || true + nmcli=${pkgs.networkmanager}/bin/nmcli + "$nmcli" -t -f TYPE,NAME connection show | while IFS=: read -r type name; do + if [ "$type" = "802-11-wireless" ]; then + "$nmcli" connection modify "$name" connection.autoconnect no || true + fi + done + # Drop any Wi-Fi link that already came up, so the fix applies without a reboot. + "$nmcli" device disconnect wlp2s0 || true ''; }; @@ -81,12 +92,17 @@ # Allow unfree packages nixpkgs.config.allowUnfree = true; + # The NixOS options manual is rarely consulted locally (options are searched + # online), but generating it evaluates the doc string of every option — a + # measurable chunk of eval time. Package man pages (`man git`) are unaffected. + documentation.nixos.enable = false; + # List packages installed in system profile. To search, run: # $ nix search wget environment.systemPackages = with pkgs; [ git btop - flake.inputs.zmx.packages.${pkgs.stdenv.hostPlatform.system}.default + python3 ]; # Some programs need SUID wrappers, can be configured further or are diff --git a/configurations/nixos/pureintent/default.nix b/configurations/nixos/pureintent/default.nix index ce58010..625fd1f 100644 --- a/configurations/nixos/pureintent/default.nix +++ b/configurations/nixos/pureintent/default.nix @@ -4,6 +4,10 @@ let inherit (flake) inputs; inherit (inputs) self; homeMod = self + /modules/home; + # pu-managed CI fleet (kolu-ci-1 .. kolu-ci-8). These are ssh_config aliases + # provided by `pu` (Include ~/.pu-state/*/ssh_config) and are reachable only + # from pureintent, so drishti monitors them from here. + ciHosts = map (n: "kolu-ci-${toString n}") (lib.range 1 8); in { nixos-unified.sshTarget = "srid@pureintent"; @@ -25,10 +29,16 @@ in imports = [ self.nixosModules.default ./configuration.nix + ./devbox.nix (self + /modules/nixos/linux/beszel.nix) (self + /modules/nixos/linux/incus) + (self + /modules/nixos/linux/llm-debugging.nix) ]; + # anywhen runs as an incus-pet container (see modules/nixos/linux/incus/incus-pet). + # Deployed with: + # incus-pet deploy github:srid/anywhen --port 6111 --listen 100.122.32.106 + # Expose the incus UI on the Tailscale interface only. virtualisation.incus.preseed.config."core.https_address" = "100.122.32.106:8443"; @@ -38,10 +48,17 @@ in "${homeMod}/cli/controlpersist.nix" "${homeMod}/claude-code" "${homeMod}/work/juspay.nix" + "${homeMod}/work/opencode.nix" "${homeMod}/services/vira.nix" "${homeMod}/services/kolu.nix" + "${homeMod}/services/drishti" { services.kolu.host = "100.122.32.106"; # Tailscale IP of pureintent + # Browser origin differs from the Host kolu sees (served via Tailscale + # MagicDNS reverse proxy), so allow it for the CSWSH origin gate. + services.kolu.allowedOrigins = [ "https://pureintent.rooster-blues.ts.net" ]; + # Watch the pu-managed CI fleet from here (only reachable from pureintent). + services.drishti.hosts = [ "localhost" ] ++ ciHosts; } # "${homeMod}/services/dropbox.nix" @@ -82,4 +99,14 @@ in # Workaround the annoying `Failed to start Network Manager Wait Online` error on switch. # https://github.com/NixOS/nixpkgs/issues/180175 systemd.services.NetworkManager-wait-online.enable = false; + + # Workaround `nixos-rebuild switch` hanging at "reloading the following units: + # dbus-broker.service". The reload step stalls (broker has long-lived clients + # holding the bus); skip reload/restart during activation. Bus policy changes + # land on next boot instead. Same applies to the per-user broker + # (`Failed to reload user unit dbus-broker.service` → exit 4). + systemd.services.dbus-broker.reloadIfChanged = lib.mkForce false; + systemd.services.dbus-broker.restartIfChanged = lib.mkForce false; + systemd.user.services.dbus-broker.reloadIfChanged = lib.mkForce false; + systemd.user.services.dbus-broker.restartIfChanged = lib.mkForce false; } diff --git a/configurations/nixos/pureintent/devbox.nix b/configurations/nixos/pureintent/devbox.nix new file mode 100644 index 0000000..15fc458 --- /dev/null +++ b/configurations/nixos/pureintent/devbox.nix @@ -0,0 +1,58 @@ +# Depends on programs.jumphost.socks5Proxy (https://github.com/srid/jumphost-nix) +# for the local SOCKS5 listener. See modules/home/work/juspay.nix:29-31. +{ config, flake, pkgs, ... }: + +let + socksPort = config.home-manager.users.${flake.config.me.username}.programs.jumphost.socks5Proxy.port; + + proxychainsBin = "${config.programs.proxychains.package}/bin/proxychains4"; + + proxyExports = '' + export ALL_PROXY=socks5://127.0.0.1:${toString socksPort} + export HTTPS_PROXY=socks5://127.0.0.1:${toString socksPort} + export HTTP_PROXY=socks5://127.0.0.1:${toString socksPort} + ''; + + vanjaram-run = pkgs.writeShellScriptBin "vanjaram-run" '' + ${proxyExports} + exec ${proxychainsBin} "$@" + ''; + + puBin = "${flake.inputs.project-unknown.packages.${pkgs.stdenv.hostPlatform.system}.default}/bin/pu"; + + pu = pkgs.writeShellScriptBin "pu" '' + ${proxyExports} + exec ${proxychainsBin} ${puBin} "$@" + ''; +in +{ + programs.proxychains = { + enable = true; + quietMode = true; + chain.type = "strict"; + proxyDNS = true; + proxies.devbox = { + enable = true; + type = "socks5"; + host = "127.0.0.1"; + port = socksPort; + }; + }; + + environment.systemPackages = [ + vanjaram-run + pu + ]; + + # pu writes per-instance ssh_config files under ~/.pu-state//. Including + # them lets `ssh ` work directly. The inner ssh those configs spawn goes + # to `pu@` which is only reachable via vanjaram — route any ssh to + # user `pu` through the SOCKS5 proxy. + home-manager.users.${flake.config.me.username}.programs.ssh = { + includes = [ "~/.pu-state/*/ssh_config" ]; + matchBlocks."pu-jumphost" = { + match = "user pu"; + proxyCommand = "${pkgs.netcat-openbsd}/bin/nc -X 5 -x 127.0.0.1:${toString socksPort} %h %p"; + }; + }; +} diff --git a/configurations/nixos/pureintent/mod.just b/configurations/nixos/pureintent/mod.just new file mode 100644 index 0000000..ceacaf5 --- /dev/null +++ b/configurations/nixos/pureintent/mod.just @@ -0,0 +1,55 @@ +# Pureintent-specific operational recipes. +# +# Imported from the top-level Justfile. Each recipe bakes in the values +# that are stable on this host (tailscale IP, container names, port +# choices) so the day-to-day operator just types `just anywhen-deploy`, +# not the full incus-pet invocation. + +LISTEN_IP := "100.122.32.106" # pureintent's tailscale IP + +# ---------------------------------------------------------------------- +# anywhen — github:srid/anywhen as a per-app incus-pet container. +# ---------------------------------------------------------------------- + +# Deploy anywhen (default: master); pass `ref` to deploy a branch/PR. +[group('anywhen')] +anywhen-deploy ref="github:srid/anywhen": + nix run .#incus-pet -- deploy {{ ref }} anywhen --port 6111 --listen {{ LISTEN_IP }} + +# Drop the anywhen container entirely (state goes with it — back up first). +[group('anywhen')] +anywhen-rm: + nix run .#incus-pet -- rm anywhen + +# Pull a WAL-checkpointed snapshot of the live SQLite DB to PATH. +[group('anywhen')] +anywhen-backup path="/tmp/anywhen.db": + incus exec anywhen -- systemctl stop anywhen.service + incus exec anywhen -- sh -c 'cd /var/lib/private/anywhen && sqlite3 anywhen.db "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true' + incus file pull anywhen/var/lib/private/anywhen/anywhen.db {{ path }} + incus exec anywhen -- systemctl start anywhen.service + @echo "backup -> {{ path }}" + +# Push a DB file into the anywhen container (stop/copy/chown/wipe-wal/start). +[group('anywhen')] +anywhen-restore db: + incus exec anywhen -- systemctl stop anywhen.service + incus file push {{ db }} anywhen/var/lib/private/anywhen/anywhen.db --mode 644 + incus exec anywhen -- sh -c 'chown $(stat -c "%u:%g" /var/lib/private/anywhen) /var/lib/private/anywhen/anywhen.db' + incus exec anywhen -- rm -f /var/lib/private/anywhen/anywhen.db-wal /var/lib/private/anywhen/anywhen.db-shm + incus exec anywhen -- systemctl start anywhen.service + @echo "restored {{ db }} -> anywhen.service active" + +# Health check (curl /api/health, in-container service status, full list). +[group('anywhen')] +anywhen-status: + @echo "--- tailnet ---" + @curl -sS http://{{ LISTEN_IP }}:6111/api/health && echo + @echo "--- container ---" + @incus exec anywhen -- systemctl is-active anywhen.service + @nix run .#incus-pet -- list + +# Drop into a root shell inside the anywhen container. +[group('anywhen')] +anywhen-shell: + incus exec anywhen -- bash diff --git a/docs/LINUX-INTERNET-ISSUES.md b/docs/LINUX-INTERNET-ISSUES.md index 96753ae..e349881 100644 --- a/docs/LINUX-INTERNET-ISSUES.md +++ b/docs/LINUX-INTERNET-ISSUES.md @@ -239,6 +239,57 @@ ping -c 2 -W 2 1.1.1.1 curl -4 -sS --connect-timeout 4 --max-time 8 https://github.com >/dev/null ``` +## Variant: MagicDNS with no upstream resolver + +Different outage, similar-looking symptom: `/etc/resolv.conf` again pointed only +at `100.100.100.100`, but this time IP-layer routing was healthy and the cause +was purely DNS. + +Symptoms: + +- `ping 1.1.1.1` works. +- `ping google.com` fails with `Name or service not known`. +- `getent hosts github.com` returns nothing. +- `*.ts.net` lookups still work (split-DNS route is independent). + +Smoking gun in `journalctl -u tailscaled`: + +```text +dns: resolver: forward: no upstream resolvers set, returning SERVFAIL +``` + +And `tailscale dns status` shows: + +```text +Resolvers (in preference order): + (no resolvers configured, system default will be used: see 'System DNS configuration' below) +... + (failed to read system DNS configuration: Access denied: dns-osconfig dump access denied) +``` + +What's happening: Tailscale is managing `/etc/resolv.conf` (`accept-dns=true`) +and MagicDNS handles `*.ts.net` via the split-DNS route, but for every other +query it needs an upstream resolver. If the tailnet admin console has no +**Global nameservers** configured, Tailscale tries to fall back to the device's +system DNS — which on NixOS/tailscale 1.98 fails with the `dns-osconfig` +access-denied error above. Result: SERVFAIL for everything non-tailnet. + +Fix in the admin console at : + +1. Under **Global nameservers**, add an upstream (e.g. Cloudflare `1.1.1.1`). +2. Turn **Override DNS servers ON**. With it off, the global nameserver is only + used when the device's own OS DNS is readable — which on this host it isn't. + +Netmap propagation is near-instant; no daemon restart needed. Verify: + +```bash +ssh pureintent 'tailscale dns status | sed -n "/Resolvers/,/Split/p"' +ssh pureintent 'getent hosts github.com && ping -c2 google.com' +``` + +The "Resolvers (in preference order)" list should now contain the upstream IPs +instead of `(no resolvers configured ...)`. + ## Main lesson When both Ethernet and Wi-Fi are on the same subnet, a stale primary interface can diff --git a/docs/NAIVEINTENT-VS-PUREINTENT.md b/docs/NAIVEINTENT-VS-PUREINTENT.md new file mode 100644 index 0000000..0f67102 --- /dev/null +++ b/docs/NAIVEINTENT-VS-PUREINTENT.md @@ -0,0 +1,79 @@ +# pureintent vs naiveintent — which to use for dev + agentic work + +Both are AMD Zen 4 (Phoenix) 8c/16t boxes, so CPU is nearly a wash. The +real differences are **RAM, SSD, and form factor**. All numbers below +were measured directly on each machine (June 2026). + +## Measured comparison + +| | **pureintent** (Beelink SER8) | **naiveintent** (Lenovo ThinkPad 21ME) | +|---|---|---| +| CPU | Ryzen 7 **8845HS**, 8c/16t | Ryzen 7 PRO **8840HS**, 8c/16t | +| Sustained all-core clock | **4.59 GHz** | 4.27 GHz | +| Single-core throughput | ~4.55 GB/s sha (tie) | ~4.70 GB/s sha (tie) | +| RAM | 32 GB — 20 GB free, **already swapping (5.5 GB)** | **64 GB** — 55 GB free, 0 swap | +| SSD | Crucial P3 Plus (QLC, **DRAM-less**) | KIOXIA KXG8 (premium TLC) | +| Sustained write (4 GB, fdatasync) | **729 MB/s** | **3.1 GB/s** (4.3×) | +| Sequential read (direct) | 5.3 GB/s | 3.2 GB/s (through LUKS) | +| Free disk | 200 GB (**77% full**) | 782 GB (13% full) | +| Encryption | none | LUKS full-disk | +| Form factor | always-on mini PC | laptop (battery, lid, roams) | + +Both run the `powersave` governor with `balance_performance` EPP +(amd-pstate active mode — normal, not a real powersave), so that's not +a differentiator. + +## What it means + +- **CPU: a wash.** Same silicon. pureintent holds ~7% higher *sustained* + all-core clock (mini-PC cooling beats a thin laptop chassis), so large + parallel Nix builds finish marginally faster on it. Single-core / + interactive latency is a tie. Not a deciding factor. +- **RAM strongly favors naiveintent (2×).** This is what bites agentic + work — several Claude Code agents + language servers + builds + a + browser + incus containers eat RAM fast. pureintent already dips into + swap at light load; naiveintent has 55 GB free. +- **Disk strongly favors naiveintent.** 4.3× faster sustained writes and + 6× more free space, on a better drive. Agentic/dev I/O is write-heavy + (nix store, git, node_modules, compile artifacts, logs). pureintent's + QLC + DRAM-less drive at 77% full only degrades further. LUKS on + naiveintent is effectively free (AES-NI). + +## Verdict + +For *doing* heavy interactive + agentic work, **naiveintent wins +clearly** — 2× RAM and 4× write throughput dwarf pureintent's ~7% +multi-core edge. + +The only caveat was form factor: a laptop that sleeps/roams makes a poor +always-on host for a service like Kolu. **naiveintent will be deployed +always-on and plugged in**, so that caveat is moot → **migrate to +naiveintent.** + +## Migration notes + +naiveintent's config is currently near-empty (`kolu` + `gc`); pureintent +carries the workhorse stack. To port the setup from +`configurations/nixos/pureintent/default.nix`: + +- **Kolu** — `services.kolu.host = "naiveintent"` is already set. Revisit + `allowedOrigins` (pureintent allows its Tailscale MagicDNS origin) and + any Tailscale-IP binding. +- **home-manager shared modules** naiveintent lacks: `ssh-agent-forwarding`, + `controlpersist`, `claude-code`, `work/juspay.nix`, `services/vira.nix`, + `services/drishti`. +- **incus** stack (`beszel.nix`, `incus`) + the `core.https_address` + preseed, if you want anywhen/containers and the incus UI. +- **remote builders** (`buildMachines` + `sincereintent.nix`). +- the dbus-broker / NetworkManager-wait-online activation workarounds. + +Won't move cleanly: + +- **drishti** CI-fleet monitoring depends on pureintent-local ssh aliases + (`pu`-managed `ssh_config`, `kolu-ci-1..8`, reachable only from + pureintent). Either keep drishti on pureintent or move the `pu` state. +- Decide whether pureintent is decommissioned or kept as a secondary + builder. + +Regardless of the outcome: **GC pureintent** — at 77% full on a degrading +QLC drive it needs headroom. diff --git a/docs/eval-bench.sh b/docs/eval-bench.sh new file mode 100755 index 0000000..b21f51d --- /dev/null +++ b/docs/eval-bench.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Ralph eval-time benchmark — counter-based. +# Usage: ralph-bench.sh [target] target = pureintent | sincereintent | (both) +# +# Primary metric: deterministic eval-work counters from NIX_SHOW_STATS: +# nrThunks, nrFunctionCalls, nrPrimOpCalls. +# These are byte-identical across runs (independent of CPU clock/load), so a +# single eval gives an exact number. cpuTime is NOT used: this machine thermally +# throttles, inflating CPU-seconds for identical work (11s..21s for one eval). +# Eval cache disabled; fetcher cache warm via persistent $HOME. +set -uo pipefail + +export HOME=/tmp/ralph-evalhome +mkdir -p "$HOME" +FLAKE="${FLAKE:-/home/srid/code/nixos-config/.worktrees/ralph}" +ONLY="${1:-}" + +declare -A ATTR +ATTR[pureintent]="$FLAKE#nixosConfigurations.pureintent.config.system.build.toplevel.drvPath" +ATTR[sincereintent]="$FLAKE#homeConfigurations.\"srid@sincereintent\".activationPackage.drvPath" + +num() { grep -o "\"$1\": [0-9.]*" /tmp/ralph-stats.json | grep -o '[0-9.]*' | head -1; } + +for target in pureintent sincereintent; do + [ -n "$ONLY" ] && [ "$ONLY" != "$target" ] && continue + NIX_SHOW_STATS=1 nix eval --raw "${ATTR[$target]}" --option eval-cache false \ + >/dev/null 2>/tmp/ralph-stats.json + if [ $? -ne 0 ]; then echo "$target FAILED"; tail -8 /tmp/ralph-stats.json; exit 1; fi + printf '%-11s thunks=%-10s calls=%-10s primops=%-9s | values=%s sets=%s\n' \ + "$target" "$(num nrThunks)" "$(num nrFunctionCalls)" "$(num nrPrimOpCalls)" \ + "$(num number)" "$(grep -o '"sets": {[^}]*"number": [0-9]*' /tmp/ralph-stats.json | grep -o '[0-9]*$')" +done diff --git a/docs/eval-time-ralph-report.md b/docs/eval-time-ralph-report.md new file mode 100644 index 0000000..45e2eb6 --- /dev/null +++ b/docs/eval-time-ralph-report.md @@ -0,0 +1,143 @@ +# Nix Eval-Time Optimization (Ralph) + +Iterative measurement-driven reduction of Nix **evaluation** time for the two +configs that get evaluated most often on this machine: + +- `pureintent` — the NixOS system (`nixosConfigurations.pureintent`) +- `sincereintent` — the home-manager config (`homeConfigurations."srid@sincereintent"`) + +## Methodology + +Primary metric: **deterministic eval-work counters** from `NIX_SHOW_STATS=1` — +`nrThunks`, `nrFunctionCalls`, `nrPrimOpCalls`. These count the actual evaluation +work and are **byte-identical across runs** (independent of CPU clock, load, GC). + +> **Why not `cpuTime`/wall?** Initially planned, but rejected after measuring: +> this is a live, thermally-throttling laptop. The *same* eval (identical +> nrThunks every run) reported cpuTime climbing 11s → 21s monotonically as the +> CPU clock dropped under sustained load. Time is meaningless here; counters are +> exact. A 1% drop in nrThunks is a real 1% less work the evaluator must do, on +> any machine. Single eval per target — no median needed. + +```sh +# docs/eval-bench.sh [target] target = pureintent | zest | (both) +# - persistent $HOME=/tmp/ralph-evalhome keeps the fetcher/git cache warm +# - --option eval-cache false forces a full re-eval every run +# - reports nrThunks / nrFunctionCalls / nrPrimOpCalls per target (deterministic) +nix eval --raw .#nixosConfigurations.pureintent.config.system.build.toplevel.drvPath --option eval-cache false +nix eval --raw '.#homeConfigurations."srid@sincereintent".activationPackage.drvPath' --option eval-cache false +``` + +No `nix store gc` between runs (per request). The eval cache is disabled rather +than cleared, so the warm fetcher cache removes input-fetch noise. + +**Commit rule:** commit only if `nrThunks` improves >3% for a target with no +regression on the other. (Counters have ~zero noise, so any real reduction +counts; >3% is the bar for a "meaningful" cycle.) Behaviour may change *only* by +dropping inputs/features first confirmed unused. + +Profiling tool: `nix eval … --eval-profiler flamegraph --eval-profile-file f` +then aggregate self/inclusive frames (collapsed-stack format). + +## Baseline (nrThunks / nrFunctionCalls / nrPrimOpCalls) + +| Target | nrThunks | nrFunctionCalls | nrPrimOpCalls | +|------------|------------|-----------------|---------------| +| pureintent | 20,400,114 | 12,975,268 | 6,507,025 | +| sincereintent | 5,323,428 | 3,227,404 | 1,565,120 | + +Env: nix 2.34.7, x86_64-linux. + +## Optimization log + +(Δ% is on nrThunks vs baseline for the affected target.) + +| Cycle | Change | pureintent thunks | sincereintent thunks | Verdict | +|-------|--------|-------------------|----------------------|---------| +| 0 | baseline | 20,400,114 | 5,323,428 | — | +| 1 | pureintent: `documentation.nixos.enable = false` (drop NixOS options manual; option doc-strings no longer evaluated) | **19,201,996 (−5.9%)** | 5,323,428 | ✅ commit | +| 2 | home: `manual.manpages.enable = false` in shared `modules/home/default.nix` (drop home-manager options manpage; evaluated every HM option's doc) | **17,431,437 (−9.2%)** | **3,551,679 (−33.3%)** | ✅ commit | + +> Note: `modules/home/default.nix` is also fed to every Linux system's *embedded* +> home-manager via `modules/nixos/default.nix`, so cycle 2 reduced pureintent +> too (its HM user's manpages were previously on). + +| 3 | (profiling cycle — no change) investigated nixpkgs double-instantiation, unused inputs, `escapeShellArg` hotspot | 17,431,437 | 3,551,679 | — no behaviour-preserving win found | +| 4 | home: drop heavy `home.packages` from shared `cli/terminal.nix` — `yt-dlp`, `lima`, `omnix`, `pandoc`, `google-cloud-sdk` (kept `hledger`). *Behaviour change, user-approved.* | **16,989,720 (−2.5%)** | **2,757,201 (−22.4%)** | ✅ commit | + +## Dead ends + +_(investigated, no improvement — recorded so we don't retry)_ + +- **nixpkgs is NOT double-instantiated.** `nixos-unified.lib.mkLinuxSystem` calls + `nixpkgs.lib.nixosSystem` without pinning `nixpkgs.pkgs`, but the NixOS + `nixpkgs` module imports the package set exactly once; the `pkgs/top-level/impure.nix` + frame in the profile is that single import. `home-manager.useGlobalPkgs = true` + (set by nixos-unified) already shares it with the embedded HM. No win available. +- **Dropping unused flake inputs does not reduce eval counters.** Nix input + evaluation is lazy: an input not referenced during a config's eval (e.g. + `nixos-hardware`, `git-hooks` — 0 refs) contributes ~0 thunks to that config. + Pruning them shrinks `flake.lock`/`nix flake` overhead only, not `nrThunks`. +- **`escapeShellArg` (~72% inclusive on pureintent) is not a fixable hotspot.** + It is driven by `home-manager` file-linking (`files.nix:442`, ~43% — one + escaped command per managed dotfile) and NixOS `/etc` generation + (`etc.nix:58`). Both scale with how much is managed; nixpkgs/HM own the + implementation. Not patchable from this repo. +- **`programs.command-not-found` already disabled** by the `nix-index-database` + module; nothing to gain. + +## Remaining cost is feature-bound + +After the two documentation wins, the dominant `derivationStrict` targets in the +pureintent eval profile are all *wanted* features (samples ≈ inclusive share): +`home-manager-generation`/`home-manager-files` (~43%, embedded HM), `/etc` +(~29%), `vira.service`+`vira-wrapped`+`vira-0.1.0.0` (Haskell app), `kolu.service`, +`pipewire`, plus heavy packages in `home.packages` (`google-cloud-sdk`, `pandoc`, +`omnix` — Haskell). Further reductions require trading a feature, not a free +structural change. + +## Final measurement (median irrelevant — counters are exact) + +| Target | nrThunks (base → final) | Δ | nrFunctionCalls | nrPrimOpCalls | +|--------|-------------------------|------|-----------------|---------------| +| pureintent | 20,400,114 → **16,989,720** | **−16.7%** | 12,975,268 → 10,794,555 (−16.8%) | 6,507,025 → 5,327,976 (−18.1%) | +| sincereintent | 5,323,428 → **2,757,201** | **−48.2%** | 3,227,404 → 1,631,213 (−49.5%) | 1,565,120 → 771,171 (−50.7%) | + +Both configs still instantiate cleanly (`nix build --dry-run` on the system +toplevel / home activationPackage succeeds). + +## Key findings + +1. **On a thermally-throttling machine, time is a lie — count work instead.** + The same eval reported cpuTime from 11s to 21s as the CPU clock sagged, while + `nrThunks` was byte-identical every run. Eval-work counters (`nrThunks`, + `nrFunctionCalls`, `nrPrimOpCalls`) are deterministic, zero-noise, and + machine-independent — a far better optimization metric than wall/cpu time. +2. **Generated option documentation is the biggest free win.** The NixOS options + manual (`documentation.nixos.enable`) and the home-manager options manpage + (`manual.manpages.enable`) each evaluate the doc string of *every* option. + Disabling both: pureintent −14.6%, sincereintent −33% — with zero behaviour + cost (options are searched online). +3. **A shared module multiplies.** `manual.manpages.enable = false` in + `modules/home/default.nix` helped both the standalone home config *and* every + Linux system's embedded home-manager (via `modules/nixos/default.nix`). +4. **Eval is lazy — unused inputs are already free.** Pruning unreferenced flake + inputs does not move the counters; it only trims `flake.lock`. +5. **The remainder is feature-bound.** After the doc wins, eval cost is dominated + by embedded home-manager file-linking, `/etc` generation, and the closures of + wanted services/packages. Trimming 5 heavy `home.packages` (user-approved) took + sincereintent to −48% total; the rest are features worth their eval cost. + +## Cost breakdown (per-package eval cost, standalone `pkgs.

.drvPath` thunks) + +Shared base floor ≈ 1.09M thunks; the excess is each package's own closure-eval: + +| Package | thunks | excess | removed? | +|---------|--------|--------|----------| +| yt-dlp | 2.15M | +1.05M | ✅ | +| lima | 1.78M | +0.69M | ✅ | +| omnix | 1.76M | +0.67M | ✅ | +| pandoc | 1.46M | +0.37M | ✅ | +| google-cloud-sdk | 1.39M | +0.30M | ✅ | +| hledger | 1.35M | +0.26M | kept (wanted) | +| ripgrep / fd / just / television | ~1.09M | ~0 | kept (at floor) | diff --git a/docs/pureintent-eval-ralph-report.md b/docs/pureintent-eval-ralph-report.md new file mode 100644 index 0000000..c3b0f0f --- /dev/null +++ b/docs/pureintent-eval-ralph-report.md @@ -0,0 +1,258 @@ +# Ralph: pureintent eval-time optimization + +Iterative measurement-driven shrinking of `nixosConfigurations.pureintent` +evaluation time, following the [Ralph +skill](https://github.com/srid/agency/blob/master/.apm/skills/ralph/SKILL.md). + +## Methodology + +- **Host**: `srid-nc` (NixOS, x86_64-linux, Nix 2.31.5) +- **Repo**: cloned at `~/ralph/nixos-config`, branch `optimize-eval` +- **Command**: + ``` + /usr/bin/env time -f "%e" \ + nixos-rebuild dry-build --flake .#pureintent \ + --option eval-cache false + ``` + - `dry-build` so no derivations are actually realised. + - `--option eval-cache false` because every meaningful source change + invalidates the flake eval-cache — measuring with a hot eval-cache + just measures sqlite lookups (~0.5 s) and tells us nothing about the + work this PR is meant to cut. + - Filesystem / Nix-store cache is warm (we ran the build once to + populate everything). +- **Baseline policy**: 5–7 consecutive runs, report **median**, range, + and `NIX_SHOW_STATS` counters (`nrFunctionCalls`, `nrThunks`, + `totalBytes`, etc.). +- **Noise floor**: ≈0.5 % (range was 0.06 s on a 10.87 s run). Commit + threshold per Ralph rules: **> 3 %**. +- **Constraints**: (1) other NixOS / home configs must still build, + (2) pureintent runtime behaviour preserved (drv hash may shift), + (3) reducing flake inputs is in scope (npins-style relocation OK). + +## Baseline + +7 warm runs, eval-cache disabled, `nixos-rebuild dry-build .#pureintent`: + +| run | seconds | +|----:|--------:| +| 1 | 10.87 | +| 2 | 10.87 | +| 3 | 10.86 | +| 4 | 10.88 | +| 5 | 10.86 | +| 6 | 10.85 | +| 7 | 10.91 | + +**Median: 10.87 s** · range 0.06 s (≈ 0.5 %). + +`NIX_SHOW_STATS` for one baseline eval: + +| counter | value | +|---|---:| +| CPU time | 16.29 s | +| GC fraction | 3.3 % | +| `nrFunctionCalls` | 25 043 557 | +| `nrPrimOpCalls` | 12 110 404 | +| `nrThunks` | 38 038 010 | +| `nrAvoided` | 32 199 960 | +| `nrLookups` | 16 259 247 | +| `nrOpUpdates` | 2 469 935 | +| `nrOpUpdateValuesCopied` | 85 701 830 | +| `values.number` | 57 025 753 | +| `values.bytes` | 912 412 048 | +| `sets.elements` | 120 709 352 | +| `sets.bytes` | 2 059 750 432 | +| `totalBytes` | 4 139 856 480 | +| `maxRss` | 3 120 MB | + +## Flake input inventory + +Inputs declared in `flake.nix`: + +`agenix`, `disc-scrape`, `disko`, `emanote`, `flake-parts`, `git-hooks`, +`github-nix-ci`, `home-manager`, `imako`, `jumphost-nix`, `kolu`, +`landrun-nix`, `llm-agents`, `nix-darwin`, `nix-index-database`, +`nixos-hardware`, `nixos-unified`, `nixos-vscode-server`, `nixpkgs`, +`nixvim`, `project-unknown`, `vira`, `zmx`. + +Per-input blame from Phase-1 probes (drop the module that imports the +input; everything else held constant; 3 runs each, eval-cache off): + +| input / module | path | wall when dropped | Δ saved | share | +|---|---|---:|---:|---:| +| baseline | — | 10.87 | — | 100 % | +| `nixvim` | `modules/home/editors/neovim/` | 6.90 | **3.97** | **36.5 %** | +| `vira` | `modules/home/services/vira.nix` | 9.59 | 1.28 | 11.8 % | +| `jumphost-nix` | `modules/home/work/juspay.nix` | 8.16 (vira out, jumphost-nix import removed) | 1.43 | 13.1 % | +| `kolu` | `modules/home/services/kolu.nix` | 10.37 | 0.50 | 4.6 % | +| `agenix` (HM module) | `modules/home/agenix.nix` | ≈ 8.07 | ≈ 0 | < noise | +| `agenix` (NixOS module) | `modules/nixos/common.nix:agenix` | 10.82 | ≈ 0 | < noise | +| `programs.jumphost.*` body | `modules/home/work/juspay.nix` | 9.51 → 9.56 | 0.05 | < noise | +| `claude-code` | `modules/home/claude-code` | 11.01 | ≈ 0 | < noise | +| `buildMachines` | `modules/home/nix/buildMachines*` | 10.89 | ≈ 0 | < noise | +| `incus`, `beszel`, `firefox`, `ttyd` | various | ≈ 10.85–10.92 | ≈ 0 | < noise | + +Compound floors: + +| drop | wall (s) | reduction | +|---|---:|---:| +| baseline | 10.87 | — | +| nixvim + vira + juspay + agenix (HM+NixOS) | 4.29 | −60.5 % | +| above + kolu | 3.85 | −64.6 % | + +So the absolute lower bound (with these four heavies stubbed out) is +≈ 3.85 s — that's the cost of nixpkgs + home-manager + nixos-unified + +everything else combined. + +The `home-manager.sharedModules` plumbing block in +`modules/nixos/common.nix` was probed independently: removing it left +the wall time unchanged (10.95 s ≈ 10.87 s), so the per-host HM-setup +boilerplate itself isn't a contributor. + +## Optimization log + +| # | hypothesis | mutation | wall (s) | Δ vs baseline | committed? | notes | +|---|---|---|---|---|---|---| +| 0 | — | baseline | 10.87 | — | — | reference | +| 1 | nixvim is 36 % of eval; the cost is the option system, not the resulting nvim binary | delete `inputs.nixvim` + the nixvim module; replace `modules/home/editors/neovim/` with a minimal `programs.neovim { enable; defaultEditor; vimAlias; viAlias; }` | **6.98** | **−3.89 s (−35.8 %)** | ✅ | 7-run median; range 7.73–6.94 (first-run warm-up jitter). Other configs (`naiveintent`, `infinitude-macos`, `srid@zest`) still eval cleanly. **Behaviour note:** nvim is now plain — none of the previous plugins (rose-pine, telescope, treesitter, lualine, noice, LSP keymaps, nvim-tree, lazygit, outline-nvim, mapleader) survive. User explicitly approved dropping nixvim. | +| 2a | jumphost-nix module is ~1.5 s | inline `${jumphost-nix}/module.nix` into `juspay.nix` (drop input, hard-code values, drop devbox.nix's port-option read) | 7.03 | +0.05 (noise) | ❌ | The shallow drilldown was misleading (eval-error truncation). The cost moves; it doesn't disappear. Reverted. | +| 2b | vira HM module is ~1.3 s | inline `inputs.vira.homeManagerModules.vira` into `vira.nix` as direct `systemd.user.services.vira` + `age.secrets` | 6.94 | -0.04 (noise) | ❌ | Same explanation as 2a. Reverted. | +| 2c | unused flake inputs accumulate cost | drop `llm-agents` (literally only commented-out reference) | 7.01 | +0.02 (noise) | ❌ | Lazy inputs don't contribute. Reverted. | +| 2d | flake-parts modules add overhead | drop `claude-sandboxed.nix`, `devshell.nix`, `landrun-nix` | 6.91–6.99 | ≤0.06 (noise) | ❌ | Reverted. | +| 2e | `programs.ssh.matchBlocks` is the systemd-style cost driver | strip `controlpersist.nix` (3 matchBlocks) | 7.00 | +0.01 (noise) | ❌ | matchBlocks aren't the contributor either; the cost is the **per-entry merge** that pureintent has anyway via devbox.nix's `pu-jumphost` etc. Reverted. | +| 2f | nixos-unified's `autoWire` is overhead | replace with a hand-rolled `flake.nixosConfigurations.pureintent = mkLinuxSystem …` | ERR | — | ❌ | Pureintent itself reaches into `self.nixosModules.default` (created by autoWire), so a useful comparison needs reproducing the auto-wired attrsets first — out of scope. | + +## Final measurement + +7 warm runs, eval-cache disabled, `nixos-rebuild dry-build .#pureintent`, +on `optimize-eval` HEAD (cycle 1 applied): + +| run | seconds | +|----:|--------:| +| 1 | 7.00 | +| 2 | 7.01 | +| 3 | 6.94 | +| 4 | 6.99 | +| 5 | 6.97 | +| 6 | 7.01 | +| 7 | 6.94 | + +**Median: 6.99 s** · range 6.94–7.01 (≈ 1 %). + +| | wall (s) | Δ | +|---|---:|---:| +| baseline | 10.87 | — | +| after cycle 1 | **6.99** | **−3.88 s (−35.7 %)** | + +Three other configurations continue to evaluate cleanly: +`nixosConfigurations.naiveintent`, +`darwinConfigurations.infinitude-macos`, +`homeConfigurations."srid@zest"`. + +## Dead ends + +All of the following were tried after cycle 1 and produced **no +measurable improvement** (≤ noise floor). Reported here so they don't +have to be re-tried. + +- **Inline `${jumphost-nix}/module.nix`** into `juspay.nix`. Replaced the + 152-line work-jump-host module with an equivalent inline + `programs.ssh.matchBlocks` / `systemd.user.services` / + `programs.git.includes`. Result: 7.03 s — within noise. Earlier + drilldown numbers that suggested ~1.5 s of jumphost-nix cost were + *eval-error truncation* (devbox.nix references + `programs.jumphost.socks5Proxy.port` and aborted eval before the rest + of pureintent was processed). The real cost is in the resulting + submodule materialisation (programs.ssh.matchBlocks, etc.), which is + the same whether the values arrive via a wrapper module or a literal. + +- **Inline `inputs.vira.homeManagerModules.vira`**. 167-line option + schema + submodule replaced with a hand-rolled + `systemd.user.services.vira` and `age.secrets` block. + Result: 6.94 s — within noise. Same explanation as above. + +- **Inline `inputs.kolu.homeManagerModules.default`**. Similar pattern; + eval errored when `services.kolu.host` setter outlived the option + declaration. With downstream-fix, expected zero benefit by the same + argument. + +- **Prune `inputs.llm-agents`** (only reference was already commented + out). Result: 7.01 s — within noise. + +- **Drop `modules/flake-parts/claude-sandboxed.nix`** (landrun-nix + consumer at the flake-parts level). Result: 6.91 s — within noise. + +- **Drop `modules/flake-parts/devshell.nix`** at flake-parts level. + Result: 6.99 s — within noise. + +- **Drop `landrun-nix` input + `claude-sandboxed.nix`**. Result: + 6.97 s — within noise. + +- **Strip `modules/home/cli/controlpersist.nix`** entirely (3 + `programs.ssh.matchBlocks` entries). Result: 7.00 s — within noise. + +- **Replace `nixos-unified.flakeModules.autoWire`** with a manual + `flake.nixosConfigurations.pureintent = mkLinuxSystem …` — errors, + because pureintent's own `default.nix` reaches into + `self.nixosModules.default` which autoWire is responsible for + creating. Measuring nixos-unified's residual overhead would require + reproducing the auto-wired module attrsets by hand; not worth the + effort given everything else has plateaued. + +## Why we plateau at ~7 s + +After cycle 1, the post-mortem profile (each "drop X" probe is run with +all *downstream consumers stubbed out* so the measurement isn't +eval-error-truncated): + +| component | wall when dropped | Δ saved | +|---|---:|---:| +| (post-cycle-1 baseline) | 6.99 | — | +| `vira` HM module | ≈ 5.71 | 1.28 | +| `kolu` HM module | ≈ 6.49 | 0.50 | +| jumphost-nix module + body | (cannot measure cleanly; ~1.3 cost is real but inlining gives it back) | — | +| home CLI modules (tmux, starship, terminal, git, direnv, just, npm, nix-index-database, ttyd) | ≈ 0 each, ≈ 0.4 s cumulative | — | +| `agenix` (NixOS + HM) | ≈ 0 | — | +| `claude-code`, `buildMachines`, `incus`, `beszel`, `firefox`, `pipewire` | ≈ 0 each | — | + +The remaining ≈ 4 s is the cost of evaluating nixpkgs `lib`, the NixOS +module system, home-manager's option universe, and `nixos-unified`'s +auto-wiring — together they are the irreducible floor for any host that +uses this flake. + +The HM modules that *are* expensive (vira, kolu, jumphost-nix) cost what +they cost because their values land in +`systemd.user.services.` or `programs.ssh.matchBlocks.`, +each of which forces a per-entry home-manager submodule. Inlining the +wrapper module doesn't help because the merge is the same on either +side. The only way to reclaim that time is to (a) drop the service / +the matchBlock entirely, or (b) write the unit / ssh_config file +directly via `home.file` / `xdg.configFile` and bypass home-manager's +own ssh and systemd modules across the whole user — a much larger +refactor than fit inside this PR's scope. + +## Key findings + +1. **nixvim is 36 % of pureintent's eval time.** Its + home-manager-style options module materialises hundreds of plugin + submodules every eval. Cycle 1 dropped it (user-approved behaviour + change to plain `programs.neovim`) and reclaimed 3.88 s. +2. **HM submodule materialisation, not option-declaration count, is the + driver.** Inlining wrapper modules (jumphost-nix, vira) gives the + work-saving illusion in shallow probes but no real saving because + `systemd.user.services` and `programs.ssh.matchBlocks` re-do the + same submodule work regardless of where their values came from. +3. **Unused flake inputs cost essentially nothing** at pureintent + eval-time; flake inputs are lazy and only inputs reached + transitively from `nixosConfigurations.pureintent` contribute. +4. **Eval-error-truncated probes look like wins.** Any "drop X" probe + where a downstream module reads X's option silently shortens the + eval and lies about its cost. Always validate probes succeed (we + started checking exit codes after the jumphost-nix dead end). + +## Methodology cost + +- 1 successful commit + push (cycle 1). +- ≈ 50 probe runs on `srid-nc` over the cycle (3-run medians of dry-build). +- 1 draft PR open: . diff --git a/flake.lock b/flake.lock index 10732f6..7ba4f67 100644 --- a/flake.lock +++ b/flake.lock @@ -55,11 +55,11 @@ ] }, "locked": { - "lastModified": 1771437256, - "narHash": "sha256-bLqwib+rtyBRRVBWhMuBXPCL/OThfokA+j6+uH7jDGU=", + "lastModified": 1776249299, + "narHash": "sha256-Dt9t1TGRmJFc0xVYhttNBD6QsAgHOHCArqGa0AyjrJY=", "owner": "numtide", "repo": "blueprint", - "rev": "06ee7190dc2620ea98af9eb225aa9627b68b0e33", + "rev": "56131e8628f173d24a27f6d27c0215eff57e40dd", "type": "github" }, "original": { @@ -69,12 +69,33 @@ } }, "bun2nix": { + "inputs": { + "flake-parts": "flake-parts_2", + "nixpkgs": "nixpkgs_2", + "systems": "systems_3", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1779931262, + "narHash": "sha256-8ypPqVPCxiLD6GAvGjA+cJ3REJzgMJDGkGJLZYuCfGo=", + "owner": "juspay", + "repo": "bun2nix", + "rev": "ce67cc1885e5e28fcc3ff865479be1a19ff396cb", + "type": "github" + }, + "original": { + "owner": "juspay", + "ref": "rawflake", + "repo": "bun2nix", + "type": "github" + } + }, + "bun2nix_2": { "inputs": { "flake-parts": [ "llm-agents", "flake-parts" ], - "import-tree": "import-tree", "nixpkgs": [ "llm-agents", "nixpkgs" @@ -89,11 +110,11 @@ ] }, "locked": { - "lastModified": 1770895533, - "narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=", + "lastModified": 1778446047, + "narHash": "sha256-oQvcadh2BCkrog+SGrG6YffKJrveYpjj3TdQJWaKhaM=", "owner": "nix-community", "repo": "bun2nix", - "rev": "c843f477b15f51151f8c6bcc886954699440a6e1", + "rev": "f2bc12af1a6369648aac41041ceeaa0b866599c6", "type": "github" }, "original": { @@ -257,6 +278,24 @@ "type": "github" } }, + "drishti": { + "inputs": { + "bun2nix": "bun2nix" + }, + "locked": { + "lastModified": 1782927932, + "narHash": "sha256-OjOH2WguMohk/NAYdnS/rj0Ag+0G/k9XW5HsvcmErUE=", + "owner": "srid", + "repo": "drishti", + "rev": "f692afb245a00a28f5b8bac2fa2462a58db9388a", + "type": "github" + }, + "original": { + "owner": "srid", + "repo": "drishti", + "type": "github" + } + }, "ema": { "flake": false, "locked": { @@ -280,14 +319,14 @@ "commonmark-wikilink": "commonmark-wikilink", "ema": "ema", "emanote-template": "emanote-template", - "flake-parts": "flake-parts_2", + "flake-parts": "flake-parts_3", "fourmolu-nix": "fourmolu-nix", "git-hooks": "git-hooks_2", "haskell-flake": "haskell-flake", "heist-extra": "heist-extra", "lvar": "lvar", "nixos-unified": "nixos-unified", - "nixpkgs": "nixpkgs_2", + "nixpkgs": "nixpkgs_3", "unionmount": "unionmount" }, "locked": { @@ -342,6 +381,28 @@ } }, "flake-parts_2": { + "inputs": { + "nixpkgs-lib": [ + "drishti", + "bun2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777988971, + "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-parts_3": { "inputs": { "nixpkgs-lib": [ "emanote", @@ -362,7 +423,7 @@ "type": "github" } }, - "flake-parts_3": { + "flake-parts_4": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" }, @@ -380,7 +441,7 @@ "type": "github" } }, - "flake-parts_4": { + "flake-parts_5": { "inputs": { "nixpkgs-lib": [ "imako", @@ -401,7 +462,7 @@ "type": "github" } }, - "flake-parts_5": { + "flake-parts_6": { "inputs": { "nixpkgs-lib": [ "llm-agents", @@ -409,32 +470,11 @@ ] }, "locked": { - "lastModified": 1775087534, - "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "flake-parts_6": { - "inputs": { - "nixpkgs-lib": [ - "nixvim", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1777988971, - "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "type": "github" }, "original": { @@ -464,24 +504,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems_5" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "fourmolu-nix": { "locked": { "lastModified": 1707266073, @@ -758,12 +780,12 @@ "aeson-typescript": "aeson-typescript", "commonmark-simple": "commonmark-simple_2", "commonmark-wikilink": "commonmark-wikilink_2", - "flake-parts": "flake-parts_4", + "flake-parts": "flake-parts_5", "fourmolu-nix": "fourmolu-nix_2", "git-hooks": "git-hooks_4", "haskell-flake": "haskell-flake_2", "lvar": "lvar_2", - "nixpkgs": "nixpkgs_3", + "nixpkgs": "nixpkgs_4", "process-compose-flake": "process-compose-flake", "unionmount": "unionmount_2" }, @@ -781,21 +803,6 @@ "type": "github" } }, - "import-tree": { - "locked": { - "lastModified": 1763762820, - "narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=", - "owner": "vic", - "repo": "import-tree", - "rev": "3c23749d8013ec6daa1d7255057590e9ca726646", - "type": "github" - }, - "original": { - "owner": "vic", - "repo": "import-tree", - "type": "github" - } - }, "jumphost-nix": { "flake": false, "locked": { @@ -812,18 +819,41 @@ "type": "github" } }, - "kolu": { + "juspay-ai": { + "inputs": { + "llm-agents": [ + "llm-agents" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, "locked": { - "lastModified": 1778618379, - "narHash": "sha256-V870SzI1lvwZuhqroDDB5p2fja2OmaIl0mzlLGt1o8g=", + "lastModified": 1782075422, + "narHash": "sha256-VVgVpxWFruch4X0PTfesyXVeppsSyBQFja+QIAuCQLo=", "owner": "juspay", - "repo": "kolu", - "rev": "5c86cbd4567f27fb81762a0399b48d344fb37a90", + "repo": "AI", + "rev": "c24b8d17ee80a8d03ef589eec0e68cd157e4ae9b", "type": "github" }, "original": { "owner": "juspay", - "ref": "feat/minimap-hide-parked-toggle", + "repo": "AI", + "type": "github" + } + }, + "kolu": { + "locked": { + "lastModified": 1783019894, + "narHash": "sha256-0SsUGZl+0tYSDVgdNFX80fDGzZEcEr6dfDYnC6tzXqU=", + "owner": "juspay", + "repo": "kolu", + "rev": "da15c87a740b9cc512438131c968c21bfb0e8839", + "type": "github" + }, + "original": { + "owner": "juspay", + "ref": "W1", "repo": "kolu", "type": "github" } @@ -847,26 +877,23 @@ "llm-agents": { "inputs": { "blueprint": "blueprint", - "bun2nix": "bun2nix", - "flake-parts": "flake-parts_5", - "nixpkgs": [ - "nixpkgs" - ], - "systems": "systems_3", - "treefmt-nix": "treefmt-nix" + "bun2nix": "bun2nix_2", + "flake-parts": "flake-parts_6", + "nixpkgs": "nixpkgs_5", + "systems": "systems_4", + "treefmt-nix": "treefmt-nix_2" }, "locked": { - "lastModified": 1775790657, - "narHash": "sha256-kAJGGBOI+2DFJSkN3RH1Qk9uUSFqMfp6cK0+eORs+OA=", + "lastModified": 1782068377, + "narHash": "sha256-pKjKMGfTGlL+qOfDnV6h1XNXiS3/NXLAM2dnnmLxjsQ=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "d9583b68fdc553936b35dc6ca206d8d8dd552e5b", + "rev": "3bf60a513d1ebab0b46aee641b2323394905d543", "type": "github" }, "original": { "owner": "numtide", "repo": "llm-agents.nix", - "rev": "d9583b68fdc553936b35dc6ca206d8d8dd552e5b", "type": "github" } }, @@ -1083,6 +1110,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1752900028, "narHash": "sha256-dPALCtmik9Wr14MGqVXm+OQcv7vhPBXcWNIOThGnB/Q=", @@ -1098,7 +1141,7 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_4": { "locked": { "lastModified": 1770169770, "narHash": "sha256-awR8qIwJxJJiOmcEGgP2KUqYmHG4v/z8XpL9z8FnT1A=", @@ -1114,7 +1157,23 @@ "type": "github" } }, - "nixpkgs_4": { + "nixpkgs_5": { + "locked": { + "lastModified": 1781607440, + "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { "locked": { "lastModified": 1778443072, "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", @@ -1130,7 +1189,7 @@ "type": "github" } }, - "nixpkgs_5": { + "nixpkgs_7": { "locked": { "lastModified": 1765803225, "narHash": "sha256-xwaZV/UgJ04+ixbZZfoDE8IsOWjtvQZICh9aamzPnrg=", @@ -1146,43 +1205,6 @@ "type": "github" } }, - "nixpkgs_6": { - "locked": { - "lastModified": 1764635402, - "narHash": "sha256-6rYcajRLe2C5ZYnV1HYskJl+QAkhvseWTzbdQiTN9OI=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "5f53b0d46d320352684242d000b36dcfbbf7b0bc", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixvim": { - "inputs": { - "flake-parts": "flake-parts_6", - "nixpkgs": [ - "nixpkgs" - ], - "systems": "systems_4" - }, - "locked": { - "lastModified": 1778510615, - "narHash": "sha256-cMNCx8mQTJnVkA6kt3B3ArGpCOOniYn644hH0mJHSsw=", - "owner": "nix-community", - "repo": "nixvim", - "rev": "fa8cd368d27cf9541f086485884928315abdcc8c", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "nixvim", - "type": "github" - } - }, "process-compose-flake": { "locked": { "lastModified": 1767863885, @@ -1213,6 +1235,26 @@ "type": "github" } }, + "project-unknown": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779830167, + "narHash": "sha256-j8WvZUrFIXajfk0O8jk4PnwzibgYIfrSwShy7rcnSwI=", + "owner": "juspay", + "repo": "project-unknown", + "rev": "6c54c2a6e4bdc2199010a5a1bd83c89cc67e0ea3", + "type": "github" + }, + "original": { + "owner": "juspay", + "repo": "project-unknown", + "type": "github" + } + }, "record-hasfield": { "flake": false, "locked": { @@ -1234,13 +1276,15 @@ "agenix": "agenix", "disc-scrape": "disc-scrape", "disko": "disko", + "drishti": "drishti", "emanote": "emanote", - "flake-parts": "flake-parts_3", + "flake-parts": "flake-parts_4", "git-hooks": "git-hooks_3", "github-nix-ci": "github-nix-ci", "home-manager": "home-manager", "imako": "imako", "jumphost-nix": "jumphost-nix", + "juspay-ai": "juspay-ai", "kolu": "kolu", "landrun-nix": "landrun-nix", "llm-agents": "llm-agents", @@ -1249,10 +1293,9 @@ "nixos-hardware": "nixos-hardware", "nixos-unified": "nixos-unified_2", "nixos-vscode-server": "nixos-vscode-server", - "nixpkgs": "nixpkgs_4", - "nixvim": "nixvim", - "vira": "vira", - "zmx": "zmx" + "nixpkgs": "nixpkgs_6", + "project-unknown": "project-unknown", + "vira": "vira" } }, "rust-flake": { @@ -1376,21 +1419,6 @@ "type": "github" } }, - "systems_5": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, "tabler-icons-hs": { "flake": false, "locked": { @@ -1410,7 +1438,8 @@ "treefmt-nix": { "inputs": { "nixpkgs": [ - "llm-agents", + "drishti", + "bun2nix", "nixpkgs" ] }, @@ -1428,6 +1457,27 @@ "type": "github" } }, + "treefmt-nix_2": { + "inputs": { + "nixpkgs": [ + "llm-agents", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + }, "unionmount": { "flake": false, "locked": { @@ -1475,7 +1525,7 @@ "nix-serve-ng": "nix-serve-ng", "nix-systems": "nix-systems", "nixos-unified": "nixos-unified_3", - "nixpkgs": "nixpkgs_5", + "nixpkgs": "nixpkgs_7", "process-compose-flake": "process-compose-flake_2", "record-hasfield": "record-hasfield", "servant-event-stream": "servant-event-stream", @@ -1511,43 +1561,6 @@ "repo": "warp-tls-simple", "type": "github" } - }, - "zig2nix": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_6" - }, - "locked": { - "lastModified": 1764678235, - "narHash": "sha256-NNQWR3DAufaH7fs6ZplfAv1xPHEc0Ne3Z0v4MNHCqSw=", - "owner": "Cloudef", - "repo": "zig2nix", - "rev": "8b6ec85bccdf6b91ded19e9ef671205937e271e6", - "type": "github" - }, - "original": { - "owner": "Cloudef", - "repo": "zig2nix", - "type": "github" - } - }, - "zmx": { - "inputs": { - "zig2nix": "zig2nix" - }, - "locked": { - "lastModified": 1776804427, - "narHash": "sha256-GMzvteQTfdLyy8ZvAKW1RkZQrNK2OJE/ZX6j89/Bzmc=", - "owner": "neurosnap", - "repo": "zmx", - "rev": "375046461dafb9ab2df1faf93cf35b25e37ed042", - "type": "github" - }, - "original": { - "owner": "neurosnap", - "repo": "zmx", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 76f90d9..fadd3c9 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,6 @@ nixos-vscode-server.url = "github:nix-community/nixos-vscode-server"; nix-index-database.url = "github:nix-community/nix-index-database"; nix-index-database.inputs.nixpkgs.follows = "nixpkgs"; - zmx.url = "github:neurosnap/zmx"; # vira.url = "github:juspay/vira/github"; vira.url = "github:juspay/vira"; # landrun-nix.url = "github:srid/landrun-nix"; @@ -35,17 +34,35 @@ jumphost-nix.url = "github:srid/jumphost-nix"; jumphost-nix.flake = false; - # KOLU - kolu.url = "github:juspay/kolu/feat/minimap-hide-parked-toggle"; + # Pinned to PR #1652 (W1): https://github.com/juspay/kolu/pull/1652 + kolu.url = "github:juspay/kolu/W1"; - # claude-code 2.1.98 (newer versions are nerfed) - # See: https://x.com/Sthiven_R/status/2043992488109899849 - llm-agents.url = "github:numtide/llm-agents.nix/d9583b68fdc553936b35dc6ca206d8d8dd552e5b"; - llm-agents.inputs.nixpkgs.follows = "nixpkgs"; + # drishti remote host monitor (home-manager module) + drishti.url = "github:srid/drishti"; - # Neovim - nixvim.url = "github:nix-community/nixvim"; - nixvim.inputs.nixpkgs.follows = "nixpkgs"; + # Juspay's AI tooling repo. We consume only its opencode home-manager + # module (config only, not the package) via homeModules.opencode. + juspay-ai.url = "github:juspay/AI"; + juspay-ai.inputs.nixpkgs.follows = "nixpkgs"; + juspay-ai.inputs.llm-agents.follows = "llm-agents"; + + # anywhen is NOT a flake input — it's deployed as an incus-pet + # container, with the flake ref passed at deploy time (see + # `just pureintent anywhen-deploy`). The host config doesn't import + # anything from anywhen, so locking it here would just bloat + # flake.lock without buying us anything. + + project-unknown.url = "github:juspay/project-unknown"; + project-unknown.inputs.nixpkgs.follows = "nixpkgs"; + + # Source for opencode (see modules/home/work/opencode.nix). + # NOTE: previously pinned to d9583b68 for claude-code 2.1.98 (newer + # versions are nerfed; https://x.com/Sthiven_R/status/2043992488109899849), + # but claude-code is no longer consumed from here (see + # modules/home/claude-code), so we now track latest. + llm-agents.url = "github:numtide/llm-agents.nix"; + # Don't force nixpkgs.follows here: latest llm-agents needs a newer + # nixpkgs than ours (e.g. pnpm_11), so let it use its own pinned nixpkgs. # Emanote & Imako emanote.url = "github:srid/emanote"; diff --git a/justfile b/justfile index 17d3a65..716791e 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,10 @@ default: @just --list +# Per-host operational recipes. Namespaced via `mod` so each host's +# recipes live behind their own prefix (e.g. `just pureintent anywhen-deploy`). +mod pureintent 'configurations/nixos/pureintent/mod.just' + # Main commands # -------------------------------------------------------------------------------------------------- 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/flake-parts/nixos-flake.nix b/modules/flake-parts/nixos-flake.nix index d5b95ef..22dcda2 100644 --- a/modules/flake-parts/nixos-flake.nix +++ b/modules/flake-parts/nixos-flake.nix @@ -16,7 +16,6 @@ "nix-darwin" # "nixos-hardware" "nix-index-database" - "nixvim" ]; }; }; diff --git a/modules/home/claude-code/default.nix b/modules/home/claude-code/default.nix index 8dffece..743c046 100644 --- a/modules/home/claude-code/default.nix +++ b/modules/home/claude-code/default.nix @@ -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*)" + ]; }; }; } diff --git a/modules/home/cli/terminal.nix b/modules/home/cli/terminal.nix index d358e77..c7afa88 100644 --- a/modules/home/cli/terminal.nix +++ b/modules/home/cli/terminal.nix @@ -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; diff --git a/modules/home/default.nix b/modules/home/default.nix index 3849837..f4b245b 100644 --- a/modules/home/default.nix +++ b/modules/home/default.nix @@ -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 diff --git a/modules/home/editors/neovim/default.nix b/modules/home/editors/neovim/default.nix index a8fe9dc..38e3270 100644 --- a/modules/home/editors/neovim/default.nix +++ b/modules/home/editors/neovim/default.nix @@ -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 = { - "ff" = { - options.desc = "file finder"; - action = "find_files"; - }; - "fr" = { - options.desc = "recent files"; - action = "oldfiles"; - }; - "fg" = { - options.desc = "find via grep"; - action = "live_grep"; - }; - "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"; - "A" = "code_action"; - }; - diagnostic = { - "k" = "goto_prev"; - "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; }; } diff --git a/modules/home/editors/neovim/lazygit.nix b/modules/home/editors/neovim/lazygit.nix deleted file mode 100644 index 38cdfd2..0000000 --- a/modules/home/editors/neovim/lazygit.nix +++ /dev/null @@ -1,10 +0,0 @@ -{ - plugins.lazygit.enable = true; - keymaps = [ - { - action = "LazyGit"; - key = "gg"; - } - ]; - -} diff --git a/modules/home/editors/neovim/nvim-tree.nix b/modules/home/editors/neovim/nvim-tree.nix deleted file mode 100644 index ed14a12..0000000 --- a/modules/home/editors/neovim/nvim-tree.nix +++ /dev/null @@ -1,13 +0,0 @@ -{ - plugins.nvim-tree.enable = true; - keymaps = [ - { - action = "NvimTreeFindFileToggle"; - key = "tt"; - } - { - action = "NvimTreeFindFile"; - key = "tf"; - } - ]; -} diff --git a/modules/home/services/drishti/default.nix b/modules/home/services/drishti/default.nix new file mode 100644 index 0000000..8ffb6f7 --- /dev/null +++ b/modules/home/services/drishti/default.nix @@ -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; + }; +} diff --git a/modules/home/services/obsidian.nix b/modules/home/services/obsidian.nix index 095e13d..23693b1 100644 --- a/modules/home/services/obsidian.nix +++ b/modules/home/services/obsidian.nix @@ -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; diff --git a/modules/home/work/juspay.nix b/modules/home/work/juspay.nix index dc2807b..8ea469a 100644 --- a/modules/home/work/juspay.nix +++ b/modules/home/work/juspay.nix @@ -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"; diff --git a/modules/home/work/opencode.nix b/modules/home/work/opencode.nix new file mode 100644 index 0000000..6bd712a --- /dev/null +++ b/modules/home/work/opencode.nix @@ -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}")" + ''; +} 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..ca3b323 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/README.md @@ -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 `:` 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/` + 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@ + --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 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 + -- pkill -9 -f switch-to-configuration` clears it. + +## 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..160943c --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/SKILL.md @@ -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 [] [--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. +- 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). 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..64a5461 --- /dev/null +++ b/modules/nixos/linux/incus/incus-pet/default.nix @@ -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 : 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 [] [--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() { + # 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" </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 + ''; +} 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/modules/nixos/linux/llm-debugging.nix b/modules/nixos/linux/llm-debugging.nix new file mode 100644 index 0000000..f9f01a7 --- /dev/null +++ b/modules/nixos/linux/llm-debugging.nix @@ -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; +} diff --git a/packages/fuckport.nix b/packages/fuckport.nix index 14e7021..e05673f 100644 --- a/packages/fuckport.nix +++ b/packages/fuckport.nix @@ -30,10 +30,11 @@ writers.writeHaskellBin "fuckport" main :: IO () main = do - port <- Prelude.head <$> getArgs - s <- lsof "-i" (":" <> port) |> jc "--lsof" |> capture - let v = fromJust $ decode @[LsofRow] s - forM_ v $ \r -> do - putStrLn $ "Killing " <> show (pid r) <> " (" <> command r <> ")" - kill ["-KILL", show (pid r)] + ports <- getArgs + forM_ ports $ \port -> do + s <- lsof "-i" (":" <> port) |> jc "--lsof" |> capture + let v = fromJust $ decode @[LsofRow] s + forM_ v $ \r -> do + putStrLn $ "Killing " <> show (pid r) <> " (" <> command r <> ") on port " <> port + kill ["-KILL", show (pid r)] '' diff --git a/packages/portfwd.nix b/packages/portfwd.nix new file mode 100644 index 0000000..3e43f76 --- /dev/null +++ b/packages/portfwd.nix @@ -0,0 +1,20 @@ +{ writeShellApplication, openssh, ... }: + +writeShellApplication { + name = "portfwd"; + meta.description = '' + Forward a local port to the same port on a remote host over SSH. + Usage: portfwd [host] (host defaults to pureintent) + e.g. `portfwd 7721` or `portfwd 7721 sincereintent` + ''; + runtimeInputs = [ openssh ]; + text = '' + if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: portfwd [host]" >&2 + exit 1 + fi + port="$1" + host="''${2:-pureintent}" + ssh -L "$port:localhost:$port" "$host" + ''; +} diff --git a/packages/twitter-convert/default.nix b/packages/twitter-convert/default.nix new file mode 100644 index 0000000..b760277 --- /dev/null +++ b/packages/twitter-convert/default.nix @@ -0,0 +1,18 @@ +{ writeShellApplication, ffmpeg, ... }: + +# Some useful resources for this undocumented shit: +# - https://stackoverflow.com/q/59056863/55246 +# - https://gist.github.com/nikhan/26ddd9c4e99bbf209dd7 +writeShellApplication { + name = "twitter-convert"; + runtimeInputs = [ ffmpeg ]; + meta.description = '' + Convert a video for uploading to X / Twitter. + + You may need a Basic or Premium tier to upload longer videos. + ''; + text = '' + export ARSCRIPT="${./ffmpeg_ar}" + ${./ffmpeg_twitter} "$1" + ''; +} diff --git a/packages/twitter-convert/ffmpeg_ar b/packages/twitter-convert/ffmpeg_ar new file mode 100755 index 0000000..a40aa8d --- /dev/null +++ b/packages/twitter-convert/ffmpeg_ar @@ -0,0 +1,12 @@ +#!/bin/bash +eval $(ffprobe -v error -show_entries stream=width,height -of default=noprint_wrappers=1 "$1") +#results in e.g. +#width=300 +#height=1200 +echo "height=$height"$'\n'"width=$width" +newenv=$(bc <<< "width=$width; height=$height;"$'\nscale=3; aspect=width / height;\nprint "aspect=", aspect; + print "''\n'$'";\n\nif (aspect>3) { r=(height) * (aspect/3); print "height=";} + if (aspect<(1/3)) {r=(width) / (aspect/(1/3)); print "width=";}\nscale=0 + if (r) { print r/1 }') +[ ! -z "$newenv" ] && echo "$newenv" && export $newenv +echo "aspect=""$(bc <<< "scale=3; $width/$height")" diff --git a/packages/twitter-convert/ffmpeg_twitter b/packages/twitter-convert/ffmpeg_twitter new file mode 100755 index 0000000..6cd45fb --- /dev/null +++ b/packages/twitter-convert/ffmpeg_twitter @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -xe + +export $($ARSCRIPT "$1") +ffmpeg -i "$1" \ + -filter_complex 'fps=30,format=yuv420p,pad='"ceil($width/2)*2:ceil($height/2)*2"':(ow-iw)/2:(oh-ih)/2' -c:v h264_videotoolbox -c:a aac -ac 2 -ar 44100 -r 30 \ + output.mp4 \ No newline at end of file diff --git a/secrets/juspay-anthropic-api-key.age b/secrets/juspay-anthropic-api-key.age index f16fda1..e4ac2d8 100644 --- a/secrets/juspay-anthropic-api-key.age +++ b/secrets/juspay-anthropic-api-key.age @@ -1,9 +1,9 @@ age-encryption.org/v1 --> ssh-ed25519 96IXNQ 9d0R+OUXFfcIyavqAjeTQ0cQdC5ikhQec4k4QlqKmXE -zGcRQZj4iv5xivSrz2IQDIXHEOfajtXuXUFLaSJpOeM --> ssh-ed25519 It7HZQ yI5rfIG/CPOUbXnXyK8voA9iZ2JaXalV9eVPUjaqQwU -pfzi9JfBtDocCQW5xEAuH1UT1MzqH7CQbaSgKtt2EUE --> ssh-ed25519 0mMrRw Wsd1KEmQFVWa4qcw56ByW3HG2djFGvtmYjlVsqczPRU -OmZncQdNMzxgHJ8puGwMzrXNrmA6vBweEHHyU0u7kD8 ---- 3u/3Uxd+nfjADOvXfht8mJWjcrXJRiyt/UgfJ3YERi8 -#h'ru_z)d O0;CaxNlDen \ No newline at end of file +-> ssh-ed25519 96IXNQ xARGTD+HwoFJNUlNqJnPgCFgh21NWpfpG3IgVbnY53M +th3oAcHh2oFmwrCJCuTk//hfn3ad8RiDfElQ0UYdM4c +-> ssh-ed25519 It7HZQ ADxdXtnMvKfV57VgvO9JQx09Uk4yNFgSnMEr8PX9tUM +BzIMSHpZ8vfa1PU7cINGMuPZCgamNuyk6Sl9JPz3/TA +-> ssh-ed25519 0mMrRw lBGsIYsWnQBvuOGaxpPMN9FxNIy848ciNEuxOGC8fBc +GY5+rAUPB97L0SVbWAmT26sfNCul9WytTonyXqFTB8s +--- 1dYxiDNUh3B9KRKw6P8vnZFWYR/uKKef+bRlTB6dUuQ +o1%?&:$ #b{ *iZ5l.ӧi9j h \ No newline at end of file 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 ]; -}