master @ 291 LINES
[ HISTORY ] [ UP ]
┌─ NIX ──────────────────────────────────────────────────────────────────────┐
│ # ooknet.org site builder - push-driven static deployment. │
│ # │
│ # The site is NOT a nix package: /git pages read real repositories at │
│ # build time, which the sandbox can't see. This module provisions a │
│ # builder instead: a oneshot unit that pulls the site source, updates │
│ # bare mirrors of the /git repos, runs the astro build with │
│ # OOKNET_GIT_ROOT pointing at those mirrors, and atomically swaps a │
│ # `current` symlink. A localhost webhook socket (point a forgejo │
│ # system webhook at it) and a daily timer trigger it. │
│ # │
│ # Consumer wiring (ooknet flake): │
│ # │
│ # inputs.ooknet-org.url = "git+https://git.ooknet.org/ooks/ooknet.org"; │
│ # imports = [ inputs.ooknet-org.nixosModules.ooknet-site ]; │
│ # │
│ # services.ooknet-site = { │
│ # enable = true; │
│ # mirrors = [ │
│ # { slug = "ooknet-org"; url = "http://localhost:3000/ooks/ooknet.org.git" │
│ ; } │
│ # { slug = "ooknet"; url = "http://localhost:3000/ooks/ooknet.git"; } │
│ # { slug = "wowsim-stats"; url = "http://localhost:3000/ooks/wowsim-stats.gi │
│ t"; } │
│ # ]; │
│ # }; │
│ # │
│ # services.caddy.virtualHosts."ooknet.org".extraConfig = '' │
│ # root * ${config.services.ooknet-site.webroot} │
│ # file_server │
│ # ''; │
│ # │
│ # Failed builds never touch the symlink, so a broken push can't take │
│ # the site down. │
│ { │
│ config, │
│ lib, │
│ pkgs, │
│ ... │
│ }: let │
│ inherit (lib) mkEnableOption mkOption mkIf types concatMapStringsSep escapeShell │
│ Arg; │
│ │
│ cfg = config.services.ooknet-site; │
│ │
│ buildScript = pkgs.writeShellApplication { │
│ name = "ooknet-site-build"; │
│ # bash: npm lifecycle scripts spawn `sh` from PATH │
│ runtimeInputs = [pkgs.git cfg.nodejs pkgs.bash pkgs.coreutils pkgs.findutils] │
│ ++ cfg.extraPackages; │
│ text = '' │
│ state=${escapeShellArg cfg.stateDir} │
│ src="$state/src" │
│ export HOME="$state" │
│ export npm_config_cache="$state/npm-cache" │
│ │
│ # site source │
│ if [ ! -d "$src/.git" ]; then │
│ git clone --branch ${escapeShellArg cfg.branch} ${escapeShellArg cfg.siteR │
│ epo} "$src" │
│ fi │
│ git -C "$src" fetch origin ${escapeShellArg cfg.branch} │
│ git -C "$src" reset --hard origin/${escapeShellArg cfg.branch} │
│ # drop debris from killed builds (dist survives reset - untracked); │
│ # node_modules stays, npm ci manages it │
│ git -C "$src" clean -fdxq -e node_modules │
│ │
│ # bare mirrors for the /git pages │
│ mkdir -p "$state/mirrors" │
│ ${concatMapStringsSep "\n" (m: '' │
│ if [ ! -d "$state/mirrors/${m.slug}.git" ]; then │
│ git clone --mirror ${escapeShellArg m.url} "$state/mirrors/${m.slug}.git │
│ " │
│ else │
│ git -C "$state/mirrors/${m.slug}.git" remote update --prune │
│ fi │
│ '') cfg.mirrors} │
│ │
│ # build │
│ cd "$src/${cfg.siteSubdir}" │
│ npm ci --no-audit --no-fund │
│ OOKNET_GIT_ROOT="$state/mirrors" npm run build │
│ │
│ # publish: prune first so the disk never holds an extra copy, │
│ # then MOVE dist into place (same filesystem - a rename, not a │
│ # copy), and swap the symlink │
│ ts=$(date +%Y%m%d-%H%M%S) │
│ mkdir -p "$state/builds" │
│ find "$state/builds" -mindepth 1 -maxdepth 1 -type d \ │
│ | sort | head -n -${toString (cfg.keepBuilds - 1)} | xargs -r rm -rf │
│ mv dist "$state/builds/$ts" │
│ chmod -R a+rX "$state/builds/$ts" │
│ ln -sfn "builds/$ts" "$state/current.tmp" │
│ mv -T "$state/current.tmp" "$state/current" │
│ ''; │
│ }; │
│ │
│ # socket-activated per-connection handler: drain the request, answer │
│ # 204, poke the trigger file the path unit watches. Localhost only; │
│ # forgejo is on the same host, so no secret handshake is needed. │
│ webhookHandler = pkgs.writeShellApplication { │
│ name = "ooknet-site-webhook"; │
│ runtimeInputs = [pkgs.coreutils]; │
│ text = '' │
│ length=0 │
│ while IFS= read -r line; do │
│ line=''${line%$'\r'} │
│ [ -z "$line" ] && break │
│ case "$line" in │
│ [Cc]ontent-[Ll]ength:*) length=''${line##*: } ;; │
│ esac │
│ done │
│ [ "$length" -gt 0 ] 2>/dev/null && head -c "$length" > /dev/null │
│ printf 'HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n' │
│ date +%s%N > ${escapeShellArg cfg.stateDir}/trigger │
│ ''; │
│ }; │
│ in { │
│ options.services.ooknet-site = { │
│ enable = mkEnableOption "the ooknet.org site builder"; │
│ │
│ siteRepo = mkOption { │
│ type = types.str; │
│ default = "http://localhost:3000/ooks/ooknet.org.git"; │
│ description = "Clone url of the site repository."; │
│ }; │
│ │
│ branch = mkOption { │
│ type = types.str; │
│ default = "master"; │
│ description = "Branch the site deploys from."; │
│ }; │
│ │
│ siteSubdir = mkOption { │
│ type = types.str; │
│ default = "ooknet-design"; │
│ description = "Subdirectory of the site repo holding package.json."; │
│ }; │
│ │
│ mirrors = mkOption { │
│ type = types.listOf (types.submodule { │
│ options = { │
│ slug = mkOption { │
│ type = types.str; │
│ description = "Mirror name; must match a slug in the site's git.config │
│ .ts."; │
│ }; │
│ url = mkOption { │
│ type = types.str; │
│ description = "Clone url, typically anonymous http from local forgejo. │
│ "; │
│ }; │
│ }; │
│ }); │
│ default = []; │
│ description = "Repositories rendered by the site's /git pages."; │
│ }; │
│ │
│ extraPackages = mkOption { │
│ type = types.listOf types.package; │
│ default = []; │
│ description = "Extra tools on the builder path (the flake export adds mermai │
│ d-ascii, which the site's diagram fences exec)."; │
│ }; │
│ │
│ nodejs = mkOption { │
│ type = types.package; │
│ default = pkgs.nodejs; │
│ description = "Node used for the build; keep in step with the site dev shell │
│ ."; │
│ }; │
│ │
│ stateDir = mkOption { │
│ type = types.path; │
│ default = "/var/lib/ooknet-site"; │
│ description = "Builder state: source checkout, mirrors, npm cache, builds."; │
│ }; │
│ │
│ webroot = mkOption { │
│ type = types.path; │
│ readOnly = true; │
│ default = "${cfg.stateDir}/current"; │
│ description = "Point the webserver root here; a symlink to the last good bui │
│ ld."; │
│ }; │
│ │
│ webhook = { │
│ enable = mkOption { │
│ type = types.bool; │
│ default = true; │
│ description = "Listen for forgejo push webhooks on localhost."; │
│ }; │
│ address = mkOption { │
│ type = types.str; │
│ default = "127.0.0.1"; │
│ description = "Bind address. Keep it loopback; there is no auth."; │
│ }; │
│ port = mkOption { │
│ type = types.port; │
│ default = 9473; │
│ description = "Webhook port; forgejo posts to http://address:port/."; │
│ }; │
│ }; │
│ │
│ timer = mkOption { │
│ type = types.nullOr types.str; │
│ default = "daily"; │
│ description = "systemd OnCalendar backstop rebuild; null disables."; │
│ }; │
│ │
│ keepBuilds = mkOption { │
│ type = types.ints.positive; │
│ default = 2; │
│ description = "Builds retained including the live one - 2 keeps one rollback │
│ . Multi-gigabyte outputs on small disks want this low."; │
│ }; │
│ │
│ user = mkOption { │
│ type = types.str; │
│ default = "ooknet-site"; │
│ description = "Builder user."; │
│ }; │
│ │
│ group = mkOption { │
│ type = types.str; │
│ default = "ooknet-site"; │
│ description = "Builder group."; │
│ }; │
│ }; │
│ │
│ config = mkIf cfg.enable { │
│ users.users.${cfg.user} = { │
│ isSystemUser = true; │
│ inherit (cfg) group; │
│ home = cfg.stateDir; │
│ }; │
│ users.groups.${cfg.group} = {}; │
│ │
│ systemd.tmpfiles.rules = [ │
│ # 0755 so the webserver can follow current/ into builds/ │
│ "d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -" │
│ "f ${cfg.stateDir}/trigger 0644 ${cfg.user} ${cfg.group} - -" │
│ ]; │
│ │
│ systemd.services.ooknet-site-build = { │
│ description = "ooknet.org site build"; │
│ # webhook/timer-driven oneshot: activation must not run builds or │
│ # inherit their failures │
│ restartIfChanged = false; │
│ serviceConfig = { │
│ Type = "oneshot"; │
│ User = cfg.user; │
│ Group = cfg.group; │
│ ExecStart = lib.getExe buildScript; │
│ WorkingDirectory = cfg.stateDir; │
│ TimeoutStartSec = "15min"; │
│ # coalesce push bursts before the expensive part starts │
│ ExecStartPre = "${pkgs.coreutils}/bin/sleep 3"; │
│ PrivateTmp = true; │
│ NoNewPrivileges = true; │
│ }; │
│ }; │
│ │
│ systemd.timers.ooknet-site-build = mkIf (cfg.timer != null) { │
│ description = "ooknet.org site rebuild backstop"; │
│ wantedBy = ["timers.target"]; │
│ timerConfig = { │
│ OnCalendar = cfg.timer; │
│ Persistent = true; │
│ RandomizedDelaySec = "10m"; │
│ }; │
│ }; │
│ │
│ systemd.paths.ooknet-site-trigger = mkIf cfg.webhook.enable { │
│ description = "ooknet.org rebuild trigger"; │
│ wantedBy = ["paths.target"]; │
│ pathConfig = { │
│ PathModified = "${cfg.stateDir}/trigger"; │
│ Unit = "ooknet-site-build.service"; │
│ }; │
│ }; │
│ │
│ systemd.sockets.ooknet-site-webhook = mkIf cfg.webhook.enable { │
│ description = "ooknet.org webhook socket"; │
│ wantedBy = ["sockets.target"]; │
│ listenStreams = ["${cfg.webhook.address}:${toString cfg.webhook.port}"]; │
│ socketConfig.Accept = true; │
│ }; │
│ │
│ systemd.services."ooknet-site-webhook@" = mkIf cfg.webhook.enable { │
│ description = "ooknet.org webhook handler"; │
│ serviceConfig = { │
│ User = cfg.user; │
│ Group = cfg.group; │
│ StandardInput = "socket"; │
│ StandardOutput = "socket"; │
│ ExecStart = lib.getExe webhookHandler; │
│ PrivateTmp = true; │
│ NoNewPrivileges = true; │
│ }; │
│ }; │
│ }; │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ NIX ────────────────────────────────┐
│ # ooknet.org site builder - push-driven stat │
│ ic deployment. │
│ # │
│ # The site is NOT a nix package: /git pages │
│ read real repositories at │
│ # build time, which the sandbox can't see. T │
│ his module provisions a │
│ # builder instead: a oneshot unit that pulls │
│ the site source, updates │
│ # bare mirrors of the /git repos, runs the a │
│ stro build with │
│ # OOKNET_GIT_ROOT pointing at those mirrors, │
│ and atomically swaps a │
│ # `current` symlink. A localhost webhook soc │
│ ket (point a forgejo │
│ # system webhook at it) and a daily timer tr │
│ igger it. │
│ # │
│ # Consumer wiring (ooknet flake): │
│ # │
│ # inputs.ooknet-org.url = "git+https://git │
│ .ooknet.org/ooks/ooknet.org"; │
│ # imports = [ inputs.ooknet-org.nixosModul │
│ es.ooknet-site ]; │
│ # │
│ # services.ooknet-site = { │
│ # enable = true; │
│ # mirrors = [ │
│ # { slug = "ooknet-org"; url = "http │
│ ://localhost:3000/ooks/ooknet.org.git"; } │
│ # { slug = "ooknet"; url = "http: │
│ //localhost:3000/ooks/ooknet.git"; } │
│ # { slug = "wowsim-stats"; url = "http │
│ ://localhost:3000/ooks/wowsim-stats.git"; } │
│ # ]; │
│ # }; │
│ # │
│ # services.caddy.virtualHosts."ooknet.org" │
│ .extraConfig = '' │
│ # root * ${config.services.ooknet-site.w │
│ ebroot} │
│ # file_server │
│ # ''; │
│ # │
│ # Failed builds never touch the symlink, so │
│ a broken push can't take │
│ # the site down. │
│ { │
│ config, │
│ lib, │
│ pkgs, │
│ ... │
│ }: let │
│ inherit (lib) mkEnableOption mkOption mkIf │
│ types concatMapStringsSep escapeShellArg; │
│ │
│ cfg = config.services.ooknet-site; │
│ │
│ buildScript = pkgs.writeShellApplication { │
│ name = "ooknet-site-build"; │
│ # bash: npm lifecycle scripts spawn `sh` │
│ from PATH │
│ runtimeInputs = [pkgs.git cfg.nodejs pkg │
│ s.bash pkgs.coreutils pkgs.findutils] ++ cfg │
│ .extraPackages; │
│ text = '' │
│ state=${escapeShellArg cfg.stateDir} │
│ src="$state/src" │
│ export HOME="$state" │
│ export npm_config_cache="$state/npm-ca │
│ che" │
│ │
│ # site source │
│ if [ ! -d "$src/.git" ]; then │
│ git clone --branch ${escapeShellArg │
│ cfg.branch} ${escapeShellArg cfg.siteRepo} " │
│ $src" │
│ fi │
│ git -C "$src" fetch origin ${escapeShe │
│ llArg cfg.branch} │
│ git -C "$src" reset --hard origin/${es │
│ capeShellArg cfg.branch} │
│ # drop debris from killed builds (dist │
│ survives reset - untracked); │
│ # node_modules stays, npm ci manages i │
│ t │
│ git -C "$src" clean -fdxq -e node_modu │
│ les │
│ │
│ # bare mirrors for the /git pages │
│ mkdir -p "$state/mirrors" │
│ ${concatMapStringsSep "\n" (m: '' │
│ if [ ! -d "$state/mirrors/${m.slug}. │
│ git" ]; then │
│ git clone --mirror ${escapeShellAr │
│ g m.url} "$state/mirrors/${m.slug}.git" │
│ else │
│ git -C "$state/mirrors/${m.slug}.g │
│ it" remote update --prune │
│ fi │
│ '') cfg.mirrors} │
│ │
│ # build │
│ cd "$src/${cfg.siteSubdir}" │
│ npm ci --no-audit --no-fund │
│ OOKNET_GIT_ROOT="$state/mirrors" npm r │
│ un build │
│ │
│ # publish: prune first so the disk nev │
│ er holds an extra copy, │
│ # then MOVE dist into place (same file │
│ system - a rename, not a │
│ # copy), and swap the symlink │
│ ts=$(date +%Y%m%d-%H%M%S) │
│ mkdir -p "$state/builds" │
│ find "$state/builds" -mindepth 1 -maxd │
│ epth 1 -type d \ │
│ | sort | head -n -${toString (cfg.ke │
│ epBuilds - 1)} | xargs -r rm -rf │
│ mv dist "$state/builds/$ts" │
│ chmod -R a+rX "$state/builds/$ts" │
│ ln -sfn "builds/$ts" "$state/current.t │
│ mp" │
│ mv -T "$state/current.tmp" "$state/cur │
│ rent" │
│ ''; │
│ }; │
│ │
│ # socket-activated per-connection handler: │
│ drain the request, answer │
│ # 204, poke the trigger file the path unit │
│ watches. Localhost only; │
│ # forgejo is on the same host, so no secre │
│ t handshake is needed. │
│ webhookHandler = pkgs.writeShellApplicatio │
│ n { │
│ name = "ooknet-site-webhook"; │
│ runtimeInputs = [pkgs.coreutils]; │
│ text = '' │
│ length=0 │
│ while IFS= read -r line; do │
│ line=''${line%$'\r'} │
│ [ -z "$line" ] && break │
│ case "$line" in │
│ [Cc]ontent-[Ll]ength:*) length=''$ │
│ {line##*: } ;; │
│ esac │
│ done │
│ [ "$length" -gt 0 ] 2>/dev/null && hea │
│ d -c "$length" > /dev/null │
│ printf 'HTTP/1.1 204 No Content\r\nCon │
│ nection: close\r\n\r\n' │
│ date +%s%N > ${escapeShellArg cfg.stat │
│ eDir}/trigger │
│ ''; │
│ }; │
│ in { │
│ options.services.ooknet-site = { │
│ enable = mkEnableOption "the ooknet.org │
│ site builder"; │
│ │
│ siteRepo = mkOption { │
│ type = types.str; │
│ default = "http://localhost:3000/ooks/ │
│ ooknet.org.git"; │
│ description = "Clone url of the site r │
│ epository."; │
│ }; │
│ │
│ branch = mkOption { │
│ type = types.str; │
│ default = "master"; │
│ description = "Branch the site deploys │
│ from."; │
│ }; │
│ │
│ siteSubdir = mkOption { │
│ type = types.str; │
│ default = "ooknet-design"; │
│ description = "Subdirectory of the sit │
│ e repo holding package.json."; │
│ }; │
│ │
│ mirrors = mkOption { │
│ type = types.listOf (types.submodule { │
│ options = { │
│ slug = mkOption { │
│ type = types.str; │
│ description = "Mirror name; must │
│ match a slug in the site's git.config.ts."; │
│ }; │
│ url = mkOption { │
│ type = types.str; │
│ description = "Clone url, typica │
│ lly anonymous http from local forgejo."; │
│ }; │
│ }; │
│ }); │
│ default = []; │
│ description = "Repositories rendered b │
│ y the site's /git pages."; │
│ }; │
│ │
│ extraPackages = mkOption { │
│ type = types.listOf types.package; │
│ default = []; │
│ description = "Extra tools on the buil │
│ der path (the flake export adds mermaid-asci │
│ i, which the site's diagram fences exec)."; │
│ }; │
│ │
│ nodejs = mkOption { │
│ type = types.package; │
│ default = pkgs.nodejs; │
│ description = "Node used for the build │
│ ; keep in step with the site dev shell."; │
│ }; │
│ │
│ stateDir = mkOption { │
│ type = types.path; │
│ default = "/var/lib/ooknet-site"; │
│ description = "Builder state: source c │
│ heckout, mirrors, npm cache, builds."; │
│ }; │
│ │
│ webroot = mkOption { │
│ type = types.path; │
│ readOnly = true; │
│ default = "${cfg.stateDir}/current"; │
│ description = "Point the webserver roo │
│ t here; a symlink to the last good build."; │
│ }; │
│ │
│ webhook = { │
│ enable = mkOption { │
│ type = types.bool; │
│ default = true; │
│ description = "Listen for forgejo pu │
│ sh webhooks on localhost."; │
│ }; │
│ address = mkOption { │
│ type = types.str; │
│ default = "127.0.0.1"; │
│ description = "Bind address. Keep it │
│ loopback; there is no auth."; │
│ }; │
│ port = mkOption { │
│ type = types.port; │
│ default = 9473; │
│ description = "Webhook port; forgejo │
│ posts to http://address:port/."; │
│ }; │
│ }; │
│ │
│ timer = mkOption { │
│ type = types.nullOr types.str; │
│ default = "daily"; │
│ description = "systemd OnCalendar back │
│ stop rebuild; null disables."; │
│ }; │
│ │
│ keepBuilds = mkOption { │
│ type = types.ints.positive; │
│ default = 2; │
│ description = "Builds retained includi │
│ ng the live one - 2 keeps one rollback. Mult │
│ i-gigabyte outputs on small disks want this │
│ low."; │
│ }; │
│ │
│ user = mkOption { │
│ type = types.str; │
│ default = "ooknet-site"; │
│ description = "Builder user."; │
│ }; │
│ │
│ group = mkOption { │
│ type = types.str; │
│ default = "ooknet-site"; │
│ description = "Builder group."; │
│ }; │
│ }; │
│ │
│ config = mkIf cfg.enable { │
│ users.users.${cfg.user} = { │
│ isSystemUser = true; │
│ inherit (cfg) group; │
│ home = cfg.stateDir; │
│ }; │
│ users.groups.${cfg.group} = {}; │
│ │
│ systemd.tmpfiles.rules = [ │
│ # 0755 so the webserver can follow cur │
│ rent/ into builds/ │
│ "d ${cfg.stateDir} 0755 ${cfg.user} ${ │
│ cfg.group} - -" │
│ "f ${cfg.stateDir}/trigger 0644 ${cfg. │
│ user} ${cfg.group} - -" │
│ ]; │
│ │
│ systemd.services.ooknet-site-build = { │
│ description = "ooknet.org site build"; │
│ # webhook/timer-driven oneshot: activa │
│ tion must not run builds or │
│ # inherit their failures │
│ restartIfChanged = false; │
│ serviceConfig = { │
│ Type = "oneshot"; │
│ User = cfg.user; │
│ Group = cfg.group; │
│ ExecStart = lib.getExe buildScript; │
│ WorkingDirectory = cfg.stateDir; │
│ TimeoutStartSec = "15min"; │
│ # coalesce push bursts before the ex │
│ pensive part starts │
│ ExecStartPre = "${pkgs.coreutils}/bi │
│ n/sleep 3"; │
│ PrivateTmp = true; │
│ NoNewPrivileges = true; │
│ }; │
│ }; │
│ │
│ systemd.timers.ooknet-site-build = mkIf │
│ (cfg.timer != null) { │
│ description = "ooknet.org site rebuild │
│ backstop"; │
│ wantedBy = ["timers.target"]; │
│ timerConfig = { │
│ OnCalendar = cfg.timer; │
│ Persistent = true; │
│ RandomizedDelaySec = "10m"; │
│ }; │
│ }; │
│ │
│ systemd.paths.ooknet-site-trigger = mkIf │
│ cfg.webhook.enable { │
│ description = "ooknet.org rebuild trig │
│ ger"; │
│ wantedBy = ["paths.target"]; │
│ pathConfig = { │
│ PathModified = "${cfg.stateDir}/trig │
│ ger"; │
│ Unit = "ooknet-site-build.service"; │
│ }; │
│ }; │
│ │
│ systemd.sockets.ooknet-site-webhook = mk │
│ If cfg.webhook.enable { │
│ description = "ooknet.org webhook sock │
│ et"; │
│ wantedBy = ["sockets.target"]; │
│ listenStreams = ["${cfg.webhook.addres │
│ s}:${toString cfg.webhook.port}"]; │
│ socketConfig.Accept = true; │
│ }; │
│ │
│ systemd.services."ooknet-site-webhook@" │
│ = mkIf cfg.webhook.enable { │
│ description = "ooknet.org webhook hand │
│ ler"; │
│ serviceConfig = { │
│ User = cfg.user; │
│ Group = cfg.group; │
│ StandardInput = "socket"; │
│ StandardOutput = "socket"; │
│ ExecStart = lib.getExe webhookHandle │
│ r; │
│ PrivateTmp = true; │
│ NoNewPrivileges = true; │
│ }; │
│ }; │
│ }; │
│ } │
└──────────────────────────────────────────────┘
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET