@intentius/chant 0.124.32 → 0.124.33
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/dist/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -1
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +81 -21
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +23 -1
- package/dist/cli/registry.d.ts +18 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +235 -228
- package/dist/workspace/check.schema.json +70 -2
- package/dist/workspace/checks/security.d.ts +35 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +5 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/declaration.d.ts +17 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +3 -0
- package/dist/workspace/declaration.schema.json +23 -0
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +17 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +18 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +16 -1
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +81 -21
- package/src/cli/parse-args.ts +17 -1
- package/src/cli/registry.ts +18 -4
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +70 -2
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +5 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/declaration.schema.json +23 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +22 -1
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +18 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The security groups a workspace opts into (#4168 section 8): the
|
|
3
|
+
* declaration's top-level `security`, such as `["verified-releases"]`, plus
|
|
4
|
+
* the `security` of the member a command runs in (#4168 step 7). With no
|
|
5
|
+
* `security` key none is on, and a security feature the workspace has not
|
|
6
|
+
* turned on never refuses, warns or hides anything (the rule #4156 set for
|
|
7
|
+
* runner signing).
|
|
8
|
+
*
|
|
9
|
+
* What each group turns on:
|
|
10
|
+
* - `verified-releases`: `chant release publish` refuses a release with no
|
|
11
|
+
* verified signature, as the `release` gate in `identity.gates` does, and
|
|
12
|
+
* `chant components promote --release` verifies the release first.
|
|
13
|
+
* - `supply-chain`: a publish without OIDC provenance warns, and a target
|
|
14
|
+
* with no SBOM source is refused (`publish-sbom-missing`).
|
|
15
|
+
* - `who-ran-it`: `chant ci tick` refuses to tag without a runner key.
|
|
16
|
+
* - `review-rules`: `chant components pr-apply` counts an approval only from
|
|
17
|
+
* someone who approved the pull request on the forge, as
|
|
18
|
+
* `--require-review` does, and a generated pull-request pipeline passes
|
|
19
|
+
* the flag.
|
|
20
|
+
* - `isolated-runners`: `chant ci run apply`, under `chant ci serve` too,
|
|
21
|
+
* refuses to start without a provisioner's runner-start statement for the
|
|
22
|
+
* job (#4147), as `--require-runner-start` does, and a generated
|
|
23
|
+
* pull-request pipeline passes the flag. A `--local` run is exempt.
|
|
24
|
+
*
|
|
25
|
+
* Each feature still turns on alone: `identity.gates.release`,
|
|
26
|
+
* `--require-provenance`, `--require-review`, `ci.green.signedSince`.
|
|
27
|
+
* `chant workspace check` reports what a declared group still lacks
|
|
28
|
+
* (./checks/security.ts, WSP161 to WSP165). {@link SECURITY_FEATURES} is the
|
|
29
|
+
* table the reference page prints.
|
|
30
|
+
*/
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { relative, sep } from "node:path";
|
|
33
|
+
import { memberHolding } from "./record-assets.js";
|
|
34
|
+
import { scopeSource } from "./write-scope.js";
|
|
35
|
+
export const SECURITY_GROUPS = ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"];
|
|
36
|
+
/**
|
|
37
|
+
* The groups `declaration` turns on for `member` (a member name): the
|
|
38
|
+
* top-level list plus that member's own. Empty for no declaration or no
|
|
39
|
+
* `security` key anywhere.
|
|
40
|
+
*/
|
|
41
|
+
export function securityGroupsOf(declaration, member = null) {
|
|
42
|
+
const own = member === null ? [] : (declaration?.members?.find((m) => m.name === member)?.security ?? []);
|
|
43
|
+
return new Set([...(declaration?.security ?? []), ...own]);
|
|
44
|
+
}
|
|
45
|
+
/** The member of `source`'s declaration whose directory holds `cwd`, or null. */
|
|
46
|
+
export function memberAtCwd(source, cwd) {
|
|
47
|
+
if (!source.declaration || source.rootOnDisk === null)
|
|
48
|
+
return null;
|
|
49
|
+
let rel;
|
|
50
|
+
try {
|
|
51
|
+
rel = relative(realpathSync(source.rootOnDisk), realpathSync(cwd));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
rel = relative(source.rootOnDisk, cwd);
|
|
55
|
+
}
|
|
56
|
+
if (rel.startsWith(".."))
|
|
57
|
+
return null;
|
|
58
|
+
const posix = sep === "/" ? rel : rel.split(sep).join("/");
|
|
59
|
+
return memberHolding(posix === "" ? "." : posix, source.declaration.members);
|
|
60
|
+
}
|
|
61
|
+
/** The groups `source` turns on for a command run in `cwd`: the top-level list and the list of the member holding `cwd`. */
|
|
62
|
+
export function securityGroupsIn(source, cwd) {
|
|
63
|
+
return securityGroupsOf(source.declaration, memberAtCwd(source, cwd));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The groups the workspace holding `cwd` turns on for the member holding
|
|
67
|
+
* `cwd`, read at base (the target branch tip) when there is one, else from
|
|
68
|
+
* the working tree, as the release gate is read. Empty outside a workspace.
|
|
69
|
+
*/
|
|
70
|
+
export function securityGroupsAt(cwd, at) {
|
|
71
|
+
return securityGroupsIn(scopeSource(cwd, at), cwd);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Every security feature, the group that raises it and the setting that
|
|
75
|
+
* turns it on alone. The reference page's table is printed from this list
|
|
76
|
+
* (`securityFeatureTable`), and a test compares the two.
|
|
77
|
+
*/
|
|
78
|
+
export const SECURITY_FEATURES = [
|
|
79
|
+
{ feature: "Publish refuses an unsigned release", byDefault: "off: publishes and records `signature: unsigned`", setting: "`identity.gates.release`", group: "verified-releases", codes: ["security-signers-missing", "security-publish-allows-unsigned"] },
|
|
80
|
+
{ feature: "Signed cut in the release gate's class", byDefault: "off", setting: "`identity.gates.release` with a `class`", group: "verified-releases", codes: ["security-release-gate-missing"] },
|
|
81
|
+
{ feature: "Promote verifies the release", byDefault: "off: `chant release verify` on demand", setting: "`chant release verify`", group: "verified-releases", codes: [] },
|
|
82
|
+
{ feature: "SBOM on every target", byDefault: "off: a target with no SBOM source publishes without one", setting: "`sbom` on the target", group: "supply-chain", codes: ["security-sbom-disabled", "security-sbom-unsourced"] },
|
|
83
|
+
{ feature: "OIDC provenance", byDefault: "off: a plan field", setting: "`--require-provenance`", group: "supply-chain", codes: ["security-publish-no-provenance"] },
|
|
84
|
+
{ feature: "SBOM and verify steps on components", byDefault: "off", setting: "a `generate-sbom` or `verify` step", group: "supply-chain", codes: ["security-component-no-sbom", "security-component-no-verify"] },
|
|
85
|
+
{ feature: "Signed green tags and runs", byDefault: "off: every tag counts", setting: "`ci.green.signedSince`", group: "who-ran-it", codes: ["security-signed-since-unset"] },
|
|
86
|
+
{ feature: "Trusted runner keys", byDefault: "off", setting: "`.chant/trust.json` with a runner role", group: "who-ran-it", codes: ["security-runner-untrusted"] },
|
|
87
|
+
{ feature: "Tick signs its tags", byDefault: "off: unsigned tags", setting: "`--key` or `CHANT_RUNNER_KEY`", group: "who-ran-it", codes: ["security-tick-no-key"] },
|
|
88
|
+
{ feature: "Forge review required, authors never count", byDefault: "off", setting: "`--require-review`, `requireReview: true`", group: "review-rules", codes: ["security-review-not-required"] },
|
|
89
|
+
{ feature: "Identified attribution", byDefault: "off: `any`", setting: "`identity.attribution: \"identified\"`", group: "review-rules", codes: ["security-attribution-any"] },
|
|
90
|
+
{ feature: "Signed approvals", byDefault: "off", setting: "`identity.gates.<gate>`", group: "review-rules", codes: ["security-gate-unsigned"] },
|
|
91
|
+
{ feature: "Apply jobs require a runner-start statement", byDefault: "off", setting: "`--require-runner-start`", group: "isolated-runners", codes: ["security-runner-start-no-key", "security-runner-start-no-role", "security-trust-unreadable"] },
|
|
92
|
+
{ feature: "Secrets file", byDefault: "off", setting: "`--secrets`", group: null, codes: [] },
|
|
93
|
+
{ feature: "Env allowlist and log redaction", byDefault: "on: `ci run`'s contract, a listed exception", setting: "`--pass-env`", group: null, codes: [] },
|
|
94
|
+
];
|
|
95
|
+
/** The reference page's table of {@link SECURITY_FEATURES}, as markdown. */
|
|
96
|
+
export function securityFeatureTable() {
|
|
97
|
+
const rows = SECURITY_FEATURES.map((f) => `| ${f.feature} | ${f.byDefault} | ${f.setting} | ${f.group ? `\`${f.group}\`` : "none"} | ${f.codes.map((c) => `\`${c}\``).join(", ") || "none"} |`);
|
|
98
|
+
return ["| Feature | Default | Turns it on alone | Group | Checked as |", "|---|---|---|---|---|", ...rows].join("\n");
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.33",
|
|
4
4
|
"description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"./workspace/*.schema.json": "./src/workspace/*.schema.json",
|
|
54
54
|
"./notify.schema.json": "./src/notify.schema.json",
|
|
55
|
+
"./health.schema.json": "./src/health.schema.json",
|
|
55
56
|
"./workspace/factory-rules": {
|
|
56
57
|
"development": "./src/workspace/factory-rules.ts",
|
|
57
58
|
"types": "./dist/workspace/factory-rules.d.ts",
|
|
@@ -103,6 +104,12 @@
|
|
|
103
104
|
"import": "./dist/notify-verify.js",
|
|
104
105
|
"default": "./src/notify-verify.ts"
|
|
105
106
|
},
|
|
107
|
+
"./runner-start": {
|
|
108
|
+
"development": "./src/runner-start.ts",
|
|
109
|
+
"types": "./dist/runner-start.d.ts",
|
|
110
|
+
"import": "./dist/runner-start.js",
|
|
111
|
+
"default": "./src/runner-start.ts"
|
|
112
|
+
},
|
|
106
113
|
"./notify": {
|
|
107
114
|
"development": "./src/notify.ts",
|
|
108
115
|
"types": "./dist/notify.d.ts",
|
|
@@ -18,7 +18,7 @@ A reviewer who approved this pull request approves the plan with:
|
|
|
18
18
|
chant approve pr-12 pr-apply --plan jcs1-sha256:7623a8677afb0bf98d77c602521a3abfa96f76cff25675d350a28c2e26a7bad0 --approver github:<your-login> --sign
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
On merge the plan is recorded, and a release deploys it. With apply on merge it applies if the plan is still this one; if it moved, the apply refuses and names both digests.
|
|
22
22
|
|
|
23
23
|
### Plan summary
|
|
24
24
|
|
package/src/ci-jobs.ts
CHANGED
|
@@ -417,6 +417,12 @@ export const GITLAB_SCHEDULE_SOURCE = '$CI_PIPELINE_SOURCE == "schedule"';
|
|
|
417
417
|
/** A job variable that makes the runner clone the whole history, which a job reading the base commit needs. */
|
|
418
418
|
export const GITLAB_FULL_HISTORY = { GIT_DEPTH: "0" } as const;
|
|
419
419
|
|
|
420
|
+
/** The tags a build request is pushed as (#4169), as a glob for a trigger. */
|
|
421
|
+
export const BUILD_REQUEST_TAGS = "ci/build/**";
|
|
422
|
+
|
|
423
|
+
/** The `rules:` if-expression for a build request's tag (#4169). */
|
|
424
|
+
export const GITLAB_BUILD_REQUEST_RULE = "$CI_COMMIT_TAG =~ /^ci\\/build\\//";
|
|
425
|
+
|
|
420
426
|
/** The `rules:` if-expression for a push to `branch`, a quoted name or an expression such as `$CI_DEFAULT_BRANCH`. */
|
|
421
427
|
export function gitlabPushRule(branch: string): string {
|
|
422
428
|
return `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ${branch}`;
|
|
@@ -462,11 +468,14 @@ export function gitlabRules(subject: string, trigger: OpTrigger, selector: { var
|
|
|
462
468
|
return { if: `${GITLAB_MERGE_REQUEST_SOURCE} && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "${branch}"` };
|
|
463
469
|
});
|
|
464
470
|
}
|
|
465
|
-
case "push":
|
|
466
|
-
|
|
471
|
+
case "push": {
|
|
472
|
+
const rules = pushBranches(trigger.branches).map((branch) => {
|
|
467
473
|
assertGitlabBranch(subject, branch);
|
|
468
474
|
return { if: gitlabPushRule(JSON.stringify(branch)) };
|
|
469
475
|
});
|
|
476
|
+
// #4169: a newest build also runs at each build request's tag.
|
|
477
|
+
return trigger.build === "newest" ? [...rules, { if: GITLAB_BUILD_REQUEST_RULE }] : rules;
|
|
478
|
+
}
|
|
470
479
|
}
|
|
471
480
|
}
|
|
472
481
|
|
|
@@ -148,6 +148,42 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
|
|
|
148
148
|
vi.restoreAllMocks();
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
test("--pr-loop asks for --require-review only under --require-review or the review-rules security group (#4168)", async () => {
|
|
152
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs");
|
|
153
|
+
const { tmpdir } = await import("node:os");
|
|
154
|
+
const { join } = await import("node:path");
|
|
155
|
+
const { execFileSync } = await import("node:child_process");
|
|
156
|
+
const root = mkdtempSync(join(tmpdir(), "chant-build-review-rules-"));
|
|
157
|
+
try {
|
|
158
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
159
|
+
mkdirSync(join(root, "app"));
|
|
160
|
+
const declare = (extra: Record<string, unknown>) =>
|
|
161
|
+
writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({ name: "acme", schema: 1, ...extra, members: [{ name: "app", dir: "app", kind: "chant", because: "the app" }] }));
|
|
162
|
+
generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "jobs: {}", stages: [], jobs: [] });
|
|
163
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
164
|
+
const path = join(root, "app");
|
|
165
|
+
const output = join(root, "out.yml");
|
|
166
|
+
|
|
167
|
+
declare({});
|
|
168
|
+
expect(await runBuild({ args: makeArgs({ path, output, generate: "github", prLoop: true }), plugins: [], serializers: [] })).toBe(0);
|
|
169
|
+
expect(generateComponentsPipelineMock.mock.calls[0][2].prLoop).toEqual({});
|
|
170
|
+
expect(await runBuild({ args: makeArgs({ path, output, generate: "github", prLoop: true, requireReview: true }), plugins: [], serializers: [] })).toBe(0);
|
|
171
|
+
expect(generateComponentsPipelineMock.mock.calls[1][2].prLoop).toEqual({ requireReview: true });
|
|
172
|
+
|
|
173
|
+
declare({ security: ["review-rules"] });
|
|
174
|
+
expect(await runBuild({ args: makeArgs({ path, output, generate: "github", prLoop: true }), plugins: [], serializers: [] })).toBe(0);
|
|
175
|
+
expect(generateComponentsPipelineMock.mock.calls[2][2].prLoop).toEqual({ requireReview: true });
|
|
176
|
+
|
|
177
|
+
// #4168 step 7: a member's own group counts, and isolated-runners asks for --require-runner-start.
|
|
178
|
+
writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "chant", because: "the app", security: ["isolated-runners"] }] }));
|
|
179
|
+
expect(await runBuild({ args: makeArgs({ path, output, generate: "github", prLoop: true }), plugins: [], serializers: [] })).toBe(0);
|
|
180
|
+
expect(generateComponentsPipelineMock.mock.calls[3][2].prLoop).toEqual({ requireRunnerStart: true });
|
|
181
|
+
} finally {
|
|
182
|
+
rmSync(root, { recursive: true, force: true });
|
|
183
|
+
vi.restoreAllMocks();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
151
187
|
test("--pr-loop without --report-url bakes the workspace's ci.reportUrl into the jobs, and the flag wins (#4160)", async () => {
|
|
152
188
|
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs");
|
|
153
189
|
const { tmpdir } = await import("node:os");
|
|
@@ -17,8 +17,12 @@ import type { PrLoopPipelineOptions } from "../../components/pr-pipeline";
|
|
|
17
17
|
* flags the generated jobs pass to pr-plan and pr-apply. `--pr-build` and
|
|
18
18
|
* `--pr-build-command` (#4088) add the build and lint steps before both.
|
|
19
19
|
* `reportUrl` is `--report-url`, else the workspace's `ci.reportUrl` (#4160).
|
|
20
|
+
* `requireReview` is `--require-review`, or a workspace that opts into the
|
|
21
|
+
* `review-rules` security group (#4168); off otherwise. `requireRunnerStart`
|
|
22
|
+
* is `--require-runner-start`, or the `isolated-runners` group. `--apply-on-merge`
|
|
23
|
+
* (#4168) writes the apply job on merge in place of the record job.
|
|
20
24
|
*/
|
|
21
|
-
function prLoopOption(args: CommandContext["args"], reportUrl: string | undefined): PrLoopPipelineOptions {
|
|
25
|
+
function prLoopOption(args: CommandContext["args"], reportUrl: string | undefined, requireReview = false, requireRunnerStart = false): PrLoopPipelineOptions {
|
|
22
26
|
return {
|
|
23
27
|
...(args.gate ? { gate: args.gate } : {}),
|
|
24
28
|
...(args.branch ? { branch: args.branch } : {}),
|
|
@@ -26,6 +30,9 @@ function prLoopOption(args: CommandContext["args"], reportUrl: string | undefine
|
|
|
26
30
|
...(reportUrl ? { reportUrl } : {}),
|
|
27
31
|
...(args.prBuild ? { build: true } : {}),
|
|
28
32
|
...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
|
|
33
|
+
...(args.requireReview === true || requireReview ? { requireReview: true } : {}),
|
|
34
|
+
...(args.requireRunnerStart === true || requireRunnerStart ? { requireRunnerStart: true } : {}),
|
|
35
|
+
...(args.applyOnMerge ? { applyOnMerge: true } : {}),
|
|
29
36
|
};
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -102,6 +109,14 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
|
|
|
102
109
|
return 1;
|
|
103
110
|
}
|
|
104
111
|
}
|
|
112
|
+
// #4168: the review-rules group turns on --require-review in the generated apply. Read only inside a workspace.
|
|
113
|
+
let reviewRules = false;
|
|
114
|
+
let isolatedRunners = false;
|
|
115
|
+
if (found && args.prLoop) {
|
|
116
|
+
const groups = (await import("../../workspace/security")).securityGroupsAt(resolve(args.path));
|
|
117
|
+
reviewRules = groups.has("review-rules");
|
|
118
|
+
isolatedRunners = groups.has("isolated-runners");
|
|
119
|
+
}
|
|
105
120
|
if (found) {
|
|
106
121
|
memberPipeline = await import("../../workspace/member-pipeline");
|
|
107
122
|
try {
|
|
@@ -116,7 +131,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
|
|
|
116
131
|
params: args.param,
|
|
117
132
|
paramsFile: args.paramsFile,
|
|
118
133
|
...(args.waveGate ? { waveGate: { gate: args.waveGate, ...(args.canary?.length ? { canary: args.canary } : {}), ...(args.base ? { base: args.base } : {}) } } : {}),
|
|
119
|
-
...(args.prLoop ? { prLoop: prLoopOption(args, reportUrl) } : {}),
|
|
134
|
+
...(args.prLoop ? { prLoop: prLoopOption(args, reportUrl, reviewRules, isolatedRunners) } : {}),
|
|
120
135
|
...(args.image ? { image: args.image } : {}),
|
|
121
136
|
...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
|
|
122
137
|
});
|
|
@@ -146,7 +161,7 @@ async function runGenerateComponents(ctx: CommandContext): Promise<number> {
|
|
|
146
161
|
? { gatedWaves: { gate: args.waveGate, ...(args.canary?.length ? { canary: args.canary } : {}), ...(args.base ? { base: args.base } : {}) } }
|
|
147
162
|
: {}),
|
|
148
163
|
// #3183: a plan job per pull request and an apply job per merge.
|
|
149
|
-
...(args.prLoop ? { prLoop: prLoopOption(args, reportUrl) } : {}),
|
|
164
|
+
...(args.prLoop ? { prLoop: prLoopOption(args, reportUrl, reviewRules, isolatedRunners) } : {}),
|
|
150
165
|
// #4002: the job image and the lines that install what it lacks, chant itself included.
|
|
151
166
|
...(args.image ? { image: args.image } : {}),
|
|
152
167
|
...(args.beforeScript?.length ? { beforeScript: args.beforeScript } : {}),
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { createMockPlugin } from "@intentius/chant-test-utils";
|
|
3
|
+
import type { BuildResult } from "../../build";
|
|
4
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
5
|
+
import type { ParsedArgs } from "../registry";
|
|
6
|
+
import { HEALTH_SCHEMA_ID } from "../../health";
|
|
7
|
+
import { UNOBSERVED_REASONS } from "../../observation";
|
|
8
|
+
import { contract, validSchema } from "../../workspace/__fixtures__/contract-repo";
|
|
9
|
+
import schema from "../../health.schema.json";
|
|
10
|
+
|
|
11
|
+
const { expectValid, validate } = contract(schema);
|
|
12
|
+
|
|
13
|
+
const buildMock = vi.fn();
|
|
14
|
+
const loadChantConfigMock = vi.fn();
|
|
15
|
+
vi.mock("../../build", () => ({ build: (...args: unknown[]) => buildMock(...args) }));
|
|
16
|
+
vi.mock("../../config", async () => {
|
|
17
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
18
|
+
return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const { runLifecycleHealth } = await import("./lifecycle-health");
|
|
22
|
+
|
|
23
|
+
function args(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
24
|
+
return { command: "lifecycle", path: "health", format: "", fix: false, watch: false, verbose: false, help: false, extraPositional: "prod", ...overrides } as ParsedArgs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildResult(entities: Array<[string, string, string]>): BuildResult {
|
|
28
|
+
return {
|
|
29
|
+
outputs: new Map([["aws", "{}"]]),
|
|
30
|
+
entities: new Map(entities.map(([name, lexicon, entityType]) => [name, { lexicon, entityType, kind: "resource", props: {} }])),
|
|
31
|
+
dependencies: new Map(),
|
|
32
|
+
errors: [],
|
|
33
|
+
warnings: [],
|
|
34
|
+
manifest: { lexicons: ["aws"], outputs: {}, deployOrder: ["aws"] },
|
|
35
|
+
sourceFileCount: 1,
|
|
36
|
+
} as unknown as BuildResult;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("chant lifecycle health (#4177)", () => {
|
|
40
|
+
let out: string[];
|
|
41
|
+
let err: string[];
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
out = [];
|
|
44
|
+
err = [];
|
|
45
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
|
|
46
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
|
|
47
|
+
loadChantConfigMock.mockResolvedValue({ config: { environments: ["prod"] } });
|
|
48
|
+
buildMock.mockReset();
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => vi.restoreAllMocks());
|
|
51
|
+
|
|
52
|
+
test("asks only about the types the lexicon names, and prints chant-health/v1", async () => {
|
|
53
|
+
buildMock.mockResolvedValue(buildResult([["web", "aws", "AWS::ECS::Service"], ["bucket", "aws", "AWS::S3::Bucket"]]));
|
|
54
|
+
const asked: string[][] = [];
|
|
55
|
+
const plugin: LexiconPlugin = {
|
|
56
|
+
...createMockPlugin({ name: "aws" }),
|
|
57
|
+
healthTypes: ["AWS::ECS::Service"],
|
|
58
|
+
observeHealth: async (o) => {
|
|
59
|
+
asked.push([...o.entities.keys()]);
|
|
60
|
+
return { entities: { web: { type: "AWS::ECS::Service", state: "unhealthy", detail: "0/2 running", observedAt: "t" } }, unobserved: {}, absent: [] };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const exit = await runLifecycleHealth({ args: args({ json: true }), plugins: [plugin], serializers: [plugin.serializer] });
|
|
64
|
+
expect(exit).toBe(0);
|
|
65
|
+
expect(asked).toEqual([["web"]]);
|
|
66
|
+
const json = JSON.parse(out[0]!);
|
|
67
|
+
expect(json.schema).toBe("chant-health/v1");
|
|
68
|
+
expect(typeof json.chantVersion).toBe("string");
|
|
69
|
+
expect(json.lexicons.aws.entities.web.state).toBe("unhealthy");
|
|
70
|
+
expect(json.summary).toEqual({ healthy: 0, unhealthy: 1, unknown: 0, unobserved: 0, absent: 0 });
|
|
71
|
+
expectValid(json);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a reader that throws is not read, never unhealthy", async () => {
|
|
75
|
+
buildMock.mockResolvedValue(buildResult([["web", "aws", "AWS::ECS::Service"]]));
|
|
76
|
+
const plugin: LexiconPlugin = {
|
|
77
|
+
...createMockPlugin({ name: "aws" }),
|
|
78
|
+
healthTypes: ["AWS::ECS::Service"],
|
|
79
|
+
observeHealth: async () => {
|
|
80
|
+
throw new Error("no route to host");
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
await runLifecycleHealth({ args: args(), plugins: [plugin], serializers: [plugin.serializer] });
|
|
84
|
+
expect(out.join("\n")).toContain("not read web (AWS::ECS::Service): read-failed, no route to host");
|
|
85
|
+
expect(err.join("\n")).toContain("0 healthy, 0 unhealthy, 0 unknown, 1 not observed");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("refuses with no environment, and with no lexicon that reads health", async () => {
|
|
89
|
+
const plain = createMockPlugin({ name: "aws" });
|
|
90
|
+
expect(await runLifecycleHealth({ args: args({ extraPositional: undefined }), plugins: [plain], serializers: [] })).toBe(1);
|
|
91
|
+
expect(await runLifecycleHealth({ args: args(), plugins: [plain], serializers: [] })).toBe(1);
|
|
92
|
+
expect(err.join("\n")).toContain("No configured lexicon has a health read");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the JSON of a read that failed follows the schema", async () => {
|
|
96
|
+
buildMock.mockResolvedValue(buildResult([["web", "aws", "AWS::ECS::Service"], ["gone", "aws", "AWS::ECS::Service"]]));
|
|
97
|
+
const plugin: LexiconPlugin = {
|
|
98
|
+
...createMockPlugin({ name: "aws" }),
|
|
99
|
+
healthTypes: ["AWS::ECS::Service"],
|
|
100
|
+
observeHealth: async () => ({ entities: {}, unobserved: { web: { type: "AWS::ECS::Service", reason: "no-credentials", detail: "no AWS credentials" } }, absent: ["gone"] }),
|
|
101
|
+
};
|
|
102
|
+
await runLifecycleHealth({ args: args({ json: true }), plugins: [plugin], serializers: [plugin.serializer] });
|
|
103
|
+
const json = JSON.parse(out[0]!);
|
|
104
|
+
expect(json.summary).toEqual({ healthy: 0, unhealthy: 0, unknown: 0, unobserved: 1, absent: 1 });
|
|
105
|
+
expectValid(json);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("the chant-health/v1 schema (#4177)", () => {
|
|
110
|
+
test("is a valid schema with the exported id, and names every unobserved reason", () => {
|
|
111
|
+
expect(validSchema(schema)).toBe(true);
|
|
112
|
+
expect(schema.$id).toBe(HEALTH_SCHEMA_ID);
|
|
113
|
+
expect([...schema.$defs.hole.properties.reason.enum].sort()).toEqual([...UNOBSERVED_REASONS].sort());
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a document with neither lexicons nor stacks, or a state outside the three, does not validate", () => {
|
|
117
|
+
const base = { schema: "chant-health/v1", chantVersion: "0.124.33", environment: "prod", observedAt: "t", summary: { healthy: 0, unhealthy: 0, unknown: 0, unobserved: 0, absent: 0 } };
|
|
118
|
+
expect(validate(base)).toBe(false);
|
|
119
|
+
expectValid({ ...base, stacks: { east: {} } });
|
|
120
|
+
const bad = { ...base, lexicons: { aws: { entities: { web: { type: "AWS::ECS::Service", state: "down", detail: "", observedAt: "t" } }, unobserved: {}, absent: [] } } };
|
|
121
|
+
expect(validate(bad)).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant lifecycle health <env> [lexicon] [--json]` (#4177): whether each
|
|
3
|
+
* declared resource that has a health read is up, separate from drift.
|
|
4
|
+
*
|
|
5
|
+
* It builds the project as `lifecycle diff` does, so the entity names are the
|
|
6
|
+
* graph's node ids, then asks each lexicon with a health read about the
|
|
7
|
+
* entities whose types it names. An environment's declared endpoint is applied
|
|
8
|
+
* for the read, as on the other live reads (#1166).
|
|
9
|
+
*
|
|
10
|
+
* The JSON is the {@link HEALTH_SCHEMA} shape. A single-stack project prints
|
|
11
|
+
* `lexicons` at the top level; a multi-stack one nests them under `stacks`,
|
|
12
|
+
* the same split `lifecycle diff --live --json` makes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { build } from "../../build";
|
|
17
|
+
import { loadChantConfig } from "../../config";
|
|
18
|
+
import { isObservableDeclarable } from "../../declarable";
|
|
19
|
+
import { HEALTH_SCHEMA, healthApplicable, observeHealthFor, summarizeHealth, type HealthLexicon, type HealthReadResult } from "../../health";
|
|
20
|
+
import { applyLiveEndpoint } from "../../live-endpoint";
|
|
21
|
+
import type { SerializerResult } from "../../serializer";
|
|
22
|
+
import { CHANT_VERSION } from "../version";
|
|
23
|
+
import { commandBuildParams } from "../build-params-cli";
|
|
24
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
|
|
25
|
+
import { formatBold, formatError, formatWarning } from "../format";
|
|
26
|
+
import type { CommandContext } from "../registry";
|
|
27
|
+
import { resolveStackTargets } from "./lifecycle";
|
|
28
|
+
|
|
29
|
+
type ByLexicon = Record<string, HealthReadResult>;
|
|
30
|
+
|
|
31
|
+
export async function runLifecycleHealth(ctx: CommandContext): Promise<number> {
|
|
32
|
+
const { args, plugins } = ctx;
|
|
33
|
+
const environment = args.extraPositional;
|
|
34
|
+
const lexiconFilter = args.extraPositional2;
|
|
35
|
+
if (!environment) {
|
|
36
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle health <environment> [lexicon]" }));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readers = plugins.filter(
|
|
41
|
+
(p) => p.observeHealth && (p.healthTypes?.length ?? 0) > 0 && (!lexiconFilter || p.name === lexiconFilter),
|
|
42
|
+
);
|
|
43
|
+
if (readers.length === 0) {
|
|
44
|
+
console.error(formatWarning({
|
|
45
|
+
message: lexiconFilter
|
|
46
|
+
? `${lexiconFilter}: no health read in this lexicon`
|
|
47
|
+
: "No configured lexicon has a health read (aws, azure and gcp do)",
|
|
48
|
+
}));
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projectRoot = resolve(".");
|
|
53
|
+
const { config } = await loadChantConfig(projectRoot);
|
|
54
|
+
const declaredParams = await commandBuildParams(config.buildParams, args);
|
|
55
|
+
if (!declaredParams) return 1;
|
|
56
|
+
const targets = resolveStackTargets(args, config);
|
|
57
|
+
const buildOptions = resolveProjectBuildOptions({
|
|
58
|
+
config,
|
|
59
|
+
configDir: projectRoot,
|
|
60
|
+
plugins,
|
|
61
|
+
modes: resolveBuildModes(config, { fold: args.fold, sandbox: args.sandbox }),
|
|
62
|
+
buildParams: declaredParams,
|
|
63
|
+
});
|
|
64
|
+
const serializers = readers.map((p) => p.serializer);
|
|
65
|
+
|
|
66
|
+
const endpoint = applyLiveEndpoint(config.environments, environment, readers);
|
|
67
|
+
if (endpoint.notice) console.error(formatWarning({ message: endpoint.notice }));
|
|
68
|
+
|
|
69
|
+
const perStack: Record<string, ByLexicon> = {};
|
|
70
|
+
let single: ByLexicon | undefined;
|
|
71
|
+
let anyBuildError = false;
|
|
72
|
+
const observedAt = new Date().toISOString();
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
for (const target of targets) {
|
|
76
|
+
const buildResult = await build(target.root, serializers, undefined, buildOptions);
|
|
77
|
+
if (buildResult.errors.length > 0) {
|
|
78
|
+
console.error(formatError({
|
|
79
|
+
message: `Build failed for ${target.stack ? `stack "${target.stack}"` : "project"}; fix errors before reading health`,
|
|
80
|
+
}));
|
|
81
|
+
anyBuildError = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const byLexicon: ByLexicon = {};
|
|
85
|
+
for (const plugin of readers) {
|
|
86
|
+
const declared = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
87
|
+
for (const [name, entity] of buildResult.entities) {
|
|
88
|
+
if (entity.lexicon === plugin.name && isObservableDeclarable(entity)) {
|
|
89
|
+
declared.set(name, {
|
|
90
|
+
entityType: entity.entityType,
|
|
91
|
+
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const entities = healthApplicable(plugin as HealthLexicon, declared);
|
|
96
|
+
if (entities.size === 0) continue;
|
|
97
|
+
const raw = buildResult.outputs.get(plugin.name);
|
|
98
|
+
const buildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
|
|
99
|
+
byLexicon[plugin.name] = await observeHealthFor(plugin as HealthLexicon, {
|
|
100
|
+
environment,
|
|
101
|
+
buildOutput,
|
|
102
|
+
entities,
|
|
103
|
+
...(target.stack ? { stack: target.stack } : {}),
|
|
104
|
+
...(target.region ? { region: target.region } : {}),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (target.stack) perStack[target.stack] = byLexicon;
|
|
108
|
+
else single = byLexicon;
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
endpoint.restore();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const all = single ? [single] : Object.values(perStack);
|
|
115
|
+
const summary = summarizeHealth(all.flatMap((b) => Object.values(b)));
|
|
116
|
+
|
|
117
|
+
if (args.json) {
|
|
118
|
+
console.log(JSON.stringify({
|
|
119
|
+
schema: HEALTH_SCHEMA,
|
|
120
|
+
chantVersion: CHANT_VERSION,
|
|
121
|
+
environment,
|
|
122
|
+
observedAt,
|
|
123
|
+
...(single ? { lexicons: single } : { stacks: perStack }),
|
|
124
|
+
summary,
|
|
125
|
+
}));
|
|
126
|
+
} else {
|
|
127
|
+
if (single) renderHealth(single);
|
|
128
|
+
else for (const [stack, byLexicon] of Object.entries(perStack)) {
|
|
129
|
+
console.log(`\n${formatBold(`■ stack ${stack}`)}`);
|
|
130
|
+
renderHealth(byLexicon);
|
|
131
|
+
}
|
|
132
|
+
const total = summary.healthy + summary.unhealthy + summary.unknown + summary.unobserved + summary.absent;
|
|
133
|
+
if (total === 0) {
|
|
134
|
+
console.error(formatWarning({ message: "No declared resource has a health read" }));
|
|
135
|
+
} else {
|
|
136
|
+
console.error(
|
|
137
|
+
`${summary.healthy} healthy, ${summary.unhealthy} unhealthy, ${summary.unknown} unknown` +
|
|
138
|
+
(summary.unobserved > 0 ? `, ${summary.unobserved} not observed` : "") +
|
|
139
|
+
(summary.absent > 0 ? `, ${summary.absent} not deployed` : ""),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return anyBuildError ? 1 : 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderHealth(byLexicon: ByLexicon): void {
|
|
147
|
+
for (const [lexicon, result] of Object.entries(byLexicon)) {
|
|
148
|
+
console.log(formatBold(lexicon));
|
|
149
|
+
for (const [name, entry] of Object.entries(result.entities)) {
|
|
150
|
+
console.log(` ${entry.state.padEnd(9)} ${name} (${entry.type}): ${entry.detail}`);
|
|
151
|
+
}
|
|
152
|
+
for (const [name, hole] of Object.entries(result.unobserved)) {
|
|
153
|
+
console.log(` ${"not read".padEnd(9)} ${name}${hole.type ? ` (${hole.type})` : ""}: ${hole.reason}${hole.detail ? `, ${hole.detail}` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
for (const name of result.absent) console.log(` ${"absent".padEnd(9)} ${name}: not deployed`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -90,7 +90,7 @@ interface StackTarget {
|
|
|
90
90
|
* observed as the stack named after the environment (unchanged behavior). An
|
|
91
91
|
* explicit `--src` always wins and forces a single scoped target.
|
|
92
92
|
*/
|
|
93
|
-
function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
|
|
93
|
+
export function resolveStackTargets(args: ParsedArgs, config: ChantConfig): StackTarget[] {
|
|
94
94
|
if (args.src) return [{ root: resolve(args.src) }];
|
|
95
95
|
if (config.stacks && config.stacks.length > 0) {
|
|
96
96
|
return config.stacks.map((s) => ({ stack: s.name, root: resolve(s.src), region: s.region }));
|