diff --git a/flake.lock b/flake.lock index 2f56ce4..07e5b2d 100644 --- a/flake.lock +++ b/flake.lock @@ -1052,6 +1052,26 @@ "type": "github" } }, + "rev": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1787647619, + "narHash": "sha256-2uifZrVfZxca+jMfAD92caqlRf4ME3SIbJtv43f7Vsk=", + "ref": "refs/heads/main", + "rev": "6750a1871521b95fe177abfae5a9f3ef60f23f2c", + "revCount": 233, + "type": "git", + "url": "https://git.naps.pt/yolo/rev.git" + }, + "original": { + "type": "git", + "url": "https://git.naps.pt/yolo/rev.git" + } + }, "root": { "inputs": { "agent-of-empires": "agent-of-empires", @@ -1069,6 +1089,7 @@ "nixpkgs": "nixpkgs_7", "nvchad-starter": "nvchad-starter", "nvchad4nix": "nvchad4nix", + "rev": "rev", "rose-pine-hyprcursor": "rose-pine-hyprcursor", "sem": "sem", "spicetify-nix": "spicetify-nix", diff --git a/flake.nix b/flake.nix index 69a53eb..46bf2fa 100644 --- a/flake.nix +++ b/flake.nix @@ -73,6 +73,13 @@ # Terminal-session orchestrator. Pins its own nixpkgs for the same reason # ethui and agent-of-empires do — it is a verified Rust build. maestro.url = "git+https://git.naps.pt/naps62/maestro.git"; + # Always-on local code review server. Follows nixpkgs, unlike the Rust + # inputs above: it is a plain node bundle, and a second nixpkgs would put a + # second node 26 in the closure for nothing. + rev = { + url = "git+https://git.naps.pt/yolo/rev.git"; + inputs.nixpkgs.follows = "nixpkgs"; + }; nix-index-database = { url = "github:nix-community/nix-index-database"; inputs.nixpkgs.follows = "nixpkgs"; diff --git a/home/common/programs/nix-autodeploy/default.nix b/home/common/programs/nix-autodeploy/default.nix new file mode 100644 index 0000000..163df14 --- /dev/null +++ b/home/common/programs/nix-autodeploy/default.nix @@ -0,0 +1,199 @@ +{ + config, + lib, + pkgs, + ... +}: +# nix-autodeploy: one webhook endpoint that turns "a repo I own pushed to main" +# into "that flake input is bumped, committed, and (optionally) applied". +# +# Replaces per-app deploy webhooks that rebuilt from a checkout on the box. The +# apps are flake inputs now, so deploying one is a lock bump plus a generation +# switch — the same operation for every app, hence one service instead of N. +let + cfg = config.services.nixAutodeploy; + + # A user unit inherits almost no PATH, and the deploy shells out to git (with + # the gitea credential helper), nix and nh. + profilePath = lib.concatStringsSep ":" [ + "%h/.local/bin" + "%h/.nix-profile/bin" + "/etc/profiles/per-user/%u/bin" + "/run/current-system/sw/bin" + ]; + + deploy = pkgs.writeShellApplication { + name = "nix-autodeploy-deploy"; + runtimeInputs = [ + pkgs.git + pkgs.nix + pkgs.nh + pkgs.curl + pkgs.jq + pkgs.util-linux + ]; + text = '' + # usage: nix-autodeploy-deploy + input=$1 + mode=$2 + flake=${lib.escapeShellArg cfg.flake} + topic=${lib.escapeShellArg cfg.ntfy.topic} + ntfy_url=${lib.escapeShellArg cfg.ntfy.url} + + notify() { + [ -n "''${NTFY_TOKEN:-}" ] || return 0 + curl -fsS -X POST "$ntfy_url/$topic" \ + -H "Authorization: Bearer $NTFY_TOKEN" \ + -H "Title: $1" -d "$2" > /dev/null || true + } + + # Serialize: two pushes landing together would otherwise race on the same + # working tree and the same flake.lock. + exec 9> "''${XDG_RUNTIME_DIR:-/tmp}/nix-autodeploy.lock" + flock 9 + + cd "$flake" + + branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch" != main ]; then + notify "autodeploy skipped ($input)" "checkout is on $branch, not main" + exit 0 + fi + if ! git diff --quiet || ! git diff --cached --quiet; then + notify "autodeploy skipped ($input)" "working tree is dirty" + exit 0 + fi + + git fetch --quiet origin main + git merge --ff-only --quiet origin/main + + nix flake update "$input" + if git diff --quiet -- flake.lock; then + echo "autodeploy: $input already at the pushed revision" + exit 0 + fi + + rev=$(nix flake metadata --json | + jq -r --arg i "$input" '.locks.nodes[$i].locked.rev[0:7]') + git commit --quiet -m "chore(flake): bump $input to $rev" -- flake.lock + git push --quiet origin main + + if [ "$mode" != apply ]; then + notify "$input bumped to $rev" "Not applied — run 'nh home switch' when convenient." + exit 0 + fi + + if nh home switch "$flake"; then + notify "$input deployed" "Bumped to $rev and switched." + else + notify "$input FAILED to apply" "Lock is at $rev on main; the switch failed. See journalctl --user -u run-*." + exit 1 + fi + ''; + }; + + listener = pkgs.writers.writePython3Bin "nix-autodeploy" { + # Only line length: http.server's do_GET/do_POST spelling is already + # excused inline. + flakeIgnore = [ "E501" ]; + } (builtins.readFile ./listener.py); + + repoMap = lib.mapAttrs (_: r: { + inherit (r) input; + inherit (r) apply; + }) cfg.repos; +in +{ + options.services.nixAutodeploy = { + enable = lib.mkEnableOption "the forge-webhook listener that bumps and applies flake inputs"; + + port = lib.mkOption { + type = lib.types.port; + default = 7375; + description = "Port the listener binds on 0.0.0.0. There is no auth beyond the webhook HMAC, so only expose it through the reverse proxy."; + }; + + flake = lib.mkOption { + type = lib.types.str; + example = "/home/naps62/tea/nixos-config"; + description = "Absolute path to the nixos-config checkout whose flake.lock gets bumped. Must be on main and clean, or the deploy skips."; + }; + + environmentFile = lib.mkOption { + type = lib.types.str; + example = "%h/.config/nix-autodeploy/env"; + description = '' + File holding `NIX_AUTODEPLOY_SECRET` (the webhook HMAC secret, shared + with every repo below) and `NTFY_TOKEN`. Not in the store — these are + secrets. + ''; + }; + + ntfy = { + url = lib.mkOption { + type = lib.types.str; + default = "https://ntfy.home.naps.pt"; + description = "Base URL of the ntfy server deploy results are posted to."; + }; + topic = lib.mkOption { + type = lib.types.str; + default = "nix-autodeploy"; + description = "ntfy topic for deploy results."; + }; + }; + + repos = lib.mkOption { + default = { }; + description = "Forge repositories to listen for, keyed by `/` exactly as the webhook payload spells it."; + example = lib.literalExpression '' + { "yolo/rev" = { input = "rev"; }; } + ''; + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + input = lib.mkOption { + type = lib.types.str; + description = "Name of the flake input in this repo's flake.nix that tracks that repository."; + }; + apply = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether to run `nh home switch` after the bump. Set false for an + app whose restart disrupts a live session — the lock is still + bumped and pushed, and the ntfy message says it is waiting. + ''; + }; + }; + } + ); + }; + }; + + config = lib.mkIf cfg.enable { + systemd.user.services.nix-autodeploy = { + Unit = { + Description = "nix-autodeploy — forge webhooks bump and apply flake inputs"; + After = [ "network.target" ]; + # Same restart-budget trap every other always-on unit here avoids: at + # RestartSec=2 a fast-crashing listener would park in `failed`. + StartLimitIntervalSec = 0; + }; + Service = { + Type = "simple"; + ExecStart = lib.getExe listener; + EnvironmentFile = cfg.environmentFile; + Environment = [ + "PATH=${profilePath}" + "NIX_AUTODEPLOY_PORT=${toString cfg.port}" + "NIX_AUTODEPLOY_DEPLOY_BIN=${lib.getExe deploy}" + "NIX_AUTODEPLOY_ENV_FILE=${cfg.environmentFile}" + "NIX_AUTODEPLOY_REPOS=${builtins.toJSON repoMap}" + ]; + Restart = "always"; + RestartSec = 2; + }; + Install.WantedBy = [ "default.target" ]; + }; + }; +} diff --git a/home/common/programs/nix-autodeploy/listener.py b/home/common/programs/nix-autodeploy/listener.py new file mode 100644 index 0000000..4e891ed --- /dev/null +++ b/home/common/programs/nix-autodeploy/listener.py @@ -0,0 +1,102 @@ +"""Forge webhook listener that turns a push into a flake-input bump. + +Verifies the HMAC a Gitea (or GitHub) webhook signs the body with, then hands +the actual work to a transient systemd unit. Nothing is done in-process: the +deploy runs `nh home switch`, which restarts every unit home-manager owns — +including this listener — so the job has to outlive it. +""" + +import hashlib +import hmac +import json +import os +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +SECRET = os.environ.get("NIX_AUTODEPLOY_SECRET", "").encode() +PORT = int(os.environ.get("NIX_AUTODEPLOY_PORT", "7375")) +DEPLOY = os.environ["NIX_AUTODEPLOY_DEPLOY_BIN"] +# {"/": {"input": "rev", "apply": true}, ...} +REPOS = json.loads(os.environ["NIX_AUTODEPLOY_REPOS"]) +ENV_FILE = os.environ.get("NIX_AUTODEPLOY_ENV_FILE", "") +MAX_BODY = 1 << 20 + +if not SECRET: + sys.exit("NIX_AUTODEPLOY_SECRET is not set") + + +def signature_ok(body: bytes, headers) -> bool: + expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() + # Gitea sends the bare hex digest; GitHub prefixes it with "sha256=". + for name in ("X-Gitea-Signature", "X-Hub-Signature-256"): + got = headers.get(name) + if got and hmac.compare_digest(got.removeprefix("sha256="), expected): + return True + return False + + +def spawn(repo: str, entry: dict) -> None: + cmd = [ + "systemd-run", + "--user", + "--collect", + f"--description=nix-autodeploy: {repo}", + # PATH is not inherited by a transient unit, and the deploy shells out + # to git, nix and the gitea credential helper. + f"--setenv=PATH={os.environ['PATH']}", + ] + if ENV_FILE: + cmd.append(f"--property=EnvironmentFile={ENV_FILE}") + cmd += [DEPLOY, entry["input"], "apply" if entry.get("apply", True) else "notify"] + subprocess.run(cmd, check=True) + + +class Handler(BaseHTTPRequestHandler): + def reply(self, code: int, text: str) -> None: + payload = text.encode() + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's spelling + if self.path == "/health": + self.reply(200, "ok\n") + else: + self.reply(404, "no\n") + + def do_POST(self) -> None: # noqa: N802 + length = int(self.headers.get("Content-Length", "0")) + if length > MAX_BODY: + return self.reply(413, "body too large\n") + body = self.rfile.read(length) + + if not signature_ok(body, self.headers): + return self.reply(401, "bad signature\n") + + try: + event = json.loads(body) + except json.JSONDecodeError: + return self.reply(400, "bad json\n") + + ref = event.get("ref") + repo = (event.get("repository") or {}).get("full_name") + if ref != "refs/heads/main": + return self.reply(200, f"ignored ref {ref}\n") + + entry = REPOS.get(repo) + if entry is None: + return self.reply(200, f"ignored repo {repo}\n") + + spawn(repo, entry) + self.reply(202, f"deploying {entry['input']}\n") + + def log_message(self, fmt: str, *args) -> None: + # Journal already timestamps; the default format prepends its own. + sys.stderr.write(f"{self.address_string()} {fmt % args}\n") + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/home/common/programs/rev/default.nix b/home/common/programs/rev/default.nix new file mode 100644 index 0000000..1a47b5b --- /dev/null +++ b/home/common/programs/rev/default.nix @@ -0,0 +1,7 @@ +{ inputs, ... }: +# rev: the always-on code review server. Module only — no `enable` here. It +# discovers every repo under its roots and serves them without auth, so only a +# box that is already a trusted single-user machine should run it. +{ + imports = [ inputs.rev.homeManagerModules.default ]; +} diff --git a/home/yolo/default.nix b/home/yolo/default.nix index 2bcc2a6..0160e2d 100644 --- a/home/yolo/default.nix +++ b/home/yolo/default.nix @@ -22,6 +22,8 @@ in ../common/programs/gpg.nix ../common/programs/aoe ../common/programs/maestro + ../common/programs/rev + ../common/programs/nix-autodeploy ../common/features/xdg.nix ./monitors.nix ./services.nix diff --git a/home/yolo/services.nix b/home/yolo/services.nix index 9bc498e..42adb5f 100644 --- a/home/yolo/services.nix +++ b/home/yolo/services.nix @@ -3,12 +3,11 @@ inputs, ... }: -# The user services this box exists to run, ported from hand-written units in -# ~/.config/systemd/user on the Ubuntu machine. +# The user services this box exists to run. # -# NOT self-contained: every ExecStart under ~/.bun or ~/.local/bin is an -# imperatively-installed binary, and the WorkingDirectories are clones of -# separate repos. Nix owns the unit definitions here, nothing more. +# maestro and rev come from their own flakes, so nix owns the build as well as +# the unit. aoe-web is still the odd one out: its unit is defined here and the +# binary comes from the flake input. let # A user unit gets almost no PATH by default; these are the profile dirs the # original units got for free from the system PATH on Ubuntu. @@ -19,16 +18,17 @@ let aoe = inputs.agent-of-empires.packages.${pkgs.system}.aoe-with-web; in { + # sem is here as well as on rev's unit: the shell uses it directly too. home.packages = [ sem - pkgs.bun # ACP adapter aoe's structured (web) sessions spawn as `claude-agent-acp`. pkgs.claude-agent-acp ]; - # maestro's own units come from its flake module, not from the hand-written - # set below. 8081, not 8080: aoe-web already has that port on this host. - # There is no auth layer, so 0.0.0.0 is only safe behind the LAN perimeter. + # maestro's and rev's units come from their flake modules, not from the + # hand-written set below. 8081, not 8080: aoe-web already has that port on + # this host. There is no auth layer, so 0.0.0.0 is only safe behind the LAN + # perimeter. services.maestro = { enable = true; web.enable = true; @@ -41,60 +41,40 @@ in }; }; + # Everything under ~, three levels deep — the worktrees live at + # ~///worktrees/. sem gives entity-level diffs; without it + # rev falls back to line diffs. + services.rev = { + enable = true; + roots = [ "%h" ]; + depth = 3; + semBin = "${sem}/bin/sem"; + }; + + # One endpoint for both app repos. rev applies straight away; maestro only + # bumps the lock and notifies, because switching restarts the daemon that + # owns every interactive shell on this box. + services.nixAutodeploy = { + enable = true; + flake = "/home/naps62/tea/nixos-config"; + environmentFile = "%h/.config/nix-autodeploy/env"; + repos = { + "yolo/rev".input = "rev"; + "naps62/maestro" = { + input = "maestro"; + apply = false; + }; + }; + }; + systemd.user.services = { - rev = { - Unit = { - Description = "rev — always-on local code review server"; - After = [ "network.target" ]; - # MUST stay 0: at RestartSec=2 a fast-crashing rev burns the default - # 5-starts-per-10s budget, and systemd parks the unit in `failed` until - # a manual `systemctl --user reset-failed`. - StartLimitIntervalSec = 0; - }; - Service = { - Type = "simple"; - WorkingDirectory = "%h/tea/rev"; - # nodejs_26, not pkgs.nodejs: rev's package.json sets engines >=26 and - # the nixpkgs default is 24. - ExecStart = "${pkgs.nodejs_26}/bin/node server/index.ts"; - Environment = [ - "NODE_ENV=production" - "REV_ROOTS=%h" - "REV_DEPTH=3" - "REV_SEM_BIN=${sem}/bin/sem" - "PATH=${toolPath}" - ]; - Restart = "always"; - RestartSec = 2; - }; - Install.WantedBy = [ "default.target" ]; - }; - - rev-deploy = { - Unit = { - Description = "rev-deploy — Gitea webhook listener that deploys rev on push to main"; - After = [ "network.target" ]; - # Same restart-budget trap as `rev` above. - StartLimitIntervalSec = 0; - }; - Service = { - Type = "simple"; - WorkingDirectory = "%h/tea/rev"; - ExecStart = "${pkgs.bun}/bin/bun scripts/deploy-webhook.ts"; - EnvironmentFile = "%h/.config/rev/deploy.env"; - # Unit files are home-manager symlinks; deploy.sh must not rewrite them. - Environment = [ "PATH=${toolPath}" "REV_SKIP_UNIT_INSTALL=1" ]; - Restart = "always"; - RestartSec = 2; - }; - Install.WantedBy = [ "default.target" ]; - }; - aoe-web = { Unit = { Description = "aoe serve — Agent of Empires web dashboard"; After = [ "network.target" ]; - # Same restart-budget trap as `rev` above. + # MUST stay 0: at RestartSec=2 a fast-crashing aoe burns the default + # 5-starts-per-10s budget, and systemd parks the unit in `failed` until + # a manual `systemctl --user reset-failed`. StartLimitIntervalSec = 0; }; Service = {