All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
The operator-level extra beside the admin-configured Nextcloud target (#103), unblocked now that the ONE→BASEL tunnel is stable again. - sidecar: optional mirror step (mirror.ts) driven purely by env — BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private key on the secrets volume, never in image or repo), BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run, so --delete aligns the remote retention with the local one (the newest-complete-set guarantee carries over). Only set files travel (db-*.dump, files-*.tar.gz); status files and bundles stay local. Host key pinned via accept-new into .mirror_known_hosts on the backups volume; fixed remote modes (dirs 750, files 640, symbolic --chmod — octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync + openssh-client added to the sidecar image. - status: additive `mirror` block in status.json (outcome, transferred count, lastSuccessAt carried across failures) — shown on the admin backup card; failures alert via a new backupMirrorFailed mail (de+en) while the local run still counts as succeeded. - deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated user dorfteich-backup with a /home/ home and a bash login shell, explicitly avoiding the Debian backup-user (UID 34) pitfalls (nologin shell rejects rsync sessions, /var/backups home), key placement through the api container onto the secrets volume, .env values, on-demand verification. - tests: rsync-arg/stats-parsing units plus an integration suite against the real rsync binary (local target; skips where rsync is absent) — transfer, idempotent re-run (0 files), retention alignment, failure path carrying lastSuccessAt. Verified live against the real BASEL host from a native sidecar run: initial transfer, host-key pinning, retention alignment after a local prune, idempotency, and the failure path (surfaced in status.json while the local run stayed green). BASEL side provisioned per the doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
206 lines
9.1 KiB
TypeScript
206 lines
9.1 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Environment schemas live here so api, collab, and tooling validate their
|
|
* configuration the same way. Every service calls `parseEnv` once at startup
|
|
* and crashes with a readable list of problems instead of failing later at
|
|
* first use.
|
|
*/
|
|
/** Fields every service configures the same way; keep these in sync. */
|
|
const nodeEnv = z.enum(['development', 'test', 'production']).default('development');
|
|
const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info');
|
|
/** Version shown in health responses; injected at image build time. */
|
|
const appVersion = z.string().default('0.0.0-dev');
|
|
/** PostgreSQL connection string — required, there is no sensible default. */
|
|
const databaseUrl = z
|
|
.string()
|
|
.min(1)
|
|
.refine((url) => url.startsWith('postgresql://') || url.startsWith('postgres://'), {
|
|
message: 'must be a postgresql:// connection string',
|
|
});
|
|
/**
|
|
* Symmetric secret shared by the api (which signs) and the collab server
|
|
* (which verifies) for the short-lived collaboration tokens (issue #34,
|
|
* ADR 0007). The dev default only keeps native dev/test/CI running without
|
|
* extra setup; every real instance MUST set its own identical value in the
|
|
* `.env` of both services (the stage setup and the M8 wizard do this).
|
|
*/
|
|
const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me');
|
|
|
|
/**
|
|
* SMTP delivery fields, shared by the api (transactional mail) and the
|
|
* backup sidecar (failure alert mail, issue #83). Defaults match the
|
|
* Mailpit container from the dev overlay; production instances configure
|
|
* their real relay in the stage `.env` or through the M8 setup wizard,
|
|
* whose secret store both services overlay the same way.
|
|
*/
|
|
const smtpFields = {
|
|
SMTP_HOST: z.string().default('localhost'),
|
|
SMTP_PORT: z.coerce.number().int().default(1025),
|
|
SMTP_SECURE: z
|
|
.enum(['true', 'false'])
|
|
.default('false')
|
|
.transform((value) => value === 'true'),
|
|
SMTP_USER: z.string().optional(),
|
|
SMTP_PASS: z.string().optional(),
|
|
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
|
};
|
|
|
|
export const apiEnvSchema = z.object({
|
|
NODE_ENV: nodeEnv,
|
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
LOG_LEVEL: logLevel,
|
|
APP_VERSION: appVersion,
|
|
DATABASE_URL: databaseUrl,
|
|
COLLAB_TOKEN_SECRET: collabTokenSecret,
|
|
/** Set to "false" to skip `prisma migrate deploy` at startup (tests, tooling). */
|
|
MIGRATE_ON_START: z
|
|
.enum(['true', 'false'])
|
|
.default('true')
|
|
.transform((value) => value === 'true'),
|
|
/** Public base URL of this instance — used in e-mail links. */
|
|
APP_BASE_URL: z.string().url().default('http://localhost:5173'),
|
|
...smtpFields,
|
|
/**
|
|
* Filesystem root for uploaded files (ADR 0011). The compose stack
|
|
* mounts the `uploads` volume at `/data/uploads` and sets this
|
|
* explicitly; the relative default only serves native (non-Docker)
|
|
* dev/test runs.
|
|
*/
|
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
|
/**
|
|
* Base URL of the internal pandoc-server sidecar (ADR 0009, issue #62).
|
|
* The default matches the compose service name; native dev/test runs point
|
|
* it at a locally running container or leave it unreachable (the converter
|
|
* readiness check is warning-level, so an unset sidecar never fails readyz).
|
|
*/
|
|
PANDOC_URL: z.string().url().default('http://pandoc:3030'),
|
|
/**
|
|
* Base URL of the internal Gotenberg sidecar for PDF export (ADR 0009, issue
|
|
* #67). Like the converter, its readiness check is warning-level, so an
|
|
* unreachable renderer degrades PDF export without failing readyz. The default
|
|
* matches the compose service name.
|
|
*/
|
|
GOTENBERG_URL: z.string().url().default('http://gotenberg:3000'),
|
|
/**
|
|
* Directory of the self-hosted font catalog (WOFF2, ADR 0016), baked into the
|
|
* api image so the PDF exporter can inline a pond's fonts as base64. Native
|
|
* dev/test runs point this at the web app's built `public/fonts`.
|
|
*/
|
|
FONTS_DIR: z.string().min(1).default('./fonts'),
|
|
/**
|
|
* Directory holding installed plugin packages (ADR 0008, issue #71). Layout
|
|
* `<PLUGINS_DIR>/<id>/<version>/…` for unpacked bundles the sandbox iframe
|
|
* loads, plus a `_dropzone/` a Site Admin drops ZIPs into and a
|
|
* `_quarantine/` for rejected drops. In Docker a persistent volume mounts
|
|
* here; the relative default serves native dev/test runs.
|
|
*/
|
|
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
|
/**
|
|
* The backup sidecar's volume with the restore sets and `status.json`
|
|
* (ADR 0015). The api mounts it read-only and only consumes the status
|
|
* file (readyz freshness, issue #85; admin backup card, issue #86).
|
|
*/
|
|
BACKUPS_DIR: z.string().min(1).default('./data/backups'),
|
|
/**
|
|
* Env-backed secret store (security.md §Secrets, issue #80): a mode-600
|
|
* dotenv-style file on a persistent volume where the setup wizard writes
|
|
* secrets entered in the browser (currently the SMTP configuration).
|
|
* Values from this file fill environment variables that are NOT set on
|
|
* the process — explicit container env always wins, so operators can
|
|
* override a broken wizard entry from the stage `.env`.
|
|
*/
|
|
SECRETS_FILE: z.string().min(1).default('./data/secrets.env'),
|
|
/**
|
|
* First-run pre-seeding (issue #80): when the api boots against a database
|
|
* that still requires setup and all three SETUP_ADMIN_* values are set, it
|
|
* creates the Site Admin, applies the optional instance values below, and
|
|
* completes (locks) the wizard — automated deploys never see it.
|
|
*/
|
|
SETUP_ADMIN_USERNAME: z.string().optional(),
|
|
SETUP_ADMIN_EMAIL: z.string().optional(),
|
|
SETUP_ADMIN_PASSWORD: z.string().optional(),
|
|
SETUP_ADMIN_DISPLAY_NAME: z.string().optional(),
|
|
SETUP_INSTANCE_NAME: z.string().optional(),
|
|
SETUP_DEFAULT_LOCALE: z.enum(['de', 'en']).optional(),
|
|
SETUP_REGISTRATION_MODE: z.enum(['open', 'closed']).optional(),
|
|
});
|
|
|
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
|
|
|
/**
|
|
* Configuration for the collaboration server (Hocuspocus, ADR 0003). It is a
|
|
* thin real-time front-end to the same PostgreSQL database as the api; it does
|
|
* not run migrations (the api owns the schema) and needs no SMTP or uploads.
|
|
*/
|
|
export const collabEnvSchema = z.object({
|
|
NODE_ENV: nodeEnv,
|
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
LOG_LEVEL: logLevel,
|
|
APP_VERSION: appVersion,
|
|
DATABASE_URL: databaseUrl,
|
|
COLLAB_TOKEN_SECRET: collabTokenSecret,
|
|
});
|
|
|
|
export type CollabEnv = z.infer<typeof collabEnvSchema>;
|
|
|
|
/**
|
|
* Configuration for the backup sidecar (ADR 0015, issue #83). It talks to
|
|
* the same database and mounts the same data volumes as the api, plus its
|
|
* own `backups` volume for the nightly restore sets and `status.json`.
|
|
*/
|
|
export const backupEnvSchema = z.object({
|
|
NODE_ENV: nodeEnv,
|
|
LOG_LEVEL: logLevel,
|
|
APP_VERSION: appVersion,
|
|
DATABASE_URL: databaseUrl,
|
|
/** Where restore sets and `status.json` are written (the `backups` volume). */
|
|
BACKUPS_DIR: z.string().min(1).default('./data/backups'),
|
|
/** Same mounts as the api — archived together as one restore set. */
|
|
UPLOADS_DIR: z.string().min(1).default('./data/uploads'),
|
|
PLUGINS_DIR: z.string().min(1).default('./data/plugins'),
|
|
/** Daily run time as HH:MM, interpreted in the container's TZ. */
|
|
BACKUP_TIME: z
|
|
.string()
|
|
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'must be HH:MM (24h)')
|
|
.default('03:00'),
|
|
/** Local retention in days: 30 for Prod, 7 for Test/Int (ADR 0015). */
|
|
BACKUP_RETENTION_DAYS: z.coerce.number().int().min(1).default(30),
|
|
/** Failure-alert recipient; unset disables the mail (logged instead). */
|
|
BACKUP_MAIL_TO: z.string().optional(),
|
|
/** Language of the failure mail (ADR 0012 — both exist, operator picks). */
|
|
BACKUP_MAIL_LOCALE: z.enum(['de', 'en']).default('en'),
|
|
/** Instance label in the mail subject, e.g. "dorfteich-test". */
|
|
BACKUP_INSTANCE_LABEL: z.string().optional(),
|
|
/**
|
|
* Optional rsync mirror to a private host (issue #84, ADR 0015), e.g.
|
|
* `dorfteich-backup@172.30.1.10:/home/RAID/BACKUPS/dorfteich-prod/`.
|
|
* Unset disables the mirror entirely. Deliberately env-only (operator
|
|
* territory), unlike the admin-configured Nextcloud target (#103).
|
|
*/
|
|
BACKUP_MIRROR_TARGET: z.string().optional(),
|
|
/** Private SSH key file for the mirror; mount it via the secrets volume
|
|
* (e.g. `/data/secrets/basel_ed25519`), mode 600, never in the image. */
|
|
BACKUP_MIRROR_SSH_KEY: z.string().optional(),
|
|
BACKUP_MIRROR_SSH_PORT: z.coerce.number().int().min(1).max(65535).default(22),
|
|
...smtpFields,
|
|
/** Read-only view of the wizard-written secret store (issue #80). */
|
|
SECRETS_FILE: z.string().min(1).default('./data/secrets.env'),
|
|
});
|
|
|
|
export type BackupEnv = z.infer<typeof backupEnvSchema>;
|
|
|
|
export function parseEnv<Schema extends z.ZodTypeAny>(
|
|
schema: Schema,
|
|
env: Record<string, string | undefined>,
|
|
): z.infer<Schema> {
|
|
const result = schema.safeParse(env);
|
|
if (!result.success) {
|
|
const problems = result.error.issues
|
|
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
.join('\n');
|
|
throw new Error(`Invalid environment configuration:\n${problems}`);
|
|
}
|
|
return result.data;
|
|
}
|