@starci/hfs 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +8 -5
- package/db/post-cutover.mjs +4 -4
- package/db/reset.mjs +125 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +100 -7
- package/lint/run.mjs +30 -8
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +53 -13
- package/release/index.mjs +8 -6
- package/release/push.mjs +1 -1
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +505 -971
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +60 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
- package/runtime/scripts/api/git/lib.mjs +3 -3
- package/runtime/scripts/api/sops/lib.mjs +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/example-secrets.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +17 -6
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +14 -2
- package/stack/digest.mjs +1 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/edge-alias.mjs +1 -1
- package/stack/image-scan.mjs +1 -1
- package/stack/ingress-create.mjs +2 -2
- package/stack/ingress.mjs +3 -3
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +16 -8
- package/stack/sealed.mjs +5 -5
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +103 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +43 -18
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +31 -5
- package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
- package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +21 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
- package/upgrade/index.mjs +1 -1
package/stack/release-store.mjs
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// the remote shell. The record holds names and digests, never a secret value. Files travel as text on stdin / stdout.
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { eachInOrder, mapInOrder } from '../runtime/scripts/lib/in-order.mjs';
|
|
9
8
|
import { firstLine, lines as splitLines } from './context.mjs';
|
|
10
9
|
import { isReleaseId } from './digest.mjs';
|
|
11
10
|
import { failure, refusal } from './errors.mjs';
|
|
@@ -56,9 +55,8 @@ export function createReleaseStore(config, ssh) {
|
|
|
56
55
|
async store(dir, id) {
|
|
57
56
|
const target = dirOf(id);
|
|
58
57
|
const contents = FILES.map((name) => [name, readLocal(dir, name)]);
|
|
59
|
-
|
|
60
|
-
await
|
|
61
|
-
await run(`set -e; d="${target}"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`);
|
|
58
|
+
const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
|
|
59
|
+
await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi; IFS= read -r stack; IFS= read -r release; printf '%s' "$stack" | base64 -d > "$d/stack.yaml"; printf '%s' "$release" | base64 -d > "$d/release.json"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`, input);
|
|
62
60
|
},
|
|
63
61
|
/** The release was verified: previous := current, current := id. */
|
|
64
62
|
async accept(id) {
|
|
@@ -72,15 +70,22 @@ export function createReleaseStore(config, ssh) {
|
|
|
72
70
|
/** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
|
|
73
71
|
async fetch(id, targetDir) {
|
|
74
72
|
const source = dirOf(id);
|
|
75
|
-
const
|
|
73
|
+
const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
|
|
74
|
+
const frames = out.split('\n');
|
|
75
|
+
if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame))) throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
|
|
76
|
+
const files = FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
|
|
76
77
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
77
78
|
for (const [name, text] of files) fs.writeFileSync(path.join(targetDir, name), text);
|
|
78
79
|
return targetDir;
|
|
79
80
|
},
|
|
80
81
|
/** The stored releases, newest first: [{ id, current, previous }]. */
|
|
81
82
|
async list() {
|
|
82
|
-
const [
|
|
83
|
-
const
|
|
83
|
+
const pointerScript = (name) => `if [ -L "${base}/${name}" ]; then basename "$(readlink "${base}/${name}")"; else printf '\\n'; fi`;
|
|
84
|
+
const response = await run(`${pointerScript('current')}; ${pointerScript('previous')}; cd "${base}/releases" 2>/dev/null && ls -1t || true`);
|
|
85
|
+
const [current, previous, ...entries] = response.split('\n');
|
|
86
|
+
const now = isReleaseId(current) ? current : '';
|
|
87
|
+
const before = isReleaseId(previous) ? previous : '';
|
|
88
|
+
const out = entries.join('\n');
|
|
84
89
|
return splitLines(out).filter(isReleaseId).map((id) => ({ id, current: id === now, previous: id === before }));
|
|
85
90
|
},
|
|
86
91
|
};
|
package/stack/remote.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { tool } from './context.mjs';
|
|
14
14
|
import { restrictToUser, writePrivate } from './private-file.mjs';
|
|
15
15
|
import { refusal } from './errors.mjs';
|
|
16
|
+
import { createSshSession, sessionDirectory } from './ssh-session.mjs';
|
|
16
17
|
|
|
17
18
|
const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
|
|
18
19
|
const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
|
|
@@ -33,11 +34,11 @@ export const remoteDocker = (run, args) => run(['docker', ...args.map(shellQuote
|
|
|
33
34
|
|
|
34
35
|
const noop = () => {};
|
|
35
36
|
|
|
36
|
-
function makeRunner(ctx, argv, { cleanup = noop, notes = [] } = {}) {
|
|
37
|
+
function makeRunner(ctx, argv, { cleanup = noop, notes = [], connection } = {}) {
|
|
37
38
|
const [command, ...head] = argv;
|
|
38
39
|
const run = (remote, { input } = {}) => {
|
|
39
40
|
const options = input === undefined ? {} : { input: lfOnly(input) };
|
|
40
|
-
const result = tool(ctx, command, [...head, lfOnly(remote)], options);
|
|
41
|
+
const result = connection ? connection.remote(lfOnly(remote), options) : tool(ctx, command, [...head, lfOnly(remote)], options);
|
|
41
42
|
if (result.error) return { status: 127, stdout: '', stderr: `cannot run ${command}: ${result.error.message}` };
|
|
42
43
|
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
43
44
|
};
|
|
@@ -119,21 +120,28 @@ export function openSealedSession(ctx, config) {
|
|
|
119
120
|
function sealedRunner(ctx, config) {
|
|
120
121
|
const session = openSealedSession(ctx, config);
|
|
121
122
|
const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
|
|
122
|
-
return
|
|
123
|
+
return sessionRunner(ctx, argv, session.dir, session.cleanup, session.notes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sessionRunner(ctx, argv, dir, cleanup, notes = []) {
|
|
127
|
+
const connection = createSshSession(ctx, { argv, controlPath: path.join(dir, 'cm').replaceAll('\\', '/'), cleanup });
|
|
128
|
+
return makeRunner(ctx, argv, { connection, cleanup: connection.close, notes });
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
/** The ssh runner of one invocation (see the top of the file). */
|
|
126
|
-
|
|
132
|
+
function sshRunner(ctx, config, { sshCmd = null } = {}) {
|
|
133
|
+
if (ctx.stackSsh && !sshCmd) return makeRunner(ctx, ['ssh'], { connection: ctx.stackSsh });
|
|
127
134
|
if (sshCmd) return makeRunner(ctx, sshCommandArgv(sshCmd));
|
|
128
135
|
if (config.remote.sealed && ctx.env.STARCI_STACK_SEALED_SSH === '1') return sealedRunner(ctx, config);
|
|
129
|
-
|
|
136
|
+
const dir = sessionDirectory();
|
|
137
|
+
return sessionRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host], dir, () => fs.rmSync(dir, { recursive: true, force: true }));
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/**
|
|
133
141
|
* The context to open the runner with. On an operator machine (not CI, no --ssh-cmd, the sealed members exist, no explicit
|
|
134
142
|
* STARCI_STACK_SEALED_SSH) the sealed session is selected; otherwise the context is returned unchanged.
|
|
135
143
|
*/
|
|
136
|
-
|
|
144
|
+
function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
|
|
137
145
|
const { sealed } = config.remote;
|
|
138
146
|
if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
|
|
139
147
|
const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
|
|
@@ -150,7 +158,7 @@ export async function withRunner(ctx, config, options, deps, body) {
|
|
|
150
158
|
try {
|
|
151
159
|
return await body(run, run.notes ?? []);
|
|
152
160
|
} finally {
|
|
153
|
-
run.cleanup?.();
|
|
161
|
+
await run.cleanup?.();
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
@@ -174,7 +182,7 @@ function ciInputs(ctx) {
|
|
|
174
182
|
const sshBlock = (alias, inputs) => [
|
|
175
183
|
'', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
|
|
176
184
|
' StrictHostKeyChecking yes', ' BatchMode yes', ' ConnectTimeout 20', ' ServerAliveInterval 30',
|
|
177
|
-
' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist
|
|
185
|
+
' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist 60', '',
|
|
178
186
|
].join('\n');
|
|
179
187
|
|
|
180
188
|
/** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
|
package/stack/sealed.mjs
CHANGED
|
@@ -19,16 +19,16 @@ const CR = String.fromCodePoint(13);
|
|
|
19
19
|
const LF = String.fromCodePoint(10);
|
|
20
20
|
|
|
21
21
|
/** True for content that is not text: a NUL byte in the first 8 KiB. Binary files are never rewritten. */
|
|
22
|
-
|
|
22
|
+
const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
|
|
23
23
|
|
|
24
24
|
/** Every CRLF of a string becomes LF. */
|
|
25
|
-
|
|
25
|
+
const lfText = (text) => text.replaceAll(CR + LF, LF);
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Line endings of a text file as the services need them: every CRLF becomes LF (a script rendered from a Windows checkout failed with
|
|
29
29
|
* `set: Illegal option -`). Binary content is returned untouched. Takes and returns a Buffer.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
function lfBytes(bytes) {
|
|
32
32
|
if (isBinary(bytes) || !bytes.includes(13)) return bytes;
|
|
33
33
|
return Buffer.from(lfText(bytes.toString('latin1')), 'latin1');
|
|
34
34
|
}
|
|
@@ -37,14 +37,14 @@ export function lfBytes(bytes) {
|
|
|
37
37
|
export const configBytes = (file) => lfBytes(fs.readFileSync(file));
|
|
38
38
|
|
|
39
39
|
/** The key that makes the version suffix of a secret name unguessable from the name alone: a local fixture key or the age identity. */
|
|
40
|
-
|
|
40
|
+
function versionKeyOf(ctx) {
|
|
41
41
|
const key = ctx.env.STARCI_SECRET_VERSION_KEY || ctx.env.SOPS_AGE_KEY;
|
|
42
42
|
if (!key) throw refusal('STACK_VERSION_KEY_MISSING', 'STARCI_SECRET_VERSION_KEY (a local fixture) or SOPS_AGE_KEY is needed to version the secret names');
|
|
43
43
|
return key;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** `<stack>-<key>-v<10 hex of HMAC(versionKey, key NUL value)>`: a changed value is a new name, an unchanged one keeps its name. */
|
|
47
|
-
|
|
47
|
+
function secretRuntimeName(stack, key, value, versionKey) {
|
|
48
48
|
const version = createHmac('sha256', versionKey).update(`${key}\0${value}`).digest('hex').slice(0, 10);
|
|
49
49
|
const name = `${stack}-${key.toLowerCase().replaceAll('_', '-')}-v${version}`;
|
|
50
50
|
if (name.length > MAX_NAME) throw refusal('STACK_SECRET_NAME_TOO_LONG', `the Swarm secret name for ${key} would exceed ${MAX_NAME} characters`);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ssh-session.mjs - one command-owned connection; all processes use ctx.spawn. Windows prefers a Unix socket in private 0700 HOME
|
|
2
|
+
// when runtime ssh -V reports OpenSSH >= 6.7 and a local AF_UNIX Docker CLI probe succeeds. Otherwise a fresh ephemeral TCP
|
|
3
|
+
// port is asserted to bind only 127.0.0.1 and never reaches files or logs. Finally and SIGINT/SIGTERM close the tunnel.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { failure } from './errors.mjs';
|
|
8
|
+
import { restrictToUser } from './private-file.mjs';
|
|
9
|
+
|
|
10
|
+
const FREE_PORT = "const net=require('node:net');const s=net.createServer();s.on('error',()=>process.exit(1));s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close();});";
|
|
11
|
+
const WAIT_PORT = "const net=require('node:net');const deadline=Date.now()+20000;function probe(){const s=net.connect({host:'127.0.0.1',port:Number(process.argv[1])});s.setTimeout(200);s.on('connect',()=>{s.destroy();process.exit(0);});function retry(){s.destroy();if(Date.now()>=deadline)process.exit(1);else setTimeout(probe,50);}s.on('error',retry);s.on('timeout',retry);}probe();";
|
|
12
|
+
|
|
13
|
+
const WAIT_SOCKET = WAIT_PORT.replace("{host:'127.0.0.1',port:Number(process.argv[1])}", '{path:process.argv[1]}');
|
|
14
|
+
const UNIX_PROBE = String.raw`
|
|
15
|
+
const http = require('node:http');
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const { spawn } = require('node:child_process');
|
|
18
|
+
const socket = process.argv[1];
|
|
19
|
+
let child;
|
|
20
|
+
function finish(code) {
|
|
21
|
+
child?.kill();
|
|
22
|
+
server.close();
|
|
23
|
+
try { fs.unlinkSync(socket); } catch {}
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
const server = http.createServer((req, res) => {
|
|
27
|
+
res.setHeader('Api-Version', '1.47');
|
|
28
|
+
res.setHeader('Content-Type', 'application/json');
|
|
29
|
+
res.end(req.url.endsWith('/_ping') ? 'OK' : JSON.stringify({ Version: '27.0.0', ApiVersion: '1.47', MinAPIVersion: '1.24', Os: 'linux', Arch: 'amd64' }));
|
|
30
|
+
});
|
|
31
|
+
server.on('error', () => finish(1));
|
|
32
|
+
server.listen(socket, () => {
|
|
33
|
+
child = spawn('docker', ['--host', 'unix://' + socket.replaceAll('\\', '/'), 'version', '--format', '{{.Server.Version}}'], { stdio: 'ignore', windowsHide: true });
|
|
34
|
+
child.on('error', () => finish(1));
|
|
35
|
+
child.on('exit', code => finish(code === 0 ? 0 : 1));
|
|
36
|
+
});
|
|
37
|
+
setTimeout(() => finish(1), 5000);
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** TCP Docker forwarding must never expose the manager on a non-loopback interface. */
|
|
41
|
+
function loopbackForward(bind, port, socket) {
|
|
42
|
+
if (bind !== '127.0.0.1') throw failure('STACK_SSH_SESSION', 'the Docker tunnel must bind 127.0.0.1');
|
|
43
|
+
return `${bind}:${port}:${socket}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function unixEndpoint(ctx, command, env) {
|
|
47
|
+
const version = ctx.spawn(command, ['-V'], { env });
|
|
48
|
+
const match = /OpenSSH(?:_for_Windows)?_(\d+)\.(\d+)/u.exec(`${version.stderr ?? ''} ${version.stdout ?? ''}`);
|
|
49
|
+
if (!successful(version) || !match || Number(match[1]) < 6 || Number(match[1]) === 6 && Number(match[2]) < 7) return null;
|
|
50
|
+
if (!env.HOME) return null;
|
|
51
|
+
fs.mkdirSync(env.HOME, { recursive: true, mode: 0o700 });
|
|
52
|
+
fs.chmodSync(env.HOME, 0o700);
|
|
53
|
+
restrictToUser(ctx, env.HOME, '(OI)(CI)F');
|
|
54
|
+
const probe = path.join(env.HOME, 'probe.sock');
|
|
55
|
+
try {
|
|
56
|
+
if (!successful(ctx.spawn(process.execPath, ['-e', UNIX_PROBE, probe], { env, timeout: 7000 }))) return null;
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(probe, { force: true });
|
|
59
|
+
}
|
|
60
|
+
return path.join(env.HOME, 'docker.sock').replaceAll('\\', '/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The same multiplexing settings belong to the master, remote commands and Docker's SSH alias. */
|
|
64
|
+
export function controlSettings(controlPath) {
|
|
65
|
+
return ['ControlMaster=auto', `ControlPath=${controlPath}`, 'ControlPersist=60'];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function successful(result) {
|
|
69
|
+
return !result.error && result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function freePort(ctx) {
|
|
73
|
+
const result = ctx.spawn(process.execPath, ['-e', FREE_PORT]);
|
|
74
|
+
const port = Number(result.stdout?.trim());
|
|
75
|
+
if (!successful(result) || !Number.isInteger(port) || port < 1 || port > 65535) throw failure('STACK_SSH_SESSION', 'cannot allocate a loopback port');
|
|
76
|
+
return port;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Waits for the tunnel child to exit before any private credentials are removed. */
|
|
80
|
+
function stopTunnel(child) {
|
|
81
|
+
if (!child || child.spawnError || child.exitCode !== null && child.exitCode !== undefined || child.signalCode) return;
|
|
82
|
+
if (!child.once) { child.kill(); return; }
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
|
|
85
|
+
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
86
|
+
child.kill();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function closeTunnel(child, cleanup) {
|
|
91
|
+
try { await stopTunnel(child); } finally { cleanup(); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Creates a lazy connection; no SSH is spawned until a Docker query or remote command needs it. */
|
|
95
|
+
export function createSshSession(ctx, { argv, env = ctx.env, controlPath, socket = '/var/run/docker.sock', cleanup = () => {} }) {
|
|
96
|
+
const windows = (ctx.platform ?? process.platform) === 'win32';
|
|
97
|
+
const [command, ...head] = argv;
|
|
98
|
+
const controls = windows ? [] : controlSettings(controlPath).flatMap((setting) => ['-o', setting]);
|
|
99
|
+
let started = false;
|
|
100
|
+
let ready = false;
|
|
101
|
+
let child;
|
|
102
|
+
let host;
|
|
103
|
+
let closing;
|
|
104
|
+
const signalHandlers = new Map();
|
|
105
|
+
let closed = false;
|
|
106
|
+
|
|
107
|
+
function start() {
|
|
108
|
+
if (closed) throw failure('STACK_SSH_SESSION', 'the command session is already closed');
|
|
109
|
+
if (ready) return;
|
|
110
|
+
if (started) throw failure('STACK_SSH_SESSION', 'the SSH connection failed to open');
|
|
111
|
+
started = true;
|
|
112
|
+
if (windows) {
|
|
113
|
+
const localSocket = unixEndpoint(ctx, command, env);
|
|
114
|
+
const port = localSocket ? null : freePort(ctx);
|
|
115
|
+
const forward = localSocket ? `./docker.sock:${socket}` : loopbackForward('127.0.0.1', port, socket);
|
|
116
|
+
host = localSocket ? `unix://${localSocket}` : `tcp://127.0.0.1:${port}`;
|
|
117
|
+
child = ctx.spawn(command, ['-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'GatewayPorts=no', ...(localSocket ? ['-o', 'StreamLocalBindMask=0177'] : []), '-L', forward, ...head], { env, cwd: localSocket ? env.HOME : undefined, background: true });
|
|
118
|
+
if (typeof child?.kill !== 'function') {
|
|
119
|
+
child = undefined;
|
|
120
|
+
throw failure('STACK_SSH_SESSION', 'cannot start the Docker SSH tunnel');
|
|
121
|
+
}
|
|
122
|
+
installSignals();
|
|
123
|
+
const probeArgs = localSocket ? ['-e', WAIT_SOCKET, localSocket] : ['-e', WAIT_PORT, String(port)];
|
|
124
|
+
const probe = ctx.spawn(process.execPath, probeArgs, { env, timeout: 25000 });
|
|
125
|
+
if (!successful(probe)) throw failure('STACK_SSH_SESSION', 'the Docker SSH tunnel did not become ready');
|
|
126
|
+
} else {
|
|
127
|
+
const result = ctx.spawn(command, ['-MNf', ...controls, ...head], { env });
|
|
128
|
+
if (!successful(result)) throw failure('STACK_SSH_SESSION', 'cannot open the SSH control master');
|
|
129
|
+
}
|
|
130
|
+
ready = true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function remote(remoteCommand, options = {}) {
|
|
134
|
+
// Windows cannot multiplex exec channels; Docker queries share the tunnel and remote scripts remain one call per step.
|
|
135
|
+
if (!windows) start();
|
|
136
|
+
return ctx.spawn(command, [...controls, ...head, remoteCommand], { ...options, env });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function dockerHost(alias) {
|
|
140
|
+
start();
|
|
141
|
+
return windows ? host : `ssh://${alias}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function installSignals() {
|
|
145
|
+
for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) {
|
|
146
|
+
const handler = () => { Promise.resolve(close()).finally(() => process.exit(code)); };
|
|
147
|
+
signalHandlers.set(signal, handler);
|
|
148
|
+
process.once(signal, handler);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function finish() {
|
|
153
|
+
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
154
|
+
signalHandlers.clear();
|
|
155
|
+
cleanup();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function close() {
|
|
159
|
+
if (closed) return closing;
|
|
160
|
+
closed = true;
|
|
161
|
+
if (windows && child) {
|
|
162
|
+
closing = closeTunnel(child, finish);
|
|
163
|
+
return closing;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
if (!windows && started) ctx.spawn(command, ['-O', 'exit', ...controls, ...head], { env });
|
|
167
|
+
} finally {
|
|
168
|
+
finish();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { remote, dockerHost, close };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A short private directory for an invocation whose SSH credentials already live in the operator or CI HOME. */
|
|
176
|
+
export function sessionDirectory() {
|
|
177
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'sc-'));
|
|
178
|
+
}
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -1,13 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
1
3
|
// One command vocabulary for managed CI rendering and the runtime coverage gate.
|
|
2
4
|
const exampleCommand = (source, command) => source?.runtimeExample
|
|
3
5
|
? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
|
|
4
6
|
: command;
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
|
|
9
|
+
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
10
|
+
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
|
|
11
|
+
|
|
12
|
+
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
13
|
+
function codecovSteps(lcovReport, codecov) {
|
|
14
|
+
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
15
|
+
' - name: coverage upload',
|
|
16
|
+
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
17
|
+
].join('\n');
|
|
18
|
+
return [
|
|
19
|
+
' - name: coverage upload skipped',
|
|
20
|
+
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
21
|
+
' run: |',
|
|
22
|
+
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
23
|
+
' exit 0',
|
|
24
|
+
' - name: coverage upload',
|
|
25
|
+
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
26
|
+
` if: \${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`,
|
|
27
|
+
' with:',
|
|
28
|
+
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
29
|
+
` files: ${lcovReport}`,
|
|
30
|
+
' disable_search: true',
|
|
31
|
+
' fail_ci_if_error: true',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
|
|
36
|
+
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
|
|
37
|
+
if (!sonar) return '';
|
|
38
|
+
const header = [
|
|
39
|
+
' sonar:',
|
|
40
|
+
' needs: [fast, test]',
|
|
41
|
+
' runs-on: ubuntu-latest',
|
|
42
|
+
' env:',
|
|
43
|
+
` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
|
|
44
|
+
` SONAR_HOST_URL: \${{ vars.${sonar.hostVar} }}`,
|
|
45
|
+
' steps:',
|
|
46
|
+
].join('\n');
|
|
47
|
+
const steps = [
|
|
48
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
49
|
+
` if: ${SONAR_CONDITION}`,
|
|
50
|
+
' with:',
|
|
51
|
+
` name: ${lcovArtifact}`,
|
|
52
|
+
` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
|
|
53
|
+
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
54
|
+
` if: ${SONAR_CONDITION}`,
|
|
55
|
+
' with:',
|
|
56
|
+
' name: sonar-lint',
|
|
57
|
+
' path: reports',
|
|
58
|
+
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
59
|
+
` if: ${SONAR_CONDITION}`,
|
|
60
|
+
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
61
|
+
` if: ${SONAR_CONDITION}`,
|
|
62
|
+
' timeout-minutes: 10',
|
|
63
|
+
].join('\n');
|
|
64
|
+
// Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
|
|
65
|
+
const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
|
|
66
|
+
return read('sonar-steps.yml')
|
|
67
|
+
.replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
|
|
68
|
+
.replaceAll('{{nodeMajor}}', nodeMajor)
|
|
69
|
+
.replace('{{sonarJobHeader}}', () => header)
|
|
70
|
+
.replace('{{sonarJobSteps}}', () => steps)
|
|
71
|
+
.trimEnd();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
75
|
+
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
|
|
76
|
+
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
77
|
+
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
78
|
+
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
79
|
+
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
80
|
+
const lcovArtifact = 'coverage-lcov';
|
|
7
81
|
return {
|
|
8
82
|
ciLint: exampleCommand(source, 'npm run lint'),
|
|
9
83
|
ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
|
|
10
84
|
ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
|
|
11
85
|
ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
|
|
86
|
+
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
87
|
+
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
88
|
+
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
89
|
+
sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
|
|
90
|
+
ciLintSteps: [
|
|
91
|
+
...['be', 'fe'].flatMap((side) => [
|
|
92
|
+
' - name: lint ' + side,
|
|
93
|
+
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
94
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
95
|
+
' env:',
|
|
96
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
97
|
+
]),
|
|
98
|
+
...(sonar ? [
|
|
99
|
+
' - name: lint with Sonar import',
|
|
100
|
+
" if: env.SONAR_TOKEN != ''",
|
|
101
|
+
` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
|
|
102
|
+
' env:',
|
|
103
|
+
' NODE_OPTIONS: --max-old-space-size=6144',
|
|
104
|
+
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
105
|
+
" if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
|
|
106
|
+
' with:',
|
|
107
|
+
' name: sonar-lint',
|
|
108
|
+
' path: reports/lint.sonar.json',
|
|
109
|
+
] : []),
|
|
110
|
+
].join('\n'),
|
|
111
|
+
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
112
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
|
|
113
|
+
lcovArtifact,
|
|
12
114
|
};
|
|
13
115
|
}
|
package/sync/format.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const IGNORE_FILE = '.prettierignore';
|
|
|
13
13
|
const LOCKFILES = new Set(['package-lock.json']);
|
|
14
14
|
|
|
15
15
|
/** The repository's own prettier, or a SyncError when it is not installed. */
|
|
16
|
-
|
|
16
|
+
function loadPrettier(repoRoot) {
|
|
17
17
|
const require = createRequire(path.join(repoRoot, 'package.json'));
|
|
18
18
|
try {
|
|
19
19
|
return require('prettier');
|
package/sync/hygiene.mjs
CHANGED
|
@@ -43,7 +43,7 @@ function ignoredAmong(cwd, files) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
|
|
46
|
-
|
|
46
|
+
function hygieneFindings(files, ignored) {
|
|
47
47
|
const findings = [];
|
|
48
48
|
for (const file of files) {
|
|
49
49
|
if (file.startsWith(WORK) && ignored.has(file)) {
|