From 3d5c125006d542ea2f5a127fe5c1f79a210b163d Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 7 Apr 2026 16:04:48 +0100 Subject: [PATCH] refactor: refactor the site into a module --- hosts/lab/default.nix | 3 +- hosts/lab/sites.nix | 23 +++++ modules/site.nix | 206 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 hosts/lab/sites.nix create mode 100644 modules/site.nix diff --git a/hosts/lab/default.nix b/hosts/lab/default.nix index 247bf04..af6e9a1 100644 --- a/hosts/lab/default.nix +++ b/hosts/lab/default.nix @@ -6,7 +6,8 @@ ./foundry.nix ./dokuwiki.nix ./forgejo.nix - ./wynne.nix + ../../modules/site.nix + ./sites.nix ./fail2ban.nix ./uptime-kuma.nix ]; diff --git a/hosts/lab/sites.nix b/hosts/lab/sites.nix new file mode 100644 index 0000000..fe4cd86 --- /dev/null +++ b/hosts/lab/sites.nix @@ -0,0 +1,23 @@ +{ ... }: +let + wynneDataDir = "/srv/website"; +in +{ + services.site.wynne = { + domain = "wynne.rs"; + redirectDomains = [ "ily.rs" ]; + repo = "https://git.ily.rs/lew/website"; + branch = "master"; + port = 4322; + packageManager = "pnpm"; + dataDir = wynneDataDir; + environment = { + ASTRO_DB_REMOTE_URL = "file:${wynneDataDir}/data/guestbook.db"; + }; + buildEnvironment = { + ASTRO_DB_REMOTE_URL = "file:${wynneDataDir}/data/guestbook.db"; + }; + readWritePaths = [ "${wynneDataDir}/data" ]; + afterServices = [ "forgejo.service" ]; + }; +} diff --git a/modules/site.nix b/modules/site.nix new file mode 100644 index 0000000..f4f68cd --- /dev/null +++ b/modules/site.nix @@ -0,0 +1,206 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) mkOption types mkIf mkMerge mapAttrsToList optional; + + siteModule = types.submodule ({ name, config, ... }: { + options = { + domain = mkOption { + type = types.str; + description = "Primary domain name."; + }; + + redirectDomains = mkOption { + type = types.listOf types.str; + default = []; + description = "Domains that redirect to the primary domain."; + }; + + repo = mkOption { + type = types.str; + description = "Git repository URL."; + }; + + branch = mkOption { + type = types.str; + default = "main"; + }; + + port = mkOption { + type = types.port; + description = "Port the Node.js server listens on."; + }; + + webhookPort = mkOption { + type = types.port; + default = config.port + 1; + description = "Port for the rebuild webhook listener."; + }; + + packageManager = mkOption { + type = types.enum [ "npm" "pnpm" ]; + default = "pnpm"; + }; + + entryPoint = mkOption { + type = types.str; + default = "dist/server/entry.mjs"; + description = "Node.js entry point relative to repo root."; + }; + + environment = mkOption { + type = types.attrsOf types.str; + default = {}; + description = "Extra environment variables for the running server."; + }; + + buildEnvironment = mkOption { + type = types.attrsOf types.str; + default = {}; + description = "Extra environment variables for building."; + }; + + dataDir = mkOption { + type = types.str; + default = "/srv/${name}"; + }; + + readWritePaths = mkOption { + type = types.listOf types.str; + default = []; + description = "Extra paths the server can write to at runtime."; + }; + + afterServices = mkOption { + type = types.listOf types.str; + default = []; + description = "Systemd units to wait for before building."; + }; + }; + }); + + cfg = config.services.site; + + makeSiteConfig = name: site: + let + dataDir = site.dataDir; + pmBin = + if site.packageManager == "pnpm" + then "${pkgs.pnpm}/bin/pnpm" + else "${pkgs.nodejs}/bin/npm"; + installCmd = + if site.packageManager == "pnpm" + then "${pmBin} install --frozen-lockfile" + else "${pmBin} ci"; + in + { + services.caddy.virtualHosts = { + ${site.domain} = { + extraConfig = '' + reverse_proxy localhost:${toString site.port} + encode zstd gzip + ''; + }; + } // builtins.listToAttrs (map (d: { + name = d; + value.extraConfig = '' + redir https://${site.domain}{uri} permanent + ''; + }) site.redirectDomains); + + systemd.services.${name} = { + description = site.domain; + environment = { + HOST = "127.0.0.1"; + PORT = toString site.port; + } // site.environment; + serviceConfig = { + Type = "simple"; + WorkingDirectory = "${dataDir}/repo"; + ExecStart = "${pkgs.nodejs}/bin/node ${site.entryPoint}"; + Restart = "on-failure"; + User = name; + Group = name; + ReadWritePaths = site.readWritePaths; + }; + }; + + systemd.services."${name}-rebuild" = { + description = "Clone/pull and build ${site.domain}"; + after = [ "network-online.target" ] ++ site.afterServices; + path = [ pkgs.nodejs pkgs.bash ] + ++ optional (site.packageManager == "pnpm") pkgs.pnpm; + environment = site.buildEnvironment; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = false; + ExecStartPre = "+${pkgs.writeShellScript "prepare-${name}" '' + mkdir -p ${dataDir} + chown -R ${name}:${name} ${dataDir} + ''}"; + ExecStart = pkgs.writeShellScript "rebuild-${name}" '' + set -euo pipefail + if [ ! -d ${dataDir}/repo/.git ]; then + ${pkgs.git}/bin/git clone ${site.repo} ${dataDir}/repo + fi + cd ${dataDir}/repo + ${pkgs.git}/bin/git fetch origin + ${pkgs.git}/bin/git reset --hard origin/${site.branch} + ${installCmd} + ${pmBin} run build + ''; + ExecStartPost = "+/run/current-system/sw/bin/systemctl restart ${name}"; + User = name; + Group = name; + }; + }; + + systemd.paths."${name}-rebuild-trigger" = { + description = "Watch for ${name} rebuild trigger"; + wantedBy = [ "multi-user.target" ]; + pathConfig = { + PathModified = "${dataDir}/trigger"; + Unit = "${name}-rebuild.service"; + }; + }; + + systemd.services."${name}-webhook" = { + description = "Webhook listener for ${site.domain}"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + ExecStart = let + hooks = pkgs.writeText "${name}-hooks.json" (builtins.toJSON [{ + id = "${name}-rebuild"; + execute-command = "/run/current-system/sw/bin/touch"; + pass-arguments-to-command = [ + { source = "string"; name = "${dataDir}/trigger"; } + ]; + }]); + in "${pkgs.webhook}/bin/webhook -hooks ${hooks} -port ${toString site.webhookPort} -verbose"; + Restart = "always"; + User = name; + Group = name; + }; + }; + + users.users.${name} = { + isSystemUser = true; + group = name; + home = dataDir; + }; + users.groups.${name} = {}; + }; + +in +{ + options.services.site = mkOption { + type = types.attrsOf siteModule; + default = {}; + description = "Node.js web site services with git clone, build, and webhook support."; + }; + + config = mkIf (cfg != {}) (mkMerge (mapAttrsToList makeSiteConfig cfg)); +}