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
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ two roles, and both are declared in the manifest:
```jsonc
// manifest.json — a Postgres-backed app being backed up
"accepts": ["backup@1"],
"backup": { "preHook": { … }, "postHook": { … } }
"backup": { "preHook": { … }, "postHook": { … } } // or a list of participations
```

The **contract**, not the app, is the coupling point: an acceptor never names
Expand Down Expand Up @@ -167,6 +167,8 @@ CI (`bin/validate-manifest.mjs`) enforces the parts a schema can't:
| **error** | `accepts` naming an `implicit` contract (`container-logs@1`) — there is no acceptor side to opt into. |
| **warn** | The app runs a database server and accepts nothing — it will show up as *uncovered*. |
| **warn** | The app runs a database server, accepts `backup@1`, and declares no hooks — the snapshot will copy live database files. |
| **warn** | A database service no participation's `preHook` names — that one database is copied live while the rest of the app is quiesced. |
| **error** | A plural `backup` participation with a missing, empty or duplicate `id`. |

Warnings don't fail the build; whether an app is backed up is the author's call to
make, but it shouldn't be made by accident.
Expand Down Expand Up @@ -209,6 +211,30 @@ the snapshot.
| `preHook` | `{ service, command[] }` | Run **before** the file capture — quiesce or dump (e.g. `pg_dump`). `service` must name a compose service; `command` is **exec-form** (argv), not a shell string. |
| `postHook` | `{ service, command[] }` | Run **after** the capture — clean up (e.g. remove the dump). Same shape. |

**An app with two stateful services declares two participations.** The block is
either the single object above or a **list**, each entry with its own `id` and
hooks. The single object is shorthand for one participation named `default`;
neither form is more correct, and no existing bundle has to change.

```jsonc
// manifest.json — postiz, which runs its own Postgres and Temporal's
"accepts": ["backup@1"],
"backup": [
{ "id": "app-db",
"preHook": { "service": "postiz-postgres", "command": ["sh", "-c", "pg_dump -U postiz-user -d postiz-db-local -f /backups/postiz.sql"] },
"postHook": { "service": "postiz-postgres", "command": ["sh", "-c", "rm -f /backups/postiz.sql"] } },
{ "id": "temporal-db",
"preHook": { "service": "temporal-postgres", "command": ["sh", "-c", "pg_dumpall -U temporal -f /backups/temporal.sql"] },
"postHook": { "service": "temporal-postgres", "command": ["sh", "-c", "rm -f /backups/temporal.sql"] } }
]
```

`id` is required in the list form, non-empty, and unique within the list — Hola
orders hooks and reports failures by it, so a duplicate makes two databases
indistinguishable in the one message you get when a dump fails. Pre-hooks run in
declaration order and are **fail-closed**: the first failure stops the run, and
only the participations that started are cleaned up.

Rules that make the hooks useful:

- **Declare `accepts: ["backup@1"]` alongside the block.** The block alone doesn't
Expand All @@ -224,8 +250,11 @@ Rules that make the hooks useful:
release declares `upgrade.preUpgradeBackup: "required"` (the upgrade aborts rather
than snapshot a useless dump), and best-effort (warn + continue) otherwise. The
`postHook` always runs (even if the capture failed) and never fails the upgrade.
- Multi-DB apps: point the hook at the database service (`postiz-postgres`,
`immich-postgres`, etc.). One hook per block today.
- **Every database service needs its own participation.** CI warns per database
service, not per app: one hook no longer makes a two-database app look done. A
database with no participation naming it in a `preHook` is copied live while
the rest of the app is quiesced, and Hola renders the app as *partially
covered*.

### Push targets (`push`)

Expand Down
173 changes: 163 additions & 10 deletions bin/validate-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,68 @@ function checkIngressService(app, manifest, manifestPath, issues) {
}
}

/**
* The `backup` block, normalised to a list of participations — the catalog-side
* twin of `backupParticipations()` in try-hola/hola @hola/shared/contracts.
*
* Acceptor participation is plural (spec 004, FR-001/005): an app with two
* stateful services declares two participations, each with its own `id` and
* hooks. The singular object stays valid and normalises to one participation
* named `default`, exactly as the server does it, so no existing bundle has to
* change.
*
* Returns `[{ id, index, preHook, postHook }]`; `index` is the array position
* (or `undefined` for the singular form) so an error can point at the entry.
*/
function backupParticipations(block) {
if (block === undefined || block === null) return [];
if (Array.isArray(block)) {
return block
.filter((entry) => entry && typeof entry === 'object')
.map((entry, index) => ({ id: entry.id, index, preHook: entry.preHook, postHook: entry.postHook }));
}
if (typeof block !== 'object') return [];
return [{ id: 'default', index: undefined, preHook: block.preHook, postHook: block.postHook }];
}

/** `backup` / `backup[2]` — how to name a participation in an error message. */
function participationField(part, suffix) {
const base = part.index === undefined ? 'backup' : `backup[${part.index}]`;
return suffix ? `${base}.${suffix}` : base;
}

/**
* Backup hooks (#121) run via `docker compose exec <service>`, so a hook naming
* a service that doesn't exist fails at snapshot time — the least convenient
* moment. Same cross-check `ingress.service` already gets.
*
* Also enforces what the JSON Schema can't about the plural form: every
* participation needs a non-empty `id`, and no two may share one. The server
* keys hook ordering and reporting on that id, so a duplicate makes two
* different databases indistinguishable in a failure message.
*/
function checkBackupHooks(app, manifest, manifestPath, issues) {
const hooks = [
['backup.preHook.service', manifest?.backup?.preHook?.service],
['backup.postHook.service', manifest?.backup?.postHook?.service],
].filter(([, service]) => typeof service === 'string' && service);
const parts = backupParticipations(manifest?.backup);
if (parts.length === 0) return;

if (Array.isArray(manifest?.backup)) {
const seen = new Set();
for (const part of parts) {
if (typeof part.id !== 'string' || part.id.trim() === '') {
issues.push(`${app}/${participationField(part, 'id')}: a plural backup participation needs a non-empty id`);
continue;
}
if (seen.has(part.id)) {
issues.push(`${app}/${participationField(part, 'id')}: duplicate participation id "${part.id}" — ids identify which database a hook failure was about`);
}
seen.add(part.id);
}
}

const hooks = parts.flatMap((part) => [
[participationField(part, 'preHook.service'), part.preHook?.service],
[participationField(part, 'postHook.service'), part.postHook?.service],
]).filter(([, service]) => typeof service === 'string' && service);
if (hooks.length === 0) return;

const composeText = readCompose(app, manifestPath, 'backup', issues);
Expand Down Expand Up @@ -211,12 +263,93 @@ function refList(raw) {
}

/**
* Images that mean "this app runs a database server" — the case where a
* Images that mean "this service IS a database server" — the case where a
* file-level copy is crash-consistent at best and hooks are usually wanted.
* Caches (redis/valkey) are deliberately absent: every app here uses them as
* rebuildable state, so warning on them would be noise.
*
* This is the twin of DATABASE_IMAGE_FAMILIES in try-hola/hola
* packages/shared/src/contracts.ts, and the two MUST name the same families:
* this one decides whether an author is warned, that one decides whether the
* operator's dashboard judges the app's coverage at all. A family only this
* side knows means a needless warning; a family only that side knows means an
* app ships with no hooks and no warning. (A family NEITHER side knew is how
* `pgautoupgrade` — the Postgres image four catalog apps run — ended up
* rendering as fully quiesced on the dashboard; see try-hola/hola#470.)
*
* Matching mirrors `isDatabaseImage`: the last path segment of the image ref,
* minus tag and digest, matched exactly or as `family-*` / `*-family`, unless
* the remaining words name a companion role. It used to be a substring regex
* over the whole `image:` line, which could not tell WHICH service was the
* database — and per-service is exactly what the plural-participation warning
* below needs.
*/
const DATABASE_IMAGE_FAMILIES = [
'postgres', 'postgresql', 'pgautoupgrade', 'pgvector', 'postgis', 'timescaledb',
'mysql', 'mariadb', 'percona', 'mongo', 'mongodb', 'mssql', 'cockroachdb', 'couchdb',
];

/** Words that make a family name something that TALKS to a database, not one. */
const COMPANION_ROLE_WORDS = new Set([
'adminer', 'admin', 'agent', 'backup', 'backups', 'cli', 'client', 'dump',
'exporter', 'express', 'init', 'operator', 'proxy', 'restore', 'ui', 'web',
]);

function namesACompanionRole(remainder) {
return remainder.split('-').some((word) => COMPANION_ROLE_WORDS.has(word));
}

function isDatabaseImage(imageRef) {
if (typeof imageRef !== 'string' || imageRef.trim().length === 0) return false;
const withoutDigest = imageRef.split('@')[0] ?? '';
const lastSlash = withoutDigest.lastIndexOf('/');
const afterSlash = lastSlash >= 0 ? withoutDigest.slice(lastSlash + 1) : withoutDigest;
const segment = (withoutTagOf(afterSlash) ?? '').toLowerCase().trim();
if (!segment) return false;
return DATABASE_IMAGE_FAMILIES.some((family) => {
if (segment === family) return true;
if (segment.startsWith(`${family}-`)) return !namesACompanionRole(segment.slice(family.length + 1));
if (segment.endsWith(`-${family}`)) return !namesACompanionRole(segment.slice(0, -(family.length + 1)));
return false;
});
}

function withoutTagOf(segment) {
return segment.split(':')[0];
}

/**
* Service name -> image, for every top-level service in compose.yaml.
*
* Line-based rather than a YAML parse, for the same reason `serviceExists` is:
* this script has no dependencies beyond the ajv it spawns, and every compose
* in this catalog is uniformly two-space indented under a single top-level
* `services:` key. A service whose image is set some other way (build:, an
* anchor) simply doesn't appear, which costs a warning, never a false one.
*/
const DATABASE_IMAGE = /image:\s*\S*(postgres|pgautoupgrade|timescale|mysql|mariadb|percona|mongo|cockroach|mssql|sql-server)/i;
function composeServiceImages(composeText) {
const out = new Map();
let inServices = false;
let current;
for (const line of composeText.split('\n')) {
if (/^services:\s*$/.test(line)) { inServices = true; continue; }
if (/^\S/.test(line)) { inServices = false; current = undefined; continue; }
if (!inServices) continue;
const service = line.match(/^ {2}([A-Za-z0-9][A-Za-z0-9._-]*):\s*$/);
if (service) { current = service[1]; continue; }
if (!current) continue;
const image = line.match(/^ {4}image:\s*["']?([^"'\s]+)["']?\s*$/);
if (image) { out.set(current, image[1]); current = undefined; }
}
return out;
}

/** Every compose service whose image names a recognised database family. */
function databaseServices(composeText) {
return [...composeServiceImages(composeText)]
.filter(([, image]) => isDatabaseImage(image))
.map(([service]) => service);
}

/**
* The two halves of a contract have to agree, and neither the JSON Schema nor the
Expand Down Expand Up @@ -285,15 +418,35 @@ function checkContracts(app, manifest, manifestPath, issues, warnings) {
const composePath = join(dirname(manifestPath), 'compose.yaml');
if (!existsSync(composePath)) return;
const composeText = readFileSync(composePath, 'utf8');
if (!DATABASE_IMAGE.test(composeText)) return;
const databases = databaseServices(composeText);
if (databases.length === 0) return;

if (!accepts.includes('backup@1')) {
warnings.push(
`${app}/accepts: runs a database server but accepts nothing — Hola will report it as UNCOVERED. Declare "backup@1" (with hooks) or say why not.`
`${app}/accepts: runs a database server (${databases.join(', ')}) but accepts nothing — Hola will report it as UNCOVERED. Declare "backup@1" (with hooks) or say why not.`
);
return;
}

// Per DATABASE SERVICE, not per app. An app-level check passes the moment ONE
// hook exists, which is how postiz shipped with its second Postgres
// (temporal-postgres) never quiesced — the one case plural participations
// exist for. The pre-hook is the quiesce, so that is what has to name it.
const quiesced = new Set(
backupParticipations(manifest?.backup)
.map((part) => part.preHook?.service)
.filter((service) => typeof service === 'string' && service)
);
const unquiesced = databases.filter((service) => !quiesced.has(service));
if (unquiesced.length === 0) return;

if (quiesced.size === 0) {
warnings.push(
`${app}/backup: accepts "backup@1" and runs a database server (${unquiesced.join(', ')}), but declares no pre-hook for it — the snapshot will copy live database files, which is crash-consistent at best.`
);
} else if (manifest?.backup === undefined) {
} else {
warnings.push(
`${app}/backup: accepts "backup@1" and runs a database server, but declares no hooks — the snapshot will copy live database files, which is crash-consistent at best.`
`${app}/backup: no participation's preHook names ${unquiesced.map((s) => `"${s}"`).join(', ')} — that database is copied live while the rest of the app is quiesced. Hola renders this app as PARTIALLY covered. Add a participation for it.`
);
}
}
Expand Down
Loading
Loading