Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions actools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 12 additions & 10 deletions docs/architecture/runtime-authority-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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`) |
Expand All @@ -192,25 +193,26 @@ 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) |
| `modules/backup/db-full-backup.sh` | daily full dump (PITR baseline) | **E3** |
| `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

Expand Down
70 changes: 44 additions & 26 deletions docs/backup-format-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,29 @@
> 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.

---

## Why this document exists

`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
Expand All @@ -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/<date>/`, 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.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<YYYY-MM-DD>/full-dump-<HHMMSS>.sql.gz.age`
layout with a `manifest.txt` and a per-artifact `.sha256`; the binlog archive
Expand All @@ -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.

---

Expand All @@ -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.
Loading