@starci/hfs 4.12.2 → 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.
Files changed (160) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -8,7 +8,38 @@ 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
+ /** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
13
+ export const LOCK_TTL_SECONDS = 3600;
14
+ const TOKEN = /^[0-9a-f]{32}$/u;
15
+ const TRIGGERS = ['ci', 'dispatch', 'urgent'];
16
+ /** An attempt id: UTC compact time and 8 random hex. */
17
+ export const ATTEMPT_ID = /^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$/u;
18
+ const checkedToken = (token) => {
19
+ if (typeof token !== 'string' || !TOKEN.test(token))
20
+ throw refusal('STACK_LOCK_TOKEN', 'a lock token is exactly 32 lowercase hex characters');
21
+ return token;
22
+ };
23
+ const checkedTrigger = (trigger) => {
24
+ if (typeof trigger !== 'string' || !TRIGGERS.includes(trigger))
25
+ throw refusal('STACK_LOCK_TRIGGER', 'a deploy trigger is ci, dispatch or urgent');
26
+ return trigger; // TRIGGERS holds exactly the LockTrigger members
27
+ };
28
+ const checkedAttempt = (attempt) => {
29
+ if (typeof attempt !== 'string' || !ATTEMPT_ID.test(attempt))
30
+ throw refusal('STACK_ATTEMPT_ID', 'an attempt id is a UTC compact time and 8 lowercase hex characters');
31
+ return attempt;
32
+ };
33
+ /**
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.
37
+ */
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)]);
12
43
  const BASE = /^\/[A-Za-z0-9._/-]+$/u;
13
44
  const FILES = ['stack.yaml', 'release.json'];
14
45
  /** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
@@ -20,7 +51,7 @@ const checkedId = (id) => {
20
51
  };
21
52
  const checkedBase = (config) => {
22
53
  const base = config.remote?.releasesBase ?? `/srv/${config.stack}`;
23
- if (!BASE.test(base) || base.split('/').includes('..'))
54
+ if (!BASE.test(base) || base.includes('..'))
24
55
  throw refusal('STACK_RELEASE_BASE', `the releases base ${base} must be an absolute path without ..`);
25
56
  return base;
26
57
  };
@@ -28,10 +59,11 @@ const checkedBase = (config) => {
28
59
  * The release store of one stack. `ssh` is the runner; the returned API is async:
29
60
  * store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
30
61
  */
31
- export function createReleaseStore(config, ssh) {
62
+ export function createReleaseStore(config, ssh, options = {}) {
32
63
  const base = checkedBase(config);
33
- const dirOf = (id) => `${base}/releases/${checkedId(id)}`;
34
- 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);
35
67
  const answer = await ssh(lfOnly(command), { input });
36
68
  if (answer.status !== 0)
37
69
  throw failure('STACK_RELEASE_SSH', `remote command failed: ${firstLine(answer.stderr)}`);
@@ -46,22 +78,30 @@ export function createReleaseStore(config, ssh) {
46
78
  }
47
79
  }
48
80
  async function pointer(name) {
49
- const out = await run(releasePointerScript(`"${base}"`, name));
81
+ const out = await run('pointer', [base, name]);
50
82
  const id = out.trim();
51
83
  return isReleaseId(id) ? id : '';
52
84
  }
85
+ /** Both files of a stored release as [name, text] pairs, framed as base64 lines. */
86
+ async function readFiles(id) {
87
+ const out = await run('read', [base, checkedId(id)]);
88
+ const frames = out.split('\n');
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)))
90
+ throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
91
+ return FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
92
+ }
53
93
  return {
54
94
  /** Uploads stack.yaml and release.json of a rendered release as text; read-only afterwards. */
55
95
  async store(dir, id) {
56
- const target = dirOf(id);
96
+ checkedId(id);
57
97
  const contents = FILES.map((name) => [name, readLocal(dir, name)]);
58
98
  const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
59
- 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);
60
100
  },
61
101
  /** The release was verified: previous := current, current := id. */
62
102
  async accept(id) {
63
103
  checkedId(id);
64
- 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]);
65
105
  },
66
106
  /** The accepted release id, '' when none. */
67
107
  current: () => pointer('current'),
@@ -69,20 +109,66 @@ export function createReleaseStore(config, ssh) {
69
109
  previous: () => pointer('previous'),
70
110
  /** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
71
111
  async fetch(id, targetDir) {
72
- const source = dirOf(id);
73
- const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
74
- const frames = out.split('\n');
75
- if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame)))
76
- throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
77
- const files = FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
112
+ const files = await readFiles(id);
78
113
  fs.mkdirSync(targetDir, { recursive: true });
79
114
  for (const [name, text] of files)
80
115
  fs.writeFileSync(path.join(targetDir, name), text);
81
116
  return targetDir;
82
117
  },
118
+ /** A stored release in memory: the stack bytes and the parsed release record. */
119
+ async read(id) {
120
+ const [stack, release] = await readFiles(id);
121
+ let record;
122
+ try {
123
+ record = JSON.parse(release[1]);
124
+ }
125
+ catch {
126
+ throw failure('STACK_RELEASE_STORE', `release ${id} has a release.json that is not JSON`);
127
+ }
128
+ return { stackYaml: stack[1], release: record !== null && typeof record === 'object' && !Array.isArray(record) ? record : {} };
129
+ },
130
+ async tryLock(token, trigger) {
131
+ const answer = (await run('lock-acquire', [base, checkedToken(token), checkedTrigger(trigger), String(LOCK_TTL_SECONDS)])).trim();
132
+ if (answer === 'acquired')
133
+ return { acquired: true };
134
+ const busy = /^busy ([a-z]+) (-?[0-9]+)$/u.exec(answer);
135
+ if (!busy)
136
+ throw failure('STACK_RELEASE_SSH', 'the deploy lock script answered something else than acquired or busy');
137
+ return { acquired: false, holder: busy[1], ageSeconds: Number(busy[2]) };
138
+ },
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'; },
141
+ journal(attempt) {
142
+ const id = checkedAttempt(attempt);
143
+ const encoded = (value) => `${Buffer.from(JSON.stringify(value), 'utf8').toString('base64')}
144
+ `;
145
+ return {
146
+ async write(document) {
147
+ await run('journal-write', [base, id], encoded(document));
148
+ },
149
+ async index(entry) {
150
+ await run('journal-index', [base], encoded(entry));
151
+ },
152
+ };
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
+ },
83
169
  /** The stored releases, newest first: [{ id, current, previous }]. */
84
170
  async list() {
85
- const response = await run(`${releasePointerScript(`"${base}"`, 'current')}; ${releasePointerScript(`"${base}"`, 'previous')}; ${releaseListScript(`"${base}"`)}`);
171
+ const response = await run('list', [base]);
86
172
  const [current, previous, ...entries] = response.split('\n');
87
173
  const now = isReleaseId(current) ? current : '';
88
174
  const before = isReleaseId(previous) ? previous : '';
@@ -73,6 +73,8 @@ export declare function openSealedSession(ctx: StackContext, config: DeployConfi
73
73
  export declare const ownerSshRunner: (ctx: StackContext, config: DeployConfig, options?: SshOptions) => Runner;
74
74
  /** Opens the runner (`deps.ssh` wins in specs), calls `body(run, notes)` and always cleans the session up. */
75
75
  export declare function withRunner<T>(ctx: StackContext, config: DeployConfig, options: SshOptions, deps: RunnerDeps, body: (run: Runner, notes: string[]) => T | Promise<T>): Promise<T>;
76
+ /** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
77
+ export declare const CI_MEMBER = "vps-ci";
76
78
  /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
77
79
  export declare function connectCi(ctx: StackContext, config: DeployConfig, { execute }?: {
78
80
  execute?: boolean;
package/stack/remote.mjs CHANGED
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { tool } from './context.mjs';
16
16
  import { restrictToUser, writePrivate } from './private-file.mjs';
17
17
  import { refusal } from './errors.mjs';
18
+ import { openMember as openSealedMember } from './sealed.mjs';
18
19
  import { createSshSession, sessionDirectory } from './ssh-session.mjs';
19
20
  import { assertStackAccount } from './target-binding.mjs';
20
21
  const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
@@ -215,22 +216,36 @@ function ciHome(ctx) {
215
216
  throw refusal('STACK_CONNECT_NO_HOME', 'HOME is not set');
216
217
  return path.join(ctx.env.HOME, '.ssh');
217
218
  }
218
- function ciInputs(ctx) {
219
+ /** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
220
+ export const CI_MEMBER = 'vps-ci';
221
+ /**
222
+ * The CI transport values: the sealed member `vps-ci` of the environment (opened with the server identity the job holds) when it exists, else the
223
+ * variables of the job (the transition, and the owner's own runs). The member keeps the host, the account, the key and the pinned host keys out of
224
+ * GitHub and of a public log; the pinned host keys are reviewed in git as ciphertext.
225
+ */
226
+ function ciValues(ctx, config) {
227
+ const present = typeof config.secretsDir === 'string' && fs.existsSync(memberFile(config.secretsDir, CI_MEMBER));
228
+ if (!present)
229
+ return { values: ctx.env, sealed: false };
230
+ const map = openSealedMember(ctx, config, CI_MEMBER);
231
+ return { values: Object.fromEntries([...CI_VARIABLES, 'VPS_GATE'].map((name) => [name, typeof map[name] === 'string' ? map[name] : undefined])), sealed: true };
232
+ }
233
+ function ciInputs(ctx, values, sealed) {
219
234
  for (const name of CI_VARIABLES)
220
- if (!ctx.env[name])
221
- throw refusal('STACK_CONNECT_MISSING', `${name} is not set`);
235
+ if (!values[name])
236
+ throw refusal('STACK_CONNECT_MISSING', sealed ? `the sealed member ${CI_MEMBER} holds no ${name}` : `${name} is not set and the environment has no sealed member ${CI_MEMBER}`);
222
237
  const [host, user] = ['VPS_HOST', 'VPS_USER'].map((name) => {
223
- const value = ctx.env[name]; // set: every CI variable was checked above
238
+ const value = values[name]; // set: every CI variable was checked above
224
239
  if (!ACCOUNT.test(value) || value.startsWith('-'))
225
240
  throw refusal('STACK_CONNECT_INVALID', `${name} must match ${ACCOUNT}`);
226
241
  return value;
227
242
  });
228
- const key = lfOnly(ctx.env.VPS_SSH_KEY);
243
+ const key = lfOnly(values.VPS_SSH_KEY);
229
244
  if (!key.includes('PRIVATE KEY'))
230
245
  throw refusal('STACK_CONNECT_INVALID', 'VPS_SSH_KEY is not a PEM private key');
231
246
  if (!ctx.env.GITHUB_ENV)
232
247
  throw refusal('STACK_CONNECT_NO_GITHUB_ENV', 'GITHUB_ENV is not set');
233
- return { key: withNewline(key), known: withNewline(lfOnly(ctx.env.VPS_KNOWN_HOSTS)), host, user };
248
+ return { key: withNewline(key), known: withNewline(lfOnly(values.VPS_KNOWN_HOSTS)), host, user };
234
249
  }
235
250
  const sshBlock = (alias, inputs) => [
236
251
  '', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
@@ -240,18 +255,24 @@ const sshBlock = (alias, inputs) => [
240
255
  /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
241
256
  export async function connectCi(ctx, config, { execute = false } = {}) {
242
257
  const dir = ciHome(ctx);
243
- const inputs = ciInputs(ctx);
258
+ const { values, sealed } = ciValues(ctx, config);
259
+ const inputs = ciInputs(ctx, values, sealed);
244
260
  const alias = config.remote.host;
245
- const plan = [`${dir}/deploy_key (0600) from VPS_SSH_KEY`, `${dir}/known_hosts (0644) from VPS_KNOWN_HOSTS`, `a Host ${alias} block in ${dir}/config`, `DOCKER_HOST=ssh://${alias} in GITHUB_ENV`];
246
- const data = { host: alias, files: ['deploy_key', 'known_hosts', 'config'] };
261
+ const gated = values.VPS_GATE === '1';
262
+ const origin = sealed ? `the sealed member ${CI_MEMBER}` : 'VPS_SSH_KEY';
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 };
247
265
  if (!execute)
248
266
  return { ok: true, lines: plan.map((item) => `dry run: would write ${item}`), data };
249
267
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
250
268
  writeWithMode(path.join(dir, 'deploy_key'), inputs.key, 0o600);
251
269
  writeWithMode(path.join(dir, 'known_hosts'), inputs.known, 0o644);
252
270
  fs.appendFileSync(path.join(dir, 'config'), sshBlock(alias, inputs), { mode: 0o600 });
253
- fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}\n`);
254
- return { ok: true, lines: plan.map((item) => `wrote ${item}`), data };
271
+ fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}
272
+ ${gated ? 'STARCI_SSH_GATE=1' + String.fromCharCode(10) : ''}`);
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.
274
+ const masks = sealed ? [inputs.host, inputs.user, config.remote.releasesBase].filter((value) => typeof value === 'string' && value.length >= 4).map((value) => `::add-mask::${value}`) : [];
275
+ return { ok: true, lines: [...masks, ...plan.map((item) => `wrote ${item}`)], data };
255
276
  }
256
277
  /** CI cleanup: closes the ssh control master (failure ignored) and removes the deploy key. */
257
278
  export async function disconnectCi(ctx, config, { execute = false } = {}) {
@@ -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
+ }
@@ -28,6 +28,8 @@ export interface ServerKeyOptions {
28
28
  export declare const SERVER_AGE_IDENTITY = "/root/.config/sops/age/keys.txt";
29
29
  /** The managed workflow `--restore github` dispatches (templates/app/ci-workflows*). */
30
30
  export declare const RESTORE_WORKFLOW = "stack-key-restore.yml";
31
+ /** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
32
+ export declare const AS_ROOT = "if [ \"$(id -u)\" -eq 0 ]; then sh -s; else sudo -n sh -s; fi";
31
33
  /**
32
34
  * The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
33
35
  * /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
@@ -37,7 +37,7 @@ export const SERVER_AGE_IDENTITY = '/root/.config/sops/age/keys.txt';
37
37
  export const RESTORE_WORKFLOW = 'stack-key-restore.yml';
38
38
  const RECIPIENT = /^age1[a-z0-9]+$/u;
39
39
  /** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
40
- const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
40
+ export const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
41
41
  /**
42
42
  * The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
43
43
  * /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ import type { DeployConfig } from './config.mjs';
2
+ import type { StackContext } from './context.mjs';
3
+ import type { ReleaseStore } from './release-store.mjs';
4
+ import type { ActionResult, Runner, SshOptions } from './remote.mjs';
5
+ /** One live service of the stack as `docker service inspect` reports it (names, digests and states only; no environment, no secret). */
6
+ export interface ObservedService {
7
+ /** The service name without the stack prefix (`api` for `shop_api`). */
8
+ service: string;
9
+ /** The image reference of the service spec, or null when it has none. */
10
+ image: string | null;
11
+ /** The `sha256:...` digest the image reference pins, or null when it is a tag only. */
12
+ digest: string | null;
13
+ /** The update state of the service (`completed`, `rollback_completed`, ...), null when it was never updated. */
14
+ updateState: string | null;
15
+ /** The first 16 hex of the sha256 of the canonical task template: it changes when anything a task runs with changes. */
16
+ taskTemplateHash: string | null;
17
+ }
18
+ /** The kinds of drift a status reports. */
19
+ export type DriftKind = 'rolled back by swarm' | 'updated outside a release' | 'missing' | 'not in the release';
20
+ /** One service that does not run what the accepted release says. */
21
+ export interface ServiceDrift {
22
+ service: string;
23
+ kind: DriftKind;
24
+ detail: string;
25
+ }
26
+ /** The `sha256:...` digest an image reference pins, or null. */
27
+ export declare const digestOf: (image: string | null | undefined) => string | null;
28
+ /** The image of each service of a stack file, by service name (the pinned references of a rendered release). */
29
+ export declare function stackImages(stackYaml: string): Record<string, string>;
30
+ /** The live services of the stack, sorted by name. Throws when docker cannot be asked. */
31
+ export declare function observedServices(ctx: Pick<StackContext, 'spawn'>, config: Pick<DeployConfig, 'stack'>): ObservedService[];
32
+ /** What a release says its services run: the stack's images and, when it was accepted by this version, the services observed at the accept. */
33
+ export interface ExpectedRelease {
34
+ stackYaml: string;
35
+ release: Record<string, unknown>;
36
+ }
37
+ /** Compares the live services with a release; an empty list means they run the release. */
38
+ export declare function compareWithRelease(expected: ExpectedRelease, live: readonly ObservedService[]): ServiceDrift[];
39
+ /** The collaborators of a status (the specs inject stubs). */
40
+ export interface StatusDeps {
41
+ ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
42
+ releaseStore: (config: DeployConfig, ssh: Runner, options?: {
43
+ gated?: boolean | undefined;
44
+ }) => ReleaseStore;
45
+ observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
46
+ }
47
+ /**
48
+ * `stack status`: the accepted release against the live services. Returns { ok, lines, data: { release, sourceSha, drift, services } }; ok is false exactly
49
+ * when a service drifted. An environment with no accepted release reports that and is not a drift.
50
+ */
51
+ export declare function statusStack(ctx: StackContext, config: DeployConfig, options?: SshOptions, deps?: Partial<StatusDeps>): Promise<ActionResult>;