@starci/hfs 4.16.1 → 4.16.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 +9 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/modules/kernel/failure-codes.yaml +1 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +74 -7
- package/runtime/scripts/hfs/rules/env-parity.mjs +22 -5
- package/runtime/scripts/hfs/rules/stacks-release.mjs +11 -3
- package/runtime/scripts/hfs/secret-standard.d.mts +7 -0
- package/runtime/scripts/hfs/secret-standard.mjs +11 -2
- package/runtime/scripts/lib/sealed-member.d.mts +2 -0
- package/runtime/scripts/lib/sealed-member.mjs +2 -0
- package/stack/remote.d.mts +2 -1
- package/stack/remote.mjs +2 -2
- package/stack/ssh-gate.d.mts +3 -1
- package/stack/ssh-gate.mjs +17 -2
- package/sync/index.mjs +3 -0
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +35 -16
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +35 -16
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +0 -4
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +0 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.16.2 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Fixed: R248 `ENV_DUPLICATE` judges app env only. The operator's SSH member (`deploy.remote.sealed`) and the CI member `vps-ci` are separate identities by design (ruling ci-transport-one-home) and never reach an app container, so the SSH group keys (`SSH_GROUP`, now exported from `secret-standard`) held by exactly those two members of one environment are no duplicate; an app key in two members, or an SSH group key in any third member, stays red.
|
|
6
|
+
- Changed: the CI transport lives in the sealed `vps-ci` alone and GitHub keeps only `SOPS_AGE_KEY`: `stack-key-restore.yml` (both editions) opens the sealed member with the same `stack connect` step as the deploy instead of `VPS_*` environment secrets, and the starters no longer declare `VPS_SSH_KEY`, `VPS_KNOWN_HOSTS`, `VPS_HOST` and `VPS_USER` under `ci:`.
|
|
7
|
+
- Added: the ssh gate operation `identity-restore <recipient>` (stdin: the identity; writes only the fixed server identity path, once, for the matching recipient), so the restore workflow is admitted when the CI key is forced through `starci-ssh-gate`; `CI_MEMBER` moves to `sealed-member`.
|
|
8
|
+
- Fixed: R259 `DB_MIGRATION_DESTRUCTIVE` leaves out `SET NOT NULL` and a type change on a column (or table) the same migration created earlier, which touches no existing data, and an entity references a table only by the name its `@Entity`/`@Table` decorator spells: a column property of the same word (`queue`) is no reference to a dropped table `queue`; an entity that spells no name keeps the identifier match.
|
|
9
|
+
- Fixed: `HFS_SCHEDULE_UNDECLARED` judges unparsable SQL only when it calls `cron.schedule`, `cron.unschedule`, `cron.schedule_in_database` or `cron.alter_job`; a statement of another dialect (MySQL session setup) schedules nothing.
|
|
10
|
+
- Changed: the bundled canon pins name hfs 4.16.2, @starci/grammar 0.19.1, eslint-canon-be 3.11.3, eslint-canon-fe 9.8.2 and eslint-grammar-core and eslint-grammar-offset-pop 0.19.1.
|
|
11
|
+
|
|
3
12
|
## 4.16.1 - 2026-10-31
|
|
4
13
|
|
|
5
14
|
- Changed: the bundled canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.19.0, eslint-canon-be 3.11.2, eslint-canon-fe 9.8.1, stylelint-canon 2.4.0 and hfs 4.16.1 (RELEASE_VERSION_NOT_BUMPED: 4.16.0 is on npm).
|
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.19.
|
|
34
|
+
version: 0.19.1
|
|
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.11.
|
|
41
|
+
version: 3.11.3
|
|
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.8.
|
|
48
|
+
version: 9.8.2
|
|
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.19.
|
|
55
|
+
version: 0.19.1
|
|
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.19.
|
|
62
|
+
version: 0.19.1
|
|
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.16.
|
|
109
|
+
version: 4.16.2
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -1989,6 +1989,7 @@ ENV_DUPLICATE:
|
|
|
1989
1989
|
causes_vi:
|
|
1990
1990
|
- "Vi phạm luật R248: một khoá chỉ sống trong một member; hai bản sao lệch giá trị là lỗi vận hành chờ xảy ra."
|
|
1991
1991
|
- "Khoá được di chuyển sang member mới nhưng quên gỡ khỏi member cũ."
|
|
1992
|
+
- "Chỉ các khoá nhóm SSH (`VPS_HOST`, `VPS_USER`, `privateKey`, `knownHosts`) được phép nằm ở cả member `deploy.remote.sealed` lẫn `vps-ci` của cùng môi trường; mọi khoá app khác, hoặc khoá SSH ở member thứ ba, vẫn bị báo."
|
|
1992
1993
|
nextStep_vi: "Giữ khoá ở đúng một member và gỡ nó khỏi member còn lại (`starci app secret set` rồi chỉnh lại member thừa)."
|
|
1993
1994
|
owner: op-retry
|
|
1994
1995
|
kind: check-finding
|
|
@@ -369,7 +369,7 @@ function columnAlterObject(tokens, index, table, line) {
|
|
|
369
369
|
&& sqlWord(tokens[at + 2 + Number(sqlWord(tokens[at + 1], 'column'))], 'set') && sqlWord(tokens[at + 3 + Number(sqlWord(tokens[at + 1], 'column'))], 'default'));
|
|
370
370
|
if (!type && !(notNull && !defaultSet))
|
|
371
371
|
return null;
|
|
372
|
-
return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
|
|
372
|
+
return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', alters: true, line };
|
|
373
373
|
}
|
|
374
374
|
function alteredColumn(table, column, line) {
|
|
375
375
|
return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
|
|
@@ -467,11 +467,32 @@ function globalConstraintNames(statement) {
|
|
|
467
467
|
}
|
|
468
468
|
return names;
|
|
469
469
|
}
|
|
470
|
+
const ENTITY_DECORATORS = new Set(['Entity', 'Table', 'ViewEntity']);
|
|
471
|
+
/** Adds the table names a class decorator `@Entity(...)`, `@Table(...)` or `@ViewEntity(...)` spells to `tables`; true when such a decorator spells none. */
|
|
472
|
+
function noteEntityTables(ts, decorator, tables) {
|
|
473
|
+
const call = decorator.expression;
|
|
474
|
+
const callee = ts.isCallExpression(call) ? call.expression : call;
|
|
475
|
+
if (!ts.isIdentifier(callee) || !ENTITY_DECORATORS.has(callee.text))
|
|
476
|
+
return false;
|
|
477
|
+
const before = tables.size;
|
|
478
|
+
const collect = (node) => {
|
|
479
|
+
if (ts.isStringLiteralLike(node))
|
|
480
|
+
for (const part of [node.text, node.text.split('.').at(-1)])
|
|
481
|
+
tables.add(part);
|
|
482
|
+
ts.forEachChild(node, collect);
|
|
483
|
+
};
|
|
484
|
+
if (ts.isCallExpression(call))
|
|
485
|
+
for (const argument of call.arguments)
|
|
486
|
+
collect(argument);
|
|
487
|
+
return tables.size === before;
|
|
488
|
+
}
|
|
470
489
|
function sourceNameScopes(ts, text, file) {
|
|
471
490
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
472
491
|
const checker = lexicalChecker(ts, source);
|
|
473
492
|
const scopes = [];
|
|
474
493
|
const entityNames = /\.entity\.[cm]?tsx?$/.test(file) ? new Set() : null;
|
|
494
|
+
const entityTables = new Set();
|
|
495
|
+
let implicitTable = false;
|
|
475
496
|
const addSql = (sql) => {
|
|
476
497
|
for (const statement of databaseSqlStatements(databaseSqlTokens(sql))) {
|
|
477
498
|
const names = new Set(statement.filter((token) => sqlName(token) !== null).map((token) => token.v));
|
|
@@ -490,10 +511,15 @@ function sourceNameScopes(ts, text, file) {
|
|
|
490
511
|
addSql(sql);
|
|
491
512
|
if (entityNames && ts.isIdentifier(node))
|
|
492
513
|
entityNames.add(node.text);
|
|
514
|
+
if (entityNames && ts.isDecorator(node))
|
|
515
|
+
implicitTable = noteEntityTables(ts, node, entityTables) || implicitTable;
|
|
493
516
|
ts.forEachChild(node, visit);
|
|
494
517
|
};
|
|
495
518
|
visit(source);
|
|
496
|
-
|
|
519
|
+
// An entity names its table by the string of its @Entity/@Table decorator; a column property of the same word (`queue`) is no table reference.
|
|
520
|
+
// An entity that names none (or has no such decorator) keeps the conservative identifier match.
|
|
521
|
+
const tables = entityTables.size > 0 && !implicitTable ? entityTables : undefined;
|
|
522
|
+
return entityNames ? [{ names: entityNames, tables, constraints: entityNames, globalConstraints: new Set() }] : scopes;
|
|
497
523
|
}
|
|
498
524
|
/**
|
|
499
525
|
* Whether a be/src source references a destroyed object. A column (`<schema>.<table>.<column>`) is referenced only by a source that
|
|
@@ -501,9 +527,10 @@ function sourceNameScopes(ts, text, file) {
|
|
|
501
527
|
* SQL constraints require explicit constraint names, while entities retain their table/decorator association.
|
|
502
528
|
* Other object kinds match their leaf name conservatively.
|
|
503
529
|
*/
|
|
504
|
-
const referencesObject = (scopes, object) => scopes.some(({ names, constraints, globalConstraints }) => (object.kind === 'constraint' && globalConstraints.has(object.leaf))
|
|
505
|
-
|| (
|
|
506
|
-
|
|
530
|
+
const referencesObject = (scopes, object) => scopes.some(({ names, tables, constraints, globalConstraints }) => (object.kind === 'constraint' && globalConstraints.has(object.leaf))
|
|
531
|
+
|| (object.kind === 'table' && tables !== undefined ? tables.has(object.leaf)
|
|
532
|
+
: (object.kind === 'constraint' ? constraints : names).has(object.leaf)
|
|
533
|
+
&& (!['column', 'constraint'].includes(object.kind) || !object.table || (tables ?? names).has(object.table))));
|
|
507
534
|
function destructiveObjectFinding(file, object, contract, references) {
|
|
508
535
|
const reference = references.find(({ scopes }) => referencesObject(scopes, object));
|
|
509
536
|
if (contract && !reference)
|
|
@@ -512,10 +539,50 @@ function destructiveObjectFinding(file, object, contract, references) {
|
|
|
512
539
|
: 'use a *_contract_* migration after the application stops using this object';
|
|
513
540
|
return found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destructively changes ${object.name}; ${reason}`, { line: object.line, object: object.name, ...(reference ? { reference: reference.file } : {}) });
|
|
514
541
|
}
|
|
542
|
+
/** The `<table leaf>.<column leaf>` keys an ALTER TABLE ... ADD COLUMN statement adds, and `<table leaf>.*` for a CREATE TABLE. */
|
|
543
|
+
function addedColumns(statement) {
|
|
544
|
+
const added = [];
|
|
545
|
+
for (let index = 0; index < statement.length - 1; index += 1) {
|
|
546
|
+
if (sqlWord(statement[index], 'create')) {
|
|
547
|
+
const at = skipWords(statement, index + 1, ['temp', 'temporary', 'unlogged']);
|
|
548
|
+
const created = sqlWord(statement[at], 'table') ? nameAt(statement, skipWords(statement, at + 1, ['if', 'not', 'exists'])) : null;
|
|
549
|
+
if (created)
|
|
550
|
+
added.push(`${created.leaf}.*`);
|
|
551
|
+
}
|
|
552
|
+
if (!(sqlWord(statement[index], 'alter') && sqlWord(statement[index + 1], 'table')))
|
|
553
|
+
continue;
|
|
554
|
+
const table = nameAt(statement, skipWords(statement, index + 2, ['if', 'exists', 'only']));
|
|
555
|
+
if (!table)
|
|
556
|
+
continue;
|
|
557
|
+
const body = statement.slice(table.next);
|
|
558
|
+
body.forEach((token, at) => {
|
|
559
|
+
if (!sqlWord(token, 'add'))
|
|
560
|
+
return;
|
|
561
|
+
const start = skipWords(body, at + 1, ['column', 'if', 'not', 'exists']);
|
|
562
|
+
if (['constraint', 'primary', 'foreign', 'unique', 'check', 'exclude'].some((word) => sqlWord(body[start], word)))
|
|
563
|
+
return;
|
|
564
|
+
const column = nameAt(body, start);
|
|
565
|
+
if (column)
|
|
566
|
+
added.push(`${table.leaf}.${column.leaf}`);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return added;
|
|
570
|
+
}
|
|
571
|
+
/** An ALTER of a column (SET NOT NULL, TYPE) the same migration added earlier changes no existing data: it is not destructive. */
|
|
572
|
+
const alterOfNewColumn = (object, added) => object.alters === true && (added.has(`${object.table}.${object.leaf}`) || added.has(`${object.table}.*`));
|
|
573
|
+
/** The destructive objects of one migration's SQL, in order, leaving out the alters of columns and tables the migration created itself. */
|
|
574
|
+
function migrationDestructiveObjects(text) {
|
|
575
|
+
const added = new Set();
|
|
576
|
+
return databaseSqlStatements(databaseExecutableTokens(text)).flatMap((statement) => {
|
|
577
|
+
for (const key of addedColumns(statement))
|
|
578
|
+
added.add(key);
|
|
579
|
+
return destructiveObjects(statement).filter((object) => !alterOfNewColumn(object, added));
|
|
580
|
+
});
|
|
581
|
+
}
|
|
515
582
|
function destructiveFileFindings(file, text, references) {
|
|
516
583
|
const contract = /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file);
|
|
517
584
|
const seen = new Set();
|
|
518
|
-
const objects =
|
|
585
|
+
const objects = migrationDestructiveObjects(text);
|
|
519
586
|
return objects.flatMap((object) => {
|
|
520
587
|
const key = `${object.line}:${object.kind}:${object.name}`;
|
|
521
588
|
if (seen.has(key))
|
|
@@ -564,7 +631,7 @@ export async function contractReleaseFindings({ repoRoot, migrations, git }) {
|
|
|
564
631
|
const text = readText(repoRoot, file);
|
|
565
632
|
if (text === null)
|
|
566
633
|
return [];
|
|
567
|
-
return
|
|
634
|
+
return migrationDestructiveObjects(text).flatMap((object) => {
|
|
568
635
|
const reference = references.find(({ scopes }) => referencesObject(scopes, object));
|
|
569
636
|
return reference ? [found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destroys ${object.name}, which the previous release ${release} still references in ${reference.file}; the contract migration runs right after the accept while that release is the rollback target: ship the code change in one release and this contract migration in the next`, { line: object.line, object: object.name, release, reference: reference.file })] : [];
|
|
570
637
|
});
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// `.starcistacks/<env>/runtime/env/KEYS.md`, and the `environment:`/`env:` entries, `runtimeSecrets`, `secrets`
|
|
14
14
|
// and `vars[].name` of every `.starcistacks` yaml. A `<KEY>_FILE` name declares `<KEY>`.
|
|
15
15
|
// Findings: ENV_REQUIRED_UNSET a required read sits in neither S nor D; ENV_SEALED_UNREAD a sealed key sits in
|
|
16
|
-
// neither R nor a declared consumer; ENV_DUPLICATE one key in two members of one environment
|
|
16
|
+
// neither R nor a declared consumer; ENV_DUPLICATE one app key in two members of one environment (the transport members, the operator's `deploy.remote.sealed` member and
|
|
17
|
+
// `vps-ci`, never reach an app container and may both hold the SSH_GROUP keys, nothing else); ENV_PUBLIC_SEALED a NEXT_PUBLIC_* key in
|
|
17
18
|
// custody; ENV_EXAMPLE_DRIFT a `*.env.example` key nothing reads. The rule judges the app root (`isRoot`): the whole
|
|
18
19
|
// tree `all` is its input, both editions.
|
|
19
20
|
import path from 'node:path';
|
|
@@ -21,6 +22,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
22
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
22
23
|
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
23
24
|
import { envelopeOf } from '../secret-document.mjs';
|
|
25
|
+
import { SSH_GROUP, sshTransportMembers } from '../secret-standard.mjs';
|
|
26
|
+
import { memberOfName } from '../../lib/sealed-member.mjs';
|
|
24
27
|
import { found, readText } from './read.mjs';
|
|
25
28
|
export const ENV_REQUIRED_UNSET = 'ENV_REQUIRED_UNSET';
|
|
26
29
|
export const ENV_SEALED_UNREAD = 'ENV_SEALED_UNREAD';
|
|
@@ -349,25 +352,39 @@ function sealedMemberFinding(key, member, reads, declared) {
|
|
|
349
352
|
}
|
|
350
353
|
return null;
|
|
351
354
|
}
|
|
352
|
-
|
|
355
|
+
/** The transport members per environment: the `deploy.remote.sealed` member(s) of application-stacks.yaml and `vps-ci`. */
|
|
356
|
+
function transportMembers(repoRoot) {
|
|
357
|
+
const text = readText(repoRoot, '.starcistacks/application-stacks.yaml');
|
|
358
|
+
let stacks = {};
|
|
359
|
+
try {
|
|
360
|
+
stacks = text === null ? {} : parseYaml(text) ?? {};
|
|
361
|
+
}
|
|
362
|
+
catch { /* a malformed stacks yaml is the shape rule's finding, not this one's */ }
|
|
363
|
+
return (environment) => sshTransportMembers(stacks.environments?.[environment]);
|
|
364
|
+
}
|
|
365
|
+
function addSealedFindings(sealed, reads, declared, transport, findings) {
|
|
353
366
|
for (const [key, members] of sealed) {
|
|
354
367
|
for (const member of members) {
|
|
355
368
|
const finding = sealedMemberFinding(key, member, reads, declared);
|
|
356
369
|
if (finding !== null)
|
|
357
370
|
findings.push(finding);
|
|
358
371
|
}
|
|
359
|
-
addDuplicateFindings(key, members, findings);
|
|
372
|
+
addDuplicateFindings(key, members, transport, findings);
|
|
360
373
|
}
|
|
361
374
|
}
|
|
362
|
-
|
|
375
|
+
/** One key in two members of one environment, except an SSH_GROUP key held by the two transport members (a classification: those never reach an app). */
|
|
376
|
+
function addDuplicateFindings(key, members, transport, findings) {
|
|
363
377
|
const firstByEnvironment = new Map();
|
|
364
378
|
for (const member of members) {
|
|
365
379
|
const environment = member.split('/')[1];
|
|
380
|
+
const exempt = (file) => SSH_GROUP.includes(key) && transport(environment).has(memberOfName(file.split('/').pop()));
|
|
366
381
|
const first = firstByEnvironment.get(environment);
|
|
367
382
|
if (first === undefined) {
|
|
368
383
|
firstByEnvironment.set(environment, member);
|
|
369
384
|
continue;
|
|
370
385
|
}
|
|
386
|
+
if (exempt(first) && exempt(member))
|
|
387
|
+
continue;
|
|
371
388
|
findings.push(found(ENV_DUPLICATE, member, `${key} is sealed in ${first} and again in ${member}; one key lives in one member per environment`));
|
|
372
389
|
}
|
|
373
390
|
}
|
|
@@ -394,7 +411,7 @@ export function envParityFindings({ repoRoot, all = [], isRoot }) {
|
|
|
394
411
|
const declared = declaredKeys(repoRoot, all);
|
|
395
412
|
const findings = [];
|
|
396
413
|
addRequiredFindings(reads, sealed, declared, findings);
|
|
397
|
-
addSealedFindings(sealed, reads, declared, findings);
|
|
414
|
+
addSealedFindings(sealed, reads, declared, transportMembers(repoRoot), findings);
|
|
398
415
|
addExampleFindings(repoRoot, all, reads, findings);
|
|
399
416
|
return findings;
|
|
400
417
|
}
|
|
@@ -245,6 +245,14 @@ function executableExpression(expression) {
|
|
|
245
245
|
}
|
|
246
246
|
return `SELECT ${query}`;
|
|
247
247
|
}
|
|
248
|
+
const PG_CRON_CALL = /\bcron\s*\.\s*(?:schedule|unschedule|schedule_in_database|alter_job)\b/i;
|
|
249
|
+
/**
|
|
250
|
+
* SQL Postgres cannot parse is judged only when it names a pg_cron call: a statement of another dialect (MySQL session setup, say) schedules nothing, so
|
|
251
|
+
* it is no unknown job, while unparsable text that does call cron.schedule stays an undeclared job nobody can read.
|
|
252
|
+
*/
|
|
253
|
+
function unparsedSchedule(text, file) {
|
|
254
|
+
return PG_CRON_CALL.test(text) ? [{ id: null, cadence: null, at: file }] : [];
|
|
255
|
+
}
|
|
248
256
|
function doSqlObservations(statement, file) {
|
|
249
257
|
const body = statement.args?.find((arg) => arg.DefElem?.defname === 'as')?.DefElem?.arg?.String?.sval;
|
|
250
258
|
if (!present(body))
|
|
@@ -254,7 +262,7 @@ function doSqlObservations(statement, file) {
|
|
|
254
262
|
tree = pg.parsePlPgSQLSync("DO '" + body.replaceAll("'", "''") + "';");
|
|
255
263
|
}
|
|
256
264
|
catch {
|
|
257
|
-
return
|
|
265
|
+
return unparsedSchedule(body, file);
|
|
258
266
|
}
|
|
259
267
|
const observations = [];
|
|
260
268
|
walkSql(tree, (value) => {
|
|
@@ -267,7 +275,7 @@ function doSqlObservations(statement, file) {
|
|
|
267
275
|
function sqlObservations(text, file) {
|
|
268
276
|
const tree = parseSql(text);
|
|
269
277
|
if (!tree)
|
|
270
|
-
return
|
|
278
|
+
return unparsedSchedule(text, file);
|
|
271
279
|
const observations = [];
|
|
272
280
|
walkSql(tree, (value) => {
|
|
273
281
|
if (value.DoStmt)
|
|
@@ -338,7 +346,7 @@ function templateObservations(node, file) {
|
|
|
338
346
|
const named = spell(DYNAMIC);
|
|
339
347
|
const tree = parseSql(named);
|
|
340
348
|
if (!tree)
|
|
341
|
-
return
|
|
349
|
+
return unparsedSchedule(named, file);
|
|
342
350
|
let dynamicCall = false;
|
|
343
351
|
walkSql(tree, (value) => {
|
|
344
352
|
if (value.FuncCall && (value.FuncCall.funcname ?? []).some((part) => part.String?.sval === DYNAMIC))
|
|
@@ -46,6 +46,13 @@ export interface MoveOptions {
|
|
|
46
46
|
}
|
|
47
47
|
/** The provider a key belongs to: its first word (`SEPAY_IPN_SECRET_KEY` is `sepay`, `OPENROUTER_API_KEY` is `openrouter`). */
|
|
48
48
|
export declare const providerOf: (key: string) => string;
|
|
49
|
+
/** The keys of the one sealed SSH member `deploy.remote.sealed` names (stack/remote.mjs): the deploy transport reads exactly these. */
|
|
50
|
+
export declare const SSH_GROUP: readonly string[];
|
|
51
|
+
/**
|
|
52
|
+
* The two transport members of an environment (ruling ci-transport-one-home): the operator's member(s) `deploy.remote.sealed` names and
|
|
53
|
+
* the CI member `vps-ci`, two identities by design, neither of which reaches an app container. Only the SSH_GROUP keys may appear in both.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sshTransportMembers(spec: EnvironmentSpec | undefined): Set<string>;
|
|
49
56
|
/**
|
|
50
57
|
* Every deviation of the app at `repoRoot` (one environment, else each with a secrets folder): LEGACY_MEMBER, MIXED_PROVIDERS,
|
|
51
58
|
* RECIPIENT_DRIFT, UNDECLARED_KEY (only where application-stacks.yaml declares the environment), UNTRACKED. Names only.
|
|
@@ -8,7 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { parseYaml } from "../../engine/yaml.mjs";
|
|
10
10
|
import { lsFiles } from "../api/git/ls-files.mjs";
|
|
11
|
-
import { MEMBER_SUFFIX, isLegacyName, memberOfName } from "../lib/sealed-member.mjs";
|
|
11
|
+
import { CI_MEMBER, MEMBER_SUFFIX, isLegacyName, memberOfName } from "../lib/sealed-member.mjs";
|
|
12
12
|
import { configuredRecipients } from "./secret-init.mjs";
|
|
13
13
|
import { envelopeOf, validateMap } from "./secret-document.mjs";
|
|
14
14
|
import { deleteSealed, fileOf, sealedFiles, writeSecret } from "./secret-store.mjs";
|
|
@@ -27,7 +27,16 @@ function declaredKeys(spec, member, keys) {
|
|
|
27
27
|
return entries.filter((entry) => entry?.key !== undefined && memberOfEntry(entry) === member).map((entry) => entry.key);
|
|
28
28
|
}
|
|
29
29
|
/** The keys of the one sealed SSH member `deploy.remote.sealed` names (stack/remote.mjs): the deploy transport reads exactly these. */
|
|
30
|
-
const SSH_GROUP = Object.freeze(["VPS_HOST", "VPS_USER", "privateKey", "knownHosts"]);
|
|
30
|
+
export const SSH_GROUP = Object.freeze(["VPS_HOST", "VPS_USER", "privateKey", "knownHosts"]);
|
|
31
|
+
/**
|
|
32
|
+
* The two transport members of an environment (ruling ci-transport-one-home): the operator's member(s) `deploy.remote.sealed` names and
|
|
33
|
+
* the CI member `vps-ci`, two identities by design, neither of which reaches an app container. Only the SSH_GROUP keys may appear in both.
|
|
34
|
+
*/
|
|
35
|
+
export function sshTransportMembers(spec) {
|
|
36
|
+
const sealed = spec?.deploy?.remote?.sealed;
|
|
37
|
+
const operator = typeof sealed === "string" ? [sealed] : Object.values(sealed ?? {});
|
|
38
|
+
return new Set([...operator.filter((slug) => typeof slug === "string"), CI_MEMBER]);
|
|
39
|
+
}
|
|
31
40
|
/**
|
|
32
41
|
* True when the stack groups `member` on purpose: every key of it is declared in the environment's `secrets:`, per key (`key` with
|
|
33
42
|
* `member: <member>`) or in the `keys` list of an entry naming the member, or is a key of the sealed SSH member the deploy names.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** The suffix of a member, and of a legacy one. */
|
|
2
2
|
export declare const MEMBER_SUFFIX = ".enc.yaml";
|
|
3
3
|
export declare const LEGACY_SUFFIX = ".enc";
|
|
4
|
+
/** The sealed member that holds the CI transport of an environment: its own identity with a forced command, never the operator's SSH member. */
|
|
5
|
+
export declare const CI_MEMBER = "vps-ci";
|
|
4
6
|
/** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
|
|
5
7
|
export declare const SEALED_NAME: RegExp;
|
|
6
8
|
/** True for the file name of a sealed member, current or legacy. */
|
|
@@ -7,6 +7,8 @@ import path from 'node:path';
|
|
|
7
7
|
/** The suffix of a member, and of a legacy one. */
|
|
8
8
|
export const MEMBER_SUFFIX = '.enc.yaml';
|
|
9
9
|
export const LEGACY_SUFFIX = '.enc';
|
|
10
|
+
/** The sealed member that holds the CI transport of an environment: its own identity with a forced command, never the operator's SSH member. */
|
|
11
|
+
export const CI_MEMBER = 'vps-ci';
|
|
10
12
|
/** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
|
|
11
13
|
export const SEALED_NAME = /\.enc(?:\.yaml)?$/u;
|
|
12
14
|
/** True for the file name of a sealed member, current or legacy. */
|
package/stack/remote.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CI_MEMBER } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
1
2
|
import type { DeployConfig } from './config.mjs';
|
|
2
3
|
import type { StackContext } from './context.mjs';
|
|
3
4
|
import type { RemoteArgv } from './ssh-session.mjs';
|
|
@@ -74,7 +75,7 @@ export declare const ownerSshRunner: (ctx: StackContext, config: DeployConfig, o
|
|
|
74
75
|
/** Opens the runner (`deps.ssh` wins in specs), calls `body(run, notes)` and always cleans the session up. */
|
|
75
76
|
export declare function withRunner<T>(ctx: StackContext, config: DeployConfig, options: SshOptions, deps: RunnerDeps, body: (run: Runner, notes: string[]) => T | Promise<T>): Promise<T>;
|
|
76
77
|
/** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
|
|
77
|
-
export
|
|
78
|
+
export { CI_MEMBER };
|
|
78
79
|
/** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
|
|
79
80
|
export declare function connectCi(ctx: StackContext, config: DeployConfig, { execute }?: {
|
|
80
81
|
execute?: boolean;
|
package/stack/remote.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// `bash -s` fail with `set: pipefail`). Key material lives in memory and in a private temp directory only; it is never in argv (only the key
|
|
10
10
|
// FILE path is), in a line, in a result or in an error message.
|
|
11
11
|
import fs from 'node:fs';
|
|
12
|
-
import { isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
12
|
+
import { CI_MEMBER, isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { tool } from './context.mjs';
|
|
@@ -217,7 +217,7 @@ function ciHome(ctx) {
|
|
|
217
217
|
return path.join(ctx.env.HOME, '.ssh');
|
|
218
218
|
}
|
|
219
219
|
/** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
|
|
220
|
-
export
|
|
220
|
+
export { CI_MEMBER };
|
|
221
221
|
/**
|
|
222
222
|
* The CI transport values: the sealed member `vps-ci` of the environment (opened with the server identity the job holds) when it exists, else the
|
|
223
223
|
* variables of the job (the transition, and the owner's own runs). The member keeps the host, the account, the key and the pinned host keys out of
|
package/stack/ssh-gate.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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';
|
|
2
|
+
export type ArgKind = 'base' | 'release' | 'pointer' | 'token' | 'trigger' | 'attempt' | 'seconds' | 'recipient';
|
|
3
3
|
/** One named operation: its arguments by kind, whether it reads stdin, and the sh body that runs with the arguments as $1.... */
|
|
4
4
|
export interface GateOp {
|
|
5
5
|
readonly name: string;
|
|
@@ -20,6 +20,8 @@ export declare function gateOp(name: string): GateOp;
|
|
|
20
20
|
export declare function gatedCommand(name: string, args: readonly string[]): string;
|
|
21
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
22
|
export declare function inlineCommand(name: string, args: readonly string[]): string;
|
|
23
|
+
/** The command of an operation up to its arguments (`sh -c '<body>' starci-op`): the text a workflow template renders for a key without the gate. */
|
|
24
|
+
export declare function inlinePrefix(name: string): string;
|
|
23
25
|
/** The POSIX sh text of `starci-ssh-gate`, to install executable at /usr/local/bin/starci-ssh-gate on the manager. */
|
|
24
26
|
export declare function gateScript(): string;
|
|
25
27
|
/** 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"`). */
|
package/stack/ssh-gate.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
// A body reads its arguments as $1... ($0 is `starci-op`) and its stdin; it holds no free text.
|
|
11
11
|
import { refusal } from './errors.mjs';
|
|
12
12
|
import { shellQuote } from './remote.mjs';
|
|
13
|
+
import { SERVER_AGE_IDENTITY } from './server-key.mjs';
|
|
13
14
|
const RELEASE_ID = /^[0-9a-f]{16}$/u;
|
|
14
15
|
const TOKEN = /^[0-9a-f]{32}$/u;
|
|
15
16
|
const ATTEMPT = /^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$/u;
|
|
17
|
+
const RECIPIENT = /^age1[a-z0-9]{58}$/u;
|
|
16
18
|
const BASE = /^\/[A-Za-z0-9._/-]+$/u;
|
|
17
19
|
/** The closed alphabet of each argument kind. */
|
|
18
20
|
export const ARG_TESTS = Object.freeze({
|
|
@@ -23,7 +25,14 @@ export const ARG_TESTS = Object.freeze({
|
|
|
23
25
|
trigger: (value) => value === 'ci' || value === 'dispatch' || value === 'urgent',
|
|
24
26
|
attempt: (value) => ATTEMPT.test(value),
|
|
25
27
|
seconds: (value) => /^[0-9]{1,6}$/u.test(value),
|
|
28
|
+
recipient: (value) => RECIPIENT.test(value),
|
|
26
29
|
});
|
|
30
|
+
/**
|
|
31
|
+
* Writes the deploy server's age identity from stdin (stack-key-restore.yml, `--restore github`): the file is the one fixed path SERVER_AGE_IDENTITY,
|
|
32
|
+
* never an argument, it is kept only when its public key is the recipient $1, it is owned by root with mode 600, and an existing identity is never overwritten.
|
|
33
|
+
*/
|
|
34
|
+
const INNER_RESTORE = 'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"';
|
|
35
|
+
const IDENTITY_RESTORE = `f=${shellQuote(SERVER_AGE_IDENTITY)}; r="$1"; if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c ${shellQuote(INNER_RESTORE)} sh "$f" "$r"`;
|
|
27
36
|
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
37
|
/** Prints the release the pointer `$2` under `$1` names, or an empty line for an absent link; a failing readlink fails the script. */
|
|
29
38
|
const POINTER = 'if [ -L "$1/$2" ]; then basename "$(readlink "$1/$2")"; else printf \'\\n\'; fi';
|
|
@@ -32,6 +41,7 @@ const POINTER = 'if [ -L "$1/$2" ]; then basename "$(readlink "$1/$2")"; else pr
|
|
|
32
41
|
* rename, a lock unrefreshed for $4 seconds is moved aside (one rename wins) and claimed anew; prints `acquired` or `busy <trigger> <age>`.
|
|
33
42
|
*/
|
|
34
43
|
export const GATE_OPS = Object.freeze([
|
|
44
|
+
{ name: 'identity-restore', args: ['recipient'], stdin: true, body: IDENTITY_RESTORE },
|
|
35
45
|
{ name: 'pointer', args: ['base', 'pointer'], stdin: false, body: POINTER },
|
|
36
46
|
{ 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
47
|
{ 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` },
|
|
@@ -80,10 +90,14 @@ export function gatedCommand(name, args) {
|
|
|
80
90
|
/** The same operation as one command for a key without the gate: the body under `sh -c` with the arguments as positional parameters. */
|
|
81
91
|
export function inlineCommand(name, args) {
|
|
82
92
|
const op = gateOp(name);
|
|
83
|
-
return
|
|
93
|
+
return `${inlinePrefix(name)} ${checkedArguments(op, args).map(shellQuote).join(' ')}`;
|
|
94
|
+
}
|
|
95
|
+
/** The command of an operation up to its arguments (`sh -c '<body>' starci-op`): the text a workflow template renders for a key without the gate. */
|
|
96
|
+
export function inlinePrefix(name) {
|
|
97
|
+
return `sh -c ${shellQuote(gateOp(name).body)} starci-op`;
|
|
84
98
|
}
|
|
85
99
|
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',
|
|
100
|
+
base: 'v_base', release: 'v_release', pointer: 'v_pointer', token: 'v_token', trigger: 'v_trigger', attempt: 'v_attempt', seconds: 'v_seconds', recipient: 'v_recipient',
|
|
87
101
|
});
|
|
88
102
|
/** The sh variable that holds the body of an operation. */
|
|
89
103
|
const bodyVariable = (name) => `OP_${name.replaceAll('-', '_')}`;
|
|
@@ -108,6 +122,7 @@ export function gateScript() {
|
|
|
108
122
|
'v_pointer() { case "$1" in current|previous) ;; *) return 1;; esac; }',
|
|
109
123
|
'v_trigger() { case "$1" in ci|dispatch|urgent) ;; *) return 1;; esac; }',
|
|
110
124
|
`v_attempt() { case "$1" in ${digits(8)}T${digits(6)}Z-${digits(8, '[0-9a-f]')}) ;; *) return 1;; esac; v_hex 8 "\${1#*Z-}"; }`,
|
|
125
|
+
'v_recipient() { case "$1" in age1*) ;; *) return 1;; esac; case "$1" in *[!a-z0-9]*) return 1;; esac; [ "${#1}" -eq 62 ]; }',
|
|
111
126
|
'v_seconds() { case "$1" in \'\'|*[!0-9]*) return 1;; esac; [ "${#1}" -le 6 ]; }',
|
|
112
127
|
...bodies,
|
|
113
128
|
'cmd=${SSH_ORIGINAL_COMMAND-}',
|
package/sync/index.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
|
|
|
42
42
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
43
43
|
import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
|
|
44
44
|
import { SERVER_AGE_IDENTITY } from '../stack/server-key.mjs';
|
|
45
|
+
import { inlinePrefix } from '../stack/ssh-gate.mjs';
|
|
45
46
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
46
47
|
const NODE_MAJOR = 22;
|
|
47
48
|
const APP_BINARY = 'starci app';
|
|
@@ -408,6 +409,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
408
409
|
nodeMajor: String(NODE_MAJOR),
|
|
409
410
|
// The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
|
|
410
411
|
serverAgeIdentity: SERVER_AGE_IDENTITY,
|
|
412
|
+
// The operation `identity-restore` of the ssh gate, as one command for a CI key without the gate (stack/ssh-gate.mjs owns the body), a JSON string so the workflow carries it as one YAML scalar.
|
|
413
|
+
identityRestoreInline: JSON.stringify(inlinePrefix('identity-restore')),
|
|
411
414
|
sonarKey: sonarKey ?? app.project,
|
|
412
415
|
sonarHostLine: sonarHostLine(quality.sonar),
|
|
413
416
|
sonarExclusions: sonarScope(presets).exclusions.join(','),
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
4
|
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
5
|
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
-
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds the secret
|
|
7
|
+
# SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read). The transport is the sealed member vps-ci of that
|
|
8
|
+
# environment, opened with that identity by the same "stack connect" step the deploy uses: GitHub holds no SSH key, host key, host or account.
|
|
10
9
|
name: restore stack key
|
|
11
10
|
|
|
12
11
|
on:
|
|
@@ -33,26 +32,46 @@ jobs:
|
|
|
33
32
|
runs-on: ubuntu-latest
|
|
34
33
|
environment: ${{ inputs.environment }}
|
|
35
34
|
timeout-minutes: 10
|
|
35
|
+
env:
|
|
36
|
+
STACK_ENVIRONMENT: ${{ inputs.environment }}
|
|
36
37
|
steps:
|
|
38
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
39
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
40
|
+
with:
|
|
41
|
+
node-version: 22
|
|
42
|
+
cache: npm
|
|
43
|
+
- run: npm ci --ignore-scripts
|
|
44
|
+
- name: install SOPS
|
|
45
|
+
uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
|
|
46
|
+
with:
|
|
47
|
+
version: v3.13.2
|
|
48
|
+
- name: connect to the swarm manager
|
|
49
|
+
env:
|
|
50
|
+
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
51
|
+
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
|
52
|
+
run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
|
|
37
53
|
- name: write the identity on the server
|
|
38
54
|
env:
|
|
39
55
|
RECIPIENT: ${{ inputs.recipient }}
|
|
40
56
|
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
-
|
|
42
|
-
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
-
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
-
VPS_USER: ${{ vars.VPS_USER }}
|
|
57
|
+
INLINE_RESTORE: {{identityRestoreInline}}
|
|
45
58
|
shell: bash
|
|
46
59
|
run: |
|
|
47
60
|
set -euo pipefail
|
|
48
61
|
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
62
|
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
63
|
umask 077
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
manager="${DOCKER_HOST#ssh://}"
|
|
65
|
+
# The operation is the ssh gate's identity-restore (stack/ssh-gate.mjs): the server writes the identity beside its target, keeps it only
|
|
66
|
+
# when its public key is the recipient, then moves it in. A CI key forced through the gate (STARCI_SSH_GATE=1) names it; a key without the gate runs its body.
|
|
67
|
+
if [ "${STARCI_SSH_GATE:-}" = 1 ]; then
|
|
68
|
+
remote="starci-op identity-restore $RECIPIENT"
|
|
69
|
+
else
|
|
70
|
+
remote="$INLINE_RESTORE $RECIPIENT"
|
|
71
|
+
fi
|
|
72
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T "$manager" "$remote"
|
|
73
|
+
- name: disconnect from the swarm manager
|
|
74
|
+
if: ${{ always() }}
|
|
75
|
+
env:
|
|
76
|
+
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
77
|
+
run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
# stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
|
|
4
4
|
# identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
|
|
5
5
|
# owned by root; it never echoes the identity and never overwrites one.
|
|
6
|
-
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# VPS_KNOWN_HOSTS (pinned host keys)
|
|
6
|
+
# The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds the secret
|
|
7
|
+
# SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read). The transport is the sealed member vps-ci of that
|
|
8
|
+
# environment, opened with that identity by the same "stack connect" step the deploy uses: GitHub holds no SSH key, host key, host or account.
|
|
10
9
|
name: restore stack key
|
|
11
10
|
|
|
12
11
|
on:
|
|
@@ -33,26 +32,46 @@ jobs:
|
|
|
33
32
|
runs-on: ubuntu-latest
|
|
34
33
|
environment: ${{ inputs.environment }}
|
|
35
34
|
timeout-minutes: 10
|
|
35
|
+
env:
|
|
36
|
+
STACK_ENVIRONMENT: ${{ inputs.environment }}
|
|
36
37
|
steps:
|
|
38
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
39
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
|
40
|
+
with:
|
|
41
|
+
node-version: 22
|
|
42
|
+
cache: npm
|
|
43
|
+
- run: npm ci --ignore-scripts
|
|
44
|
+
- name: install SOPS
|
|
45
|
+
uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
|
|
46
|
+
with:
|
|
47
|
+
version: v3.13.2
|
|
48
|
+
- name: connect to the swarm manager
|
|
49
|
+
env:
|
|
50
|
+
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
51
|
+
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
|
52
|
+
run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
|
|
37
53
|
- name: write the identity on the server
|
|
38
54
|
env:
|
|
39
55
|
RECIPIENT: ${{ inputs.recipient }}
|
|
40
56
|
AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
|
|
41
|
-
|
|
42
|
-
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
|
|
43
|
-
VPS_HOST: ${{ vars.VPS_HOST }}
|
|
44
|
-
VPS_USER: ${{ vars.VPS_USER }}
|
|
57
|
+
INLINE_RESTORE: {{identityRestoreInline}}
|
|
45
58
|
shell: bash
|
|
46
59
|
run: |
|
|
47
60
|
set -euo pipefail
|
|
48
61
|
[[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
|
|
49
62
|
[ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
|
|
50
63
|
umask 077
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
manager="${DOCKER_HOST#ssh://}"
|
|
65
|
+
# The operation is the ssh gate's identity-restore (stack/ssh-gate.mjs): the server writes the identity beside its target, keeps it only
|
|
66
|
+
# when its public key is the recipient, then moves it in. A CI key forced through the gate (STARCI_SSH_GATE=1) names it; a key without the gate runs its body.
|
|
67
|
+
if [ "${STARCI_SSH_GATE:-}" = 1 ]; then
|
|
68
|
+
remote="starci-op identity-restore $RECIPIENT"
|
|
69
|
+
else
|
|
70
|
+
remote="$INLINE_RESTORE $RECIPIENT"
|
|
71
|
+
fi
|
|
72
|
+
printf '%s\n' "$AGE_IDENTITY" | ssh -T "$manager" "$remote"
|
|
73
|
+
- name: disconnect from the swarm manager
|
|
74
|
+
if: ${{ always() }}
|
|
75
|
+
env:
|
|
76
|
+
XDG_DATA_HOME: ${{ runner.temp }}/starci-data
|
|
77
|
+
run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
|
|
@@ -167,12 +167,8 @@ ci:
|
|
|
167
167
|
secrets:
|
|
168
168
|
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
|
|
169
169
|
- {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
|
|
170
|
-
- {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
|
|
171
|
-
- {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
|
|
172
170
|
vars:
|
|
173
171
|
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
|
|
174
|
-
- {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
|
|
175
|
-
- {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
|
|
176
172
|
services:
|
|
177
173
|
sonar:
|
|
178
174
|
provider: sonarqube
|
|
@@ -256,12 +256,8 @@ ci:
|
|
|
256
256
|
secrets:
|
|
257
257
|
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
|
|
258
258
|
- {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
|
|
259
|
-
- {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
|
|
260
|
-
- {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
|
|
261
259
|
vars:
|
|
262
260
|
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
|
|
263
|
-
- {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
|
|
264
|
-
- {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
|
|
265
261
|
services:
|
|
266
262
|
sonar:
|
|
267
263
|
provider: sonarqube
|