@starci/hfs 4.7.27 → 4.7.29
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 +17 -0
- package/README.md +9 -3
- package/package.json +7 -1
- package/release/candidate-ci.mjs +3 -21
- package/runtime/engine/yaml.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +913 -637
- package/runtime/knowledge/hfs/slots.yaml +21 -14
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +146 -58
- package/runtime/scripts/api/git/check-ignore.mjs +5 -0
- package/runtime/scripts/gates/runtime-host.mjs +83 -0
- package/runtime/scripts/gates/sonar-gate.mjs +251 -0
- package/runtime/scripts/gates/starcistacks-ci.mjs +55 -0
- package/runtime/scripts/gates/starcistacks-form.mjs +26 -0
- package/runtime/scripts/gates/starcistacks-runtime.mjs +2 -0
- package/runtime/scripts/gates/starcistacks-value-file.mjs +17 -0
- package/runtime/scripts/gates/starcistacks.mjs +499 -0
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +1 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +7 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/bypass-markers.mjs +24 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/lib/bypass-markers.mjs +116 -0
- package/runtime/scripts/lib/check-code-loosening.mjs +128 -0
- package/runtime/scripts/lib/example-refs.mjs +205 -0
- package/runtime/scripts/lib/failure-code-reads.mjs +100 -0
- package/runtime/scripts/lib/gate-loosening.mjs +52 -8
- package/runtime/scripts/lib/json-formats.mjs +34 -0
- package/runtime/scripts/lib/package-at.mjs +1 -1
- package/runtime/scripts/lib/properties.mjs +16 -0
- package/runtime/scripts/lib/provision-control-failure.mjs +32 -0
- package/runtime/scripts/lib/sonar-new-code.mjs +58 -0
- package/runtime/scripts/lib/starciwork-boundary.mjs +164 -0
- package/runtime/scripts/lib/work-edition.mjs +40 -0
- package/scaffold/app.mjs +3 -0
- package/scaffold/service.mjs +1 -1
- package/scaffold/test-world.mjs +125 -0
- package/src/main.mjs +1 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +16 -4
- package/templates/app/hooks/husky/pre-commit +8 -7
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +32 -0
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +14 -0
- package/templates/be/test-world/integration/readiness.integration-spec.ts.tpl +28 -0
- package/templates/be/test-world/world/global-setup.ts.tpl +1 -0
- package/templates/be/test-world/world/global-teardown.ts.tpl +1 -0
- package/templates/be/test-world/world/test-apps.options.ts.tpl +62 -0
- package/templates/be/test-world/world/test-world.config.ts.tpl +20 -0
- package/templates/be/test-world/world/use-test-world.ts.tpl +2 -0
- package/upgrade/index.mjs +48 -9
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// test-world.mjs - the test world of a full app: the declaration of @starci/test-world under be/src/tests/world/ and one baseline
|
|
2
|
+
// real spec per world layer, rendered from templates/be/test-world/ for the api apps and connections hfs.json declares.
|
|
3
|
+
//
|
|
4
|
+
// A full app's release gate runs integration, e2e and contract (the managed test:* scripts), and the jest preset gives those
|
|
5
|
+
// projects only to an app that has be/src/tests/world/global-setup.ts. The scaffold writes these files into every new full app,
|
|
6
|
+
// and `starci app upgrade` adds the missing ones to an existing full app (never overwriting a file the app already has):
|
|
7
|
+
// - world/: the declaration (stack .starcistacks/dev, Postgres per connection, the api apps, the cli app's migration runner as
|
|
8
|
+
// the migrate step, a platform base for modules worlds), its spec entry and the library's global setup and teardown;
|
|
9
|
+
// - integration/readiness/: the readiness probe over the real database, ready, refused while the database is cut, ready again;
|
|
10
|
+
// - e2e/system-health/<app>-serves-once-migrated: each api app booted over the migrated database and called over HTTP;
|
|
11
|
+
// - fixtures/persistence/migrations.sql.ts: the ledger reads the e2e asserts.
|
|
12
|
+
// The contract layer is the app's own contract spec the be skeleton already ships (src/tests/contract/http-security/).
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { TEMPLATES_DIR } from '../sync/index.mjs';
|
|
16
|
+
import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
|
|
17
|
+
|
|
18
|
+
const TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'be', 'test-world');
|
|
19
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/u;
|
|
20
|
+
|
|
21
|
+
/** The file the jest preset reads to give an app its world projects; its presence means the app has a test world. */
|
|
22
|
+
export const WORLD_MARKER = 'be/src/tests/world/global-setup.ts';
|
|
23
|
+
|
|
24
|
+
const template = (file) => fs.readFileSync(path.join(TEMPLATE_DIR, ...file.split('/')), 'utf8').replace(/\r\n/g, '\n');
|
|
25
|
+
|
|
26
|
+
/** A template with its `{{name}}` slots filled; every slot must have a value and every value must be used. */
|
|
27
|
+
function fill(file, values) {
|
|
28
|
+
const used = new Set();
|
|
29
|
+
const text = template(file).replace(/\{\{([A-Za-z]+)\}\}/gu, (_, key) => {
|
|
30
|
+
if (!Object.hasOwn(values, key)) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/be/test-world/${file} names {{${key}}}, which has no value`);
|
|
31
|
+
used.add(key);
|
|
32
|
+
return values[key];
|
|
33
|
+
});
|
|
34
|
+
const unused = Object.keys(values).filter((key) => !used.has(key));
|
|
35
|
+
if (unused.length) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/be/test-world/${file} does not use ${unused.join(', ')}`);
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `.name` for an identifier, `["name"]` otherwise: how a spec reads a record key. */
|
|
40
|
+
const access = (name) => (IDENTIFIER.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`);
|
|
41
|
+
const constantOf = (name) => name.replace(/[^A-Za-z0-9]+/gu, '_').toUpperCase();
|
|
42
|
+
const union = (names) => names.map((name) => JSON.stringify(name)).join(' | ');
|
|
43
|
+
const migrationsConst = (connection) => `${constantOf(connection.name)}_APPLIED_MIGRATIONS`;
|
|
44
|
+
|
|
45
|
+
/** The api apps and connections of a full declaration, refused when the world has nothing to boot or migrate. */
|
|
46
|
+
function worldShape(app) {
|
|
47
|
+
const apis = app.sides.be.apps.filter((entry) => entry.kind === 'api');
|
|
48
|
+
const connections = app.sides.be.connections ?? [];
|
|
49
|
+
if (!apis.length) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world boots the api apps of hfs.json, and it declares none');
|
|
50
|
+
if (!connections.length) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world migrates the connections of hfs.json, and it declares none');
|
|
51
|
+
if (!app.sides.be.apps.some((entry) => entry.kind === 'cli' && entry.name === 'cli')) throw new ScaffoldError('HFS_SCAFFOLD_TEST_WORLD', 'the test world migrates through the cli app (be/apps/cli), and hfs.json declares none');
|
|
52
|
+
return { apis, connections };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function worldConfig({ apis, connections }) {
|
|
56
|
+
return fill('world/test-world.config.ts.tpl', {
|
|
57
|
+
appModuleImports: apis.map((api) => `import { AppModule as ${pascalOf(api.name)}App } from "../../../apps/${api.name}/src/app.module"`).join('\n'),
|
|
58
|
+
optionsImports: ['cliOptionsOf', ...apis.map((api) => `${camelOf(api.name)}OptionsOf`), 'migrateCli', 'platformBase'].sort().join(', '),
|
|
59
|
+
connectionDeclarations: connections.map((connection) => `{ name: ${JSON.stringify(connection.name)} }`).join(', '),
|
|
60
|
+
worldApps: apis.map((api) => ` ${IDENTIFIER.test(api.name) ? api.name : JSON.stringify(api.name)}: { module: ${pascalOf(api.name)}App, options: ${camelOf(api.name)}OptionsOf },`).join('\n'),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function worldOptions({ apis, connections }) {
|
|
65
|
+
const appOptions = apis.map((api) => [
|
|
66
|
+
'',
|
|
67
|
+
`/** The ${api.name} app, read the way its \`main.ts\` reads its environment, listening on the port the run reserved for it. */`,
|
|
68
|
+
`export const ${camelOf(api.name)}OptionsOf = (w: AppWiring): ${pascalOf(api.name)}Options =>`,
|
|
69
|
+
` read${pascalOf(api.name)}Env(environmentOf(w, { PORT: String(w.apps${access(api.name)}.port) }))`,
|
|
70
|
+
'',
|
|
71
|
+
].join('\n')).join('');
|
|
72
|
+
return fill('world/test-apps.options.ts.tpl', {
|
|
73
|
+
appEnvImports: apis.flatMap((api) => [
|
|
74
|
+
`import { read${pascalOf(api.name)}Env } from "../../../apps/${api.name}/src/${api.name}.env"`,
|
|
75
|
+
`import type { ${pascalOf(api.name)}Options } from "../../../apps/${api.name}/src/${api.name}.options"`,
|
|
76
|
+
]).join('\n'),
|
|
77
|
+
appsUnion: union(apis.map((api) => api.name)),
|
|
78
|
+
connectionsUnion: union(connections.map((connection) => connection.name)),
|
|
79
|
+
environmentConnections: connections.map((connection) => ` ${connection.envPrefix}_URL: w.db${access(connection.name)}.url,`).join('\n'),
|
|
80
|
+
appOptions,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function migrationsFixture({ connections }) {
|
|
85
|
+
return fill('fixtures/migrations.sql.ts.tpl', {
|
|
86
|
+
migrationQueries: connections.map((connection) => [
|
|
87
|
+
'',
|
|
88
|
+
`/** The migrations the run applied to the ${connection.name} connection ($none), oldest first: the ledger the cli migrate step writes. */`,
|
|
89
|
+
`export const ${migrationsConst(connection)} = sql\`SELECT name FROM ${connection.name}_migrations ORDER BY id LIMIT 500\``,
|
|
90
|
+
'',
|
|
91
|
+
].join('\n')).join(''),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The connection an api app's e2e reads back: the one it owns, else the first declared. */
|
|
96
|
+
const connectionOf = (api, connections) => connections.find((connection) => connection.owner === api.name) ?? connections[0];
|
|
97
|
+
|
|
98
|
+
function e2eSpec(api, connections) {
|
|
99
|
+
const connection = connectionOf(api, connections);
|
|
100
|
+
return fill('e2e/serves-once-migrated.e2e-spec.ts.tpl', {
|
|
101
|
+
app: api.name,
|
|
102
|
+
appAccess: access(api.name),
|
|
103
|
+
connection: connection.name,
|
|
104
|
+
connectionAccess: access(connection.name),
|
|
105
|
+
migrationsConst: migrationsConst(connection),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The test world files of the full app `app` (a resolved hfs declaration): [{ path, content }], app-relative, in a fixed order.
|
|
111
|
+
* Refused (HFS_SCAFFOLD_TEST_WORLD) when the declaration has no api app, no connection or no cli app to migrate through.
|
|
112
|
+
*/
|
|
113
|
+
export function testWorldFiles(app) {
|
|
114
|
+
const shape = worldShape(app);
|
|
115
|
+
return [
|
|
116
|
+
{ path: 'be/src/tests/world/test-world.config.ts', content: worldConfig(shape) },
|
|
117
|
+
{ path: 'be/src/tests/world/test-apps.options.ts', content: worldOptions(shape) },
|
|
118
|
+
{ path: 'be/src/tests/world/use-test-world.ts', content: fill('world/use-test-world.ts.tpl', {}) },
|
|
119
|
+
{ path: WORLD_MARKER, content: fill('world/global-setup.ts.tpl', {}) },
|
|
120
|
+
{ path: 'be/src/tests/world/global-teardown.ts', content: fill('world/global-teardown.ts.tpl', {}) },
|
|
121
|
+
{ path: 'be/src/tests/fixtures/persistence/migrations.sql.ts', content: migrationsFixture(shape) },
|
|
122
|
+
{ path: 'be/src/tests/integration/readiness/readiness.integration-spec.ts', content: fill('integration/readiness.integration-spec.ts.tpl', {}) },
|
|
123
|
+
...shape.apis.map((api) => ({ path: `be/src/tests/e2e/system-health/${api.name}-serves-once-migrated.e2e-spec.ts`, content: e2eSpec(api, shape.connections) })),
|
|
124
|
+
];
|
|
125
|
+
}
|
package/src/main.mjs
CHANGED
|
@@ -107,7 +107,7 @@ starci app db migrate [--execute] [--local | --linked | --url-env <NAME>] [--jso
|
|
|
107
107
|
starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
108
108
|
starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
|
|
109
109
|
starci app release push [--execute] [--remote <name>] [--json] [--cwd <dir>]
|
|
110
|
-
starci app upgrade --edition full [--plan] [--cwd <dir>]
|
|
110
|
+
starci app upgrade [--edition full] [--plan] [--cwd <dir>]
|
|
111
111
|
starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
|
|
112
112
|
starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
|
|
113
113
|
starci app explain <path> [--cwd <dir>] [--json]
|
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
|
-
function loadPrettier(repoRoot) {
|
|
16
|
+
export 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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// .starcistacks must not be a plaintext secret (only *.enc is sealed). It is also the secrets guard of the commit: every staged file, in
|
|
4
4
|
// any tree, is read from the index and judged with the one secret judgement of `starci app check` (scripts/hfs/rules/secrets.mjs: a secret by
|
|
5
5
|
// being, an .enc that is no sops envelope, a line that matches a secret pattern), so no plaintext secret reaches the history whatever
|
|
6
|
-
// .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override.
|
|
6
|
+
// .gitignore says (`git add -f`, a path tracked before a rule tightened). There is no override. It also
|
|
7
|
+
// refuses every bypass marker the index holds (R303: an inline disable, a type-check escape, a skipped test, a failure swallowed in a hook or
|
|
8
|
+
// workflow; scripts/lib/bypass-markers.mjs), so a bypass fails at the commit and not only at the release. At an HFS app root (hfs.json) it also
|
|
7
9
|
// refuses a back-end logic file the index adds without its unit spec beside it (BE_UNIT_SPEC_NOT_STAGED,
|
|
8
10
|
// scripts/hfs/rules/staged-specs.mjs over the coverage scope; a constants-only file has nothing to test), naming the file. The pre-commit hook judges the staged files;
|
|
9
11
|
// scripts/gates/hfs-sync.mjs judges every tracked file.
|
|
@@ -17,6 +19,8 @@ import path from 'node:path';
|
|
|
17
19
|
import { pathToFileURL } from 'node:url';
|
|
18
20
|
import { secretFileFindings } from '../runtime/scripts/hfs/rules/secrets.mjs';
|
|
19
21
|
import { stagedSpecFindings } from '../runtime/scripts/hfs/rules/staged-specs.mjs';
|
|
22
|
+
import { HFS_BYPASS_MARKER, bypassMessage } from '../runtime/scripts/hfs/rules/bypass-markers.mjs';
|
|
23
|
+
import { bypassesIn } from '../runtime/scripts/lib/bypass-markers.mjs';
|
|
20
24
|
import { loadSlotManifest } from '../runtime/scripts/hfs/slots.mjs';
|
|
21
25
|
import { runGit } from '../runtime/scripts/api/git/lib.mjs';
|
|
22
26
|
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
@@ -48,7 +52,7 @@ function ignoredAmong(cwd, files) {
|
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
|
|
51
|
-
function hygieneFindings(files, ignored) {
|
|
55
|
+
export function hygieneFindings(files, ignored) {
|
|
52
56
|
const findings = [];
|
|
53
57
|
for (const file of files) {
|
|
54
58
|
if (file.startsWith(WORK) && ignored.has(file)) {
|
|
@@ -104,12 +108,20 @@ function secretGuardFindings(cwd, files, alreadyRefused = new Set()) {
|
|
|
104
108
|
});
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
/**
|
|
111
|
+
/** The bypass markers (R303) of `files` read from the index: an inline disable, a type-check escape, a skipped test or a swallowed failure fails at the commit. */
|
|
112
|
+
function bypassGuardFindings(cwd, files) {
|
|
113
|
+
return files.flatMap(file => {
|
|
114
|
+
const text = stagedText(cwd, file);
|
|
115
|
+
return text === null ? [] : bypassesIn(file, text).map(hit => ({ file, code: HFS_BYPASS_MARKER, message: bypassMessage(file, hit) }));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Findings for `files`: the guarded trees, then the secret guard and the bypass guard over every file. */
|
|
108
120
|
export function judge(cwd, files) {
|
|
109
121
|
const guarded = files.filter(GUARDED);
|
|
110
122
|
const findings = hygieneFindings(guarded, ignoredAmong(cwd, guarded.filter(file => file.startsWith(WORK))));
|
|
111
123
|
const refused = new Set(findings.filter(finding => finding.code === 'HFS_PLAINTEXT_SECRET').map(finding => finding.file));
|
|
112
|
-
return { checked: files.length, findings: [...findings, ...secretGuardFindings(cwd, files, refused)] };
|
|
124
|
+
return { checked: files.length, findings: [...findings, ...secretGuardFindings(cwd, files, refused), ...bypassGuardFindings(cwd, files)] };
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
// The scripts/housekeeping/ledger-hygiene.mjs of the checkout under judgment: the root git names for `cwd`, which carries
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{{header}}
|
|
2
|
-
# Commit gate (L0 only): work hygiene
|
|
3
|
-
#
|
|
4
|
-
|
|
2
|
+
# Commit gate (L0 only): work hygiene, the secrets guard and the bypass guard (no inline disable, type-check escape, skipped test or
|
|
3
|
+
# swallowed failure: HFS_BYPASS_MARKER) of the staged files, managed files equal to their render, eslint of the staged files of each
|
|
4
|
+
# side, stylelint over the staged CSS of fe, a prettier check of the staged files. Every step ends the hook when it fails: none can be
|
|
5
|
+
# skipped. Types and the targeted specs belong to the op gate.
|
|
6
|
+
npx starci app hygiene || exit $?
|
|
7
|
+
npx starci app sync --check || exit $?
|
|
5
8
|
# NUL-delimited paths keep filenames intact; bounded batches fit the Windows command line.
|
|
6
9
|
git diff --cached --name-only --diff-filter=ACMR -z --relative=be -- '*.ts' '*.mts' '*.cts' '*.js' '*.mjs' '*.cjs' |
|
|
7
10
|
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd be && npx eslint -- "$@"; fi' sh || exit $?
|
|
8
11
|
git diff --cached --name-only --diff-filter=ACMR -z --relative=fe -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' |
|
|
9
12
|
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd fe && npx eslint --max-warnings=0 --no-warn-ignored -- "$@"; fi' sh || exit $?
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
(cd fe && npx stylelint $styles)
|
|
13
|
-
fi
|
|
13
|
+
git diff --cached --name-only --diff-filter=ACMR -z --relative=fe -- '*.css' |
|
|
14
|
+
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then cd fe && npx stylelint "$@"; fi' sh || exit $?
|
|
14
15
|
git diff --cached --name-only --diff-filter=ACMR -z |
|
|
15
16
|
xargs -0 -n 50 sh -c 'if [ "$#" -gt 0 ]; then npx prettier --check --ignore-unknown -- "$@"; fi' sh || exit $?
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { appliedMigrations, {{migrationsConst}} } from "../../fixtures/persistence/migrations.sql"
|
|
2
|
+
import { useTestWorld } from "../../world/use-test-world"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* system-health: the real {{app}} app booted over the database the run migrated through the cli, called over HTTP. It answers
|
|
6
|
+
* liveness and readiness while its ledger holds the applied migrations; readiness answers 503 while the database is cut and
|
|
7
|
+
* liveness stays up.
|
|
8
|
+
*/
|
|
9
|
+
describe("system-health: the {{app}} app serves once its database is migrated (e2e)", () => {
|
|
10
|
+
const world = useTestWorld({ apps: ["{{app}}"] })
|
|
11
|
+
|
|
12
|
+
it("answers liveness and readiness over the migrated {{connection}} database", async () => {
|
|
13
|
+
const live = await world.apps{{appAccess}}.api.get("/health/live")
|
|
14
|
+
const ready = await world.apps{{appAccess}}.api.get("/health/ready")
|
|
15
|
+
const applied = await appliedMigrations(world.db{{connectionAccess}}, {{migrationsConst}})
|
|
16
|
+
|
|
17
|
+
expect(live.status).toBe(200)
|
|
18
|
+
expect(ready.status).toBe(200)
|
|
19
|
+
expect(ready.body).toEqual({ ok: true })
|
|
20
|
+
expect(applied.length).toBeGreaterThan(0)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("answers 503 on readiness while the database is cut, and liveness stays up", async () => {
|
|
24
|
+
await world.interruptDatabase(async () => {
|
|
25
|
+
const ready = await world.apps{{appAccess}}.api.get("/health/ready")
|
|
26
|
+
const live = await world.apps{{appAccess}}.api.get("/health/live")
|
|
27
|
+
|
|
28
|
+
expect(ready.status).toBe(503)
|
|
29
|
+
expect(live.status).toBe(200)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityManager } from "typeorm"
|
|
2
|
+
import { sql } from "@modules/platform/database"
|
|
3
|
+
|
|
4
|
+
/** One row of a connection's migration ledger: the name of a migration the cli migrate step applied. */
|
|
5
|
+
export interface AppliedMigrationRow {
|
|
6
|
+
/** The migration class name. */
|
|
7
|
+
readonly name: string
|
|
8
|
+
}
|
|
9
|
+
{{migrationQueries}}
|
|
10
|
+
/** Reads the ledger of one connection with one of the queries above. */
|
|
11
|
+
export const appliedMigrations = (
|
|
12
|
+
manager: EntityManager,
|
|
13
|
+
query: ReturnType<typeof sql>,
|
|
14
|
+
): Promise<ReadonlyArray<AppliedMigrationRow>> => manager.query(query)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { HttpSecurityModule, ReadinessService } from "@modules/platform/http-security"
|
|
2
|
+
import { httpSecurityOf } from "../../world/test-apps.options"
|
|
3
|
+
import { useTestWorld } from "../../world/use-test-world"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* readiness: the required-dependency probe over the real primary database of the run, no HTTP door of ours. It answers ready
|
|
7
|
+
* while the database commits a transaction, refuses while the database is cut and answers ready again once it is back.
|
|
8
|
+
*/
|
|
9
|
+
describe("readiness: the required database (integration)", () => {
|
|
10
|
+
const world = useTestWorld({
|
|
11
|
+
modules: [(w) => HttpSecurityModule.register(httpSecurityOf(w))],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const readiness = (): ReadinessService => world.resolve(ReadinessService)
|
|
15
|
+
|
|
16
|
+
it("reports ready while the real primary database commits a transaction", async () => {
|
|
17
|
+
await expect(readiness().probeRequired()).resolves.toEqual({ ok: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("refuses readiness while the database is cut, then reports ready again", async () => {
|
|
21
|
+
await world.interruptDatabase(async () => {
|
|
22
|
+
await expect(readiness().probeRequired()).rejects.toMatchObject({
|
|
23
|
+
code: "HTTP_SECURITY_REQUIRED_DEPENDENCY_UNAVAILABLE",
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
await expect(readiness().probeRequired()).resolves.toEqual({ ok: true })
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "@starci/test-world/global-setup"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "@starci/test-world/global-teardown"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed options the test world hands to the real apps, the migrate step and the platform base of a modules world: what the
|
|
3
|
+
* `main.ts` of each app parses from the environment of a deployment, built from the wiring of the run. The databases are the real
|
|
4
|
+
* ones the library runs; the migrate step is the cli app's own migration runner, the one `cli migrate run` calls.
|
|
5
|
+
*/
|
|
6
|
+
import type { DynamicModule } from "@nestjs/common"
|
|
7
|
+
import { NestFactory } from "@nestjs/core"
|
|
8
|
+
import type { WorldWiring } from "@starci/test-world"
|
|
9
|
+
import { ClockModule } from "@modules/platform/clock"
|
|
10
|
+
import { EnvSource } from "@modules/platform/config"
|
|
11
|
+
import { DatabaseModule, MigrationRunnerService } from "@modules/platform/database"
|
|
12
|
+
import { parseHttpSecurityConfig } from "@modules/platform/http-security"
|
|
13
|
+
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
14
|
+
import { LoggingModule } from "@modules/platform/logging"
|
|
15
|
+
import { RetryModule } from "@modules/platform/retry"
|
|
16
|
+
import { AppModule as CliApp } from "../../../apps/cli/src/app.module"
|
|
17
|
+
import { readCliEnv } from "../../../apps/cli/src/cli.env"
|
|
18
|
+
import { createCliAppOptions } from "../../../apps/cli/src/cli.options"
|
|
19
|
+
import type { CliAppOptions } from "../../../apps/cli/src/cli.options"
|
|
20
|
+
{{appEnvImports}}
|
|
21
|
+
|
|
22
|
+
/** The wiring of the world: its api apps and its database connections. */
|
|
23
|
+
export type AppWiring = WorldWiring<{{appsUnion}}, {{connectionsUnion}}>
|
|
24
|
+
|
|
25
|
+
/** The one browser origin the apps of the world accept. */
|
|
26
|
+
export const ALLOWED_ORIGIN = "http://localhost:3000"
|
|
27
|
+
|
|
28
|
+
/** The environment a deployment hands every app of the run: its mode, its origin allowlist and the URL of every connection. */
|
|
29
|
+
const environmentOf = (w: AppWiring, extra: Readonly<Record<string, string>> = {}): EnvSource =>
|
|
30
|
+
new EnvSource({
|
|
31
|
+
NODE_ENV: "test",
|
|
32
|
+
HTTP_SECURITY_ALLOWED_ORIGINS: ALLOWED_ORIGIN,
|
|
33
|
+
{{environmentConnections}}
|
|
34
|
+
...extra,
|
|
35
|
+
})
|
|
36
|
+
{{appOptions}}
|
|
37
|
+
/** The origin allowlist and rate limits of the run, read the way the api apps read them. */
|
|
38
|
+
export const httpSecurityOf = (w: AppWiring): HttpSecurityOptions => parseHttpSecurityConfig(environmentOf(w))
|
|
39
|
+
|
|
40
|
+
/** The cli app over the run's databases, read the way its `main.ts` reads its environment. */
|
|
41
|
+
export const cliOptionsOf = (w: AppWiring): CliAppOptions => createCliAppOptions(readCliEnv(environmentOf(w)))
|
|
42
|
+
|
|
43
|
+
/** The migrate step of the run: the cli app composed over the run's databases, running the migrations of every connection. */
|
|
44
|
+
export const migrateCli = async (options: CliAppOptions): Promise<void> => {
|
|
45
|
+
const cli = await NestFactory.createApplicationContext(CliApp.register(options), { logger: false })
|
|
46
|
+
try {
|
|
47
|
+
await cli.get(MigrationRunnerService).run()
|
|
48
|
+
} finally {
|
|
49
|
+
await cli.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The platform base of a modules world: the clock, the timer, the logger and the database over the run's connections. */
|
|
54
|
+
export const platformBase = (w: AppWiring): ReadonlyArray<DynamicModule> => [
|
|
55
|
+
ClockModule.register({ isGlobal: true }),
|
|
56
|
+
RetryModule.register({ isGlobal: true }),
|
|
57
|
+
LoggingModule.register({ isGlobal: true }),
|
|
58
|
+
DatabaseModule.register({
|
|
59
|
+
isGlobal: true,
|
|
60
|
+
connections: [{ ...cliOptionsOf(w).database, entities: [], migrations: [] }],
|
|
61
|
+
}),
|
|
62
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declaration of the test world: selection and overrides only. The service list and the image versions come from the stack
|
|
3
|
+
* definition (`.starcistacks/dev`); the library runs the Postgres of every connection real behind toxiproxy, migrates it once per
|
|
4
|
+
* run through the cli app and boots the api apps in process.
|
|
5
|
+
*/
|
|
6
|
+
import { defineTestWorld } from "@starci/test-world"
|
|
7
|
+
{{appModuleImports}}
|
|
8
|
+
import { {{optionsImports}} } from "./test-apps.options"
|
|
9
|
+
|
|
10
|
+
export const { useTestWorld, useSandbox } = defineTestWorld({
|
|
11
|
+
stack: ".starcistacks/dev",
|
|
12
|
+
stacks: {
|
|
13
|
+
postgresql: { connections: [{{connectionDeclarations}}] },
|
|
14
|
+
},
|
|
15
|
+
apps: {
|
|
16
|
+
{{worldApps}}
|
|
17
|
+
},
|
|
18
|
+
migrate: { module: migrateCli, options: cliOptionsOf },
|
|
19
|
+
modules: { base: platformBase },
|
|
20
|
+
})
|
package/upgrade/index.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// starci app upgrade --edition full: the additive edition migration from a lite app to the full HFS tree.
|
|
1
|
+
// starci app upgrade --edition full: the additive edition migration from a lite app to the full HFS tree. On a full app,
|
|
2
|
+
// `starci app upgrade` completes it instead: it adds the test world (scaffold/test-world.mjs) the app lacks, with the world's
|
|
3
|
+
// devDependencies at canon pins, and never rewrites a file the app already has.
|
|
2
4
|
//
|
|
3
5
|
// The upgrade is deliberately assembled from the same full-edition render and scaffold templates as a new full app. It never
|
|
4
6
|
// rewrites an existing product source file. A dry run returns the exact ordered operations; apply performs those operations and
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
} from "../runtime/scripts/hfs/slots.mjs";
|
|
15
17
|
import { managedScriptNames } from "../runtime/scripts/hfs/architecture/managed-scripts.mjs";
|
|
16
18
|
import { LOCK_STEP, npmLock, scaffoldWorkSeeds } from "../scaffold/app.mjs";
|
|
19
|
+
import { WORLD_MARKER, testWorldFiles } from "../scaffold/test-world.mjs";
|
|
17
20
|
import { byCodeUnit } from "../report/order.mjs";
|
|
18
21
|
import {
|
|
19
22
|
TEMPLATES_DIR,
|
|
@@ -306,13 +309,54 @@ function additionStep(root, file, authored) {
|
|
|
306
309
|
return null;
|
|
307
310
|
}
|
|
308
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The steps that complete an existing full app: every test world file it lacks when it has no world (an existing file that is not
|
|
314
|
+
* the scaffold's refuses, nothing is overwritten) and the full test toolchain devDependencies it lacks, at canon pins.
|
|
315
|
+
*/
|
|
316
|
+
function fullCompletion(root, declaration) {
|
|
317
|
+
const steps = [];
|
|
318
|
+
if (!fs.existsSync(path.join(root, ...WORLD_MARKER.split("/")))) {
|
|
319
|
+
for (const file of testWorldFiles(declaration)) {
|
|
320
|
+
const step = additionStep(root, file, new Map());
|
|
321
|
+
if (step) steps.push({ ...step, why: "Add the full test world and its baseline integration and e2e specs." });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const current = readJson(root, "package.json");
|
|
325
|
+
const missing = FULL_DEV_DEPENDENCIES.filter(
|
|
326
|
+
(name) => current.devDependencies?.[name] === undefined && current.dependencies?.[name] === undefined,
|
|
327
|
+
);
|
|
328
|
+
if (missing.length) {
|
|
329
|
+
const pins = parseYaml(fs.readFileSync(PINS_FILE, "utf8")).pins;
|
|
330
|
+
const added = pinnedPackage(root, pins).devDependencies;
|
|
331
|
+
const devDependencies = Object.fromEntries(
|
|
332
|
+
Object.entries({ ...current.devDependencies, ...Object.fromEntries(missing.map((name) => [name, added[name]])) })
|
|
333
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
334
|
+
);
|
|
335
|
+
steps.push({
|
|
336
|
+
op: "rewrite-managed",
|
|
337
|
+
path: "package.json",
|
|
338
|
+
why: `Add the test world toolchain the full app lacks (${missing.join(", ")}) at canon pins.`,
|
|
339
|
+
content: jsonText({ ...current, devDependencies }),
|
|
340
|
+
kind: "file",
|
|
341
|
+
});
|
|
342
|
+
steps.push({ op: "rewrite-managed", path: "package-lock.json", why: `Resolve the added pinned dependencies with ${LOCK_STEP}.`, kind: "lock" });
|
|
343
|
+
}
|
|
344
|
+
return { managed: [], steps, dependenciesChanged: missing.length > 0 };
|
|
345
|
+
}
|
|
346
|
+
|
|
309
347
|
async function model({ root, to, presets, manifest }) {
|
|
310
|
-
if (to !== "full")
|
|
348
|
+
if (to !== undefined && to !== "full")
|
|
311
349
|
throw new UpgradeError(
|
|
312
350
|
"HFS_UPGRADE_TARGET_INVALID",
|
|
313
351
|
"upgrade supports only --edition full",
|
|
314
352
|
);
|
|
315
353
|
const declaration = loadHfs(root, manifest);
|
|
354
|
+
if (declaration?.edition !== "lite") return fullCompletion(root, declaration);
|
|
355
|
+
if (to === undefined)
|
|
356
|
+
throw new UpgradeError(
|
|
357
|
+
"HFS_UPGRADE_USAGE",
|
|
358
|
+
"a lite app upgrades with starci app upgrade --edition full",
|
|
359
|
+
);
|
|
316
360
|
const hadCli = declaration.sides.be.apps.some(
|
|
317
361
|
(app) => app.kind === "cli" && app.name === "cli",
|
|
318
362
|
);
|
|
@@ -449,7 +493,7 @@ async function upgradeEdition({
|
|
|
449
493
|
if (!locked.ok)
|
|
450
494
|
throw new UpgradeError(
|
|
451
495
|
"HFS_UPGRADE_LOCK_FAILED",
|
|
452
|
-
`\`${LOCK_STEP}\` could not resolve the upgraded lockfile (${locked.detail}); the
|
|
496
|
+
`\`${LOCK_STEP}\` could not resolve the upgraded lockfile (${locked.detail}); the app was restored`,
|
|
453
497
|
);
|
|
454
498
|
}
|
|
455
499
|
} catch (error) {
|
|
@@ -474,11 +518,6 @@ function upgradeArguments(argv) {
|
|
|
474
518
|
} else if (arg === "--plan") plan = true;
|
|
475
519
|
else throw new UpgradeError("HFS_UPGRADE_USAGE", `unknown argument ${arg}`);
|
|
476
520
|
}
|
|
477
|
-
if (to === undefined)
|
|
478
|
-
throw new UpgradeError(
|
|
479
|
-
"HFS_UPGRADE_USAGE",
|
|
480
|
-
"starci app upgrade needs --edition full",
|
|
481
|
-
);
|
|
482
521
|
return { root, to, plan };
|
|
483
522
|
}
|
|
484
523
|
|
|
@@ -491,7 +530,7 @@ export async function upgradeMain(
|
|
|
491
530
|
const steps = await upgradeEdition({ root, to, plan, presets });
|
|
492
531
|
for (const step of steps) stdout(`${step.op} ${step.path}: ${step.why}\n`);
|
|
493
532
|
stdout(
|
|
494
|
-
`starci app upgrade --edition
|
|
533
|
+
`starci app upgrade${to ? ` --edition ${to}` : ""}${plan ? " --plan" : ""}: ${steps.length} step${steps.length === 1 ? "" : "s"} ${plan ? "planned; no file written" : "applied"}${steps.length === 0 ? " (the full app is complete)" : ""}\n`,
|
|
495
534
|
);
|
|
496
535
|
return 0;
|
|
497
536
|
}
|