diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e018d8c..7aa7baa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -78,7 +78,7 @@ jobs: ./ root@$IP:/root/actoolsDrupal/ # Create env file - $SSH "printf 'BASE_DOMAIN=e2e-${{ github.run_id }}.example.com\nEMAIL=ci@example.com\nDRUPAL_VERSION=11\nPHP_VERSION=8.3\nDB_ROOT_PASS=CiTestPass123\nDRUPAL_ADMIN_PASS=CiAdmin123\nDRUPAL_ADMIN_USER=admin\nDRUPAL_ADMIN_EMAIL=admin@example.com\nENABLE_S3_STORAGE=false\nENABLE_ENCRYPTED_BACKUP=true\nMARIADB_VERSION=11.4\nENVIRONMENT_MODE=production-isolated\nENABLE_REDIS=true\nREDIS_MEM=256mb\nWORKER_MEMORY_LIMIT=2g\nPHP_MEMORY_LIMIT=512m\nDB_MEMORY_LIMIT=2g\n' > /root/actoolsDrupal/actools.env" + $SSH "printf 'BASE_DOMAIN=e2e-${{ github.run_id }}.example.com\nEMAIL=ci@example.com\nDRUPAL_VERSION=11\nPHP_VERSION=8.3\nDB_ROOT_PASS=CiTestPass123\nDRUPAL_ADMIN_PASS=CiAdmin123\nDRUPAL_ADMIN_USER=admin\nDRUPAL_ADMIN_EMAIL=admin@example.com\nENABLE_S3_STORAGE=false\nENABLE_ENCRYPTED_BACKUP=true\nENABLE_PITR=true\nMARIADB_VERSION=11.4\nENVIRONMENT_MODE=production-isolated\nENABLE_REDIS=true\nREDIS_MEM=256mb\nWORKER_MEMORY_LIMIT=2g\nPHP_MEMORY_LIMIT=512m\nDB_MEMORY_LIMIT=2g\n' > /root/actoolsDrupal/actools.env" # Run the installer, preserving the log, WITHOUT letting `tee` mask a # non-zero install — the bug being that a pipeline's exit status is @@ -353,6 +353,45 @@ jobs: fi echo "OK: encrypted backup round-trip verified (actools_prod has $TC tables)" + - name: Binlog enablement (E3a — behavior-changing) + run: | + IP="${{ steps.server.outputs.server_ip }}" + SSH="ssh -o StrictHostKeyChecking=no -i /tmp/ci_key root@$IP" + # =========================================================================== + # E3a is behavior-changing: the install env sets ENABLE_PITR=true, so the + # canonical compose db service folds in the gated binlog wiring — the dedicated + # mariadb_binlogs named volume at /var/log/mysql plus the 99-binlog.cnf config + # mount. Prove on the live box that (1) MariaDB came up WITH the binlog config + # ("MariaDB ready." — the core my.cnf-class startup risk, incl. the named-volume + # ownership for the mysql user), (2) binary logging is ACTIVE (log_bin=ON), and + # (3) binlog data files are actually being written under /var/log/mysql in the db + # container. An SSH-timeout here is infra (re-run); a real failure is a FAIL line. + # =========================================================================== + + # 1) MariaDB started cleanly WITH the binlog config (does not break startup). + if ! grep -qF "MariaDB ready." install.log; then + echo "FAIL: 'MariaDB ready.' not seen in the install log under ENABLE_PITR=true"; exit 1 + fi + echo "OK: MariaDB ready. (the binlog config did not break startup)" + + # 2) Binary logging is active (log_bin=ON). + LOGBIN=$($SSH "docker exec -i actools_db sh -c 'MYSQL_PWD=\"\$MARIADB_ROOT_PASSWORD\" mariadb -uroot -N -e \"SHOW VARIABLES LIKE \\\"log_bin\\\";\"' 2>/dev/null" || true) + echo "log_bin variable: $LOGBIN" + if ! echo "$LOGBIN" | grep -qiE 'log_bin[[:space:]]+ON'; then + echo "FAIL: log_bin is not ON (binary logging inactive under ENABLE_PITR=true)"; exit 1 + fi + echo "OK: log_bin=ON (binary logging active)" + + # 3) Binlog data files are being written to the dedicated volume at /var/log/mysql. + if ! $SSH "docker exec -i actools_db sh -c 'ls /var/log/mysql/mysql-bin.[0-9]* >/dev/null 2>&1'"; then + echo "FAIL: no binlog data file (mysql-bin.NNNNNN) under /var/log/mysql in actools_db" + $SSH "docker exec -i actools_db sh -c 'ls -la /var/log/mysql/ 2>&1'" || true + exit 1 + fi + BINLOGS=$($SSH "docker exec -i actools_db sh -c 'ls /var/log/mysql/mysql-bin.[0-9]* 2>/dev/null'" || true) + echo "OK: binlog data file(s) present under /var/log/mysql:" + echo "$BINLOGS" + - name: Upload logs if: always() uses: actions/upload-artifact@v7 diff --git a/actools.sh b/actools.sh index 6132abc..64cae29 100755 --- a/actools.sh +++ b/actools.sh @@ -415,6 +415,13 @@ setup_stack() { # ── MariaDB my.cnf ────────────────────────────────────────────────────────── generate_mycnf + # ── Binlog config for PITR (gated; placed only when ENABLE_PITR=true) ───────── + # Materialized next to my.cnf so the gated ./99-binlog.cnf mount in compose.sh + # resolves; mounted into the db service by generate_compose when ENABLE_PITR=true. + if [[ "${ENABLE_PITR:-false}" == "true" ]]; then + cp "${INSTALL_DIR}/modules/backup/99-binlog.cnf" "${INSTALL_DIR}/99-binlog.cnf" + fi + # ── Container images: Caddy / PHP / worker (modules/stack/images.sh) ───────── build_caddy_image build_php_image diff --git a/docs/architecture/runtime-authority-map.md b/docs/architecture/runtime-authority-map.md index ec75d5a..3682d16 100644 --- a/docs/architecture/runtime-authority-map.md +++ b/docs/architecture/runtime-authority-map.md @@ -158,11 +158,11 @@ closure alone, is required. > unclassified or wiring-flipped file. **C4 changed NO module file** — disposition > of the unwired files is deferred (see the disposition column). Baseline `d482818`. -The 6 live modules ship **34 files: 21 wired, 1 documentation, 12 unwired.** The -12 unwired files reside on the box (in-place install + `chown -R`, `actools.sh:94`/`:405`) +The 6 live modules ship **32 files: 22 wired, 1 documentation, 9 unwired.** The +9 unwired files reside on the box (in-place install + `chown -R`, `actools.sh:94`/`:405`) but never execute on the live path. -### Wired (21) — reached on the live path +### Wired (22) — reached on the live path | file | reached via | |---|---| | `modules/audit/audit.sh` | EXECUTED by `cli/actools:320` (`audit` command; `AUDIT_SCRIPT` resolved :313, arm :312–321) | @@ -173,6 +173,7 @@ but never execute on the live path. | `modules/audit/lib/security.sh` | sourced by `audit.sh:61` | | `modules/audit/lib/report.sh` | sourced by `audit.sh:62` | | `modules/backup/cron.sh` | source-closure of `actools.sh` (`:516`) | +| `modules/backup/99-binlog.cnf` | mounted into the db service by `compose.sh` `generate_compose` (gated `ENABLE_PITR`); materialized beside `my.cnf` by `actools.sh:422` | | `modules/db/core.sh` | source-closure of `actools.sh` (`:457`) | | `modules/drupal/provision.sh` | source-closure of `actools.sh` (`:181`) | | `modules/host/age.sh` | source-closure — host loop (`:193`) | @@ -192,7 +193,7 @@ but never execute on the live path. |---|---| | `modules/audit/docs/fix_catalog.md` | audit fix-catalog reference doc; referenced by no code | -### Unwired (12) — ship on the box, OFF the live path; disposition deferred +### Unwired (9) — ship on the box, OFF the live path; disposition deferred | file | what it is | disposition (deferred) | |---|---|---| | `modules/backup/binlog-rotate.sh` | hourly binlog rotation/encryption/upload | **E3** (binlog/PITR) | @@ -200,17 +201,18 @@ but never execute on the live path. | `modules/backup/pitr-restore.sh` | point-in-time restore | **E3** | | `modules/backup/cli-pitr.sh` | `actools` CLI integration for PITR | **E3** | | `modules/backup/deploy-pitr.sh` | manual PITR deploy script (entrypoint for the above) | **E3** | -| `modules/backup/mariadb-binlog.cnf` | binlog MariaDB config | **E3** | -| `modules/backup/99-binlog.cnf` | binlog MariaDB config | **E3** | -| `modules/backup/docker-compose.binlog.yml` | binlog volume compose override | **E3** | | `modules/backup/actools-db-backup.cron` | backup cron entries | **E3** | | `modules/audit/deploy-audit.sh` | self-declared UNWIRED + STALE; superseded by `lib/*.sh` | **Phase 5**: reconcile-or-delete | | `modules/drupal/prepare.sh` | "Stage 1" extraction; superseded (`provision.sh` inlines it); unsourced | **Phase 5**: reconcile-or-delete | | `modules/drupal/secure.sh` | self-declared UNWIRED; "Phase 5 decision" | **Phase 5**: wire-or-delete | -The 9-file `backup/` cluster is a **partial implementation of E3** — those -phases reconcile/test/harden it rather than build from scratch. C4 records and -guards these files; it wires, deletes, and moves nothing. +The remaining 6-file `backup/` cluster (`binlog-rotate.sh`, `db-full-backup.sh`, +`pitr-restore.sh`, `cli-pitr.sh`, `deploy-pitr.sh`, `actools-db-backup.cron`) is a +**partial implementation of E3** — still E3b/E3c/E3d/E5 targets that reconcile/test/ +harden it rather than build from scratch. E3a folded the binlog foundation +(`99-binlog.cnf` + the gated volume/config wiring, default `ENABLE_PITR=false`) onto +the live path and deleted the two superseded drafts (`mariadb-binlog.cnf`, +`docker-compose.binlog.yml`); it wires none of these six. ## Command registry — registered vs not-registered diff --git a/docs/backup-format-contract.md b/docs/backup-format-contract.md index 289565e..ff2a506 100644 --- a/docs/backup-format-contract.md +++ b/docs/backup-format-contract.md @@ -5,10 +5,13 @@ > contract, pinned by a guard (see "What the guard enforces"). The **encrypted (B)** > variant is now **LIVE** — wired into the daily cron (`modules/backup/cron.sh`) and > gated by `ENABLE_ENCRYPTED_BACKUP` (**default off**, so enabling it is opt-in) — and -> is pinned by the same guard. The **PITR (C)** clauses remain the **target** that the -> draft scripts in `modules/backup/` are *not yet wired to*; they are marked -> **TARGET / NOT YET LIVE** throughout and describe what E3 must build, not shipped -> behavior. +> is pinned by the same guard. The **PITR (C)** path is now **partially live**: binary +> logging — the binlog *foundation* — is **LIVE** as of E3a, gated by `ENABLE_PITR` +> (**default off**), via the standalone `99-binlog.cnf` config and the dedicated +> `mariadb_binlogs` volume folded into the canonical compose db service. The C +> **full-backup producer and the PITR restore remain TARGET / NOT YET LIVE** (E3b / E5); +> the remaining draft scripts in `modules/backup/` are *not yet wired to* them, and +> those clauses describe what the later E3 phases must build, not shipped behavior. --- @@ -16,13 +19,15 @@ `modules/backup/` carries the live daily backup generator (`cron.sh`) — which now performs Age encryption at rest behind the `ENABLE_ENCRYPTED_BACKUP` flag (E2) — -alongside a nine-file draft cluster for point-in-time recovery (`db-full-backup.sh`, -`pitr-restore.sh`, and their supporting `.cnf`/compose/cron files). Across the live -code and those drafts there are three different ways a backup artifact gets named, -located, time-stamped, encrypted, and checksummed. Left unreconciled, the encrypted -backup (E2) and the binlog/PITR path (E3) would each cement an incompatible dialect -into production; this contract is the single shape they conform to. E2 is now live -and conforms; E3 remains a draft. +alongside a six-file draft cluster for point-in-time recovery (`db-full-backup.sh`, +`pitr-restore.sh`, `binlog-rotate.sh`, `cli-pitr.sh`, `deploy-pitr.sh`, and the +`actools-db-backup.cron` schedule). Across the live code and those drafts there are +three different ways a backup artifact gets named, located, time-stamped, encrypted, +and checksummed. Left unreconciled, the encrypted backup (E2) and the binlog/PITR path +(E3) would each cement an incompatible dialect into production; this contract is the +single shape they conform to. E2 is now live and conforms; E3's binlog foundation is +now live (gated `ENABLE_PITR`, default off) and the remaining PITR producer/restore +stay drafts. This contract fixes one canonical artifact shape, pins the live producer-and-consumer agreement so it cannot silently drift, and records exactly where each draft diverges @@ -42,14 +47,17 @@ The three dialects, in brief: - **PITR (C)** — `modules/backup/db-full-backup.sh` and `pitr-restore.sh`, with `binlog-rotate.sh`. A nested directory layout under `backups/db//`, a separate binlog archive, and a base dump taken with `--master-data=2` to embed the binlog - coordinate. Drafts; E3 wires them. + coordinate. The binlog *foundation* (binary logging via `99-binlog.cnf` + the + `mariadb_binlogs` volume, gated `ENABLE_PITR`, default off) is **live as of E3a**; the + full-backup producer and restore remain drafts (E3b / E5). The encryption key infrastructure is already live: `modules/host/age.sh` generates a per-deployment Age keypair at install (`.age-key.txt`, mode 600; the derived `.age-public-key`, mode 644). The encrypted variant (B) now consumes that key -material on the live path (gated by `ENABLE_ENCRYPTED_BACKUP`); the PITR variant (C) -does not consume it yet. No `ENABLE_PITR` or `ENABLE_BINLOG` flag exists in the tree; -E3 introduces one. +material on the live path (gated by `ENABLE_ENCRYPTED_BACKUP`); the PITR full-backup +producer (C) does not consume it yet. The `ENABLE_PITR` flag now exists in the tree +(introduced by E3a, **default off**); it currently gates binary logging only — the +full-backup producer and restore that will also consume it remain drafts. --- @@ -169,7 +177,9 @@ The base dump is taken with `--master-data=2`, which embeds the binlog file and position the dump corresponds to; binlog replay starts from that coordinate. The PITR tree is rooted **under** `${INSTALL_DIR}/backups/`, which reconciles the drafts' use of `${ACTOOLS_HOME}/backups/...` (the two are equal when `INSTALL_DIR=/home/actools`). The -whole PITR path is gated by a future `ENABLE_PITR` flag that E3 introduces. +whole PITR path is gated by the `ENABLE_PITR` flag (introduced by E3a, **default off**); +E3a's binary logging consumes it now, and the full-backup producer and restore conform +to the same gate as they land (E3b / E5). Checksum and integrity. Every artifact — plaintext or encrypted, standard or PITR — carries a verified `.sha256` sidecar computed over its **final** bytes (the ciphertext @@ -249,7 +259,7 @@ agreement: it renders `cron.sh` with `ENABLE_ENCRYPTED_BACKUP=true` and asserts artifact, the ciphertext checksum, and the plaintext removal on the producer side, and the `.age`-detecting decrypt path on the consumer side, with new non-vacuity arms. -### C — `db-full-backup.sh` / `pitr-restore.sh` (with `binlog-rotate.sh`) → E3 — TARGET / NOT YET LIVE +### C — `db-full-backup.sh` / `pitr-restore.sh` (with `binlog-rotate.sh`) → E3b / E3c / E5 — TARGET / NOT YET LIVE What C already gets right: the nested `backups/db//full-dump-.sql.gz.age` layout with a `manifest.txt` and a per-artifact `.sha256`; the binlog archive @@ -266,12 +276,16 @@ Where C diverges from X, and what E3 must do: variable, not the default, is authoritative. - **Password shape.** C's drafts dump and replay with `--user=root --password="${DB_ROOT_PASS}"` on argv. E3 must use the secure `--defaults-extra-file` shape, matching A. -- **Gating.** PITR must be opt-in. E3 introduces the `ENABLE_PITR` flag and the binlog - generation it depends on (the `my.cnf`/compose changes), and defines the PITR - retention window described under X. +- **Gating.** PITR must be opt-in. E3a introduced the `ENABLE_PITR` flag (default off) + and the binlog generation it depends on (the gated `compose.sh` volume/config wiring + plus the standalone `99-binlog.cnf` the installer places); the remaining E3 phases gate + the full-backup producer and restore behind the same flag and define the PITR retention + window described under X. C keeps its nested layout, `manifest.txt`, per-artifact `.sha256`, and `--master-data=2` -base dump unchanged. E3 extends the guard to pin C once C is wired. +base dump unchanged. The binlog foundation is already pinned by +`tests/guards/binlog_enablement_guard_test.bats` (E3a); the later E3 phases extend the +contract guard to pin the C producer/restore once they are wired. --- @@ -293,8 +307,12 @@ doctoring the producer's filename stem, doctoring the restore glob, rooting the glob at a `backups`-prefixed sibling, and checksumming the plaintext instead of the ciphertext each make the relevant assertion fail. -The guard now asserts A and B. It does **not** yet assert the PITR (C) variant: those -clauses of this document remain the **target** the C drafts are not yet wired to. E3 -extends the guard to pin C's agreement when E3 wires binlog/PITR. Until then, treat every -C statement here as a specification of intended behavior, not a description of shipped -behavior; A and B describe shipped behavior. +The contract guard now asserts A and B. It does **not** yet assert the PITR (C) +producer/restore artifact shapes: those clauses remain the **target** the C drafts are +not yet wired to, and the later E3 phases extend this guard to pin C's agreement once +they wire the producer/restore. The binlog *foundation* of C, by contrast, is shipped +(gated `ENABLE_PITR`, **default off**) and is pinned separately by +`tests/guards/binlog_enablement_guard_test.bats`. Until the producer/restore land, treat +the C **producer/restore** statements here as a specification of intended behavior, not a +description of shipped behavior; A, B, and the C binlog foundation describe shipped +behavior. diff --git a/docs/runbooks/PHASE0_LEDGER.md b/docs/runbooks/PHASE0_LEDGER.md index 1c5410d..415996c 100644 --- a/docs/runbooks/PHASE0_LEDGER.md +++ b/docs/runbooks/PHASE0_LEDGER.md @@ -86,6 +86,151 @@ Approved / Needs revision / Blocked ### Forbidden next scope ```` +## Entry 033 — E3a: binlog enablement (PITR foundation — binary logging, gated) + +Date: +Phase: E3a (Track E — the 4.5 build; PITR foundation / binary logging) +Baseline: 3f59987 (#62) — current `main`, E2 (encrypted-backup deploy) merged +Task prompt source: SPEC-E3a-binlog-enablement.md + +### Objective +Enable binary logging as the Point-in-Time-Recovery FOUNDATION, conforming to the E1 contract +(scheme X, variant C — the binlog foundation only). The volume + config mount are folded +**directly into the canonical `compose.sh` db service**, gated by a new `ENABLE_PITR` flag +(**default off** → no behavior change unless enabled) — the same absorb pattern E2 used, and +exactly what the deleted overlay `docker-compose.binlog.yml` itself recommended. The binlog +config is kept as the standalone, auditable `modules/backup/99-binlog.cnf` (a DB-critical config +is far more reviewable as a `.cnf` artifact than buried in a shell heredoc): `actools.sh` places +it beside `my.cnf` in `INSTALL_DIR` (gated, after the `mycnf.sh` step) and `compose.sh` mounts it +into the db service at `/etc/mysql/mariadb.conf.d/99-binlog.cnf` plus the dedicated +`mariadb_binlogs` named volume at `/var/log/mysql` (gated), declaring the named volume in the +top-level `volumes:` section. The absorbed overlay `docker-compose.binlog.yml` and the +byte-identical dead-twin `mariadb-binlog.cnf` are **deleted**; `deploy-pitr.sh`'s single dangling +reference to the dead-twin is repointed to `99-binlog.cnf` (no other change to that draft). This +is the FOUNDATION only — binary logging on. The full-backup producer (E3b), rotation (E3c), the +`pitr` CLI (E3d), and restore (E5) remain unwired drafts, out of scope. + +### Declared deviations (see HANDOFF-E3a) +- **`/var/log/mysql` mount is a REPLACE, not an ADD.** The db service already binds + `./logs/db:/var/log/mysql`. The spec's literal "add `mariadb_binlogs:/var/log/mysql`" would + produce a duplicate container-path mount. When `ENABLE_PITR=true` the single + `…:/var/log/mysql` line renders `mariadb_binlogs` **in place of** `./logs/db` (one unambiguous + mount) — which is also exactly what a `docker-compose -f` overlay merge would yield (volumes + key by container path; the override wins). When off, the same line renders `./logs/db` — + byte-identical to the golden. **Known risk (gated by e2e):** the named volume's ownership for + the container's `mysql` user (UID 999) vs the pre-created `./logs/db` bind; `slow.log` also + writes under `/var/log/mysql`, so it shares the named volume when PITR is on. The e2e + `MariaDB ready.` + `log_bin=ON` + a real `mysql-bin.NNNNNN` file is the live proof. +- **`printf` inline-append technique, not the redis service-fragment heredoc.** The spec + referenced the redis `$(if …; then cat < 259 total (+7 new guard); 0 fail (jq+age installed) +bats tests/guards/binlog_enablement_guard_test.bats # 7/7 (incl. arm 6 — the gating/non-vacuity bite) +bats tests/generated/golden_drift_test.bats # 6/6 (default output byte-identical; PITR off renders nothing) +bats tests/guards/live_module_file_inventory_test.bats # 2/2 (manifest 34->32; 99-binlog.cnf now ON the closure) +bats tests/guards/backup_format_contract_guard_test.bats # 10/10 (A + B agreements unchanged) +bats tests/guards/doc_command_claim_guard_test.bats # REGISTERED=30 (no pitr command) +# non-vacuity: arm 6 doctors an OFF-TREE copy of compose.sh (gate forced always-true) and proves +# the ENABLE_PITR=false/unset arms would FAIL against an ungated block; the repo is never modified. +``` + +### Test result +PASS (sandbox). `bats -r tests/` is 259 total / 0 fail (this sandbox has `jq`+`age` installed, so +the E2-era jq-environmental failures do not apply). The new guard is 7/7 incl. the gating arm; +golden_drift is 6/6 with the goldens unchanged (the gated wiring renders to nothing when +`ENABLE_PITR` is unset/false); the inventory guard is 2/2 with `99-binlog.cnf` correctly on the +live closure. Zero previously-passing tests regressed. + +### Gate +**Behavior-CHANGING (opt-in).** When `ENABLE_PITR=true` the db service composition changes (the +dedicated `mariadb_binlogs` volume + the `99-binlog.cnf` mount), so a **branch e2e MUST be green +before merge** (`workflow_dispatch` on the phase branch). The new "Binlog enablement (E3a)" step +installs with `ENABLE_PITR=true` and asserts `MariaDB ready.` (the binlog config does not break +startup — the core `my.cnf`-class risk, incl. the named-volume ownership for the `mysql` user), +`log_bin=ON` (binary logging active), and a `mysql-bin.NNNNNN` file under `/var/log/mysql` in the +db container (binlogs writing to the dedicated volume). `MariaDB ready.` + `log_bin=ON` green is +the merge signal; an SSH-timeout is infra → re-run. With `ENABLE_PITR` unset/false the generated +compose is byte-identical to today's (golden_drift 6/6), so the merge to `main` is behavior-free +for existing deployments — the behavior change is opt-in. The coding window cannot run the VM — +the branch e2e is the live proof. Branch-e2e run #: . + +### Verdict +Pending — see SPEC-E3a §13. REVIEW (re-derive the gated rendering both ways; prove the +off-rendering is byte-clean; prove the new guard's gating/non-vacuity arm bites on an off-tree +copy; confirm no PITR producer/restore is wired; byte-identity of mycnf.sh/cron.sh/cli-actools/the +5 untouched cluster files; the patch reproduces the tree) then DOC-CHECK (C binlog foundation live ++ code-true, producer/restore still target; the runtime-authority recount is consistent with no +third stale count; the project doc copies match the repo) then the operator's green branch e2e +(`MariaDB ready.` + `log_bin=ON`). The coding window does not self-approve. + +### Commit SHA +Sandbox commit on 3f59987; operator stamps the squash/merge SHA on apply. + + ## Entry 032 — E2: encrypted-backup deploy (Age-at-rest, gated) Date: @@ -230,12 +375,16 @@ re-run. The coding window cannot run the VM — the branch e2e is the live proof run #: . ### Verdict -Pending — see SPEC-E2 §7. REVIEW (re-derive scope; byte-identity of age.sh/helper/PITR drafts; -guard non-vacuity incl. the new encrypted + anchored-root + ciphertext-checksum arms; golden -drift; the patch reproduces the tree) then DOC-CHECK (every documented command exists in -`cli/actools`; no doc claims an unshipped feature as live — B is now live, C still not; no -unmeasured perf/time guarantee survives; the project doc copies match the repo) then the -operator's green branch e2e follow. The coding window does not self-approve. +**APPROVED — ratified (): E2 merged to `main` as `3f59987` (#62) — the squash-merge +"feat(e2): encrypted-backup deploy — Age-at-rest in cron.sh, gated, restore decrypts (Track E)". +The gated encrypt-at-rest stage in `cron.sh` and the `.age`-aware, fail-closed `restore` consumer +landed as specified; branch e2e #104 is green — `MariaDB ready.` with the encrypted-backup +round-trip (the `prod_db_.sql.gz.age` artifact + a verifying ciphertext checksum + no +plaintext remaining, then `restore prod` reloads the DB). `age.sh`, the PITR/binlog (C) drafts, and +the non-encrypted backup shape were untouched (byte-identical to `6d06a73`), and the encrypted path +is gated by `ENABLE_ENCRYPTED_BACKUP` (default off), so the change is opt-in. `3f59987` is the +current `main` and the verified baseline of E3a, which rides this ratification.** +*(Original pending text, for the record:)* Pending — see SPEC-E2 §7. REVIEW (re-derive scope; byte-identity of age.sh/helper/PITR drafts; guard non-vacuity incl. the new encrypted + anchored-root + ciphertext-checksum arms; golden drift; the patch reproduces the tree) then DOC-CHECK (every documented command exists in `cli/actools`; no doc claims an unshipped feature as live — B is now live, C still not; no unmeasured perf/time guarantee survives; the project doc copies match the repo) then the operator's green branch e2e follow. The coding window does not self-approve. ### Commit SHA Sandbox commit on 6d06a73; operator stamps the squash/merge SHA on apply. diff --git a/modules/backup/99-binlog.cnf b/modules/backup/99-binlog.cnf index f90d726..f231302 100644 --- a/modules/backup/99-binlog.cnf +++ b/modules/backup/99-binlog.cnf @@ -1,8 +1,8 @@ # /etc/mysql/mariadb.conf.d/99-binlog.cnf # Phase 4.5 Item 2 — Binary logging for Point-in-Time Recovery -# Deploy: sudo cp 99-binlog.cnf /etc/mysql/mariadb.conf.d/ -# docker compose restart db -# Verify: docker compose exec db mysql -u root -p -e "SHOW VARIABLES LIKE 'log_bin%';" +# Placed automatically by the installer (copied to ${INSTALL_DIR}/99-binlog.cnf) and +# mounted into the db service by modules/stack/compose.sh when ENABLE_PITR=true, which +# also declares the dedicated mariadb_binlogs volume. No manual cp/restart needed. [mysqld] # Binary log location (inside container maps to named volume) diff --git a/modules/backup/deploy-pitr.sh b/modules/backup/deploy-pitr.sh index d77e312..2434025 100755 --- a/modules/backup/deploy-pitr.sh +++ b/modules/backup/deploy-pitr.sh @@ -36,7 +36,7 @@ echo " Done" echo "▶ Copying module files to ${MODULES}..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cp "${SCRIPT_DIR}/mariadb-binlog.cnf" "${MODULES}/99-binlog.cnf" +cp "${SCRIPT_DIR}/99-binlog.cnf" "${MODULES}/99-binlog.cnf" cp "${SCRIPT_DIR}/db-full-backup.sh" "${MODULES}/db-full-backup.sh" cp "${SCRIPT_DIR}/binlog-rotate.sh" "${MODULES}/binlog-rotate.sh" cp "${SCRIPT_DIR}/pitr-restore.sh" "${MODULES}/pitr-restore.sh" diff --git a/modules/backup/docker-compose.binlog.yml b/modules/backup/docker-compose.binlog.yml deleted file mode 100644 index 4e27b80..0000000 --- a/modules/backup/docker-compose.binlog.yml +++ /dev/null @@ -1,26 +0,0 @@ -# docker-compose.binlog.yml -# Phase 4.5 Item 2 — Binlog volume mount override -# -# Merge with main compose file: -# docker compose -f docker-compose.yml -f docker-compose.binlog.yml up -d -# -# OR add the volumes/volume_mounts below directly into your existing -# db service definition in docker-compose.yml (preferred for this project). - -services: - db: - volumes: - # Existing data volume (already present — do not duplicate) - # - db_data:/var/lib/mysql - - # Binlog lives in a separate named volume so it is NOT wiped - # if you ever recreate the db container from a fresh dump. - # PITR depends on binlogs surviving independent of data volume. - - mariadb_binlogs:/var/log/mysql - - # Drop the binlog config into the container - - ./modules/backup/99-binlog.cnf:/etc/mysql/mariadb.conf.d/99-binlog.cnf:ro - -volumes: - mariadb_binlogs: - driver: local diff --git a/modules/backup/mariadb-binlog.cnf b/modules/backup/mariadb-binlog.cnf deleted file mode 100644 index f90d726..0000000 --- a/modules/backup/mariadb-binlog.cnf +++ /dev/null @@ -1,30 +0,0 @@ -# /etc/mysql/mariadb.conf.d/99-binlog.cnf -# Phase 4.5 Item 2 — Binary logging for Point-in-Time Recovery -# Deploy: sudo cp 99-binlog.cnf /etc/mysql/mariadb.conf.d/ -# docker compose restart db -# Verify: docker compose exec db mysql -u root -p -e "SHOW VARIABLES LIKE 'log_bin%';" - -[mysqld] -# Binary log location (inside container maps to named volume) -log_bin = /var/log/mysql/mysql-bin.log -binlog_format = ROW -binlog_row_image = FULL - -# Retention and size -expire_logs_days = 7 -max_binlog_size = 100M - -# Safety: each transaction committed to binlog before ack to client -# 1 = safest (fsync on every commit), small perf cost on HDD -# On NVMe (Hetzner CX) this is negligible -sync_binlog = 1 - -# Required for binary logging — unique per server -# If you ever add a replica/Galera node, each node needs a different ID -server_id = 1 - -# Log DDL statements (CREATE TABLE, ALTER, DROP) in binlog as well -log_bin_trust_function_creators = 1 - -# Checksum each binlog event — detects corruption during recovery -binlog_checksum = CRC32 diff --git a/modules/stack/compose.sh b/modules/stack/compose.sh index 3bf3293..faa421f 100644 --- a/modules/stack/compose.sh +++ b/modules/stack/compose.sh @@ -28,6 +28,7 @@ generate_compose() { local REDIS_MEM="${REDIS_MEMORY_LIMIT:-256m}" local CADVISOR="${ENABLE_CADVISOR:-false}" local REDIS_ON="${ENABLE_REDIS:-true}" + local PITR_ON="${ENABLE_PITR:-false}" PHP_ENV_BLOCK=" PHP_MEMORY_LIMIT: \"${WEB_MEM}\" @@ -84,7 +85,7 @@ networks: volumes: caddy_data: caddy_config: - db_data: + db_data:$(if [[ "${PITR_ON}" == "true" ]]; then printf '\n mariadb_binlogs: { driver: local }'; fi) services: @@ -237,8 +238,8 @@ fi) MARIADB_AUTO_UPGRADE: "1" volumes: - db_data:/var/lib/mysql - - ./logs/db:/var/log/mysql - - ./my.cnf:/etc/mysql/conf.d/actools.cnf:ro + - $(if [[ "${PITR_ON}" == "true" ]]; then printf 'mariadb_binlogs'; else printf './logs/db'; fi):/var/log/mysql + - ./my.cnf:/etc/mysql/conf.d/actools.cnf:ro$(if [[ "${PITR_ON}" == "true" ]]; then printf '\n - ./99-binlog.cnf:/etc/mysql/mariadb.conf.d/99-binlog.cnf:ro'; fi) healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s diff --git a/tests/guards/binlog_enablement_guard_test.bats b/tests/guards/binlog_enablement_guard_test.bats new file mode 100644 index 0000000..0df4ac2 --- /dev/null +++ b/tests/guards/binlog_enablement_guard_test.bats @@ -0,0 +1,209 @@ +#!/usr/bin/env bats +# ============================================================================= +# tests/guards/binlog_enablement_guard_test.bats — E3a guard +# (binlog enablement: the gated PITR-foundation wiring in compose.sh) +# +# Pins the binary-logging FOUNDATION that E3a folds into the canonical db service, +# gated by the new ENABLE_PITR flag (default off). With ENABLE_PITR=true the +# generated docker-compose.yml must (i) mount the standalone 99-binlog.cnf into the +# db service at /etc/mysql/mariadb.conf.d/99-binlog.cnf, (ii) mount the dedicated +# mariadb_binlogs named volume at /var/log/mysql (so binlogs survive container +# recreation), and (iii) declare mariadb_binlogs in the top-level volumes: section. +# With ENABLE_PITR off (false OR unset) NONE of the three may appear — the default +# compose output stays byte-identical to today's (pinned separately by golden_drift). +# +# This guard reads the bytes the installer would write by sourcing the SAME +# canonical modules/stack/compose.sh generator the golden-capture harness uses and +# calling generate_compose() directly against a deterministic environment. +# +# Discipline mirrors tests/guards/backup_format_contract_guard_test.bats: +# - the wiring is DERIVED by rendering the real generator, not transcribed; +# - the off-rendering (ENABLE_PITR off) assertion is NON-VACUOUS: arm 6 doctors +# an OFF-TREE copy of compose.sh so the binlog block renders UNCONDITIONALLY and +# proves that the off assertion then bites (the wiring leaks into the PITR=off +# render) — i.e. an ungated block cannot pass arms 4/5; +# - non-vacuity runs on an OFF-TREE scratch copy — the repo is never modified. +# +# dash/bats-safe: no process substitution; the generator renders into a mktemp dir; +# all scratch copies live under a mktemp dir torn down in teardown(). +# +# CI wiring: discovered by the recursive bats job (lint.yml: `bats -r tests/`). +# ============================================================================= + +setup() { + REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + COMPOSE_SH="${REPO}/modules/stack/compose.sh" + BINLOG_CNF="${REPO}/modules/backup/99-binlog.cnf" + WORK="$(mktemp -d)" +} + +teardown() { + rm -rf "${WORK:-}" +} + +# --------------------------------------------------------------------------- +# _render_compose +# Source the given compose generator and render docker-compose.yml into a fresh +# temp INSTALL_DIR against a fixed, deterministic environment, then copy the +# result to . is "true"/"false" (exported as ENABLE_PITR) or the +# literal "UNSET" (ENABLE_PITR left unexported, so the generator's :-false default +# governs). Echoes nothing; rc!=0 on render failure. +# --------------------------------------------------------------------------- +_render_compose() { + local gen="$1" out="$2" pitr="$3" + local dir + dir="$(mktemp -d "${WORK}/render.XXXXXX")" || return 1 + ( + set -e + export INSTALL_DIR="$dir" + export MARIADB_VERSION="11.4" + export DB_ROOT_PASS="TEST_DB_ROOT_PASS_FIXED" + export PHP_MEMORY_LIMIT="512m" + export WORKER_MEMORY_LIMIT="2g" + export DB_MEMORY_LIMIT="2g" + export REDIS_MEMORY_LIMIT="256m" + export ENABLE_REDIS="true" + export ENABLE_S3_STORAGE="false" + export ENABLE_CADVISOR="false" + export ENVIRONMENT_MODE="production-isolated" + if [ "$pitr" != "UNSET" ]; then + export ENABLE_PITR="$pitr" + fi + # shellcheck source=/dev/null + . "$gen" + generate_compose + ) || return 1 + [ -f "${dir}/docker-compose.yml" ] || return 1 + cp "${dir}/docker-compose.yml" "$out" +} + +# _top_level_volumes_block — print the top-level volumes: block +# (from the `volumes:` line at column 0 up to the `services:` line). +_top_level_volumes_block() { + awk '/^volumes:/{f=1} f{print} f&&/^services:/{exit}' "$1" +} + +# _db_volumes_block — print the db service volumes: list (from the +# db service's ` volumes:` line down to its next sibling key). +_db_volumes_block() { + awk '/^ db:/{d=1} d&&/^ volumes:/{v=1; print; next} v&&/^ [a-z]/{exit} v{print}' "$1" +} + +# --------------------------------------------------------------------------- +# Arm 1 — PITR on: the db service mounts the standalone 99-binlog.cnf config. +# --------------------------------------------------------------------------- +@test "PITR on: db service mounts 99-binlog.cnf at /etc/mysql/mariadb.conf.d/99-binlog.cnf" { + run _render_compose "$COMPOSE_SH" "${WORK}/on.yml" true + [ "$status" -eq 0 ] || { echo "render failed: $output"; return 1; } + + _db_volumes_block "${WORK}/on.yml" | grep -qF './99-binlog.cnf:/etc/mysql/mariadb.conf.d/99-binlog.cnf:ro' || { + echo "PITR on: db service does not mount 99-binlog.cnf into mariadb.conf.d." + echo "--- db volumes ---"; _db_volumes_block "${WORK}/on.yml"; return 1 + } +} + +# --------------------------------------------------------------------------- +# Arm 2 — PITR on: the db service mounts the dedicated mariadb_binlogs volume. +# --------------------------------------------------------------------------- +@test "PITR on: db service mounts mariadb_binlogs:/var/log/mysql" { + run _render_compose "$COMPOSE_SH" "${WORK}/on.yml" true + [ "$status" -eq 0 ] || { echo "render failed: $output"; return 1; } + + _db_volumes_block "${WORK}/on.yml" | grep -qF 'mariadb_binlogs:/var/log/mysql' || { + echo "PITR on: db service does not mount mariadb_binlogs at /var/log/mysql." + echo "--- db volumes ---"; _db_volumes_block "${WORK}/on.yml"; return 1 + } +} + +# --------------------------------------------------------------------------- +# Arm 3 — PITR on: the top-level volumes: section declares mariadb_binlogs. +# --------------------------------------------------------------------------- +@test "PITR on: top-level volumes declares mariadb_binlogs" { + run _render_compose "$COMPOSE_SH" "${WORK}/on.yml" true + [ "$status" -eq 0 ] || { echo "render failed: $output"; return 1; } + + _top_level_volumes_block "${WORK}/on.yml" | grep -qE '^[[:space:]]+mariadb_binlogs:' || { + echo "PITR on: top-level volumes: does not declare the mariadb_binlogs named volume." + echo "--- top-level volumes ---"; _top_level_volumes_block "${WORK}/on.yml"; return 1 + } +} + +# --------------------------------------------------------------------------- +# Arm 4 — PITR off (ENABLE_PITR=false): NONE of the binlog wiring renders. +# (The off-rendering must be clean — byte-identity is pinned by golden_drift.) +# --------------------------------------------------------------------------- +@test "PITR off (false): none of the binlog wiring renders" { + run _render_compose "$COMPOSE_SH" "${WORK}/off.yml" false + [ "$status" -eq 0 ] || { echo "render failed: $output"; return 1; } + + if grep -qE 'mariadb_binlogs|99-binlog\.cnf' "${WORK}/off.yml"; then + echo "PITR off: binlog wiring leaked into the OFF render." + grep -nE 'mariadb_binlogs|99-binlog\.cnf' "${WORK}/off.yml" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Arm 5 — PITR unset (ENABLE_PITR not set): NONE of the binlog wiring renders. +# --------------------------------------------------------------------------- +@test "PITR unset: none of the binlog wiring renders" { + run _render_compose "$COMPOSE_SH" "${WORK}/unset.yml" UNSET + [ "$status" -eq 0 ] || { echo "render failed: $output"; return 1; } + + if grep -qE 'mariadb_binlogs|99-binlog\.cnf' "${WORK}/unset.yml"; then + echo "PITR unset: binlog wiring leaked into the default render." + grep -nE 'mariadb_binlogs|99-binlog\.cnf' "${WORK}/unset.yml" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Arm 6 — Non-vacuity: an UNGATED binlog block is caught by the PITR=off arm. +# Doctor an OFF-TREE copy of compose.sh so the gate is always-true (the block +# renders unconditionally), render it with ENABLE_PITR=false, and assert the +# binlog wiring now LEAKS into the off render — i.e. arms 4/5 are non-vacuous +# (an unconditional block cannot pass them). The repo is never touched. +# --------------------------------------------------------------------------- +@test "non-vacuous: an unconditionally-rendered binlog block leaks into the PITR=off render" { + local doctored="${WORK}/compose-ungated.sh" + cp "$COMPOSE_SH" "$doctored" + # Make every `"${PITR_ON}" == "true"` gate always-true by neutralizing PITR_ON. + sed -i 's/\${PITR_ON}/true/g' "$doctored" + + # Sanity (else this arm is vacuous): the doctor must have removed the gate token. + if grep -qF '${PITR_ON}' "$doctored"; then + echo "VACUOUS: the gate doctor did not neutralize \${PITR_ON}." + return 1 + fi + + run _render_compose "$doctored" "${WORK}/ungated-off.yml" false + [ "$status" -eq 0 ] || { echo "doctored render failed: $output"; return 1; } + + # The ungated block must now leak the binlog wiring even with ENABLE_PITR=false, + # proving the PITR=off arms would FAIL against an unconditional block. + grep -qF 'mariadb_binlogs:/var/log/mysql' "${WORK}/ungated-off.yml" || { + echo "VACUOUS GUARD: an ungated compose still produced a clean off render." + return 1 + } + grep -qF './99-binlog.cnf:/etc/mysql/mariadb.conf.d/99-binlog.cnf:ro' "${WORK}/ungated-off.yml" || { + echo "VACUOUS GUARD: an ungated compose did not emit the 99-binlog.cnf mount." + return 1 + } +} + +# --------------------------------------------------------------------------- +# Arm 7 — Config shape: 99-binlog.cnf carries the PITR-required settings, so a +# future weakening of the binlog config trips this guard. +# --------------------------------------------------------------------------- +@test "config shape: 99-binlog.cnf carries log_bin, binlog_format=ROW, server_id, sync_binlog=1" { + [ -f "$BINLOG_CNF" ] || { echo "99-binlog.cnf missing at $BINLOG_CNF"; return 1; } + + grep -qE '^log_bin[[:space:]]*=' "$BINLOG_CNF" || { + echo "config: 99-binlog.cnf does not set log_bin."; return 1; } + grep -qE '^binlog_format[[:space:]]*=[[:space:]]*ROW' "$BINLOG_CNF" || { + echo "config: 99-binlog.cnf does not set binlog_format = ROW."; return 1; } + grep -qE '^server_id[[:space:]]*=' "$BINLOG_CNF" || { + echo "config: 99-binlog.cnf does not set server_id."; return 1; } + grep -qE '^sync_binlog[[:space:]]*=[[:space:]]*1' "$BINLOG_CNF" || { + echo "config: 99-binlog.cnf does not set sync_binlog = 1."; return 1; } +} diff --git a/tests/guards/live_module_file_inventory_test.bats b/tests/guards/live_module_file_inventory_test.bats index e281cab..3e893ab 100644 --- a/tests/guards/live_module_file_inventory_test.bats +++ b/tests/guards/live_module_file_inventory_test.bats @@ -23,7 +23,7 @@ setup() { REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" } -# --- the classified manifest (the 34 files of the 6 live modules) --- +# --- the classified manifest (the 32 files of the 6 live modules) --- # WIRED: reached on the live path (source-closure of actools.sh, OR executed / # sourced via the cli/actools `audit` command). EXPECTED_WIRED_FILES=( @@ -34,6 +34,7 @@ EXPECTED_WIRED_FILES=( modules/audit/lib/stack.sh modules/audit/lib/security.sh modules/audit/lib/report.sh + modules/backup/99-binlog.cnf modules/backup/cron.sh modules/db/core.sh modules/drupal/provision.sh @@ -61,9 +62,6 @@ EXPECTED_UNWIRED_FILES=( modules/backup/pitr-restore.sh modules/backup/cli-pitr.sh modules/backup/deploy-pitr.sh - modules/backup/mariadb-binlog.cnf - modules/backup/99-binlog.cnf - modules/backup/docker-compose.binlog.yml modules/backup/actools-db-backup.cron modules/audit/deploy-audit.sh modules/drupal/prepare.sh diff --git a/tests/helpers/capture_golden_outputs.sh b/tests/helpers/capture_golden_outputs.sh index 06e01bc..2624471 100644 --- a/tests/helpers/capture_golden_outputs.sh +++ b/tests/helpers/capture_golden_outputs.sh @@ -67,7 +67,7 @@ readonly FIXED_BACKUP_PASS="TEST_BACKUP_PASS_FIXED" # if a future edit moves it (a vestigial drift canary). The range is not # sed-extracted to generate a fixture; the CLI is validated directly by # tests/installer/cli_authority_test.bats (installed == cli/actools). -readonly SC_START=521 SC_END=536 +readonly SC_START=528 SC_END=543 # ── Variant specs: name|REDIS|S3|CADVISOR|ENV_MODE ─────────────────────────── declare -a ALL_VARIANT_SPECS=(