@starci/hfs 4.13.0 → 4.15.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 (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +1 -1
  3. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  4. package/runtime/knowledge/hfs/rules.yaml +140 -2
  5. package/runtime/knowledge/hfs/slots.yaml +6 -2
  6. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  7. package/runtime/modules/schemas/application-stacks.schema.yaml +22 -0
  8. package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
  9. package/runtime/scripts/hfs/manifest-shape.d.mts +6 -0
  10. package/runtime/scripts/hfs/rule-params-shape.d.mts +5 -0
  11. package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
  12. package/runtime/scripts/hfs/slots.d.mts +2 -1
  13. package/runtime/scripts/hfs/slots.mjs +15 -5
  14. package/runtime/scripts/hfs/stack-deploy-config.d.mts +12 -0
  15. package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -1
  16. package/src/stack-records-double.d.mts +3 -1
  17. package/stack/ci-key.d.mts +11 -0
  18. package/stack/ci-key.mjs +44 -0
  19. package/stack/cli.d.mts +5 -1
  20. package/stack/cli.mjs +9 -6
  21. package/stack/config.d.mts +9 -0
  22. package/stack/deploy.d.mts +4 -1
  23. package/stack/deploy.mjs +10 -5
  24. package/stack/journal.d.mts +19 -0
  25. package/stack/journal.mjs +11 -2
  26. package/stack/notify.d.mts +9 -0
  27. package/stack/notify.mjs +32 -0
  28. package/stack/release-store.d.mts +10 -6
  29. package/stack/release-store.mjs +40 -37
  30. package/stack/remote.mjs +5 -4
  31. package/stack/rollback-guard.d.mts +10 -0
  32. package/stack/rollback-guard.mjs +41 -0
  33. package/stack/ssh-gate.d.mts +35 -0
  34. package/stack/ssh-gate.mjs +145 -0
  35. package/stack/status.d.mts +3 -1
  36. package/stack/status.mjs +2 -1
  37. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +25 -6
  38. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +13 -3
@@ -1,6 +1,6 @@
1
1
  import type { ReleaseStore, StoredRelease } from '../stack/release-store.mjs';
2
2
  /** The records half of a ReleaseStore, plus what it kept. */
3
- export interface RecordsDouble extends Pick<ReleaseStore, 'read' | 'tryLock' | 'refreshLock' | 'unlock' | 'journal'> {
3
+ export interface RecordsDouble extends Pick<ReleaseStore, 'read' | 'tryLock' | 'refreshLock' | 'unlock' | 'journal' | 'journals'> {
4
4
  /** The latest document of each attempt, as the journal wrote it (a copy at each write). */
5
5
  documents: Map<string, Record<string, unknown>>;
6
6
  /** Every write of every document, in order: the phase boundaries a cancelled job would have left. */
@@ -25,6 +25,8 @@ export interface RecordsOptions {
25
25
  freeAfterTries?: number;
26
26
  } | undefined;
27
27
  failJournal?: boolean | undefined;
28
+ /** The attempt documents the journal already holds (what journals() answers): the evidence of the manual rollback guard. */
29
+ history?: Record<string, unknown>[] | undefined;
28
30
  /** The journal accepts this many document writes and fails every later one (a manager that drops mid-deploy). */
29
31
  failJournalAfter?: number | undefined;
30
32
  }
@@ -0,0 +1,11 @@
1
+ import type { DeployConfig } from './config.mjs';
2
+ import type { StackContext } from './context.mjs';
3
+ import type { ActionResult } from './remote.mjs';
4
+ /** What the verb takes. */
5
+ export interface CiKeyOptions {
6
+ publicKey?: string | null | undefined;
7
+ printAuthorizedKeys?: boolean | undefined;
8
+ printGate?: boolean | undefined;
9
+ }
10
+ /** Prints the gate, the authorized_keys line or the install steps (see the top of the file). */
11
+ export declare function ciKey(_ctx: StackContext, config: DeployConfig, options?: CiKeyOptions): Promise<ActionResult>;
@@ -0,0 +1,44 @@
1
+ // ci-key.mjs - `starci app stack ci-key`: the owner-run side of the forced-command gate of the CI SSH key (ssh-gate.mts).
2
+ // --print-gate prints the text of starci-ssh-gate, to install executable as /usr/local/bin/starci-ssh-gate on the manager
3
+ // --print-authorized-keys prints the authorized_keys line of the CI key from its PUBLIC key file (--public-key <file>)
4
+ // Both print only their text, so they pipe straight into the owner's ssh. Nothing here reaches a server, reads a sealed member or touches a private
5
+ // key: creating, installing and rotating the key stay owner-run steps. Without a flag the verb prints the install steps.
6
+ import fs from 'node:fs';
7
+ import { refusal } from './errors.mjs';
8
+ import { GATE_PATH, authorizedKeysLine, gateScript } from './ssh-gate.mjs';
9
+ const lineOf = (file) => {
10
+ if (!file)
11
+ throw refusal('STACK_USAGE', '--print-authorized-keys needs --public-key <the .pub file of the CI key>');
12
+ let text;
13
+ try {
14
+ text = fs.readFileSync(file, 'utf8');
15
+ }
16
+ catch {
17
+ throw refusal('STACK_CI_KEY_PUBLIC', `cannot read the public key file ${file}`);
18
+ }
19
+ return authorizedKeysLine(text);
20
+ };
21
+ /** Prints the gate, the authorized_keys line or the install steps (see the top of the file). */
22
+ export async function ciKey(_ctx, config, options = {}) {
23
+ if (options.printGate === true && options.printAuthorizedKeys === true)
24
+ throw refusal('STACK_USAGE', 'print the gate or the authorized_keys line, one at a time');
25
+ if (options.printGate === true)
26
+ return { ok: true, lines: [gateScript().trimEnd()], data: { gatePath: GATE_PATH } };
27
+ if (options.printAuthorizedKeys === true) {
28
+ const line = lineOf(options.publicKey);
29
+ return { ok: true, lines: [line], data: { authorizedKeys: line } };
30
+ }
31
+ const env = config.env;
32
+ return {
33
+ ok: true,
34
+ lines: [
35
+ 'the CI key reaches Docker, which is root on the manager: its authorized_keys line forces starci-ssh-gate, which admits docker system dial-stdio and the release store\'s named operations only.',
36
+ 'owner-run steps on the manager (as root; <ops> is your own ssh access):',
37
+ ` 1. starci app stack ci-key --env ${env} --print-gate | ssh <ops> 'install -m 0755 -o root -g root /dev/stdin ${GATE_PATH}'`,
38
+ ` 2. starci app stack ci-key --env ${env} --public-key <ci-key.pub> --print-authorized-keys | ssh <ops> 'cat >> ~<ci-account>/.ssh/authorized_keys'`,
39
+ ' 3. seal VPS_GATE=1 in the vps-ci member of the environment, so `stack connect` tells the deploy to use the gate (STARCI_SSH_GATE=1); remove it to go back.',
40
+ 'not yet admitted by the gate: the post-deploy tasks and the manager backup/decrypt of a stack database run free commands; keep those environments on an ungated key until they are named operations.',
41
+ ],
42
+ data: { gatePath: GATE_PATH },
43
+ };
44
+ }
package/stack/cli.d.mts CHANGED
@@ -15,6 +15,10 @@ export interface Flags {
15
15
  urgent?: boolean;
16
16
  'first-start'?: boolean;
17
17
  redeploy?: boolean;
18
+ 'data-acknowledged'?: boolean;
19
+ 'print-authorized-keys'?: boolean;
20
+ 'print-gate'?: boolean;
21
+ 'public-key'?: string;
18
22
  image?: string[];
19
23
  keep?: string[];
20
24
  'extra-compose'?: string[];
@@ -36,7 +40,7 @@ export interface Flags {
36
40
  remote?: string;
37
41
  'ci-run'?: string;
38
42
  }
39
- export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept", "status"];
43
+ export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept", "status", "ci-key"];
40
44
  /** One action of `starci app stack`. */
41
45
  export type Action = (typeof ACTIONS)[number];
42
46
  /** True when `value` names one action of `starci app stack`. */
package/stack/cli.mjs CHANGED
@@ -20,11 +20,11 @@ import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './ta
20
20
  import { oneCommand, prepareUrgentRelease } from './urgent-release.mjs';
21
21
  /** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
22
22
  const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune', 'status']);
23
- export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status']);
23
+ export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status', 'ci-key']);
24
24
  /** True when `value` names one action of `starci app stack`. */
25
25
  export const isAction = (value) => ACTIONS.includes(value);
26
- export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start', '--redeploy']);
27
- export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote', '--ci-run']);
26
+ export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start', '--redeploy', '--data-acknowledged', '--print-authorized-keys', '--print-gate']);
27
+ export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote', '--ci-run', '--public-key']);
28
28
  export const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
29
29
  /** `--ssh-cmd` replaces the configured alias for an action whose module (or the Docker transport) reaches the manager over SSH. */
30
30
  const SSH = ['--ssh-cmd'];
@@ -41,7 +41,7 @@ export const FLAGS = Object.freeze({
41
41
  deploy: ['--image', '--sha', '--execute', '--out', '--timeout', '--retry', '--skip-public', '--target-binding', '--urgent', '--reason', '--remote', '--db-url-env', '--backup-out', '--first-start', '--db-service', '--ci-run', '--redeploy', ...SSH],
42
42
  converge: ['--since', '--timeout', ...SSH],
43
43
  verify: ['--retry', '--skip-public', '--skip-internal', '--target-binding', ...SSH],
44
- rollback: ['--release', '--execute', '--timeout', ...SSH],
44
+ rollback: ['--release', '--execute', '--timeout', '--data-acknowledged', ...SSH],
45
45
  prune: ['--keep', '--execute', ...SSH],
46
46
  backup: ['--swarm-state', '--execute', ...SSH],
47
47
  jobs: ['--execute', ...SSH],
@@ -51,11 +51,12 @@ export const FLAGS = Object.freeze({
51
51
  build: ['--push', '--execute'],
52
52
  accept: ['--release', '--execute', '--retry', '--db-url-env', ...SSH],
53
53
  status: [...SSH],
54
+ 'ci-key': ['--public-key', '--print-authorized-keys', '--print-gate'],
54
55
  });
55
56
  /** The operands of the actions that take any, as the usage prints them. */
56
57
  export const OPERANDS = Object.freeze({ jobs: '<action> [<arg>...]', task: '<name> [<arg>...]', ingress: '<action> [<arg>...]' });
57
58
  /** The read-only actions, whose `--env` defaults to production. Every action with an effect names its environment, so none reaches production by omission. */
58
- export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify', 'status']);
59
+ export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify', 'status', 'ci-key']);
59
60
  /** Splits argv into { flags, positional }; an unknown flag, a flag of another action or a flag without its value is a refusal. */
60
61
  function parseFlags(action, argv) {
61
62
  const own = new Set(['--env', '--json', ...FLAGS[action]]);
@@ -137,13 +138,14 @@ const OPTIONS = {
137
138
  }),
138
139
  converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
139
140
  verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
140
- rollback: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, timeoutSeconds: number(flags, 'timeout') }),
141
+ rollback: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, timeoutSeconds: number(flags, 'timeout'), dataAcknowledged: flags['data-acknowledged'] === true }),
141
142
  prune: ({ flags, cwd }) => ({ keepFiles: (flags.keep ?? []).map((file) => path.resolve(cwd, file)), execute: flags.execute === true }),
142
143
  backup: ({ flags }) => ({ swarmState: flags['swarm-state'] === true, execute: flags.execute === true }),
143
144
  ingress: ({ flags, positional }) => ({ action: positional[0], execute: flags.execute === true, args: positional.slice(1) }),
144
145
  build: ({ flags }) => ({ push: flags.push === true, execute: flags.execute === true }),
145
146
  accept: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, retrySeconds: number(flags, 'retry'), dbUrlEnv: flags['db-url-env'] }),
146
147
  status: () => ({}),
148
+ 'ci-key': ({ flags, cwd }) => ({ publicKey: flags['public-key'] ? path.resolve(cwd, flags['public-key']) : null, printAuthorizedKeys: flags['print-authorized-keys'] === true, printGate: flags['print-gate'] === true }),
147
149
  retire: ({ flags }) => ({ projects: flags.project ?? [], execute: flags.execute === true }),
148
150
  jobs: ({ flags, positional }) => ({ action: positional[0], args: positional.slice(1), execute: flags.execute === true }),
149
151
  task: ({ flags, positional }) => ({ name: positional[0], args: positional.slice(1), execute: flags.execute === true }),
@@ -160,6 +162,7 @@ const MODULES = {
160
162
  build: ['./build.mjs', 'buildImages'],
161
163
  accept: ['./deploy.mjs', 'acceptRelease'],
162
164
  status: ['./status.mjs', 'statusStack'],
165
+ 'ci-key': ['./ci-key.mjs', 'ciKey'],
163
166
  };
164
167
  async function handlerOf(action, importer) {
165
168
  const [file, name] = MODULES[action];
@@ -84,6 +84,14 @@ export interface BackupBlock {
84
84
  dir: string | null;
85
85
  volumes: BackupVolume[];
86
86
  }
87
+ /** The normalised `deploy.notify` block: the sealed webhook URL and the outcomes announced. */
88
+ export interface NotifyBlock {
89
+ webhook: {
90
+ member: string;
91
+ key: string;
92
+ };
93
+ on: string[];
94
+ }
87
95
  export interface RegistryBlock {
88
96
  host: string;
89
97
  visibility: string;
@@ -172,6 +180,7 @@ export interface DeployBlock {
172
180
  tasks: Record<string, TaskEntry>;
173
181
  postDeploy: string[];
174
182
  backup: BackupBlock | null;
183
+ notify: NotifyBlock | null;
175
184
  ingress: IngressConfig | null;
176
185
  registry: RegistryBlock;
177
186
  }
@@ -41,6 +41,7 @@ export interface RollbackOptions {
41
41
  timeoutSeconds?: number | undefined;
42
42
  sshCmd?: string | null | undefined;
43
43
  lockWaitSeconds?: number | undefined;
44
+ dataAcknowledged?: boolean | undefined;
44
45
  }
45
46
  /** What an accept takes. */
46
47
  export interface AcceptOptions {
@@ -70,7 +71,9 @@ export interface DeployDeps {
70
71
  keepFiles: string[];
71
72
  execute: boolean;
72
73
  }) => Promise<ActionResult>;
73
- releaseStore: (config: DeployConfig, ssh: Runner) => ReleaseStore;
74
+ releaseStore: (config: DeployConfig, ssh: Runner, options?: {
75
+ gated?: boolean | undefined;
76
+ }) => ReleaseStore;
74
77
  observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
75
78
  ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
76
79
  floor: Partial<FloorDeps>;
package/stack/deploy.mjs CHANGED
@@ -42,6 +42,8 @@ import { pruneStack } from './prune.mjs';
42
42
  import { compareWithRelease, observedServices, stackImages } from './status.mjs';
43
43
  import { parseYaml } from '../runtime/engine/yaml.mjs';
44
44
  import { createReleaseStore } from './release-store.mjs';
45
+ import { contractFinding, enforceContractGuard } from './rollback-guard.mjs';
46
+ import { gateIsOn } from './ssh-gate.mjs';
45
47
  import { urgentDeploy } from './urgent-floor.mjs';
46
48
  import { verifyStack } from './verify.mjs';
47
49
  /** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
@@ -163,7 +165,7 @@ async function openRunner(ctx, config, options, d, cleanups) {
163
165
  cleanups.push(() => run.cleanup?.());
164
166
  return run;
165
167
  }
166
- const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups));
168
+ const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups), { gated: gateIsOn(ctx.env) });
167
169
  /** Redeploys a stored release after a failed deploy: fetch, stack deploy, converge. Returns the lines to print. */
168
170
  async function redeployStored(ctx, config, d, store, previous, out, steps, rec) {
169
171
  const dir = path.join(out, 'previous');
@@ -352,7 +354,7 @@ async function deployExecute(ctx, config, options, d, out, seams) {
352
354
  const contract = phase && !first ? await rec.phase('contract', () => contractPhase(phase), (done) => done.ok) : null;
353
355
  if (contract) {
354
356
  lines.push(...contract.lines);
355
- rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied] });
357
+ rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied], contract: contract.applied });
356
358
  steps.push({ step: 'contract', ok: contract.ok });
357
359
  data.migrations = { ...data.migrations, contractApplied: contract.applied };
358
360
  }
@@ -413,7 +415,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
413
415
  if (options.urgent !== true)
414
416
  await render();
415
417
  const run = await openRunner(ctx, config, options, d, cleanups);
416
- const store = d.releaseStore(config, run);
418
+ const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
417
419
  const meta = {
418
420
  kind: 'deploy', trigger: triggerOf(options), sourceSha: options.sourceSha, ciRun: options.ciRun,
419
421
  urgent: options.urgent === true ? { role: ctx.env.STARCI_ROLE ?? 'unknown', reason: options.reason ?? '' } : undefined,
@@ -457,8 +459,10 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
457
459
  const target = release ?? await store.previous();
458
460
  if (!target)
459
461
  throw refusal('STACK_ROLLBACK_NO_TARGET', 'no previous accepted release is recorded; name one with --release');
462
+ // A manual rollback to a release older than an applied contract migration is refused unless the owner acknowledges the data effect.
463
+ const notice = enforceContractGuard(contractFinding(await store.journals(), target), options.dataAcknowledged === true);
460
464
  if (options.execute !== true)
461
- return { ok: true, lines: [`dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
465
+ return { ok: true, lines: [...notice, `dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
462
466
  return await recordedAttempt(ctx, config, store, { kind: 'rollback', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
463
467
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
464
468
  try {
@@ -475,6 +479,7 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
475
479
  ['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile })],
476
480
  ['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out: scratch, releaseId: target }); return { ok: true, lines: [] }; }],
477
481
  ], lines, steps, rec);
482
+ lines.unshift(...notice);
478
483
  lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
479
484
  return { ok: !failed, lines, data: { release: target, executed: true, steps } };
480
485
  }
@@ -525,7 +530,7 @@ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
525
530
  const contract = database ? await rec.phase('contract', () => contractPhase({ ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run: null, dbUrlEnv: options.dbUrlEnv }), (done) => done.ok) : null;
526
531
  if (contract) {
527
532
  lines.push(...contract.lines);
528
- rec.note({ migrations: contract.applied });
533
+ rec.note({ migrations: contract.applied, contract: contract.applied });
529
534
  }
530
535
  if (contract && !contract.ok)
531
536
  return { ok: false, lines: [...lines, `release ${release} is accepted and serving; its contract migration is red: the owner decides`], data: { release, accepted: true, executed: true, contractFailed: true } };
@@ -47,6 +47,8 @@ export interface AttemptDocument {
47
47
  backup: Record<string, unknown> | null;
48
48
  /** Reserved for the migrate and contract phases: the names of the migrations that were applied. */
49
49
  migrations: string[];
50
+ /** The contract migrations this attempt applied (the marker the manual rollback guard reads); empty when it applied none. */
51
+ contract: string[];
50
52
  /** `docker service inspect` snapshots by moment: `converge`, `rollback`, `accept`. */
51
53
  observed: Record<string, ObservedService[]>;
52
54
  outcome: Outcome;
@@ -55,6 +57,20 @@ export interface AttemptDocument {
55
57
  message: string;
56
58
  } | null;
57
59
  }
60
+ /** The facts of a finished attempt that a notification carries: names, digests and codes, never a message text or a value. */
61
+ export interface AttemptSummary {
62
+ attempt: string;
63
+ kind: AttemptKind;
64
+ stack: string;
65
+ env: string;
66
+ outcome: Outcome;
67
+ trigger: Trigger;
68
+ sourceSha: string | null;
69
+ tag: string | null;
70
+ releaseId: string | null;
71
+ ciUrl: string | null;
72
+ errorCode: string | null;
73
+ }
58
74
  /** What an attempt is told about itself when it opens. */
59
75
  export interface AttemptMeta {
60
76
  kind: AttemptKind;
@@ -74,6 +90,7 @@ export interface Note {
74
90
  previous?: string | null;
75
91
  backup?: Record<string, unknown> | null;
76
92
  migrations?: string[];
93
+ contract?: string[];
77
94
  urgent?: {
78
95
  role: string;
79
96
  reason: string;
@@ -95,6 +112,8 @@ export interface Recorder {
95
112
  code: string;
96
113
  message: string;
97
114
  } | null): Promise<void>;
115
+ /** The facts of the attempt as it stands (call after `finish` for a notification). */
116
+ summary(): AttemptSummary;
98
117
  }
99
118
  /** A new attempt id: the UTC time and 8 random hex characters. */
100
119
  export declare const newAttemptId: (ctx: Pick<StackContext, "now">) => string;
package/stack/journal.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { firstLine } from './context.mjs';
13
13
  import { StackError, refusal } from './errors.mjs';
14
+ import { notifyOutcome } from './notify.mjs';
14
15
  const REASON_MAX = 500;
15
16
  const SAFE = /[^A-Za-z0-9._/:@ -]/gu;
16
17
  const isoOf = (ctx) => new Date(ctx.now()).toISOString();
@@ -39,7 +40,7 @@ export async function openAttempt(ctx, config, store, meta) {
39
40
  schema: 'starci/stack-deploy-attempt@1', attempt: id, kind: meta.kind, stack: config.stack, env: config.env, startedAt: isoOf(ctx), endedAt: null,
40
41
  trigger: meta.trigger, sourceSha: meta.sourceSha ?? null, tag: facts.tag, ci: facts.ci,
41
42
  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
+ releaseId: null, previous: null, phases: [], backup: null, migrations: [], contract: [], observed: {}, outcome: 'running', error: null,
43
44
  };
44
45
  const warnings = [];
45
46
  let beat = async () => { };
@@ -70,6 +71,8 @@ export async function openAttempt(ctx, config, store, meta) {
70
71
  document.backup = found.backup;
71
72
  if (found.migrations !== undefined)
72
73
  document.migrations = found.migrations;
74
+ if (found.contract !== undefined)
75
+ document.contract = found.contract;
73
76
  if (found.urgent)
74
77
  document.urgent = { role: clean(found.urgent.role, 40), reason: found.urgent.reason.slice(0, REASON_MAX), tag: found.urgent.tag ?? null };
75
78
  },
@@ -93,6 +96,10 @@ export async function openAttempt(ctx, config, store, meta) {
93
96
  }
94
97
  },
95
98
  snapshot(moment, services) { document.observed[moment] = services; },
99
+ summary: () => ({
100
+ attempt: id, kind: document.kind, stack: document.stack, env: document.env, outcome: document.outcome, trigger: document.trigger,
101
+ sourceSha: document.sourceSha, tag: document.tag, releaseId: document.releaseId, ciUrl: document.ci?.url ?? null, errorCode: document.error?.code ?? null,
102
+ }),
96
103
  async finish(outcome, error = null) {
97
104
  document.outcome = outcome;
98
105
  document.endedAt = isoOf(ctx);
@@ -165,10 +172,12 @@ export async function recordedAttempt(ctx, config, store, meta, wait, body) {
165
172
  });
166
173
  const result = await body(recorder);
167
174
  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 } };
175
+ const notice = await notifyOutcome(ctx, config, recorder.summary());
176
+ return { ...result, lines: [...result.lines, ...recorder.warnings, ...notice], data: { ...result.data, attempt: recorder.id } };
169
177
  }
170
178
  catch (error) {
171
179
  await recorder.finish('refused', errorOf(error));
180
+ await notifyOutcome(ctx, config, recorder.summary());
172
181
  throw error;
173
182
  }
174
183
  finally {
@@ -0,0 +1,9 @@
1
+ import type { DeployConfig } from './config.mjs';
2
+ import type { StackContext } from './context.mjs';
3
+ import type { AttemptSummary } from './journal.mjs';
4
+ /** The one-line text a chat webhook shows. */
5
+ export declare function notifyText(summary: AttemptSummary): string;
6
+ /** The JSON body: `text` (Slack style) and `content` (Discord style) carry the line, the rest is the same facts as fields. */
7
+ export declare function notifyBody(summary: AttemptSummary): Record<string, unknown>;
8
+ /** Sends the message for a finished attempt when `deploy.notify` is declared and lists its outcome; returns the lines to print (empty when nothing was sent or it went well). */
9
+ export declare function notifyOutcome(ctx: StackContext, config: Pick<DeployConfig, 'notify' | 'secretsDir'>, summary: AttemptSummary): Promise<string[]>;
@@ -0,0 +1,32 @@
1
+ import { openMember } from './sealed.mjs';
2
+ const TIMEOUT_MS = 10_000;
3
+ const HTTPS_URL = /^https:\/\/[^\s]+$/u;
4
+ /** The one-line text a chat webhook shows. */
5
+ export function notifyText(summary) {
6
+ const commit = summary.sourceSha ? ` of ${summary.sourceSha.slice(0, 10)}` : '';
7
+ const tag = summary.tag ? ` (${summary.tag})` : '';
8
+ const release = summary.releaseId ? `, release ${summary.releaseId}` : '';
9
+ const failure = summary.errorCode ? `, ${summary.errorCode}` : '';
10
+ return `[${summary.stack}/${summary.env}] ${summary.kind} ${summary.outcome}${commit}${tag}${release}${failure}`;
11
+ }
12
+ /** The JSON body: `text` (Slack style) and `content` (Discord style) carry the line, the rest is the same facts as fields. */
13
+ export function notifyBody(summary) {
14
+ const text = notifyText(summary);
15
+ return { text, content: text, ...summary };
16
+ }
17
+ /** Sends the message for a finished attempt when `deploy.notify` is declared and lists its outcome; returns the lines to print (empty when nothing was sent or it went well). */
18
+ export async function notifyOutcome(ctx, config, summary) {
19
+ const declared = config.notify;
20
+ if (!declared || !declared.on.includes(summary.outcome))
21
+ return [];
22
+ try {
23
+ const url = openMember(ctx, config, declared.webhook.member)[declared.webhook.key];
24
+ if (typeof url !== 'string' || !HTTPS_URL.test(url))
25
+ return [`notify: the sealed ${declared.webhook.member} holds no https URL under ${declared.webhook.key}; nothing was sent`];
26
+ const response = await ctx.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(notifyBody(summary)), redirect: 'error', signal: AbortSignal.timeout(TIMEOUT_MS) });
27
+ return response.ok ? [] : [`notify: the webhook answered HTTP ${response.status}; the deploy outcome stands`];
28
+ }
29
+ catch (error) {
30
+ return [`notify: the message was not sent (${error.name === 'TimeoutError' ? 'timed out' : 'the webhook could not be reached'}); the deploy outcome stands`];
31
+ }
32
+ }
@@ -48,20 +48,22 @@ export interface ReleaseStore {
48
48
  unlock(token: string): Promise<boolean>;
49
49
  /** The journal of one attempt under /srv/<stack>/deploys. */
50
50
  journal(attempt: string): AttemptJournal;
51
+ /** The newest attempt documents (at most 500), oldest first; a document that is not a JSON object is left out. */
52
+ journals(): Promise<Record<string, unknown>[]>;
51
53
  }
52
54
  /** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
53
55
  export declare const LOCK_TTL_SECONDS = 3600;
54
56
  /** An attempt id: UTC compact time and 8 random hex. */
55
57
  export declare const ATTEMPT_ID: RegExp;
56
58
  /**
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.
59
+ * The remote command of one try at the deploy lock (ssh-gate.mts holds the body): `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is
60
+ * replaced by rename, a lock unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
61
+ * Composed only from a validated base, token and trigger.
60
62
  */
61
63
  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`. */
64
+ /** The remote command that marks the lock alive when `token` owns it: prints `held` or `lost`. */
63
65
  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`. */
66
+ /** The remote command that removes the lock when `token` owns it: prints `released` or `lost`. */
65
67
  export declare const lockReleaseScript: (base: string, token: string) => string;
66
68
  /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
67
69
  export declare const lfOnly: (text: unknown) => string;
@@ -69,4 +71,6 @@ export declare const lfOnly: (text: unknown) => string;
69
71
  * The release store of one stack. `ssh` is the runner; the returned API is async:
70
72
  * store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
71
73
  */
72
- export declare function createReleaseStore(config: Pick<DeployConfig, 'remote' | 'stack'>, ssh: StoreSsh): ReleaseStore;
74
+ export declare function createReleaseStore(config: Pick<DeployConfig, 'remote' | 'stack'>, ssh: StoreSsh, options?: {
75
+ gated?: boolean | undefined;
76
+ }): ReleaseStore;
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { firstLine, lines as splitLines } from './context.mjs';
9
9
  import { isReleaseId } from './digest.mjs';
10
10
  import { failure, refusal } from './errors.mjs';
11
- import { releaseListScript, releasePointerScript } from './remote.mjs';
11
+ import { gatedCommand, inlineCommand } from './ssh-gate.mjs';
12
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
13
  export const LOCK_TTL_SECONDS = 3600;
14
14
  const TOKEN = /^[0-9a-f]{32}$/u;
@@ -30,27 +30,16 @@ const checkedAttempt = (attempt) => {
30
30
  throw refusal('STACK_ATTEMPT_ID', 'an attempt id is a UTC compact time and 8 lowercase hex characters');
31
31
  return attempt;
32
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
33
  /**
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.
34
+ * The remote command of one try at the deploy lock (ssh-gate.mts holds the body): `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is
35
+ * replaced by rename, a lock unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
36
+ * Composed only from a validated base, token and trigger.
38
37
  */
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`;
38
+ export const lockAcquireScript = (base, token, trigger, ttl = LOCK_TTL_SECONDS) => inlineCommand('lock-acquire', [base, checkedToken(token), checkedTrigger(trigger), String(Math.trunc(ttl))]);
39
+ /** The remote command that marks the lock alive when `token` owns it: prints `held` or `lost`. */
40
+ export const lockRefreshScript = (base, token) => inlineCommand('lock-refresh', [base, checkedToken(token)]);
41
+ /** The remote command that removes the lock when `token` owns it: prints `released` or `lost`. */
42
+ export const lockReleaseScript = (base, token) => inlineCommand('lock-release', [base, checkedToken(token)]);
54
43
  const BASE = /^\/[A-Za-z0-9._/-]+$/u;
55
44
  const FILES = ['stack.yaml', 'release.json'];
56
45
  /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
@@ -62,7 +51,7 @@ const checkedId = (id) => {
62
51
  };
63
52
  const checkedBase = (config) => {
64
53
  const base = config.remote?.releasesBase ?? `/srv/${config.stack}`;
65
- if (!BASE.test(base) || base.split('/').includes('..'))
54
+ if (!BASE.test(base) || base.includes('..'))
66
55
  throw refusal('STACK_RELEASE_BASE', `the releases base ${base} must be an absolute path without ..`);
67
56
  return base;
68
57
  };
@@ -70,10 +59,11 @@ const checkedBase = (config) => {
70
59
  * The release store of one stack. `ssh` is the runner; the returned API is async:
71
60
  * store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
72
61
  */
73
- export function createReleaseStore(config, ssh) {
62
+ export function createReleaseStore(config, ssh, options = {}) {
74
63
  const base = checkedBase(config);
75
- const dirOf = (id) => `${base}/releases/${checkedId(id)}`;
76
- async function run(command, input) {
64
+ /** Runs the named operation: through the forced-command gate (`starci-op <name> <args>`) when the key has one, else as the same body under `sh -c`. */
65
+ async function run(name, args, input) {
66
+ const command = options.gated === true ? gatedCommand(name, args) : inlineCommand(name, args);
77
67
  const answer = await ssh(lfOnly(command), { input });
78
68
  if (answer.status !== 0)
79
69
  throw failure('STACK_RELEASE_SSH', `remote command failed: ${firstLine(answer.stderr)}`);
@@ -88,14 +78,13 @@ export function createReleaseStore(config, ssh) {
88
78
  }
89
79
  }
90
80
  async function pointer(name) {
91
- const out = await run(releasePointerScript(`"${base}"`, name));
81
+ const out = await run('pointer', [base, name]);
92
82
  const id = out.trim();
93
83
  return isReleaseId(id) ? id : '';
94
84
  }
95
85
  /** Both files of a stored release as [name, text] pairs, framed as base64 lines. */
96
86
  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`);
87
+ const out = await run('read', [base, checkedId(id)]);
99
88
  const frames = out.split('\n');
100
89
  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
90
  throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
@@ -104,15 +93,15 @@ export function createReleaseStore(config, ssh) {
104
93
  return {
105
94
  /** Uploads stack.yaml and release.json of a rendered release as text; read-only afterwards. */
106
95
  async store(dir, id) {
107
- const target = dirOf(id);
96
+ checkedId(id);
108
97
  const contents = FILES.map((name) => [name, readLocal(dir, name)]);
109
98
  const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
110
- await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi; IFS= read -r stack; IFS= read -r release; printf '%s' "$stack" | base64 -d > "$d/stack.yaml"; printf '%s' "$release" | base64 -d > "$d/release.json"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`, input);
99
+ await run('store', [base, checkedId(id)], input);
111
100
  },
112
101
  /** The release was verified: previous := current, current := id. */
113
102
  async accept(id) {
114
103
  checkedId(id);
115
- await run(`set -e; cd "${base}"; test -d "releases/${id}"; if [ -L current ] && [ "$(basename "$(readlink current)")" != "${id}" ]; then ln -sfn "$(readlink current)" previous; fi; ln -sfn "releases/${id}" current.new; mv -Tf current.new current`);
104
+ await run('accept', [base, id]);
116
105
  },
117
106
  /** The accepted release id, '' when none. */
118
107
  current: () => pointer('current'),
@@ -139,7 +128,7 @@ export function createReleaseStore(config, ssh) {
139
128
  return { stackYaml: stack[1], release: record !== null && typeof record === 'object' && !Array.isArray(record) ? record : {} };
140
129
  },
141
130
  async tryLock(token, trigger) {
142
- const answer = (await run(lockAcquireScript(base, token, trigger))).trim();
131
+ const answer = (await run('lock-acquire', [base, checkedToken(token), checkedTrigger(trigger), String(LOCK_TTL_SECONDS)])).trim();
143
132
  if (answer === 'acquired')
144
133
  return { acquired: true };
145
134
  const busy = /^busy ([a-z]+) (-?[0-9]+)$/u.exec(answer);
@@ -147,25 +136,39 @@ export function createReleaseStore(config, ssh) {
147
136
  throw failure('STACK_RELEASE_SSH', 'the deploy lock script answered something else than acquired or busy');
148
137
  return { acquired: false, holder: busy[1], ageSeconds: Number(busy[2]) };
149
138
  },
150
- async refreshLock(token) { return (await run(lockRefreshScript(base, token))).trim() === 'held'; },
151
- async unlock(token) { return (await run(lockReleaseScript(base, token))).trim() === 'released'; },
139
+ async refreshLock(token) { return (await run('lock-refresh', [base, checkedToken(token)])).trim() === 'held'; },
140
+ async unlock(token) { return (await run('lock-release', [base, checkedToken(token)])).trim() === 'released'; },
152
141
  journal(attempt) {
153
142
  const id = checkedAttempt(attempt);
154
- const file = `${base}/deploys/${id}.json`;
155
143
  const encoded = (value) => `${Buffer.from(JSON.stringify(value), 'utf8').toString('base64')}
156
144
  `;
157
145
  return {
158
146
  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));
147
+ await run('journal-write', [base, id], encoded(document));
160
148
  },
161
149
  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));
150
+ await run('journal-index', [base], encoded(entry));
163
151
  },
164
152
  };
165
153
  },
154
+ /** The attempt documents the journal keeps, oldest first. */
155
+ async journals() {
156
+ const documents = [];
157
+ for (const frame of (await run('journal-read', [base])).split('\n')) {
158
+ if (frame === '')
159
+ continue;
160
+ try {
161
+ const parsed = JSON.parse(Buffer.from(frame, 'base64').toString('utf8'));
162
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
163
+ documents.push(parsed);
164
+ }
165
+ catch { /* a document that cannot be read is no evidence either way */ }
166
+ }
167
+ return documents;
168
+ },
166
169
  /** The stored releases, newest first: [{ id, current, previous }]. */
167
170
  async list() {
168
- const response = await run(`${releasePointerScript(`"${base}"`, 'current')}; ${releasePointerScript(`"${base}"`, 'previous')}; ${releaseListScript(`"${base}"`)}`);
171
+ const response = await run('list', [base]);
169
172
  const [current, previous, ...entries] = response.split('\n');
170
173
  const now = isReleaseId(current) ? current : '';
171
174
  const before = isReleaseId(previous) ? previous : '';