@starci/hfs 4.12.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -0,0 +1,182 @@
1
+ // journal.mjs - the record of every deploy attempt on the manager, and the lock that serialises attempts.
2
+ // A deploy (CI, dispatch or the owner's urgent one), a rollback and an accept each run as one ATTEMPT:
3
+ // 1. the journal is opened: /srv/<stack>/deploys/<attempt>.json (the document, rewritten at every phase boundary, so a cancelled job still leaves its
4
+ // last phase) and one line in the append-only /srv/<stack>/deploys.jsonl; a journal that cannot be written refuses the attempt before anything changed;
5
+ // 2. the deploy lock /srv/<stack>/deploy.lock is taken (a phase of the journal, `lock`): the wait is bounded and a held lock is a refusal
6
+ // `STACK_DEPLOY_LOCKED` with nothing changed; the lock is refreshed at every phase start and released last;
7
+ // 3. the body runs, every phase through `phase()`: start, end and verdict are written, a thrown step is recorded failed and rethrown;
8
+ // 4. the outcome (accepted, pending, rolled-back, rollback-failed, failed, refused, already-current) closes the document.
9
+ // The document holds names, digests, times and verdicts, never a secret value. After the first write a failing journal never fails the deploy: the failure is
10
+ // a line of the result (`warnings`).
11
+ import { randomBytes } from 'node:crypto';
12
+ import { firstLine } from './context.mjs';
13
+ import { StackError, refusal } from './errors.mjs';
14
+ const REASON_MAX = 500;
15
+ const SAFE = /[^A-Za-z0-9._/:@ -]/gu;
16
+ const isoOf = (ctx) => new Date(ctx.now()).toISOString();
17
+ const compact = (iso) => iso.replaceAll(/[-:]|\.\d+/gu, '');
18
+ const clean = (value, max) => value.replaceAll(SAFE, '?').slice(0, max);
19
+ /** A new attempt id: the UTC time and 8 random hex characters. */
20
+ export const newAttemptId = (ctx) => `${compact(isoOf(ctx))}-${randomBytes(4).toString('hex')}`;
21
+ /** The CI facts of the runner environment: the tag of a tag run and the run's URL. Nothing else of the environment is read. */
22
+ function ciFacts(ctx, run) {
23
+ const { GITHUB_REF_TYPE: refType, GITHUB_REF_NAME: ref, GITHUB_SERVER_URL: server, GITHUB_REPOSITORY: repository } = ctx.env;
24
+ const tag = refType === 'tag' && ref ? clean(ref, 100) : null;
25
+ if (!run)
26
+ return { tag, ci: null };
27
+ const url = server && repository && /^https:\/\/[A-Za-z0-9.-]+$/u.test(server) && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(repository) ? `${server}/${repository}/actions/runs/${run}` : null;
28
+ return { tag, ci: { run, url } };
29
+ }
30
+ /**
31
+ * Opens the journal of an attempt and returns its recorder. Writes the first document and the index line; a failure of either is the refusal
32
+ * `STACK_DEPLOY_JOURNAL` (the manager cannot keep a record, so nothing is changed).
33
+ */
34
+ export async function openAttempt(ctx, config, store, meta) {
35
+ const id = newAttemptId(ctx);
36
+ const journal = store.journal(id);
37
+ const facts = ciFacts(ctx, meta.ciRun);
38
+ const document = {
39
+ schema: 'starci/stack-deploy-attempt@1', attempt: id, kind: meta.kind, stack: config.stack, env: config.env, startedAt: isoOf(ctx), endedAt: null,
40
+ trigger: meta.trigger, sourceSha: meta.sourceSha ?? null, tag: facts.tag, ci: facts.ci,
41
+ urgent: meta.urgent ? { role: clean(meta.urgent.role, 40), reason: meta.urgent.reason.slice(0, REASON_MAX), tag: meta.urgent.tag ?? null } : null,
42
+ releaseId: null, previous: null, phases: [], backup: null, migrations: [], observed: {}, outcome: 'running', error: null,
43
+ };
44
+ const warnings = [];
45
+ let beat = async () => { };
46
+ const save = async () => {
47
+ try {
48
+ await journal.write(document);
49
+ }
50
+ catch (error) {
51
+ warnings.push(`journal: could not write ${id}: ${firstLine(error.message)}`);
52
+ }
53
+ };
54
+ try {
55
+ await journal.write(document);
56
+ await journal.index({ attempt: id, at: document.startedAt, event: 'start', kind: meta.kind, trigger: meta.trigger, sourceSha: document.sourceSha });
57
+ }
58
+ catch (error) {
59
+ throw refusal('STACK_DEPLOY_JOURNAL', `the manager cannot keep the deploy journal (${firstLine(error.message)}); nothing was changed`);
60
+ }
61
+ const recorder = {
62
+ id, warnings,
63
+ beforePhase: (next) => { beat = next; },
64
+ note(found) {
65
+ if (found.releaseId !== undefined)
66
+ document.releaseId = found.releaseId;
67
+ if (found.previous !== undefined)
68
+ document.previous = found.previous;
69
+ if (found.backup !== undefined)
70
+ document.backup = found.backup;
71
+ if (found.migrations !== undefined)
72
+ document.migrations = found.migrations;
73
+ if (found.urgent)
74
+ document.urgent = { role: clean(found.urgent.role, 40), reason: found.urgent.reason.slice(0, REASON_MAX), tag: found.urgent.tag ?? null };
75
+ },
76
+ async phase(name, action, passed = () => true) {
77
+ const entry = { phase: name, startedAt: isoOf(ctx), endedAt: null, verdict: 'running' };
78
+ document.phases.push(entry);
79
+ await beat();
80
+ await save();
81
+ try {
82
+ const value = await action();
83
+ entry.verdict = passed(value) ? 'ok' : 'failed';
84
+ return value;
85
+ }
86
+ catch (error) {
87
+ entry.verdict = 'failed';
88
+ throw error;
89
+ }
90
+ finally {
91
+ entry.endedAt = isoOf(ctx);
92
+ await save();
93
+ }
94
+ },
95
+ snapshot(moment, services) { document.observed[moment] = services; },
96
+ async finish(outcome, error = null) {
97
+ document.outcome = outcome;
98
+ document.endedAt = isoOf(ctx);
99
+ document.error = error;
100
+ await save();
101
+ try {
102
+ await journal.index({ attempt: id, at: document.endedAt, event: 'end', outcome, releaseId: document.releaseId });
103
+ }
104
+ catch (indexError) {
105
+ warnings.push(`journal: could not index ${id}: ${firstLine(indexError.message)}`);
106
+ }
107
+ },
108
+ };
109
+ return recorder;
110
+ }
111
+ /** The default wait for a held deploy lock (the design's ten minutes). */
112
+ export const LOCK_WAIT_SECONDS = 600;
113
+ const LOCK_POLL_SECONDS = 10;
114
+ /** Takes the deploy lock, waiting while another deploy holds it; `STACK_DEPLOY_LOCKED` when the wait ends. */
115
+ export async function holdLock(ctx, store, trigger, wait = {}) {
116
+ const token = randomBytes(16).toString('hex');
117
+ const deadline = ctx.now() + (wait.waitSeconds ?? LOCK_WAIT_SECONDS) * 1000;
118
+ for (;;) {
119
+ const attempt = await store.tryLock(token, trigger);
120
+ if (attempt.acquired)
121
+ return { token, refresh: () => store.refreshLock(token), release: () => store.unlock(token) };
122
+ if (ctx.now() >= deadline)
123
+ throw refusal('STACK_DEPLOY_LOCKED', `another deploy (${attempt.holder}) has held the deploy lock for ${attempt.ageSeconds}s without finishing; nothing was changed. If its job is gone, remove the deploy.lock directory under the releases base on the manager`);
124
+ await ctx.sleep((wait.pollSeconds ?? LOCK_POLL_SECONDS) * 1000);
125
+ }
126
+ }
127
+ /** How a finished result reads as an outcome. */
128
+ export function outcomeOf(result, kind) {
129
+ const data = (result.data ?? {});
130
+ if (data.alreadyCurrent)
131
+ return 'already-current';
132
+ if (result.ok)
133
+ return data.pendingAccept ? 'pending' : 'accepted';
134
+ if (kind !== 'deploy')
135
+ return 'failed';
136
+ const changed = (data.steps ?? []).some((entry) => entry.step === 'deploy' || entry.step === 'deploy-hold');
137
+ if (!changed)
138
+ return 'refused';
139
+ return data.rolledBack ? 'rolled-back' : 'rollback-failed';
140
+ }
141
+ /** A thrown value as the { code, message } of a journal document. */
142
+ function errorOf(error) {
143
+ if (error instanceof StackError)
144
+ return { code: error.code, message: firstLine(error.message).slice(0, 300) };
145
+ return { code: 'UNEXPECTED', message: firstLine(error?.message).slice(0, 300) };
146
+ }
147
+ /**
148
+ * Runs `body` as one recorded attempt under the deploy lock: journal opened, lock taken, `body(recorder)` run, outcome written, lock released. The result
149
+ * gains `data.attempt` (the attempt id) and the journal's warnings as lines; a throw is recorded as `refused` and rethrown.
150
+ */
151
+ export async function recordedAttempt(ctx, config, store, meta, wait, body) {
152
+ const recorder = await openAttempt(ctx, config, store, meta);
153
+ let hold = null;
154
+ try {
155
+ hold = await recorder.phase('lock', () => holdLock(ctx, store, meta.trigger, wait));
156
+ const owned = hold;
157
+ recorder.beforePhase(async () => {
158
+ try {
159
+ if (!await owned.refresh())
160
+ recorder.warnings.push('journal: the deploy lock is no longer ours (taken over as stale)');
161
+ }
162
+ catch {
163
+ recorder.warnings.push('journal: could not refresh the deploy lock');
164
+ }
165
+ });
166
+ const result = await body(recorder);
167
+ await recorder.finish(outcomeOf(result, meta.kind), result.refused ? { code: result.refused.code, message: result.refused.message.slice(0, 300) } : null);
168
+ return { ...result, lines: [...result.lines, ...recorder.warnings], data: { ...result.data, attempt: recorder.id } };
169
+ }
170
+ catch (error) {
171
+ await recorder.finish('refused', errorOf(error));
172
+ throw error;
173
+ }
174
+ finally {
175
+ if (hold) {
176
+ try {
177
+ await hold.release();
178
+ }
179
+ catch { /* the lock is taken over as stale after its TTL; the deploy's own verdict stands */ }
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,54 @@
1
+ import type { BackupReceipt } from './db-phases.mjs';
2
+ import type { DeployConfig } from './config.mjs';
3
+ import type { StackContext } from './context.mjs';
4
+ import type { Runner } from './remote.mjs';
5
+ /** What was measured on an archive: whether it exists, its size, whether it starts with the age header and its sha256. */
6
+ export interface ArchiveFacts {
7
+ exists: boolean;
8
+ size: number;
9
+ header: boolean;
10
+ sha256: string;
11
+ }
12
+ /** A dump the manager made: where the archive and its receipt are, the receipt and the measurements taken after it was written. */
13
+ export interface ManagerBackup {
14
+ receiptPath: string;
15
+ receipt: BackupReceipt;
16
+ archive: string;
17
+ facts: ArchiveFacts;
18
+ }
19
+ /** The parts of the archive, in tar order. */
20
+ export declare const BACKUP_PARTS: readonly string[];
21
+ /** The folder of the database backups on the manager. */
22
+ export declare function backupFolder(config: Pick<DeployConfig, 'backup' | 'stack'>): string;
23
+ /** The measurements of a local archive (an external database's backup, made where the deploy runs). */
24
+ export declare function localFacts(archive: string): ArchiveFacts;
25
+ /** What the dump script is given. */
26
+ export interface DumpInput {
27
+ dir: string;
28
+ file: string;
29
+ container: string;
30
+ url: string;
31
+ recipients: readonly string[];
32
+ }
33
+ /** The one-line shell script that dumps, tars and encrypts; its stdout is: the archive sha256, its byte size, then the row counts JSON. */
34
+ export declare function dumpScript({ dir, file, container, url, recipients }: DumpInput): string;
35
+ /** The script that measures an archive: `exists`, `bytes`, `sha256` and `header` lines. */
36
+ export declare function measureScript(archive: string): string;
37
+ /** The root script of the test decrypt: the manager's own identity opens the archive in a private scratch directory and tar lists it. */
38
+ export declare function decryptScript(archive: string): string;
39
+ /**
40
+ * Dumps the database of `container` on the manager and writes the receipt beside the archive. Returns the receipt and what the manager measures on
41
+ * the archive afterwards (a second reading, so the receipt is judged against the file, not against the script that wrote it).
42
+ */
43
+ export declare function managerBackup(_ctx: StackContext, config: DeployConfig, { run, container, url, recipients, at }: {
44
+ run: Runner;
45
+ container: string;
46
+ url: string;
47
+ recipients: string[];
48
+ at: string;
49
+ }): Promise<ManagerBackup>;
50
+ /** Opens the archive on the manager with the manager's own identity and lists its parts. Throws when the identity cannot open it. */
51
+ export declare function managerDecrypt(_ctx: StackContext, { run, archive }: {
52
+ run: Runner;
53
+ archive: string;
54
+ }): Promise<string[]>;
@@ -0,0 +1,136 @@
1
+ // manager-backup.mjs - the database backup of a deploy whose database runs in the stack (database.target: stack): the dump is made ON the
2
+ // manager from the database container (pg_dumpall roles, pg_dump schema and data, per-table row counts), tarred and encrypted with age to the
3
+ // .sops.yaml recipients plus the server's own, and kept on the manager beside the volume backups (deploy.backup.dir, else /var/backups/<stack>/db).
4
+ // No plaintext leaves a root-private scratch directory that a trap removes, no database URL or password exists on the machine that runs the
5
+ // deploy (the container reads its own POSTGRES_PASSWORD_FILE), and the receipt is written next to the archive. The archive stays on the manager;
6
+ // copying it to another failure domain remains the owner's job. Every remote command is composed only from validated names and quoted values.
7
+ import fs from 'node:fs';
8
+ import { createHash } from 'node:crypto';
9
+ import { OWN_PASSWORD_SHELL, DB_IDENT } from '../db/postgres.mjs';
10
+ import { RECEIPT_SCHEMA } from '../db/backup-receipt.mjs';
11
+ import { failure, refusal } from './errors.mjs';
12
+ import { shellQuote } from './remote.mjs';
13
+ import { AS_ROOT, SERVER_AGE_IDENTITY } from './server-key.mjs';
14
+ /** The parts of the archive, in tar order. */
15
+ export const BACKUP_PARTS = Object.freeze(['roles.sql', 'schema.sql', 'data.sql', 'rowcounts.json']);
16
+ const BASE = /^\/[A-Za-z0-9._/-]+$/u;
17
+ const CONTAINER = /^[0-9a-f]{12,64}$/u;
18
+ const RECIPIENT = /^age1[0-9a-z]{50,}$/u;
19
+ const AGE_HEADER = 'age-encryption.org/v1';
20
+ /** Row counts of every ordinary table but the catalogs' (auth included), one JSON array, computed inside the database. */
21
+ const ROWCOUNTS_SQL = "select coalesce(json_agg(json_build_object('schema', schemaname, 'table', tablename, 'count', (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', schemaname, tablename), false, true, '')))[1]::text) order by schemaname, tablename), '[]'::json) from pg_catalog.pg_tables where schemaname not in ('pg_catalog', 'information_schema')";
22
+ /** The folder of the database backups on the manager. */
23
+ export function backupFolder(config) {
24
+ const base = config.backup?.dir ?? `/var/backups/${config.stack}`;
25
+ const dir = `${base}/db`;
26
+ if (!BASE.test(dir) || dir.split('/').includes('..'))
27
+ throw refusal('STACK_DEPLOY_BACKUP_DIR', `the backup folder ${dir} must be an absolute path without ..`);
28
+ return dir;
29
+ }
30
+ /** The measurements of a local archive (an external database's backup, made where the deploy runs). */
31
+ export function localFacts(archive) {
32
+ if (!fs.existsSync(archive))
33
+ return { exists: false, size: 0, header: false, sha256: '' };
34
+ const bytes = fs.readFileSync(archive);
35
+ return { exists: true, size: bytes.length, header: bytes.subarray(0, AGE_HEADER.length + 1).toString('ascii') === `${AGE_HEADER}\n`, sha256: createHash('sha256').update(bytes).digest('hex') };
36
+ }
37
+ /** The one-line shell script that dumps, tars and encrypts; its stdout is: the archive sha256, its byte size, then the row counts JSON. */
38
+ export function dumpScript({ dir, file, container, url, recipients }) {
39
+ if (!CONTAINER.test(container))
40
+ throw refusal('STACK_DEPLOY_BACKUP_FAILED', 'the database container id is not a plain id');
41
+ if (recipients.length === 0 || recipients.some((recipient) => !RECIPIENT.test(recipient)))
42
+ throw refusal('STACK_DEPLOY_BACKUP_FAILED', 'the backup needs at least one age recipient, each a plain age1... key');
43
+ const final = `${dir}/${file}`;
44
+ const partial = `${final}.partial`;
45
+ const [archive, tmp] = [shellQuote(final), shellQuote(partial)];
46
+ const dx = (...args) => `dx ${args.join(' ')}`;
47
+ const dump = [
48
+ dx('pg_dumpall', '--roles-only', '--no-role-passwords', '--dbname', shellQuote(url), '>', '"$S/roles.sql"'),
49
+ dx('pg_dump', '--schema-only', '--dbname', shellQuote(url), '>', '"$S/schema.sql"'),
50
+ dx('pg_dump', '--data-only', '--dbname', shellQuote(url), '>', '"$S/data.sql"'),
51
+ dx('psql', '-X', '-q', '-A', '-t', '-d', shellQuote(url), '-c', shellQuote(ROWCOUNTS_SQL), '>', '"$S/rowcounts.json"'),
52
+ `tar -cf "$S/backup.tar" -C "$S" ${BACKUP_PARTS.join(' ')}`,
53
+ `age ${recipients.map((recipient) => `-r ${shellQuote(recipient)}`).join(' ')} -o ${tmp} "$S/backup.tar"`,
54
+ `mv ${tmp} ${archive}`,
55
+ ].join(' && ');
56
+ return [
57
+ 'set -o pipefail', 'umask 077', `mkdir -p ${shellQuote(dir)}`,
58
+ `S=$(mktemp -d ${shellQuote(`${dir}/.plain.XXXXXX`)})`, "trap 'rm -rf \"$S\"' EXIT",
59
+ `PW=${shellQuote(OWN_PASSWORD_SHELL)}`,
60
+ `dx() { docker exec ${container} sh -c "$PW" sh "$@" </dev/null; }`,
61
+ `{ ${dump}; } || { rm -f ${tmp}; exit 1; }`,
62
+ `sha256sum ${archive} | cut -d' ' -f1`, `stat -c %s ${archive}`, 'cat "$S/rowcounts.json"',
63
+ ].join('; ');
64
+ }
65
+ /** The script that measures an archive: `exists`, `bytes`, `sha256` and `header` lines. */
66
+ export function measureScript(archive) {
67
+ const file = shellQuote(archive);
68
+ return [
69
+ `[ -f ${file} ] || { echo exists=0; exit 0; }`, 'echo exists=1', `echo "bytes=$(stat -c %s ${file})"`, `echo "sha256=$(sha256sum ${file} | cut -d' ' -f1)"`,
70
+ `if [ "$(head -c ${AGE_HEADER.length} ${file})" = ${shellQuote(AGE_HEADER)} ]; then echo header=1; else echo header=0; fi`,
71
+ ].join('; ');
72
+ }
73
+ /** The root script of the test decrypt: the manager's own identity opens the archive in a private scratch directory and tar lists it. */
74
+ export function decryptScript(archive) {
75
+ return [
76
+ 'set -e', 'umask 077', 'PATH="$PATH:/usr/local/bin:/usr/bin:/bin"', 't=$(mktemp -d)', "trap 'rm -rf \"$t\"' EXIT",
77
+ `age -d -i ${shellQuote(SERVER_AGE_IDENTITY)} -o "$t/backup.tar" ${shellQuote(archive)}`, 'tar -tf "$t/backup.tar"',
78
+ ].join('\n');
79
+ }
80
+ const sh = (script) => `bash -c ${shellQuote(script)}`;
81
+ const stamp = (at) => at.replaceAll(/[-:.]/gu, '');
82
+ const projectName = (config) => config.project.replaceAll(/[^A-Za-z0-9_-]/gu, '-') || 'database';
83
+ /** The rows of rowcounts.json as the receipt's summary: table count and total rows. */
84
+ function summarize(text) {
85
+ let counts;
86
+ try {
87
+ counts = JSON.parse(text.trim() || '[]');
88
+ }
89
+ catch {
90
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the row counts of the dump are unreadable; output withheld');
91
+ }
92
+ if (!Array.isArray(counts))
93
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the row counts of the dump are not a list');
94
+ return { tables: counts.length, rows: counts.reduce((sum, row) => sum + BigInt(/^\d+$/u.test(String(row?.count)) ? String(row.count) : '0'), 0n).toString() };
95
+ }
96
+ /**
97
+ * Dumps the database of `container` on the manager and writes the receipt beside the archive. Returns the receipt and what the manager measures on
98
+ * the archive afterwards (a second reading, so the receipt is judged against the file, not against the script that wrote it).
99
+ */
100
+ export async function managerBackup(_ctx, config, { run, container, url, recipients, at }) {
101
+ if (!DB_IDENT.test(new URL(url).username))
102
+ throw refusal('STACK_DEPLOY_BACKUP_FAILED', 'the database role is not a plain identifier');
103
+ const dir = backupFolder(config);
104
+ const name = `${projectName(config)}-${stamp(at)}`;
105
+ const file = `${name}.tar.age`;
106
+ const archive = `${dir}/${file}`;
107
+ const receiptPath = `${dir}/${name}.receipt.json`;
108
+ const dumped = run(sh(dumpScript({ dir, file, container, url, recipients })));
109
+ if (dumped.status !== 0)
110
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the dump on the manager failed; output withheld');
111
+ const [sha256 = '', bytes = '', ...rest] = dumped.stdout.split('\n');
112
+ if (!/^[a-f0-9]{64}$/u.test(sha256.trim()) || !/^\d+$/u.test(bytes.trim()))
113
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the dump on the manager printed no archive size or hash');
114
+ const receipt = {
115
+ schema: RECEIPT_SCHEMA, project: projectName(config), target: 'container', ref: `${config.stack}`, at, file, bytes: Number(bytes.trim()), sha256: sha256.trim(),
116
+ parts: [...BACKUP_PARTS], rowcounts: summarize(rest.join('\n')), schemaOnly: false,
117
+ };
118
+ const written = run(sh(`umask 077; cat > ${shellQuote(receiptPath)}`), { input: `${JSON.stringify(receipt, null, 2)}\n` });
119
+ if (written.status !== 0)
120
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the receipt could not be written on the manager');
121
+ const measured = run(sh(measureScript(archive)));
122
+ if (measured.status !== 0)
123
+ throw failure('STACK_DEPLOY_BACKUP_FAILED', 'the archive could not be measured on the manager');
124
+ const reading = Object.fromEntries(measured.stdout.split('\n').map((line) => line.trim().split('=')).filter((pair) => pair.length === 2));
125
+ const facts = { exists: reading.exists === '1', size: Number(reading.bytes ?? 0), header: reading.header === '1', sha256: reading.sha256 ?? '' };
126
+ return { receiptPath, receipt, archive, facts };
127
+ }
128
+ /** Opens the archive on the manager with the manager's own identity and lists its parts. Throws when the identity cannot open it. */
129
+ export async function managerDecrypt(_ctx, { run, archive }) {
130
+ if (!BASE.test(archive))
131
+ throw refusal('STACK_DEPLOY_BACKUP_DECRYPT_FAILED', 'the archive path is not a plain absolute path');
132
+ const result = run(AS_ROOT, { input: `${decryptScript(archive)}\n` });
133
+ if (result.status !== 0)
134
+ throw failure('STACK_DEPLOY_BACKUP_DECRYPT_FAILED', `the manager's identity could not open the archive (is the server recipient in .sops.yaml?); output withheld`);
135
+ return result.stdout.split('\n').map((line) => line.replace(/^\.\//u, '').trim()).filter(Boolean);
136
+ }
@@ -10,6 +10,26 @@ export interface ReleaseEntry {
10
10
  current: boolean;
11
11
  previous: boolean;
12
12
  }
13
+ /** What a deploy lock is taken for: the trigger of the attempt (a closed set, so no free text reaches the remote shell). */
14
+ export type LockTrigger = 'ci' | 'dispatch' | 'urgent';
15
+ /** The answer of one try at the deploy lock: taken, or who holds it (a trigger word) and for how long it has gone unrefreshed. */
16
+ export type LockAttempt = {
17
+ acquired: true;
18
+ } | {
19
+ acquired: false;
20
+ holder: string;
21
+ ageSeconds: number;
22
+ };
23
+ /** The journal files of one attempt: its document (rewritten at every phase boundary) and the append-only index line. */
24
+ export interface AttemptJournal {
25
+ write(document: object): Promise<void>;
26
+ index(entry: object): Promise<void>;
27
+ }
28
+ /** A stored release read into memory: the stack bytes and the parsed release record. */
29
+ export interface StoredRelease {
30
+ stackYaml: string;
31
+ release: Record<string, unknown>;
32
+ }
13
33
  /** The release record protocol on the manager (see createReleaseStore). */
14
34
  export interface ReleaseStore {
15
35
  store(dir: string, id: string): Promise<void>;
@@ -18,7 +38,31 @@ export interface ReleaseStore {
18
38
  previous(): Promise<string>;
19
39
  fetch(id: string, targetDir: string): Promise<string>;
20
40
  list(): Promise<ReleaseEntry[]>;
41
+ /** A stored release read into memory (both files); throws when it is not stored. */
42
+ read(id: string): Promise<StoredRelease>;
43
+ /** One try at /srv/<stack>/deploy.lock for the holder `token` (32 hex): a stale lock (unrefreshed for LOCK_TTL_SECONDS) is taken over. */
44
+ tryLock(token: string, trigger: LockTrigger): Promise<LockAttempt>;
45
+ /** Marks the lock as alive; false when the holder no longer owns it. */
46
+ refreshLock(token: string): Promise<boolean>;
47
+ /** Releases the lock when `token` still owns it; false when it did not. */
48
+ unlock(token: string): Promise<boolean>;
49
+ /** The journal of one attempt under /srv/<stack>/deploys. */
50
+ journal(attempt: string): AttemptJournal;
21
51
  }
52
+ /** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
53
+ export declare const LOCK_TTL_SECONDS = 3600;
54
+ /** An attempt id: UTC compact time and 8 random hex. */
55
+ export declare const ATTEMPT_ID: RegExp;
56
+ /**
57
+ * The remote script of one try at the deploy lock: `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is replaced by rename, a lock
58
+ * unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`. Composed only from
59
+ * a validated base, token and trigger.
60
+ */
61
+ export declare const lockAcquireScript: (base: string, token: string, trigger: string, ttl?: number) => string;
62
+ /** The remote script that marks the lock alive when `token` owns it: prints `held` or `lost`. */
63
+ export declare const lockRefreshScript: (base: string, token: string) => string;
64
+ /** The remote script that removes the lock when `token` owns it: prints `released` or `lost`. */
65
+ export declare const lockReleaseScript: (base: string, token: string) => string;
22
66
  /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
23
67
  export declare const lfOnly: (text: unknown) => string;
24
68
  /**
@@ -9,6 +9,48 @@ import { firstLine, lines as splitLines } from './context.mjs';
9
9
  import { isReleaseId } from './digest.mjs';
10
10
  import { failure, refusal } from './errors.mjs';
11
11
  import { releaseListScript, releasePointerScript } from './remote.mjs';
12
+ /** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
13
+ export const LOCK_TTL_SECONDS = 3600;
14
+ const TOKEN = /^[0-9a-f]{32}$/u;
15
+ const TRIGGERS = ['ci', 'dispatch', 'urgent'];
16
+ /** An attempt id: UTC compact time and 8 random hex. */
17
+ export const ATTEMPT_ID = /^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$/u;
18
+ const checkedToken = (token) => {
19
+ if (typeof token !== 'string' || !TOKEN.test(token))
20
+ throw refusal('STACK_LOCK_TOKEN', 'a lock token is exactly 32 lowercase hex characters');
21
+ return token;
22
+ };
23
+ const checkedTrigger = (trigger) => {
24
+ if (typeof trigger !== 'string' || !TRIGGERS.includes(trigger))
25
+ throw refusal('STACK_LOCK_TRIGGER', 'a deploy trigger is ci, dispatch or urgent');
26
+ return trigger; // TRIGGERS holds exactly the LockTrigger members
27
+ };
28
+ const checkedAttempt = (attempt) => {
29
+ if (typeof attempt !== 'string' || !ATTEMPT_ID.test(attempt))
30
+ throw refusal('STACK_ATTEMPT_ID', 'an attempt id is a UTC compact time and 8 lowercase hex characters');
31
+ return attempt;
32
+ };
33
+ const OWNER_READ = (lock) => `if [ -r "${lock}/owner" ]; then read -r held_token held_trigger held_stamp < "${lock}/owner"; else held_token=none; held_trigger=unknown; held_stamp=$(stat -c %Y "${lock}" 2>/dev/null) || held_stamp=0; fi; case "$held_stamp" in ''|*[!0-9]*) held_stamp=0;; esac`;
34
+ /**
35
+ * The remote script of one try at the deploy lock: `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is replaced by rename, a lock
36
+ * unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`. Composed only from
37
+ * a validated base, token and trigger.
38
+ */
39
+ export const lockAcquireScript = (base, token, trigger, ttl = LOCK_TTL_SECONDS) => {
40
+ const lock = `${base}/deploy.lock`;
41
+ return [
42
+ `b="${base}"; l="${lock}"; now=$(date +%s); mkdir -p "$b" || exit 1`,
43
+ `claim() { printf '%s %s %s\\n' '${checkedToken(token)}' '${checkedTrigger(trigger)}' "$now" > "$l/owner.new" && mv -f "$l/owner.new" "$l/owner" && echo acquired && exit 0; exit 1; }`,
44
+ 'if mkdir "$l" 2>/dev/null; then claim; fi',
45
+ OWNER_READ('$l'),
46
+ `age=$((now - held_stamp)); if [ "$age" -gt ${Math.trunc(ttl)} ]; then if mv "$l" "$l.stale.${token}" 2>/dev/null; then rm -rf "$l.stale.${token}"; if mkdir "$l" 2>/dev/null; then claim; fi; fi; fi`,
47
+ 'case "$held_trigger" in ci|dispatch|urgent) ;; *) held_trigger=unknown;; esac; echo "busy $held_trigger $age"',
48
+ ].join('; ');
49
+ };
50
+ /** The remote script that marks the lock alive when `token` owns it: prints `held` or `lost`. */
51
+ export const lockRefreshScript = (base, token) => `l="${base}/deploy.lock"; now=$(date +%s); if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = '${checkedToken(token)}' ]; then printf '%s %s %s\\n' "$t" "$g" "$now" > "$l/owner.new" && mv -f "$l/owner.new" "$l/owner" && echo held; else echo lost; fi; else echo lost; fi`;
52
+ /** The remote script that removes the lock when `token` owns it: prints `released` or `lost`. */
53
+ export const lockReleaseScript = (base, token) => `l="${base}/deploy.lock"; if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = '${checkedToken(token)}' ]; then rm -rf "$l" && echo released; else echo lost; fi; else echo lost; fi`;
12
54
  const BASE = /^\/[A-Za-z0-9._/-]+$/u;
13
55
  const FILES = ['stack.yaml', 'release.json'];
14
56
  /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
@@ -50,6 +92,15 @@ export function createReleaseStore(config, ssh) {
50
92
  const id = out.trim();
51
93
  return isReleaseId(id) ? id : '';
52
94
  }
95
+ /** Both files of a stored release as [name, text] pairs, framed as base64 lines. */
96
+ async function readFiles(id) {
97
+ const source = dirOf(id);
98
+ const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
99
+ const frames = out.split('\n');
100
+ if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame)))
101
+ throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
102
+ return FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
103
+ }
53
104
  return {
54
105
  /** Uploads stack.yaml and release.json of a rendered release as text; read-only afterwards. */
55
106
  async store(dir, id) {
@@ -69,17 +120,49 @@ export function createReleaseStore(config, ssh) {
69
120
  previous: () => pointer('previous'),
70
121
  /** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
71
122
  async fetch(id, targetDir) {
72
- const source = dirOf(id);
73
- const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
74
- const frames = out.split('\n');
75
- if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame)))
76
- throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
77
- const files = FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
123
+ const files = await readFiles(id);
78
124
  fs.mkdirSync(targetDir, { recursive: true });
79
125
  for (const [name, text] of files)
80
126
  fs.writeFileSync(path.join(targetDir, name), text);
81
127
  return targetDir;
82
128
  },
129
+ /** A stored release in memory: the stack bytes and the parsed release record. */
130
+ async read(id) {
131
+ const [stack, release] = await readFiles(id);
132
+ let record;
133
+ try {
134
+ record = JSON.parse(release[1]);
135
+ }
136
+ catch {
137
+ throw failure('STACK_RELEASE_STORE', `release ${id} has a release.json that is not JSON`);
138
+ }
139
+ return { stackYaml: stack[1], release: record !== null && typeof record === 'object' && !Array.isArray(record) ? record : {} };
140
+ },
141
+ async tryLock(token, trigger) {
142
+ const answer = (await run(lockAcquireScript(base, token, trigger))).trim();
143
+ if (answer === 'acquired')
144
+ return { acquired: true };
145
+ const busy = /^busy ([a-z]+) (-?[0-9]+)$/u.exec(answer);
146
+ if (!busy)
147
+ throw failure('STACK_RELEASE_SSH', 'the deploy lock script answered something else than acquired or busy');
148
+ return { acquired: false, holder: busy[1], ageSeconds: Number(busy[2]) };
149
+ },
150
+ async refreshLock(token) { return (await run(lockRefreshScript(base, token))).trim() === 'held'; },
151
+ async unlock(token) { return (await run(lockReleaseScript(base, token))).trim() === 'released'; },
152
+ journal(attempt) {
153
+ const id = checkedAttempt(attempt);
154
+ const file = `${base}/deploys/${id}.json`;
155
+ const encoded = (value) => `${Buffer.from(JSON.stringify(value), 'utf8').toString('base64')}
156
+ `;
157
+ return {
158
+ async write(document) {
159
+ await run(`set -e; d="${base}/deploys"; mkdir -p "$d"; IFS= read -r doc; printf '%s' "$doc" | base64 -d > "$d/.${id}.tmp"; mv -f "$d/.${id}.tmp" "${file}"`, encoded(document));
160
+ },
161
+ async index(entry) {
162
+ await run(`set -e; mkdir -p "${base}/deploys"; IFS= read -r line; printf '%s\\n' "$(printf '%s' "$line" | base64 -d)" >> "${base}/deploys.jsonl"`, encoded(entry));
163
+ },
164
+ };
165
+ },
83
166
  /** The stored releases, newest first: [{ id, current, previous }]. */
84
167
  async list() {
85
168
  const response = await run(`${releasePointerScript(`"${base}"`, 'current')}; ${releasePointerScript(`"${base}"`, 'previous')}; ${releaseListScript(`"${base}"`)}`);
@@ -73,6 +73,8 @@ export declare function openSealedSession(ctx: StackContext, config: DeployConfi
73
73
  export declare const ownerSshRunner: (ctx: StackContext, config: DeployConfig, options?: SshOptions) => Runner;
74
74
  /** Opens the runner (`deps.ssh` wins in specs), calls `body(run, notes)` and always cleans the session up. */
75
75
  export declare function withRunner<T>(ctx: StackContext, config: DeployConfig, options: SshOptions, deps: RunnerDeps, body: (run: Runner, notes: string[]) => T | Promise<T>): Promise<T>;
76
+ /** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
77
+ export declare const CI_MEMBER = "vps-ci";
76
78
  /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
77
79
  export declare function connectCi(ctx: StackContext, config: DeployConfig, { execute }?: {
78
80
  execute?: boolean;