# 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`.