OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
 
master @ 291 LINES
 
[ HISTORY ]  [ UP ]
 

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

# 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