@intentius/chant 0.124.39 → 0.124.41
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/__fixtures__/oidc-emulator.d.ts +49 -0
- package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
- package/dist/__fixtures__/oidc-emulator.js +89 -0
- package/dist/ci-handover.d.ts +117 -0
- package/dist/ci-handover.d.ts.map +1 -0
- package/dist/ci-handover.js +217 -0
- package/dist/ci-identity.d.ts +190 -0
- package/dist/ci-identity.d.ts.map +1 -0
- package/dist/ci-identity.js +219 -0
- package/dist/cli/component-endpoint.d.ts +20 -0
- package/dist/cli/component-endpoint.d.ts.map +1 -0
- package/dist/cli/component-endpoint.js +36 -0
- package/dist/cli/handlers/approve.d.ts +2 -1
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -6
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +15 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +26 -4
- package/dist/cli/handlers/gate-resume.d.ts +3 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.js +15 -2
- package/dist/cli/handlers/lock.d.ts +8 -0
- package/dist/cli/handlers/lock.d.ts.map +1 -0
- package/dist/cli/handlers/lock.js +197 -0
- package/dist/cli/handlers/pr-env.d.ts +34 -0
- package/dist/cli/handlers/pr-env.d.ts.map +1 -0
- package/dist/cli/handlers/pr-env.js +436 -0
- package/dist/cli/handlers/pr.d.ts +74 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +514 -58
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -2
- package/dist/cli/handlers/query.d.ts +6 -0
- package/dist/cli/handlers/query.d.ts.map +1 -0
- package/dist/cli/handlers/query.js +80 -0
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +17 -6
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +41 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +31 -2
- package/dist/cli/registry.d.ts +12 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +35 -3
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +29 -2
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +2 -0
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/deploy-units.js +5 -0
- package/dist/components/fan-out-run.d.ts +28 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +27 -3
- package/dist/components/pr-environment.d.ts +298 -0
- package/dist/components/pr-environment.d.ts.map +1 -0
- package/dist/components/pr-environment.js +406 -0
- package/dist/components/pr-hooks.d.ts +66 -0
- package/dist/components/pr-hooks.d.ts.map +1 -0
- package/dist/components/pr-hooks.js +119 -0
- package/dist/components/pr-pipeline.d.ts +72 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +96 -5
- package/dist/components/pr-policy.d.ts +109 -0
- package/dist/components/pr-policy.d.ts.map +1 -0
- package/dist/components/pr-policy.js +150 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +5 -3
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +39 -16
- package/dist/gate-decision.d.ts +15 -2
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +17 -2
- package/dist/gate-policy.d.ts +140 -0
- package/dist/gate-policy.d.ts.map +1 -0
- package/dist/gate-policy.js +242 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +6 -1
- package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
- package/dist/lifecycle/gate-checkpoint.js +1 -1
- package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
- package/dist/lifecycle/gate-seal.d.ts.map +1 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +4 -3
- package/dist/lifecycle/publish-npm.d.ts +2 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +31 -8
- package/dist/lifecycle/query.d.ts +68 -0
- package/dist/lifecycle/query.d.ts.map +1 -0
- package/dist/lifecycle/query.js +246 -0
- package/dist/lifecycle/release-ledger.d.ts +31 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +19 -1
- package/dist/lifecycle/run-ledger.d.ts +13 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +16 -0
- package/dist/lifecycle/status.d.ts +5 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/status.js +10 -5
- package/dist/lifecycle/target-lock.d.ts +97 -0
- package/dist/lifecycle/target-lock.d.ts.map +1 -0
- package/dist/lifecycle/target-lock.js +286 -0
- package/dist/live-endpoint.d.ts +18 -0
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +27 -0
- package/dist/op/activities/activity-contracts.d.ts +30 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +26 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +4 -0
- package/dist/op/activities/review-agent.d.ts +137 -0
- package/dist/op/activities/review-agent.d.ts.map +1 -0
- package/dist/op/activities/review-agent.js +266 -0
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +11 -0
- package/dist/op/cron.d.ts +8 -0
- package/dist/op/cron.d.ts.map +1 -1
- package/dist/op/cron.js +51 -0
- package/dist/op/gate-approval.d.ts +20 -13
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +13 -18
- package/dist/op/gate-quorum.d.ts +131 -0
- package/dist/op/gate-quorum.d.ts.map +1 -0
- package/dist/op/gate-quorum.js +183 -0
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-resume.js +5 -4
- package/dist/op/gate-review.js +1 -1
- package/dist/op/gate.d.ts +32 -4
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +40 -24
- package/dist/op/index.d.ts +6 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +4 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/op-waves-run.d.ts +62 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +190 -11
- package/dist/op/op-waves.d.ts +55 -3
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +63 -8
- package/dist/op/schedule-overdue.d.ts +57 -0
- package/dist/op/schedule-overdue.d.ts.map +1 -0
- package/dist/op/schedule-overdue.js +138 -0
- package/dist/override-gate.d.ts +2 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/override-gate.js +1 -1
- package/dist/pr-forge.d.ts +36 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +82 -0
- package/dist/pr-loop.d.ts +90 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +77 -3
- package/dist/pr-post.d.ts +72 -0
- package/dist/pr-post.d.ts.map +1 -0
- package/dist/pr-post.js +134 -0
- package/dist/wave-blocked.d.ts +79 -0
- package/dist/wave-blocked.d.ts.map +1 -0
- package/dist/wave-blocked.js +245 -0
- package/dist/wave-engine.d.ts +6 -0
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +2 -0
- package/dist/wave-runtime.mjs +239 -239
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
- package/dist/workspace/ci-branches.d.ts +51 -0
- package/dist/workspace/ci-branches.d.ts.map +1 -0
- package/dist/workspace/ci-branches.js +69 -0
- package/dist/workspace/ci-cloud-identity.d.ts +11 -0
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
- package/dist/workspace/ci-cloud-identity.js +24 -0
- package/dist/workspace/ci-locks.d.ts +12 -0
- package/dist/workspace/ci-locks.d.ts.map +1 -0
- package/dist/workspace/ci-locks.js +6 -0
- package/dist/workspace/ci-run.d.ts +24 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +85 -6
- package/dist/workspace/declaration.d.ts +63 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +147 -0
- package/dist/workspace/identity-gate-rule.d.ts +1 -1
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +1 -1
- package/dist/workspace/identity.d.ts +1 -1
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +8 -5
- package/dist/workspace/member-pipeline.d.ts +6 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +23 -0
- package/dist/workspace/pr-report.schema.json +73 -2
- package/dist/workspace/security.d.ts.map +1 -1
- package/dist/workspace/security.js +4 -0
- package/dist/workspace/status-gates.d.ts +3 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +8 -7
- package/dist/workspace/status.d.ts +3 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +7 -3
- package/dist/workspace/status.schema.json +1 -0
- package/dist/workspace/trust/seal.d.ts +2 -2
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/oidc-emulator.ts +118 -0
- package/src/ci-handover.test.ts +209 -0
- package/src/ci-handover.ts +237 -0
- package/src/ci-identity.test.ts +201 -0
- package/src/ci-identity.ts +310 -0
- package/src/cli/component-endpoint.ts +43 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/fan-out.test.ts +10 -0
- package/src/cli/handlers/fan-out.ts +29 -4
- package/src/cli/handlers/gate-resume.ts +16 -2
- package/src/cli/handlers/lock.test.ts +98 -0
- package/src/cli/handlers/lock.ts +202 -0
- package/src/cli/handlers/operator.test.ts +3 -1
- package/src/cli/handlers/pr-endpoint.test.ts +169 -0
- package/src/cli/handlers/pr-env.ts +462 -0
- package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
- package/src/cli/handlers/pr.test.ts +184 -0
- package/src/cli/handlers/pr.ts +508 -64
- package/src/cli/handlers/promote.ts +25 -2
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/handlers/run.test.ts +2 -1
- package/src/cli/handlers/run.ts +16 -5
- package/src/cli/main.ts +41 -2
- package/src/cli/parse-args.ts +23 -2
- package/src/cli/registry.ts +12 -2
- package/src/components/auto-release-infra.test.ts +131 -0
- package/src/components/auto-release.test.ts +40 -1
- package/src/components/auto-release.ts +50 -4
- package/src/components/cli-support.ts +4 -0
- package/src/components/deploy-units.ts +5 -0
- package/src/components/fan-out-run.ts +39 -2
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/pr-environment.test.ts +249 -0
- package/src/components/pr-environment.ts +538 -0
- package/src/components/pr-hooks.test.ts +118 -0
- package/src/components/pr-hooks.ts +157 -0
- package/src/components/pr-pipeline.ts +130 -5
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +5 -2
- package/src/components/promote.test.ts +39 -0
- package/src/components/promote.ts +38 -14
- package/src/gate-decision-bundle.test.ts +4 -3
- package/src/gate-decision.ts +39 -2
- package/src/gate-policy-bundle.test.ts +28 -0
- package/src/gate-policy.test.ts +162 -0
- package/src/gate-policy.ts +298 -0
- package/src/lifecycle/__fixtures__/publish-fakes.ts +6 -1
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/publish-declaration.ts +4 -3
- package/src/lifecycle/publish-npm.ts +33 -8
- package/src/lifecycle/publish-targets.test.ts +36 -3
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/release-ledger.ts +42 -1
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/status.test.ts +19 -0
- package/src/lifecycle/status.ts +14 -6
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/live-endpoint.ts +31 -0
- package/src/op/activities/activity-contracts.ts +31 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activities/review-agent.test.ts +240 -0
- package/src/op/activities/review-agent.ts +355 -0
- package/src/op/builders.ts +13 -0
- package/src/op/cron.ts +44 -0
- package/src/op/gate-alias.test.ts +2 -2
- package/src/op/gate-approval.test.ts +30 -9
- package/src/op/gate-approval.ts +23 -26
- package/src/op/gate-quorum.test.ts +139 -0
- package/src/op/gate-quorum.ts +248 -0
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +1 -1
- package/src/op/gate.ts +64 -23
- package/src/op/index.ts +13 -1
- package/src/op/local-executor.ts +9 -2
- package/src/op/op-waves-hook-targets.test.ts +126 -0
- package/src/op/op-waves-policy-hold.test.ts +66 -0
- package/src/op/op-waves-quorum.test.ts +101 -0
- package/src/op/op-waves-run.ts +233 -11
- package/src/op/op-waves.ts +83 -10
- package/src/op/schedule-overdue.test.ts +220 -0
- package/src/op/schedule-overdue.ts +161 -0
- package/src/override-gate-bundle.test.ts +1 -1
- package/src/override-gate.ts +2 -2
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +33 -0
- package/src/pr-forge.ts +114 -0
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +146 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/wave-blocked-bundle.test.ts +25 -0
- package/src/wave-blocked.test.ts +146 -0
- package/src/wave-blocked.ts +271 -0
- package/src/wave-engine.ts +8 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
- package/src/workspace/ci-branches.test.ts +62 -0
- package/src/workspace/ci-branches.ts +79 -0
- package/src/workspace/ci-cloud-identity.test.ts +53 -0
- package/src/workspace/ci-cloud-identity.ts +23 -0
- package/src/workspace/ci-locks.ts +16 -0
- package/src/workspace/ci-run-index.test.ts +2 -1
- package/src/workspace/ci-run.test.ts +133 -8
- package/src/workspace/ci-run.ts +92 -7
- package/src/workspace/declaration.schema.json +147 -0
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +134 -2
- package/src/workspace/identity-gate-rule.ts +1 -1
- package/src/workspace/identity.ts +1 -1
- package/src/workspace/lineage-upgrade.ts +11 -8
- package/src/workspace/member-pipeline.test.ts +29 -0
- package/src/workspace/member-pipeline.ts +30 -0
- package/src/workspace/pr-report.schema.json +73 -2
- package/src/workspace/security.ts +4 -0
- package/src/workspace/status-gates.ts +9 -6
- package/src/workspace/status.schema.json +1 -0
- package/src/workspace/status.ts +11 -4
- package/src/workspace/trust/gate-seal.test.ts +1 -1
- package/src/workspace/trust/seal.ts +2 -2
- package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
- /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
assertGatePolicySettings,
|
|
6
|
+
checkGatePolicy,
|
|
7
|
+
conftestFindings,
|
|
8
|
+
exportGatePolicy,
|
|
9
|
+
gatePolicyArgv,
|
|
10
|
+
opaFindings,
|
|
11
|
+
type GatePolicyExec,
|
|
12
|
+
type GatePolicyTree,
|
|
13
|
+
} from "./gate-policy";
|
|
14
|
+
|
|
15
|
+
/** conftest's JSON for one input: a deny with its query, an older deny without one, and a warning. */
|
|
16
|
+
const CONFTEST_DENY = JSON.stringify([
|
|
17
|
+
{
|
|
18
|
+
filename: "input.json",
|
|
19
|
+
namespace: "main",
|
|
20
|
+
successes: 2,
|
|
21
|
+
failures: [
|
|
22
|
+
{ msg: "bucket logs is public", metadata: { query: "data.main.deny_public_bucket" } },
|
|
23
|
+
{ msg: { msg: "no owner tag" } },
|
|
24
|
+
],
|
|
25
|
+
warnings: [{ msg: "the plan deletes 3 resources" }],
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const opa = (value: unknown): string => JSON.stringify({ result: [{ expressions: [{ value, text: "data.main", location: { row: 1, col: 1 } }] }] });
|
|
30
|
+
|
|
31
|
+
describe("reading the engine's answer (#4294)", () => {
|
|
32
|
+
test("conftest: failures deny by their query, warnings are advice", () => {
|
|
33
|
+
expect(conftestFindings(CONFTEST_DENY)).toEqual({
|
|
34
|
+
denials: [
|
|
35
|
+
{ rule: "main.deny_public_bucket", message: "bucket logs is public" },
|
|
36
|
+
{ rule: "main.deny", message: "no owner tag" },
|
|
37
|
+
],
|
|
38
|
+
holds: [],
|
|
39
|
+
warnings: ["the plan deletes 3 resources"],
|
|
40
|
+
});
|
|
41
|
+
expect(conftestFindings(JSON.stringify([{ filename: "input.json", namespace: "main", successes: 1 }]))).toEqual({ denials: [], holds: [], warnings: [] });
|
|
42
|
+
expect(conftestFindings("not json")).toBeUndefined();
|
|
43
|
+
expect(conftestFindings(JSON.stringify({ result: [] }))).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("opa: deny, violation, hold and warn rules by name; a child package one level down; allow_unattended is no rule", () => {
|
|
47
|
+
const found = opaFindings(
|
|
48
|
+
opa({
|
|
49
|
+
deny: ["public bucket"],
|
|
50
|
+
violation_size: "too many deletes",
|
|
51
|
+
hold_iam: ["an IAM role changes"],
|
|
52
|
+
hold: true,
|
|
53
|
+
warn: ["consider tags"],
|
|
54
|
+
allow_unattended: true,
|
|
55
|
+
allow_unattended_docs: ["docs only"],
|
|
56
|
+
other: 3,
|
|
57
|
+
aws: { deny_region: ["us-west-1 is not allowed"] },
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(found).toEqual({
|
|
61
|
+
denials: [
|
|
62
|
+
{ rule: "main.aws.deny_region", message: "us-west-1 is not allowed" },
|
|
63
|
+
{ rule: "main.deny", message: "public bucket" },
|
|
64
|
+
{ rule: "main.violation_size", message: "too many deletes" },
|
|
65
|
+
],
|
|
66
|
+
holds: [
|
|
67
|
+
{ rule: "main.hold", message: "hold" },
|
|
68
|
+
{ rule: "main.hold_iam", message: "an IAM role changes" },
|
|
69
|
+
],
|
|
70
|
+
warnings: ["consider tags"],
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("opa: an undefined query matches nothing, an array is a deny set, errors and other shapes are no answer", () => {
|
|
75
|
+
expect(opaFindings(JSON.stringify({}))).toEqual({ denials: [], holds: [], warnings: [] });
|
|
76
|
+
expect(opaFindings(opa(["a", { msg: "b" }]), "policy.deny")).toEqual({ denials: [{ rule: "policy.deny", message: "a" }, { rule: "policy.deny", message: "b" }], holds: [], warnings: [] });
|
|
77
|
+
expect(opaFindings(JSON.stringify({ errors: [{ message: "rego_parse_error" }] }))).toBeUndefined();
|
|
78
|
+
expect(opaFindings(opa(7))).toBeUndefined();
|
|
79
|
+
expect(opaFindings("{")).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("running the engine (#4294)", () => {
|
|
84
|
+
const fake = (out: { status: number | null; stdout?: string; stderr?: string }, seen: string[][] = []): GatePolicyExec => (argv) => {
|
|
85
|
+
seen.push(argv);
|
|
86
|
+
return { status: out.status, stdout: out.stdout ?? "", stderr: out.stderr ?? "" };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
test("the engines' own argv, and a command's placeholders filled", () => {
|
|
90
|
+
expect(gatePolicyArgv({}, { policy: "/p", input: "/i.json" })).toEqual(["conftest", "test", "--no-color", "--output", "json", "--policy", "/p", "--all-namespaces", "/i.json"]);
|
|
91
|
+
expect(gatePolicyArgv({ engine: "conftest", namespace: "infra" }, { policy: "/p", input: "/i.json" })).toContain("--namespace");
|
|
92
|
+
expect(gatePolicyArgv({ engine: "opa" }, { policy: "/p", input: "/i.json" })).toEqual(["opa", "eval", "--format", "json", "--data", "/p", "--input", "/i.json", "data.main"]);
|
|
93
|
+
expect(gatePolicyArgv({ engine: "opa", command: ["./bin/check", "--rules={policy}", "{input}"] }, { policy: "/p", input: "/i.json" })).toEqual(["./bin/check", "--rules=/p", "/i.json"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the input is written to the file the engine reads, and a denial is a verdict with its sorted rules", () => {
|
|
97
|
+
let input: unknown;
|
|
98
|
+
const exec: GatePolicyExec = (argv) => {
|
|
99
|
+
input = JSON.parse(readFileSync(argv[argv.length - 1]!, "utf-8"));
|
|
100
|
+
return { status: 1, stdout: CONFTEST_DENY, stderr: "" };
|
|
101
|
+
};
|
|
102
|
+
const verdict = checkGatePolicy({ settings: {}, input: { changeSet: { entries: [] } }, policyDir: "/p", exec });
|
|
103
|
+
expect(input).toEqual({ changeSet: { entries: [] } });
|
|
104
|
+
expect(verdict).toMatchObject({ result: "denied", engine: "conftest", rules: ["main.deny", "main.deny_public_bucket"], warnings: ["the plan deletes 3 resources"] });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a hold with no denial is held; nothing is passed", () => {
|
|
108
|
+
expect(checkGatePolicy({ settings: { engine: "opa" }, input: {}, policyDir: "/p", exec: fake({ status: 0, stdout: opa({ hold_iam: ["iam"] }) }) })).toMatchObject({ result: "held", holds: [{ rule: "main.hold_iam" }] });
|
|
109
|
+
expect(checkGatePolicy({ settings: { engine: "opa" }, input: {}, policyDir: "/p", exec: fake({ status: 0, stdout: opa({ deny: [], warn: [] }) }) })).toMatchObject({ result: "passed", denials: [], holds: [] });
|
|
110
|
+
expect(checkGatePolicy({ settings: { engine: "opa" }, input: {}, policyDir: "/p", exec: fake({ status: 0, stdout: opa({ deny: ["x"], hold: true }) }) }).result).toBe("denied");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an engine that does not start, prints no result or fails without a denial is an error", () => {
|
|
114
|
+
expect(checkGatePolicy({ settings: {}, input: {}, policyDir: "/p", exec: fake({ status: null, stderr: "spawn conftest ENOENT" }) })).toMatchObject({ result: "error", error: expect.stringContaining("did not run") });
|
|
115
|
+
expect(checkGatePolicy({ settings: {}, input: {}, policyDir: "/p", exec: fake({ status: 2, stdout: "Error: rego_parse_error" }) })).toMatchObject({ result: "error", error: expect.stringContaining("gave no conftest result") });
|
|
116
|
+
expect(checkGatePolicy({ settings: {}, input: {}, policyDir: "/p", exec: fake({ status: 2, stdout: "[]", stderr: "could not load policy" }) })).toMatchObject({ result: "error", error: expect.stringContaining("exited 2") });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("the policy at base (#4294)", () => {
|
|
121
|
+
const files: Record<string, string> = { "policy/main.rego": "package main\n", "policy/lib/util.rego": "package lib\n", "src/app.ts": "" };
|
|
122
|
+
const tree: GatePolicyTree = {
|
|
123
|
+
stat: (p) => (files[p] !== undefined ? "file" : Object.keys(files).some((f) => f.startsWith(`${p}/`)) ? "dir" : undefined),
|
|
124
|
+
list: (p) => {
|
|
125
|
+
const names = new Map<string, "file" | "dir">();
|
|
126
|
+
for (const f of Object.keys(files).filter((f) => f.startsWith(`${p}/`))) {
|
|
127
|
+
const rest = f.slice(p.length + 1).split("/");
|
|
128
|
+
names.set(rest[0]!, rest.length > 1 ? "dir" : "file");
|
|
129
|
+
}
|
|
130
|
+
return names.size ? [...names].map(([name, type]) => ({ name, type })) : undefined;
|
|
131
|
+
},
|
|
132
|
+
read: (p) => files[p]!,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
test("the directory is copied out of the tree, and removed again", () => {
|
|
136
|
+
const out = exportGatePolicy(tree, "./policy/");
|
|
137
|
+
if ("error" in out) throw new Error(out.error);
|
|
138
|
+
expect(readFileSync(join(out.dir, "main.rego"), "utf-8")).toBe("package main\n");
|
|
139
|
+
expect(readFileSync(join(out.dir, "lib", "util.rego"), "utf-8")).toBe("package lib\n");
|
|
140
|
+
expect(existsSync(join(out.dir, "app.ts"))).toBe(false);
|
|
141
|
+
out.cleanup();
|
|
142
|
+
expect(existsSync(out.dir)).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a directory the base does not have is an error", () => {
|
|
146
|
+
expect(exportGatePolicy(tree, "rules")).toEqual({ error: "the policy directory rules does not exist at base" });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("ci.policy settings (#4294)", () => {
|
|
151
|
+
test("the four settings are read, and an unattended outcome is refused", () => {
|
|
152
|
+
expect(() => assertGatePolicySettings("ci.policy", { engine: "opa", path: "policy", namespace: "main", command: ["opa", "eval", "{input}"] })).not.toThrow();
|
|
153
|
+
expect(() => assertGatePolicySettings("ci.policy", {})).not.toThrow();
|
|
154
|
+
expect(() => assertGatePolicySettings("ci.policy", { engine: "sentinel" })).toThrow(/conftest or opa/);
|
|
155
|
+
expect(() => assertGatePolicySettings("ci.policy", { path: "../elsewhere" })).toThrow(/inside the workspace/);
|
|
156
|
+
expect(() => assertGatePolicySettings("ci.policy", { command: ["check"] })).toThrow(/\{input\}/);
|
|
157
|
+
expect(() => assertGatePolicySettings("ci.policy", { outcomes: ["deny", "allow_unattended"] })).toThrow(/no unattended outcome/);
|
|
158
|
+
expect(() => assertGatePolicySettings("ci.policy", { allow_unattended: true })).toThrow(/no unattended outcome/);
|
|
159
|
+
expect(() => assertGatePolicySettings("ci.policy", { rules: [] })).toThrow(/not a setting/);
|
|
160
|
+
expect(() => assertGatePolicySettings("ci.policy", [])).toThrow(/must be/);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate-time policy (#4294): a policy engine run over a plan's change set,
|
|
3
|
+
* with the policy read at base, whose rules deny a plan or hold it for a
|
|
4
|
+
* person.
|
|
5
|
+
*
|
|
6
|
+
* The engine is any command that prints conftest's JSON result
|
|
7
|
+
* (`conftest test --output json`) or OPA's (`opa eval --format json`). The
|
|
8
|
+
* rules are counted as conftest counts them:
|
|
9
|
+
*
|
|
10
|
+
* - `deny`, `violation`, `deny_<name>` and `violation_<name>` deny the plan.
|
|
11
|
+
* A denied plan applies only under an override that counts
|
|
12
|
+
* (`./override-gate.ts`), on top of the gate's own approval.
|
|
13
|
+
* - `hold` and `hold_<name>` hold the plan: it waits for a person's approval
|
|
14
|
+
* of its digest, even where the gate would let it through without one.
|
|
15
|
+
* conftest runs only deny and warn rules, so a hold needs the opa shape.
|
|
16
|
+
* - `warn` and `warn_<name>` are advice. They fail nothing.
|
|
17
|
+
*
|
|
18
|
+
* There is no outcome that lets a plan apply with nobody approving it: a
|
|
19
|
+
* non-empty change set always waits for a person (ws-104, ws-080). A rule
|
|
20
|
+
* named `allow_unattended` is read as no rule at all.
|
|
21
|
+
*
|
|
22
|
+
* A policy that cannot run, or gives no answer this module can read, is an
|
|
23
|
+
* error, and the caller stops the apply: no path waives a policy.
|
|
24
|
+
*
|
|
25
|
+
* Like `./override-gate.ts` this module reads what it is handed and imports
|
|
26
|
+
* only node built-ins, so a product can bundle it into one file
|
|
27
|
+
* (`gate-policy-bundle.test.ts`). The pull-request jobs' use of it is in
|
|
28
|
+
* `./components/pr-policy.ts`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** The result shapes chant reads: conftest's and OPA's. */
|
|
37
|
+
export const GATE_POLICY_ENGINES = ["conftest", "opa"] as const;
|
|
38
|
+
export type GatePolicyEngine = (typeof GATE_POLICY_ENGINES)[number];
|
|
39
|
+
|
|
40
|
+
/** Where the policy is read when the settings name no `path`. */
|
|
41
|
+
export const DEFAULT_POLICY_PATH = "policy";
|
|
42
|
+
|
|
43
|
+
/** The settings of one policy step, read at base. */
|
|
44
|
+
export interface GatePolicySettings {
|
|
45
|
+
/** The result shape, and the default command. Default `conftest`. */
|
|
46
|
+
engine?: GatePolicyEngine;
|
|
47
|
+
/** The policy directory, relative to the declaration at base. Default {@link DEFAULT_POLICY_PATH}. */
|
|
48
|
+
path?: string;
|
|
49
|
+
/** The package to read. conftest default: every namespace. opa default: `main`. */
|
|
50
|
+
namespace?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The command to run instead of the engine's own, as argv. `{policy}` is
|
|
53
|
+
* the policy directory exported from base and `{input}` the input file. It
|
|
54
|
+
* prints the result shape `engine` names.
|
|
55
|
+
*/
|
|
56
|
+
command?: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A rule that matched, and what it said. */
|
|
60
|
+
export interface GatePolicyFinding {
|
|
61
|
+
/** `<package>.<rule>`, such as `main.deny_public_bucket`. */
|
|
62
|
+
rule: string;
|
|
63
|
+
message: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What an engine's answer holds. */
|
|
67
|
+
export interface GatePolicyFindings {
|
|
68
|
+
denials: GatePolicyFinding[];
|
|
69
|
+
holds: GatePolicyFinding[];
|
|
70
|
+
warnings: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One policy check's outcome. `denied` wins over `held`. `error`: the
|
|
75
|
+
* policy did not run or gave no readable answer, and the apply stops.
|
|
76
|
+
*/
|
|
77
|
+
export interface GatePolicyVerdict {
|
|
78
|
+
result: "passed" | "denied" | "held" | "error";
|
|
79
|
+
engine: GatePolicyEngine;
|
|
80
|
+
denials: GatePolicyFinding[];
|
|
81
|
+
/** The ids of the rules that denied, sorted, each once: what an override names. */
|
|
82
|
+
rules: string[];
|
|
83
|
+
holds: GatePolicyFinding[];
|
|
84
|
+
warnings: string[];
|
|
85
|
+
error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const DENY_RULE = /^(deny|violation)(_[A-Za-z0-9]+)*$/;
|
|
89
|
+
export const WARN_RULE = /^warn(_[A-Za-z0-9]+)*$/;
|
|
90
|
+
export const HOLD_RULE = /^hold(_[A-Za-z0-9]+)*$/;
|
|
91
|
+
|
|
92
|
+
const ruleId = (query: string): string => query.replace(/^data\./, "");
|
|
93
|
+
|
|
94
|
+
function messageOf(v: unknown): string {
|
|
95
|
+
if (typeof v === "string") return v;
|
|
96
|
+
if (v !== null && typeof v === "object" && typeof (v as { msg?: unknown }).msg === "string") return (v as { msg: string }).msg;
|
|
97
|
+
return JSON.stringify(v);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The findings in `conftest test --output json`. Undefined when it is not that shape. */
|
|
101
|
+
export function conftestFindings(stdout: string): GatePolicyFindings | undefined {
|
|
102
|
+
let results: unknown;
|
|
103
|
+
try {
|
|
104
|
+
results = JSON.parse(stdout);
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(results)) return undefined;
|
|
109
|
+
const out: GatePolicyFindings = { denials: [], holds: [], warnings: [] };
|
|
110
|
+
for (const r of results as Array<{ namespace?: string; failures?: Array<{ msg?: unknown; metadata?: { query?: unknown } }>; warnings?: Array<{ msg?: unknown }> }>) {
|
|
111
|
+
if (r === null || typeof r !== "object") return undefined;
|
|
112
|
+
for (const f of r.failures ?? []) {
|
|
113
|
+
// conftest names the query that denied in the failure's metadata; an older one gives only the namespace.
|
|
114
|
+
const rule = typeof f.metadata?.query === "string" ? ruleId(f.metadata.query) : `${r.namespace ?? "main"}.deny`;
|
|
115
|
+
out.denials.push({ rule, message: messageOf(f.msg) });
|
|
116
|
+
}
|
|
117
|
+
for (const w of r.warnings ?? []) out.warnings.push(messageOf(w.msg));
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The rules of one package's value, and with `nested` its child packages one level down. */
|
|
123
|
+
function packageFindings(doc: Record<string, unknown>, out: GatePolicyFindings, pkg: string, nested: boolean): void {
|
|
124
|
+
for (const [name, value] of Object.entries(doc).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
125
|
+
// A rule matches when its value is true, a message, or a non-empty set of messages.
|
|
126
|
+
const said = Array.isArray(value) ? value.map(messageOf) : value === true ? [name] : typeof value === "string" && value !== "" ? [value] : [];
|
|
127
|
+
if (DENY_RULE.test(name)) {
|
|
128
|
+
for (const message of said) out.denials.push({ rule: `${pkg}.${name}`, message });
|
|
129
|
+
} else if (HOLD_RULE.test(name)) {
|
|
130
|
+
for (const message of said) out.holds.push({ rule: `${pkg}.${name}`, message });
|
|
131
|
+
} else if (WARN_RULE.test(name)) {
|
|
132
|
+
out.warnings.push(...said);
|
|
133
|
+
} else if (nested && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
134
|
+
packageFindings(value as Record<string, unknown>, out, `${pkg}.${name}`, false);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The findings in `opa eval --format json data.<namespace>`. The query's
|
|
141
|
+
* value is the package: its rules are read by name. An array value is a
|
|
142
|
+
* deny set named after the query. A query that is undefined has no rule
|
|
143
|
+
* that matched. Undefined when the output is not that shape.
|
|
144
|
+
*/
|
|
145
|
+
export function opaFindings(stdout: string, namespace = "main"): GatePolicyFindings | undefined {
|
|
146
|
+
let parsed: { result?: Array<{ expressions?: Array<{ value?: unknown }> }>; errors?: unknown };
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(stdout);
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.errors !== undefined) return undefined;
|
|
153
|
+
const value = parsed.result?.[0]?.expressions?.[0]?.value;
|
|
154
|
+
const out: GatePolicyFindings = { denials: [], holds: [], warnings: [] };
|
|
155
|
+
if (value === undefined) return out;
|
|
156
|
+
if (Array.isArray(value)) {
|
|
157
|
+
out.denials.push(...value.map((v) => ({ rule: namespace, message: messageOf(v) })));
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
161
|
+
packageFindings(value as Record<string, unknown>, out, namespace, true);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A verdict from what the engine found. */
|
|
166
|
+
export function gatePolicyVerdict(engine: GatePolicyEngine, found: GatePolicyFindings): GatePolicyVerdict {
|
|
167
|
+
const rules = [...new Set(found.denials.map((d) => d.rule))].sort();
|
|
168
|
+
return {
|
|
169
|
+
result: found.denials.length > 0 ? "denied" : found.holds.length > 0 ? "held" : "passed",
|
|
170
|
+
engine,
|
|
171
|
+
denials: found.denials,
|
|
172
|
+
rules,
|
|
173
|
+
holds: found.holds,
|
|
174
|
+
warnings: found.warnings,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const errorVerdict = (engine: GatePolicyEngine, error: string): GatePolicyVerdict => ({ result: "error", engine, denials: [], rules: [], holds: [], warnings: [], error });
|
|
179
|
+
|
|
180
|
+
/** The argv one check runs: the settings' `command` with its placeholders filled, or the engine's own. */
|
|
181
|
+
export function gatePolicyArgv(settings: GatePolicySettings, at: { policy: string; input: string }): string[] {
|
|
182
|
+
if (settings.command) return settings.command.map((a) => a.replaceAll("{policy}", at.policy).replaceAll("{input}", at.input));
|
|
183
|
+
if ((settings.engine ?? "conftest") === "opa") {
|
|
184
|
+
return ["opa", "eval", "--format", "json", "--data", at.policy, "--input", at.input, `data.${settings.namespace ?? "main"}`];
|
|
185
|
+
}
|
|
186
|
+
return ["conftest", "test", "--no-color", "--output", "json", "--policy", at.policy, ...(settings.namespace ? ["--namespace", settings.namespace] : ["--all-namespaces"]), at.input];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** What one run of the engine printed and how it exited. `status` is null when it did not start. */
|
|
190
|
+
export interface GatePolicyRun {
|
|
191
|
+
status: number | null;
|
|
192
|
+
stdout: string;
|
|
193
|
+
stderr: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type GatePolicyExec = (argv: string[], cwd: string) => GatePolicyRun;
|
|
197
|
+
|
|
198
|
+
/** Runs the engine with the job's environment. */
|
|
199
|
+
export const defaultGatePolicyExec: GatePolicyExec = (argv, cwd) => {
|
|
200
|
+
const r = spawnSync(argv[0]!, argv.slice(1), { cwd, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
|
|
201
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: `${r.stderr ?? ""}${r.error ? r.error.message : ""}` };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export interface CheckGatePolicyOptions {
|
|
205
|
+
settings: GatePolicySettings;
|
|
206
|
+
/** The input document, or its JSON text. */
|
|
207
|
+
input: unknown;
|
|
208
|
+
/** The policy directory, already exported from base. */
|
|
209
|
+
policyDir: string;
|
|
210
|
+
/** Where the engine runs. Default the policy directory. */
|
|
211
|
+
cwd?: string;
|
|
212
|
+
exec?: GatePolicyExec;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tail = (text: string): string => text.trim().split("\n").slice(-3).join(" ");
|
|
216
|
+
|
|
217
|
+
/** Run the engine once over `input` and read its answer. */
|
|
218
|
+
export function checkGatePolicy(options: CheckGatePolicyOptions): GatePolicyVerdict {
|
|
219
|
+
const { settings } = options;
|
|
220
|
+
const engine = settings.engine ?? "conftest";
|
|
221
|
+
const work = mkdtempSync(join(tmpdir(), "chant-policy-input-"));
|
|
222
|
+
try {
|
|
223
|
+
const file = join(work, "input.json");
|
|
224
|
+
writeFileSync(file, typeof options.input === "string" ? options.input : JSON.stringify(options.input));
|
|
225
|
+
const argv = gatePolicyArgv(settings, { policy: options.policyDir, input: file });
|
|
226
|
+
const r = (options.exec ?? defaultGatePolicyExec)(argv, options.cwd ?? options.policyDir);
|
|
227
|
+
if (r.status === null) return errorVerdict(engine, `${argv[0]} did not run: ${tail(r.stderr) || "no such command"}`);
|
|
228
|
+
const found = engine === "opa" ? opaFindings(r.stdout, settings.namespace ?? "main") : conftestFindings(r.stdout);
|
|
229
|
+
if (found === undefined) return errorVerdict(engine, `${argv[0]} gave no ${engine} result (exit ${r.status}): ${tail(r.stderr || r.stdout)}`);
|
|
230
|
+
// conftest exits 1 on a denial and 2 or more when it could not run; opa exits 0 on a query it ran.
|
|
231
|
+
if (found.denials.length === 0 && r.status !== 0) return errorVerdict(engine, `${argv[0]} exited ${r.status}: ${tail(r.stderr)}`);
|
|
232
|
+
return gatePolicyVerdict(engine, found);
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(work, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The part of a tree a policy is exported from: a workspace tree at base fits. */
|
|
239
|
+
export interface GatePolicyTree {
|
|
240
|
+
stat(path: string): "file" | "dir" | undefined;
|
|
241
|
+
list(path: string): Array<{ name: string; type: "file" | "dir" }> | undefined;
|
|
242
|
+
read(path: string): string;
|
|
243
|
+
bytes?(path: string): Uint8Array;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Copy the policy directory `path` out of `tree` (the tree at base) into a
|
|
248
|
+
* new temporary directory, so the engine reads the policy as the base has
|
|
249
|
+
* it and never the change's copy. Returns the directory and how to remove
|
|
250
|
+
* it, or the reason there is no policy to read.
|
|
251
|
+
*/
|
|
252
|
+
export function exportGatePolicy(tree: GatePolicyTree, path: string): { dir: string; cleanup: () => void } | { error: string } {
|
|
253
|
+
const root = path.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
254
|
+
const kind = root === "" || root === "." ? "dir" : tree.stat(root);
|
|
255
|
+
if (kind === undefined) return { error: `the policy directory ${path} does not exist at base` };
|
|
256
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-policy-"));
|
|
257
|
+
const cleanup = (): void => rmSync(dir, { recursive: true, force: true });
|
|
258
|
+
const copy = (from: string, to: string): void => {
|
|
259
|
+
for (const entry of tree.list(from) ?? []) {
|
|
260
|
+
const src = from === "" || from === "." ? entry.name : `${from}/${entry.name}`;
|
|
261
|
+
const dest = join(to, entry.name);
|
|
262
|
+
if (entry.type === "dir") {
|
|
263
|
+
mkdirSync(dest, { recursive: true });
|
|
264
|
+
copy(src, dest);
|
|
265
|
+
} else {
|
|
266
|
+
writeFileSync(dest, tree.bytes ? tree.bytes(src) : tree.read(src));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
if (kind === "file") writeFileSync(join(dir, root.split("/").pop()!), tree.bytes ? tree.bytes(root) : tree.read(root));
|
|
272
|
+
else copy(root, dir);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
cleanup();
|
|
275
|
+
return { error: `the policy at ${path} could not be read at base: ${err instanceof Error ? err.message : String(err)}` };
|
|
276
|
+
}
|
|
277
|
+
return { dir, cleanup };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The settings, checked. Throws with what is wrong. `where` names them, such as `ci.policy`. */
|
|
281
|
+
export function assertGatePolicySettings(where: string, raw: unknown): asserts raw is GatePolicySettings {
|
|
282
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be { engine?, path?, namespace?, command? }`);
|
|
283
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
284
|
+
if (key === "engine") {
|
|
285
|
+
if (!(GATE_POLICY_ENGINES as readonly unknown[]).includes(value)) throw new Error(`${where}.engine must be ${GATE_POLICY_ENGINES.join(" or ")}, the result shape the command prints`);
|
|
286
|
+
} else if (key === "path" || key === "namespace") {
|
|
287
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}.${key} must be a non-empty string`);
|
|
288
|
+
if (key === "path" && (value.startsWith("/") || value.split("/").includes(".."))) throw new Error(`${where}.path must be a directory inside the workspace, relative to its declaration`);
|
|
289
|
+
} else if (key === "command") {
|
|
290
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every((a) => typeof a === "string" && a !== "")) throw new Error(`${where}.command must be argv, a non-empty list of strings`);
|
|
291
|
+
if (!value.some((a: string) => a.includes("{input}"))) throw new Error(`${where}.command must pass the input file as {input}`);
|
|
292
|
+
} else if (key === "outcomes" || key === "allow_unattended" || key === "allowUnattended") {
|
|
293
|
+
throw new Error(`${where}.${key}: chant's policy step denies and holds only; a non-empty plan always waits for a person (ws-104), so there is no unattended outcome`);
|
|
294
|
+
} else {
|
|
295
|
+
throw new Error(`${where}.${key} is not a setting; the settings are engine, path, namespace and command`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -203,7 +203,12 @@ if (args[0] === "publish") {
|
|
|
203
203
|
const [name, version] = split(args[1]);
|
|
204
204
|
const p = reg.packages[name];
|
|
205
205
|
if (!p) { console.error("npm error 404 Not Found - " + name); process.exit(1); }
|
|
206
|
-
if (
|
|
206
|
+
if (process.env.FAKE_NPM_VIEW_ERROR && name.includes(process.env.FAKE_NPM_VIEW_ERROR) && p.versions.includes(version)) { console.error("npm error code E500"); console.error("npm error 500 Internal Server Error - GET https://registry.example/" + name); process.exit(1); }
|
|
207
|
+
if (p.lag && p.lag[version] > 0) {
|
|
208
|
+
p.lag[version] -= 1; save();
|
|
209
|
+
// A new package npm does not serve yet answers 404, as the real registry does.
|
|
210
|
+
if (process.env.FAKE_NPM_LAG_404) { console.error("npm error code E404"); console.error("npm error 404 No match found for version " + version); process.exit(1); }
|
|
211
|
+
}
|
|
207
212
|
else if (p.versions.includes(version)) console.log(version);
|
|
208
213
|
} else if (args[0] === "dist-tag" && args[1] === "add") {
|
|
209
214
|
const [name, version] = split(args[2]);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The run writes the file just before it appends the pending fact, and puts
|
|
7
7
|
* the file's digest on that fact as `checkpointDigest`. `chant approve`
|
|
8
8
|
* copies the digest onto the approval, the way it copies the plan digest, and
|
|
9
|
-
* a seal covers it (
|
|
9
|
+
* a seal covers it (`./gate-seal.ts`). A later run that
|
|
10
10
|
* resumes at the gate reads the file back and checks it against the digest
|
|
11
11
|
* the approver saw, so a value pushed onto the branch after the approval is
|
|
12
12
|
* refused rather than carried into the steps after the gate.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
|
|
22
|
-
import type { Signer } from "
|
|
22
|
+
import type { Signer } from "../workspace/trust/trust-files";
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The ssh signature namespace of a gate approval's seal (#3163, ws-080), so a
|
|
@@ -61,9 +61,10 @@ export const NpmPublishSchema = z
|
|
|
61
61
|
*/
|
|
62
62
|
tokenEnv: envName.optional(),
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
* published before installing them to audit (#4244). A
|
|
66
|
-
* publish before it serves it.
|
|
64
|
+
* A cap, in seconds, on the reveal's wait for the registry to serve every
|
|
65
|
+
* version it published before installing them to audit (#4244). A
|
|
66
|
+
* registry accepts a publish before it serves it. Unset, the wait ends
|
|
67
|
+
* only when every version is served or the registry fails (#4271).
|
|
67
68
|
*/
|
|
68
69
|
registryWait: z.number().int().min(0).optional(),
|
|
69
70
|
})
|
|
@@ -160,10 +160,12 @@ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx
|
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
/** Seconds the reveal waits for the registry to serve what it published, by default (#4244). */
|
|
164
|
-
export const NPM_REGISTRY_WAIT_SECONDS = 600;
|
|
165
163
|
/** Between polls of the registry while it waits. */
|
|
166
164
|
export const NPM_REGISTRY_POLL_MS = 10_000;
|
|
165
|
+
/** Between progress lines while the reveal waits for the registry (#4271). */
|
|
166
|
+
export const NPM_REGISTRY_PROGRESS_MS = 60_000;
|
|
167
|
+
/** `npm view`'s answers for a version the registry does not serve yet; any other failure is the registry's error. */
|
|
168
|
+
const NPM_VIEW_NOT_YET = /\bE404\b|\b404 Not Found\b|is not in (this|the npm) registry|No match found for version|\b(ETARGET|notarget)\b/;
|
|
167
169
|
/** npm's errors for a version the registry does not serve yet. */
|
|
168
170
|
const NPM_NOT_SERVED = /\b(ETARGET|ERESOLVE|notarget)\b|No matching version found/;
|
|
169
171
|
|
|
@@ -225,29 +227,52 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
225
227
|
* `npm audit signatures` there (#4049), which checks each package's
|
|
226
228
|
* registry signature and provenance attestation.
|
|
227
229
|
*/
|
|
230
|
+
/**
|
|
231
|
+
* Whether the registry serves the version yet, while the reveal waits for
|
|
232
|
+
* it (#4271). A 404, or an answer without the version, is not yet; any other
|
|
233
|
+
* failure of `npm view` is the registry's error and ends the wait.
|
|
234
|
+
*/
|
|
235
|
+
const probe = async (ctx: PublishContext, pkg: NpmPackage): Promise<boolean> => {
|
|
236
|
+
const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
|
|
237
|
+
if (r.code === 0) return r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
|
|
238
|
+
if (r.output.some((l) => NPM_VIEW_NOT_YET.test(l))) return false;
|
|
239
|
+
const said = r.output.filter((l) => /^npm (error|ERR!)/.test(l)).at(-1) ?? r.output.at(-1) ?? "npm printed nothing";
|
|
240
|
+
throw new PublishTargetError(
|
|
241
|
+
"publish-npm-registry-failed",
|
|
242
|
+
`npm view ${pkg.name}@${pkg.version} exited ${r.code} while the reveal waited for the registry to serve it, so nothing was audited: ${said}`,
|
|
243
|
+
"Each package was published. Once the registry answers, run the publish again: it publishes nothing twice and audits again.",
|
|
244
|
+
);
|
|
245
|
+
};
|
|
228
246
|
/**
|
|
229
247
|
* Wait until the registry serves every version (#4244): npm accepts a
|
|
230
248
|
* publish minutes before `npm view` and `npm install` find it, so an
|
|
231
|
-
* install straight after fails with ETARGET or ERESOLVE.
|
|
249
|
+
* install straight after fails with ETARGET or ERESOLVE. The wait ends when
|
|
250
|
+
* every version is served or the registry fails (#4271), with a progress
|
|
251
|
+
* line about once a minute. `registryWait` caps it only when declared.
|
|
232
252
|
*/
|
|
233
253
|
const served = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
|
|
234
|
-
const
|
|
254
|
+
const cap = decl.registryWait === undefined ? undefined : decl.registryWait * 1000;
|
|
235
255
|
const start = now();
|
|
256
|
+
let logged: number | undefined;
|
|
236
257
|
let missing = pkgs;
|
|
237
258
|
for (;;) {
|
|
238
259
|
const still: NpmPackage[] = [];
|
|
239
|
-
for (const pkg of missing) if (!(await
|
|
260
|
+
for (const pkg of missing) if (!(await probe(ctx, pkg))) still.push(pkg);
|
|
240
261
|
missing = still;
|
|
241
262
|
if (missing.length === 0) return;
|
|
242
263
|
const names = missing.map((p) => `${p.name}@${p.version}`).join(", ");
|
|
243
|
-
|
|
264
|
+
const waited = now() - start;
|
|
265
|
+
if (cap !== undefined && waited >= cap) {
|
|
244
266
|
throw new PublishTargetError(
|
|
245
267
|
"publish-npm-not-served",
|
|
246
|
-
`the registry did not serve ${names} within ${Math.round(
|
|
268
|
+
`the registry did not serve ${names} within ${Math.round(cap / 1000)}s of the publish, so nothing was audited`,
|
|
247
269
|
"Each package was published. Once npm serves them, run the publish again: it publishes nothing twice and audits again. `registryWait` sets the wait.",
|
|
248
270
|
);
|
|
249
271
|
}
|
|
250
|
-
|
|
272
|
+
if (logged === undefined || now() - logged >= NPM_REGISTRY_PROGRESS_MS) {
|
|
273
|
+
ctx.log(`waiting for the registry to serve ${names}${waited > 0 ? ` (${Math.round(waited / 1000)}s so far)` : ""}`);
|
|
274
|
+
logged = now();
|
|
275
|
+
}
|
|
251
276
|
await sleep(NPM_REGISTRY_POLL_MS);
|
|
252
277
|
}
|
|
253
278
|
};
|
|
@@ -486,7 +486,8 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
486
486
|
});
|
|
487
487
|
|
|
488
488
|
describe("npm: the reveal waits for the registry to serve what it published (#4244)", () => {
|
|
489
|
-
|
|
489
|
+
// `step` is how far the clock moves per sleep, so a long wait costs few npm runs.
|
|
490
|
+
function lagged(env: Record<string, string>, registryWait?: number, step?: number) {
|
|
490
491
|
const dir = temp();
|
|
491
492
|
writePackage(join(dir, "core"), "@acme/core", "1.0.0");
|
|
492
493
|
writePackage(join(dir, "cli"), "@acme/cli", "1.0.0");
|
|
@@ -494,7 +495,7 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
494
495
|
const npm = writeFakeNpm(join(dir, "bin"), log);
|
|
495
496
|
let clock = 0;
|
|
496
497
|
const sleeps: number[] = [];
|
|
497
|
-
const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += ms; } };
|
|
498
|
+
const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += step ?? ms; } };
|
|
498
499
|
const ctx = context(dir, { env: { PATH: process.env.PATH, ...env }, provenance: OIDC });
|
|
499
500
|
const d = decl({ type: "npm", packages: ["core", "cli"], stage: false, ...(registryWait !== undefined ? { registryWait } : {}) });
|
|
500
501
|
return { log, deps, ctx, d, sleeps };
|
|
@@ -518,7 +519,39 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
518
519
|
expect(installs(log)).toBe(1);
|
|
519
520
|
});
|
|
520
521
|
|
|
521
|
-
test("
|
|
522
|
+
test("with no registryWait it waits past 600 seconds until npm serves, naming what is missing about once a minute (#4271)", async () => {
|
|
523
|
+
// 21 polls, the clock moving 30s each: served after 630s.
|
|
524
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/core=21,@acme/cli=1" }, undefined, 30_000);
|
|
525
|
+
const [target] = await createPublishTargets([d], deps);
|
|
526
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
527
|
+
expect(sleeps.length).toBe(21);
|
|
528
|
+
const progress = ctx.lines.filter((l) => l.startsWith("waiting for the registry to serve"));
|
|
529
|
+
expect(progress[0]).toBe("waiting for the registry to serve @acme/core@1.0.0, @acme/cli@1.0.0");
|
|
530
|
+
expect(progress[1]).toBe("waiting for the registry to serve @acme/core@1.0.0 (60s so far)");
|
|
531
|
+
expect(progress.at(-1)).toBe("waiting for the registry to serve @acme/core@1.0.0 (600s so far)");
|
|
532
|
+
expect(progress.length).toBe(11);
|
|
533
|
+
expect(installs(log)).toBe(1);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a 404 for the version is not yet served, and the wait goes on (#4271)", async () => {
|
|
537
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/cli=2", FAKE_NPM_LAG_404: "1" });
|
|
538
|
+
const [target] = await createPublishTargets([d], deps);
|
|
539
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
540
|
+
expect(sleeps).toEqual([10_000, 10_000]);
|
|
541
|
+
expect(installs(log)).toBe(1);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("a registry error that is not a 404 ends the wait with npm's error, before any install (#4271)", async () => {
|
|
545
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_VIEW_ERROR: "@acme/cli" });
|
|
546
|
+
const [target] = await createPublishTargets([d], deps);
|
|
547
|
+
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|
|
548
|
+
expect(err.code).toBe("publish-npm-registry-failed");
|
|
549
|
+
expect(err.message).toBe("npm view @acme/cli@1.0.0 exited 1 while the reveal waited for the registry to serve it, so nothing was audited: npm error 500 Internal Server Error - GET https://registry.example/@acme/cli");
|
|
550
|
+
expect(sleeps).toEqual([]);
|
|
551
|
+
expect(installs(log)).toBe(0);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("with registryWait declared, a version never served: the wait ends with an error naming it, before any install", async () => {
|
|
522
555
|
const { log, deps, ctx, d } = lagged({ FAKE_NPM_LAG: "@acme/core=1000" }, 30);
|
|
523
556
|
const [target] = await createPublishTargets([d], deps);
|
|
524
557
|
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|