@starci/hfs 4.13.0 → 4.13.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.13.2 - 2026-10-31
4
+
5
+ - Added: R344 `BE_AI_VIRTUAL_KEY` in the bundled rule catalog and on the `be.integrations` slot: an app's ai-router gateway has one client key, `STARCI_AI_KEY`, read by `integrations/ai`, and no admin token; `@starci/eslint-canon-be` `ai-virtual-key-name` enforces it (ruling be-ai-one-virtual-key).
6
+ - Changed: the bundled canon pins name hfs 4.13.2 and the lint cohort eslint-canon-be 3.10.0, eslint-canon-fe 9.6.5, eslint-grammar-core and eslint-grammar-offset-pop 0.16.5.
7
+
8
+ ## 4.13.1 - 2026-10-31
9
+
10
+ The open items of the delivery pipeline (contexts/project/cicd.md, "As built").
11
+
12
+ - Added: `starci-ssh-gate`, the forced command of the CI SSH key. The release store's remote operations are named scripts with arguments of a closed alphabet (`stack/ssh-gate.mts`); the gate admits `docker system dial-stdio` and `starci-op <name> <args>` and refuses anything else with exit 126, saying nothing about the request. `starci app stack ci-key --env <env>` prints the owner-run install steps, `--print-gate` the gate text and `--public-key <file> --print-authorized-keys` the line (`restrict,command="starci-ssh-gate",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-user-rc <key>`). A sealed `vps-ci` member that holds `VPS_GATE: "1"` makes `stack connect` export `STARCI_SSH_GATE=1`, and the deploy, rollback, accept and status verbs then send the operations through the gate; without it they run the same bodies under `sh -c`. Not admitted yet: the post-deploy tasks and the manager backup and test decrypt of a stack database.
13
+ - Added: `deploy.notify` (`webhook: {member, key}`, optional `on`), off unless declared: one JSON webhook message (outcome, environment, stack, commit, tag, release, attempt, CI run URL, failure code) when an attempt ends; the URL is a key of a sealed member, https only, never printed; a failing webhook is a line of the result and never fails the deploy.
14
+ - Added: the journal document of an attempt carries `contract`, the contract migrations it applied, and the release store reads the journal (`journals()`). `stack rollback` refuses `STACK_ROLLBACK_CONTRACT` (dry run too) for a release older than the previous release of an attempt that applied a contract migration, or that the journal never saw once any is recorded, unless `--data-acknowledged`.
15
+ - Changed: the bundled canon pins name hfs 4.13.1 and the lint cohort eslint-canon-be 3.9.4, eslint-canon-fe 9.6.4, eslint-grammar-core and eslint-grammar-offset-pop 0.16.4 (their copies of the pins moved with them).
16
+ - Changed: the release store's lock, pointer, list, read, store, accept and journal commands are the named operations above; a releases base may not contain `..` anywhere.
17
+
3
18
  ## 4.13.0 - 2026-10-31
4
19
 
5
20
  The delivery pipeline of an app (contexts/project/cicd.md, ruling app-cicd-auto-deploy; owner approval 2026-10-31: every green release tag deploys by itself, STARCI_DEPLOY_PAUSED is the brake, agents deploy, the CI deploy key is sealed, pull requests get CI, contract migrations run after the accept, Sonar judges every full run, images are cached in GitHub Actions and built for amd64 unless declared).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.13.0",
3
+ "version": "4.13.2",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.16.3
34
+ version: 0.16.5
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.9.3
41
+ version: 3.10.0
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.6.3
48
+ version: 9.6.5
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.16.3
55
+ version: 0.16.5
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.16.3
62
+ version: 0.16.5
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.13.0
109
+ version: 4.13.2
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -6046,3 +6046,26 @@ rules:
6046
6046
  source: owner
6047
6047
  quote: "app check cache lại vào file dc không? sau chỉ check diff; chốt starcistacks lưu hết nhé và check diff và triển cho ci và local lun."
6048
6048
  ruling: "The app commits its check results in .starcistacks/cache/; CI trusts only the cache at the trusted base, a forged record blocks at any count, stale and missing only warn (ruling app-check-cache)."
6049
+ - id: R344
6050
+ code: "BE_AI_VIRTUAL_KEY"
6051
+ law: "An app's ai-router gateway has one client key, STARCI_AI_KEY (sk-starci-<app>-<48 hex>, sealed in the ai member, used by the integrations/ai LangChain client and the agent runtime alike), and no admin token: integrations/ai reads exactly that secret name with env.secret, as a literal, and the custody owner integrations/ai-gateway passes the gateway no adminToken."
6052
+ kinds: [lint]
6053
+ gates: [pre-commit, settle, land, ci]
6054
+ failureCodes: ["BE_AI_VIRTUAL_KEY"]
6055
+ enforcers:
6056
+ - {kind: eslint-be, id: ai-virtual-key-name}
6057
+ editions: [lite, full]
6058
+ docs: [contexts/code/be/ai.md]
6059
+ templateNa: "The skeleton scaffolds no model call; an app adds the ai and ai-gateway integrations and the ai member when it calls a model."
6060
+ proves:
6061
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6062
+ exemplifiedBy:
6063
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6064
+ witness:
6065
+ - {path: packages/eslint-canon-be/src/ai-gateway.spec.ts, editions: [lite, full], codes: [BE_AI_VIRTUAL_KEY]}
6066
+ cases:
6067
+ - id: case-1
6068
+ date: "2026-10-31"
6069
+ source: owner
6070
+ quote: "Xóa ai api key và openclaw ai key chỉnh thành starci ai key là key ảo"
6071
+ ruling: "Per app and environment the ai member holds the upstream provider keys and one random virtual key STARCI_AI_KEY that the gateway mounts and every client uses; the gateway has no admin API (ruling be-ai-one-virtual-key)."
@@ -1999,7 +1999,7 @@ slots:
1999
1999
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
2000
2000
  coverage: required
2001
2001
  budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
2002
- rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT]
2002
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT, BE_AI_VIRTUAL_KEY]
2003
2003
  - id: be.integrations.model
2004
2004
  since: 3.1.0
2005
2005
  profiles: [be]
@@ -633,6 +633,7 @@ $defs:
633
633
  uniqueItems: true
634
634
  items: {type: string, pattern: '^[a-z][a-z0-9-]{0,40}$'}
635
635
  backup: {$ref: '#/$defs/deployBackup'}
636
+ notify: {$ref: '#/$defs/deployNotify'}
636
637
  registry: {$ref: '#/$defs/deployRegistry'}
637
638
  ingress:
638
639
  description: The shared host ingress (stack-ingress-config.mjs is the authority for the fields; starci app check R294 judges them).
@@ -850,6 +851,27 @@ $defs:
850
851
  type: array
851
852
  minItems: 1
852
853
  items: {$ref: '#/$defs/deployBackupVolume'}
854
+ deployNotify:
855
+ description: >-
856
+ One webhook message per deploy outcome (accepted, rolled-back, rollback-failed, failed; `refused` only when listed): the outcome, the environment, the commit, the tag, the
857
+ release and the attempt, as JSON with a `text` and a `content` line so a chat webhook shows it. Off unless declared. The URL is a sealed value of the environment
858
+ (https only), never written here; a failing webhook is a line of the result and never fails the deploy.
859
+ type: object
860
+ additionalProperties: false
861
+ required: [webhook]
862
+ properties:
863
+ webhook:
864
+ type: object
865
+ additionalProperties: false
866
+ required: [member, key]
867
+ properties:
868
+ member: {$ref: '#/$defs/deployName'}
869
+ key: {$ref: '#/$defs/deployKey'}
870
+ on:
871
+ type: array
872
+ uniqueItems: true
873
+ minItems: 1
874
+ items: {enum: [accepted, rolled-back, rollback-failed, failed, refused]}
853
875
  deployRegistry:
854
876
  description: >-
855
877
  The image registry and the visibility the app's packages are meant to have. The canon scaffold writes `public`; the value then belongs to the app.
@@ -99,6 +99,16 @@ export interface BackupConfig {
99
99
  volume: string;
100
100
  }>;
101
101
  }
102
+ /** The outcomes of a deploy attempt a notification can be sent for. */
103
+ export declare const NOTIFY_OUTCOMES: readonly ["accepted", "rolled-back", "rollback-failed", "failed", "refused"];
104
+ /** `deploy.notify`: the sealed member and key that hold the webhook URL (https), and the outcomes that are announced. */
105
+ export interface NotifyConfig {
106
+ webhook: {
107
+ member: string;
108
+ key: string;
109
+ };
110
+ on: string[];
111
+ }
102
112
  export interface RegistryConfig {
103
113
  host: string;
104
114
  visibility: string;
@@ -124,6 +134,8 @@ export interface DeployConfig {
124
134
  tasks: Record<string, TaskConfig>;
125
135
  postDeploy: string[];
126
136
  backup: BackupConfig | null;
137
+ /** deploy.notify: one webhook message per deploy outcome; null (off) unless declared. */
138
+ notify: NotifyConfig | null;
127
139
  ingress: IngressConfig | null;
128
140
  registry: RegistryConfig;
129
141
  }
@@ -5,6 +5,8 @@ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
5
5
  import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
6
6
  import { list } from '../lib/list.mjs';
7
7
  import { normalizeIngress } from './stack-ingress-config.mjs';
8
+ /** The outcomes of a deploy attempt a notification can be sent for. */
9
+ export const NOTIFY_OUTCOMES = ['accepted', 'rolled-back', 'rollback-failed', 'failed', 'refused'];
8
10
  export const DEPLOY_CONFIG_CODE = 'STACKS_DEPLOY_CONFIG_INVALID';
9
11
  export const NAME = /^[a-z][a-z0-9-]*$/u;
10
12
  export const KEY = /^[A-Z][A-Z0-9_]*$/u;
@@ -17,7 +19,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
17
19
  const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
18
20
  const UPPER_ENV = KEY;
19
21
  const FIELDS = {
20
- deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
22
+ deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'notify', 'ingress', 'registry'],
21
23
  remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
22
24
  image: ['app', 'buildArgs'],
23
25
  buildArgs: ['values', 'sealed'],
@@ -36,6 +38,8 @@ const FIELDS = {
36
38
  backup: ['image', 'keepDays', 'dir', 'volumes'],
37
39
  backupVolume: ['name', 'volume'],
38
40
  registry: ['host', 'visibility'],
41
+ notify: ['webhook', 'on'],
42
+ webhook: ['member', 'key'],
39
43
  };
40
44
  const text = (value) => typeof value === 'string' && value.length > 0;
41
45
  const APPROVALS = ['none', 'reviewers'];
@@ -409,6 +413,22 @@ function backup(c, block) {
409
413
  c.problem('deploy.backup.volumes', 'must list at least one volume');
410
414
  return { image: block.image, keepDays: block.keepDays ?? 14, dir: block.dir ?? null, volumes };
411
415
  }
416
+ /**
417
+ * `deploy.notify`: off unless declared. The webhook URL is a sealed value (never in the declaration, the log or the journal); the default announces every
418
+ * outcome that changed or tried to change the services (accepted, rolled-back, rollback-failed, failed), and `on` narrows or adds `refused`.
419
+ */
420
+ function notify(c, block, members) {
421
+ if (block === undefined)
422
+ return null;
423
+ if (!closedFields(c, 'deploy.notify', block, FIELDS.notify))
424
+ return null;
425
+ if (!closedFields(c, 'deploy.notify.webhook', block.webhook, FIELDS.webhook))
426
+ return null;
427
+ memberRef(c, 'deploy.notify.webhook.member', block.webhook.member, members);
428
+ patternField(c, 'deploy.notify.webhook.key', block.webhook.key, KEY);
429
+ const on = block.on === undefined ? ['accepted', 'rolled-back', 'rollback-failed', 'failed'] : stringList(c, 'deploy.notify.on', block.on, new RegExp(`^(?:${NOTIFY_OUTCOMES.join('|')})$`, 'u'), { nonEmpty: true });
430
+ return { webhook: { member: block.webhook.member, key: block.webhook.key }, on };
431
+ }
412
432
  function ingress(c, block, probe) {
413
433
  if (block === undefined)
414
434
  return null;
@@ -450,6 +470,7 @@ export function normalizeDeploy(block, { project, probe }) {
450
470
  tasks,
451
471
  postDeploy: postDeploy(c, block.postDeploy, tasks),
452
472
  backup: backup(c, block.backup),
473
+ notify: notify(c, block.notify, members),
453
474
  ingress: ingress(c, block.ingress, probe),
454
475
  registry: registry(c, block.registry),
455
476
  };
@@ -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 : '';
package/stack/remote.mjs CHANGED
@@ -228,7 +228,7 @@ function ciValues(ctx, config) {
228
228
  if (!present)
229
229
  return { values: ctx.env, sealed: false };
230
230
  const map = openSealedMember(ctx, config, CI_MEMBER);
231
- return { values: Object.fromEntries(CI_VARIABLES.map((name) => [name, typeof map[name] === 'string' ? map[name] : undefined])), sealed: true };
231
+ return { values: Object.fromEntries([...CI_VARIABLES, 'VPS_GATE'].map((name) => [name, typeof map[name] === 'string' ? map[name] : undefined])), sealed: true };
232
232
  }
233
233
  function ciInputs(ctx, values, sealed) {
234
234
  for (const name of CI_VARIABLES)
@@ -258,9 +258,10 @@ export async function connectCi(ctx, config, { execute = false } = {}) {
258
258
  const { values, sealed } = ciValues(ctx, config);
259
259
  const inputs = ciInputs(ctx, values, sealed);
260
260
  const alias = config.remote.host;
261
+ const gated = values.VPS_GATE === '1';
261
262
  const origin = sealed ? `the sealed member ${CI_MEMBER}` : 'VPS_SSH_KEY';
262
- const plan = [`${dir}/deploy_key (0600) from ${origin}`, `${dir}/known_hosts (0644) from ${sealed ? CI_MEMBER : 'VPS_KNOWN_HOSTS'}`, `a Host ${alias} block in ${dir}/config`, `DOCKER_HOST=ssh://${alias} in GITHUB_ENV`];
263
- const data = { host: alias, files: ['deploy_key', 'known_hosts', 'config'], sealed };
263
+ const plan = [...(gated ? ['STARCI_SSH_GATE=1 in GITHUB_ENV (the key is forced through starci-ssh-gate)'] : []), `${dir}/deploy_key (0600) from ${origin}`, `${dir}/known_hosts (0644) from ${sealed ? CI_MEMBER : 'VPS_KNOWN_HOSTS'}`, `a Host ${alias} block in ${dir}/config`, `DOCKER_HOST=ssh://${alias} in GITHUB_ENV`];
264
+ const data = { host: alias, files: ['deploy_key', 'known_hosts', 'config'], sealed, gated };
264
265
  if (!execute)
265
266
  return { ok: true, lines: plan.map((item) => `dry run: would write ${item}`), data };
266
267
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -268,7 +269,7 @@ export async function connectCi(ctx, config, { execute = false } = {}) {
268
269
  writeWithMode(path.join(dir, 'known_hosts'), inputs.known, 0o644);
269
270
  fs.appendFileSync(path.join(dir, 'config'), sshBlock(alias, inputs), { mode: 0o600 });
270
271
  fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}
271
- `);
272
+ ${gated ? 'STARCI_SSH_GATE=1' + String.fromCharCode(10) : ''}`);
272
273
  // The log of a public repository is public: mask the host, the account and the releases root before any later line can carry them.
273
274
  const masks = sealed ? [inputs.host, inputs.user, config.remote.releasesBase].filter((value) => typeof value === 'string' && value.length >= 4).map((value) => `::add-mask::${value}`) : [];
274
275
  return { ok: true, lines: [...masks, ...plan.map((item) => `wrote ${item}`)], data };
@@ -0,0 +1,10 @@
1
+ /** What the guard found. */
2
+ export interface ContractFinding {
3
+ blocked: boolean;
4
+ migrations: string[];
5
+ reason: string;
6
+ }
7
+ /** Judges a rollback to `target` against the attempt documents of the journal (oldest first). */
8
+ export declare function contractFinding(documents: readonly Record<string, unknown>[], target: string): ContractFinding;
9
+ /** Refuses `STACK_ROLLBACK_CONTRACT` when the finding blocks and the owner did not acknowledge the data effect; returns the notice line when it did. */
10
+ export declare function enforceContractGuard(finding: ContractFinding, acknowledged: boolean): string[];
@@ -0,0 +1,41 @@
1
+ // rollback-guard.mjs - the guard of a manual rollback to an older release.
2
+ // A rollback restores service specs, never data (PROJECT-STACKS-6). A contract migration destroys what the code of the releases before the previous
3
+ // one still uses, so rolling back to such a release after a contract migration ran leaves it on a schema it cannot read. The journal knows both: each
4
+ // attempt that accepted a release carries an `accept` phase, and each attempt that applied contract migrations carries them in `contract`.
5
+ // chain the accepted releases in the order the journal accepted them (a rollback is one more entry)
6
+ // a contract migration applied by the attempt that accepted chain[i] is safe for chain[i] and chain[i-1] (the deploy only runs it once the previous
7
+ // release no longer uses what it drops), and unsafe for every release accepted for the last time before chain[i-1].
8
+ // A target the journal cannot place is refused as soon as any contract migration is recorded; `--data-acknowledged` is the owner's yes to the loss window.
9
+ import { refusal } from './errors.mjs';
10
+ const accepted = (document) => Array.isArray(document.phases) && document.phases.some((phase) => phase !== null && typeof phase === 'object' && phase.phase === 'accept' && phase.verdict === 'ok');
11
+ const contractOf = (document) => (Array.isArray(document.contract) ? document.contract.filter((name) => typeof name === 'string') : []);
12
+ /** Judges a rollback to `target` against the attempt documents of the journal (oldest first). */
13
+ export function contractFinding(documents, target) {
14
+ const chain = [];
15
+ const marks = [];
16
+ for (const document of documents) {
17
+ if (typeof document.releaseId !== 'string' || !accepted(document))
18
+ continue;
19
+ chain.push(document.releaseId);
20
+ const migrations = contractOf(document);
21
+ if (migrations.length > 0)
22
+ marks.push({ index: chain.length - 1, migrations });
23
+ }
24
+ if (marks.length === 0)
25
+ return { blocked: false, migrations: [], reason: '' };
26
+ const at = chain.lastIndexOf(target);
27
+ const unsafe = marks.filter((mark) => at === -1 || at < mark.index - 1);
28
+ if (unsafe.length === 0)
29
+ return { blocked: false, migrations: [], reason: '' };
30
+ const migrations = unsafe.flatMap((mark) => mark.migrations);
31
+ const where = at === -1 ? `the journal has no record of release ${target} being accepted, and` : `release ${target} was accepted before the release that ran them, and`;
32
+ return { blocked: true, migrations, reason: `${where} contract migration(s) ${migrations.join(', ')} ran since: the schema no longer fits that release` };
33
+ }
34
+ /** Refuses `STACK_ROLLBACK_CONTRACT` when the finding blocks and the owner did not acknowledge the data effect; returns the notice line when it did. */
35
+ export function enforceContractGuard(finding, acknowledged) {
36
+ if (!finding.blocked)
37
+ return [];
38
+ if (!acknowledged)
39
+ throw refusal('STACK_ROLLBACK_CONTRACT', `${finding.reason}. A rollback restores services, never data: restore the backup first, or pass --data-acknowledged to roll back anyway; nothing was changed`);
40
+ return [`--data-acknowledged: rolling back past ${finding.migrations.join(', ')}; the schema may not fit this release`];
41
+ }
@@ -0,0 +1,35 @@
1
+ /** What an argument is: each kind is a closed alphabet, checked by the client (TypeScript) and by the gate (POSIX sh) alike. */
2
+ export type ArgKind = 'base' | 'release' | 'pointer' | 'token' | 'trigger' | 'attempt' | 'seconds';
3
+ /** One named operation: its arguments by kind, whether it reads stdin, and the sh body that runs with the arguments as $1.... */
4
+ export interface GateOp {
5
+ readonly name: string;
6
+ readonly args: readonly ArgKind[];
7
+ readonly stdin: boolean;
8
+ readonly body: string;
9
+ }
10
+ /** The closed alphabet of each argument kind. */
11
+ export declare const ARG_TESTS: Readonly<Record<ArgKind, (value: string) => boolean>>;
12
+ /**
13
+ * The named operations of the release store. `lock-acquire`: `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is replaced by
14
+ * rename, a lock unrefreshed for $4 seconds is moved aside (one rename wins) and claimed anew; prints `acquired` or `busy <trigger> <age>`.
15
+ */
16
+ export declare const GATE_OPS: readonly GateOp[];
17
+ /** The operation called `name`; an unknown name is a defect of the caller. */
18
+ export declare function gateOp(name: string): GateOp;
19
+ /** The command the CI transport sends: `starci-op <name> <argument>...` (every argument is plain characters, so nothing needs quoting). */
20
+ export declare function gatedCommand(name: string, args: readonly string[]): string;
21
+ /** The same operation as one command for a key without the gate: the body under `sh -c` with the arguments as positional parameters. */
22
+ export declare function inlineCommand(name: string, args: readonly string[]): string;
23
+ /** The POSIX sh text of `starci-ssh-gate`, to install executable at /usr/local/bin/starci-ssh-gate on the manager. */
24
+ export declare function gateScript(): string;
25
+ /** True when the CI transport was set up for a key that has the gate (`connect` exports STARCI_SSH_GATE=1 for a sealed `vps-ci` member that says `VPS_GATE: "1"`). */
26
+ export declare const gateIsOn: (env: NodeJS.ProcessEnv | undefined) => boolean;
27
+ /** Where the gate is installed on the manager, and the forced-command word of the authorized_keys line. */
28
+ export declare const GATE_PATH = "/usr/local/bin/starci-ssh-gate";
29
+ /** The options of the CI key's line: `restrict` already denies everything optional; the explicit denials keep the line readable and survive a reader who forgets what `restrict` covers. */
30
+ export declare const AUTHORIZED_OPTIONS: readonly string[];
31
+ /**
32
+ * The authorized_keys line of the CI key from its PUBLIC key text. Only a one-line public key is accepted (a private key, a line that already carries options
33
+ * or a second line is refused); the key keeps the comment it was generated with, which names it for revocation.
34
+ */
35
+ export declare function authorizedKeysLine(publicKey: string): string;
@@ -0,0 +1,145 @@
1
+ // ssh-gate.mjs - the forced command of the CI SSH key on the manager (`starci-ssh-gate`) and the named operations it admits.
2
+ // The CI key reaches the manager's Docker socket, which is root on that host; its line in authorized_keys is therefore
3
+ // restrict,command="starci-ssh-gate" ssh-ed25519 AAAA... <comment>
4
+ // and the gate admits exactly two kinds of request:
5
+ // `docker system dial-stdio` the transport of DOCKER_HOST=ssh://<alias> (the docker CLI starts it itself)
6
+ // `starci-op <name> <argument>...` one of the named operations below, each argument validated by its kind
7
+ // Everything else - a shell, an unknown name, a wrong argument count, an argument outside its closed alphabet - is refused with exit 126 and no echo of
8
+ // the request. The operations are the release store's remote protocol (release-store.mts): the same script bodies run for the owner's key (through
9
+ // `sh -c`, without the gate) and for the CI key (through the gate), so the gate admits no script the store does not itself run.
10
+ // A body reads its arguments as $1... ($0 is `starci-op`) and its stdin; it holds no free text.
11
+ import { refusal } from './errors.mjs';
12
+ import { shellQuote } from './remote.mjs';
13
+ const RELEASE_ID = /^[0-9a-f]{16}$/u;
14
+ const TOKEN = /^[0-9a-f]{32}$/u;
15
+ const ATTEMPT = /^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$/u;
16
+ const BASE = /^\/[A-Za-z0-9._/-]+$/u;
17
+ /** The closed alphabet of each argument kind. */
18
+ export const ARG_TESTS = Object.freeze({
19
+ base: (value) => BASE.test(value) && !value.includes('..'),
20
+ release: (value) => RELEASE_ID.test(value),
21
+ pointer: (value) => value === 'current' || value === 'previous',
22
+ token: (value) => TOKEN.test(value),
23
+ trigger: (value) => value === 'ci' || value === 'dispatch' || value === 'urgent',
24
+ attempt: (value) => ATTEMPT.test(value),
25
+ seconds: (value) => /^[0-9]{1,6}$/u.test(value),
26
+ });
27
+ const OWNER_READ = 'if [ -r "$l/owner" ]; then read -r held_token held_trigger held_stamp < "$l/owner"; else held_token=none; held_trigger=unknown; held_stamp=$(stat -c %Y "$l" 2>/dev/null) || held_stamp=0; fi; case "$held_stamp" in \'\'|*[!0-9]*) held_stamp=0;; esac';
28
+ /** Prints the release the pointer `$2` under `$1` names, or an empty line for an absent link; a failing readlink fails the script. */
29
+ const POINTER = 'if [ -L "$1/$2" ]; then basename "$(readlink "$1/$2")"; else printf \'\\n\'; fi';
30
+ /**
31
+ * The named operations of the release store. `lock-acquire`: `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is replaced by
32
+ * rename, a lock unrefreshed for $4 seconds is moved aside (one rename wins) and claimed anew; prints `acquired` or `busy <trigger> <age>`.
33
+ */
34
+ export const GATE_OPS = Object.freeze([
35
+ { name: 'pointer', args: ['base', 'pointer'], stdin: false, body: POINTER },
36
+ { name: 'list', args: ['base'], stdin: false, body: `for n in current previous; do if [ -L "$1/$n" ]; then basename "$(readlink "$1/$n")"; else printf '\\n'; fi; done; if [ -d "$1/releases" ]; then ls -1t "$1/releases"; fi` },
37
+ { name: 'read', args: ['base', 'release'], stdin: false, body: `set -e; s="$1/releases/$2"; for f in stack.yaml release.json; do test -r "$s/$f"; base64 < "$s/$f" | tr -d '\\n'; printf '\\n'; done` },
38
+ { name: 'store', args: ['base', 'release'], stdin: true, body: 'set -e; d="$1/releases/$2"; 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"' },
39
+ { name: 'accept', args: ['base', 'release'], stdin: false, body: 'set -e; cd "$1"; id="$2"; 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' },
40
+ {
41
+ name: 'lock-acquire', args: ['base', 'token', 'trigger', 'seconds'], stdin: false,
42
+ body: [
43
+ 'b="$1"; tok="$2"; trg="$3"; ttl="$4"; l="$b/deploy.lock"; now=$(date +%s); mkdir -p "$b" || exit 1',
44
+ 'claim() { printf \'%s %s %s\\n\' "$tok" "$trg" "$now" > "$l/owner.new" && mv -f "$l/owner.new" "$l/owner" && echo acquired && exit 0; exit 1; }',
45
+ 'if mkdir "$l" 2>/dev/null; then claim; fi',
46
+ OWNER_READ,
47
+ 'age=$((now - held_stamp)); if [ "$age" -gt "$ttl" ]; then if mv "$l" "$l.stale.$tok" 2>/dev/null; then rm -rf "$l.stale.$tok"; if mkdir "$l" 2>/dev/null; then claim; fi; fi; fi',
48
+ 'case "$held_trigger" in ci|dispatch|urgent) ;; *) held_trigger=unknown;; esac; echo "busy $held_trigger $age"',
49
+ ].join('; '),
50
+ },
51
+ { name: 'lock-refresh', args: ['base', 'token'], stdin: false, body: 'l="$1/deploy.lock"; now=$(date +%s); if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = "$2" ]; 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
+ { name: 'lock-release', args: ['base', 'token'], stdin: false, body: 'l="$1/deploy.lock"; if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = "$2" ]; then rm -rf "$l" && echo released; else echo lost; fi; else echo lost; fi' },
53
+ { name: 'journal-write', args: ['base', 'attempt'], stdin: true, body: 'set -e; d="$1/deploys"; mkdir -p "$d"; IFS= read -r doc; printf \'%s\' "$doc" | base64 -d > "$d/.$2.tmp"; mv -f "$d/.$2.tmp" "$d/$2.json"' },
54
+ { name: 'journal-index', args: ['base'], stdin: true, body: 'set -e; mkdir -p "$1/deploys"; IFS= read -r line; printf \'%s\\n\' "$(printf \'%s\' "$line" | base64 -d)" >> "$1/deploys.jsonl"' },
55
+ // The newest 500 attempt documents, one base64 line each, oldest first (attempt ids sort by their UTC time).
56
+ { name: 'journal-read', args: ['base'], stdin: false, body: 'd="$1/deploys"; [ -d "$d" ] || exit 0; names=$(ls -1 "$d") || exit 1; printf \'%s\\n\' "$names" | tail -n 500 | while IFS= read -r f; do case "$f" in ' + `${'[0-9]'.repeat(8)}T${'[0-9]'.repeat(6)}Z-${'[0-9a-f]'.repeat(8)}.json` + ') base64 < "$d/$f" | tr -d \'\\n\'; printf \'\\n\';; esac; done' },
57
+ ]);
58
+ const BY_NAME = new Map(GATE_OPS.map((op) => [op.name, op]));
59
+ /** The operation called `name`; an unknown name is a defect of the caller. */
60
+ export function gateOp(name) {
61
+ const op = BY_NAME.get(name);
62
+ if (!op)
63
+ throw refusal('STACK_GATE_OP', `${name} is not an operation of the ssh gate`);
64
+ return op;
65
+ }
66
+ function checkedArguments(op, args) {
67
+ if (args.length !== op.args.length)
68
+ throw refusal('STACK_GATE_ARGUMENT', `${op.name} takes ${op.args.length} argument(s)`);
69
+ op.args.forEach((kind, index) => {
70
+ if (!ARG_TESTS[kind](args[index]))
71
+ throw refusal('STACK_GATE_ARGUMENT', `argument ${index + 1} of ${op.name} is not a plain ${kind}`);
72
+ });
73
+ return args;
74
+ }
75
+ /** The command the CI transport sends: `starci-op <name> <argument>...` (every argument is plain characters, so nothing needs quoting). */
76
+ export function gatedCommand(name, args) {
77
+ const op = gateOp(name);
78
+ return ['starci-op', op.name, ...checkedArguments(op, args)].join(' ');
79
+ }
80
+ /** The same operation as one command for a key without the gate: the body under `sh -c` with the arguments as positional parameters. */
81
+ export function inlineCommand(name, args) {
82
+ const op = gateOp(name);
83
+ return `sh -c ${shellQuote(op.body)} starci-op ${checkedArguments(op, args).map(shellQuote).join(' ')}`;
84
+ }
85
+ const VALIDATOR = Object.freeze({
86
+ base: 'v_base', release: 'v_release', pointer: 'v_pointer', token: 'v_token', trigger: 'v_trigger', attempt: 'v_attempt', seconds: 'v_seconds',
87
+ });
88
+ /** The sh variable that holds the body of an operation. */
89
+ const bodyVariable = (name) => `OP_${name.replaceAll('-', '_')}`;
90
+ /** The POSIX sh text of `starci-ssh-gate`, to install executable at /usr/local/bin/starci-ssh-gate on the manager. */
91
+ export function gateScript() {
92
+ const digits = (count, set = '[0-9]') => set.repeat(count);
93
+ const bodies = GATE_OPS.map((op) => `${bodyVariable(op.name)}=${shellQuote(op.body)}`);
94
+ const cases = GATE_OPS.map((op) => {
95
+ const checks = op.args.map((kind, index) => `${VALIDATOR[kind]} "$${index + 1}"`).join(' && ');
96
+ return ` ${op.name}) [ $# -eq ${op.args.length} ]${checks ? ` && ${checks}` : ''} || refuse; exec sh -c "$${bodyVariable(op.name)}" starci-op "$@";;`;
97
+ });
98
+ return [
99
+ '#!/bin/sh',
100
+ '# starci-ssh-gate: the forced command of the CI key (generated by `starci app stack ci-key --print-gate`; do not edit, regenerate).',
101
+ '# Admits `docker system dial-stdio` and the named operations of the release store, nothing else. A refusal says nothing about the request.',
102
+ 'PATH="$PATH:/usr/local/bin:/usr/bin:/bin"',
103
+ "refuse() { echo 'starci-ssh-gate: command refused' >&2; exit 126; }",
104
+ 'v_base() { case "$1" in /*) ;; *) return 1;; esac; case "$1" in *[!A-Za-z0-9._/-]*|*..*) return 1;; esac; }',
105
+ 'v_hex() { case "$2" in \'\'|*[!0-9a-f]*) return 1;; esac; [ "${#2}" -eq "$1" ]; }',
106
+ 'v_release() { v_hex 16 "$1"; }',
107
+ 'v_token() { v_hex 32 "$1"; }',
108
+ 'v_pointer() { case "$1" in current|previous) ;; *) return 1;; esac; }',
109
+ 'v_trigger() { case "$1" in ci|dispatch|urgent) ;; *) return 1;; esac; }',
110
+ `v_attempt() { case "$1" in ${digits(8)}T${digits(6)}Z-${digits(8, '[0-9a-f]')}) ;; *) return 1;; esac; v_hex 8 "\${1#*Z-}"; }`,
111
+ 'v_seconds() { case "$1" in \'\'|*[!0-9]*) return 1;; esac; [ "${#1}" -le 6 ]; }',
112
+ ...bodies,
113
+ 'cmd=${SSH_ORIGINAL_COMMAND-}',
114
+ '[ -n "$cmd" ] || refuse',
115
+ 'if [ "$cmd" = \'docker system dial-stdio\' ]; then exec docker system dial-stdio; fi',
116
+ "IFS=' '; set -f",
117
+ 'set -- $cmd',
118
+ '[ $# -ge 2 ] && [ "$1" = starci-op ] || refuse',
119
+ 'op=$2; shift 2',
120
+ 'case "$op" in',
121
+ ...cases,
122
+ ' *) refuse;;',
123
+ 'esac',
124
+ '',
125
+ ].join('\n');
126
+ }
127
+ /** True when the CI transport was set up for a key that has the gate (`connect` exports STARCI_SSH_GATE=1 for a sealed `vps-ci` member that says `VPS_GATE: "1"`). */
128
+ export const gateIsOn = (env) => env?.STARCI_SSH_GATE === '1';
129
+ /** Where the gate is installed on the manager, and the forced-command word of the authorized_keys line. */
130
+ export const GATE_PATH = '/usr/local/bin/starci-ssh-gate';
131
+ /** The options of the CI key's line: `restrict` already denies everything optional; the explicit denials keep the line readable and survive a reader who forgets what `restrict` covers. */
132
+ export const AUTHORIZED_OPTIONS = Object.freeze(['restrict', 'command="starci-ssh-gate"', 'no-pty', 'no-port-forwarding', 'no-agent-forwarding', 'no-X11-forwarding', 'no-user-rc']);
133
+ const KEY_LINE = /^(ssh-ed25519|ecdsa-sha2-nistp(?:256|384|521)|ssh-rsa|sk-ssh-ed25519@openssh\.com) [A-Za-z0-9+/]+={0,3}(?: [A-Za-z0-9._@:+=-]+)?$/u;
134
+ /**
135
+ * The authorized_keys line of the CI key from its PUBLIC key text. Only a one-line public key is accepted (a private key, a line that already carries options
136
+ * or a second line is refused); the key keeps the comment it was generated with, which names it for revocation.
137
+ */
138
+ export function authorizedKeysLine(publicKey) {
139
+ const text = publicKey.trim();
140
+ if (text.includes('PRIVATE KEY'))
141
+ throw refusal('STACK_CI_KEY_PRIVATE', 'that is a private key: give the public half (.pub) of the CI key');
142
+ if (!KEY_LINE.test(text))
143
+ throw refusal('STACK_CI_KEY_PUBLIC', 'the CI public key must be one line: <type> <base64> [comment], with no options in front');
144
+ return `${AUTHORIZED_OPTIONS.join(',')} ${text}`;
145
+ }
@@ -39,7 +39,9 @@ export declare function compareWithRelease(expected: ExpectedRelease, live: read
39
39
  /** The collaborators of a status (the specs inject stubs). */
40
40
  export interface StatusDeps {
41
41
  ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
42
- releaseStore: (config: DeployConfig, ssh: Runner) => ReleaseStore;
42
+ releaseStore: (config: DeployConfig, ssh: Runner, options?: {
43
+ gated?: boolean | undefined;
44
+ }) => ReleaseStore;
43
45
  observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
44
46
  }
45
47
  /**
package/stack/status.mjs CHANGED
@@ -11,6 +11,7 @@ import { parseYaml } from '../runtime/engine/yaml.mjs';
11
11
  import { canonical } from './converge.mjs';
12
12
  import { lines as splitLines, toolOutput } from './context.mjs';
13
13
  import { createReleaseStore } from './release-store.mjs';
14
+ import { gateIsOn } from './ssh-gate.mjs';
14
15
  const DIGEST = /@(sha256:[0-9a-f]{64})$/u;
15
16
  const IMAGE_FIELD = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
16
17
  /** The `sha256:...` digest an image reference pins, or null. */
@@ -89,7 +90,7 @@ export async function statusStack(ctx, config, options = {}, deps = {}) {
89
90
  const d = { ...DEFAULT_DEPS, ...deps };
90
91
  const run = await d.ssh(ctx, config, { sshCmd: options.sshCmd ?? null });
91
92
  try {
92
- const store = d.releaseStore(config, run);
93
+ const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
93
94
  const current = await store.current();
94
95
  const live = d.observedServices(ctx, config);
95
96
  if (!current)