forked from EduCraft/curriculum-project-hub
326 lines
17 KiB
Markdown
326 lines
17 KiB
Markdown
# Backup, migration, and disaster-recovery audit
|
|
|
|
## Verdict
|
|
|
|
The current Hub has no production backup or restore control plane. PostgreSQL
|
|
can be dumped and restored manually, but the service's authoritative state is
|
|
split between PostgreSQL and mutable Project workspaces, with no shared
|
|
consistency point, backup catalog, off-host retention, restore workflow,
|
|
recovery objectives, or recovery drill.
|
|
|
|
The current deployment path can also delete customer workspaces when the
|
|
documented relative workspace root is used: `deploy_platform.sh` applies
|
|
`rsync --delete` to the live Hub directory while `.env.example` places
|
|
workspaces below `./data/project-workspaces`. Project creation has the same
|
|
cross-store weakness at a smaller scale: it creates the workspace before the
|
|
database transaction and leaves an orphan directory when that transaction
|
|
fails.
|
|
|
|
Migration safety is incomplete even though the current PostgreSQL migration
|
|
engine rolled back a deliberately failing migration's DDL in the executed
|
|
probe. The failed migration record blocks later migrations, migrations execute
|
|
inside every service start, no compatibility declaration or production-data
|
|
rehearsal exists, and current history contains destructive changes that make an
|
|
old application query fail after schema upgrade. Application rollback is
|
|
therefore not database rollback.
|
|
|
|
## Contract and persistent-state baseline
|
|
|
|
The audit preserves the accepted contracts rather than inventing missing
|
|
product policy:
|
|
|
|
- `Organization` is the tenant root, but `User` is global and can belong to
|
|
multiple Organizations. A selective Organization restore is not equivalent
|
|
to restoring a self-contained database schema.
|
|
- Project workspaces are authoritative curriculum-engineering files bounded by
|
|
ADR-0018. PostgreSQL stores their absolute `Project.workspaceDir` locations;
|
|
it does not store their file content.
|
|
- Run, provider cursor, ingress, delivery, audit, and workspace commit/recovery
|
|
semantics remain split across the unresolved lifecycle tickets. A backup
|
|
design cannot silently choose their crash behavior.
|
|
- ADR-0022 requires storage ceilings and emergency `DRAIN`/`STOP_NOW`, but does
|
|
not decide backup frequency, retention, recovery objectives, or tenant-level
|
|
restore scope.
|
|
- Audit retention is still `OPEN`; regulatory backup retention and deletion
|
|
obligations require product/legal input already recorded in the map's Fog.
|
|
|
|
The recovery set has at least four classes:
|
|
|
|
| State | Current location | Recovery significance |
|
|
| --- | --- | --- |
|
|
| Tenant and workflow state | PostgreSQL | Organizations, identities, membership, permissions, Feishu bindings, sessions, runs, locks, receipts, usage facts, and audit records |
|
|
| Customer artifacts | `HUB_PROJECT_WORKSPACE_ROOT` | Project source files, generated artifacts, inbound attachments, and any workspace-local session/history files |
|
|
| Credentials and configuration | `/srv/.../.secrets/platform.env` today; future org-scoped encrypted connections plus external key material | Database/Feishu/provider access and ability to decrypt restored Organization credentials |
|
|
| Release identity | application release, Prisma migrations, `cph`, runtime/toolchain manifest | Required to start a schema-compatible application and reproduce outputs after restore |
|
|
|
|
Caches, build output, and reinstallable dependencies should be classified as
|
|
regenerable rather than silently mixed into the protected recovery set.
|
|
|
|
## Executed evidence
|
|
|
|
All probes used isolated temporary paths or databases. No production or shared
|
|
test data was modified.
|
|
|
|
### 1. The deployment rsync pattern deletes an in-tree workspace
|
|
|
|
A temporary live Hub directory was given the documented workspace shape and a
|
|
sentinel customer file, then the exact deployment rsync include/exclude pattern
|
|
was applied:
|
|
|
|
```text
|
|
rsync -a --delete \
|
|
--exclude node_modules --exclude dist --exclude .env \
|
|
hub/ <temporary-live-hub>/
|
|
|
|
workspace_survived=false
|
|
reproduced: deploy rsync pattern deletes an in-tree workspace root
|
|
```
|
|
|
|
This is deterministic: `data/` is absent from the release source and is not an
|
|
exclude, so `--delete` removes it from the destination. The root cause is that
|
|
the documented persistent path is nested inside the mutable release tree and
|
|
the deploy contract does not distinguish release artifacts from state.
|
|
|
|
### 2. A rejected Project transaction leaves an orphan workspace
|
|
|
|
A minimal Node harness called the real built `createProjectFromOrgAdmin` with a
|
|
valid Organization admin and a missing Folder. The fake Prisma boundary rejected
|
|
inside the same transaction seam used by production. The harness asserted that
|
|
the Organization workspace directory must remain empty and was run twice:
|
|
|
|
```text
|
|
node .scratch/saas-production-readiness/probes/orphan-workspace.mjs
|
|
|
|
orphan_workspace_entries=project_4aa0b6792c4547059d2d92b505b9299e
|
|
exit_code=1
|
|
|
|
orphan_workspace_entries=project_190e21afb8854244872b1241a600aa58
|
|
exit_code=1
|
|
```
|
|
|
|
`createManagedProject` calls `mkdir` before `prisma.$transaction`; the failure
|
|
path has no compensating removal, staged rename, or orphan reconciler. The
|
|
throwaway harness was removed after reproduction.
|
|
|
|
### 3. PostgreSQL rolled back failed migration DDL but retained a blocking failure
|
|
|
|
An isolated database and throwaway Prisma project applied one migration that
|
|
created a table, divided by zero, then would have created another table. The
|
|
real repository Prisma CLI returned `P3018`:
|
|
|
|
```text
|
|
migration_exit=1
|
|
before_failure=missing
|
|
after_failure=missing
|
|
20260710000000_partial_failure|finished=false|rolled_back=false|steps=0|logs=true
|
|
```
|
|
|
|
This specific PostgreSQL/Prisma 6.19.3 path did **not** leave partial DDL, so the
|
|
audit does not claim that it did. It did leave an unfinished
|
|
`_prisma_migrations` row, and Prisma's error explicitly refused new migrations
|
|
until operator recovery. Because systemd runs `migrate deploy` as
|
|
`ExecStartPre`, the application cannot start past that state. The throwaway
|
|
database and migration were removed. This one multi-statement result is not a
|
|
general atomicity guarantee: Prisma's first-party guidance says PostgreSQL
|
|
migrations are not transaction-wrapped by default, so each migration must make
|
|
its transaction and partial-failure behavior explicit.
|
|
|
|
### 4. The current schema is not backward-compatible with an older application
|
|
|
|
The application immediately before the typed-principal migration queried
|
|
`PermissionGrant.principal`. That migration drops the column in favor of
|
|
`principalType` plus `principalId`. Running the old read against the current
|
|
test database produced:
|
|
|
|
```text
|
|
ERROR: column "principal" does not exist
|
|
HINT: Perhaps you meant to reference the column "PermissionGrant.principalId".
|
|
old_query_exit=1
|
|
```
|
|
|
|
This is direct evidence that switching the application symlink back after that
|
|
migration cannot restore service. The migration history also deletes duplicate
|
|
grant rows and later drops `ExternalPrincipalMembership.source`; there are no
|
|
downgrade migrations.
|
|
|
|
### 5. A database-only custom dump restores cleanly
|
|
|
|
As a positive baseline, `pg_dump --format=custom` captured the current
|
|
`cph_hub_test` database and `pg_restore --exit-on-error --no-owner` restored it
|
|
into a new isolated database:
|
|
|
|
```text
|
|
restored_tables=24
|
|
finished_migrations=9
|
|
failed_migrations=0
|
|
dump_bytes=76113
|
|
```
|
|
|
|
All nine finished migration checksums in the source database also matched the
|
|
checked-in migration files. This proves a manual logical database round trip on
|
|
the current PostgreSQL 14.20 workstation. It does not prove workspace,
|
|
credentials, global database roles, production volume, cross-version restore,
|
|
application readiness, or an operational RPO/RTO.
|
|
|
|
## Critical findings
|
|
|
|
### 1. No backup, retention, or restore mechanism exists
|
|
|
|
The repository has no backup/restore command, systemd timer, off-host target,
|
|
encryption/immutability policy, retention policy, backup manifest/checksum,
|
|
success/failure alert, restore command, or scheduled recovery drill. The
|
|
installer assumes PostgreSQL and its role/database already exist. A database
|
|
dump alone does not reproduce those global prerequisites or the filesystem and
|
|
secret state above.
|
|
|
|
There is also no decided RPO, RTO, backup frequency, retention duration,
|
|
geographic copy rule, deletion-in-backups rule, or ownership/escalation path.
|
|
Those values are product and operations decisions, not safe constants to infer
|
|
from the single-host topology.
|
|
|
|
### 2. Persistent workspaces are inside the documented release tree
|
|
|
|
`.env.example` recommends `./data/project-workspaces`; systemd's working
|
|
directory is the live Hub directory; deployment mutates that same directory
|
|
with `rsync --delete`. No preflight forbids an in-release workspace root and no
|
|
exclude preserves it. This can turn a routine deploy into irreversible customer
|
|
data loss before build, migration, restart, or readiness runs.
|
|
|
|
The persistent workspace root must live outside every release directory, have
|
|
explicit ownership/mode and capacity, and be impossible for release retention
|
|
or `rsync --delete` to traverse. Immutable release work already exists as a
|
|
separate ticket, but its acceptance test must include this sentinel case.
|
|
|
|
### 3. PostgreSQL and workspaces have no consistency boundary
|
|
|
|
PostgreSQL can provide a transactionally consistent database snapshot, but the
|
|
Hub mutates workspace files independently and has no generation, journal,
|
|
commit marker, snapshot ID, or shared checkpoint that binds filesystem content
|
|
to the corresponding database state. A database dump and filesystem copy taken
|
|
at different instants can disagree about Project existence, active runs,
|
|
provider cursors, delivered messages, and file effects.
|
|
|
|
Project creation already demonstrates the same missing compensation boundary.
|
|
A replacement host has an additional problem: `Project.workspaceDir` is an
|
|
absolute path and runtime paths from the database are used directly; restore
|
|
has no same-mount validation, safe rebasing operation, or missing/orphan scan.
|
|
|
|
For the initial single-host topology, a quiesced backup checkpoint is the
|
|
smallest credible route: stop new admission, drain/cancel according to the
|
|
accepted run contract, reach durable zero in-flight work, capture database and
|
|
workspace artifacts under one manifest, then resume. An online scheme would
|
|
need a newly designed journal/generation protocol; it must not be implied by
|
|
two independent copy commands.
|
|
|
|
### 4. Schema mutation is coupled to service start
|
|
|
|
Both `npm start` and the systemd unit execute `prisma migrate deploy`; every
|
|
ordinary restart can therefore become a schema rollout. The release path has no
|
|
separate migration lock/gate, `migrate status` check, production-data rehearsal,
|
|
duration/lock budget, pre-migration recovery checkpoint, or compatibility
|
|
manifest. CI only applies the full migration history to a fresh empty
|
|
PostgreSQL database, so it does not exercise real upgrade volume, legacy values,
|
|
old/new application overlap, failure recovery, or rollback compatibility.
|
|
|
|
The current failed-migration behavior is fail-closed, which is better than
|
|
starting on an unknown schema, but there is no operator procedure for inspecting
|
|
the failure, deciding roll-forward versus mark-rolled-back, restoring the
|
|
checkpoint, or preventing a restart loop. Existing destructive migrations prove
|
|
that application rollback cannot be assumed safe.
|
|
|
|
### 5. Restore has no isolated verification or cutover gate
|
|
|
|
There is no procedure that restores into a non-production environment with
|
|
outbound Feishu/provider traffic disabled, verifies backup checksums and
|
|
migration state, validates workspace paths/content/permissions, checks tenant
|
|
relationships, decrypts a sample org connection, reconciles non-terminal work,
|
|
runs application readiness and representative journeys, and measures recovery
|
|
time/point before controlled cutover.
|
|
|
|
Starting the current server is not a neutral verifier: startup deletes all
|
|
Project locks and independently marks only `ACTIVE` runs failed. Restored data
|
|
must first pass the durable lifecycle reconciliation designed by the run-state
|
|
tickets; otherwise validation itself mutates the evidence and can leave
|
|
`WAITING_FOR_USER` inconsistent.
|
|
|
|
### 6. Full-service and Organization-selective recovery are different products
|
|
|
|
A full-service restore can recreate the shared database and all workspaces.
|
|
Restoring one Organization is much harder: users are global and may belong to
|
|
multiple Organizations, permission/audit/history rows span many related tables,
|
|
workspace paths are external, and Feishu/provider connections have external
|
|
state. The repository has no export/import identity mapping or conflict policy.
|
|
|
|
The initial production gate must at least prove full-service disaster recovery.
|
|
Whether Organization-selective restore/export is promised, and how retained
|
|
backups interact with customer deletion, remains an explicit product/legal
|
|
decision rather than an accidental property of `pg_restore` filters.
|
|
|
|
## Required recovery contract
|
|
|
|
Production readiness requires the following guarantees, with exact numerical
|
|
objectives supplied by the pending policy decision:
|
|
|
|
1. **Declared objectives and ownership.** RPO/RTO, backup frequency, retention,
|
|
copy locations, encryption/key custody, immutability, deletion handling,
|
|
full-service versus Organization restore scope, operator ownership, and
|
|
escalation are documented and testable.
|
|
2. **Complete backup set.** One cataloged manifest identifies release and
|
|
migration checksums, PostgreSQL artifact and global prerequisites, workspace
|
|
snapshot, configuration inventory, encrypted org-secret data, external key
|
|
recovery material, timestamps, sizes, and cryptographic checksums. Backup
|
|
data and decryption material are not kept only on the protected host or in
|
|
the same failure domain.
|
|
3. **One consistency point.** A backup is taken only after an observable
|
|
quiesce/checkpoint protocol, or by a proven online generation protocol that
|
|
binds database state and workspace effects. Accepted input, active runs,
|
|
queues, deliveries, locks, and provider cursors have explicit recovery
|
|
semantics.
|
|
4. **Safe migration rollout.** Migration is a release phase, not an incidental
|
|
service start. The gate checks status/checksums, rehearses on representative
|
|
production-shaped data, records old/new application compatibility, uses
|
|
expand-and-contract for destructive changes, requires a recent verified
|
|
recovery checkpoint, and provides a tested failed-migration procedure.
|
|
5. **Isolated restore verification.** Every backup class is periodically
|
|
restored to a clean isolated host/database with outbound integrations off.
|
|
Verification checks integrity, schema/migration state, global database
|
|
prerequisites, workspace completeness/path ownership, secret decryption,
|
|
tenant isolation, lifecycle reconciliation, application readiness, and
|
|
representative admin/Feishu/agent journeys.
|
|
6. **Observable operation.** Backup age, duration, artifact/checksum status,
|
|
off-host replication, retention deletion, WAL/archive continuity if used,
|
|
restore-drill result, measured RPO/RTO, and failures are logged/metricized
|
|
and alerted with bounded cardinality.
|
|
7. **Controlled cutover and fallback.** Disaster recovery has an explicit
|
|
authority, traffic/workload brake, DNS/proxy/Feishu reconnect sequence,
|
|
duplicate/gap checks, rollback/fallback point, verification checklist, and
|
|
incident evidence record.
|
|
|
|
## Required release evidence
|
|
|
|
- A deploy test proves an in-tree or release-overlapping workspace root is
|
|
rejected before any rsync/switch, while persistent sentinel files survive
|
|
build failure, successful release, rollback, and release pruning.
|
|
- Project creation, archive/deletion, failed transaction, crash, and restore
|
|
tests prove database/workspace allocation has no silent missing or orphan
|
|
state; the reconciler reports rather than hides inconsistencies.
|
|
- Every checked-in migration is rehearsed from the previous production-shaped
|
|
snapshot, with old/new compatibility and lock/runtime evidence. A deliberately
|
|
failed migration exercises the documented recovery route.
|
|
- The backup implementation proves completeness, checksum verification,
|
|
encryption, off-host copy, retention, monitoring, and fail-closed partial
|
|
artifact cleanup.
|
|
- A clean isolated host restores a representative database, workspaces,
|
|
secrets, and compatible release; lifecycle reconciliation and critical tenant
|
|
journeys pass with outbound integrations controlled.
|
|
- Scheduled drills publish achieved recovery point and recovery time against the
|
|
accepted objectives, and alert when no recent successful drill exists.
|
|
|
|
## Primary-source interpretation
|
|
|
|
The official PostgreSQL, Prisma, and systemd behavior supporting this audit is
|
|
collected with claim-level links in
|
|
[Backup and migration primary sources](./backup-migration-primary-sources.md).
|
|
That note distinguishes what a PostgreSQL-consistent dump proves from the
|
|
cross-store guarantees the Hub must build itself.
|