From 4086895a7d70752ee78cc44aaec8c49729e5fd1f Mon Sep 17 00:00:00 2001 From: Paul O'Fallon Date: Sun, 20 Sep 2026 02:43:08 +0000 Subject: [PATCH] feat(backup): plural participations, per-database coverage (#152, #153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server has supported a list of backup@1 participations since spec 004; the catalog's schema forbade it, so no bundle could adopt it and postiz's second Postgres stayed unquiesced (#153, blocked on #152). Schema: `backup` is now the singular object OR a list of `{ id, preHook?, postHook? }`. The singular form is untouched and normalises server-side to one participation named `default`, so no existing bundle changes. Validator: `backupParticipations()` mirrors the server's reader and every hook check runs per participation, with errors that name the entry (`backup[1].preHook.service`). Adds what JSON Schema can't express — an id must be non-empty and unique, because the server keys hook ordering and failure reporting on it and a duplicate makes two databases indistinguishable in the one message you get when a dump fails. The "runs a database, declares no hooks" warning is now **per database service**. An app-level check passes the moment one hook exists, which is exactly how postiz shipped with temporal-postgres never quiesced. The pre-hook is the quiesce, so that is what has to name each database. That required knowing WHICH service is the database, so DATABASE_IMAGE (a substring regex over the whole `image:` line) becomes a service->image scan plus a port of the server's `isDatabaseImage` — same family list, same last-path- segment matching, same companion-role exclusions (`postgres-exporter` is not a database). The docstring now says outright that this list and DATABASE_IMAGE_FAMILIES are twins that must name the same families, and what goes wrong when they don't: a family only this side knows is a needless warning, a family only that side knows is an app shipping with no hooks and no warning, and a family NEITHER knew is how `pgautoupgrade` ended up rendering as fully quiesced on four apps' dashboards (try-hola/hola#470). postiz 2.1.0 adopts the plural form: `app-db` keeps the existing pg_dump, `temporal-db` adds `pg_dumpall` over Temporal's `temporal` and `temporal_visibility` databases. temporal-postgres gains the same `${HOLA_APP_DATA}/backups:/backups` bind the primary database has — without it the dump would land outside the data root and never reach a snapshot. Verified: all 18 apps validate clean with no warnings; before the postiz change the new per-database warning fired on exactly temporal-postgres and nothing else. Negative cases caught: duplicate id, a hook naming a nonexistent service, a missing id (schema). The server's own `backupParticipations` + `judgeBackupCoverage` read postiz's new block as `quiesced 2/2`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh --- README.md | 35 ++- bin/validate-manifest.mjs | 173 +++++++++++- schemas/manifest.schema.json | 502 ++++++++++++++++++++++++++++------- src/postiz/package.json | 2 +- src/postiz/src/compose.yaml | 9 +- src/postiz/src/manifest.json | 65 +++-- 6 files changed, 649 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 631acca..c01d98e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 @@ -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`) diff --git a/bin/validate-manifest.mjs b/bin/validate-manifest.mjs index b898bc9..7494adb 100755 --- a/bin/validate-manifest.mjs +++ b/bin/validate-manifest.mjs @@ -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 `, 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); @@ -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 @@ -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.` ); } } diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 7faf386..578226e 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -5,7 +5,13 @@ "description": "Shape of src//src/manifest.json, the per-app metadata the Hola server reads at deploy time. See README.md for full field docs. additionalProperties:false is used at the top level and for defaultEnv entries because the Hola server's manifest coercion silently drops unknown/malformed fields instead of erroring -- a typo'd field name is otherwise a silent no-op, not a CI failure.", "type": "object", "additionalProperties": false, - "required": ["name", "version", "title", "description", "ingress"], + "required": [ + "name", + "version", + "title", + "description", + "ingress" + ], "properties": { "name": { "type": "string", @@ -17,21 +23,34 @@ "minLength": 1, "description": "Hola bundle version (describes impact on the Hola user, not upstream's version)." }, - "title": { "type": "string", "minLength": 1 }, - "description": { "type": "string", "minLength": 1 }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, "icon": { "type": "string", "description": "Emoji or an icon URL." }, - "category": { "type": "string" }, + "category": { + "type": "string" + }, "tags": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "ingress": { "type": "object", "additionalProperties": false, - "required": ["service", "port"], + "required": [ + "service", + "port" + ], "properties": { "service": { "type": "string", @@ -48,7 +67,9 @@ }, "defaultEnv": { "type": "array", - "items": { "$ref": "#/$defs/appEnvVar" } + "items": { + "$ref": "#/$defs/appEnvVar" + } }, "defaults": { "type": "object", @@ -59,11 +80,23 @@ "items": { "type": "object", "additionalProperties": true, - "required": ["container"], + "required": [ + "container" + ], "properties": { - "host": { "type": "integer" }, - "container": { "type": "integer" }, - "protocol": { "type": "string", "enum": ["tcp", "udp"] } + "host": { + "type": "integer" + }, + "container": { + "type": "integer" + }, + "protocol": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } } } }, @@ -72,11 +105,19 @@ "items": { "type": "object", "additionalProperties": true, - "required": ["containerPath"], + "required": [ + "containerPath" + ], "properties": { - "hostPath": { "type": "string" }, - "containerPath": { "type": "string" }, - "readOnly": { "type": "boolean" } + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + } } } } @@ -86,74 +127,144 @@ "type": "object", "description": "SSO integration. Permissive on nested shapes -- this block is not part of the typed-defaultEnv contract and the server's coercion is deliberately forward-compatible about extra fields.", "additionalProperties": true, - "required": ["mode"], + "required": [ + "mode" + ], "properties": { "mode": { "type": "string", - "enum": ["none", "native-oidc", "forward-auth", "native-ldap"] + "enum": [ + "none", + "native-oidc", + "forward-auth", + "native-ldap" + ] }, "fallback": { "type": "string", - "enum": ["forward-auth"], + "enum": [ + "forward-auth" + ], "description": "Gates a native-oidc/none app behind proxy login too; can coexist with any mode." }, "oidc": { "type": "object", "additionalProperties": true, "properties": { - "redirectPath": { "type": "string" }, - "scopes": { "type": "array", "items": { "type": "string" } }, + "redirectPath": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, "env": { "type": "object", - "additionalProperties": { "type": "string" }, + "additionalProperties": { + "type": "string" + }, "properties": { - "issuer": { "type": "string" }, - "clientId": { "type": "string" }, - "clientSecret": { "type": "string" }, - "redirectUri": { "type": "string" }, - "authUrl": { "type": "string" }, - "tokenUrl": { "type": "string" }, - "userinfoUrl": { "type": "string" } + "issuer": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "authUrl": { + "type": "string" + }, + "tokenUrl": { + "type": "string" + }, + "userinfoUrl": { + "type": "string" + } } }, "staticEnv": { "type": "object", - "additionalProperties": { "type": "string" } + "additionalProperties": { + "type": "string" + } }, "setup": { "type": "object", "additionalProperties": true, - "required": ["command"], + "required": [ + "command" + ], "properties": { - "service": { "type": "string" }, - "user": { "type": "string" }, - "check": { "type": "array", "items": { "type": "string" } }, - "checkMatch": { "type": "string" }, - "command": { "type": "array", "items": { "type": "string" } } + "service": { + "type": "string" + }, + "user": { + "type": "string" + }, + "check": { + "type": "array", + "items": { + "type": "string" + } + }, + "checkMatch": { + "type": "string" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + } } }, "extraRedirectUris": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "credentialsFile": { "type": "object", "additionalProperties": true, - "required": ["path"], + "required": [ + "path" + ], "properties": { - "path": { "type": "string" } + "path": { + "type": "string" + } } }, "roleClaim": { "type": "object", "additionalProperties": true, - "required": ["claim"], + "required": [ + "claim" + ], "properties": { - "claim": { "type": "string" }, - "adminGroup": { "type": "string" }, - "adminValue": { "type": "string" }, - "memberValue": { "type": "string" }, - "scope": { "type": "string" } + "claim": { + "type": "string" + }, + "adminGroup": { + "type": "string" + }, + "adminValue": { + "type": "string" + }, + "memberValue": { + "type": "string" + }, + "scope": { + "type": "string" + } } } } @@ -161,18 +272,38 @@ "ldap": { "type": "object", "additionalProperties": true, - "required": ["env"], + "required": [ + "env" + ], "properties": { "env": { "type": "object", - "additionalProperties": { "type": "string" }, - "required": ["host", "port", "bindDn", "bindPassword", "baseDn"], + "additionalProperties": { + "type": "string" + }, + "required": [ + "host", + "port", + "bindDn", + "bindPassword", + "baseDn" + ], "properties": { - "host": { "type": "string" }, - "port": { "type": "string" }, - "bindDn": { "type": "string" }, - "bindPassword": { "type": "string" }, - "baseDn": { "type": "string" } + "host": { + "type": "string" + }, + "port": { + "type": "string" + }, + "bindDn": { + "type": "string" + }, + "bindPassword": { + "type": "string" + }, + "baseDn": { + "type": "string" + } } } } @@ -183,7 +314,9 @@ "properties": { "allowedGroups": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } } } @@ -192,45 +325,97 @@ "consumes": { "description": "Cross-app capability(ies) this app consumes, e.g. 'app-registry'. A bare string or an array of strings. NOTE: 'apps-data' is deprecated here -- it is now the provider grant of the 'backup@1' contract, so a backup engine declares `provides: [\"backup@1\"]` instead and the operator consents to the grant at install. See ADR 0004.", "anyOf": [ - { "type": "string" }, - { "type": "array", "items": { "type": "string" } } + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } ] }, "provides": { "description": "Capability contract(s) this app PERFORMS for other apps (ADR 0004) -- e.g. 'backup@1' for a backup engine. A contract's provider grant is elevated access the server injects because of the role, disclosed to the operator for consent at install: 'backup@1' grants a read-only mount of every app's data. Only contracts with an app-side provider may be declared; 'auth@1' and 'push@1' are provided by the platform itself, and an app claiming them is a manifest error. See Capability contracts in README.md.", "anyOf": [ - { "$ref": "#/$defs/appProvidedContractRef" }, - { "type": "array", "items": { "$ref": "#/$defs/appProvidedContractRef" } } + { + "$ref": "#/$defs/appProvidedContractRef" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/appProvidedContractRef" + } + } ] }, "accepts": { "description": "Capability contract(s) this app OPTS IN to being a subject of (ADR 0004) -- 'backup@1' means \"back me up\". Acceptance is DECLARED, never inferred from the typed block: the block ('backup') says HOW an app participates, 'accepts' says WHETHER it does, and they are different facts. An app that needs no hooks at all (SQLite, flat-file) must still declare `accepts` -- without it Hola cannot tell a genuinely-covered app from one nobody ever considered, and the coverage view reads it as uncovered rather than fine.", "anyOf": [ - { "$ref": "#/$defs/acceptableContractRef" }, - { "type": "array", "items": { "$ref": "#/$defs/acceptableContractRef" } } + { + "$ref": "#/$defs/acceptableContractRef" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/acceptableContractRef" + } + } ] }, "upgrade": { "type": "object", "additionalProperties": false, "properties": { - "breaking": { "type": "boolean" }, - "minFromVersion": { "type": "string" }, - "waypoints": { "type": "array", "items": { "type": "string" } }, - "upgradeNotesUrl": { "type": "string" }, + "breaking": { + "type": "boolean" + }, + "minFromVersion": { + "type": "string" + }, + "waypoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "upgradeNotesUrl": { + "type": "string" + }, "preUpgradeBackup": { "type": "string", - "enum": ["required", "recommended", "none"] + "enum": [ + "required", + "recommended", + "none" + ] } } }, "backup": { - "type": "object", - "additionalProperties": false, - "properties": { - "preHook": { "$ref": "#/$defs/backupHook" }, - "postHook": { "$ref": "#/$defs/backupHook" } - } + "description": "How this app participates in backup@1. Either a single participation (the object form, normalised server-side to one participation named \"default\") or a list of them — an app with two stateful services declares two, each with its own id and hooks. Requires \"backup@1\" in accepts[].", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "preHook": { + "$ref": "#/$defs/backupHook" + }, + "postHook": { + "$ref": "#/$defs/backupHook" + } + } + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/backupParticipation" + } + } + ] }, "push": { "type": "array", @@ -239,7 +424,11 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["id", "label", "path"], + "required": [ + "id", + "label", + "path" + ], "properties": { "id": { "type": "string", @@ -264,13 +453,19 @@ }, "mode": { "type": "string", - "enum": ["mirror", "additive"], + "enum": [ + "mirror", + "additive" + ], "default": "additive", "description": "mirror: rsync --delete, so the server copy matches the local one exactly (files only on the server are DELETED). additive (default): copy in without deleting. Declare mirror only when the directory genuinely is a replica of the operator's copy." }, "quiesce": { "type": "string", - "enum": ["stop", "none"], + "enum": [ + "stop", + "none" + ], "default": "none", "description": "stop: stop the app for the duration of the push and start it after — for apps that hold open handles on this data (e.g. a SQLite database read at startup). none (default): leave it running." }, @@ -292,11 +487,16 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["type", "reason"], + "required": [ + "type", + "reason" + ], "properties": { "type": { "type": "string", - "enum": ["allow-privilege-escalation"], + "enum": [ + "allow-privilege-escalation" + ], "description": "allow-privilege-escalation: drop no-new-privileges on the ingress service so setuid escalation (sudo) works. Needed by browser-desktop apps." }, "reason": { @@ -314,22 +514,37 @@ "contractRef": { "type": "string", "description": "A capability contract reference, `@`. CLOSED SET: a contract is a promise about *server* behavior, so matching two strings does nothing unless Hola implements the middle. Mirrors CONTRACTS in try-hola/hola packages/shared/src/contracts.ts -- adding a contract there is what makes it declarable here. Enumerated rather than left open because the server drops an unrecognized ref with a warning (ADR 0003 forward-compat), which would make a typo'd `backups@1` a silent no-op instead of a CI failure.", - "enum": ["auth@1", "backup@1", "container-logs@1", "push@1"] + "enum": [ + "auth@1", + "backup@1", + "container-logs@1", + "push@1" + ] }, "acceptableContractRef": { "type": "string", "description": "The subset of #/$defs/contractRef an app can OPT IN to, i.e. those whose participation is `declared`. An `implicit` contract (container-logs@1) has no acceptor side: every install is a subject by virtue of running, and the server drops an `accepts` naming one with a warning, so declaring it here is a manifest error rather than a silent no-op.", - "enum": ["auth@1", "backup@1", "push@1"] + "enum": [ + "auth@1", + "backup@1", + "push@1" + ] }, "appProvidedContractRef": { "type": "string", "description": "The subset of #/$defs/contractRef whose provider is a catalog app rather than the platform, and which may therefore appear in `provides`.", - "enum": ["backup@1", "container-logs@1"] + "enum": [ + "backup@1", + "container-logs@1" + ] }, "backupHook": { "type": "object", "additionalProperties": false, - "required": ["service", "command"], + "required": [ + "service", + "command" + ], "properties": { "service": { "type": "string", @@ -339,23 +554,40 @@ "command": { "type": "array", "minItems": 1, - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Exec-form argv, not a shell string." } } }, "paramType": { "type": "string", - "enum": ["string", "integer", "port", "boolean", "enum", "url", "email", "timezone"] + "enum": [ + "string", + "integer", + "port", + "boolean", + "enum", + "url", + "email", + "timezone" + ] }, "paramGenerate": { "type": "object", "additionalProperties": false, - "required": ["kind"], + "required": [ + "kind" + ], "properties": { "kind": { "type": "string", - "enum": ["hex", "base64", "fernet"] + "enum": [ + "hex", + "base64", + "fernet" + ] }, "length": { "type": "integer", @@ -367,36 +599,71 @@ "paramEnumOption": { "type": "object", "additionalProperties": false, - "required": ["value"], + "required": [ + "value" + ], "properties": { - "value": { "type": "string" }, - "label": { "type": "string" }, - "description": { "type": "string" } + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } } }, "appEnvVar": { "type": "object", "additionalProperties": false, - "required": ["key", "value", "isSecret"], + "required": [ + "key", + "value", + "isSecret" + ], "properties": { - "key": { "type": "string", "minLength": 1 }, - "value": { "type": "string" }, - "isSecret": { "type": "boolean" }, - "description": { "type": "string" }, - "label": { "type": "string" }, - "type": { "$ref": "#/$defs/paramType" }, + "key": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "string" + }, + "isSecret": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "$ref": "#/$defs/paramType" + }, "required": { "type": "boolean", "description": "Tri-state: true/false/absent. Absent means legacy \"isSecret implies required\"." }, - "advanced": { "type": "boolean" }, - "placeholder": { "type": "string" }, + "advanced": { + "type": "boolean" + }, + "placeholder": { + "type": "string" + }, "pattern": { "type": "string", "description": "string type: regex the value must match." }, - "minLength": { "type": "integer", "minimum": 0 }, - "maxLength": { "type": "integer", "minimum": 0 }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, "min": { "type": "integer", "description": "integer/port type." @@ -408,16 +675,47 @@ "options": { "type": "array", "description": "enum type.", - "items": { "$ref": "#/$defs/paramEnumOption" } + "items": { + "$ref": "#/$defs/paramEnumOption" + } + }, + "trueValue": { + "type": "string", + "description": "boolean type." + }, + "falseValue": { + "type": "string", + "description": "boolean type." + }, + "httpsOnly": { + "type": "boolean", + "description": "url type." }, - "trueValue": { "type": "string", "description": "boolean type." }, - "falseValue": { "type": "string", "description": "boolean type." }, - "httpsOnly": { "type": "boolean", "description": "url type." }, "generate": { "$ref": "#/$defs/paramGenerate", "description": "Only meaningful when isSecret is true." } } + }, + "backupParticipation": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Identifies this participation in hook ordering and failure reports. Unique within the array (bin/validate-manifest.mjs enforces uniqueness). Name the thing it quiesces, e.g. 'app-db', 'temporal-db'." + }, + "preHook": { + "$ref": "#/$defs/backupHook" + }, + "postHook": { + "$ref": "#/$defs/backupHook" + } + } } } } diff --git a/src/postiz/package.json b/src/postiz/package.json index 02d08f7..06308cc 100644 --- a/src/postiz/package.json +++ b/src/postiz/package.json @@ -1,6 +1,6 @@ { "name": "postiz", - "version": "2.0.4", + "version": "2.1.0", "description": "Postiz — open-source social media scheduling (Hola app package)", "license": "UNLICENSED", "oci": { diff --git a/src/postiz/src/compose.yaml b/src/postiz/src/compose.yaml index 9a71203..5ccc86b 100644 --- a/src/postiz/src/compose.yaml +++ b/src/postiz/src/compose.yaml @@ -125,8 +125,9 @@ services: temporal-postgres: # pgautoupgrade: migrates this DB across the Postgres major in place on boot (see - # postiz-postgres). Temporal's own DB; the app-data snapshot covers it at file level - # (the manifest backup hook dumps the primary postiz DB). + # postiz-postgres). Temporal's own databases (`temporal` and, from auto-setup, + # `temporal_visibility`) — quiesced by its own backup@1 participation, same as + # the primary Postiz database. image: pgautoupgrade/pgautoupgrade:18-alpine@sha256:19f988b90c8d0ad9c7edd70cf23c83e069abd8a57f0fdce0373588ddaf189894 environment: POSTGRES_USER: temporal @@ -135,6 +136,10 @@ services: PGDATA: /var/lib/postgresql/data volumes: - ${HOLA_APP_DATA}/temporal-postgres:/var/lib/postgresql/data + # Same bind the primary database uses: the pre-hook's dump has to land + # INSIDE the app data root, because the root is what the backup provider + # captures. A dump written anywhere else is never in a snapshot. + - ${HOLA_APP_DATA}/backups:/backups healthcheck: test: ["CMD-SHELL", "pg_isready -U temporal"] interval: 10s diff --git a/src/postiz/src/manifest.json b/src/postiz/src/manifest.json index 964f2da..b26f148 100644 --- a/src/postiz/src/manifest.json +++ b/src/postiz/src/manifest.json @@ -1,6 +1,6 @@ { "name": "postiz", - "version": "2.0.4", + "version": "2.1.0", "title": "Postiz", "description": "Open-source social media scheduling tool with AI features", "icon": "https://raw.githubusercontent.com/try-hola/apps/main/icons/postiz.svg", @@ -16,7 +16,10 @@ "description": "Unique random string used to sign sessions. Generate one, e.g. `openssl rand -hex 32`.", "label": "Session Signing Key", "required": true, - "generate": { "kind": "hex", "length": 32 } + "generate": { + "kind": "hex", + "length": 32 + } }, { "key": "DISABLE_REGISTRATION", @@ -66,28 +69,52 @@ } } }, - "backup": { - "preHook": { - "service": "postiz-postgres", - "command": [ - "sh", - "-c", - "pg_dump -U postiz-user -d postiz-db-local -f /backups/postiz.sql" - ] + "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" + ] + } }, - "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" + ] + } } - }, + ], "upgrade": { "breaking": true, "preUpgradeBackup": "required", "upgradeNotesUrl": "https://github.com/pgautoupgrade/docker-pgautoupgrade" }, - "accepts": ["backup@1"] + "accepts": [ + "backup@1" + ] }