diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6a4b4628 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.env +ghost.env +.ghost-* +data +node_modules +caddy/sites +caddy/custom +caddy/global +caddy/.staging diff --git a/.editorconfig b/.editorconfig index 15191303..446ce78d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,3 +27,7 @@ indent_style = tab [Caddyfile] indent_style = tab + +# Match Ghost's JavaScript/TypeScript formatting without changing shell style. +[*.{js,mjs,ts,mts}] +indent_size = 2 diff --git a/.github/workflows/manager.yml b/.github/workflows/manager.yml new file mode 100644 index 00000000..ddf49e33 --- /dev/null +++ b/.github/workflows/manager.yml @@ -0,0 +1,27 @@ +--- +name: Recovery manager image +on: + push: + tags: ['v*'] +permissions: + contents: read + packages: write +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + - name: Publish release manager + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + image="ghcr.io/${GITHUB_REPOSITORY,,}/manager" + docker buildx create --use + docker buildx build --platform linux/amd64,linux/arm64 \ + --file manager/Dockerfile --tag "$image:$GITHUB_REF_NAME" \ + --metadata-file /tmp/manager-image.json --push . + digest=$(jq -r '.["containerimage.digest"]' /tmp/manager-image.json) + printf 'Release manager: `%s@%s`\n' "$image" "$digest" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0674390..e72fdabe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,15 @@ jobs: with: node-version: "22" + - name: Install development tools + run: npm ci --ignore-scripts + + - name: Check JavaScript and TypeScript + run: | + npm run format:check + npm run lint + npm run typecheck + - name: Check the runtime prerequisites run: | set -eu @@ -157,3 +166,17 @@ jobs: run: | docker ps -a docker compose ls || true + + recovery: + name: Recovery checkpoint and restore drill + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: "22" + - name: Restore a real site into an isolated destination + env: + GD_TEST_RECOVERY: "1" + run: node --test --test-timeout=1800000 tests/recovery-e2e.test.mjs diff --git a/.gitignore b/.gitignore index 48466d73..77b3fcdd 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,16 @@ caddy/.staging/ caddy/Caddyfile.local # Backups written by the helpers *.bak.* + +# Recovery checkpoints contain credentials and full site data. +.ghost-backups/ +.ghost-operation.json +.ghost-operation-lock/ +.ghost-manager-image +.ghost-operation.json.tmp +.ghost-docker.json.tmp* +.env.tmp.* +ghost.env.tmp.* + +# Development tooling only; never part of the manager runtime. +node_modules/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..ccdcbc99 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "singleQuote": true, + "embeddedLanguageFormatting": "off", + "ignorePatterns": ["tests/fixtures/**", "**/node_modules/**"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..36cf78d8 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc"], + "categories": { "correctness": "error" }, + "rules": { "curly": ["error", "all"] }, + "ignorePatterns": ["tests/fixtures/**"] +} diff --git a/CLAUDE.md b/CLAUDE.md index a07f4712..90c12ab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ curl -fsSL .../bootstrap.sh | bash -s -- --domain example.com # release-select ./install.sh --local --no-prompt --no-start # checkout-owned installer scripts/site.sh check # doctor: config, health, DB, ingress scripts/site.sh list # every managed container on this host +scripts/recovery.sh backup --keep 5 # private recovery checkpoint +scripts/recovery.sh status # inspect an unfinished operation # Core operations docker compose up -d # Start the services for the selected mode @@ -73,8 +75,12 @@ scripts/config.sh unset ghost.env KEY # Caddy routes scripts/caddy.sh apply # Render, validate, install, reload, verify -# Tests (Node 20+ built-in runner, no dependencies and no package.json; +# Development (Node 22.18+ on the 22.x line, or Node 24+; # docker tests skip without a daemon) +npm ci --ignore-scripts +npm run format:check +npm run lint +npm run typecheck node --test --test-timeout=120000 tests/*.test.mjs GD_TEST_INGRESS=1 node --test --test-timeout=900000 tests/ingress.test.mjs GD_TEST_INSTALL=1 node --test --test-timeout=1800000 tests/install-e2e.test.mjs @@ -135,8 +141,7 @@ The repository includes comprehensive migration tools: - Sets up Docker Compose environment - `scripts/config-to-env.js` - Converts Ghost JSON config to ghost.env format. - CommonJS; there is no package.json in this repository, so `.js` is CommonJS - by default. This is the only host Node dependency, and `install.sh --import` + CommonJS; the root package keeps the default CommonJS module mode. This is the only host Node dependency, and `install.sh --import` removes it ## Installer @@ -165,7 +170,9 @@ Rules that must not regress: `--image-registry`, `--ghost-channel`, `--without`) exit 3 naming the step, not as unknown options. -See `docs/install.md`. +See `docs/install.md`. Recovery checkpoints, isolated restore and the manager +image are documented in `docs/recovery.md`. Supported mutating scripts share the +operation lock; new mutating commands must acquire it and refuse unresolved journals. ## Development Workflow @@ -183,6 +190,12 @@ in `tests/` run by Node's built-in runner: the shell libraries are exercised through a real shell via `tests/helpers.mjs`, while fixtures, structured-output parsing and assertions are JavaScript. +JavaScript and TypeScript use oxfmt with Ghost's two-space, single-quote style. +Oxlint requires braces around control flow. The manager is strict TypeScript using +Node's native type stripping; `npm run typecheck` uses `tsc --noEmit` with +`erasableSyntaxOnly`. No compiler or npm dependencies are needed in the manager +image. See `docs/development.md`. + For analytics setup, see `TINYBIRD.md` for detailed instructions. ## Implementation plan diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..8014598e --- /dev/null +++ b/docs/development.md @@ -0,0 +1,41 @@ +# Development tools + +Use Node 22.18 or later on the 22.x line, or Node 24+. These are developer/test +requirements; site operators still use the shell dispatcher and Docker. + +```sh +npm ci --ignore-scripts +npm run format +npm run format:check +npm run lint +npm run typecheck +npm test +``` + +The dependencies are pinned in `package-lock.json`. CI runs the formatting, lint +and type checks before the helper suite. Docker integration tests skip without a +working daemon; the opt-in restore drills are described in `docs/recovery.md`. + +Formatting uses Ghost's oxfmt version and conventions: two spaces for JavaScript +and TypeScript, single quotes, and no embedded-language formatting. The formatter +covers the manager, tests, the legacy JavaScript helper and tooling configuration; +fixture data is excluded. Shell files retain their existing four-space style and +ShellCheck validation. Oxlint checks correctness and requires braces around control +flow so dense one-line conditions do not return. + +The manager is strict TypeScript with explicit checkpoint, journal, ownership, +Docker response and subprocess types. Its local `package.json` selects ESM; the +repository root keeps CommonJS for the legacy `.js` helper. Tests remain `.mjs` +and import the manager's `.ts` modules directly. + +Node strips erasable types at runtime; it does **not** type-check the program. +`npm run typecheck` runs TypeScript with `noEmit`, `erasableSyntaxOnly`, +`verbatimModuleSyntax` and `allowImportingTsExtensions`. Use type-only imports and +explicit `.ts` extensions, and avoid enums, parameter properties, decorators, +path aliases or other constructs that require transformation. Runtime JSON and +checkpoint integrity checks still apply; TypeScript types are not input validation. + +The manager Dockerfile copies the TypeScript source and its ESM package metadata, +then runs `node /opt/manager/main.ts`. It does not install the formatter, linter, +compiler, or any npm runtime dependencies. The pinned Node image is tested by the +same local, production HTTPS and ActivityPub restore drills as the recovery code. diff --git a/docs/ghost-cli-replacement.md b/docs/ghost-cli-replacement.md index 6306d7da..fcfde682 100644 --- a/docs/ghost-cli-replacement.md +++ b/docs/ghost-cli-replacement.md @@ -876,6 +876,15 @@ blockers for dependent steps. ### S4 — Backup, restore, locks, and recovery journal +Implementation: see `docs/recovery.md`. The initial manager supports stack-managed +MySQL and Ghost/ActivityPub, private directory checkpoints, a shared host lock and +durable recovery journals. Analytics and external database/override configurations +fail preflight pending explicit state contracts. Maintenance stops all application +and ingress containers. Restore targets a fresh checkout and remains isolated until +explicit activation; S7 must add in-place transactional upgrade orchestration around +these primitives. Source checkouts build and execute an immutable manager image ID; +release tags publish multi-platform manager images selectable by digest. + Repo: ghost-docker. Deps: S1. Implement the reusable §2.5 checkpoint/restore contract, explicit DB connection abstraction, operation lock, maintenance handling, retention, and journals. Backups include required local application/configuration state and diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 00000000..cb8726ae --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,144 @@ +# Recovery checkpoints + +S4 introduces `scripts/recovery.sh` and a containerized manager. The host still +needs only the installer prerequisites; Node runs inside the manager. The manager +is TypeScript executed directly by the pinned Node 22 image, with no compilation +step or npm runtime dependencies. See `docs/development.md` for type-checking and +formatting. A source +checkout builds its own manager before downtime and executes the resulting image +ID. Releases publish `ghcr.io/tryghost/ghost-docker/manager`; set +`GD_MANAGER_IMAGE=ghcr.io/tryghost/ghost-docker/manager@sha256:…` to use a published +immutable digest. Mutable image tags are refused. The journal, checkpoint and installation metadata (when present) record the actual +manager image ID. Publication is wired to release tags; no registry publication is +needed to use a source checkout. + +```sh +scripts/recovery.sh backup --keep 5 +scripts/recovery.sh status +``` + +Backups are private directory checkpoints under `.ghost-backups/`, **not migration +bundles**. Copy the entire checkpoint directory to private off-host storage. It +contains database credentials and all site data. Completed checkpoints have a +versioned manifest and SHA-256 inventory. Incomplete `.partial-*` directories are +never offered as completed backups or removed by retention. Retention removes only +verified completed checkpoints, keeping the newest requested count (default five). +A failed backup remains journaled; inspect it and run `recover` to restore the +previous service state. Partial files remain available for inspection/removal. + +The initial supported configuration is the stack-managed MySQL database, local or +production mode, optionally ActivityPub, and data bind mounts beneath +`PROJECT_DIR/data`. External MySQL, Compose overrides, symlinks/hardlinks/special +files, analytics, supervisor, rootless Docker and user namespaces are refused +before downtime. Local Unix sockets are required; Docker Desktop/OrbStack must +share the site's absolute path with the daemon. The manager mounts that same path, +the socket, and (for restore) a read-only checkpoint. Docker socket access is +host-privileged. No unrelated host directories are mounted writable. + +A checkpoint includes: + +- A logical SQL dump of the explicitly selected Ghost database, and ActivityPub's + database when enabled, including schema, triggers, routines and events. +- The entire Ghost content tree, including hidden files, themes and ActivityPub + uploads, with recorded ownership and modes. +- `.env`, `ghost.env`, installation metadata when present, the stack's Compose + definition, MySQL initialization scripts and the full Caddy configuration tree. +- Exact registry image digests, manager identity and file checksums. + +Caddy's certificate and cache volumes are regenerated, so activation may need +certificate issuance. Analytics is currently refused because its persistent queues +and remote deployment state need their own recovery contract. Ghost/ActivityPub +backups do not undo delivered email, payments, webhooks, federation messages or +other remote effects. Extra databases unrelated to the enabled services are not +included. External writers to the managed databases must be stopped by the +operator; an enabled MySQL event scheduler with active events is refused. + +Backup disables restart policies and stops every application/ingress container, +including background workers and custom Caddy routes, while leaving MySQL running. +Requests fail to connect during maintenance. The stopped ingress is intentional: +custom routes cannot bypass a maintenance response. After snapshot verification, +the manager restores each container's previous restart policy and running/stopped +state and verifies readiness before reporting completion. It checks available space +with conservative database/content allowances and a 256 MiB reserve; actual write +errors still fail the operation. Checkpoints are published only after file writes, +manifest verification and directory synchronization. + +## Restore drill or recovery to a fresh checkout + +Restore into a separate checkout with no `.env`, metadata, existing data or +containers belonging to the chosen destination project. Use a checkout containing +S4. The source checkpoint's stack configuration and image versions are restored; +upgrading is a separate operation. + +```sh +# Run these from the fresh destination checkout. +scripts/recovery.sh restore /absolute/path/to/checkpoint \ + --project ghost-rehearsal --local --port 2468 +scripts/recovery.sh status +# Once you intend to run this copy normally: +scripts/recovery.sh activate +``` + +`--local` changes the destination's mode, URL and port and disables optional +profiles for a rehearsal. Without it, the checkpoint's mode/URLs/profiles are +retained. `--project` is always explicit and must not already exist on this daemon. +Generated production routes are rendered for the new identity at activation; +operator Caddy files are retained and must be appropriate for the destination. + +Restore checks the checkpoint before mutation, pulls its immutable images, restores +SQL and files, compares every table row count and the content inventory, then boots a temporary Ghost without published ports on an internal +Docker network. This verification container can reach MySQL but cannot send mail or +webhooks to external services. It is removed after verification. Normal Ghost, +ActivityPub, jobs and Caddy remain stopped. The journal remains at `verified` until +`activate`; configuration helpers also remain locked out by that journal. + +**Activation enables normal outbound behavior and ingress.** Use it only when the +copy is intended to become a running site. Production cutover, DNS and ensuring the +old site no longer accepts writes are operator responsibilities. Activation verifies +Compose health and production proxy routing before clearing the journal. Restore +does not overwrite an existing installed site: preserve that site and use a fresh +destination. It does not claim an automatic rollback of a live deployment. + +## Interrupted operations + +Every supported mutating entrypoint (install, config edits, Caddy edits and +recovery) shares `.ghost-operation-lock`. The manager also keeps a durable +`.ghost-operation.json` journal, written before side effects. `site.sh check` +reports unresolved operations. Direct `docker compose` commands bypass these +safeguards; do not run them concurrently with managed operations. + +```sh +scripts/recovery.sh status +scripts/recovery.sh recover +``` + +Locks never expire by age. Recovery checks the host PID, Docker daemon identity and any manager container; +a live or ambiguous owner is not stolen. An unreachable daemon prevents reclamation. +An incomplete owner record requires manual inspection. A reused PID can conservatively +block recovery; inspect the owner rather than deleting a live lock. + +For interrupted backups, recovery reconciles saved container identities and returns +them to their original state. It does not promote an unfinished checkpoint. For a +restore that has never activated, recovery removes any verification container and +repeats restoration from the same verified checkpoint into the same destination. +Partial SQL restores cannot be mistaken for resumable imports. Once activation has +begun, recovery only retries readiness/activation; it **never replays the checkpoint** +because new writes may already exist. Failed restore verification leaves ingress +blocked. Preserve the journal and checkpoint until recovery is resolved. + +## Verification + +`node --test tests/recovery.test.mjs` checks lock exclusion/reclamation, daemon +identity, checkpoint integrity, retention, failed durable writes (`ENOSPC`) and +SQL producer/consumer exit propagation. The standard helper suite also covers the +host configuration interfaces that share the lock. + +`GD_TEST_RECOVERY=1 node --test --test-timeout=1800000 tests/recovery-e2e.test.mjs` +runs real local, HTTPS production and ActivityPub restore drills. Each kills the +host dispatcher while its backup manager is still alive, checks that recovery +cannot steal that lock, kills the manager, and recovers. It also kills restore +during verification and recovers without ingress. Assertions cover database values, +active theme, hidden assets, application configuration including multiline literal +values, private checkpoint permissions, isolation, activation and restart policy. +These drills run in CI. The release publishing workflow itself requires a release +tag and package publishing credentials. diff --git a/help b/help index f363ff35..66afc167 100755 --- a/help +++ b/help @@ -48,6 +48,12 @@ ROUTING (production): Your own routes: caddy/custom/*.caddy Global options: caddy/global/*.caddy +RECOVERY CHECKPOINTS: + scripts/recovery.sh backup --keep 5 + scripts/recovery.sh status + scripts/recovery.sh recover + See docs/recovery.md for isolated restore and activation. + TROUBLESHOOTING: docker compose exec ghost sh # Access Ghost container shell docker compose logs --tail=100 # View last 100 log lines diff --git a/install.sh b/install.sh index a0000d57..654c795d 100755 --- a/install.sh +++ b/install.sh @@ -153,6 +153,9 @@ if [[ -n $dir ]]; then fi dir=$checkout +operation_acquire "$dir" +trap operation_release EXIT + # --- Refuse to install over an existing site ------------------------------- for existing in "$GD_ENV_FILE_NAME" "$GD_META_FILE_NAME"; do diff --git a/manager/Dockerfile b/manager/Dockerfile new file mode 100644 index 00000000..aaf35f9f --- /dev/null +++ b/manager/Dockerfile @@ -0,0 +1,8 @@ +FROM docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d AS docker +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 +RUN apk add --no-cache bash jq curl +COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker /usr/local/libexec/docker/cli-plugins /usr/local/libexec/docker/cli-plugins +COPY scripts/lib /opt/scripts/lib +COPY manager/*.ts manager/package.json /opt/manager/ +ENTRYPOINT ["node", "/opt/manager/main.ts"] diff --git a/manager/main.ts b/manager/main.ts new file mode 100644 index 00000000..6e4cacc3 --- /dev/null +++ b/manager/main.ts @@ -0,0 +1,854 @@ +import { GHOST_READINESS_PROBE, CADDY_ROUTING_PROBE } from './probes.ts'; +import type { + Owner, + Journal, + NewJournal, + BackupJournal, + RestoreJournal, + Phase, + RestoreOptions, + Container, + Image, + ComposeConfig, + DatabaseConnection, + SiteState, + Checkpoint, + DatabaseCounts, + SiteMetadata, +} from './types.ts'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { run } from './process.ts'; +import { + durableJSON, + privateDirectory, + inventory, + copyTree, + verifyCheckpoint, + checkSpace, + retainCompleted, + syncDirectory, + treeMetadata, + restoreMetadata, +} from './storage.ts'; + +process.umask(0o077); + +function requiredEnvironment(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing manager environment: ${key}`); + } + return value; +} + +const site = requiredEnvironment('GD_SITE'); +const managerImage = requiredEnvironment('GD_MANAGER_IMAGE'); +const daemonId = requiredEnvironment('GD_DAEMON_ID'); +const owner: Owner = [Number(process.env.GD_OWNER_UID), Number(process.env.GD_OWNER_GID)]; +if (!site || !path.isAbsolute(site) || owner.some((n) => !Number.isInteger(n) || n < 0)) { + throw new Error('Missing host path/ownership contract'); +} +const journalFile = path.join(site, '.ghost-operation.json'); +const backupRoot = path.join(site, '.ghost-backups'); +let journal: Journal | null = fs.existsSync(journalFile) + ? (JSON.parse(fs.readFileSync(journalFile, 'utf8')) as Journal) + : null; +if (journal && journal.daemon !== daemonId) { + throw new Error('Use the Docker daemon recorded by the unfinished operation'); +} +if (journal && journal.version !== 1) { + throw new Error('Unsupported recovery journal version'); +} +const command = process.argv[2]; +const args = process.argv.slice(3); + +const docker = (...argv: string[]) => run('docker', argv); +const composeArgs = (...argv: string[]) => [ + 'compose', + '--project-directory', + site, + ...(journal?.kind === 'restore' ? ['--project-name', journal.options.project] : []), + '-f', + `${site}/compose.yml`, + ...argv, +]; +const compose = (...argv: string[]) => + run('docker', composeArgs(...argv), { label: `Docker Compose ${argv[0]}` }); + +async function envGet(key: string, fallback = ''): Promise { + try { + return JSON.parse( + await run('bash', [ + '-c', + `set -o pipefail; . /opt/scripts/lib/common.sh; env_get "$1" "$2" | jq -Rs '.[0:-1]'`, + '--', + `${site}/.env`, + key, + ]), + ); + } catch { + return fallback; + } +} + +async function envSet(file: string, key: string, value: string) { + await run('bash', [ + '-c', + '. /opt/scripts/lib/common.sh; env_set "$1" "$2" "$3"', + '--', + file, + key, + value, + ]); + fs.chownSync(file, ...owner); +} + +function begin(operation: NewJournal) { + journal = { version: 1, daemon: daemonId, updatedAt: new Date().toISOString(), ...operation }; + phase(operation.phase); +} + +function backupJournal(): BackupJournal { + if (journal?.kind !== 'backup') { + throw new Error('No backup operation is active'); + } + return journal; +} + +function restoreJournal(): RestoreJournal { + if (journal?.kind !== 'restore') { + throw new Error('No restore operation is active'); + } + return journal; +} + +function phase(next: Phase, fields: { error?: string } = {}) { + if (!journal) { + throw new Error('No operation is active'); + } + journal = { ...journal, ...fields, phase: next, updatedAt: new Date().toISOString() }; + durableJSON(journalFile, journal, owner); + process.stdout.write(`${journal.kind}: ${next}\n`); +} + +function checkpointHash(source: string) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(`${source}/manifest.json`)) + .digest('hex'); +} + +function finish() { + fs.unlinkSync(journalFile); + syncDirectory(site); + journal = null; +} + +async function inspectContainer(id: string): Promise { + const containers = JSON.parse(await docker('inspect', id)) as Container[]; + if (!containers[0]) { + throw new Error('Docker returned no container'); + } + return containers[0]; +} + +async function inspectImage(reference: string): Promise { + const images = JSON.parse(await docker('image', 'inspect', reference)) as Image[]; + if (!images[0]) { + throw new Error('Docker returned no image'); + } + return images[0]; +} + +async function containers(): Promise { + const ids = (await compose('ps', '-aq')).split(/\s+/).filter(Boolean); + return ids.length ? JSON.parse(await docker('inspect', ...ids)) : []; +} + +async function freeze() { + // A stopped Caddy also blocks custom routes. Stop every writer, not just HTTP. + const list = await containers(); + const targets = list.filter((c) => c.Config.Labels['com.docker.compose.service'] !== 'db'); + for (const c of targets) { + await docker('update', '--restart=no', c.Id); + } + if (targets.length) { + await docker('stop', '--time', '30', ...targets.map((c) => c.Id)); + } + const remaining = (await containers()).filter( + (c) => c.Config.Labels['com.docker.compose.service'] !== 'db' && c.State.Running, + ); + if (remaining.length) { + throw new Error('Application writers are still running'); + } +} + +async function resumeBackup() { + const operation = backupJournal(); + for (const saved of operation.running) { + const existing = await inspectContainer(saved.id); + if (existing.Config.Labels['com.docker.compose.project'] !== operation.project) { + throw new Error('Container identity changed during operation'); + } + await docker('update', `--restart=${saved.restart}`, saved.id); + if (saved.running) { + await docker('start', saved.id); + } + } + // Restore the state that actually existed, including deliberately stopped services. + await verifyRunning(operation.running.filter((c) => c.running).map((c) => c.id)); +} + +async function verifyRunning(ids: string[]) { + const deadline = Date.now() + 600_000; + while (true) { + const list: Container[] = ids.length ? JSON.parse(await docker('inspect', ...ids)) : []; + if ( + list.every((c) => c.State.Running && (!c.State.Health || c.State.Health.Status === 'healthy')) + ) { + return; + } + if ( + Date.now() >= deadline || + list.some((c) => c.State.Status === 'exited' || c.State.Health?.Status === 'unhealthy') + ) { + throw new Error('Service readiness verification failed'); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +async function connection(): Promise { + const project = await envGet('COMPOSE_PROJECT_NAME'); + const host = await envGet('DATABASE_HOST', 'db'); + const port = await envGet('DATABASE_PORT', '3306'); + if (!['db', `db-${project}`].includes(host) || port !== '3306') { + throw new Error('S4 supports only the stack-managed MySQL connection'); + } + const names = [await envGet('DATABASE_NAME', 'ghost')]; + if ((await envGet('COMPOSE_PROFILES')).split(',').includes('activitypub')) { + names.push(await envGet('ACTIVITYPUB_DATABASE_NAME', 'activitypub')); + } + if ( + names.some( + (n) => + !/^[a-zA-Z0-9_]+$/.test(n) || + ['mysql', 'sys', 'information_schema', 'performance_schema'].includes(n.toLowerCase()), + ) + ) { + throw new Error('Unsupported database identifier'); + } + const password = await envGet('DATABASE_ROOT_PASSWORD'); + if (!password) { + throw new Error('Missing database operator credential'); + } + return { names: [...new Set(names)], env: { MYSQL_PWD: password } }; +} + +async function sql(db: DatabaseConnection, sqlText: string) { + return run( + 'docker', + composeArgs( + 'exec', + '-T', + '-e', + 'MYSQL_PWD', + 'db', + 'mysql', + '--host=127.0.0.1', + '--port=3306', + '--user=root', + '--batch', + '--skip-column-names', + '-e', + sqlText, + ), + { env: db.env, label: 'Database query' }, + ); +} + +async function preflight(): Promise { + await run('bash', ['-c', '. /opt/scripts/lib/common.sh; config_validate "$1"', '--', site], { + label: 'Configuration validation (run scripts/config.sh validate)', + }); + if ((await envGet('PROJECT_DIR')) !== site) { + throw new Error('PROJECT_DIR must equal the absolute mounted host path'); + } + const profiles = (await envGet('COMPOSE_PROFILES')).split(','); + if (profiles.some((p) => !['local', 'production', 'activitypub'].includes(p))) { + throw new Error( + 'Recovery currently supports Ghost and ActivityPub; analytics/supervisor require a separate state contract', + ); + } + if ( + fs.existsSync(`${site}/compose.override.yml`) || + fs.existsSync(`${site}/compose.override.yaml`) + ) { + throw new Error('Compose overrides are unsupported'); + } + const config = JSON.parse(await compose('config', '--format', 'json')) as ComposeConfig; + const supported = ['ghost', 'db', 'caddy', 'activitypub', 'activitypub-migrate']; + if (Object.keys(config.services).some((name) => !supported.includes(name))) { + throw new Error('Unrecognized service state cannot be checkpointed'); + } + const content = config.services.ghost.volumes.find( + (v) => v.target === config.services.ghost.environment.paths__contentPath, + )?.source; + const database = config.services.db.volumes.find((v) => v.target === '/var/lib/mysql')?.source; + if (!content || !database) { + throw new Error('Missing content or database bind mount'); + } + for (const location of [content, database]) { + if (!location || !location.startsWith(`${site}/data/`)) { + throw new Error('Recovery requires data bind mounts beneath PROJECT_DIR/data'); + } + // Reject symlinked ancestors, including an external data tree. + let ancestor = location; + while (ancestor !== site) { + if (fs.existsSync(ancestor) && fs.lstatSync(ancestor).isSymbolicLink()) { + throw new Error('Symlink data paths are unsupported'); + } + ancestor = path.dirname(ancestor); + } + } + for (const [name, service] of Object.entries(config.services)) { + const allowed = + name === 'ghost' || name === 'activitypub' + ? [content] + : name === 'db' + ? [database, `${site}/mysql-init`] + : name === 'caddy' + ? [`${site}/caddy`] + : []; + if ( + (service.volumes || []).some((v) => + v.type === 'bind' + ? !allowed.includes(v.source) + : name !== 'caddy' || !['/data', '/config'].includes(v.target), + ) + ) { + throw new Error('Unrecognized service storage cannot be checkpointed'); + } + } + return { config, content, database, db: await connection() }; +} + +async function backup(keep: number) { + if (journal) { + throw new Error('An operation already needs recovery'); + } + const state = await preflight(); + const list = await containers(); + if ( + !list.some( + (c) => + c.Config.Labels['com.docker.compose.service'] === 'ghost' && + c.State.Health?.Status === 'healthy', + ) + ) { + throw new Error('Back up a healthy initialized site'); + } + const images: Record = {}; + for (const [service, config] of Object.entries(state.config.services)) { + const image = await inspectImage(config.image); + const running = list.find((c) => c.Config.Labels['com.docker.compose.service'] === service); + if (running && running.Image !== image.Id) { + throw new Error(`Configured and installed ${service} images differ`); + } + const digest = image.RepoDigests?.[0]; + if (!digest) { + throw new Error(`No immutable registry digest for ${service}`); + } + images[service] = digest; + } + privateDirectory(backupRoot, owner); + if ( + (await sql(state.db, 'SELECT @@event_scheduler')) === 'ON' && + Number( + await sql(state.db, "SELECT COUNT(*) FROM information_schema.events WHERE status='ENABLED'"), + ) > 0 + ) { + throw new Error('Disable the MySQL event scheduler before taking a consistent checkpoint'); + } + const contentFiles = inventory(state.content); + const dbBytes = Number( + await sql( + state.db, + `SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema IN (${state.db.names.map((n) => `'${n}'`).join(',')})`, + ), + ); + checkSpace(backupRoot, contentFiles.reduce((n, f) => n + f.bytes, 0) * 2 + dbBytes * 4); + const id = `${new Date().toISOString().replaceAll(':', '-')}-${crypto.randomUUID()}`; + const staging = `${backupRoot}/.partial-${id}`; + begin({ + id, + kind: 'backup', + phase: 'freezing', + project: state.config.name, + manager: managerImage, + checkpoint: `${backupRoot}/${id}`, + staging, + running: list.map((c) => ({ + id: c.Id, + running: c.State.Running, + restart: c.HostConfig.RestartPolicy.Name, + })), + }); + const operation = backupJournal(); + await freeze(); + privateDirectory(staging, owner); + const payload = `${staging}/payload`; + privateDirectory(payload, owner); + phase('snapshotting'); + const counts: DatabaseCounts = {}; + for (const database of state.db.names) { + counts[database] = {}; + const tables = ( + await sql(state.db, `SHOW FULL TABLES FROM \`${database}\` WHERE Table_type='BASE TABLE'`) + ) + .split('\n') + .filter(Boolean) + .map((line) => line.split('\t')[0]); + for (const table of tables) { + if (!/^[a-zA-Z0-9_]+$/.test(table)) { + throw new Error('Unsupported table identifier'); + } + counts[database][table] = await sql( + state.db, + `SELECT COUNT(*) FROM \`${database}\`.\`${table}\``, + ); + } + } + await run( + 'docker', + composeArgs( + 'exec', + '-T', + '-e', + 'MYSQL_PWD', + 'db', + 'mysqldump', + '--host=127.0.0.1', + '--port=3306', + '--user=root', + '--single-transaction', + '--routines', + '--events', + '--triggers', + '--hex-blob', + '--no-tablespaces', + '--set-gtid-purged=OFF', + '--databases', + ...state.db.names, + ), + { env: state.db.env, output: `${payload}/database.sql` }, + ); + if (!fs.statSync(`${payload}/database.sql`).size) { + throw new Error('Empty database dump'); + } + fs.chownSync(`${payload}/database.sql`, ...owner); + copyTree(state.content, `${payload}/content`, owner); + privateDirectory(`${payload}/config`, owner); + if (fs.existsSync(`${site}/.ghost-docker.json`)) { + const metadata = JSON.parse( + fs.readFileSync(`${site}/.ghost-docker.json`, 'utf8'), + ) as SiteMetadata; + metadata.manager = { image: operation.manager }; + durableJSON(`${site}/.ghost-docker.json`, metadata, owner); + } + for (const file of [ + '.env', + 'ghost.env', + '.ghost-docker.json', + 'compose.yml', + 'caddy', + 'mysql-init', + ]) { + if (fs.existsSync(`${site}/${file}`)) { + copyTree(`${site}/${file}`, `${payload}/config/${file}`, owner); + } + } + await envSet(`${payload}/config/.env`, 'GHOST_IMAGE_REF', images.ghost); + const manifest: Checkpoint = { + format: 'ghost-docker-recovery', + version: 1, + createdAt: new Date().toISOString(), + manager: operation.manager, + project: state.config.name, + images, + counts, + restartPolicy: state.config.services.ghost.restart || 'no', + contentMetadata: treeMetadata(state.content), + databases: state.db.names, + databaseBytes: dbBytes, + profiles: await envGet('COMPOSE_PROFILES'), + limitations: [ + 'Caddy certificates/cache are re-created', + 'Remote email, payment, federation and analytics state cannot be rolled back', + ], + files: inventory(payload), + }; + durableJSON(`${staging}/manifest.json`, manifest, owner); + verifyCheckpoint(staging); + fs.renameSync(staging, operation.checkpoint); + syncDirectory(backupRoot); + phase('resuming'); + await resumeBackup(); + retainCompleted(backupRoot, keep, id); + process.stdout.write(`Checkpoint: ${operation.checkpoint}\n`); + finish(); +} + +async function restore(source: string, options: RestoreOptions) { + if (journal) { + throw new Error('An operation already needs recovery'); + } + const manifest = verifyCheckpoint(source); + if (!/^[a-z0-9][a-z0-9_-]+$/.test(options.project || '')) { + throw new Error('Restore requires --project NAME'); + } + if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) { + throw new Error('Invalid port'); + } + if ( + fs.existsSync(`${site}/.env`) || + fs.existsSync(`${site}/.ghost-docker.json`) || + (fs.existsSync(`${site}/data`) && fs.readdirSync(`${site}/data`).length) + ) { + throw new Error('Restore destination must be a fresh checkout with empty data'); + } + if ( + await docker('ps', '-aq', '--filter', `label=com.docker.compose.project=${options.project}`) + ) { + throw new Error('Destination project already exists on this daemon'); + } + checkSpace( + site, + manifest.files.reduce((n, f) => n + f.bytes, 0) * 2 + manifest.databaseBytes * 4, + ); + for (const image of Object.values(manifest.images)) { + await docker('pull', image); + } + begin({ + id: crypto.randomUUID(), + kind: 'restore', + phase: 'preparing', + sourcePath: requiredEnvironment('GD_CHECKPOINT_SOURCE'), + checkpointHash: checkpointHash(source), + restartPolicy: manifest.restartPolicy, + options, + manager: managerImage, + }); + await restorePayload(source); +} + +async function restorePayload(source: string) { + const operation = restoreJournal(); + if (checkpointHash(source) !== operation.checkpointHash) { + throw new Error('The recovery checkpoint changed since this restore began'); + } + const manifest = verifyCheckpoint(source); + const payload = `${source}/payload`; + // Retry is permitted only while ingress has NEVER been enabled. The journal + // is persisted before creating any database or application container. + try { + await docker('rm', '-f', `ghost-recovery-${operation.id}-verify`); + } catch { + /* absent */ + } + if (fs.existsSync(`${site}/.env`)) { + await freeze(); + } + phase('restoring'); + for (const name of fs.readdirSync(`${payload}/config`)) { + if ( + !['.env', 'ghost.env', '.ghost-docker.json', 'compose.yml', 'caddy', 'mysql-init'].includes( + name, + ) + ) { + throw new Error('Unexpected checkpoint configuration'); + } + fs.rmSync(`${site}/${name}`, { recursive: true, force: true }); + copyTree(`${payload}/config/${name}`, `${site}/${name}`, owner); + } + for (const file of ['.env', 'ghost.env', '.ghost-docker.json']) { + if (fs.existsSync(`${site}/${file}`)) { + fs.chmodSync(`${site}/${file}`, 0o600); + } + } + const opts = operation.options; + for (const [key, value] of Object.entries({ + PROJECT_DIR: site, + COMPOSE_PROJECT_NAME: opts.project, + DATABASE_HOST: 'db', + DATABASE_PORT: '3306', + UPLOAD_LOCATION: './data/ghost', + MYSQL_DATA_LOCATION: './data/mysql', + RESTART_POLICY: 'no', + GHOST_IMAGE_REF: manifest.images.ghost, + })) { + await envSet(`${site}/.env`, key, value); + } + if (opts.local) { + for (const [key, value] of Object.entries({ + COMPOSE_PROFILES: 'local', + SITE_MODE: 'local', + URL: `http://localhost:${opts.port}`, + GHOST_PORT: String(opts.port), + DOMAIN: '', + ADMIN_DOMAIN: '', + ADMIN_URL: '', + WWW_REDIRECT: '', + })) { + await envSet(`${site}/.env`, key, value); + } + } + const state = await preflight(); + // Checkpoint images must match the restored stack definition. Ghost's pin + // is rewritten above; all other stack images are already digest-pinned. + for (const [name, svc] of Object.entries(state.config.services)) { + const expected = (await inspectImage(manifest.images[name])).Id; + const actual = (await inspectImage(svc.image)).Id; + if (expected !== actual) { + throw new Error(`Restore image mismatch: ${name}`); + } + } + await compose('up', '-d', '--wait', '--wait-timeout', '600', 'db'); + // Names come from the verified checkpoint, not arbitrary SQL or all databases. + if (manifest.databases.some((n) => !/^[a-zA-Z0-9_]+$/.test(n))) { + throw new Error('Invalid checkpoint database names'); + } + for (const name of manifest.databases) { + await sql(state.db, `DROP DATABASE IF EXISTS \`${name}\``); + } + await run( + 'docker', + composeArgs( + 'exec', + '-T', + '-e', + 'MYSQL_PWD', + 'db', + 'mysql', + '--host=127.0.0.1', + '--port=3306', + '--user=root', + ), + { env: state.db.env, input: `${payload}/database.sql`, label: 'Database restore' }, + ); + for (const [database, tables] of Object.entries(manifest.counts)) { + for (const [table, count] of Object.entries(tables)) { + if (!/^[a-zA-Z0-9_]+$/.test(database) || !/^[a-zA-Z0-9_]+$/.test(table)) { + throw new Error('Invalid checkpoint table identifier'); + } + if ((await sql(state.db, `SELECT COUNT(*) FROM \`${database}\`.\`${table}\``)) !== count) { + throw new Error('Restored database row counts differ from the checkpoint'); + } + } + } + fs.rmSync(state.content, { recursive: true, force: true }); + copyTree(`${payload}/content`, state.content); + restoreMetadata(state.content, manifest.contentMetadata); + if ( + JSON.stringify(inventory(state.content)) !== JSON.stringify(inventory(`${payload}/content`)) + ) { + throw new Error('Restored content verification failed'); + } + // Start an isolated Ghost with no published ports, no dependencies/jobs, + // no restart, and no external network access (network made internal below). + phase('verifying'); + await verifyIsolated(state, manifest); + if (fs.existsSync(`${site}/.ghost-docker.json`)) { + const meta = JSON.parse(fs.readFileSync(`${site}/.ghost-docker.json`, 'utf8')) as SiteMetadata; + meta.site = { + ...meta.site, + project: opts.project, + dir: site, + url: await envGet('URL'), + domain: await envGet('DOMAIN'), + adminDomain: await envGet('ADMIN_DOMAIN'), + }; + meta.mode = opts.local ? 'local' : meta.mode; + meta.profiles = (await envGet('COMPOSE_PROFILES')).split(','); + meta.manager = { image: managerImage }; + durableJSON(`${site}/.ghost-docker.json`, meta, owner); + } + phase('verified'); + process.stdout.write( + 'Restore verified with ingress blocked. Review the destination, then run scripts/recovery.sh activate.\n', + ); +} + +async function verifyIsolated(state: SiteState, manifest: Checkpoint) { + const operation = restoreJournal(); + const network = `ghost-recovery-${operation.id}`; + const name = `${network}-verify`; + // A separate internal network prevents copied sites sending mail/webhooks. + // Connect only MySQL and the verification container to it. + try { + await docker('network', 'inspect', network); + } catch { + await docker('network', 'create', '--internal', network); + } + const dbId = await compose('ps', '-q', 'db'); + try { + await docker('network', 'connect', '--alias', 'db', network, dbId); + } catch { + /* verify connection below */ + } + const dbContainer = await inspectContainer(dbId); + if (!dbContainer.NetworkSettings.Networks[network]) { + throw new Error('Could not isolate database access'); + } + try { + await docker('rm', '-f', name); + } catch { + /* first attempt */ + } + const config = state.config.services.ghost; + const environment = Object.fromEntries( + Object.entries(config.environment).map(([key, value]) => [ + key, + String(value ?? '').replaceAll('$$', '$'), + ]), + ); + try { + // Forward values through the process environment, including literal dollars + // and newlines; neither command arguments nor an env-file reparse them. + await run( + 'docker', + [ + 'run', + '-d', + '--name', + name, + '--network', + network, + ...Object.keys(environment).flatMap((key) => ['--env', key]), + '--mount', + `type=bind,source=${state.content},target=${config.environment.paths__contentPath}`, + manifest.images.ghost, + ], + { env: environment }, + ); + const deadline = Date.now() + 600_000; + while (true) { + try { + await docker( + 'exec', + name, + 'node', + '-e', + GHOST_READINESS_PROBE, + config.environment.url, + await envGet('GHOST_HEALTHCHECK_PATH', '/ghost/api/admin/site/'), + ); + break; + } catch { + const c = await inspectContainer(name); + if (!c.State.Running || Date.now() > deadline) { + throw new Error('Isolated Ghost readiness failed'); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + await sql(state.db, `SELECT COUNT(*) FROM \`${state.db.names[0]}\`.settings`); + } finally { + await docker('rm', '-f', name); + await docker('network', 'disconnect', network, dbId); + await docker('network', 'rm', network); + } +} + +async function activate() { + if (journal?.kind !== 'restore' || !['verified', 'activating'].includes(journal.phase)) { + throw new Error('No verified restore is ready to activate'); + } + const operation = journal; + await preflight(); + if ((await envGet('SITE_MODE')) === 'production') { + await run('bash', ['-c', '. /opt/scripts/lib/common.sh; caddy_apply "$1"', '--', site]); + } + // After this point recovery must NEVER replay the checkpoint: users may + // have written new data even if the process crashes before marking done. + phase('activating'); + await envSet( + `${site}/.env`, + 'RESTART_POLICY', + operation.options.local ? 'no' : operation.restartPolicy, + ); + await compose('up', '-d', '--wait', '--wait-timeout', '600'); + if ((await envGet('SITE_MODE')) === 'production') { + const domain = await envGet('DOMAIN'); + await compose('exec', '-T', 'ghost', 'node', '-e', CADDY_ROUTING_PROBE, domain); + } + finish(); + process.stdout.write('Restored site activated and verified.\n'); +} +try { + if (['recover', 'activate'].includes(command) && args.length) { + throw new Error('This command accepts no additional arguments'); + } + if (command === 'backup') { + const keep = + args.length === 0 ? 5 : args[0] === '--keep' && args.length === 2 ? Number(args[1]) : NaN; + if (!Number.isSafeInteger(keep) || keep < 1) { + throw new Error('Usage: backup [--keep N]'); + } + await backup(keep); + } else if (command === 'restore') { + const source = args.shift(); + if (!source) { + throw new Error('Restore requires a checkpoint path'); + } + const options: RestoreOptions = { project: '', port: 2368, local: false }; + while (args.length) { + const flag = args.shift(); + if (flag === '--project') { + options.project = args.shift() || ''; + } else if (flag === '--port') { + options.port = Number(args.shift()); + } else if (flag === '--local') { + options.local = true; + } else { + throw new Error(`Unknown restore option: ${flag}`); + } + } + await restore(source, options); + } else if (command === 'activate') { + await activate(); + } else if (command === 'recover') { + if (!journal) { + process.stdout.write('Stale lock recovered; no unfinished operation.\n'); + } else if (journal.kind === 'backup') { + await resumeBackup(); + finish(); + } else if (journal.kind === 'restore' && journal.phase === 'activating') { + await activate(); + } else if (journal.kind === 'restore') { + await restorePayload('/checkpoint'); + } else { + throw new Error('Unknown journal kind; manual inspection required'); + } + } else { + throw new Error('Unknown manager command'); + } +} catch (caught) { + const error = caught instanceof Error ? caught : new Error(String(caught)); + if (journal?.kind === 'restore' && journal.phase === 'activating') { + try { + await freeze(); + } catch { + error.message += '; could not confirm ingress stopped'; + } + } + if (journal) { + phase(journal.phase, { error: error.message }); + } + process.stderr.write( + `${JSON.stringify({ error: error.message, recoveryRequired: Boolean(journal) })}\n`, + ); + process.exitCode = 1; +} diff --git a/manager/package.json b/manager/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/manager/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/manager/probes.ts b/manager/probes.ts new file mode 100644 index 00000000..5c7c8f0e --- /dev/null +++ b/manager/probes.ts @@ -0,0 +1,34 @@ +// These scripts execute inside Ghost via `node -e`, where CommonJS require is +// available. Keep them readable here instead of compressing code into CLI args. +export const GHOST_READINESS_PROBE = ` +const http = require('node:http'); +const url = new URL(process.argv[1]); + +http.get({ + host: '127.0.0.1', + port: 2368, + path: process.argv[2], + headers: { + Host: url.host, + 'X-Forwarded-Proto': url.protocol.slice(0, -1) + } +}, response => { + process.exit(response.statusCode === 200 ? 0 : 1); +}).on('error', () => process.exit(1)); +`; + +export const CADDY_ROUTING_PROBE = ` +const https = require('node:https'); +const domain = process.argv[1]; + +https.get({ + host: 'caddy', + port: 443, + path: '/ghost/api/admin/site/', + servername: domain, + headers: {Host: domain}, + rejectUnauthorized: false +}, response => { + process.exit(response.statusCode === 200 ? 0 : 1); +}).on('error', () => process.exit(1)); +`; diff --git a/manager/process.ts b/manager/process.ts new file mode 100644 index 00000000..bc9a92ae --- /dev/null +++ b/manager/process.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; + +export interface RunOptions { + input?: string; + output?: string; + env?: NodeJS.ProcessEnv; + label?: string; +} + +export async function run(bin: string, argv: string[], options: RunOptions = {}): Promise { + return await new Promise((resolve, reject) => { + const fd = options.output ? fs.openSync(options.output, 'wx', 0o600) : null; + const input = options.input ? fs.openSync(options.input, 'r') : null; + const child = spawn(bin, argv, { + env: { ...process.env, ...options.env }, + stdio: [input ?? 'ignore', fd ?? 'pipe', 'pipe'], + }); + let stdout = ''; + child.stdout?.on('data', (data) => { + stdout += data; + }); + // stderr may contain application configuration or credentials. Do not log it. + child.stderr?.on('data', () => {}); + child.on('error', reject); + child.on('close', (code) => { + try { + if (fd !== null) { + fs.fsyncSync(fd); + fs.closeSync(fd); + } + if (input !== null) { + fs.closeSync(input); + } + if (code !== 0) { + reject(new Error(`${options.label || bin} failed (exit ${code})`)); + } else { + resolve(stdout.trim()); + } + } catch (error) { + reject(error); + } + }); + }); +} diff --git a/manager/storage.ts b/manager/storage.ts new file mode 100644 index 00000000..f59ac331 --- /dev/null +++ b/manager/storage.ts @@ -0,0 +1,175 @@ +import type { Owner, FileEntry, ContentMetadata, Checkpoint } from './types.ts'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +export function durableJSON(file: string, value: unknown, owner?: Owner) { + const tmp = `${file}.tmp`; + const fd = fs.openSync(tmp, 'w', 0o600); + try { + fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`); + if (owner) { + fs.fchownSync(fd, ...owner); + } + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmp, file); + syncDirectory(path.dirname(file)); +} + +export function syncDirectory(dir: string) { + const fd = fs.openSync(dir, 'r'); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +export function privateDirectory(dir: string, owner?: Owner) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (fs.lstatSync(dir).isSymbolicLink()) { + throw new Error('Symlink directories are unsupported'); + } + fs.chmodSync(dir, 0o700); + if (owner) { + fs.chownSync(dir, ...owner); + } +} +// Deliberately no archive extraction: checkpoints are private directory trees. +// Refuse symlinks and special files, including dangling links, before copying. +export function inventory(root: string, prefix = ''): FileEntry[] { + const entries: FileEntry[] = []; + for (const name of fs.readdirSync(path.join(root, prefix)).sort()) { + const relative = path.join(prefix, name); + const full = path.join(root, relative); + const stat = fs.lstatSync(full); + if (stat.isDirectory()) { + entries.push(...inventory(root, relative)); + } else if (stat.isFile() && stat.nlink === 1) { + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(full, 'r'); + const buf = Buffer.alloc(1024 * 1024); + try { + let bytes; + while ((bytes = fs.readSync(fd, buf, 0, buf.length, null))) { + hash.update(buf.subarray(0, bytes)); + } + } finally { + fs.closeSync(fd); + } + entries.push({ path: relative, bytes: stat.size, sha256: hash.digest('hex') }); + } else { + throw new Error(`Unsupported link or special file: ${relative}`); + } + } + return entries; +} + +export function copyTree(source: string, target: string, owner: Owner | null = null) { + const stat = fs.lstatSync(source); + if ( + stat.isSymbolicLink() || + (!stat.isFile() && !stat.isDirectory()) || + (stat.isFile() && stat.nlink !== 1) + ) { + throw new Error('Checkpoint trees cannot contain links or special files'); + } + if (stat.isDirectory()) { + fs.mkdirSync(target, { recursive: true, mode: stat.mode & 0o777 }); + for (const name of fs.readdirSync(source)) { + copyTree(path.join(source, name), path.join(target, name), owner); + } + } else { + fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL); + fs.chmodSync(target, stat.mode & 0o777); + const fd = fs.openSync(target, 'r'); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + } + fs.chownSync(target, ...(owner || ([stat.uid, stat.gid] as Owner))); + if (stat.isDirectory()) { + // mkdir's mode is filtered by the manager's private umask. Restore the + // source permissions so service users can traverse their mounted config. + fs.chmodSync(target, stat.mode & 0o777); + syncDirectory(target); + } +} + +export function verifyCheckpoint(root: string): Checkpoint { + const manifestPath = path.join(root, 'manifest.json'); + if (!fs.lstatSync(manifestPath).isFile()) { + throw new Error('Missing checkpoint manifest'); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Checkpoint; + if (manifest.format !== 'ghost-docker-recovery' || manifest.version !== 1) { + throw new Error('Unsupported checkpoint format'); + } + const actual = inventory(path.join(root, 'payload')); + if (JSON.stringify(actual) !== JSON.stringify(manifest.files)) { + throw new Error('Checkpoint checksum mismatch'); + } + return manifest; +} + +export function checkSpace(dir: string, bytes: number) { + const stat = fs.statfsSync(dir); + if (stat.bavail * stat.bsize < bytes + 256 * 1024 * 1024) { + throw new Error('Insufficient free space (including 256 MiB reserve)'); + } +} + +export function retainCompleted(root: string, keep: number, protectedId: string) { + if (!Number.isSafeInteger(keep) || keep < 1) { + throw new Error('--keep must be a positive integer'); + } + const complete = fs + .readdirSync(root) + .filter((name) => /^\d{4}-.*-[0-9a-f-]{36}$/.test(name)) + .filter((name) => { + try { + verifyCheckpoint(path.join(root, name)); + return true; + } catch { + return false; + } + }) + .sort() + .reverse(); + for (const name of complete.slice(keep)) { + if (name !== protectedId) { + fs.rmSync(path.join(root, name), { recursive: true }); + } + } + syncDirectory(root); +} + +export function treeMetadata(root: string, prefix = ''): ContentMetadata[] { + const stat = fs.lstatSync(path.join(root, prefix)); + const result = [{ path: prefix, uid: stat.uid, gid: stat.gid, mode: stat.mode & 0o777 }]; + if (stat.isDirectory()) { + for (const name of fs.readdirSync(path.join(root, prefix)).sort()) { + result.push(...treeMetadata(root, path.join(prefix, name))); + } + } + return result; +} + +export function restoreMetadata(root: string, entries: ContentMetadata[]) { + const paths = treeMetadata(root).map((e) => e.path); + if (JSON.stringify(paths) !== JSON.stringify(entries.map((e) => e.path))) { + throw new Error('Content metadata does not match the checkpoint tree'); + } + for (const e of entries) { + if (![e.uid, e.gid, e.mode].every((n) => Number.isInteger(n) && n >= 0) || e.mode > 0o777) { + throw new Error('Invalid content ownership/mode'); + } + fs.chownSync(path.join(root, e.path), e.uid, e.gid); + fs.chmodSync(path.join(root, e.path), e.mode); + } +} diff --git a/manager/tsconfig.json b/manager/tsconfig.json new file mode 100644 index 00000000..008adc0a --- /dev/null +++ b/manager/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "strict": true, + "noEmit": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/manager/types.ts b/manager/types.ts new file mode 100644 index 00000000..5a0b1573 --- /dev/null +++ b/manager/types.ts @@ -0,0 +1,128 @@ +/** Host ownership is explicit; container user IDs are never assumed. */ +export type Owner = [uid: number, gid: number]; + +export interface FileEntry { + path: string; + bytes: number; + sha256: string; +} + +export interface ContentMetadata { + path: string; + uid: number; + gid: number; + mode: number; +} + +export type DatabaseCounts = Record>; + +export interface Checkpoint { + format: 'ghost-docker-recovery'; + version: 1; + createdAt: string; + manager: string; + project: string; + images: Record; + counts: DatabaseCounts; + restartPolicy: string; + contentMetadata: ContentMetadata[]; + databases: string[]; + databaseBytes: number; + profiles: string; + limitations: string[]; + files: FileEntry[]; +} + +export interface RestoreOptions { + project: string; + port: number; + local: boolean; +} + +export type Phase = + | 'freezing' + | 'snapshotting' + | 'resuming' + | 'preparing' + | 'restoring' + | 'verifying' + | 'verified' + | 'activating'; + +interface JournalBase { + version: 1; + daemon: string; + id: string; + manager: string; + phase: Phase; + updatedAt: string; + error?: string; +} + +export interface BackupJournal extends JournalBase { + kind: 'backup'; + project: string; + checkpoint: string; + staging: string; + running: { id: string; running: boolean; restart: string }[]; +} + +export interface RestoreJournal extends JournalBase { + kind: 'restore'; + sourcePath: string; + checkpointHash: string; + restartPolicy: string; + options: RestoreOptions; +} + +export type Journal = BackupJournal | RestoreJournal; +export type NewJournal = + | Omit + | Omit; + +/** The subset of Docker's inspect/config output consumed by the manager. */ +export interface Container { + Id: string; + Image: string; + Config: { Labels: Record }; + HostConfig: { RestartPolicy: { Name: string } }; + State: { Running: boolean; Status: string; Health?: { Status: string } }; + NetworkSettings: { Networks: Record }; +} + +export interface Image { + Id: string; + RepoDigests?: string[]; +} + +export interface Service { + image: string; + restart?: string; + environment: Record; + volumes: { type: string; source: string; target: string }[]; +} + +export interface ComposeConfig { + name: string; + services: Record; +} + +export interface DatabaseConnection { + names: string[]; + env: { MYSQL_PWD: string }; +} + +export interface SiteState { + config: ComposeConfig; + content: string; + database: string; + db: DatabaseConnection; +} + +export interface SiteMetadata { + site?: Record; + mode?: string; + profiles?: string[]; + manager?: { image: string }; + [key: string]: unknown; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..e1380135 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1168 @@ +{ + "name": "ghost-docker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghost-docker", + "devDependencies": { + "@types/node": "22.20.2", + "oxfmt": "0.63.0", + "oxlint": "1.83.0", + "typescript": "7.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.0.0" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz", + "integrity": "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz", + "integrity": "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz", + "integrity": "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz", + "integrity": "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz", + "integrity": "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz", + "integrity": "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz", + "integrity": "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz", + "integrity": "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz", + "integrity": "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz", + "integrity": "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz", + "integrity": "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz", + "integrity": "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz", + "integrity": "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz", + "integrity": "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz", + "integrity": "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz", + "integrity": "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz", + "integrity": "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz", + "integrity": "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz", + "integrity": "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/oxfmt": { + "version": "0.63.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.63.0.tgz", + "integrity": "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.63.0", + "@oxfmt/binding-android-arm64": "0.63.0", + "@oxfmt/binding-darwin-arm64": "0.63.0", + "@oxfmt/binding-darwin-x64": "0.63.0", + "@oxfmt/binding-freebsd-x64": "0.63.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", + "@oxfmt/binding-linux-arm64-gnu": "0.63.0", + "@oxfmt/binding-linux-arm64-musl": "0.63.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", + "@oxfmt/binding-linux-riscv64-musl": "0.63.0", + "@oxfmt/binding-linux-s390x-gnu": "0.63.0", + "@oxfmt/binding-linux-x64-gnu": "0.63.0", + "@oxfmt/binding-linux-x64-musl": "0.63.0", + "@oxfmt/binding-openharmony-arm64": "0.63.0", + "@oxfmt/binding-win32-arm64-msvc": "0.63.0", + "@oxfmt/binding-win32-ia32-msvc": "0.63.0", + "@oxfmt/binding-win32-x64-msvc": "0.63.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..36594d05 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "ghost-docker", + "private": true, + "scripts": { + "format": "oxfmt --write manager tests scripts package.json .oxfmtrc.json .oxlintrc.json", + "format:check": "oxfmt --check manager tests scripts package.json .oxfmtrc.json .oxlintrc.json", + "lint": "oxlint manager tests scripts", + "typecheck": "tsc --project manager/tsconfig.json", + "test": "node --test --test-timeout=120000 tests/*.test.mjs" + }, + "devDependencies": { + "@types/node": "22.20.2", + "oxfmt": "0.63.0", + "oxlint": "1.83.0", + "typescript": "7.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.0.0" + } +} diff --git a/scripts/caddy.sh b/scripts/caddy.sh index 2e039227..a48fd5fb 100755 --- a/scripts/caddy.sh +++ b/scripts/caddy.sh @@ -16,6 +16,13 @@ cmd=${1:-} if (($#)); then shift; fi dir="${1:-$GD_ROOT_DIR}" +case "$cmd" in + render | apply | reload) + operation_acquire "$dir" + trap operation_release EXIT + ;; +esac + case "$cmd" in render) staged=$(caddy_render "$dir") diff --git a/scripts/config-to-env.js b/scripts/config-to-env.js index 37970e1f..8743ded3 100644 --- a/scripts/config-to-env.js +++ b/scripts/config-to-env.js @@ -1,19 +1,18 @@ const fs = require('fs'); -const path = require('path'); // Hardcoded exclusions - add any default exclusions here // Can be exact matches or prefixes (to exclude entire sections) const HARDCODED_EXCLUSIONS = [ - // We don't want the database, server, logging, process or paths - // entries since they're not relevant in Docker anymore - 'database', - 'server', - 'logging', - 'process', - 'paths', - // We don't need URL or admin__url because the container owns them - 'url', - 'admin__url', + // We don't want the database, server, logging, process or paths + // entries since they're not relevant in Docker anymore + 'database', + 'server', + 'logging', + 'process', + 'paths', + // We don't need URL or admin__url because the container owns them + 'url', + 'admin__url', ]; // Parse command line arguments @@ -22,15 +21,15 @@ function parseArgs() { const options = { configFile: null, exclude: [], - include: null + include: null, }; for (let i = 0; i < args.length; i++) { if (args[i] === '--exclude' && i + 1 < args.length) { - options.exclude = args[i + 1].split(',').map(s => s.trim()); + options.exclude = args[i + 1].split(',').map((s) => s.trim()); i++; // Skip next argument } else if (args[i] === '--include' && i + 1 < args.length) { - options.include = args[i + 1].split(',').map(s => s.trim()); + options.include = args[i + 1].split(',').map((s) => s.trim()); i++; // Skip next argument } else if (!args[i].startsWith('--')) { options.configFile = args[i]; @@ -38,7 +37,9 @@ function parseArgs() { } if (!options.configFile) { - console.error('Usage: node config-to-env.js [--exclude key1,key2] [--include key1,key2]'); + console.error( + 'Usage: node config-to-env.js [--exclude key1,key2] [--include key1,key2]', + ); process.exit(1); } @@ -83,13 +84,21 @@ function formatValue(value) { const text = String(value); let out = ''; for (const c of text) { - if (c === '\\') { out += '\\\\'; } - else if (c === '"') { out += '\\"'; } - else if (c === '$') { out += '$$'; } - else if (c === '\n') { out += '\\n'; } - else if (c === '\r') { out += '\\r'; } - else if (c === '\t') { out += '\\t'; } - else { out += c; } + if (c === '\\') { + out += '\\\\'; + } else if (c === '"') { + out += '\\"'; + } else if (c === '$') { + out += '$$'; + } else if (c === '\n') { + out += '\\n'; + } else if (c === '\r') { + out += '\\r'; + } else if (c === '\t') { + out += '\\t'; + } else { + out += c; + } } return `"${out}"`; } diff --git a/scripts/config.sh b/scripts/config.sh index 88b8d8fc..8b961ce0 100755 --- a/scripts/config.sh +++ b/scripts/config.sh @@ -27,6 +27,8 @@ case "$cmd" in usage exit 2 } + operation_acquire "$(dirname -- "$1")" + trap operation_release EXIT env_set "$1" "$2" "$3" # Key names only. Values are never printed: any of them may be a # credential, and a list of "sensitive" names would silently miss one. @@ -37,6 +39,8 @@ case "$cmd" in usage exit 2 } + operation_acquire "$(dirname -- "$1")" + trap operation_release EXIT env_unset "$1" "$2" ;; mode) diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 883b392f..4357f79c 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -42,3 +42,6 @@ usage() { done } <"$0" } + +# shellcheck source=scripts/lib/operation.sh +. "$GD_LIB_DIR/operation.sh" diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh index ed57e485..48e88ef7 100644 --- a/scripts/lib/config.sh +++ b/scripts/lib/config.sh @@ -35,16 +35,14 @@ readonly GD_REQUIRED_KEYS_COMMON=( readonly GD_REQUIRED_KEYS_PRODUCTION=(DOMAIN) # config_ghost_environment DIR -# The environment Compose actually gives the ghost container, as KEYVALUE. +# The environment Compose actually gives the ghost container, as a JSON object. # `docker compose config` is pure parsing and needs no daemon, so this works # before anything is started. Its output re-escapes `$` as `$$`, which is undone # here so values compare against decoded ones. config_ghost_environment() { compose_run "$1" config --format json 2>/dev/null | - jq -r '.services.ghost.environment // {} - | to_entries[] - | [.key, (.value // "" | tostring | gsub("[$][$]"; "$"))] - | @tsv' + jq '.services.ghost.environment // {} + | with_entries(.value |= (. // "" | tostring | gsub("[$][$]"; "$")))' } # config_operator_variables DIR @@ -200,7 +198,7 @@ config_validate_ghost_env() { # ghost.env is optional: a site can run entirely on container-owned config. [[ -f $file ]] || return 0 - # KEYVALUE of what the container really gets. Empty when compose.yml + # JSON of what the container really gets. Empty when compose.yml # cannot be resolved, in which case the override check is skipped rather # than reporting nonsense. local container @@ -248,17 +246,10 @@ config_validate_ghost_env() { return $rc } -# _gd_container_value TSV KEY -# Prints the container's value for KEY, or returns 1 when it has none. +# _gd_container_value JSON KEY +# Decode JSON directly: TSV escaping changes tabs, backslashes and newlines. _gd_container_value() { - local k v - while IFS=$'\t' read -r k v; do - if [[ $k == "$2" ]]; then - printf '%s' "$v" - return 0 - fi - done <<<"$1" - return 1 + jq -er --arg key "$2" 'if has($key) then .[$key] else error("missing key") end' <<<"$1" } # _gd_is_operator_key KEY DIR OPERATOR_VAR... diff --git a/scripts/lib/operation.sh b/scripts/lib/operation.sh new file mode 100644 index 00000000..45589647 --- /dev/null +++ b/scripts/lib/operation.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# One host-visible lock for every supported mutating entrypoint. Never expire +# a lock by age. A dead owner needs explicit `recovery.sh recover`. +operation_acquire() { + local dir=$1 recovery=${2:-false} lock pid manager state daemon + lock=$dir/.ghost-operation-lock + if [[ -e $dir/.ghost-operation.json && $recovery != true ]]; then + printf 'An unfinished operation requires scripts/recovery.sh recover.\n' >&2 + return 1 + fi + if ! mkdir -m 700 "$lock" 2>/dev/null; then + [[ $recovery == true && -d $lock && ! -L $lock ]] || { + printf 'Site is locked; use scripts/recovery.sh recover after its owner exits.\n' >&2 + return 1 + } + # Missing/partial owner records are ambiguous and require inspection. + pid=$(cat "$lock/pid") || return 1 + [[ $pid =~ ^[0-9]+$ ]] || return 1 + if kill -0 "$pid" 2>/dev/null || ps -p "$pid" >/dev/null 2>&1; then + printf 'Refusing to steal a live operation lock (PID %s).\n' "$pid" >&2 + return 1 + fi + manager=$(cat "$lock/manager" 2>/dev/null) || manager="" + if [[ -n $manager ]]; then + daemon=$(docker info --format '{{.ID}}' 2>/dev/null) || return 1 + [[ -f $lock/daemon && $(cat "$lock/daemon") == "$daemon" ]] || { + printf 'Use the Docker daemon that owns this operation lock.\n' >&2 + return 1 + } + state=$(docker inspect --format '{{.State.Running}}' "$manager" 2>/dev/null) || state=false + [[ $state == false ]] || { printf 'The manager is still running.\n' >&2; return 1; } + fi + mkdir "$lock/reclaim" 2>/dev/null || return 1 + rm -f "$lock/pid" "$lock/manager" "$lock/daemon" + rmdir "$lock/reclaim" "$lock" || return 1 + mkdir -m 700 "$lock" || return 1 + fi + printf '%s\n' "$$" >"$lock/pid" + GD_OPERATION_LOCK=$lock + export GD_OPERATION_LOCK + # The journal may have appeared between the first check and mkdir. + if [[ -e $dir/.ghost-operation.json && $recovery != true ]]; then + operation_release + printf 'An unfinished operation requires scripts/recovery.sh recover.\n' >&2 + return 1 + fi +} + +operation_release() { + [[ -n ${GD_OPERATION_LOCK:-} ]] || return 0 + local pid manager running + manager=$(cat "$GD_OPERATION_LOCK/manager" 2>/dev/null) || manager="" + if [[ -n $manager ]]; then + docker info >/dev/null 2>&1 || return 0 + running=$(docker inspect --format '{{.State.Running}}' "$manager" 2>/dev/null) || running=false + [[ $running == false ]] || return 0 + fi + pid=$(cat "$GD_OPERATION_LOCK/pid" 2>/dev/null) || return 0 + [[ $pid == "$$" ]] || return 0 + rm -f "$GD_OPERATION_LOCK/pid" "$GD_OPERATION_LOCK/manager" "$GD_OPERATION_LOCK/daemon" + rmdir "$GD_OPERATION_LOCK" 2>/dev/null || true +} diff --git a/scripts/lib/preflight.sh b/scripts/lib/preflight.sh index 447fd685..d6ca93d7 100644 --- a/scripts/lib/preflight.sh +++ b/scripts/lib/preflight.sh @@ -32,7 +32,7 @@ readonly GD_REQUIRED_COMMANDS=(docker jq curl) # is the point: it is how a GNU-only or unusual dependency gets noticed. readonly GD_HOST_UTILITIES=( awk basename bash cat chmod chown cp cut date df dirname env grep head id - ls mkdir mktemp mv od rm sed sleep sort stat sysctl tr uname + ls mkdir mktemp mv od ps rm rmdir sed sleep sort stat sysctl tr uname ) # Recommended free space for a site: Ghost and MySQL images, the database, and diff --git a/scripts/recovery.sh b/scripts/recovery.sh new file mode 100755 index 00000000..6eae1456 --- /dev/null +++ b/scripts/recovery.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Private recovery checkpoints (not portable migration bundles). +# +# scripts/recovery.sh backup [--keep N] +# scripts/recovery.sh restore CHECKPOINT --project NAME [--local --port PORT] +# scripts/recovery.sh activate expose a verified restored site +# scripts/recovery.sh recover reconcile an interrupted operation +# scripts/recovery.sh status +# +# Run from the destination checkout for restore. It must be fresh. Restore +# remains isolated until activate. See docs/recovery.md before a restore drill. +set -euo pipefail +umask 077 +# shellcheck source=scripts/lib/common.sh +. "$(dirname -- "$0")/lib/common.sh" +cmd=${1:---help} +if (($#)); then shift; fi +case "$cmd" in + --help | -h | help) usage; exit 0 ;; + status) + if [[ -f $GD_ROOT_DIR/.ghost-operation.json ]]; then + jq '{id,kind,phase,updatedAt,checkpoint,error}' "$GD_ROOT_DIR/.ghost-operation.json" + else + printf 'No unfinished operation.\n' + fi + if [[ -d $GD_ROOT_DIR/.ghost-operation-lock ]]; then printf 'Operation lock exists.\n'; fi + exit 0 ;; + backup | restore | recover | activate) ;; + *) usage >&2; exit 2 ;; +esac +[[ -z ${GD_COMPOSE_OVERRIDES:-} && -z ${COMPOSE_FILE:-} ]] || { + printf 'Recovery does not support Compose overrides.\n' >&2; exit 1; +} +# Docker Desktop and OrbStack expose local Unix sockets with host path sharing. +if [[ -n ${DOCKER_CONTEXT:-} ]]; then + endpoint=$(docker context inspect "$DOCKER_CONTEXT" --format '{{.Endpoints.docker.Host}}') +else + endpoint=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}')} +fi +[[ $endpoint == unix://* && -S ${endpoint#unix://} ]] || { + printf 'Recovery requires a local Unix-socket Docker context.\n' >&2; exit 1; +} +security=$(docker info --format '{{json .SecurityOptions}}') +[[ $security != *rootless* && $security != *userns* ]] || { + printf 'Rootless/userns recovery has not been validated and is unsupported.\n' >&2; exit 1; +} +recovery=false +[[ $cmd != recover && $cmd != activate ]] || recovery=true +operation_acquire "$GD_ROOT_DIR" "$recovery" +trap operation_release EXIT + +# Release users can select a published digest. Source checkouts build the +# checked-out implementation; run by immutable image ID, never by a mutable tag. +if [[ -n ${GD_MANAGER_IMAGE:-} ]]; then + [[ $GD_MANAGER_IMAGE == *@sha256:* || $GD_MANAGER_IMAGE == sha256:* ]] || { + printf 'GD_MANAGER_IMAGE must be an immutable digest or local image ID.\n' >&2; exit 1; + } + docker image inspect "$GD_MANAGER_IMAGE" >/dev/null 2>&1 || docker pull "$GD_MANAGER_IMAGE" >&2 + manager_image=$(docker image inspect --format '{{.Id}}' "$GD_MANAGER_IMAGE") +else + docker build -q -f "$GD_ROOT_DIR/manager/Dockerfile" "$GD_ROOT_DIR" >"$GD_ROOT_DIR/.ghost-manager-image" + manager_image=$(cat "$GD_ROOT_DIR/.ghost-manager-image") +fi +args=(--mount "type=bind,source=$GD_ROOT_DIR,target=$GD_ROOT_DIR" + --mount "type=bind,source=${endpoint#unix://},target=/var/run/docker.sock") +if [[ $cmd == restore ]]; then + (($#)) || { usage >&2; exit 2; } + checkpoint=$(CDPATH='' cd -- "$1" && pwd -P) + shift + [[ $checkpoint != "$GD_ROOT_DIR" && $GD_ROOT_DIR != "$checkpoint/"* ]] || exit 2 + args+=(--mount "type=bind,source=$checkpoint,target=/checkpoint,readonly") + set -- /checkpoint "$@" +fi +# Recover needs the original checkpoint, which is recorded as a host path. +if [[ $cmd == recover && -f $GD_ROOT_DIR/.ghost-operation.json ]]; then + checkpoint=$(jq -r '.sourcePath // empty' "$GD_ROOT_DIR/.ghost-operation.json") + if [[ -n $checkpoint ]]; then + args+=(--mount "type=bind,source=$checkpoint,target=/checkpoint,readonly") + fi +fi +manager_name="ghost-recovery-$$-$RANDOM" +docker info --format '{{.ID}}' >"$GD_OPERATION_LOCK/daemon" +printf '%s\n' "$manager_name" >"$GD_OPERATION_LOCK/manager" +docker run --rm --name "$manager_name" --init \ + "${args[@]}" \ + -e "GD_SITE=$GD_ROOT_DIR" -e "GD_OWNER_UID=$(id -u)" -e "GD_OWNER_GID=$(id -g)" \ + -e "GD_DAEMON_ID=$(cat "$GD_OPERATION_LOCK/daemon")" \ + -e "GD_MANAGER_IMAGE=$manager_image" -e "GD_CHECKPOINT_SOURCE=${checkpoint:-}" \ + "$manager_image" "$cmd" "$@" diff --git a/scripts/site.sh b/scripts/site.sh index 957b5f67..5198db9e 100755 --- a/scripts/site.sh +++ b/scripts/site.sh @@ -61,6 +61,11 @@ _db_reachable() { site_check() { local dir=$1 rc=0 records mode profiles port http_port domain admin id health + if [[ -e $dir/.ghost-operation.json || -d $dir/.ghost-operation-lock ]]; then + printf 'An operation is active or needs recovery; inspect scripts/recovery.sh status.\n' >&2 + rc=1 + fi + printf 'Site directory\n %s\n\n' "$dir" if [[ ! -f $dir/$GD_ENV_FILE_NAME ]]; then diff --git a/tests/caddy.test.mjs b/tests/caddy.test.mjs index a4ed6c59..c43f737e 100644 --- a/tests/caddy.test.mjs +++ b/tests/caddy.test.mjs @@ -6,7 +6,17 @@ import { test, describe, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { tempDir, cleanup, makeSite, writeEnv, sh, shOk, shSucceeds, dockerAvailable, q } from './helpers.mjs'; +import { + tempDir, + cleanup, + makeSite, + writeEnv, + sh, + shOk, + shSucceeds, + dockerAvailable, + q, +} from './helpers.mjs'; const CADDY_ROOT = '/etc/caddy'; const STAGED_SITES = `${CADDY_ROOT}/.staging/sites`; @@ -61,11 +71,17 @@ describe('caddy.sh', () => { assert.doesNotMatch(routes, /\{\{|\$\{/, 'an unsubstituted placeholder reached the output'); }); - test('optional profiles point at this site\'s own services', () => { + test("optional profiles point at this site's own services", () => { setup({ COMPOSE_PROFILES: 'production,analytics,activitypub' }); const routes = render(); - assert.match(routes, /import \/etc\/caddy\/snippets\/TrafficAnalytics traffic-analytics-ghost-example-com:3000/); - assert.match(routes, /import \/etc\/caddy\/snippets\/ActivityPub activitypub-ghost-example-com:8080/); + assert.match( + routes, + /import \/etc\/caddy\/snippets\/TrafficAnalytics traffic-analytics-ghost-example-com:3000/, + ); + assert.match( + routes, + /import \/etc\/caddy\/snippets\/ActivityPub activitypub-ghost-example-com:8080/, + ); assert.doesNotMatch(routes, /ap\.ghost\.org/); }); @@ -78,7 +94,12 @@ describe('caddy.sh', () => { }); test('local mode renders no routes at all', () => { - setup({ COMPOSE_PROFILES: 'local', SITE_MODE: 'local', URL: 'http://localhost:2368', DOMAIN: undefined }); + setup({ + COMPOSE_PROFILES: 'local', + SITE_MODE: 'local', + URL: 'http://localhost:2368', + DOMAIN: undefined, + }); assert.ok(!shSucceeds(`caddy_render ${q(site)}`)); }); }); @@ -88,7 +109,9 @@ describe('caddy.sh', () => { setup({ COMPOSE_PROFILES: 'production,analytics,activitypub' }); render(); for (const f of readdirSync(join(site, 'caddy', 'custom'))) { - if (f.endsWith('.caddy')) rmSync(join(site, 'caddy', 'custom', f)); + if (f.endsWith('.caddy')) { + rmSync(join(site, 'caddy', 'custom', f)); + } } }); @@ -129,29 +152,35 @@ describe('caddy.sh', () => { test('no generated routes at all is an error', () => { const stagedDir = join(site, 'caddy', '.staging', 'sites'); - for (const f of readdirSync(stagedDir)) rmSync(join(stagedDir, f)); + for (const f of readdirSync(stagedDir)) { + rmSync(join(stagedDir, f)); + } assert.notEqual(validate().status, 0); }); }); - describe('install and restore', { skip: dockerAvailable() ? false : 'docker is not available' }, () => { - test('installs, validates in place, and can be rolled back', () => { - setup(); - render(); - shOk(`caddy_install ${q(site)}`); - - const live = join(site, 'caddy', 'sites', 'site.caddy'); - const installed = readFileSync(live, 'utf8'); - assert.match(installed, /reverse_proxy ghost-ghost-example-com:2368/); - assert.equal(sh(`caddy_validate ${q(site)}`).status, 0); - - setup({ DOMAIN: 'changed.example.com', URL: 'https://changed.example.com' }); - render(); - const backup = shOk(`caddy_install ${q(site)}`).trim(); - assert.match(readFileSync(live, 'utf8'), /^changed\.example\.com \{$/m); - - shOk(`caddy_restore ${q(site)} ${q(backup)}`); - assert.equal(readFileSync(live, 'utf8'), installed); - }); - }); + describe( + 'install and restore', + { skip: dockerAvailable() ? false : 'docker is not available' }, + () => { + test('installs, validates in place, and can be rolled back', () => { + setup(); + render(); + shOk(`caddy_install ${q(site)}`); + + const live = join(site, 'caddy', 'sites', 'site.caddy'); + const installed = readFileSync(live, 'utf8'); + assert.match(installed, /reverse_proxy ghost-ghost-example-com:2368/); + assert.equal(sh(`caddy_validate ${q(site)}`).status, 0); + + setup({ DOMAIN: 'changed.example.com', URL: 'https://changed.example.com' }); + render(); + const backup = shOk(`caddy_install ${q(site)}`).trim(); + assert.match(readFileSync(live, 'utf8'), /^changed\.example\.com \{$/m); + + shOk(`caddy_restore ${q(site)} ${q(backup)}`); + assert.equal(readFileSync(live, 'utf8'), installed); + }); + }, + ); }); diff --git a/tests/compose-matrix.test.mjs b/tests/compose-matrix.test.mjs index b857d10c..83106671 100644 --- a/tests/compose-matrix.test.mjs +++ b/tests/compose-matrix.test.mjs @@ -8,8 +8,17 @@ import assert from 'node:assert/strict'; import { writeFileSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { - tempDir, cleanup, makeSite, writeEnv, compose, composeConfig, - composeBinaries, dockerAvailable, sh, shOk, q, + tempDir, + cleanup, + makeSite, + writeEnv, + compose, + composeConfig, + composeBinaries, + dockerAvailable, + sh, + shOk, + q, } from './helpers.mjs'; const LONG_RUNNING = ['ghost', 'db', 'caddy', 'traffic-analytics', 'activitypub']; @@ -17,13 +26,71 @@ const ONE_SHOT = ['activitypub-migrate', 'tinybird-login', 'tinybird-sync', 'tin const MATRIX = [ { profiles: 'local', mode: 'local', services: ['db', 'ghost'] }, - { profiles: 'local,analytics', mode: 'local', services: ['db', 'ghost', 'tinybird-deploy', 'tinybird-login', 'tinybird-sync', 'traffic-analytics'] }, - { profiles: 'local,activitypub', mode: 'local', services: ['activitypub', 'activitypub-migrate', 'db', 'ghost'] }, - { profiles: 'local,analytics,activitypub', mode: 'local', services: ['activitypub', 'activitypub-migrate', 'db', 'ghost', 'tinybird-deploy', 'tinybird-login', 'tinybird-sync', 'traffic-analytics'] }, + { + profiles: 'local,analytics', + mode: 'local', + services: [ + 'db', + 'ghost', + 'tinybird-deploy', + 'tinybird-login', + 'tinybird-sync', + 'traffic-analytics', + ], + }, + { + profiles: 'local,activitypub', + mode: 'local', + services: ['activitypub', 'activitypub-migrate', 'db', 'ghost'], + }, + { + profiles: 'local,analytics,activitypub', + mode: 'local', + services: [ + 'activitypub', + 'activitypub-migrate', + 'db', + 'ghost', + 'tinybird-deploy', + 'tinybird-login', + 'tinybird-sync', + 'traffic-analytics', + ], + }, { profiles: 'production', mode: 'production', services: ['caddy', 'db', 'ghost'] }, - { profiles: 'production,analytics', mode: 'production', services: ['caddy', 'db', 'ghost', 'tinybird-deploy', 'tinybird-login', 'tinybird-sync', 'traffic-analytics'] }, - { profiles: 'production,activitypub', mode: 'production', services: ['activitypub', 'activitypub-migrate', 'caddy', 'db', 'ghost'] }, - { profiles: 'production,analytics,activitypub', mode: 'production', services: ['activitypub', 'activitypub-migrate', 'caddy', 'db', 'ghost', 'tinybird-deploy', 'tinybird-login', 'tinybird-sync', 'traffic-analytics'] }, + { + profiles: 'production,analytics', + mode: 'production', + services: [ + 'caddy', + 'db', + 'ghost', + 'tinybird-deploy', + 'tinybird-login', + 'tinybird-sync', + 'traffic-analytics', + ], + }, + { + profiles: 'production,activitypub', + mode: 'production', + services: ['activitypub', 'activitypub-migrate', 'caddy', 'db', 'ghost'], + }, + { + profiles: 'production,analytics,activitypub', + mode: 'production', + services: [ + 'activitypub', + 'activitypub-migrate', + 'caddy', + 'db', + 'ghost', + 'tinybird-deploy', + 'tinybird-login', + 'tinybird-sync', + 'traffic-analytics', + ], + }, ]; // Edge-case literals, to prove the whole path from .env into the resolved @@ -37,190 +104,213 @@ const SMTP_PASSWORD = 'smtp-p$ss'; // ingress.test.mjs, which read the value back out of a running container. const asComposeConfigEscapes = (value) => value.replaceAll('$', () => '$$'); -describe('compose mode matrix', { skip: dockerAvailable() ? false : 'docker is not available' }, () => { - let dir; - let site; - - before(() => { - dir = tempDir('matrix'); - site = makeSite(dir); - // Application configuration, the only env_file of the ghost service. - const ghostEnv = join(site, 'ghost.env'); - writeFileSync(ghostEnv, '', { mode: 0o600 }); - chmodSync(ghostEnv, 0o600); - shOk(`env_set ${q(ghostEnv)} mail__transport SMTP`); - shOk(`env_set ${q(ghostEnv)} mail__options__auth__pass ${q(SMTP_PASSWORD)}`); - }); - after(() => cleanup(dir)); - - const setup = ({ profiles, mode }) => - writeEnv(join(site, '.env'), { - COMPOSE_PROFILES: profiles, - SITE_MODE: mode, - COMPOSE_PROJECT_NAME: 'ghost-example-com', - PROJECT_DIR: site, - GHOST_IMAGE: 'ghost', - GHOST_VERSION: '6-next-alpine', - GHOST_PORT: '2368', - DATABASE_HOST: 'db', - DATABASE_PORT: '3306', - DATABASE_NAME: 'ghost', - DATABASE_USER: 'ghost', - DATABASE_PASSWORD: APP_PASSWORD, - DATABASE_ROOT_PASSWORD: ROOT_PASSWORD, - UPLOAD_LOCATION: './data/ghost', - MYSQL_DATA_LOCATION: './data/mysql', - ...(mode === 'production' - ? { NODE_ENV: 'production', URL: 'https://example.com', DOMAIN: 'example.com', RESTART_POLICY: 'unless-stopped' } - : { NODE_ENV: 'development', URL: 'http://localhost:2368', RESTART_POLICY: 'no' }), +describe( + 'compose mode matrix', + { skip: dockerAvailable() ? false : 'docker is not available' }, + () => { + let dir; + let site; + + before(() => { + dir = tempDir('matrix'); + site = makeSite(dir); + // Application configuration, the only env_file of the ghost service. + const ghostEnv = join(site, 'ghost.env'); + writeFileSync(ghostEnv, '', { mode: 0o600 }); + chmodSync(ghostEnv, 0o600); + shOk(`env_set ${q(ghostEnv)} mail__transport SMTP`); + shOk(`env_set ${q(ghostEnv)} mail__options__auth__pass ${q(SMTP_PASSWORD)}`); }); + after(() => cleanup(dir)); - for (const { label, bin } of composeBinaries()) { - describe(label, () => { - test('the digest pin overrides tags for Ghost and Tinybird together', () => { - setup(MATRIX[1]); - const pin = `ghost@sha256:${'a'.repeat(64)}`; - shOk(`env_set ${q(join(site, '.env'))} GHOST_IMAGE_REF ${q(pin)}`); - const config = composeConfig(site, { bin }); - assert.equal(config.services.ghost.image, pin); - assert.equal(config.services['tinybird-sync'].image, pin); + const setup = ({ profiles, mode }) => + writeEnv(join(site, '.env'), { + COMPOSE_PROFILES: profiles, + SITE_MODE: mode, + COMPOSE_PROJECT_NAME: 'ghost-example-com', + PROJECT_DIR: site, + GHOST_IMAGE: 'ghost', + GHOST_VERSION: '6-next-alpine', + GHOST_PORT: '2368', + DATABASE_HOST: 'db', + DATABASE_PORT: '3306', + DATABASE_NAME: 'ghost', + DATABASE_USER: 'ghost', + DATABASE_PASSWORD: APP_PASSWORD, + DATABASE_ROOT_PASSWORD: ROOT_PASSWORD, + UPLOAD_LOCATION: './data/ghost', + MYSQL_DATA_LOCATION: './data/mysql', + ...(mode === 'production' + ? { + NODE_ENV: 'production', + URL: 'https://example.com', + DOMAIN: 'example.com', + RESTART_POLICY: 'unless-stopped', + } + : { NODE_ENV: 'development', URL: 'http://localhost:2368', RESTART_POLICY: 'no' }), }); - for (const entry of MATRIX) { - describe(entry.profiles, () => { - let config; - let ghost; + for (const { label, bin } of composeBinaries()) { + describe(label, () => { + test('the digest pin overrides tags for Ghost and Tinybird together', () => { + setup(MATRIX[1]); + const pin = `ghost@sha256:${'a'.repeat(64)}`; + shOk(`env_set ${q(join(site, '.env'))} GHOST_IMAGE_REF ${q(pin)}`); + const config = composeConfig(site, { bin }); + assert.equal(config.services.ghost.image, pin); + assert.equal(config.services['tinybird-sync'].image, pin); + }); - before(() => { - setup(entry); - config = composeConfig(site, { bin }); - ghost = config.services.ghost; - }); + for (const entry of MATRIX) { + describe(entry.profiles, () => { + let config; + let ghost; - test('enables exactly the expected services', () => { - assert.deepEqual(Object.keys(config.services).sort(), entry.services); - }); + before(() => { + setup(entry); + config = composeConfig(site, { bin }); + ghost = config.services.ghost; + }); - test('Ghost receives no infrastructure root credentials', () => { - const serialized = JSON.stringify(ghost); - assert.doesNotMatch(serialized, /root-p@ss word/); - assert.doesNotMatch(serialized, /DATABASE_ROOT_PASSWORD/); - assert.doesNotMatch(serialized, /MYSQL_ROOT_PASSWORD/); - }); + test('enables exactly the expected services', () => { + assert.deepEqual(Object.keys(config.services).sort(), entry.services); + }); - test('Ghost receives no operator-only settings', () => { - for (const key of ['COMPOSE_PROFILES', 'COMPOSE_PROJECT_NAME', 'PROJECT_DIR', - 'RESTART_POLICY', 'UPLOAD_LOCATION', 'MYSQL_DATA_LOCATION', 'LOG_MAX_SIZE', 'SITE_MODE']) { - assert.equal(ghost.environment[key], undefined, `${key} reached Ghost`); - } - }); + test('Ghost receives no infrastructure root credentials', () => { + const serialized = JSON.stringify(ghost); + assert.doesNotMatch(serialized, /root-p@ss word/); + assert.doesNotMatch(serialized, /DATABASE_ROOT_PASSWORD/); + assert.doesNotMatch(serialized, /MYSQL_ROOT_PASSWORD/); + }); - test('ghost.env reaches Ghost and nothing else', () => { - assert.equal(ghost.environment.mail__transport, 'SMTP'); - assert.equal( - ghost.environment.mail__options__auth__pass, - asComposeConfigEscapes(SMTP_PASSWORD), - ); - assert.equal(config.services.db.environment.mail__transport, undefined); - }); + test('Ghost receives no operator-only settings', () => { + for (const key of [ + 'COMPOSE_PROFILES', + 'COMPOSE_PROJECT_NAME', + 'PROJECT_DIR', + 'RESTART_POLICY', + 'UPLOAD_LOCATION', + 'MYSQL_DATA_LOCATION', + 'LOG_MAX_SIZE', + 'SITE_MODE', + ]) { + assert.equal(ghost.environment[key], undefined, `${key} reached Ghost`); + } + }); - test('the application database password reaches Ghost', () => { - assert.equal( - ghost.environment.database__connection__password, - asComposeConfigEscapes(APP_PASSWORD), - ); - }); + test('ghost.env reaches Ghost and nothing else', () => { + assert.equal(ghost.environment.mail__transport, 'SMTP'); + assert.equal( + ghost.environment.mail__options__auth__pass, + asComposeConfigEscapes(SMTP_PASSWORD), + ); + assert.equal(config.services.db.environment.mail__transport, undefined); + }); - test('the database connection is fully parameterized', () => { - assert.equal(ghost.environment.database__client, 'mysql'); - assert.equal(ghost.environment.database__connection__host, 'db'); - assert.equal(ghost.environment.database__connection__port, '3306'); - assert.equal(ghost.environment.database__connection__user, 'ghost'); - assert.equal(ghost.environment.database__connection__database, 'ghost'); - }); + test('the application database password reaches Ghost', () => { + assert.equal( + ghost.environment.database__connection__password, + asComposeConfigEscapes(APP_PASSWORD), + ); + }); - test('services have unique network aliases', () => { - const aliases = (name) => config.services[name]?.networks?.ghost_network?.aliases ?? []; - assert.ok(aliases('ghost').includes('ghost-ghost-example-com')); - assert.ok(aliases('db').includes('db-ghost-example-com')); - if (entry.services.includes('activitypub')) { - assert.ok(aliases('activitypub').includes('activitypub-ghost-example-com')); - } - if (entry.services.includes('traffic-analytics')) { - assert.ok(aliases('traffic-analytics').includes('traffic-analytics-ghost-example-com')); - } - }); + test('the database connection is fully parameterized', () => { + assert.equal(ghost.environment.database__client, 'mysql'); + assert.equal(ghost.environment.database__connection__host, 'db'); + assert.equal(ghost.environment.database__connection__port, '3306'); + assert.equal(ghost.environment.database__connection__user, 'ghost'); + assert.equal(ghost.environment.database__connection__database, 'ghost'); + }); - test('Ghost publishes on the loopback interface only', () => { - assert.deepEqual(ghost.ports.map((p) => p.host_ip), ['127.0.0.1']); - assert.equal(ghost.ports[0].target, 2368); - }); + test('services have unique network aliases', () => { + const aliases = (name) => + config.services[name]?.networks?.ghost_network?.aliases ?? []; + assert.ok(aliases('ghost').includes('ghost-ghost-example-com')); + assert.ok(aliases('db').includes('db-ghost-example-com')); + if (entry.services.includes('activitypub')) { + assert.ok(aliases('activitypub').includes('activitypub-ghost-example-com')); + } + if (entry.services.includes('traffic-analytics')) { + assert.ok( + aliases('traffic-analytics').includes('traffic-analytics-ghost-example-com'), + ); + } + }); - test('Ghost has a real readiness probe', () => { - assert.ok(ghost.healthcheck?.test?.length, 'no healthcheck'); - assert.ok(ghost.healthcheck.start_interval, 'no start_interval'); - }); + test('Ghost publishes on the loopback interface only', () => { + assert.deepEqual( + ghost.ports.map((p) => p.host_ip), + ['127.0.0.1'], + ); + assert.equal(ghost.ports[0].target, 2368); + }); - test('long-running services use the site restart policy', () => { - const expected = entry.mode === 'local' ? 'no' : 'unless-stopped'; - for (const name of LONG_RUNNING.filter((n) => entry.services.includes(n))) { - assert.equal(config.services[name].restart, expected, name); - } - }); + test('Ghost has a real readiness probe', () => { + assert.ok(ghost.healthcheck?.test?.length, 'no healthcheck'); + assert.ok(ghost.healthcheck.start_interval, 'no start_interval'); + }); - test('one-shot jobs stay one-shot', () => { - for (const name of ONE_SHOT.filter((n) => entry.services.includes(n))) { - assert.equal(config.services[name].restart, 'no', name); - } - }); + test('long-running services use the site restart policy', () => { + const expected = entry.mode === 'local' ? 'no' : 'unless-stopped'; + for (const name of LONG_RUNNING.filter((n) => entry.services.includes(n))) { + assert.equal(config.services[name].restart, expected, name); + } + }); - test('every service caps its logs and carries site labels', () => { - for (const [name, service] of Object.entries(config.services)) { - assert.ok(service.logging?.options?.['max-size'], `${name} has no log cap`); - assert.ok(service.logging?.options?.['max-file'], `${name} has no log file limit`); - assert.equal(service.labels['org.ghost.docker.site'], 'ghost-example-com', name); - assert.equal(service.labels['org.ghost.docker.mode'], entry.mode, name); - assert.equal( - service.labels['org.ghost.docker.lifecycle'], - ONE_SHOT.includes(name) ? 'one-shot' : 'long-running', - name, - ); - } + test('one-shot jobs stay one-shot', () => { + for (const name of ONE_SHOT.filter((n) => entry.services.includes(n))) { + assert.equal(config.services[name].restart, 'no', name); + } + }); + + test('every service caps its logs and carries site labels', () => { + for (const [name, service] of Object.entries(config.services)) { + assert.ok(service.logging?.options?.['max-size'], `${name} has no log cap`); + assert.ok(service.logging?.options?.['max-file'], `${name} has no log file limit`); + assert.equal(service.labels['org.ghost.docker.site'], 'ghost-example-com', name); + assert.equal(service.labels['org.ghost.docker.mode'], entry.mode, name); + assert.equal( + service.labels['org.ghost.docker.lifecycle'], + ONE_SHOT.includes(name) ? 'one-shot' : 'long-running', + name, + ); + } + }); }); - }); - } + } + }); + } + + test('the IPv6 override loads through the helper file contract', () => { + setup(MATRIX.find((m) => m.profiles === 'production')); + const result = sh(`compose_run ${q(site)} config --format json`, { + env: { GD_COMPOSE_OVERRIDES: 'compose.ipv6.yml' }, + }); + assert.equal(result.status, 0, result.stderr.toString()); + const config = JSON.parse(result.stdout.toString()); + assert.equal(config.networks.ghost_network.enable_ipv6, true); }); - } - test('the IPv6 override loads through the helper file contract', () => { - setup(MATRIX.find((m) => m.profiles === 'production')); - const result = sh(`compose_run ${q(site)} config --format json`, { - env: { GD_COMPOSE_OVERRIDES: 'compose.ipv6.yml' }, + test('an unset COMPOSE_FILE cannot change the helper file list', () => { + setup(MATRIX.find((m) => m.profiles === 'production')); + const result = sh(`compose_run ${q(site)} config --services`, { + env: { COMPOSE_FILE: '/nonexistent/compose.yml' }, + }); + assert.equal(result.status, 0, result.stderr.toString()); + }); + + test('URL is required in every supported mode', () => { + setup(MATRIX.find((m) => m.profiles === 'production')); + shOk(`env_unset ${q(join(site, '.env'))} URL`); + const result = compose(site, ['config']); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /URL is required/); }); - assert.equal(result.status, 0, result.stderr.toString()); - const config = JSON.parse(result.stdout.toString()); - assert.equal(config.networks.ghost_network.enable_ipv6, true); - }); - - test('an unset COMPOSE_FILE cannot change the helper file list', () => { - setup(MATRIX.find((m) => m.profiles === 'production')); - const result = sh(`compose_run ${q(site)} config --services`, { - env: { COMPOSE_FILE: '/nonexistent/compose.yml' }, + + test('DOMAIN is not guarded, so local mode is unaffected by it', () => { + setup(MATRIX.find((m) => m.profiles === 'local')); + const result = compose(site, ['config']); + assert.equal(result.status, 0, result.stderr); }); - assert.equal(result.status, 0, result.stderr.toString()); - }); - - test('URL is required in every supported mode', () => { - setup(MATRIX.find((m) => m.profiles === 'production')); - shOk(`env_unset ${q(join(site, '.env'))} URL`); - const result = compose(site, ['config']); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /URL is required/); - }); - - test('DOMAIN is not guarded, so local mode is unaffected by it', () => { - setup(MATRIX.find((m) => m.profiles === 'local')); - const result = compose(site, ['config']); - assert.equal(result.status, 0, result.stderr); - }); -}); + }, +); diff --git a/tests/compose-readiness.test.mjs b/tests/compose-readiness.test.mjs index 271ffee9..9f870443 100644 --- a/tests/compose-readiness.test.mjs +++ b/tests/compose-readiness.test.mjs @@ -5,8 +5,14 @@ import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { - tempDir, cleanup, makeSite, writeEnv, compose, composeConfig, - composeBinaries, dockerAvailable, + tempDir, + cleanup, + makeSite, + writeEnv, + compose, + composeConfig, + composeBinaries, + dockerAvailable, } from './helpers.mjs'; const jobs = ['activitypub-migrate', 'tinybird-login', 'tinybird-sync', 'tinybird-deploy']; @@ -21,27 +27,40 @@ describe('Compose readiness contract', { skip: !dockerAvailable() }, () => { writeEnv(join(site, '.env'), { COMPOSE_PROFILES: 'local,analytics,activitypub', COMPOSE_PROJECT_NAME: `gd-ready-${process.pid}-${failure || 'success'}`, - URL: 'http://localhost:2368', DATABASE_PASSWORD: 'test', DATABASE_ROOT_PASSWORD: 'test', + URL: 'http://localhost:2368', + DATABASE_PASSWORD: 'test', + DATABASE_ROOT_PASSWORD: 'test', }); const original = composeConfig(site, { bin }); - const services = Object.fromEntries(Object.entries(original.services).map(([name, service]) => { - const job = jobs.includes(name); - return [name, { - image: 'alpine:3.20', - restart: 'no', - stop_grace_period: '1s', - depends_on: service.depends_on, - command: ['sh', '-c', job - ? `exit ${name === failure ? 1 : 0}` - : 'sleep 1; touch /tmp/ready; exec sleep 300'], - ...(!job && { - healthcheck: { - test: ['CMD-SHELL', name === failure ? 'exit 1' : 'test -f /tmp/ready'], - interval: '1s', timeout: '1s', retries: 3, + const services = Object.fromEntries( + Object.entries(original.services).map(([name, service]) => { + const job = jobs.includes(name); + return [ + name, + { + image: 'alpine:3.20', + restart: 'no', + stop_grace_period: '1s', + depends_on: service.depends_on, + command: [ + 'sh', + '-c', + job + ? `exit ${name === failure ? 1 : 0}` + : 'sleep 1; touch /tmp/ready; exec sleep 300', + ], + ...(!job && { + healthcheck: { + test: ['CMD-SHELL', name === failure ? 'exit 1' : 'test -f /tmp/ready'], + interval: '1s', + timeout: '1s', + retries: 3, + }, + }), }, - }), - }]; - })); + ]; + }), + ); writeFileSync(join(site, 'compose.yml'), JSON.stringify({ services })); const result = compose(site, ['up', '--wait', '--wait-timeout', '15'], { bin }); if (failure) { @@ -52,7 +71,8 @@ describe('Compose readiness contract', { skip: !dockerAvailable() }, () => { const ps = compose(site, ['ps', '-a', '--format', 'json'], { bin }); assert.equal(ps.status, 0, ps.stderr); const rows = ps.stdout.trim().startsWith('[') - ? JSON.parse(ps.stdout) : ps.stdout.trim().split('\n').map(JSON.parse); + ? JSON.parse(ps.stdout) + : ps.stdout.trim().split('\n').map(JSON.parse); for (const name of jobs) { const row = rows.find((r) => r.Service === name); assert.equal(row?.State, 'exited', name); diff --git a/tests/config.test.mjs b/tests/config.test.mjs index bb24d42c..b79613a1 100644 --- a/tests/config.test.mjs +++ b/tests/config.test.mjs @@ -3,7 +3,17 @@ import { test, describe, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, appendFileSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; -import { tempDir, cleanup, makeSite, sh, shOk, shSucceeds, writeEnv, q, REPO_DIR } from './helpers.mjs'; +import { + tempDir, + cleanup, + makeSite, + sh, + shOk, + shSucceeds, + writeEnv, + q, + REPO_DIR, +} from './helpers.mjs'; const productionEnv = (site) => ({ PROJECT_DIR: site, @@ -89,9 +99,6 @@ describe('config_validate_env', () => { assert.match(expectRejected('missing DOMAIN'), /DOMAIN is required/); }); - - - // URL scheme, port format and restart policy are left to Compose and Docker, // which reject them with clear errors of their own. What follows is only // what nothing else catches. @@ -100,9 +107,6 @@ describe('config_validate_env', () => { assert.match(expectRejected('mismatched domain'), /disagree/); }); - - - test('an ActivityPub database that is never provisioned is rejected', () => { shOk(`env_set ${q(envFile)} ACTIVITYPUB_DATABASE_NAME ap_custom`); assert.match(expectRejected('unprovisioned database'), /DATABASE_EXTRA_DATABASES/); @@ -115,7 +119,9 @@ describe('config_validate_env', () => { // future layout change is caught without updating a mapping. Skipped when // the images are not present locally. const probe = sh(`config_image_content_path ghost:6-next-alpine`); - if (probe.status !== 0) return; // image not pulled + if (probe.status !== 0) { + return; + } // image not pulled shOk(`env_set ${q(envFile)} GHOST_VERSION 6-next-alpine`); shOk(`env_set ${q(envFile)} GHOST_CONTENT_PATH /var/lib/ghost/content`); @@ -178,7 +184,20 @@ describe('config_validate_ghost_env', () => { assert.equal(validate().status, 0, validate().stdout.toString()); }); - for (const key of ['url', 'admin__url', 'database__connection__host', 'server__port', 'NODE_ENV']) { + test('literal dollars, tabs, newlines and backslashes compare without TSV escaping', () => { + const literal = 'literal $ # \\ tab\tline\nnext'; + shOk(`env_set ${q(ghostEnv)} custom__literal ${q(literal)}`); + const result = validate(); + assert.equal(result.status, 0, result.stdout.toString()); + }); + + for (const key of [ + 'url', + 'admin__url', + 'database__connection__host', + 'server__port', + 'NODE_ENV', + ]) { test(`the container-owned key ${key} is rejected`, () => { shOk(`env_set ${q(ghostEnv)} ${q(key)} anything`); const result = validate(); @@ -204,12 +223,18 @@ describe('config_validate_ghost_env', () => { try { writeFileSync( composeFile, - original.replace(' database__client: mysql', ' database__client: mysql\n brand__new__key: owned-by-container'), + original.replace( + ' database__client: mysql', + ' database__client: mysql\n brand__new__key: owned-by-container', + ), ); shOk(`env_set ${q(ghostEnv)} brand__new__key mine`); const result = validate(); assert.notEqual(result.status, 0); - assert.match(result.stdout.toString(), /brand__new__key is set by the container \(owned-by-container\)/); + assert.match( + result.stdout.toString(), + /brand__new__key is set by the container \(owned-by-container\)/, + ); } finally { writeFileSync(composeFile, original); } diff --git a/tests/env-compose.test.mjs b/tests/env-compose.test.mjs index 3a331712..09b37690 100644 --- a/tests/env-compose.test.mjs +++ b/tests/env-compose.test.mjs @@ -31,90 +31,111 @@ const VALUES = [ '-----BEGIN KEY-----\nabc/def+gh==\n-----END KEY-----', ]; -describe('Compose round trip', { skip: dockerAvailable() ? false : 'docker is not available' }, () => { - let dir; - let seen; +describe( + 'Compose round trip', + { skip: dockerAvailable() ? false : 'docker is not available' }, + () => { + let dir; + let seen; - before(() => { - dir = tempDir('env-compose'); + before(() => { + dir = tempDir('env-compose'); - // A variable that must NOT leak into any value through interpolation. - process.env.VAR = 'INTERPOLATED'; + // A variable that must NOT leak into any value through interpolation. + process.env.VAR = 'INTERPOLATED'; - const appEnv = join(dir, 'app.env'); - writeEnv(appEnv, Object.fromEntries(VALUES.map((v, i) => [`V${i}`, v]))); + const appEnv = join(dir, 'app.env'); + writeEnv(appEnv, Object.fromEntries(VALUES.map((v, i) => [`V${i}`, v]))); - // The probe script is mounted rather than inlined, so Compose never - // interpolates the shell syntax that reads the values back. - writeFileSync( - join(dir, 'probe.sh'), - ['#!/bin/sh', 'i=0', 'while [ "$i" -lt "$COUNT" ]; do', - ' eval "v=\\${V$i}"', - " printf '%s' \"$v\" | base64 | tr -d '\\n'", - " printf '\\n'", - ' i=$((i + 1))', 'done', ''].join('\n'), - { mode: 0o755 }, - ); + // The probe script is mounted rather than inlined, so Compose never + // interpolates the shell syntax that reads the values back. + writeFileSync( + join(dir, 'probe.sh'), + [ + '#!/bin/sh', + 'i=0', + 'while [ "$i" -lt "$COUNT" ]; do', + ' eval "v=\\${V$i}"', + " printf '%s' \"$v\" | base64 | tr -d '\\n'", + " printf '\\n'", + ' i=$((i + 1))', + 'done', + '', + ].join('\n'), + { mode: 0o755 }, + ); - writeFileSync( - join(dir, 'compose.yml'), - [ - 'services:', - ' probe:', - ' image: ${GD_PROBE_IMAGE}', - ' env_file:', - ' - path: app.env', - ' required: true', - ' environment:', - ' COUNT: ${COUNT}', - ' volumes:', - ' - ./probe.sh:/probe.sh:ro', - ' command: ["sh", "/probe.sh"]', - '', - ].join('\n'), - ); + writeFileSync( + join(dir, 'compose.yml'), + [ + 'services:', + ' probe:', + ' image: ${GD_PROBE_IMAGE}', + ' env_file:', + ' - path: app.env', + ' required: true', + ' environment:', + ' COUNT: ${COUNT}', + ' volumes:', + ' - ./probe.sh:/probe.sh:ro', + ' command: ["sh", "/probe.sh"]', + '', + ].join('\n'), + ); - // COUNT and the image come through Compose interpolation of `.env`, which - // exercises the `.env` side of the same contract. - writeEnv(join(dir, '.env'), { COUNT: String(VALUES.length), GD_PROBE_IMAGE: PROBE_IMAGE }); + // COUNT and the image come through Compose interpolation of `.env`, which + // exercises the `.env` side of the same contract. + writeEnv(join(dir, '.env'), { COUNT: String(VALUES.length), GD_PROBE_IMAGE: PROBE_IMAGE }); - const result = compose(dir, ['run', '--rm', '--no-deps', '-T', 'probe']); - assert.equal(result.status, 0, result.stderr); - seen = result.stdout.trim().split('\n').map((line) => Buffer.from(line.trim(), 'base64').toString()); - }); + const result = compose(dir, ['run', '--rm', '--no-deps', '-T', 'probe']); + assert.equal(result.status, 0, result.stderr); + seen = result.stdout + .trim() + .split('\n') + .map((line) => Buffer.from(line.trim(), 'base64').toString()); + }); - after(() => { - compose(dir, ['down', '-v', '--remove-orphans']); - cleanup(dir); - }); + after(() => { + compose(dir, ['down', '-v', '--remove-orphans']); + cleanup(dir); + }); - VALUES.forEach((value, index) => { - test(`the container sees value ${index} verbatim: ${JSON.stringify(value)}`, () => { - assert.equal(seen[index], value); + VALUES.forEach((value, index) => { + test(`the container sees value ${index} verbatim: ${JSON.stringify(value)}`, () => { + assert.equal(seen[index], value); + }); }); - }); - test('a .env value survives Compose interpolation into a service', () => { - const tricky = `tricky $VAR \${VAR} "quoted" 'single' back\\slash # hash`; - writeFileSync( - join(dir, 'interp.yml'), - [ - 'services:', - ' probe:', - ' image: ${GD_PROBE_IMAGE}', - ' environment:', - ' ECHOED: ${TRICKY}', - ' command: ["sh", "-c", "printf \'%s\' \\"$$ECHOED\\" | base64 | tr -d \'\\\\n\'"]', - '', - ].join('\n'), - ); - writeEnv(join(dir, '.env'), { - COUNT: String(VALUES.length), - GD_PROBE_IMAGE: PROBE_IMAGE, - TRICKY: tricky, + test('a .env value survives Compose interpolation into a service', () => { + const tricky = `tricky $VAR \${VAR} "quoted" 'single' back\\slash # hash`; + writeFileSync( + join(dir, 'interp.yml'), + [ + 'services:', + ' probe:', + ' image: ${GD_PROBE_IMAGE}', + ' environment:', + ' ECHOED: ${TRICKY}', + ' command: ["sh", "-c", "printf \'%s\' \\"$$ECHOED\\" | base64 | tr -d \'\\\\n\'"]', + '', + ].join('\n'), + ); + writeEnv(join(dir, '.env'), { + COUNT: String(VALUES.length), + GD_PROBE_IMAGE: PROBE_IMAGE, + TRICKY: tricky, + }); + const result = compose(dir, [ + '-f', + join(dir, 'interp.yml'), + 'run', + '--rm', + '--no-deps', + '-T', + 'probe', + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(Buffer.from(result.stdout.trim(), 'base64').toString(), tricky); }); - const result = compose(dir, ['-f', join(dir, 'interp.yml'), 'run', '--rm', '--no-deps', '-T', 'probe']); - assert.equal(result.status, 0, result.stderr); - assert.equal(Buffer.from(result.stdout.trim(), 'base64').toString(), tricky); - }); -}); + }, +); diff --git a/tests/env.test.mjs b/tests/env.test.mjs index 355b5012..7c8e52e3 100644 --- a/tests/env.test.mjs +++ b/tests/env.test.mjs @@ -55,15 +55,27 @@ describe('env.sh', () => { }); test('lists every key once, in order', () => { - const keys = shOk(`env_keys ${q(file)}`).trim().split('\n'); + const keys = shOk(`env_keys ${q(file)}`) + .trim() + .split('\n'); assert.deepEqual(keys, Object.keys(VALUES)); }); test('overwrites in place without duplicating', () => { shOk(`env_set ${q(file)} plain replaced`); assert.equal(shValue(`env_get ${q(file)} plain`).trim(), 'replaced'); - assert.equal(shOk(`env_keys ${q(file)}`).trim().split('\n').length, Object.keys(VALUES).length); - assert.equal(shOk(`env_keys ${q(file)}`).trim().split('\n')[0], 'plain'); + assert.equal( + shOk(`env_keys ${q(file)}`) + .trim() + .split('\n').length, + Object.keys(VALUES).length, + ); + assert.equal( + shOk(`env_keys ${q(file)}`) + .trim() + .split('\n')[0], + 'plain', + ); }); test('preserves comments and blank lines around an edit', () => { @@ -140,11 +152,12 @@ describe('env.sh', () => { }); test('keys around it are still listed, and its body is not mistaken for one', () => { - assert.deepEqual(shOk(`env_keys ${q(pemFile)}`).trim().split('\n'), [ - 'mail__transport', - 'TLS_KEY', - 'labs__publicAPI', - ]); + assert.deepEqual( + shOk(`env_keys ${q(pemFile)}`) + .trim() + .split('\n'), + ['mail__transport', 'TLS_KEY', 'labs__publicAPI'], + ); }); test('reading it fails with an actionable message', () => { @@ -200,7 +213,12 @@ describe('env.sh', () => { lintFile = join(dir, 'lint.env'); writeFileSync( lintFile, - ['GOOD="$$literal"', "ALSO_GOOD='$literal'", 'BAD="costs $5"', 'BAD_UNQUOTED=costs $5'].join('\n') + '\n', + [ + 'GOOD="$$literal"', + "ALSO_GOOD='$literal'", + 'BAD="costs $5"', + 'BAD_UNQUOTED=costs $5', + ].join('\n') + '\n', ); }); diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 58849be7..833f8669 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -1,6 +1,14 @@ import { execFileSync, execFile, spawnSync } from 'node:child_process'; import { promisify } from 'node:util'; -import { mkdtempSync, mkdirSync, rmSync, cpSync, writeFileSync, chmodSync, readdirSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + rmSync, + cpSync, + writeFileSync, + chmodSync, + readdirSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,7 +18,17 @@ const execFileAsync = promisify(execFile); export const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); export const REPO_DIR = join(TESTS_DIR, '..'); -const LIBS = ['fs', 'env', 'compose', 'config', 'caddy', 'meta', 'preflight', 'install']; +const LIBS = [ + 'fs', + 'env', + 'compose', + 'config', + 'caddy', + 'meta', + 'preflight', + 'install', + 'operation', +]; /** * Run a bash snippet with every ghost-docker library sourced. @@ -34,7 +52,9 @@ export function sh(script, { cwd = REPO_DIR, env = {}, input } = {}) { input, env: { ...process.env, ...env }, }); - if (result.error) throw result.error; + if (result.error) { + throw result.error; + } return { stdout: result.stdout ?? Buffer.alloc(0), stderr: result.stderr ?? Buffer.alloc(0), @@ -46,7 +66,9 @@ export function sh(script, { cwd = REPO_DIR, env = {}, input } = {}) { export function shOk(script, options) { const result = sh(script, options); if (result.status !== 0) { - throw new Error(`shell exited ${result.status}: ${result.stderr.toString()}${result.stdout.toString()}`); + throw new Error( + `shell exited ${result.status}: ${result.stderr.toString()}${result.stdout.toString()}`, + ); } return result.stdout.toString(); } @@ -101,7 +123,9 @@ export function writeEnv(file, values) { writeFileSync(file, '', { mode: 0o600 }); chmodSync(file, 0o600); for (const [key, value] of Object.entries(values)) { - if (value === undefined) continue; + if (value === undefined) { + continue; + } shOk(`env_set ${q(file)} ${q(key)} ${q(value)}`); } } @@ -119,7 +143,9 @@ export function compose(site, args, { bin, env = {} } = {}) { }); return { stdout, stderr: '', status: 0 }; } catch (error) { - if (error.status === undefined) throw error; + if (error.status === undefined) { + throw error; + } return { stdout: error.stdout ?? '', stderr: error.stderr ?? '', status: error.status }; } } @@ -162,8 +188,12 @@ export async function waitFor(check, { timeoutMs, intervalMs = 5000 } = {}) { const deadline = Date.now() + timeoutMs; for (;;) { const result = await check(); - if (result) return result; - if (Date.now() > deadline) return null; + if (result) { + return result; + } + if (Date.now() > deadline) { + return null; + } await sleep(intervalMs); } } @@ -172,13 +202,17 @@ export async function waitFor(check, { timeoutMs, intervalMs = 5000 } = {}) { export function composeBinaries() { const bins = [{ label: `compose ${composeVersion()}`, bin: undefined }]; const min = process.env.GD_TEST_MIN_COMPOSE; - if (min) bins.push({ label: `compose ${composeVersion(min)} (declared minimum)`, bin: min }); + if (min) { + bins.push({ label: `compose ${composeVersion(min)} (declared minimum)`, bin: min }); + } return bins; } function composeVersion(bin) { const argv = bin ? ['version', '--short'] : ['compose', 'version', '--short']; - return execFileSync(bin ?? 'docker', argv, { encoding: 'utf8' }).trim().replace(/^v/, ''); + return execFileSync(bin ?? 'docker', argv, { encoding: 'utf8' }) + .trim() + .replace(/^v/, ''); } // --- Installation ---------------------------------------------------------- @@ -189,21 +223,35 @@ function composeVersion(bin) { /** Never travels with a release: local state, secrets, and generated routes. */ const RELEASE_EXCLUDE = new Set([ - '.git', 'data', 'node_modules', '.env', 'ghost.env', '.ghost-docker.json', + '.git', + 'data', + 'node_modules', + '.env', + 'ghost.env', + '.ghost-docker.json', ]); /** Copy the working tree into `dest` as a release would ship it. */ export function copyWorktree(dest) { mkdirSync(dest, { recursive: true }); for (const entry of readdirSync(REPO_DIR)) { - if (RELEASE_EXCLUDE.has(entry)) continue; + if ( + RELEASE_EXCLUDE.has(entry) || + entry.startsWith('.ghost-') || + entry.startsWith('.env.tmp.') || + entry.startsWith('ghost.env.tmp.') + ) { + continue; + } cpSync(join(REPO_DIR, entry), join(dest, entry), { recursive: true }); } // Generated and operator-owned routes are per-site, not part of a release. for (const sub of ['sites', 'custom', 'global']) { const routes = join(dest, 'caddy', sub); for (const file of readdirSync(routes)) { - if (file.endsWith('.caddy')) rmSync(join(routes, file), { force: true }); + if (file.endsWith('.caddy')) { + rmSync(join(routes, file), { force: true }); + } } } rmSync(join(dest, 'caddy', '.staging'), { recursive: true, force: true }); @@ -218,11 +266,15 @@ const GIT_IDENTITY = { }; export function git(repo, args) { - return execFileSync('git', ['-C', repo, '-c', 'commit.gpgsign=false', '-c', 'tag.gpgsign=false', ...args], { - encoding: 'utf8', - env: { ...process.env, ...GIT_IDENTITY }, - stdio: ['pipe', 'pipe', 'pipe'], - }); + return execFileSync( + 'git', + ['-C', repo, '-c', 'commit.gpgsign=false', '-c', 'tag.gpgsign=false', ...args], + { + encoding: 'utf8', + env: { ...process.env, ...GIT_IDENTITY }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); } /** @@ -243,7 +295,9 @@ export function makeCandidateRelease(dir, tags = ['v9.9.9']) { git(repo, ['add', '-A']); git(repo, ['commit', '-q', '-m', 'candidate release']); tags.forEach((tag, index) => { - if (index > 0) git(repo, ['commit', '-q', '--allow-empty', '-m', `release ${tag}`]); + if (index > 0) { + git(repo, ['commit', '-q', '--allow-empty', '-m', `release ${tag}`]); + } git(repo, ['tag', tag]); }); return { repo, tags, url: `file://${repo}` }; @@ -258,7 +312,9 @@ export function run(command, args, { cwd, env = {}, input, timeout } = {}) { encoding: 'utf8', env: { ...process.env, ...env }, }); - if (result.error && result.error.code !== 'ETIMEDOUT') throw result.error; + if (result.error && result.error.code !== 'ETIMEDOUT') { + throw result.error; + } return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', diff --git a/tests/ingress.test.mjs b/tests/ingress.test.mjs index a5f06e73..4b8a1468 100644 --- a/tests/ingress.test.mjs +++ b/tests/ingress.test.mjs @@ -9,8 +9,18 @@ import assert from 'node:assert/strict'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { - tempDir, cleanup, makeSite, writeEnv, sh, shOk, compose, composeAsync, - dockerAvailable, dockerInspect, waitFor, sleep, q, + tempDir, + cleanup, + makeSite, + writeEnv, + sh, + compose, + composeAsync, + dockerAvailable, + dockerInspect, + waitFor, + sleep, + q, } from './helpers.mjs'; const enabled = process.env.GD_TEST_INGRESS === '1' && dockerAvailable(); @@ -48,14 +58,19 @@ const serviceId = (name) => compose(site, ['ps', '-q', name]).stdout.trim(); /** Wait for a service's health check to report healthy. Fails fast if it exits. */ const waitHealthy = (name, timeoutMs) => - waitFor(() => { - const id = serviceId(name); - if (!id) return false; - if (dockerInspect(id, '{{.State.Running}}') !== 'true') { - throw new Error(`${name} stopped while waiting for it to become healthy`); - } - return dockerInspect(id, '{{.State.Health.Status}}') === 'healthy'; - }, { timeoutMs }); + waitFor( + () => { + const id = serviceId(name); + if (!id) { + return false; + } + if (dockerInspect(id, '{{.State.Running}}') !== 'true') { + throw new Error(`${name} stopped while waiting for it to become healthy`); + } + return dockerInspect(id, '{{.State.Health.Status}}') === 'healthy'; + }, + { timeoutMs }, + ); /** Run a snippet of Node inside the Ghost container and return its stdout. */ const inGhost = async (script) => { @@ -69,8 +84,12 @@ describe('ingress smoke tests', { skip, concurrency: 1 }, () => { site = makeSite(dir); }); after(() => { - if (site) compose(site, ['down', '-v', '--remove-orphans']); - if (dir) cleanup(dir); + if (site) { + compose(site, ['down', '-v', '--remove-orphans']); + } + if (dir) { + cleanup(dir); + } }); describe('local mode', () => { @@ -108,8 +127,12 @@ describe('ingress smoke tests', { skip, concurrency: 1 }, () => { }); test('Ghost is published on the loopback interface only', () => { - const bindings = JSON.parse(dockerInspect(serviceId('ghost'), '{{json .HostConfig.PortBindings}}')); - const hosts = Object.values(bindings).flat().map((b) => b.HostIp); + const bindings = JSON.parse( + dockerInspect(serviceId('ghost'), '{{json .HostConfig.PortBindings}}'), + ); + const hosts = Object.values(bindings) + .flat() + .map((b) => b.HostIp); assert.deepEqual(hosts, ['127.0.0.1']); }); @@ -213,7 +236,10 @@ describe('ingress smoke tests', { skip, concurrency: 1 }, () => { const id = compose(site, ['ps', '-a', '-q', 'activitypub-migrate']).stdout.trim(); assert.ok(id, 'the one-shot migration container was not created'); assert.equal( - dockerInspect(id, '{{.State.Status}} {{.State.ExitCode}} {{.HostConfig.RestartPolicy.Name}}'), + dockerInspect( + id, + '{{.State.Status}} {{.State.ExitCode}} {{.HostConfig.RestartPolicy.Name}}', + ), 'exited 0 no', ); await sleep(10_000); @@ -221,12 +247,20 @@ describe('ingress smoke tests', { skip, concurrency: 1 }, () => { }); test('long-running services keep the site restart policy', () => { - assert.equal(dockerInspect(serviceId('ghost'), '{{.HostConfig.RestartPolicy.Name}}'), 'unless-stopped'); - assert.equal(dockerInspect(serviceId('db'), '{{.HostConfig.RestartPolicy.Name}}'), 'unless-stopped'); + assert.equal( + dockerInspect(serviceId('ghost'), '{{.HostConfig.RestartPolicy.Name}}'), + 'unless-stopped', + ); + assert.equal( + dockerInspect(serviceId('db'), '{{.HostConfig.RestartPolicy.Name}}'), + 'unless-stopped', + ); }); test('container logs are capped', () => { - const config = JSON.parse(dockerInspect(serviceId('ghost'), '{{json .HostConfig.LogConfig}}')); + const config = JSON.parse( + dockerInspect(serviceId('ghost'), '{{json .HostConfig.LogConfig}}'), + ); assert.ok(config.Config['max-size']); assert.ok(config.Config['max-file']); }); diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs index 0ece8dd0..a91da0ba 100644 --- a/tests/install-e2e.test.mjs +++ b/tests/install-e2e.test.mjs @@ -10,13 +10,29 @@ // real host ports, including 80 and 443. import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, symlinkSync, realpathSync } from 'node:fs'; +import { + existsSync, + statSync, + readFileSync, + writeFileSync, + mkdirSync, + symlinkSync, + realpathSync, +} from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { delimiter } from 'node:path'; import { - tempDir, cleanup, makeCandidateRelease, git, run, occupyPort, compose, - dockerAvailable, shOk, q, REPO_DIR, + tempDir, + cleanup, + makeCandidateRelease, + run, + occupyPort, + compose, + dockerAvailable, + shOk, + q, + REPO_DIR, } from './helpers.mjs'; const enabled = process.env.GD_TEST_INSTALL === '1' && dockerAvailable(); @@ -28,14 +44,22 @@ const CANDIDATE_TAG = 'v9.9.9-beta.1'; const PROXY_CONTAINER = 'ghost-docker-test-proxy'; let dir; -let repo; let repoUrl; const installed = new Set(); /** Clone the candidate release into a directory, as bootstrap.sh would. */ const clone = (name) => { const target = join(dir, name); - execFileSync('git', ['clone', '--quiet', '--depth', '1', '--branch', CANDIDATE_TAG, repoUrl, target]); + execFileSync('git', [ + 'clone', + '--quiet', + '--depth', + '1', + '--branch', + CANDIDATE_TAG, + repoUrl, + target, + ]); return target; }; @@ -55,13 +79,18 @@ const adminSite = async (port, host = 'localhost') => { }; const down = (site) => { - if (existsSync(join(site, '.env'))) compose(site, ['down', '-v', '--remove-orphans']); + if (existsSync(join(site, '.env'))) { + compose(site, ['down', '-v', '--remove-orphans']); + } }; // Sites are torn down together at the very end, not in each suite's own after: // two of the checks need an earlier suite's site still running alongside a // later one, so no site may be downed while a sibling suite is still asserting. -const track = (site) => { installed.add(site); return site; }; +const track = (site) => { + installed.add(site); + return site; +}; /** * Caddy issues from its own internal CA rather than attempting a real ACME @@ -76,10 +105,12 @@ const useInternalCerts = (site) => { describe('installing from a candidate release', { skip, concurrency: 1 }, () => { before(() => { dir = tempDir('install-e2e'); - ({ repo, url: repoUrl } = makeCandidateRelease(dir, ['v1.0.0', CANDIDATE_TAG])); + ({ url: repoUrl } = makeCandidateRelease(dir, ['v1.0.0', CANDIDATE_TAG])); }); after(() => { - for (const site of installed) down(site); + for (const site of installed) { + down(site); + } cleanup(dir); }); @@ -90,9 +121,11 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => }); test('bootstrap resolves the release, clones it and runs its installer', () => { - const result = run(join(REPO_DIR, 'bootstrap.sh'), + const result = run( + join(REPO_DIR, 'bootstrap.sh'), ['--channel', 'beta', '--dir', site, '--local', '--no-prompt'], - { env: { GD_BOOTSTRAP_REPO: repoUrl }, timeout: 900_000 }); + { env: { GD_BOOTSTRAP_REPO: repoUrl }, timeout: 900_000 }, + ); assert.equal(result.status, 0, result.output); assert.match(result.stdout, new RegExp(CANDIDATE_TAG.replace(/\./g, '\\.'))); assert.match(result.stdout, /Ghost is installed/); @@ -118,7 +151,11 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => // compare against the realpath, not the possibly-symlinked test path. assert.equal(recorded.site.dir, realpathSync(site)); assert.match(recorded.ghost.version, /^\d+\.\d+\.\d+$/); - assert.match(recorded.ghost.digest, /^sha256:[0-9a-f]{64}$/, 'no digest recorded for recovery'); + assert.match( + recorded.ghost.digest, + /^sha256:[0-9a-f]{64}$/, + 'no digest recorded for recovery', + ); assert.deepEqual(recorded.profiles, ['local']); }); @@ -130,8 +167,12 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => const config = JSON.parse(compose(site, ['config', '--format', 'json']).stdout); assert.equal(config.services.ghost.image, pin); const id = compose(site, ['ps', '-q', 'ghost']).stdout.trim(); - assert.equal(execFileSync('docker', ['inspect', '-f', '{{.Config.Image}}', id], - { encoding: 'utf8' }).trim(), pin); + assert.equal( + execFileSync('docker', ['inspect', '-f', '{{.Config.Image}}', id], { + encoding: 'utf8', + }).trim(), + pin, + ); const content = env(site, 'GHOST_CONTENT_PATH'); assert.ok(content.endsWith('/content'), content); assert.match(env(site, 'GHOST_TINYBIRD_PATH'), /\/core\/server\/data\/tinybird$/); @@ -153,11 +194,23 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => }); test('the site is published on the loopback interface only', () => { - const config = JSON.parse(compose(site, ['ps', '--format', 'json']).stdout.trim().split('\n')[0] ?? '{}'); + const config = JSON.parse( + compose(site, ['ps', '--format', 'json']).stdout.trim().split('\n')[0] ?? '{}', + ); assert.ok(config, 'no containers'); - const bindings = execFileSync('docker', ['inspect', '-f', '{{json .HostConfig.PortBindings}}', - compose(site, ['ps', '-q', 'ghost']).stdout.trim()], { encoding: 'utf8' }); - const hosts = Object.values(JSON.parse(bindings)).flat().map((b) => b.HostIp); + const bindings = execFileSync( + 'docker', + [ + 'inspect', + '-f', + '{{json .HostConfig.PortBindings}}', + compose(site, ['ps', '-q', 'ghost']).stdout.trim(), + ], + { encoding: 'utf8' }, + ); + const hosts = Object.values(JSON.parse(bindings)) + .flat() + .map((b) => b.HostIp); assert.deepEqual(hosts, ['127.0.0.1']); }); @@ -210,7 +263,9 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => release = await occupyPort(24771); }); after(async () => { - if (release) await release(); + if (release) { + await release(); + } }); // A port chosen by the installer moves out of the way; a port the operator @@ -231,13 +286,30 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => let site; before(() => { site = clone('existing-proxy'); - const image = readFileSync(join(REPO_DIR, 'compose.yml'), 'utf8') - .match(/image: (caddy:[^\s@]+@sha256:[0-9a-f]+)/)[1]; + const image = readFileSync(join(REPO_DIR, 'compose.yml'), 'utf8').match( + /image: (caddy:[^\s@]+@sha256:[0-9a-f]+)/, + )[1]; execFileSync('docker', ['rm', '-f', PROXY_CONTAINER], { stdio: 'ignore' }); - execFileSync('docker', [ - 'run', '-d', '--name', PROXY_CONTAINER, '-p', '80:80', '-p', '443:443', - image, 'caddy', 'respond', '--listen', ':80', 'the operator\'s own proxy', - ], { stdio: 'ignore' }); + execFileSync( + 'docker', + [ + 'run', + '-d', + '--name', + PROXY_CONTAINER, + '-p', + '80:80', + '-p', + '443:443', + image, + 'caddy', + 'respond', + '--listen', + ':80', + "the operator's own proxy", + ], + { stdio: 'ignore' }, + ); }); after(() => { execFileSync('docker', ['rm', '-f', PROXY_CONTAINER], { stdio: 'ignore' }); @@ -252,9 +324,12 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => assert.match(result.stderr, new RegExp(PROXY_CONTAINER)); assert.match(result.stderr, /Nothing was stopped/); - const running = execFileSync('docker', ['inspect', '-f', '{{.State.Running}}', PROXY_CONTAINER], - { encoding: 'utf8' }).trim(); - assert.equal(running, 'true', 'the operator\'s proxy was stopped'); + const running = execFileSync( + 'docker', + ['inspect', '-f', '{{.State.Running}}', PROXY_CONTAINER], + { encoding: 'utf8' }, + ).trim(); + assert.equal(running, 'true', "the operator's proxy was stopped"); assert.ok(!existsSync(join(site, '.env')), 'configuration was written anyway'); }); }); @@ -286,8 +361,14 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => test('the generated routes are installed, and the operator files are untouched', () => { const generated = readFileSync(join(site, 'caddy', 'sites', 'site.caddy'), 'utf8'); assert.match(generated, /^ghost\.test \{/m); - assert.match(generated, new RegExp(`reverse_proxy ghost-${env(site, 'COMPOSE_PROJECT_NAME')}:2368`)); - assert.equal(readFileSync(join(site, 'caddy', 'global', 'tls.caddy'), 'utf8'), 'local_certs\n'); + assert.match( + generated, + new RegExp(`reverse_proxy ghost-${env(site, 'COMPOSE_PROJECT_NAME')}:2368`), + ); + assert.equal( + readFileSync(join(site, 'caddy', 'global', 'tls.caddy'), 'utf8'), + 'local_certs\n', + ); }); // Node's fetch forbids overriding the Host header, so Caddy would see @@ -295,9 +376,15 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => // The installer's own curl helper sets it, which is what it verifies // with too, so drive the check through that rather than fetch. test('HTTP on the ingress port redirects to HTTPS for the site domain', () => { - const head = run('bash', ['-c', - `. ${JSON.stringify(join(site, 'scripts', 'lib', 'common.sh'))}\n` + - `install_http_head 127.0.0.1 ${env(site, 'HTTP_PORT')} / ghost.test`], { timeout: 60_000 }); + const head = run( + 'bash', + [ + '-c', + `. ${JSON.stringify(join(site, 'scripts', 'lib', 'common.sh'))}\n` + + `install_http_head 127.0.0.1 ${env(site, 'HTTP_PORT')} / ghost.test`, + ], + { timeout: 60_000 }, + ); assert.match(head.stdout, /^HTTP\/[0-9.]+ 3\d\d/m, head.output); assert.match(head.stdout, /^location: https:\/\/ghost\.test/im, head.output); }); @@ -308,9 +395,15 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => // legitimately run before DNS is pointed and no certificate can exist yet; // here the internal CA removes that variable, so it must actually work. test('Ghost Admin answers over HTTPS through Caddy', () => { - const status = run('bash', ['-c', - `. ${JSON.stringify(join(site, 'scripts', 'lib', 'common.sh'))}\n` + - `install_https_status ${JSON.stringify(site)} ghost.test`], { timeout: 180_000 }); + const status = run( + 'bash', + [ + '-c', + `. ${JSON.stringify(join(site, 'scripts', 'lib', 'common.sh'))}\n` + + `install_https_status ${JSON.stringify(site)} ghost.test`, + ], + { timeout: 180_000 }, + ); assert.equal(status.stdout.trim(), '200', status.output); }); }); @@ -363,8 +456,11 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => const missing = []; for (const tool of utilities) { const found = run('sh', ['-c', `command -v ${tool}`]).stdout.trim(); - if (!found) missing.push(tool); - else symlinkSync(found, join(bin, tool)); + if (!found) { + missing.push(tool); + } else { + symlinkSync(found, join(bin, tool)); + } } assert.deepEqual(missing, [], 'a declared utility is not installed on this host'); @@ -372,9 +468,11 @@ describe('installing from a candidate release', { skip, concurrency: 1 }, () => const found = run('sh', ['-c', `command -v ${command}`]).stdout.trim(); assert.ok(found, `${command} is not installed on this host`); const wrapper = join(bin, command); - writeFileSync(wrapper, + writeFileSync( + wrapper, `#!/bin/sh\nexec env PATH=${JSON.stringify(realPath)} ${JSON.stringify(found)} "$@"\n`, - { mode: 0o755 }); + { mode: 0o755 }, + ); } const result = install(site, ['--local', '--no-prompt', '--no-start'], { diff --git a/tests/install-probes.test.mjs b/tests/install-probes.test.mjs index 840f7662..3e1eaba9 100644 --- a/tests/install-probes.test.mjs +++ b/tests/install-probes.test.mjs @@ -24,12 +24,25 @@ test('HTTP probes preserve Host and status, ignoring curl config and proxies', a const port = server.address().port; // These settings would change the probe's result if curl loaded them. writeFileSync(join(dir, '.curlrc'), 'location\nfail\n'); - const probe = async (fn, path) => (await exec(process.env.GD_TEST_BASH || 'bash', ['-c', - `. ${q(join(REPO_DIR, 'scripts/lib/common.sh'))}\n${fn} 127.0.0.1 ${port} ${q(path)} ghost.test`, - ], { - env: { ...process.env, CURL_HOME: dir, http_proxy: 'http://127.0.0.1:1', ALL_PROXY: 'http://127.0.0.1:1' }, - timeout: 25_000, - })).stdout; + const probe = async (fn, path) => + ( + await exec( + process.env.GD_TEST_BASH || 'bash', + [ + '-c', + `. ${q(join(REPO_DIR, 'scripts/lib/common.sh'))}\n${fn} 127.0.0.1 ${port} ${q(path)} ghost.test`, + ], + { + env: { + ...process.env, + CURL_HOME: dir, + http_proxy: 'http://127.0.0.1:1', + ALL_PROXY: 'http://127.0.0.1:1', + }, + timeout: 25_000, + }, + ) + ).stdout; try { assert.equal((await probe('install_http_status', '/ok')).trim(), '200'); assert.equal((await probe('install_http_status', '/redirect')).trim(), '302'); diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 35f8523e..f6b47699 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -9,8 +9,18 @@ import assert from 'node:assert/strict'; import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { - tempDir, cleanup, copyWorktree, makeCandidateRelease, git, run, occupyPort, - sh, shOk, shSucceeds, q, REPO_DIR, + tempDir, + cleanup, + copyWorktree, + makeCandidateRelease, + git, + run, + occupyPort, + sh, + shOk, + shSucceeds, + q, + REPO_DIR, } from './helpers.mjs'; // A checkout to run install.sh from. Copied rather than used in place so a @@ -63,7 +73,11 @@ describe('install.sh options', () => { }); test('the final-phase flags are refused as unimplemented, not as unknown', () => { - for (const flag of [['--image-registry', 'ghcr'], ['--ghost-channel', 'nightly'], ['--without', 'redis']]) { + for (const flag of [ + ['--image-registry', 'ghcr'], + ['--ghost-channel', 'nightly'], + ['--without', 'redis'], + ]) { const result = install(site, ['--local', ...flag]); assert.equal(result.status, 3, flag[0]); assert.match(result.stderr, /not implemented yet/); @@ -218,8 +232,14 @@ describe('preflight', () => { describe('site identity and secrets', () => { test('a production project name is derived from the domain', () => { - assert.equal(shOk('install_project_name production Example.COM /tmp/x').trim(), 'ghost-example-com'); - assert.equal(shOk('install_project_name production blog.example.com /tmp/x').trim(), 'ghost-blog-example-com'); + assert.equal( + shOk('install_project_name production Example.COM /tmp/x').trim(), + 'ghost-example-com', + ); + assert.equal( + shOk('install_project_name production blog.example.com /tmp/x').trim(), + 'ghost-blog-example-com', + ); }); // Two local sites on one host share no state, so their identities must @@ -272,7 +292,10 @@ describe('Ghost version resolution', () => { const result = sh(fakeDocker, { env: { MOCK_DIGEST: digest } }); assert.equal(result.status, 0, result.stderr.toString()); assert.deepEqual(result.stdout.toString().trim().split('\t'), [ - '6-alpine', '6.3.1', digest, '/var/lib/ghost/content', + '6-alpine', + '6.3.1', + digest, + '/var/lib/ghost/content', '/var/lib/ghost/current/core/server/data/tinybird', ]); assert.equal(readFileSync(pulls, 'utf8').trim().split('\n').length, 1); @@ -294,12 +317,24 @@ describe('release selection', () => { // bootstrap.sh is sourced in library mode so that ordering can be tested // without cloning anything. const bootstrap = (script, env = {}) => - run('bash', ['-c', `GD_BOOTSTRAP_SOURCED=1 . ${JSON.stringify(join(REPO_DIR, 'bootstrap.sh'))}\n${script}`], { env }); + run( + 'bash', + [ + '-c', + `GD_BOOTSTRAP_SOURCED=1 . ${JSON.stringify(join(REPO_DIR, 'bootstrap.sh'))}\n${script}`, + ], + { env }, + ); before(() => { dir = tempDir('release'); ({ repo, url: repoUrl } = makeCandidateRelease(dir, [ - 'v1.9.0', 'v1.10.0', 'v1.11.0-beta.2', 'v1.11.0-beta.10', 'v0.1.0', 'not-a-release', + 'v1.9.0', + 'v1.10.0', + 'v1.11.0-beta.2', + 'v1.11.0-beta.10', + 'v0.1.0', + 'not-a-release', ])); }); after(() => cleanup(dir)); @@ -320,7 +355,8 @@ describe('release selection', () => { const config = join(dir, 'gitconfig'); writeFileSync(config, '[versionsort]\n suffix = -other\n suffix = \n suffix = -beta.\n'); const result = bootstrap('_latest_release beta', { - GD_BOOTSTRAP_REPO: repo, GIT_CONFIG_GLOBAL: config, + GD_BOOTSTRAP_REPO: repo, + GIT_CONFIG_GLOBAL: config, }); assert.equal(result.status, 0, result.output); assert.equal(result.stdout.trim(), 'v1.11.0'); @@ -349,10 +385,14 @@ describe('release selection', () => { const occupied = join(dir, 'occupied'); mkdirSync(occupied, { recursive: true }); writeFileSync(join(occupied, 'something'), 'x'); - const result = run(join(REPO_DIR, 'bootstrap.sh'), ['--dir', occupied, '--ref', 'v1.10.0', '--local'], { - env: { GD_BOOTSTRAP_REPO: repoUrl }, - timeout: 60_000, - }); + const result = run( + join(REPO_DIR, 'bootstrap.sh'), + ['--dir', occupied, '--ref', 'v1.10.0', '--local'], + { + env: { GD_BOOTSTRAP_REPO: repoUrl }, + timeout: 60_000, + }, + ); assert.notEqual(result.status, 0); // Checked before the daemon probe: a wrong directory should be reported // straight away, not after waiting on Docker. diff --git a/tests/legacy-migrate.test.mjs b/tests/legacy-migrate.test.mjs index 3ba9ebea..fe46e06c 100644 --- a/tests/legacy-migrate.test.mjs +++ b/tests/legacy-migrate.test.mjs @@ -18,7 +18,9 @@ import { tempDir, cleanup, sh, shValue, q, REPO_DIR } from './helpers.mjs'; // invariant — the helper migrate.sh calls exists and runs — instead of one // spelling of its filename. const MIGRATE = readFileSync(join(REPO_DIR, 'scripts', 'migrate.sh'), 'utf8'); -const REFERENCED = [...new Set([...MIGRATE.matchAll(/scripts\/(config-to-env\.[a-z]+)/g)].map((m) => m[1]))]; +const REFERENCED = [ + ...new Set([...MIGRATE.matchAll(/scripts\/(config-to-env\.[a-z]+)/g)].map((m) => m[1])), +]; const SCRIPT = join(REPO_DIR, 'scripts', REFERENCED[0] ?? 'config-to-env.js'); describe('legacy migration helper', () => { @@ -55,14 +57,21 @@ describe('legacy migration helper', () => { const ghostEnv = join(dir, 'ghost.env'); writeFileSync(ghostEnv, out); assert.equal(shValue(`env_get ${q(ghostEnv)} mail__options__auth__pass`).trim(), 'p$ss"word'); - assert.ok(sh(`env_lint ${q(ghostEnv)}`).status === 0, 'generated ghost.env fails its own lint'); + assert.ok( + sh(`env_lint ${q(ghostEnv)}`).status === 0, + 'generated ghost.env fails its own lint', + ); } finally { cleanup(dir); } }); test('migrate.sh calls exactly one helper, and it exists', () => { - assert.equal(REFERENCED.length, 1, `migrate.sh references ${REFERENCED.length} helpers: ${REFERENCED}`); + assert.equal( + REFERENCED.length, + 1, + `migrate.sh references ${REFERENCED.length} helpers: ${REFERENCED}`, + ); assert.ok(existsSync(SCRIPT), `migrate.sh calls ${REFERENCED[0]}, which does not exist`); }); }); diff --git a/tests/recovery-e2e.test.mjs b/tests/recovery-e2e.test.mjs new file mode 100644 index 00000000..207604d8 --- /dev/null +++ b/tests/recovery-e2e.test.mjs @@ -0,0 +1,282 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync, spawn } from 'node:child_process'; +import { + copyWorktree, + tempDir, + cleanup, + run, + compose, + dockerAvailable, + shOk, + q, + REPO_DIR, +} from './helpers.mjs'; + +const enabled = process.env.GD_TEST_RECOVERY === '1' && dockerAvailable(); +for (const mode of ['local', 'production', 'activitypub']) { + test( + `checkpoint and isolated restore of a real Ghost site (${mode})`, + { skip: enabled ? false : 'set GD_TEST_RECOVERY=1 with Docker', timeout: 1_800_000 }, + async () => { + const dir = fs.realpathSync(tempDir('recovery-e2e')); + const source = copyWorktree(path.join(dir, 'source')); + const dest = copyWorktree(path.join(dir, 'destination')); + const literal = 'literal $ # and quotes \'"\\\nnext line'; + const result = (r) => { + assert.equal(r.status, 0, r.output || `${r.stdout}\n${r.stderr}`); + return r; + }; + const envGet = (site, key) => shOk(`env_get ${q(`${site}/.env`)} ${q(key)}`).trim(); + const manager = execFileSync( + 'docker', + ['build', '-q', '-f', `${REPO_DIR}/manager/Dockerfile`, REPO_DIR], + { encoding: 'utf8' }, + ).trim(); + const recover = (site, args) => + run(`${site}/scripts/recovery.sh`, args, { + cwd: site, + env: { GD_MANAGER_IMAGE: manager }, + timeout: 900_000, + }); + const interrupt = async (target, argv, atPhase) => { + const child = spawn(`${target}/scripts/recovery.sh`, argv, { + cwd: target, + env: { ...process.env, GD_MANAGER_IMAGE: manager }, + }); + let output = ''; + child.stdout.on('data', (b) => { + output += b; + }); + child.stderr.on('data', (b) => { + output += b; + }); + const exit = new Promise((resolve) => child.on('exit', resolve)); + const deadline = Date.now() + 600_000; + let killed = false; + while (child.exitCode === null && Date.now() < deadline) { + try { + const record = JSON.parse(fs.readFileSync(`${target}/.ghost-operation.json`)); + if (record.phase === atPhase) { + const name = fs + .readFileSync(`${target}/.ghost-operation-lock/manager`, 'utf8') + .trim(); + if (atPhase === 'snapshotting') { + child.kill('SIGKILL'); + await exit; + const blocked = recover(target, ['recover']); + assert.notEqual(blocked.status, 0, blocked.output); + assert.match(blocked.output, /manager is still running/); + } + execFileSync('docker', ['kill', '--signal=KILL', name], { stdio: 'pipe' }); + killed = true; + break; + } + } catch (error) { + if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!killed) { + child.kill(); + } + assert.equal(killed, true, `Did not reach ${atPhase}: ${output}`); + assert.notEqual(await exit, 0, output); + assert.equal(fs.existsSync(`${target}/.ghost-operation.json`), true); + }; + try { + if (mode === 'production') { + fs.writeFileSync(`${source}/caddy/global/tls.caddy`, 'local_certs\n'); + } + const modeArgs = + mode === 'production' + ? ['--domain', 's4-recovery.test'] + : ['--local', ...(mode === 'activitypub' ? ['--with', 'activitypub'] : [])]; + result( + run( + `${source}/install.sh`, + [...modeArgs, '--no-prompt', '--version', '6.62.0-next-alpine'], + { cwd: source, timeout: 900_000 }, + ), + ); + // A database value and hidden asset make this more than a readiness test. + const sql = (site, query) => + compose( + site, + [ + 'exec', + '-T', + '-e', + 'MYSQL_PWD', + 'db', + 'mysql', + '-uroot', + '-N', + '-B', + 'ghost', + '-e', + query, + ], + { env: { MYSQL_PWD: envGet(site, 'DATABASE_ROOT_PASSWORD') } }, + ); + if (mode === 'activitypub') { + result( + sql( + source, + "CREATE TABLE activitypub.s4_recovery_fixture (value VARCHAR(64)); INSERT INTO activitypub.s4_recovery_fixture VALUES ('federation state')", + ), + ); + } + result(sql(source, "UPDATE settings SET value='Recovery drill title' WHERE `key`='title'")); + const asset = path.join(source, 'data/ghost/images/.recovery-drill'); + result( + compose(source, [ + 'exec', + '-T', + 'ghost', + 'node', + '-e', + 'require("fs").writeFileSync(process.env.paths__contentPath + "/images/.recovery-drill", "hidden asset survives a checkpoint\\n")', + ]), + ); + result( + run( + `${source}/scripts/config.sh`, + ['set', `${source}/ghost.env`, 'mail__from', 'Recovery '], + { cwd: source }, + ), + ); + result( + run( + `${source}/scripts/config.sh`, + ['set', `${source}/ghost.env`, 's4__literal', literal], + { cwd: source }, + ), + ); + await interrupt(source, ['backup'], 'snapshotting'); + result(recover(source, ['recover'])); + assert.match(compose(source, ['ps', '--status', 'running', '--services']).stdout, /ghost/); + result(recover(source, ['backup', '--keep', '2'])); + const checkpoint = path.join( + source, + '.ghost-backups', + fs.readdirSync(path.join(source, '.ghost-backups')).find((n) => !n.startsWith('.')), + ); + assert.equal(fs.statSync(checkpoint).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(checkpoint, 'payload/config/.env')).isFile(), true); + const expectedTheme = result( + sql(source, "SELECT value FROM settings WHERE `key`='active_theme'"), + ).stdout; + if (mode !== 'local') { + result(compose(source, ['down'])); + } + const restoreArgs = mode === 'local' ? ['--local', '--port', '24879'] : []; + await interrupt( + dest, + ['restore', checkpoint, '--project', `gd-restore-${process.pid}`, ...restoreArgs], + 'verifying', + ); + assert.equal( + compose(dest, ['ps', '--status', 'running', '--services']).stdout.trim(), + 'db', + ); + result(recover(dest, ['recover'])); + const journal = JSON.parse(fs.readFileSync(`${dest}/.ghost-operation.json`)); + assert.equal(journal.phase, 'verified'); + assert.equal( + fs.readFileSync(`${dest}/data/ghost/images/.recovery-drill`, 'utf8'), + fs.readFileSync(asset, 'utf8'), + ); + assert.equal( + result(sql(dest, "SELECT value FROM settings WHERE `key`='title'")).stdout.trim(), + 'Recovery drill title', + ); + assert.equal( + result(sql(dest, "SELECT value FROM settings WHERE `key`='active_theme'")).stdout, + expectedTheme, + ); + if (mode === 'activitypub') { + assert.equal( + result(sql(dest, 'SELECT value FROM activitypub.s4_recovery_fixture')).stdout.trim(), + 'federation state', + ); + } + assert.equal(envGet(dest, 'PROJECT_DIR'), dest); + assert.match(fs.readFileSync(`${dest}/ghost.env`, 'utf8'), /Recovery/); + assert.equal( + compose(dest, ['ps', '--status', 'running', '--services']).stdout.trim(), + 'db', + ); + result(recover(dest, ['activate'])); + assert.equal(fs.existsSync(`${dest}/.ghost-operation.json`), false); + const actualLiteral = result( + compose(dest, [ + 'exec', + '-T', + 'ghost', + 'node', + '-e', + 'console.log(JSON.stringify(process.env.s4__literal))', + ]), + ).stdout.trim(); + assert.equal(JSON.parse(actualLiteral), literal); + assert.equal( + envGet(dest, 'RESTART_POLICY'), + mode === 'production' ? 'unless-stopped' : 'no', + ); + if (mode !== 'production') { + const response = await fetch( + `http://127.0.0.1:${envGet(dest, 'GHOST_PORT')}/ghost/api/admin/site/`, + ); + assert.equal(response.status, 200); + } else { + const response = run('curl', [ + '--noproxy', + '*', + '-ksS', + '--resolve', + 's4-recovery.test:443:127.0.0.1', + '-o', + '/dev/null', + '-w', + '%{http_code}', + 'https://s4-recovery.test/ghost/api/admin/site/', + ]); + assert.equal(result(response).stdout, '200'); + } + } finally { + for (const site of [source, dest]) { + if (fs.existsSync(`${site}/.env`)) { + compose(site, ['down', '-v', '--remove-orphans']); + } + } + if (process.env.GD_KEEP_RECOVERY_TEST !== '1') { + execFileSync( + 'docker', + [ + 'run', + '--rm', + '--entrypoint', + 'sh', + '--mount', + `type=bind,source=${dir},target=/fixture`, + manager, + '-c', + 'chown -R "$1" /fixture; chmod -R u+rwX /fixture', + '--', + `${process.getuid()}:${process.getgid()}`, + ], + { stdio: 'pipe' }, + ); + cleanup(dir); + } else { + console.log(`Retained fixture: ${dir}`); + } + } + }, + ); +} diff --git a/tests/recovery.test.mjs b/tests/recovery.test.mjs new file mode 100644 index 00000000..fc42e24d --- /dev/null +++ b/tests/recovery.test.mjs @@ -0,0 +1,195 @@ +import { test } from 'node:test'; +import { run as runProcess } from '../manager/process.ts'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { + durableJSON, + inventory, + copyTree, + privateDirectory, + verifyCheckpoint, + checkSpace, + retainCompleted, +} from '../manager/storage.ts'; +import { tempDir, cleanup, sh, shOk, q } from './helpers.mjs'; + +test('checkpoint copies preserve directory permissions under the manager umask', () => { + const dir = tempDir(); + const source = path.join(dir, 'mysql-init'); + const checkpoint = path.join(dir, 'checkpoint'); + const restored = path.join(dir, 'restored'); + const previousMask = process.umask(0o077); + try { + fs.mkdirSync(source); + fs.chmodSync(source, 0o755); + fs.mkdirSync(path.join(source, 'nested')); + fs.chmodSync(path.join(source, 'nested'), 0o750); + fs.writeFileSync(path.join(source, 'init.sh'), '#!/bin/sh\n'); + fs.chmodSync(path.join(source, 'init.sh'), 0o755); + privateDirectory(checkpoint); + copyTree(source, path.join(checkpoint, 'mysql-init')); + copyTree(path.join(checkpoint, 'mysql-init'), restored); + for (const copied of [path.join(checkpoint, 'mysql-init'), restored]) { + assert.equal(fs.statSync(copied).mode & 0o777, 0o755); + assert.equal(fs.statSync(path.join(copied, 'nested')).mode & 0o777, 0o750); + assert.equal(fs.statSync(path.join(copied, 'init.sh')).mode & 0o777, 0o755); + } + assert.equal(fs.statSync(checkpoint).mode & 0o777, 0o700); + } finally { + process.umask(previousMask); + cleanup(dir); + } +}); + +test('operation lock excludes another live process and recover cannot steal it', async () => { + const dir = tempDir(); + const child = spawn( + process.env.GD_TEST_BASH || 'bash', + ['-c', `. scripts/lib/operation.sh; operation_acquire ${q(dir)}; echo ready; read -r done`], + { stdio: ['pipe', 'pipe', 'pipe'] }, + ); + try { + await once(child.stdout, 'data'); + assert.notEqual(sh(`operation_acquire ${q(dir)}`).status, 0); + assert.notEqual(sh(`operation_acquire ${q(dir)} true`).status, 0); + const ended = once(child, 'exit'); + child.stdin.end('\n'); + await ended; + assert.notEqual(sh(`operation_acquire ${q(dir)}`).status, 0); + shOk(`operation_acquire ${q(dir)} true; operation_release`); + assert.equal(fs.existsSync(path.join(dir, '.ghost-operation-lock')), false); + } finally { + child.kill(); + cleanup(dir); + } +}); + +test('unfinished journals block configuration writes and normal operations', () => { + const dir = tempDir(); + try { + fs.writeFileSync(path.join(dir, '.ghost-operation.json'), '{}'); + assert.notEqual(sh(`operation_acquire ${q(dir)}`).status, 0); + assert.equal(sh(`operation_acquire ${q(dir)} true; operation_release`).status, 0); + } finally { + cleanup(dir); + } +}); + +test('checkpoint validation detects corruption, missing files and forbidden links', () => { + const dir = tempDir(); + try { + const payload = path.join(dir, 'payload'); + fs.mkdirSync(payload); + fs.writeFileSync(path.join(payload, '.hidden'), 'asset'); + fs.mkdirSync(path.join(payload, 'empty')); + durableJSON(path.join(dir, 'manifest.json'), { + format: 'ghost-docker-recovery', + version: 1, + files: inventory(payload), + }); + verifyCheckpoint(dir); + fs.writeFileSync(path.join(payload, '.hidden'), 'corrupt'); + assert.throws(() => verifyCheckpoint(dir), /checksum/); + fs.symlinkSync('/etc/passwd', path.join(payload, 'escape')); + assert.throws(() => inventory(payload), /link/); + assert.throws(() => copyTree(payload, path.join(dir, 'copy')), /link/); + } finally { + cleanup(dir); + } +}); + +test('disk exhaustion leaves the previous durable journal readable', () => { + const dir = tempDir(); + const file = path.join(dir, 'journal.json'); + const original = fs.writeFileSync; + try { + durableJSON(file, { phase: 'before' }); + fs.writeFileSync = () => { + throw Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + }; + assert.throws(() => durableJSON(file, { phase: 'after' }), /disk full/); + assert.deepEqual(JSON.parse(fs.readFileSync(file)), { phase: 'before' }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + assert.throws(() => checkSpace(dir, Number.MAX_SAFE_INTEGER), /free space/); + } finally { + fs.writeFileSync = original; + cleanup(dir); + } +}); + +test('retention preserves incomplete and corrupt checkpoints', () => { + const dir = tempDir(); + try { + const ids = [ + '2025-a-00000000-0000-0000-0000-000000000000', + '2026-a-00000000-0000-0000-0000-000000000000', + ]; + for (const id of ids) { + fs.mkdirSync(path.join(dir, id, 'payload'), { recursive: true }); + durableJSON(path.join(dir, id, 'manifest.json'), { + format: 'ghost-docker-recovery', + version: 1, + files: [], + }); + } + fs.mkdirSync(path.join(dir, '.partial-interrupted')); + retainCompleted(dir, 1, ids[1]); + assert.equal(fs.existsSync(path.join(dir, ids[0])), false); + assert.equal(fs.existsSync(path.join(dir, ids[1])), true); + assert.equal(fs.existsSync(path.join(dir, '.partial-interrupted')), true); + } finally { + cleanup(dir); + } +}); + +test('SQL streaming propagates a producer or consumer failure despite partial output', async () => { + const dir = tempDir(); + try { + const dump = path.join(dir, 'database.sql'); + await assert.rejects( + runProcess('sh', ['-c', 'printf "partial SQL"; exit 17'], { output: dump }), + /exit 17/, + ); + assert.equal(fs.readFileSync(dump, 'utf8'), 'partial SQL'); + await assert.rejects( + runProcess('sh', ['-c', 'cat >/dev/null; exit 19'], { input: dump }), + /exit 19/, + ); + assert.equal(fs.existsSync(path.join(dir, 'manifest.json')), false); + } finally { + cleanup(dir); + } +}); + +test('a manager lock cannot be reclaimed through a different Docker daemon', () => { + const dir = tempDir(); + try { + const lock = path.join(dir, '.ghost-operation-lock'); + fs.mkdirSync(lock); + fs.writeFileSync(path.join(lock, 'pid'), '2147483647'); + fs.writeFileSync(path.join(lock, 'manager'), 'still-working'); + fs.writeFileSync(path.join(lock, 'daemon'), 'daemon-a'); + const result = sh(`docker() { printf daemon-b; }; operation_acquire ${q(dir)} true`); + assert.notEqual(result.status, 0); + assert.match(result.stderr.toString(), /daemon that owns/); + assert.equal(fs.readFileSync(path.join(lock, 'manager'), 'utf8'), 'still-working'); + } finally { + cleanup(dir); + } +}); + +test('journal publication racing with lock acquisition still blocks mutation', () => { + const dir = tempDir(); + try { + const result = sh( + `mkdir() { command mkdir "$@" && printf '{}' >${q(path.join(dir, '.ghost-operation.json'))}; }; operation_acquire ${q(dir)}`, + ); + assert.notEqual(result.status, 0); + assert.equal(fs.existsSync(path.join(dir, '.ghost-operation-lock')), false); + } finally { + cleanup(dir); + } +});