From 2948f8878f6bf3284a80c473079ffa1ffa36780b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20K=C3=B6nig?= Date: Tue, 9 Jun 2026 07:46:54 +0200 Subject: [PATCH] docs: plan frappe bench infrastructure --- .../2026-06-09-frappe-bench-infrastructure.md | 786 ++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-frappe-bench-infrastructure.md diff --git a/docs/superpowers/plans/2026-06-09-frappe-bench-infrastructure.md b/docs/superpowers/plans/2026-06-09-frappe-bench-infrastructure.md new file mode 100644 index 0000000..f6cc329 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-frappe-bench-infrastructure.md @@ -0,0 +1,786 @@ +# Frappe Bench Infrastructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert the current Frappe LMS container setup into a production-style, multisite-capable Frappe Bench backed by host MariaDB, host Redis, and agenix-managed secrets. + +**Architecture:** MariaDB and Redis become host infrastructure modules under `hosts/AZ-CLD-1/services/`. The Frappe runtime becomes one shared Bench module under `hosts/AZ-CLD-1/services/containers/frappe.nix`, with separate backend, frontend, websocket, worker, and scheduler containers. Secrets are provided through agenix and consumed via `config.age.secrets.frappe-env.path`. + +**Tech Stack:** NixOS modules, Podman OCI containers, MariaDB, Redis, Frappe Bench, Traefik, agenix, alejandra, `nixos-rebuild build`. + +**Execution constraints:** Do not run `nixos-rebuild switch`. Do not run `git push`. Build-only validation is allowed. + +--- + +## File Structure + +- Create: `hosts/AZ-CLD-1/services/mariadb.nix` + - Owns host MariaDB configuration, Frappe DB admin bootstrap, firewall rules, and MariaDB dump timer. +- Create: `hosts/AZ-CLD-1/services/redis.nix` + - Owns host Redis configuration and firewall rules. +- Rename/create: `hosts/AZ-CLD-1/services/containers/frappe.nix` + - Replaces `frappe-lms.nix` as the shared Bench runtime. + - Owns Frappe directories, Bench config, site/app initialization, containers, backup timer, Traefik route, and CLI helper. +- Modify: `hosts/AZ-CLD-1/services/default.nix` + - Imports `mariadb.nix` and `redis.nix`. +- Modify: `hosts/AZ-CLD-1/services/containers/default.nix` + - Imports `frappe.nix` instead of the old LMS-specific file. +- Modify: `hosts/common/ports.nix` + - Renames the internal service port key from `frappe-lms` to `frappe` while preserving port `3052`. +- Modify: `hosts/AZ-CLD-1/secrets.nix` + - Adds the host-local agenix secret reference `frappe-env`. +- Modify: `secrets.nix` + - Adds `secrets/frappe-env.age` to the public key registry. +- Create: `secrets/frappe-env.age` + - Encrypted agenix env file containing `FRAPPE_DB_ADMIN_PASSWORD` and `FRAPPE_ADMIN_PASSWORD`. + +--- + +## Task 1: Add agenix Secret Plumbing + +**Files:** +- Create: `secrets/frappe-env.age` +- Modify: `secrets.nix` +- Modify: `hosts/AZ-CLD-1/secrets.nix` + +- [ ] **Step 1: Generate and encrypt the Frappe env secret** + +Run: + +```bash +set -euo pipefail +db_secret_value="$(${openssl:-openssl} rand -base64 48 | tr -d '\n')" +admin_secret_value="$(${openssl:-openssl} rand -base64 32 | tr -d '\n')" +tmp_secret="$(mktemp)" +cat > "$tmp_secret" < "$1"'\'' sh' agenix -e secrets/frappe-env.age +rm -f "$tmp_secret" +``` + +Expected: `secrets/frappe-env.age` exists and contains encrypted data. + +If `openssl` is not in PATH, run inside the dev shell: + +```bash +nix develop +``` + +- [ ] **Step 2: Register the encrypted secret in the global agenix registry** + +In `secrets.nix`, add this line near the other `secrets/*.age` entries: + +```nix + "secrets/frappe-env.age".publicKeys = systems ++ users; +``` + +- [ ] **Step 3: Register the secret for `AZ-CLD-1`** + +In `hosts/AZ-CLD-1/secrets.nix`, add this attribute inside `age.secrets`: + +```nix + frappe-env = { + file = ../../secrets/frappe-env.age; + }; +``` + +- [ ] **Step 4: Format the changed Nix files** + +Run: + +```bash +alejandra secrets.nix hosts/AZ-CLD-1/secrets.nix +``` + +Expected: both files format successfully. + +- [ ] **Step 5: Verify the secret path is visible in the host config** + +Run: + +```bash +nix eval --raw .#nixosConfigurations.AZ-CLD-1.config.age.secrets.frappe-env.file +``` + +Expected output ends with: + +```text +secrets/frappe-env.age +``` + +- [ ] **Step 6: Commit the secret plumbing** + +Run: + +```bash +git add secrets/frappe-env.age secrets.nix hosts/AZ-CLD-1/secrets.nix +git commit -m "secrets: add frappe env secret" +``` + +--- + +## Task 2: Add Host MariaDB Infrastructure + +**Files:** +- Create: `hosts/AZ-CLD-1/services/mariadb.nix` +- Modify: `hosts/AZ-CLD-1/services/default.nix` + +- [ ] **Step 1: Create the MariaDB host module** + +Create `hosts/AZ-CLD-1/services/mariadb.nix` with this content: + +```nix +{ + config, + pkgs, + ... +}: let + frappeEnvFile = config.age.secrets.frappe-env.path; + frappeDbAdminUser = "frappe_admin"; + + waitForMariaDb = '' + mariadb_ready=0 + for attempt in $(${pkgs.coreutils}/bin/seq 1 60); do + if ${pkgs.mariadb_118}/bin/mysql -u root -N -e 'SELECT 1' >/dev/null 2>&1; then + mariadb_ready=1 + break + fi + ${pkgs.coreutils}/bin/sleep 2 + done + + if [ "$mariadb_ready" != "1" ]; then + echo "MariaDB did not become ready in time" >&2 + exit 1 + fi + ''; +in { + services.mysql = { + enable = true; + package = pkgs.mariadb_118; + settings.mysqld = { + bind-address = "0.0.0.0"; + character-set-server = "utf8mb4"; + collation-server = "utf8mb4_unicode_ci"; + skip-character-set-client-handshake = true; + skip-innodb-read-only-compressed = true; + }; + }; + + networking.firewall.extraCommands = '' + iptables -A INPUT -p tcp -s 127.0.0.1 --dport 3306 -j ACCEPT + iptables -A INPUT -p tcp -s 10.89.0.0/24 --dport 3306 -j ACCEPT + ''; + + systemd.services.mariadb-frappe-admin-init = { + description = "Create shared Frappe MariaDB admin user"; + wantedBy = ["multi-user.target"]; + after = ["mysql.service"]; + requires = ["mysql.service"]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "root"; + Group = "root"; + }; + + script = '' + set -euo pipefail + + set -a + source ${frappeEnvFile} + set +a + + : "''${FRAPPE_DB_ADMIN_PASSWORD:?FRAPPE_DB_ADMIN_PASSWORD is missing in ${frappeEnvFile}}" + + ${waitForMariaDb} + + escaped_db_admin_password=$(${pkgs.coreutils}/bin/printf '%s' "$FRAPPE_DB_ADMIN_PASSWORD" | ${pkgs.python3}/bin/python3 -c 'import sys; print(sys.stdin.read().replace(chr(39), chr(39) * 2), end="")') + + ${pkgs.mariadb_118}/bin/mysql -u root < "$backup_dir/all-$timestamp.sql.gz" + + ${pkgs.findutils}/bin/find "$backup_dir" -type f -name 'all-*.sql.gz' -mtime +14 -delete + ''; + }; + + systemd.timers.mariadb-backup-all = { + wantedBy = ["timers.target"]; + timerConfig = { + OnCalendar = "*-*-* 03:20:00"; + RandomizedDelaySec = "30m"; + Persistent = true; + }; + }; +} +``` + +- [ ] **Step 2: Import the MariaDB module** + +In `hosts/AZ-CLD-1/services/default.nix`, add `./mariadb.nix` near `./postgres.nix`: + +```nix + ./mariadb.nix + ./postgres.nix +``` + +- [ ] **Step 3: Format and evaluate MariaDB config** + +Run: + +```bash +alejandra hosts/AZ-CLD-1/services/mariadb.nix hosts/AZ-CLD-1/services/default.nix +nix eval .#nixosConfigurations.AZ-CLD-1.config.services.mysql.enable +``` + +Expected output: + +```text +true +``` + +- [ ] **Step 4: Verify MariaDB is no longer planned to live in Frappe** + +Run: + +```bash +rg -n "services\.mysql|mariadb-frappe-admin-init" hosts/AZ-CLD-1/services +``` + +Expected: + +- `services.mysql` appears in `hosts/AZ-CLD-1/services/mariadb.nix` only. +- `mariadb-frappe-admin-init` appears in `mariadb.nix` and future Frappe ordering references only. + +- [ ] **Step 5: Commit MariaDB infrastructure** + +Run: + +```bash +git add hosts/AZ-CLD-1/services/mariadb.nix hosts/AZ-CLD-1/services/default.nix +git commit -m "services: add host mariadb for frappe" +``` + +--- + +## Task 3: Add Host Redis Infrastructure + +**Files:** +- Create: `hosts/AZ-CLD-1/services/redis.nix` +- Modify: `hosts/AZ-CLD-1/services/default.nix` + +- [ ] **Step 1: Create the Redis host module** + +Create `hosts/AZ-CLD-1/services/redis.nix` with this content: + +```nix +{...}: { + services.redis.servers."" = { + enable = true; + port = 6379; + bind = null; + databases = 16; + appendOnly = true; + appendFsync = "everysec"; + settings = { + protected-mode = "no"; + }; + }; + + networking.firewall.extraCommands = '' + iptables -A INPUT -p tcp -s 127.0.0.1 --dport 6379 -j ACCEPT + iptables -A INPUT -p tcp -s 10.89.0.0/24 --dport 6379 -j ACCEPT + ''; +} +``` + +- [ ] **Step 2: Import the Redis module** + +In `hosts/AZ-CLD-1/services/default.nix`, add `./redis.nix` near the other infrastructure services: + +```nix + ./redis.nix +``` + +- [ ] **Step 3: Format and evaluate Redis config** + +Run: + +```bash +alejandra hosts/AZ-CLD-1/services/redis.nix hosts/AZ-CLD-1/services/default.nix +nix eval .#nixosConfigurations.AZ-CLD-1.config.services.redis.servers."".enable +``` + +Expected output: + +```text +true +``` + +- [ ] **Step 4: Commit Redis infrastructure** + +Run: + +```bash +git add hosts/AZ-CLD-1/services/redis.nix hosts/AZ-CLD-1/services/default.nix +git commit -m "services: add host redis" +``` + +--- + +## Task 4: Rename and Reshape the Frappe Bench Module + +**Files:** +- Rename/create: `hosts/AZ-CLD-1/services/containers/frappe.nix` +- Remove from imports: `hosts/AZ-CLD-1/services/containers/frappe-lms.nix` +- Modify: `hosts/common/ports.nix` + +- [ ] **Step 1: Rename the Frappe module file** + +Run: + +```bash +if [ -f hosts/AZ-CLD-1/services/containers/frappe-lms.nix ]; then + mv hosts/AZ-CLD-1/services/containers/frappe-lms.nix hosts/AZ-CLD-1/services/containers/frappe.nix +fi +``` + +Expected: `hosts/AZ-CLD-1/services/containers/frappe.nix` exists. + +- [ ] **Step 2: Rename the service port key** + +In `hosts/common/ports.nix`, replace: + +```nix + frappe-lms = 3052; +``` + +with: + +```nix + frappe = 3052; +``` + +- [ ] **Step 3: Replace the top-level Frappe constants** + +At the top of `hosts/AZ-CLD-1/services/containers/frappe.nix`, use this structure in the `let` block: + +```nix + serviceName = "frappe"; + servicePort = config.m3ta.ports.get serviceName; + + primaryDomain = "learn.az-gruppe.com"; + image = "ghcr.io/frappe/lms:stable"; + + dataDir = "/var/lib/${serviceName}"; + sitesDir = "${dataDir}/sites"; + frappeEnvFile = config.age.secrets.frappe-env.path; + + dbHost = "10.89.0.1"; + dbPort = "3306"; + dbAdminUser = "frappe_admin"; + + redisCacheUrl = "redis://10.89.0.1:6379/1"; + redisQueueUrl = "redis://10.89.0.1:6379/2"; + + sites = { + "learn.az-gruppe.com" = { + apps = ["lms"]; + adminPasswordEnv = "FRAPPE_ADMIN_PASSWORD"; + }; + }; + + siteNames = builtins.attrNames sites; +``` + +- [ ] **Step 4: Remove LMS-specific MariaDB and Redis definitions** + +Delete these pieces from `hosts/AZ-CLD-1/services/containers/frappe.nix`: + +```nix + redisCacheIp = "${ipBase}.71"; + redisQueueIp = "${ipBase}.72"; + redisCacheName = "${serviceName}-redis-cache"; + redisQueueName = "${serviceName}-redis-queue"; +``` + +Delete the entire local `services.mysql = { ... };` block. + +Delete the container definitions for: + +```text +frappe-lms-redis-cache +frappe-lms-redis-queue +``` + +- [ ] **Step 5: Update shared app base dependencies** + +In `appBase`, remove container Redis dependencies so it looks like this: + +```nix + appBase = { + inherit image; + autoStart = true; + volumes = ["${sitesDir}:/home/frappe/frappe-bench/sites"]; + }; +``` + +- [ ] **Step 6: Change the directory preparation service** + +Update the directory service so it no longer checks a manual env file under `/var/lib`. Its script should be: + +```nix + script = '' + set -euo pipefail + + ${pkgs.coreutils}/bin/mkdir -p ${dataDir} ${sitesDir} + ${pkgs.coreutils}/bin/chown root:root ${dataDir} + ${pkgs.coreutils}/bin/chmod 0750 ${dataDir} + ${pkgs.coreutils}/bin/chown -R 1000:1000 ${sitesDir} + ${pkgs.coreutils}/bin/chmod 0750 ${sitesDir} + ''; +``` + +- [ ] **Step 7: Update configurator ordering and Redis config** + +The configurator service must order after host MariaDB admin init and host Redis: + +```nix + after = [ + "mariadb-frappe-admin-init.service" + "redis.service" + "${serviceName}-directories.service" + ]; + requires = [ + "mariadb-frappe-admin-init.service" + "redis.service" + "${serviceName}-directories.service" + ]; +``` + +Inside the `bench set-config` commands, use host Redis URLs: + +```bash + bench set-config -g db_host ${dbHost} + bench set-config -gp db_port ${dbPort} + bench set-config -g redis_cache ${redisCacheUrl} + bench set-config -g redis_queue ${redisQueueUrl} + bench set-config -g redis_socketio ${redisQueueUrl} + bench set-config -gp socketio_port 9000 + bench set-config -g chromium_path /usr/bin/chromium-headless-shell +``` + +- [ ] **Step 8: Replace site init with agenix-based multisite init** + +In `${serviceName}-site-init`, source the agenix env file: + +```bash + set -a + source ${frappeEnvFile} + set +a + + : "''${FRAPPE_DB_ADMIN_PASSWORD:?FRAPPE_DB_ADMIN_PASSWORD is missing in ${frappeEnvFile}}" +``` + +For each configured site, use this shell pattern generated from the `sites` attribute set: + +```bash + : "''${FRAPPE_ADMIN_PASSWORD:?FRAPPE_ADMIN_PASSWORD is missing in ${frappeEnvFile}}" + + if [ ! -f ${sitesDir}/learn.az-gruppe.com/site_config.json ]; then + ${pkgs.podman}/bin/podman run --rm \ + --name ${serviceName}-site-init-learn \ + --network web \ + --ip ${transientIp} \ + --env-file ${frappeEnvFile} \ + --env SITE_NAME=learn.az-gruppe.com \ + -v ${sitesDir}:/home/frappe/frappe-bench/sites \ + ${image} \ + bash -c ' + set -euo pipefail + bench new-site \ + --db-host ${dbHost} \ + --db-port ${dbPort} \ + --mariadb-user-host-login-scope=% \ + --db-root-username ${dbAdminUser} \ + --db-root-password "$FRAPPE_DB_ADMIN_PASSWORD" \ + --admin-password "$FRAPPE_ADMIN_PASSWORD" \ + "$SITE_NAME" + ' + else + echo "Frappe site learn.az-gruppe.com already exists" + fi + + for app in lms; do + ${pkgs.podman}/bin/podman run --rm \ + --name ${serviceName}-install-app-$app \ + --network web \ + --ip ${transientIp} \ + --env SITE_NAME=learn.az-gruppe.com \ + --env APP_NAME="$app" \ + -v ${sitesDir}:/home/frappe/frappe-bench/sites \ + ${image} \ + bash -c ' + set -euo pipefail + if bench --site "$SITE_NAME" list-apps | grep -Fxq "$APP_NAME"; then + echo "App $APP_NAME already installed on $SITE_NAME" + else + bench --site "$SITE_NAME" install-app "$APP_NAME" + fi + ' + done +``` + +After this works for `learn.az-gruppe.com`, convert the repeated site/app shell to generated Nix using `lib.concatMapStringsSep` and `lib.escapeShellArg` so future sites can be added by extending `sites`. + +- [ ] **Step 9: Update container names and service references** + +Ensure the container names are: + +```text +frappe-backend +frappe-frontend +frappe-websocket +frappe-queue-short +frappe-queue-long +frappe-scheduler +``` + +Ensure no names retain the `frappe-lms-` prefix. + +- [ ] **Step 10: Update systemd ordering for all Frappe containers** + +Every runtime container override must require site init and host Redis: + +```nix + systemd.services."podman-${backendName}" = { + after = ["${serviceName}-site-init.service" "redis.service"]; + requires = ["${serviceName}-site-init.service" "redis.service"]; + }; +``` + +Apply the same pattern to frontend, websocket, queue-short, queue-long, and scheduler. + +- [ ] **Step 11: Update the CLI helper name and status output** + +The package name must be `frappe` via `serviceName = "frappe"`. The `status` command must check: + +```bash + systemctl status \ + mysql.service \ + redis.service \ + mariadb-frappe-admin-init.service \ + ${serviceName}-configurator.service \ + ${serviceName}-site-init.service \ + podman-${backendName}.service \ + podman-${frontendName}.service \ + podman-${websocketName}.service \ + podman-${queueShortName}.service \ + podman-${queueLongName}.service \ + podman-${schedulerName}.service +``` + +- [ ] **Step 12: Format and statically verify Frappe changes** + +Run: + +```bash +alejandra hosts/AZ-CLD-1/services/containers/frappe.nix hosts/common/ports.nix +rg -n "frappe-lms|redis-cache|redis-queue|services\.mysql|/var/lib/frappe.*\.env" hosts/AZ-CLD-1/services/containers/frappe.nix hosts/common/ports.nix || true +``` + +Expected: no matches for removed local Redis containers, local MySQL service, or manual `/var/lib/frappe/*.env` secret file. + +- [ ] **Step 13: Commit Frappe reshape** + +Run: + +```bash +git add hosts/AZ-CLD-1/services/containers/frappe.nix hosts/AZ-CLD-1/services/containers/frappe-lms.nix hosts/common/ports.nix +git commit -m "services: reshape frappe as shared bench" +``` + +--- + +## Task 5: Enable the Frappe Bench Import and Build-Test + +**Files:** +- Modify: `hosts/AZ-CLD-1/services/containers/default.nix` + +- [ ] **Step 1: Import the new Frappe module** + +In `hosts/AZ-CLD-1/services/containers/default.nix`, replace: + +```nix + # ./frappe-lms.nix +``` + +with: + +```nix + ./frappe.nix +``` + +- [ ] **Step 2: Format the container import file** + +Run: + +```bash +alejandra hosts/AZ-CLD-1/services/containers/default.nix +``` + +Expected: formatting succeeds. + +- [ ] **Step 3: Run focused Nix evaluations** + +Run: + +```bash +nix eval --raw .#nixosConfigurations.AZ-CLD-1.config.services.traefik.dynamicConfigOptions.http.routers.frappe.rule +nix eval .#nixosConfigurations.AZ-CLD-1.config.virtualisation.oci-containers.containers.frappe-backend.autoStart +nix eval .#nixosConfigurations.AZ-CLD-1.config.services.redis.servers."".enable +nix eval .#nixosConfigurations.AZ-CLD-1.config.services.mysql.enable +``` + +Expected outputs: + +```text +Host(`learn.az-gruppe.com`) +true +true +true +``` + +- [ ] **Step 4: Run repository quality checks** + +Run: + +```bash +nix flake check +``` + +Expected: command exits with status `0`. + +- [ ] **Step 5: Run the allowed build test** + +Run: + +```bash +nixos-rebuild build --flake .#AZ-CLD-1 +``` + +Expected: command exits with status `0` and creates/updates `./result`. + +Do not run `nixos-rebuild switch`. + +- [ ] **Step 6: Commit the activation and build-tested state** + +Run: + +```bash +git add hosts/AZ-CLD-1/services/containers/default.nix +git commit -m "services: enable frappe bench" +``` + +--- + +## Task 6: Final Static Verification and Handoff + +**Files:** +- Read-only verification across the repository. + +- [ ] **Step 1: Verify no old Frappe LMS structural names remain** + +Run: + +```bash +rg -n "frappe-lms|FRAPPE_LMS|frappe_lms|redis-cache|redis-queue" hosts secrets.nix secrets || true +``` + +Expected: no matches except historical documentation under `docs/superpowers/specs/` or `docs/superpowers/plans/`. + +- [ ] **Step 2: Verify no prohibited commands were run** + +Run: + +```bash +git log --oneline -5 +``` + +Expected: recent commits are local only. Do not run `git push`. + +- [ ] **Step 3: Verify worktree state** + +Run: + +```bash +git status --short +``` + +Expected: no unstaged implementation changes. `result` may appear depending on local `.gitignore`; remove it with `rm result` if it is an untracked symlink. + +- [ ] **Step 4: Write handoff note in final response** + +Include: + +```text +Implemented host MariaDB, host Redis, agenix Frappe secrets, and shared Frappe Bench module. +Validation run: nix flake check; nixos-rebuild build --flake .#AZ-CLD-1. +Not run: nixos-rebuild switch; git push. +``` + +--- + +## Self-Review + +Spec coverage: + +- Host Redis: Task 3. +- Host MariaDB: Task 2. +- Compose MariaDB settings: Task 2 Step 1. +- agenix secrets: Task 1 and Task 4 Step 8. +- Rename `frappe-lms.nix` to `frappe.nix`: Task 4 Step 1. +- Multisite-capable Bench: Task 4 Steps 3 and 8. +- Production-style separated containers: Task 4 Steps 9 and 10. +- No `switch`, no push, build allowed: header and Task 5 Step 5. + +Placeholder scan: + +- The plan contains no placeholder markers and no intentionally blank implementation steps. + +Type/name consistency: + +- Secret name: `frappe-env` everywhere. +- DB admin user: `frappe_admin` everywhere. +- Service name: `frappe` everywhere after rename. +- Redis service: default NixOS Redis instance, systemd unit `redis.service`.