@starci/hfs 4.13.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +1 -1
  3. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  4. package/runtime/knowledge/hfs/rules.yaml +140 -2
  5. package/runtime/knowledge/hfs/slots.yaml +6 -2
  6. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  7. package/runtime/modules/schemas/application-stacks.schema.yaml +22 -0
  8. package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
  9. package/runtime/scripts/hfs/manifest-shape.d.mts +6 -0
  10. package/runtime/scripts/hfs/rule-params-shape.d.mts +5 -0
  11. package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
  12. package/runtime/scripts/hfs/slots.d.mts +2 -1
  13. package/runtime/scripts/hfs/slots.mjs +15 -5
  14. package/runtime/scripts/hfs/stack-deploy-config.d.mts +12 -0
  15. package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -1
  16. package/src/stack-records-double.d.mts +3 -1
  17. package/stack/ci-key.d.mts +11 -0
  18. package/stack/ci-key.mjs +44 -0
  19. package/stack/cli.d.mts +5 -1
  20. package/stack/cli.mjs +9 -6
  21. package/stack/config.d.mts +9 -0
  22. package/stack/deploy.d.mts +4 -1
  23. package/stack/deploy.mjs +10 -5
  24. package/stack/journal.d.mts +19 -0
  25. package/stack/journal.mjs +11 -2
  26. package/stack/notify.d.mts +9 -0
  27. package/stack/notify.mjs +32 -0
  28. package/stack/release-store.d.mts +10 -6
  29. package/stack/release-store.mjs +40 -37
  30. package/stack/remote.mjs +5 -4
  31. package/stack/rollback-guard.d.mts +10 -0
  32. package/stack/rollback-guard.mjs +41 -0
  33. package/stack/ssh-gate.d.mts +35 -0
  34. package/stack/ssh-gate.mjs +145 -0
  35. package/stack/status.d.mts +3 -1
  36. package/stack/status.mjs +2 -1
  37. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +25 -6
  38. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +13 -3
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)
@@ -74,23 +74,42 @@ describe("callDbFunction", () => {
74
74
  })
75
75
 
76
76
  describe("advisoryXactLock", () => {
77
- it("locks the 64-bit hash of the key for the transaction", async () => {
77
+ it("hashes the key, then locks the hash for the transaction, both as function calls", async () => {
78
78
  const client = await setup()
79
- const manager = mockEntityManager({ query: ["SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [{}]] })
79
+ const manager = mockEntityManager({
80
+ query: [
81
+ ['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', [{ hashtextextended: "42" }]],
82
+ ['SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', [{}]],
83
+ ],
84
+ })
80
85
 
81
86
  await expect(client.advisoryXactLock(manager, "jobs:mail")).resolves.toBeUndefined()
82
87
 
83
- expect(manager.query).toHaveBeenCalledWith("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", ["jobs:mail"])
88
+ expect(manager.query).toHaveBeenNthCalledWith(1, 'SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', ["jobs:mail", 0])
89
+ expect(manager.query).toHaveBeenNthCalledWith(2, 'SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', ["42"])
90
+ })
91
+
92
+ it.each([
93
+ ["no row", []],
94
+ ["a row with no hash", [{}]],
95
+ ["a hash that is not a number", [{ hashtextextended: null }]],
96
+ ])("refuses %s before it locks", async (_case, rows) => {
97
+ const client = await setup()
98
+ const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', rows] })
99
+
100
+ await expect(client.advisoryXactLock(manager, "jobs:mail")).rejects.toMatchObject({ code: DatabaseErrorCode.ReturningRowRejected })
101
+
102
+ expect(manager.query).toHaveBeenCalledTimes(1)
84
103
  })
85
104
  })
86
105
 
87
106
  describe("notify", () => {
88
- it("sends the payload on the channel", async () => {
107
+ it("sends the payload on the channel as a function call", async () => {
89
108
  const client = await setup()
90
- const manager = mockEntityManager({ query: ["SELECT pg_notify($1, $2)", [{}]] })
109
+ const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', [{}]] })
91
110
 
92
111
  await expect(client.notify(manager, "jobs", '{"id":"job-1"}')).resolves.toBeUndefined()
93
112
 
94
- expect(manager.query).toHaveBeenCalledWith("SELECT pg_notify($1, $2)", ["jobs", '{"id":"job-1"}'])
113
+ expect(manager.query).toHaveBeenCalledWith('SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', ["jobs", '{"id":"job-1"}'])
95
114
  })
96
115
  })
@@ -82,12 +82,22 @@ export const callDbFunction = <T>(manager: EntityManager, name: string, args: Re
82
82
  return manager.query<Array<T>>(`SELECT * FROM "${target.schema}"."${target.name}"(${parameters})`, [...args])
83
83
  }
84
84
 
85
- /** Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends. */
85
+ /** The refusal of a hash answer that is not the number the lock takes. */
86
+ const hashRejected = (key: string): DatabaseError =>
87
+ new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected, params: { name: "hashtextextended", key } })
88
+
89
+ /**
90
+ * Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends.
91
+ * Both steps are `callDbFunction` calls of listed pg built-ins, so no SQL text is written here (R334).
92
+ */
86
93
  export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
87
- await manager.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
94
+ const rows = await callDbFunction<Record<string, unknown>>(manager, "hashtextextended", [key, 0])
95
+ const hash = rows[0]?.["hashtextextended"]
96
+ if (typeof hash !== "string" && typeof hash !== "number" && typeof hash !== "bigint") throw hashRejected(key)
97
+ await callDbFunction(manager, "pg_advisory_xact_lock", [hash])
88
98
  }
89
99
 
90
100
  /** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
91
101
  export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
92
- await manager.query("SELECT pg_notify($1, $2)", [channel, payload])
102
+ await callDbFunction(manager, "pg_notify", [channel, payload])
93
103
  }