@starci/hfs 5.4.0 → 5.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 +19 -0
- package/README.md +4 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/main.mjs +6 -1
- package/codemod/seeds-layout.d.mts +36 -0
- package/codemod/seeds-layout.mjs +113 -0
- package/codemod/sops-rules.d.mts +1 -1
- package/codemod/sops-rules.mjs +8 -3
- package/lint/run.d.mts +5 -4
- package/lint/run.mjs +53 -27
- package/package.json +1 -1
- package/release/lint-report.mjs +4 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +15 -2
- package/runtime/knowledge/hfs/slots.yaml +15 -18
- package/runtime/modules/kernel/failure-codes.yaml +64 -3
- package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
- package/runtime/scripts/hfs/db-squash.mjs +9 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
- package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
- package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
- package/runtime/scripts/hfs/rules/database.d.mts +2 -2
- package/runtime/scripts/hfs/rules/database.mjs +7 -6
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
- package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
- package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
- package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
- package/runtime/scripts/hfs/slots.d.mts +2 -0
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
- package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
- package/runtime/scripts/hfs/view.d.mts +25 -0
- package/runtime/scripts/hfs/view.mjs +41 -0
- package/scaffold/add-app.mjs +2 -2
- package/scaffold/add-cli-lite.mjs +41 -14
- package/scaffold/add-module.mjs +6 -3
- package/scaffold/lite-exports.mjs +1 -1
- package/scaffold/module-registry.mjs +10 -8
- package/src/main.mjs +8 -8
- package/stack/deploy.d.mts +3 -0
- package/stack/deploy.mjs +4 -1
- package/stack/local-up.mjs +25 -3
- package/stack/seeds.d.mts +29 -0
- package/stack/seeds.mjs +197 -0
- package/sync/canon-layout.d.mts +38 -0
- package/sync/canon-layout.mjs +62 -0
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +10 -7
- package/sync/index.d.mts +13 -2
- package/sync/index.mjs +52 -24
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
- package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
- package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +59 -9
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
- package/templates/be/tool-config/jest.config.js +1 -1
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
- package/templates/app/module/jest.config.js.tpl +0 -8
- /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
package/stack/deploy.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeployConfig } from './config.mjs';
|
|
2
2
|
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { DeployDatabase } from './db-phases.mjs';
|
|
3
4
|
import type { ProvisionOptions } from './provision.mjs';
|
|
4
5
|
import type { ObservedService } from './status.mjs';
|
|
5
6
|
import type { ActionResult, Runner, SshOptions } from './remote.mjs';
|
|
@@ -62,6 +63,8 @@ export interface DeployDeps {
|
|
|
62
63
|
}) => Promise<ActionResult>;
|
|
63
64
|
reconcileEdge: (ctx: StackContext, config: DeployConfig, file: string) => ActionResult | Promise<ActionResult>;
|
|
64
65
|
postDeployStack: (ctx: StackContext, config: DeployConfig) => Promise<ActionResult>;
|
|
66
|
+
/** The seed step after convergence (seeds.mjs seedDeployed). */
|
|
67
|
+
seedRelease: (ctx: StackContext, config: DeployConfig, database: DeployDatabase, f: FloorDeps) => Promise<ActionResult>;
|
|
65
68
|
verifyStack: (ctx: StackContext, config: DeployConfig, options: {
|
|
66
69
|
retrySeconds?: number | undefined;
|
|
67
70
|
skipPublic?: boolean;
|
package/stack/deploy.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { describeClash, edgeCollisions, reconcileEdgeAliases } from './edge-alia
|
|
|
35
35
|
import { firstLine, tool } from './context.mjs';
|
|
36
36
|
import { hasPlaceholderDigest, isReleaseId } from './digest.mjs';
|
|
37
37
|
import { contractPhase, declaredDatabase, expandPhase, firstStartHooks, FLOOR_DEPS, unknownDatabaseService } from './db-phases.mjs';
|
|
38
|
+
import { hasSeedEntries, seedPlanLine } from './seeds.mjs';
|
|
38
39
|
import { failure, refusal } from './errors.mjs';
|
|
39
40
|
import { recordedAttempt } from './journal.mjs';
|
|
40
41
|
import { postDeployStack } from './post-deploy.mjs';
|
|
@@ -57,6 +58,7 @@ export const DEFAULT_DEPS = Object.freeze({
|
|
|
57
58
|
convergeStack,
|
|
58
59
|
reconcileEdge: reconcileEdgeAliases,
|
|
59
60
|
postDeployStack,
|
|
61
|
+
seedRelease: lazy('./seeds.mjs', 'seedDeployed'),
|
|
60
62
|
verifyStack,
|
|
61
63
|
pruneStack,
|
|
62
64
|
releaseStore: createReleaseStore,
|
|
@@ -340,6 +342,7 @@ async function deployExecute(ctx, config, options, d, out, seams) {
|
|
|
340
342
|
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
341
343
|
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
342
344
|
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
345
|
+
...(database && hasSeedEntries(config.repoRoot, config.env) ? [['seed', () => d.seedRelease(ctx, config, database, { ...FLOOR_DEPS, ...d.floor })]] : []),
|
|
343
346
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
344
347
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
|
|
345
348
|
...(skipPublic ? [] : [['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out, releaseId }); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
|
|
@@ -400,7 +403,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
400
403
|
const { rendered, releaseId } = await render();
|
|
401
404
|
const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
|
|
402
405
|
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
403
|
-
const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted` : '';
|
|
406
|
+
const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted${hasSeedEntries(config.repoRoot, config.env) ? `; ${seedPlanLine(config.repoRoot, config.env)}` : ''}` : '';
|
|
404
407
|
const plan = options.skipPublic
|
|
405
408
|
? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept${databasePlan}`
|
|
406
409
|
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted${databasePlan}`;
|
package/stack/local-up.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// local-up.mjs - `starci app stack up --env <dev env> [--execute]`: the one command that brings the whole dev environment up (PROJECT-STACKS-6).
|
|
2
2
|
// Order: host prerequisites, dev custody, setup (install, codegen), compose services and their health, the environment's own database role,
|
|
3
|
-
// migrations with a receipt, seed, native app processes, readiness, origins. Idempotent: what runs is kept, what changed is converged.
|
|
3
|
+
// migrations with a receipt, the seed levels with their receipts, the seed commands, native app processes, readiness, origins. Idempotent: what runs is kept, what changed is converged.
|
|
4
4
|
// A dry run prints the plan. It never generates a replacement secret, never removes a volume and touches only its own compose project.
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { firstLine } from './context.mjs';
|
|
9
|
+
import { dbTarget } from '../db/postgres.mjs';
|
|
9
10
|
import { failure, refusal } from './errors.mjs';
|
|
10
11
|
import { missingKeys, missingStep, openCustody } from './local-custody.mjs';
|
|
11
12
|
import { hostRefusal, preflight, runSetup } from './local-prepare.mjs';
|
|
12
13
|
import { DEFAULT_TIMEOUT_SECONDS, makeDataDirectories, commandDigest, compose, composeValues, databaseUrl, isAlive, probe, runCommand, runEnvironment, serviceReady, startProcess, stateOf, waitFor } from './local-run.mjs';
|
|
14
|
+
import { launch } from './local-host.mjs';
|
|
13
15
|
import { readRecord, writeRecord } from './local-state.mjs';
|
|
16
|
+
import { SEED_RECEIPTS, seedStep } from './seeds.mjs';
|
|
14
17
|
import { ensureRuntimeLayers, layerPlan } from './runtime-layers.mjs';
|
|
15
18
|
const SAFE_PASSWORD = /^[A-Za-z0-9_-]{8,}$/u;
|
|
16
19
|
/** The digest of the migration folder (names and contents): what a migrate receipt records. */
|
|
@@ -81,6 +84,25 @@ async function migrate(ctx, local, custody, deps) {
|
|
|
81
84
|
writeRecord(dir, record);
|
|
82
85
|
return `migrate: applied, receipt ${record.receipts.migrate.digest}${await appRolePassword(ctx, local, custody)}`;
|
|
83
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Applies the seed levels (seeds.mjs, PROJECT-STACKS-12) on the environment's database under its owner role, the role the migrations ran as: the
|
|
89
|
+
* receipts in the database keep a rerun from applying anything that did not change. A document entry's app command runs from the app root with
|
|
90
|
+
* the owner's environment, the documents on its stdin.
|
|
91
|
+
*/
|
|
92
|
+
async function seedLevels(ctx, local, custody) {
|
|
93
|
+
const database = local.database;
|
|
94
|
+
const url = databaseUrl(local, custody);
|
|
95
|
+
if (!database || url === null)
|
|
96
|
+
return [];
|
|
97
|
+
const container = firstLine((await compose(ctx, local, ['ps', '-q', database.service])).stdout);
|
|
98
|
+
const target = dbTarget({ ...ctx, env: { ...ctx.env, DATABASE_URL: url, STARCI_DB_CONTAINER: container } }, 'DATABASE_URL');
|
|
99
|
+
const env = runEnvironment(ctx, local, { name: 'seed', argv: [], cwd: '.', heavy: 'none', skipIfExists: null, env: {}, files: [...custody.values.keys()], after: 'migrate' }, custody, 'owner');
|
|
100
|
+
const command = (argv, input) => {
|
|
101
|
+
const [program, args] = launch(ctx.platform, argv[0] ?? '', argv.slice(1));
|
|
102
|
+
return ctx.spawn(program, args, { cwd: local.repoRoot, env, input });
|
|
103
|
+
};
|
|
104
|
+
return seedStep({ ctx, repoRoot: local.repoRoot, env: local.env, target, execute: true, command }).lines;
|
|
105
|
+
}
|
|
84
106
|
/** Runs the seed commands once each (a receipt keyed by the command's digest); the seed owns its idempotence beyond that. */
|
|
85
107
|
async function seed(ctx, local, custody, after) {
|
|
86
108
|
const dir = stateOf(ctx, local);
|
|
@@ -125,7 +147,7 @@ function plan(local, missing) {
|
|
|
125
147
|
...local.setup.map((run) => `plan setup ${run.name}: ${run.argv.join(' ')}`),
|
|
126
148
|
...layerPlan(local),
|
|
127
149
|
`plan services: ${local.services.map((service) => `${service.name} (wait ${service.wait})`).join(', ')}`,
|
|
128
|
-
...(local.database ? [`plan database: own role ${local.database.role}, database ${local.database.name}, then starci app db migrate`] : []),
|
|
150
|
+
...(local.database ? [`plan database: own role ${local.database.role}, database ${local.database.name}, then starci app db migrate`, `plan seed levels: .starcistacks/seeds, then .starcistacks/${local.env}/seeds, each entry once per content hash (receipts in ${SEED_RECEIPTS})`] : []),
|
|
129
151
|
...local.seed.map((run) => `plan seed ${run.name}: ${run.argv.join(' ')}`),
|
|
130
152
|
...local.processes.map((run) => `plan process ${run.name}: ${run.argv.join(' ')}`),
|
|
131
153
|
...local.ready.map((row) => `plan ready ${row.name}: ${row.url}`),
|
|
@@ -156,7 +178,7 @@ export async function upLocal(ctx, local, { execute = false, timeoutSeconds = DE
|
|
|
156
178
|
if (!ready.ok)
|
|
157
179
|
throw failure('STACK_LOCAL_SERVICE', `${service.name} did not become ready in ${timeoutSeconds}s (${ready.detail}); read it with: starci app stack logs --env ${local.env} --service ${service.name}`);
|
|
158
180
|
}
|
|
159
|
-
lines.push(await provisionDatabase(ctx, local, custody), await migrate(ctx, local, custody, deps), ...await seed(ctx, local, custody, 'migrate'), ...startProcesses(ctx, local, custody, deps));
|
|
181
|
+
lines.push(await provisionDatabase(ctx, local, custody), await migrate(ctx, local, custody, deps), ...await seedLevels(ctx, local, custody), ...await seed(ctx, local, custody, 'migrate'), ...startProcesses(ctx, local, custody, deps));
|
|
160
182
|
for (const row of local.ready) {
|
|
161
183
|
const result = await waitFor(ctx, deadline, () => probe(ctx, local, row));
|
|
162
184
|
lines.push(`${result.ok ? 'ok ' : 'FAIL '}ready ${result.detail}`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DbTarget } from '../db/postgres.mjs';
|
|
2
|
+
import type { DeployConfig } from './config.mjs';
|
|
3
|
+
import type { RunResult, StackContext } from './context.mjs';
|
|
4
|
+
import type { DeployDatabase, FloorDeps } from './db-phases.mjs';
|
|
5
|
+
import type { ActionResult } from './remote.mjs';
|
|
6
|
+
/** The receipts table of the seed step, in the app's database. */
|
|
7
|
+
export declare const SEED_RECEIPTS = "public.starci_seed_receipts";
|
|
8
|
+
/** What the seed step reaches: the app root, the environment, its database (the migrations' target) and how an app command runs. */
|
|
9
|
+
export interface SeedStepInput {
|
|
10
|
+
ctx: StackContext;
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
env: string;
|
|
13
|
+
target: DbTarget;
|
|
14
|
+
execute: boolean;
|
|
15
|
+
/** Runs a document entry's app command with the resolved documents on stdin. */
|
|
16
|
+
command: (argv: readonly string[], input: string) => RunResult;
|
|
17
|
+
}
|
|
18
|
+
/** The seed step of `env`: plans it, or with `execute` applies it. Returns { ok, lines, data: { applied, kept } }. */
|
|
19
|
+
export declare function seedStep(input: SeedStepInput): ActionResult;
|
|
20
|
+
/** True when either seed level of the environment declares an entry. */
|
|
21
|
+
export declare function hasSeedEntries(repoRoot: string, env: string): boolean;
|
|
22
|
+
/** What a deploy's dry run says of the seed step: every entry of the two levels, each applied unless its receipt holds. */
|
|
23
|
+
export declare function seedPlanLine(repoRoot: string, env: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The seed step of a deploy (PROJECT-STACKS-6), after convergence so a document entry's app command is the new release's: in the database
|
|
26
|
+
* container under the credentials it holds (the role the migrations ran as), each app command inside a running task of the jobs service
|
|
27
|
+
* (`deploy.jobs.service`), the documents on its stdin.
|
|
28
|
+
*/
|
|
29
|
+
export declare function seedDeployed(ctx: StackContext, config: DeployConfig, database: Pick<DeployDatabase, 'service'>, f: Pick<FloorDeps, 'dbContainer' | 'containerTarget'>): Promise<ActionResult>;
|
package/stack/seeds.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// seeds.mjs - the seed step (contexts/project/stacks.md PROJECT-STACKS-12): after the migrations, the entries of the shared level and then of
|
|
2
|
+
// the environment's level, each in apply order, on the database the migrations ran on and under the role they ran as. The harness owns
|
|
3
|
+
// the receipts: one row per entry in `public.starci_seed_receipts` of that database, created by this step (never an app migration), holding
|
|
4
|
+
// the entry's content hash and, for a document entry with secret references, the member names and a digest of their values. A `once` entry
|
|
5
|
+
// with a receipt never runs again; an `upsert` entry runs again when its hash (or a referenced secret) changed; a rerun with nothing changed
|
|
6
|
+
// writes nothing. A `sql` entry runs in one transaction with its receipt. A `data` or `test-account` entry validates its documents against the
|
|
7
|
+
// app's schema, opens their secret references in memory with the environment's age identity (the sops seam) and hands the resolved documents
|
|
8
|
+
// to the app command it names on stdin, never in argv, a file or a log; its receipt is written once the command succeeded. A `files` entry
|
|
9
|
+
// is validated only: the harness has no object-storage client, so its upload stays the app's. Without `execute` the step reads the
|
|
10
|
+
// receipts and prints what would apply.
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { literal, psqlTransaction, queryRows } from '../db/postgres.mjs';
|
|
15
|
+
import { validateAgainstSchema } from '../runtime/scripts/lib/json-schema.mjs';
|
|
16
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
17
|
+
import { envelopeOf } from '../runtime/scripts/hfs/secret-document.mjs';
|
|
18
|
+
import { entryDigest, entryFiles, isDocumentEntry, readSeedLevel, resolveSecrets, secretReferences, seedDocuments, seedMemberFile } from '../runtime/scripts/hfs/stack-seeds-config.mjs';
|
|
19
|
+
import { lines as splitLines, tool } from './context.mjs';
|
|
20
|
+
import { DB_WAIT_SECONDS } from './db-phases.mjs';
|
|
21
|
+
import { failure, refusal } from './errors.mjs';
|
|
22
|
+
/** The receipts table of the seed step, in the app's database. */
|
|
23
|
+
export const SEED_RECEIPTS = 'public.starci_seed_receipts';
|
|
24
|
+
const RECEIPTS_DDL = `create table if not exists ${SEED_RECEIPTS} (scope text not null, id text not null, hash text not null, secrets text not null default '', env text not null, applied_at timestamptz not null default now(), primary key (scope, id))`;
|
|
25
|
+
/** The scope of the shared level's receipts; an environment's level uses its name. */
|
|
26
|
+
const SHARED_SCOPE = 'shared';
|
|
27
|
+
/** What a secret's value reads as in a dry run, which opens no member. */
|
|
28
|
+
const SEALED = '[sealed]';
|
|
29
|
+
const sha = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
30
|
+
/** The two levels of `env` in apply order; a manifest problem refuses the step before anything runs. */
|
|
31
|
+
function levelsOf(repoRoot, env) {
|
|
32
|
+
const shared = readSeedLevel(repoRoot, null);
|
|
33
|
+
const own = readSeedLevel(repoRoot, env, new Set(shared.entries.map((entry) => entry.id)));
|
|
34
|
+
for (const level of [shared, own]) {
|
|
35
|
+
const [problem] = level.problems;
|
|
36
|
+
if (problem)
|
|
37
|
+
throw refusal('STACK_SEED_MANIFEST', `${level.dir}/seeds.yaml ${problem.at}: ${problem.message}; run starci app check`);
|
|
38
|
+
}
|
|
39
|
+
return [shared, own];
|
|
40
|
+
}
|
|
41
|
+
/** The receipts the database holds; none before the first armed run created the table. */
|
|
42
|
+
function receiptsOf(input) {
|
|
43
|
+
const [present] = queryRows(input.ctx, input.target, `select to_regclass(${literal(SEED_RECEIPTS)}) is not null as present`);
|
|
44
|
+
if (present?.present !== true)
|
|
45
|
+
return [];
|
|
46
|
+
return queryRows(input.ctx, input.target, `select scope, id, hash, secrets from ${SEED_RECEIPTS}`).map((row) => ({ scope: String(row.scope), id: String(row.id), hash: String(row.hash), secrets: String(row.secrets) }));
|
|
47
|
+
}
|
|
48
|
+
/** The statement that records an entry's receipt. */
|
|
49
|
+
const receiptSql = (item, env, secrets) => `insert into ${SEED_RECEIPTS} (scope, id, hash, secrets, env) values (${literal(item.scope)}, ${literal(item.entry.id)}, ${literal(item.hash)}, ${literal(secrets)}, ${literal(env)}) ` +
|
|
50
|
+
'on conflict (scope, id) do update set hash = excluded.hash, secrets = excluded.secrets, env = excluded.env, applied_at = now()';
|
|
51
|
+
/** The documents of a document entry with its secret references resolved by `open`, refused when one fails the app's schema. */
|
|
52
|
+
function resolvedDocuments(input, item, open) {
|
|
53
|
+
const schemaText = fs.readFileSync(path.join(input.repoRoot, String(item.entry.schema)), 'utf8');
|
|
54
|
+
const schema = item.entry.schema?.endsWith('.json') ? JSON.parse(schemaText) : parseYaml(schemaText);
|
|
55
|
+
return seedDocuments(path.join(input.repoRoot, item.level.dir), item.entry).map((document) => {
|
|
56
|
+
if (document.error !== undefined)
|
|
57
|
+
throw refusal('STACK_SEED_DATA_INVALID', `${item.scope}/${item.entry.id}: ${document.path} does not parse`);
|
|
58
|
+
const resolved = resolveSecrets(document.doc, open);
|
|
59
|
+
const [error] = validateAgainstSchema(resolved, schema);
|
|
60
|
+
if (error !== undefined)
|
|
61
|
+
throw refusal('STACK_SEED_DATA_INVALID', `${item.scope}/${item.entry.id}: ${document.path} does not validate against ${String(item.entry.schema)}: ${error.replace(/:.*$/u, '')}`);
|
|
62
|
+
return { path: document.path, document: resolved };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** The members a document entry references, in first-use order. */
|
|
66
|
+
const membersOf = (input, item) => [...new Set(seedDocuments(path.join(input.repoRoot, item.level.dir), item.entry).flatMap((document) => secretReferences(document.doc)))];
|
|
67
|
+
/** Opens one sealed member of the environment in memory (the sops seam and its age identity) and answers its map. */
|
|
68
|
+
function openMember(input, member) {
|
|
69
|
+
const file = memberPath(input, member);
|
|
70
|
+
return input.ctx.sops.decrypt(file, envelopeOf(fs.readFileSync(file, 'utf8')).format);
|
|
71
|
+
}
|
|
72
|
+
/** The sealed file of a member of the environment; refused when the environment does not seal it. */
|
|
73
|
+
function memberPath(input, member) {
|
|
74
|
+
const file = path.join(input.repoRoot, seedMemberFile(input.env, member));
|
|
75
|
+
if (!fs.existsSync(file))
|
|
76
|
+
throw refusal('STACK_SEED_SECRET_MISSING', `${seedMemberFile(input.env, member)} is not sealed; seal it with starci app secret set --env ${input.env}`);
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
/** What a member reads as in a dry run: its key names from the envelope, each a sealed placeholder; no value is opened. */
|
|
80
|
+
function sealedMember(input, member) {
|
|
81
|
+
return Object.fromEntries(envelopeOf(fs.readFileSync(memberPath(input, member), 'utf8')).keys.map((key) => [key, SEALED]));
|
|
82
|
+
}
|
|
83
|
+
/** The receipt text of the opened members: each name with a digest of its values, never a value. */
|
|
84
|
+
const secretsReceipt = (opened) => [...opened].map(([member, values]) => `${member}:${sha(JSON.stringify(Object.entries(values).sort(([a], [b]) => (a < b ? -1 : Number(a > b))))).slice(0, 16)}`).join(',');
|
|
85
|
+
/** Applies one document entry: the app command with the resolved documents on stdin, then its receipt; answers the receipt's secrets. */
|
|
86
|
+
function applyDocuments(input, item, receipt) {
|
|
87
|
+
const opened = new Map();
|
|
88
|
+
const open = (member) => {
|
|
89
|
+
const known = opened.get(member);
|
|
90
|
+
if (known)
|
|
91
|
+
return known;
|
|
92
|
+
const values = openMember(input, member);
|
|
93
|
+
opened.set(member, values);
|
|
94
|
+
return values;
|
|
95
|
+
};
|
|
96
|
+
const documents = resolvedDocuments(input, item, open);
|
|
97
|
+
const secrets = secretsReceipt(opened);
|
|
98
|
+
if (receipt?.hash === item.hash && receipt.secrets === secrets)
|
|
99
|
+
return null;
|
|
100
|
+
const run = input.command(item.entry.target, `${JSON.stringify({ env: input.env, scope: item.scope, entry: item.entry.id, kind: item.entry.kind, documents })}\n`);
|
|
101
|
+
if (run.error || run.status !== 0)
|
|
102
|
+
throw failure('STACK_SEED_COMMAND', `${item.scope}/${item.entry.id}: ${item.entry.target.join(' ')} ${run.error ? 'could not run' : `exited ${String(run.status)}`}; its output is withheld (it held the opened secrets)`);
|
|
103
|
+
psqlTransaction(input.ctx, input.target, [{ sql: receiptSql(item, input.env, secrets) }], { withhold: true, code: 'STACK_SEED_RECEIPT' });
|
|
104
|
+
return secrets;
|
|
105
|
+
}
|
|
106
|
+
/** The line of an entry the step leaves as it is, or null when it applies. */
|
|
107
|
+
function keptLine(item, receipt) {
|
|
108
|
+
const name = `seed ${item.scope}/${item.entry.id}`;
|
|
109
|
+
if (item.entry.kind === 'files')
|
|
110
|
+
return `${name}: validated, not uploaded (files: the harness has no object-storage client; the app uploads them to ${String(item.entry.target)})`;
|
|
111
|
+
if (receipt && item.entry.mode === 'once')
|
|
112
|
+
return `${name}: kept (once, applied before)`;
|
|
113
|
+
if (receipt?.hash === item.hash && item.entry.kind === 'sql')
|
|
114
|
+
return `${name}: unchanged (upsert)`;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** The seed step of `env`: plans it, or with `execute` applies it. Returns { ok, lines, data: { applied, kept } }. */
|
|
118
|
+
export function seedStep(input) {
|
|
119
|
+
const planned = levelsOf(input.repoRoot, input.env).flatMap((level) => level.entries.map((entry) => ({
|
|
120
|
+
level, scope: level.env ?? SHARED_SCOPE, entry, hash: entryDigest(path.join(input.repoRoot, level.dir), entry),
|
|
121
|
+
})));
|
|
122
|
+
if (planned.length === 0)
|
|
123
|
+
return { ok: true, lines: ['seed: no entry in .starcistacks/seeds or the level of the environment'], data: { applied: [], entries: 0 } };
|
|
124
|
+
const receipts = receiptsOf(input);
|
|
125
|
+
const lines = [];
|
|
126
|
+
const applied = [];
|
|
127
|
+
if (input.execute)
|
|
128
|
+
psqlTransaction(input.ctx, input.target, [{ sql: RECEIPTS_DDL }], { code: 'STACK_SEED_RECEIPT' });
|
|
129
|
+
for (const item of planned) {
|
|
130
|
+
const receipt = receipts.find((row) => row.scope === item.scope && row.id === item.entry.id);
|
|
131
|
+
const kept = keptLine(item, receipt);
|
|
132
|
+
const name = `${item.scope}/${item.entry.id}`;
|
|
133
|
+
if (kept !== null) {
|
|
134
|
+
lines.push(kept);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!input.execute) {
|
|
138
|
+
const members = isDocumentEntry(item.entry) ? membersOf(input, item) : [];
|
|
139
|
+
if (isDocumentEntry(item.entry))
|
|
140
|
+
resolvedDocuments(input, item, (member) => sealedMember(input, member));
|
|
141
|
+
const unchanged = receipt?.hash === item.hash;
|
|
142
|
+
lines.push(`plan seed ${name}: ${unchanged ? 'apply when a referenced secret changed' : 'apply'} (${item.entry.kind}, ${item.entry.mode}${members.length ? `, secrets ${members.join(', ')}` : ''})`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (item.entry.kind === 'sql') {
|
|
146
|
+
const files = entryFiles(path.join(input.repoRoot, item.level.dir), item.entry).map((file) => ({ file: path.join(input.repoRoot, item.level.dir, file) }));
|
|
147
|
+
psqlTransaction(input.ctx, input.target, [...files, { sql: receiptSql(item, input.env, '') }], { code: 'STACK_SEED_SQL' });
|
|
148
|
+
lines.push(`seed ${name}: applied (sql, ${item.entry.mode}, ${files.length} file${files.length === 1 ? '' : 's'})`);
|
|
149
|
+
applied.push(name);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const secrets = applyDocuments(input, item, receipt);
|
|
153
|
+
if (secrets === null) {
|
|
154
|
+
lines.push(`seed ${name}: unchanged (${item.entry.mode})`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
lines.push(`seed ${name}: applied (${item.entry.kind}, ${item.entry.mode}${secrets ? `, secrets ${secrets.split(',').map((part) => part.split(':')[0]).join(', ')}` : ''})`);
|
|
158
|
+
applied.push(name);
|
|
159
|
+
}
|
|
160
|
+
if (planned.length === 0)
|
|
161
|
+
lines.push('seed: no entry in .starcistacks/seeds or the environment\'s level');
|
|
162
|
+
return { ok: true, lines, data: { applied, entries: planned.length } };
|
|
163
|
+
}
|
|
164
|
+
/** The running container of a service of the stack on this daemon, or undefined. */
|
|
165
|
+
function serviceContainer(ctx, config, service) {
|
|
166
|
+
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
167
|
+
return splitLines(found.stdout)[0];
|
|
168
|
+
}
|
|
169
|
+
/** True when either seed level of the environment declares an entry. */
|
|
170
|
+
export function hasSeedEntries(repoRoot, env) {
|
|
171
|
+
return readSeedLevel(repoRoot, null).entries.length > 0 || readSeedLevel(repoRoot, env).entries.length > 0;
|
|
172
|
+
}
|
|
173
|
+
/** What a deploy's dry run says of the seed step: every entry of the two levels, each applied unless its receipt holds. */
|
|
174
|
+
export function seedPlanLine(repoRoot, env) {
|
|
175
|
+
const entries = levelsOf(repoRoot, env).flatMap((level) => level.entries.map((entry) => `${level.env ?? SHARED_SCOPE}/${entry.id} (${entry.kind}, ${entry.mode})`));
|
|
176
|
+
return `seed after convergence, each entry unless its receipt in ${SEED_RECEIPTS} holds: ${entries.join(', ')}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The seed step of a deploy (PROJECT-STACKS-6), after convergence so a document entry's app command is the new release's: in the database
|
|
180
|
+
* container under the credentials it holds (the role the migrations ran as), each app command inside a running task of the jobs service
|
|
181
|
+
* (`deploy.jobs.service`), the documents on its stdin.
|
|
182
|
+
*/
|
|
183
|
+
export async function seedDeployed(ctx, config, database, f) {
|
|
184
|
+
const container = await f.dbContainer(ctx, config, { service: database.service, seconds: DB_WAIT_SECONDS });
|
|
185
|
+
if (!container)
|
|
186
|
+
return { ok: false, lines: [`FAIL seed no running container of ${config.stack}_${database.service} within ${DB_WAIT_SECONDS}s`] };
|
|
187
|
+
const command = (argv, input) => {
|
|
188
|
+
const service = config.jobs?.service;
|
|
189
|
+
if (service === undefined)
|
|
190
|
+
throw refusal('STACK_SEED_NO_SERVICE', 'a data entry runs its app command in the jobs service; declare deploy.jobs of the environment');
|
|
191
|
+
const task = serviceContainer(ctx, config, service);
|
|
192
|
+
if (!task)
|
|
193
|
+
return { status: null, stdout: '', stderr: '', error: new Error(`no running container of ${config.stack}_${service}`) };
|
|
194
|
+
return tool(ctx, 'docker', ['exec', '-i', task, ...argv], { input });
|
|
195
|
+
};
|
|
196
|
+
return seedStep({ ctx, repoRoot: config.repoRoot, env: config.env, target: f.containerTarget(ctx, container), execute: true, command });
|
|
197
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The print width of the canon config. */
|
|
2
|
+
export declare const CANON_PRINT_WIDTH = 120;
|
|
3
|
+
/** One indent level of code (`tabWidth` of the canon config). */
|
|
4
|
+
export declare const CANON_INDENT = " ";
|
|
5
|
+
/** One indent level of JSON (the canon config's override for `*.json`). */
|
|
6
|
+
export declare const CANON_JSON_INDENT = " ";
|
|
7
|
+
/**
|
|
8
|
+
* `head` + an array literal of the items + `tail`, the way Prettier prints it: `head[a, b]tail` on one line while it fits the print width,
|
|
9
|
+
* else every item on its own line with a trailing comma.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} list - The text before the array, its items and the text after it.
|
|
12
|
+
* @returns {string} The lines, without a final newline.
|
|
13
|
+
*/
|
|
14
|
+
export declare const canonList: ({ head, items, tail }: {
|
|
15
|
+
head: string;
|
|
16
|
+
items: readonly string[];
|
|
17
|
+
tail?: string;
|
|
18
|
+
}) => string;
|
|
19
|
+
/**
|
|
20
|
+
* A named import, the way Prettier prints it: `import { a, b } from "x"` on one line while it fits, else one name a line; an import of one
|
|
21
|
+
* name is never broken.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} declaration - The imported names, the module and whether it imports types only.
|
|
24
|
+
* @returns {string} The lines, without a final newline.
|
|
25
|
+
*/
|
|
26
|
+
export declare const canonImport: ({ names, from, type }: {
|
|
27
|
+
names: readonly string[];
|
|
28
|
+
from: string;
|
|
29
|
+
type?: boolean;
|
|
30
|
+
}) => string;
|
|
31
|
+
/**
|
|
32
|
+
* A JSON document as the canon prints a `.json` file (not a `package.json`, which Prettier prints the way `JSON.stringify` does): every
|
|
33
|
+
* object open one key a line, an array of scalars on one line while it fits, and a final newline.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value - The document.
|
|
36
|
+
* @returns {string} Its text.
|
|
37
|
+
*/
|
|
38
|
+
export declare const canonJson: (value: unknown) => string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// canon-layout.mts - the layout of the canon Prettier config (@starci/prettier-config) that the code and JSON `starci app sync` renders by
|
|
2
|
+
// itself are written in, so a managed file passes the app's `prettier --check` (HFS_FORMAT, the pre-commit hook) as rendered and formatting it
|
|
3
|
+
// never makes HFS_SYNC_DRIFT. A rendered file is laid out the way Prettier lays it out: a list on one line while the line fits the print width,
|
|
4
|
+
// else one item a line with a trailing comma. src/sync-prettier.spec.mts holds these numbers to the canon config and every managed file to
|
|
5
|
+
// `prettier --check`.
|
|
6
|
+
/** The print width of the canon config. */
|
|
7
|
+
export const CANON_PRINT_WIDTH = 120;
|
|
8
|
+
/** One indent level of code (`tabWidth` of the canon config). */
|
|
9
|
+
export const CANON_INDENT = ' ';
|
|
10
|
+
/** One indent level of JSON (the canon config's override for `*.json`). */
|
|
11
|
+
export const CANON_JSON_INDENT = ' ';
|
|
12
|
+
/** `open` + the items + `close` on one line when it fits or `breaks` is false, else one item a line, one level deeper, with a trailing comma. */
|
|
13
|
+
function fitted(open, items, close, flat, breaks) {
|
|
14
|
+
if (!breaks || flat.length <= CANON_PRINT_WIDTH)
|
|
15
|
+
return flat;
|
|
16
|
+
return [open, ...items.map((item) => `${CANON_INDENT}${item},`), close].join('\n');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `head` + an array literal of the items + `tail`, the way Prettier prints it: `head[a, b]tail` on one line while it fits the print width,
|
|
20
|
+
* else every item on its own line with a trailing comma.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} list - The text before the array, its items and the text after it.
|
|
23
|
+
* @returns {string} The lines, without a final newline.
|
|
24
|
+
*/
|
|
25
|
+
export const canonList = ({ head, items, tail = '' }) => fitted(`${head}[`, items, `]${tail}`, `${head}[${items.join(', ')}]${tail}`, items.length > 0);
|
|
26
|
+
/**
|
|
27
|
+
* A named import, the way Prettier prints it: `import { a, b } from "x"` on one line while it fits, else one name a line; an import of one
|
|
28
|
+
* name is never broken.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} declaration - The imported names, the module and whether it imports types only.
|
|
31
|
+
* @returns {string} The lines, without a final newline.
|
|
32
|
+
*/
|
|
33
|
+
export const canonImport = ({ names, from, type = false }) => {
|
|
34
|
+
const head = `import ${type ? 'type ' : ''}{`;
|
|
35
|
+
const tail = `} from ${JSON.stringify(from)}`;
|
|
36
|
+
return fitted(head, names, tail, `${head} ${names.join(', ')} ${tail}`, names.length > 1);
|
|
37
|
+
};
|
|
38
|
+
const isContainer = (value) => typeof value === 'object' && value !== null;
|
|
39
|
+
const isEmpty = (value) => (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
|
40
|
+
/** One JSON value at `indent`, after `prefix` (`"key": `) and before `suffix` (`,` or nothing), as the canon prints a `.json` file. */
|
|
41
|
+
function jsonValue(value, indent, prefix, suffix) {
|
|
42
|
+
if (!isContainer(value) || isEmpty(value))
|
|
43
|
+
return `${indent}${prefix}${JSON.stringify(value)}${suffix}`;
|
|
44
|
+
const inner = `${indent}${CANON_JSON_INDENT}`;
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
// An array of scalars stays on one line while it fits; an array holding an object or a list breaks, since the object is kept open.
|
|
47
|
+
const flat = `${indent}${prefix}[${value.map((item) => JSON.stringify(item)).join(', ')}]${suffix}`;
|
|
48
|
+
if (value.every((item) => !isContainer(item) || isEmpty(item)) && flat.length <= CANON_PRINT_WIDTH)
|
|
49
|
+
return flat;
|
|
50
|
+
return [`${indent}${prefix}[`, ...value.map((item, index) => jsonValue(item, inner, '', index < value.length - 1 ? ',' : '')), `${indent}]${suffix}`].join('\n');
|
|
51
|
+
}
|
|
52
|
+
const entries = Object.entries(value);
|
|
53
|
+
return [`${indent}${prefix}{`, ...entries.map(([key, item], index) => jsonValue(item, inner, `${JSON.stringify(key)}: `, index < entries.length - 1 ? ',' : '')), `${indent}}${suffix}`].join('\n');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A JSON document as the canon prints a `.json` file (not a `package.json`, which Prettier prints the way `JSON.stringify` does): every
|
|
57
|
+
* object open one key a line, an array of scalars on one line while it fits, and a final newline.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} value - The document.
|
|
60
|
+
* @returns {string} Its text.
|
|
61
|
+
*/
|
|
62
|
+
export const canonJson = (value) => `${jsonValue(value, '', '', '')}\n`;
|
package/sync/ci-commands.d.mts
CHANGED
|
@@ -11,9 +11,10 @@ interface CiSource {
|
|
|
11
11
|
/** jest --shard=i/N gives each shard ceil(specs / N) spec files in order, so shard N is empty unless (N - 1) * ceil(specs / N) < specs. */
|
|
12
12
|
export declare function filledShards(specs: number, wanted: number): number;
|
|
13
13
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
14
|
-
export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app, packages }?: {
|
|
14
|
+
export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app, packages, rootPackages }?: {
|
|
15
15
|
nodeMajor?: string;
|
|
16
16
|
packages?: boolean;
|
|
17
|
+
rootPackages?: boolean;
|
|
17
18
|
app?: {
|
|
18
19
|
sides: {
|
|
19
20
|
fe: {
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -31,7 +31,7 @@ function codecovSteps(lcovReport, codecov) {
|
|
|
31
31
|
].join('\n');
|
|
32
32
|
}
|
|
33
33
|
/** Sonar is required: on every full run it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
|
|
34
|
-
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
34
|
+
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout, scopes) {
|
|
35
35
|
if (!sonar)
|
|
36
36
|
return '';
|
|
37
37
|
const condition = SONAR_CONDITION;
|
|
@@ -64,7 +64,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
|
64
64
|
' pattern: sonar-lint-*',
|
|
65
65
|
' path: reports',
|
|
66
66
|
' merge-multiple: true',
|
|
67
|
-
...sonarMergeSteps(condition),
|
|
67
|
+
...sonarMergeSteps(condition, scopes),
|
|
68
68
|
' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
|
|
69
69
|
` if: ${condition}`,
|
|
70
70
|
' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
|
|
@@ -137,15 +137,15 @@ function frontendBuild(source, app) {
|
|
|
137
137
|
].filter(Boolean).join('\n')).join('\n');
|
|
138
138
|
return { env: singleEnv, steps };
|
|
139
139
|
}
|
|
140
|
-
/** The per-
|
|
141
|
-
function sonarMergeSteps(condition) {
|
|
140
|
+
/** The per-scope lint reports (one artifact per lint matrix scope) join into the one reports/lint.sonar.json the Sonar import reads, even when one scope wrote none. The local release gate writes the same file with mergeSonarReports (release/lint-report.mjs): keep both identical. */
|
|
141
|
+
function sonarMergeSteps(condition, scopes) {
|
|
142
142
|
return [
|
|
143
143
|
' - name: merge Sonar lint reports',
|
|
144
144
|
` if: ${condition}`,
|
|
145
145
|
' run: |',
|
|
146
146
|
" node --input-type=module <<'NODE'",
|
|
147
147
|
" import fs from 'node:fs';",
|
|
148
|
-
|
|
148
|
+
` const files = [${scopes.map((scope) => `'reports/lint.${scope}.sonar.json'`).join(', ')}];`,
|
|
149
149
|
" const reports = files.filter(file => fs.existsSync(file)).map(file => JSON.parse(fs.readFileSync(file, 'utf8')));",
|
|
150
150
|
' const rules = [...new Map(reports.flatMap(report => report.rules).map(rule => [rule.id, rule])).values()];',
|
|
151
151
|
' const issues = reports.flatMap(report => report.issues);',
|
|
@@ -179,7 +179,7 @@ function ciJobSettings(declaration, source) {
|
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
181
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
182
|
-
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app, packages = false } = {}) {
|
|
182
|
+
export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22', app, packages = false, rootPackages = false } = {}) {
|
|
183
183
|
const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
|
|
184
184
|
const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
|
|
185
185
|
const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
|
|
@@ -188,8 +188,11 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
188
188
|
const build = frontendBuild(source, app);
|
|
189
189
|
const settings = ciJobSettings(buildDeclaration(source), source);
|
|
190
190
|
const side = '${{ matrix.side }}';
|
|
191
|
+
// The lint matrix: one job per side, and one for the root packages of an app that has them (their scope runs whatever became of the sides').
|
|
192
|
+
const scopes = ['be', 'fe', ...(rootPackages ? ['packages'] : [])];
|
|
191
193
|
return {
|
|
192
194
|
...settings,
|
|
195
|
+
lintSides: `[${scopes.join(', ')}]`,
|
|
193
196
|
lcovShardsDir: lcovReport.split('/').slice(0, -1).concat('shards').join('/'),
|
|
194
197
|
// The istanbul JSON a shard writes beside the lcov (STARCI_COVERAGE_SHARD=1): what the coverage job merges.
|
|
195
198
|
shardCoverageReport: lcovReport.split('/').slice(0, -1).concat('coverage-final.json').join('/'),
|
|
@@ -224,7 +227,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
224
227
|
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
225
228
|
verdictSonarNeed: sonar ? ', sonar' : '',
|
|
226
229
|
verdictSonarCheck: sonar ? `\n if [[ "$FULL" == true && "$CHECKS" == true ]]; then expect sonar success; else expect sonar idle; fi` : '',
|
|
227
|
-
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
230
|
+
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar, scopes),
|
|
228
231
|
lcovArtifact,
|
|
229
232
|
};
|
|
230
233
|
}
|
package/sync/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PackagesCoverage } from '../runtime/scripts/hfs/coverage-scope.mjs';
|
|
1
2
|
import { type RepoView } from '../runtime/scripts/hfs/slots.mjs';
|
|
2
3
|
import type { AppRepo } from '../runtime/scripts/hfs/slot-app-view.mjs';
|
|
3
4
|
import type { Slot, SlotManifest } from '../runtime/scripts/hfs/manifest-shape.mjs';
|
|
@@ -68,6 +69,7 @@ export interface AppSourceFacts {
|
|
|
68
69
|
read: (file: string) => string;
|
|
69
70
|
workPresent: boolean;
|
|
70
71
|
hasImage: (file: string) => boolean;
|
|
72
|
+
hasContracts: boolean;
|
|
71
73
|
modules?: readonly string[];
|
|
72
74
|
otherPackages?: readonly string[];
|
|
73
75
|
stack: any;
|
|
@@ -146,6 +148,8 @@ export declare function sonarScope(presets: Presets | null | undefined): {
|
|
|
146
148
|
};
|
|
147
149
|
/** The managed ESLint config at the app root that lints the root workspace packages. */
|
|
148
150
|
export declare const PACKAGES_LINT_FILE = "eslint.config.mjs";
|
|
151
|
+
/** The app-relative folders of the root packages whose logic the back end's unit run measures: every module package and every other package, never the registry. */
|
|
152
|
+
export declare const rootPackageFolders: (source: Partial<Pick<AppSourceFacts, "modules" | "otherPackages">> | undefined) => string[];
|
|
149
153
|
export declare function appSource(root: string, { parseYaml, notices }?: {
|
|
150
154
|
parseYaml?: ParseYaml | undefined;
|
|
151
155
|
notices?: string[] | undefined;
|
|
@@ -188,12 +192,19 @@ export declare function imageApps(app: ResolvedApp, source: Partial<Pick<AppSour
|
|
|
188
192
|
/**
|
|
189
193
|
* The Dockerfile of every declared app, [{ path, content }] (app-relative): written once by starci app scaffold from templates/<side>/image/<kind>
|
|
190
194
|
* and then the app's own, judged by the docker rules (R187-R191). A Next image copies the fe packages only when the fe side opts into
|
|
191
|
-
* them and the be contracts only when the fe side reads them
|
|
195
|
+
* them and the be contracts only when the fe side reads them and the back end holds them (`source.hasContracts`: a back end that emits no
|
|
196
|
+
* contract has no be/contracts/, and a COPY of a missing folder fails the image build), so each COPY names a folder the app has.
|
|
192
197
|
*/
|
|
193
|
-
export declare function imageFiles(app: ResolvedApp, source?: Partial<Pick<AppSourceFacts, 'hasImage'>>): {
|
|
198
|
+
export declare function imageFiles(app: ResolvedApp, source?: Partial<Pick<AppSourceFacts, 'hasImage' | 'hasContracts'>>): {
|
|
194
199
|
path: string;
|
|
195
200
|
content: string;
|
|
196
201
|
}[];
|
|
202
|
+
/** Every value a template of `scope` can name, derived from hfs.json and the presets. */
|
|
203
|
+
/**
|
|
204
|
+
* The `packages` option of the managed be/jest.config.js (`starciJestConfig`), laid out as prettier lays it out (width 120, indent 4): the root packages whose
|
|
205
|
+
* specs the unit run holds, with the files measured in them; an app without one has no such option.
|
|
206
|
+
*/
|
|
207
|
+
export declare function jestPackagesSource(packages: PackagesCoverage): string;
|
|
197
208
|
export declare function variables(app: ResolvedApp, scope: string, presets: Presets | null | undefined, sonarKey: string | null | undefined, manifest?: SlotManifest, source?: Pick<AppSourceFacts, 'files' | 'read'> & Partial<AppSourceFacts>): Record<string, string>;
|
|
198
209
|
/**
|
|
199
210
|
* The rendered targets of an app declaration. Without source facts the app counts the back-end source files its own targets
|