Kieran's opinionated (and probably slightly dumb) nix config

feat: refactor mkservice

dunkirk.sh 86560cce a8bbcefd

verified
+133 -250
+8 -9
machines/terebithia/default.nix
··· 382 382 atelier.services.cachet = { 383 383 enable = true; 384 384 domain = "cachet.dunkirk.sh"; 385 - deploy.repository = "https://github.com/taciturnaxolotl/cachet"; 385 + repository = "https://github.com/taciturnaxolotl/cachet"; 386 386 secretsFile = config.age.secrets.cachet.path; 387 387 }; 388 388 389 389 atelier.services.hn-alerts = { 390 390 enable = true; 391 391 domain = "hn.dunkirk.sh"; 392 - deploy.repository = "https://github.com/taciturnaxolotl/hn-alerts"; 392 + repository = "https://github.com/taciturnaxolotl/hn-alerts"; 393 393 secretsFile = config.age.secrets.hn-alerts.path; 394 394 }; 395 395 ··· 485 485 atelier.services.indiko = { 486 486 enable = true; 487 487 domain = "indiko.dunkirk.sh"; 488 - deploy.repository = "https://github.com/taciturnaxolotl/indiko"; 488 + repository = "https://github.com/taciturnaxolotl/indiko"; 489 489 }; 490 490 491 491 atelier.services.l4 = { 492 492 enable = true; 493 493 domain = "l4.dunkirk.sh"; 494 494 port = 3004; 495 - deploy.repository = "https://github.com/taciturnaxolotl/l4"; 496 - deploy.autoUpdate = false; 495 + repository = "https://github.com/taciturnaxolotl/l4"; 497 496 secretsFile = config.age.secrets.l4.path; 498 497 }; 499 498 500 499 atelier.services.control = { 501 500 enable = true; 502 501 domain = "control.dunkirk.sh"; 503 - deploy.repository = "https://github.com/taciturnaxolotl/control"; 504 - deploy.autoUpdate = false; 502 + repository = "https://github.com/taciturnaxolotl/control"; 505 503 secretsFile = config.age.secrets.control.path; 506 504 507 505 flags."map.dunkirk.sh" = { ··· 524 522 atelier.services.traverse = { 525 523 enable = true; 526 524 domain = "traverse.dunkirk.sh"; 527 - deploy.repository = "https://github.com/taciturnaxolotl/traverse"; 525 + repository = "https://github.com/taciturnaxolotl/traverse"; 528 526 }; 529 527 530 528 atelier.services.herald = { ··· 550 548 atelier.services.canvas-mcp = { 551 549 enable = true; 552 550 domain = "canvas.dunkirk.sh"; 553 - deploy.repository = "https://github.com/taciturnaxolotl/canvas-mcp"; 551 + repository = "https://github.com/taciturnaxolotl/canvas-mcp"; 554 552 secretsFile = config.age.secrets.canvas-mcp.path; 555 553 environment = { 556 554 DKIM_PRIVATE_KEY_FILE = "${config.age.secrets.canvas-mcp-dkim.path}"; ··· 560 558 atelier.services.cedarlogic = { 561 559 enable = true; 562 560 domain = "cedarlogic.dunkirk.sh"; 561 + repository = "https://github.com/taciturnaxolotl/CedarLogic"; 563 562 secretsFile = config.age.secrets.cedarlogic.path; 564 563 }; 565 564
+38 -64
modules/lib/mkService.nix
··· 2 2 # 3 3 # Creates a standardized NixOS service module with: 4 4 # - Common options (domain, port, dataDir, secrets, etc.) 5 - # - Systemd service with git-based deployment 5 + # - Systemd service with initial git clone for scaffolding 6 6 # - Caddy reverse proxy configuration 7 7 # - Automatic backup integration via data declarations 8 + # 9 + # Subsequent deployments are handled by per-repo GitHub Actions 10 + # workflows that SSH in as the service user, git pull, and restart. 8 11 # 9 12 # Usage in a service module: 10 13 # let ··· 23 26 name, 24 27 description ? "${name} service", 25 28 defaultPort ? 3000, 26 - 29 + 27 30 # Runtime configuration 28 31 runtime ? "bun", # "bun" | "node" | "custom" 29 32 entryPoint ? "src/index.ts", 30 33 startCommand ? null, # Override the start command entirely 31 - 34 + 32 35 # Additional options specific to this service 33 36 extraOptions ? {}, 34 - 37 + 35 38 # Additional config when service is enabled 36 39 # Receives cfg (the service config) as argument 37 40 extraConfig ? cfg: {}, ··· 78 81 description = "Path to agenix secrets file"; 79 82 }; 80 83 81 - # Git-based deployment 82 - deploy = { 83 - enable = lib.mkEnableOption "Git-based deployment" // { default = true; }; 84 - 85 - repository = lib.mkOption { 86 - type = lib.types.nullOr lib.types.str; 87 - default = null; 88 - description = "Git repository URL for auto-deployment"; 89 - }; 90 - 91 - autoUpdate = lib.mkEnableOption "Automatically git pull on service restart"; 92 - 93 - branch = lib.mkOption { 94 - type = lib.types.str; 95 - default = "main"; 96 - description = "Git branch to deploy"; 97 - }; 84 + # Git repository for initial scaffolding (clone on first start) 85 + # Subsequent deploys are handled by GitHub Actions workflows 86 + repository = lib.mkOption { 87 + type = lib.types.nullOr lib.types.str; 88 + default = null; 89 + description = "Git repository URL — cloned once on first start for scaffolding"; 98 90 }; 99 91 100 92 # Data declarations for automatic backup ··· 225 217 after = [ "network.target" ]; 226 218 path = [ pkgs.git pkgs.openssh ]; 227 219 228 - preStart = lib.optionalString (cfg.deploy.enable && cfg.deploy.repository != null) '' 220 + preStart = lib.optionalString (cfg.repository != null) '' 229 221 set -e 230 - # Clone repository if not present 222 + # Clone repository on first start (scaffolding only) 231 223 if [ ! -d ${cfg.dataDir}/app/.git ]; then 232 - ${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${cfg.dataDir}/app 224 + ${pkgs.git}/bin/git clone ${cfg.repository} ${cfg.dataDir}/app 233 225 fi 234 - 235 - cd ${cfg.dataDir}/app 236 - '' + lib.optionalString (cfg.deploy.enable && cfg.deploy.autoUpdate) '' 237 - ${pkgs.git}/bin/git fetch origin || true 238 - ${pkgs.git}/bin/git reset --hard origin/${cfg.deploy.branch} || true 239 226 '' + lib.optionalString (runtime == "bun") '' 240 - 241 - if [ -f package.json ]; then 242 - echo "Installing dependencies..." 243 - ${pkgs.unstable.bun}/bin/bun install || { 244 - echo "Failed to install dependencies, trying again..." 245 - ${pkgs.unstable.bun}/bin/bun install 246 - } 227 + 228 + # Install deps only on first clone (no node_modules yet) 229 + if [ -f ${cfg.dataDir}/app/package.json ] && [ ! -d ${cfg.dataDir}/app/node_modules ]; then 230 + cd ${cfg.dataDir}/app 231 + echo "First start: installing dependencies..." 232 + ${pkgs.unstable.bun}/bin/bun install 247 233 fi 248 234 '' + lib.optionalString (runtime == "node") '' 249 - 250 - if [ -f package.json ]; then 251 - echo "Installing dependencies..." 252 - ${pkgs.nodejs_20}/bin/npm ci --production || { 253 - echo "Failed to install dependencies, trying again..." 254 - ${pkgs.nodejs_20}/bin/npm ci --production 255 - } 235 + 236 + # Install deps only on first clone (no node_modules yet) 237 + if [ -f ${cfg.dataDir}/app/package.json ] && [ ! -d ${cfg.dataDir}/app/node_modules ]; then 238 + cd ${cfg.dataDir}/app 239 + echo "First start: installing dependencies..." 240 + ${pkgs.nodejs_20}/bin/npm ci --production 256 241 fi 257 242 ''; 258 243 ··· 271 256 RestartSec = "10s"; 272 257 TimeoutStartSec = "60s"; 273 258 274 - # Automatic state directory management 275 - # Creates /var/lib/${name} with proper ownership before namespace setup 276 - StateDirectory = name; 277 - StateDirectoryMode = "0755"; 278 - 279 259 # Security hardening 280 260 NoNewPrivileges = true; 281 261 ProtectSystem = "strict"; 282 262 ProtectHome = true; 283 263 PrivateTmp = true; 264 + 265 + # ExecStartPre with ! runs as root before namespace setup, 266 + # guaranteeing dirs exist before WorkingDirectory is checked 267 + ExecStartPre = [ 268 + "!${pkgs.writeShellScript "${name}-setup" '' 269 + mkdir -p ${cfg.dataDir}/app ${cfg.dataDir}/data 270 + chown -R ${name}:services ${cfg.dataDir} 271 + chmod -R g+rwX ${cfg.dataDir} 272 + ''}" 273 + ]; 284 274 }; 285 - 286 - serviceConfig.ExecStartPre = [ 287 - # Run before preStart, creates directories so WorkingDirectory exists 288 - "!${pkgs.writeShellScript "${name}-setup" '' 289 - mkdir -p ${cfg.dataDir}/app/data 290 - mkdir -p ${cfg.dataDir}/data 291 - chown -R ${name}:services ${cfg.dataDir} 292 - chmod -R g+rwX ${cfg.dataDir} 293 - ''}" 294 - ]; 295 275 }; 296 - 297 - # StateDirectory handles base dir, tmpfiles creates subdirectories 298 - systemd.tmpfiles.rules = [ 299 - "d ${cfg.dataDir}/app 0755 ${name} services -" 300 - "d ${cfg.dataDir}/data 0755 ${name} services -" 301 - ]; 302 276 303 277 # Caddy reverse proxy 304 278 services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable {
+87 -177
modules/nixos/services/cedarlogic.nix
··· 1 1 # CedarLogic - Web-based circuit simulator 2 2 # 3 - # Custom module (not mkService) because: 4 - # - App lives in web/ subdirectory of the repo 5 - # - Needs a Vite build step before serving 6 - # - Multi-port: API (3000), Hocuspocus WS (3001), Cursor WS (3002) 7 - # - Caddy needs path-based routing to different backends 3 + # Multi-port service: API, Hocuspocus WS, Cursor WS 4 + # App lives in web/ subdirectory, needs Vite build step 8 5 9 6 { config, lib, pkgs, ... }: 10 7 11 8 let 9 + mkService = import ../../lib/mkService.nix; 12 10 cfg = config.atelier.services.cedarlogic; 13 - appDir = "${cfg.dataDir}/app"; 14 - webDir = "${appDir}/web"; 15 - in 16 - { 17 - options.atelier.services.cedarlogic = { 18 - enable = lib.mkEnableOption "CedarLogic circuit simulator"; 19 - 20 - domain = lib.mkOption { 21 - type = lib.types.str; 22 - description = "Domain to serve CedarLogic on"; 23 - }; 24 - 25 - dataDir = lib.mkOption { 26 - type = lib.types.path; 27 - default = "/var/lib/cedarlogic"; 28 - description = "Directory to store CedarLogic data"; 29 - }; 30 - 31 - port = lib.mkOption { 32 - type = lib.types.port; 33 - default = 3100; 34 - description = "Port for the HTTP API server"; 35 - }; 36 - 37 - wsPort = lib.mkOption { 38 - type = lib.types.port; 39 - default = 3101; 40 - description = "Port for the Hocuspocus WebSocket server"; 41 - }; 11 + webDir = "${cfg.dataDir}/app/web"; 42 12 43 - cursorPort = lib.mkOption { 44 - type = lib.types.port; 45 - default = 3102; 46 - description = "Port for the cursor relay WebSocket server"; 47 - }; 13 + baseModule = mkService { 14 + name = "cedarlogic"; 15 + description = "CedarLogic circuit simulator"; 16 + defaultPort = 3100; 17 + runtime = "custom"; 18 + startCommand = "cd ${webDir} && exec ${pkgs.unstable.bun}/bin/bun run src/server/index.ts"; 48 19 49 - secretsFile = lib.mkOption { 50 - type = lib.types.nullOr lib.types.path; 51 - default = null; 52 - description = "Path to secrets file (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, JWT_SECRET)"; 53 - }; 20 + extraOptions = { 21 + wsPort = lib.mkOption { 22 + type = lib.types.port; 23 + default = 3101; 24 + description = "Port for the Hocuspocus WebSocket server"; 25 + }; 54 26 55 - deploy = { 56 - repository = lib.mkOption { 57 - type = lib.types.str; 58 - default = "https://github.com/taciturnaxolotl/CedarLogic"; 59 - description = "Git repository URL"; 27 + cursorPort = lib.mkOption { 28 + type = lib.types.port; 29 + default = 3102; 30 + description = "Port for the cursor relay WebSocket server"; 60 31 }; 61 32 62 33 branch = lib.mkOption { 63 34 type = lib.types.str; 64 35 default = "web"; 65 - description = "Git branch to deploy"; 36 + description = "Git branch to clone"; 66 37 }; 67 38 }; 68 - }; 69 39 70 - config = lib.mkIf cfg.enable { 71 - # User and group 72 - users.groups.services = {}; 40 + extraConfig = cfg: { 41 + atelier.services.cedarlogic.environment = { 42 + WS_PORT = toString cfg.wsPort; 43 + CURSOR_PORT = toString cfg.cursorPort; 44 + DATABASE_PATH = "${cfg.dataDir}/data/cedarlogic.db"; 45 + GOOGLE_REDIRECT_URI = "https://${cfg.domain}/auth/google/callback"; 46 + }; 73 47 74 - users.users.cedarlogic = { 75 - isSystemUser = true; 76 - group = "cedarlogic"; 77 - extraGroups = [ "services" ]; 78 - home = cfg.dataDir; 79 - createHome = true; 80 - shell = pkgs.bash; 81 - }; 82 - 83 - users.groups.cedarlogic = {}; 84 - 85 - # Caddy needs to read static files from the dist directory 86 - users.users.caddy.extraGroups = [ "cedarlogic" "services" ]; 87 - 88 - # Allow cedarlogic user to restart its own service (for SSH deploys) 89 - security.sudo.extraRules = [ 90 - { 91 - users = [ "cedarlogic" ]; 92 - commands = [ 93 - { 94 - command = "/run/current-system/sw/bin/systemctl restart cedarlogic.service"; 95 - options = [ "NOPASSWD" ]; 96 - } 97 - ]; 98 - } 99 - ]; 48 + # Disable default caddy — we need path-based routing to 3 backends 49 + atelier.services.cedarlogic.caddy.enable = false; 100 50 101 - # Systemd service 102 - systemd.services.cedarlogic = { 103 - description = "CedarLogic circuit simulator"; 104 - wantedBy = [ "multi-user.target" ]; 105 - after = [ "network.target" ]; 106 - path = [ pkgs.git pkgs.openssh pkgs.unstable.bun ]; 51 + # Data declarations for automatic backup 52 + atelier.services.cedarlogic.data = { 53 + sqlite = "${cfg.dataDir}/data/cedarlogic.db"; 54 + }; 107 55 108 - preStart = '' 109 - set -e 56 + # Caddy needs to read static files from the dist directory 57 + users.users.caddy.extraGroups = [ "cedarlogic" "services" ]; 110 58 111 - # Clone if not present 112 - if [ ! -d ${appDir}/.git ]; then 113 - ${pkgs.git}/bin/git clone -b ${cfg.deploy.branch} ${cfg.deploy.repository} ${appDir} 114 - fi 59 + # Longer timeout for Vite build 60 + systemd.services.cedarlogic.serviceConfig = { 61 + TimeoutStartSec = lib.mkForce "120s"; 62 + UMask = "0022"; 63 + }; 115 64 65 + # Build step: install deps + parse gates + vite build 66 + systemd.services.cedarlogic.preStart = lib.mkAfter '' 116 67 cd ${webDir} 117 68 118 - # Install dependencies 119 69 if [ -f package.json ]; then 120 70 ${pkgs.unstable.bun}/bin/bun install 121 71 fi ··· 127 77 ${pkgs.unstable.bun}/bin/bun run build 128 78 ''; 129 79 130 - serviceConfig = { 131 - Type = "exec"; 132 - User = "cedarlogic"; 133 - Group = "cedarlogic"; 134 - # Don't set WorkingDirectory — preStart needs to run before 135 - # the repo is cloned, and systemd applies it to all stages. 136 - # Instead, ExecStart cd's into webDir. 137 - EnvironmentFile = lib.mkIf (cfg.secretsFile != null) cfg.secretsFile; 138 - Environment = [ 139 - "NODE_ENV=production" 140 - "PORT=${toString cfg.port}" 141 - "WS_PORT=${toString cfg.wsPort}" 142 - "CURSOR_PORT=${toString cfg.cursorPort}" 143 - "DATABASE_PATH=${cfg.dataDir}/data/cedarlogic.db" 144 - "GOOGLE_REDIRECT_URI=https://${cfg.domain}/auth/google/callback" 145 - ]; 146 - ExecStart = "${pkgs.bash}/bin/bash -c 'cd ${webDir} && exec ${pkgs.unstable.bun}/bin/bun run src/server/index.ts'"; 147 - Restart = "on-failure"; 148 - RestartSec = "10s"; 149 - TimeoutStartSec = "120s"; 150 - 151 - StateDirectory = "cedarlogic"; 152 - StateDirectoryMode = "0755"; 153 - 154 - UMask = "0022"; 155 - NoNewPrivileges = true; 156 - ProtectSystem = "strict"; 157 - ProtectHome = true; 158 - PrivateTmp = true; 159 - }; 160 - 161 - serviceConfig.ExecStartPre = [ 162 - "!${pkgs.writeShellScript "cedarlogic-setup" '' 163 - mkdir -p ${webDir} 164 - mkdir -p ${cfg.dataDir}/data 165 - chown -R cedarlogic:services ${cfg.dataDir} 166 - chmod -R g+rwX ${cfg.dataDir} 167 - ''}" 168 - ]; 169 - }; 170 - 171 - systemd.tmpfiles.rules = [ 172 - "d ${appDir} 0755 cedarlogic services -" 173 - "d ${cfg.dataDir}/data 0755 cedarlogic services -" 174 - ]; 175 - 176 - # Caddy - path-based routing to 3 backends + static file serving 177 - services.caddy.virtualHosts.${cfg.domain} = { 178 - extraConfig = '' 179 - tls { 180 - dns cloudflare {env.CLOUDFLARE_API_TOKEN} 181 - } 182 - header { 183 - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 184 - } 80 + # Caddy - path-based routing to 3 backends + static file serving 81 + services.caddy.virtualHosts.${cfg.domain} = { 82 + extraConfig = '' 83 + tls { 84 + dns cloudflare {env.CLOUDFLARE_API_TOKEN} 85 + } 86 + header { 87 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" 88 + } 185 89 186 - # Hocuspocus WebSocket (Yjs collaboration) 187 - handle /ws { 188 - reverse_proxy localhost:${toString cfg.wsPort} 189 - } 90 + # Hocuspocus WebSocket (Yjs collaboration) 91 + handle /ws { 92 + reverse_proxy localhost:${toString cfg.wsPort} 93 + } 190 94 191 - # Cursor relay WebSocket 192 - handle /cursor-ws { 193 - reverse_proxy localhost:${toString cfg.cursorPort} 194 - } 95 + # Cursor relay WebSocket 96 + handle /cursor-ws { 97 + reverse_proxy localhost:${toString cfg.cursorPort} 98 + } 195 99 196 - # API and auth routes 197 - handle /api/* { 198 - reverse_proxy localhost:${toString cfg.port} 199 - } 200 - handle /auth/* { 201 - reverse_proxy localhost:${toString cfg.port} 202 - } 100 + # API and auth routes 101 + handle /api/* { 102 + reverse_proxy localhost:${toString cfg.port} 103 + } 104 + handle /auth/* { 105 + reverse_proxy localhost:${toString cfg.port} 106 + } 203 107 204 - # Static files (Vite build output + WASM) 205 - handle { 206 - root * ${webDir}/dist 207 - try_files {path} /index.html 208 - file_server 209 - } 210 - ''; 108 + # Static files (Vite build output + WASM) 109 + handle { 110 + root * ${webDir}/dist 111 + try_files {path} /index.html 112 + file_server 113 + } 114 + ''; 115 + }; 211 116 }; 117 + }; 118 + in 119 + { 120 + imports = [ baseModule ]; 212 121 213 - # Backup config 214 - atelier.backup.services.cedarlogic = { 215 - paths = [ "${cfg.dataDir}/data" ]; 216 - exclude = [ "*.log" ]; 217 - preBackup = "systemctl stop cedarlogic"; 218 - postBackup = "systemctl start cedarlogic"; 219 - }; 122 + # Override the initial clone to use the non-default branch 123 + config = lib.mkIf cfg.enable { 124 + systemd.services.cedarlogic.preStart = lib.mkBefore '' 125 + set -e 126 + if [ ! -d ${cfg.dataDir}/app/.git ]; then 127 + ${pkgs.git}/bin/git clone -b ${cfg.branch} ${cfg.repository} ${cfg.dataDir}/app 128 + fi 129 + ''; 220 130 }; 221 131 }