@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 +15 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/knowledge/hfs/slots.yaml +1 -1
- package/runtime/modules/schemas/application-stacks.schema.yaml +22 -0
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +12 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -1
- package/src/stack-records-double.d.mts +3 -1
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +5 -1
- package/stack/cli.mjs +9 -6
- package/stack/config.d.mts +9 -0
- package/stack/deploy.d.mts +4 -1
- package/stack/deploy.mjs +10 -5
- package/stack/journal.d.mts +19 -0
- package/stack/journal.mjs +11 -2
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +10 -6
- package/stack/release-store.mjs +40 -37
- package/stack/remote.mjs +5 -4
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +3 -1
- package/stack/status.mjs +2 -1
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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>;
|
package/stack/ci-key.mjs
ADDED
|
@@ -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];
|
package/stack/config.d.mts
CHANGED
|
@@ -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
|
}
|
package/stack/deploy.d.mts
CHANGED
|
@@ -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
|
|
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 } };
|
package/stack/journal.d.mts
CHANGED
|
@@ -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
|
-
|
|
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[]>;
|
package/stack/notify.mjs
ADDED
|
@@ -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
|
|
58
|
-
* unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
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
|
|
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
|
|
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
|
|
74
|
+
export declare function createReleaseStore(config: Pick<DeployConfig, 'remote' | 'stack'>, ssh: StoreSsh, options?: {
|
|
75
|
+
gated?: boolean | undefined;
|
|
76
|
+
}): ReleaseStore;
|
package/stack/release-store.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
36
|
-
* unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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
|
-
|
|
76
|
-
async function run(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
151
|
-
async unlock(token) { return (await run(
|
|
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(
|
|
147
|
+
await run('journal-write', [base, id], encoded(document));
|
|
160
148
|
},
|
|
161
149
|
async index(entry) {
|
|
162
|
-
await run(
|
|
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(
|
|
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
|
+
}
|
package/stack/status.d.mts
CHANGED
|
@@ -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
|
|
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)
|