@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,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
3
|
+
import { firstWord, formatQueryResult, loadQueryData, QUERY_SCHEMA_VERSION, QUERY_TABLES, QueryError, runQuery, SCHEMA_TABLE } from "./query";
|
|
4
|
+
import { makeRelease } from "./release-store";
|
|
5
|
+
|
|
6
|
+
/** A port over a map of `<dir>/<file>` to text, the shape `gitLedgerReadPort` reads. */
|
|
7
|
+
function port(files: Record<string, string>): LedgerReadPort {
|
|
8
|
+
const dirs = (): string[] => [...new Set(Object.keys(files).map((p) => p.slice(0, p.indexOf("/"))))];
|
|
9
|
+
return {
|
|
10
|
+
read: async (dir, file) => files[`${dir}/${file}`] ?? null,
|
|
11
|
+
list: async (dir) => Object.keys(files).filter((p) => p.startsWith(`${dir}/`)).map((p) => p.slice(dir.length + 1)),
|
|
12
|
+
trees: async () => dirs(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lines = (...rows: unknown[]): string => rows.map((r) => (typeof r === "string" ? r : JSON.stringify(r))).join("\n") + "\n";
|
|
17
|
+
|
|
18
|
+
const release = makeRelease({ member: null, commit: "c".repeat(40), artifactDigests: [{ component: "api", kind: "image", path: "dist/api.tar", digest: "sha256:aa" }], targets: [{ env: "prod", templates: [], params: [] }] });
|
|
19
|
+
|
|
20
|
+
const ledger = port({
|
|
21
|
+
"prod/aws.json": JSON.stringify({ lexicon: "aws", environment: "prod", commit: "a".repeat(40), timestamp: "2026-10-01T00:00:00Z", resources: { Bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "my-bucket", attributes: { Arn: "arn:aws:s3:::my-bucket" } }, Queue: { type: "AWS::SQS::Queue", status: "CREATE_COMPLETE" } } }),
|
|
22
|
+
"prod/edge__k8s.json": JSON.stringify({ lexicon: "k8s", environment: "prod", commit: "b".repeat(40), timestamp: "2026-10-02T00:00:00Z", depth: "deep", resources: { Web: { type: "K8s::Apps::Deployment", status: "Available", ownership: "owned" } }, unobserved: { Gone: { reason: "not-found" } } }),
|
|
23
|
+
"prod/observation-baseline.json": JSON.stringify({ version: 1, environment: "prod" }),
|
|
24
|
+
"prod/releases.jsonl": lines(
|
|
25
|
+
{ version: 1, component: "api", env: "prod", digest: "sha256:aa", gitSha: "c".repeat(40), runId: "r1", runOrigin: { forge: "github", url: "https://example.test/run/1" }, timestamp: "2026-10-03T00:00:00Z", actor: "ci", releaseDigest: release.releaseDigest },
|
|
26
|
+
"{not json",
|
|
27
|
+
),
|
|
28
|
+
"prod/runs__deploy.jsonl": lines(
|
|
29
|
+
{ version: 1, id: "run-1", op: "deploy", env: "prod", started: "2026-10-03T00:00:00Z", ended: "2026-10-03T00:01:00Z", status: "fail", labels: { Env: "prod" }, outcomes: {}, phases: [], failure: { code: "command-failed", reason: "exit 1", phase: "apply" } },
|
|
30
|
+
{ version: 1, id: "run-2", op: "deploy", env: "prod", started: "2026-10-04T00:00:00Z", ended: "2026-10-04T00:01:00Z", status: "ok", labels: {}, outcomes: { url: "https://x" }, phases: [] },
|
|
31
|
+
),
|
|
32
|
+
"_gates/deploy.jsonl": lines(
|
|
33
|
+
{ version: 1, kind: "pending", op: "deploy", gate: "prod-approval", timestamp: "2026-10-03T00:00:00Z", expiresAt: "2026-10-10T00:00:00Z", planDigest: "sha256:p1", origin: "cli" },
|
|
34
|
+
{ version: 1, op: "deploy", gate: "prod-approval", resolvedBy: "alice", timestamp: "2026-10-03T01:00:00Z", planDigest: "sha256:p1", approver: { kind: "human" } },
|
|
35
|
+
),
|
|
36
|
+
[`_releases/${release.releaseDigest.replace(/:/g, "_")}.json`]: JSON.stringify(release),
|
|
37
|
+
"_releases/sha256_tampered.json": JSON.stringify({ ...release, releaseDigest: "sha256:tampered" }),
|
|
38
|
+
"_releases/cuts.jsonl": lines({ version: 1, releaseDigest: release.releaseDigest, label: "v1.0.0", actor: "alice", timestamp: "2026-10-02T12:00:00Z", runId: null, runOrigin: null, signature: null }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("chant query (#4297)", () => {
|
|
42
|
+
test("loads every ledger into its table and counts what it can't read", async () => {
|
|
43
|
+
const load = await loadQueryData([{ member: null, port: ledger }]);
|
|
44
|
+
expect(load.data.snapshots).toHaveLength(2);
|
|
45
|
+
expect(load.data.resources.map((r) => r.name)).toEqual(["Bucket", "Queue", "Web"]);
|
|
46
|
+
expect(load.data.resources[2]).toMatchObject({ stack: "edge", lexicon: "k8s", ownership: "owned" });
|
|
47
|
+
expect(load.data.snapshots[1]).toMatchObject({ depth: "deep", resources: 1, unobserved: 1 });
|
|
48
|
+
expect(load.data.runs.map((r) => r.status)).toEqual(["fail", "ok"]);
|
|
49
|
+
expect(load.data.gates.map((g) => g.kind)).toEqual(["pending", "resolution"]);
|
|
50
|
+
expect(load.data.deploys).toHaveLength(1);
|
|
51
|
+
expect(load.data.releases).toEqual([expect.objectContaining({ release_digest: release.releaseDigest, envs: "prod" })]);
|
|
52
|
+
expect(load.data.cuts[0]).toMatchObject({ label: "v1.0.0", signed: 0 });
|
|
53
|
+
expect(load.malformed).toMatchObject({ deploys: 1, releases: 1, runs: 0, gates: 0 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("runs SQL over the tables and joins them", async () => {
|
|
57
|
+
const load = await loadQueryData([{ member: null, port: ledger }]);
|
|
58
|
+
const byType = await runQuery("SELECT lexicon, count(*) AS n FROM resources GROUP BY lexicon ORDER BY lexicon", load);
|
|
59
|
+
expect(byType.columns).toEqual(["lexicon", "n"]);
|
|
60
|
+
expect(byType.rows).toEqual([{ lexicon: "aws", n: 2 }, { lexicon: "k8s", n: 1 }]);
|
|
61
|
+
const joined = await runQuery("SELECT d.component, c.label FROM deploys d JOIN cuts c ON c.release_digest = d.release_digest", load);
|
|
62
|
+
expect(joined.rows).toEqual([{ component: "api", label: "v1.0.0" }]);
|
|
63
|
+
const arn = await runQuery("SELECT json_extract(attributes, '$.Arn') AS arn FROM resources WHERE name = 'Bucket'", load);
|
|
64
|
+
expect(arn.rows[0].arn).toBe("arn:aws:s3:::my-bucket");
|
|
65
|
+
expect(arn.schemaVersion).toBe(QUERY_SCHEMA_VERSION);
|
|
66
|
+
expect(formatQueryResult(arn)).toContain("(1 row)");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("chant_schema lists every column with the schema version", async () => {
|
|
70
|
+
const load = await loadQueryData([]);
|
|
71
|
+
const r = await runQuery(`SELECT DISTINCT version, table_name FROM ${SCHEMA_TABLE} ORDER BY table_name`, load);
|
|
72
|
+
expect(r.rows.map((x) => x.table_name)).toEqual(Object.keys(QUERY_TABLES).sort());
|
|
73
|
+
expect(new Set(r.rows.map((x) => x.version))).toEqual(new Set([QUERY_SCHEMA_VERSION]));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("each member's rows name the member", async () => {
|
|
77
|
+
const load = await loadQueryData([{ member: "api", port: ledger }, { member: "web", port: port({}) }]);
|
|
78
|
+
const r = await runQuery("SELECT DISTINCT member FROM runs", load);
|
|
79
|
+
expect(r.rows).toEqual([{ member: "api" }]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("refuses anything but a read", async () => {
|
|
83
|
+
const load = await loadQueryData([]);
|
|
84
|
+
await expect(runQuery("DELETE FROM runs", load)).rejects.toBeInstanceOf(QueryError);
|
|
85
|
+
await expect(runQuery(" ", load)).rejects.toBeInstanceOf(QueryError);
|
|
86
|
+
// A WITH that writes is stopped by query_only.
|
|
87
|
+
await expect(runQuery("WITH x AS (SELECT 1) INSERT INTO runs (id) SELECT * FROM x", load)).rejects.toBeInstanceOf(QueryError);
|
|
88
|
+
await expect(runQuery("SELECT nope FROM runs", load)).rejects.toThrow(/does not run/);
|
|
89
|
+
expect(firstWord("-- note\n /* c */ select 1")).toBe("select");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant query "<sql>"` (#4297): one read-only SQL statement over what
|
|
3
|
+
* `chant/lifecycle` holds, run in this process with no server.
|
|
4
|
+
*
|
|
5
|
+
* The rows come from the ledgers chant already writes:
|
|
6
|
+
*
|
|
7
|
+
* - the snapshots, `<env>/<lexicon>.json` and `<env>/<stack>__<lexicon>.json`
|
|
8
|
+
* (`./git.ts` `snapshotStorageKey`), each lexicon's `describeResources`
|
|
9
|
+
* read, so the inventory works for every lexicon;
|
|
10
|
+
* - the Op run ledgers, `<env>/runs__<op>.jsonl` (`./run-ledger.ts`);
|
|
11
|
+
* - the gate ledgers, `_gates/<op>.jsonl` (`./gate-ledger.ts`);
|
|
12
|
+
* - the deploy records, `<env>/releases.jsonl` (`./release-read.ts`);
|
|
13
|
+
* - the releases and their cuts, `_releases/<digest>.json` and
|
|
14
|
+
* `_releases/cuts.jsonl` (`./release-store.ts`).
|
|
15
|
+
*
|
|
16
|
+
* The tables are a contract. {@link QUERY_SCHEMA_VERSION} names it, and the
|
|
17
|
+
* `chant_schema` table lists every table and column with the version, so a
|
|
18
|
+
* saved query can check what it runs against. A column is only added within
|
|
19
|
+
* a version; renaming or removing one, or changing what it holds, is a new
|
|
20
|
+
* version.
|
|
21
|
+
*
|
|
22
|
+
* Everything but {@link runQuery} is pure over a {@link LedgerReadPort}: the
|
|
23
|
+
* caller decides which members to read and how.
|
|
24
|
+
*
|
|
25
|
+
* The engine is `node:sqlite`, the SQLite built into Node.js 22.13 and later.
|
|
26
|
+
* The database is in memory, takes `PRAGMA query_only`, and only a statement
|
|
27
|
+
* that begins with SELECT, WITH or VALUES runs. This mirrors terragucci's
|
|
28
|
+
* `query.ts`, which reads report JSON from a bucket instead.
|
|
29
|
+
*/
|
|
30
|
+
import type { GateOrigin } from "./gate-origin";
|
|
31
|
+
import { parseGateLedger, GATES_DIR, type GateResolutionRecord, type PendingGateRecord } from "./gate-ledger";
|
|
32
|
+
import type { LedgerReadPort } from "./ledger-port";
|
|
33
|
+
import { parseReleaseLedger, releaseLedgerLines } from "./release-read";
|
|
34
|
+
import { CUTS_FILE, parseCuts, parseRelease, RELEASES_DIR, verifyReleaseDigest } from "./release-store";
|
|
35
|
+
import { opOfRunLedgerFile, parseRunLedger } from "./run-ledger";
|
|
36
|
+
import type { LifecycleSnapshot } from "./types";
|
|
37
|
+
|
|
38
|
+
/** The version of the table schema below. */
|
|
39
|
+
export const QUERY_SCHEMA_VERSION = 1;
|
|
40
|
+
|
|
41
|
+
type ColumnType = "TEXT" | "INTEGER";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Each table's columns, in order, with their SQLite type. A column named in
|
|
45
|
+
* the doc as JSON holds a JSON text, for `json_extract`. Every table starts
|
|
46
|
+
* with `member`: the workspace member whose ledger the row came from, or NULL
|
|
47
|
+
* for a project outside a workspace.
|
|
48
|
+
*/
|
|
49
|
+
export const QUERY_TABLES = {
|
|
50
|
+
/** One row per resource in each environment's newest snapshot. `attributes` is JSON. */
|
|
51
|
+
resources: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["name", "TEXT"], ["type", "TEXT"], ["physical_id", "TEXT"], ["status", "TEXT"], ["last_updated", "TEXT"], ["ownership", "TEXT"], ["attributes", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"]],
|
|
52
|
+
/** One row per snapshot file: what was observed, when, and how many resources it holds. */
|
|
53
|
+
snapshots: [["member", "TEXT"], ["env", "TEXT"], ["stack", "TEXT"], ["lexicon", "TEXT"], ["git_sha", "TEXT"], ["observed_at", "TEXT"], ["depth", "TEXT"], ["resources", "INTEGER"], ["unobserved", "INTEGER"]],
|
|
54
|
+
/** One row per Op run record. `labels` and `outcomes` are JSON. */
|
|
55
|
+
runs: [["member", "TEXT"], ["id", "TEXT"], ["op", "TEXT"], ["env", "TEXT"], ["started", "TEXT"], ["ended", "TEXT"], ["status", "TEXT"], ["git_sha", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["failure_code", "TEXT"], ["failure_reason", "TEXT"], ["failed_phase", "TEXT"], ["gate", "TEXT"], ["steward", "TEXT"], ["labels", "TEXT"], ["outcomes", "TEXT"]],
|
|
56
|
+
/** One row per gate ledger line: `kind` is `pending` or `resolution`. `signed` is 1 for a sealed approval. */
|
|
57
|
+
gates: [["member", "TEXT"], ["op", "TEXT"], ["gate", "TEXT"], ["kind", "TEXT"], ["timestamp", "TEXT"], ["resolved_by", "TEXT"], ["relayed_by", "TEXT"], ["approver_kind", "TEXT"], ["plan_digest", "TEXT"], ["environment", "TEXT"], ["release_digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["url", "TEXT"], ["note", "TEXT"], ["expires_at", "TEXT"], ["origin", "TEXT"], ["empty", "INTEGER"], ["signed", "INTEGER"]],
|
|
58
|
+
/** One row per deploy record in `<env>/releases.jsonl`. `promoted_from`, `restores` and `redeploys` are JSON. */
|
|
59
|
+
deploys: [["member", "TEXT"], ["env", "TEXT"], ["component", "TEXT"], ["digest", "TEXT"], ["git_sha", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["run_url", "TEXT"], ["timestamp", "TEXT"], ["actor", "TEXT"], ["approver", "TEXT"], ["release_digest", "TEXT"], ["digest_kind", "TEXT"], ["promoted_from", "TEXT"], ["restores", "TEXT"], ["redeploys", "TEXT"]],
|
|
60
|
+
/** One row per release file whose content hashes to its digest. `envs` joins its targets' environments with commas; `artifacts` is JSON. */
|
|
61
|
+
releases: [["member", "TEXT"], ["release_digest", "TEXT"], ["release_member", "TEXT"], ["git_sha", "TEXT"], ["envs", "TEXT"], ["artifacts", "TEXT"]],
|
|
62
|
+
/** One row per line of `_releases/cuts.jsonl`. `signed` is 1 for a signed cut. */
|
|
63
|
+
cuts: [["member", "TEXT"], ["release_digest", "TEXT"], ["label", "TEXT"], ["actor", "TEXT"], ["timestamp", "TEXT"], ["run_id", "TEXT"], ["forge", "TEXT"], ["channel", "TEXT"], ["signed", "INTEGER"], ["composed_base", "TEXT"]],
|
|
64
|
+
} as const satisfies Record<string, ReadonlyArray<readonly [string, ColumnType]>>;
|
|
65
|
+
|
|
66
|
+
export type QueryTable = keyof typeof QUERY_TABLES;
|
|
67
|
+
|
|
68
|
+
/** The table that describes the others: one row per column, with the schema version. */
|
|
69
|
+
export const SCHEMA_TABLE = "chant_schema";
|
|
70
|
+
|
|
71
|
+
type Value = string | number | null;
|
|
72
|
+
export type QueryRow = Record<string, Value>;
|
|
73
|
+
|
|
74
|
+
/** The rows of each table, as loaded. */
|
|
75
|
+
export type QueryData = Record<QueryTable, QueryRow[]>;
|
|
76
|
+
|
|
77
|
+
/** One ledger to read: a member's, or the project's own with `member` null. */
|
|
78
|
+
export interface QueryLedger {
|
|
79
|
+
member: string | null;
|
|
80
|
+
port: LedgerReadPort;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface QueryLoad {
|
|
84
|
+
data: QueryData;
|
|
85
|
+
/** Lines and files that could not be read as their kind, per table. Left out of the rows. */
|
|
86
|
+
malformed: Record<QueryTable, number>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface QueryResult {
|
|
90
|
+
schemaVersion: number;
|
|
91
|
+
columns: string[];
|
|
92
|
+
rows: QueryRow[];
|
|
93
|
+
/** How many rows each table held. */
|
|
94
|
+
tables: Record<QueryTable, number>;
|
|
95
|
+
malformed: Record<QueryTable, number>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A statement that can't run, or a Node.js with no SQLite. The message is for a person. */
|
|
99
|
+
export class QueryError extends Error {}
|
|
100
|
+
|
|
101
|
+
const TABLE_NAMES = Object.keys(QUERY_TABLES) as QueryTable[];
|
|
102
|
+
const empty = <T>(v: () => T): Record<QueryTable, T> => Object.fromEntries(TABLE_NAMES.map((t) => [t, v()])) as Record<QueryTable, T>;
|
|
103
|
+
const json = (v: unknown): string | null => (v === undefined || v === null ? null : JSON.stringify(v));
|
|
104
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
105
|
+
const bit = (v: boolean): number => (v ? 1 : 0);
|
|
106
|
+
|
|
107
|
+
/** Whether a parsed `.json` file under an environment is a snapshot (an observation baseline or another file is not). */
|
|
108
|
+
function isSnapshot(v: unknown): v is LifecycleSnapshot {
|
|
109
|
+
if (!v || typeof v !== "object") return false;
|
|
110
|
+
const s = v as Partial<LifecycleSnapshot>;
|
|
111
|
+
return typeof s.lexicon === "string" && typeof s.environment === "string" && typeof s.commit === "string" && typeof s.timestamp === "string" && !!s.resources && typeof s.resources === "object" && !Array.isArray(s.resources);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function snapshotRows(member: string | null, env: string, file: string, s: LifecycleSnapshot, data: QueryData): void {
|
|
115
|
+
// The file name folds the stack in as `<stack>__<lexicon>` (git.ts snapshotStorageKey); the record names it too.
|
|
116
|
+
const key = file.slice(0, -".json".length);
|
|
117
|
+
const stack = s.stack ?? (key.includes("__") ? key.slice(0, key.lastIndexOf("__")) : null);
|
|
118
|
+
const at = { git_sha: s.commit, observed_at: s.timestamp };
|
|
119
|
+
const resources = Object.entries(s.resources);
|
|
120
|
+
data.snapshots.push({ member, env, stack, lexicon: s.lexicon, ...at, depth: s.depth ?? "identity", resources: resources.length, unobserved: Object.keys(s.unobserved ?? {}).length });
|
|
121
|
+
for (const [name, r] of resources) {
|
|
122
|
+
data.resources.push({ member, env, stack, lexicon: s.lexicon, name, type: str(r?.type), physical_id: str(r?.physicalId), status: str(r?.status), last_updated: str(r?.lastUpdated), ownership: str(r?.ownership), attributes: json(r?.attributes), ...at });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function gateRow(member: string | null, r: GateResolutionRecord | PendingGateRecord): QueryRow {
|
|
127
|
+
const pending = r.kind === "pending";
|
|
128
|
+
const res = pending ? undefined : (r as GateResolutionRecord);
|
|
129
|
+
const origin: GateOrigin | undefined = (r as { origin?: GateOrigin }).origin;
|
|
130
|
+
return {
|
|
131
|
+
member,
|
|
132
|
+
op: r.op,
|
|
133
|
+
gate: r.gate,
|
|
134
|
+
kind: pending ? "pending" : "resolution",
|
|
135
|
+
timestamp: r.timestamp,
|
|
136
|
+
resolved_by: res?.resolvedBy ?? null,
|
|
137
|
+
relayed_by: str(res?.relayedBy),
|
|
138
|
+
approver_kind: str(res?.approver?.kind),
|
|
139
|
+
plan_digest: str(r.planDigest),
|
|
140
|
+
environment: str(r.environment),
|
|
141
|
+
release_digest: str(r.releaseDigest),
|
|
142
|
+
git_sha: str(r.gitSha),
|
|
143
|
+
run_id: str(r.runId),
|
|
144
|
+
url: str(res?.url),
|
|
145
|
+
note: str(res?.note),
|
|
146
|
+
expires_at: pending ? str((r as PendingGateRecord).expiresAt) : null,
|
|
147
|
+
origin: str(origin),
|
|
148
|
+
empty: bit(res?.empty === true),
|
|
149
|
+
signed: bit(res?.seal !== undefined),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read every table's rows from the ledgers, in the order given. A ledger
|
|
155
|
+
* whose branch is absent reads as no rows. A line or file that is not its
|
|
156
|
+
* kind is counted in `malformed` and left out, as each ledger's own reader
|
|
157
|
+
* does.
|
|
158
|
+
*/
|
|
159
|
+
export async function loadQueryData(ledgers: readonly QueryLedger[]): Promise<QueryLoad> {
|
|
160
|
+
const data = empty<QueryRow[]>(() => []) as QueryData;
|
|
161
|
+
const malformed = empty(() => 0);
|
|
162
|
+
for (const { member, port } of ledgers) {
|
|
163
|
+
const trees = (await port.trees()).sort();
|
|
164
|
+
for (const env of trees.filter((t) => !t.startsWith("_"))) {
|
|
165
|
+
for (const file of (await port.list(env)).sort()) {
|
|
166
|
+
if (file.endsWith(".json")) {
|
|
167
|
+
const text = await port.read(env, file);
|
|
168
|
+
let parsed: unknown;
|
|
169
|
+
try {
|
|
170
|
+
parsed = text === null ? null : JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
parsed = null;
|
|
173
|
+
}
|
|
174
|
+
// Other JSON files live beside the snapshots (the observation baseline); only a snapshot-shaped one is read.
|
|
175
|
+
if (isSnapshot(parsed)) snapshotRows(member, env, file, parsed, data);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (file === "releases.jsonl") {
|
|
179
|
+
const { records, malformed: bad } = parseReleaseLedger(releaseLedgerLines(await port.read(env, file)));
|
|
180
|
+
malformed.deploys += bad;
|
|
181
|
+
for (const d of records) {
|
|
182
|
+
data.deploys.push({ member, env: d.env, component: d.component, digest: d.digest, git_sha: d.gitSha, run_id: d.runId, forge: str(d.runOrigin?.forge), run_url: str(d.runOrigin?.url), timestamp: d.timestamp, actor: d.actor, approver: str(d.approver), release_digest: str(d.releaseDigest), digest_kind: str(d.digestKind), promoted_from: json(d.promotedFrom), restores: json(d.restores), redeploys: json(d.redeploys) });
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (opOfRunLedgerFile(file) !== null) {
|
|
187
|
+
const text = await port.read(env, file);
|
|
188
|
+
if (text === null) continue;
|
|
189
|
+
const { records, malformed: bad } = parseRunLedger(text);
|
|
190
|
+
malformed.runs += bad;
|
|
191
|
+
for (const r of records) {
|
|
192
|
+
data.runs.push({ member, id: r.id, op: r.op, env: r.env, started: r.started, ended: r.ended, status: r.status, git_sha: str(r.gitSha), forge: str(r.runOrigin?.forge), run_url: str(r.runOrigin?.url), failure_code: str(r.failure?.code), failure_reason: str(r.failure?.reason), failed_phase: str(r.failure?.phase), gate: str(r.gate?.name), steward: str(r.steward), labels: json(r.labels ?? {}), outcomes: json(r.outcomes ?? {}) });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (trees.includes(GATES_DIR)) {
|
|
198
|
+
for (const file of (await port.list(GATES_DIR)).sort()) {
|
|
199
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
200
|
+
const text = await port.read(GATES_DIR, file);
|
|
201
|
+
if (text === null) continue;
|
|
202
|
+
const { resolutions, pending, malformed: bad } = parseGateLedger(text);
|
|
203
|
+
malformed.gates += bad;
|
|
204
|
+
// Both kinds share the file; ledger order is time order, so they are merged back by timestamp.
|
|
205
|
+
const lines = [...pending, ...resolutions].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
206
|
+
for (const r of lines) data.gates.push(gateRow(member, r));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (trees.includes(RELEASES_DIR)) {
|
|
210
|
+
for (const file of (await port.list(RELEASES_DIR)).sort()) {
|
|
211
|
+
if (file === CUTS_FILE) {
|
|
212
|
+
const { cuts, malformed: bad } = parseCuts(await port.read(RELEASES_DIR, file));
|
|
213
|
+
malformed.cuts += bad;
|
|
214
|
+
for (const c of cuts) {
|
|
215
|
+
data.cuts.push({ member, release_digest: c.releaseDigest, label: c.label, actor: c.actor, timestamp: c.timestamp, run_id: c.runId, forge: str(c.runOrigin?.forge), channel: str(c.channel), signed: bit(c.signature !== null), composed_base: str(c.composed?.base) });
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!file.endsWith(".json")) continue;
|
|
220
|
+
const text = await port.read(RELEASES_DIR, file);
|
|
221
|
+
const release = text === null ? null : parseRelease(text);
|
|
222
|
+
// `readAllReleases` (release-store.ts) counts a file whose content does not hash to its digest as malformed; so does this.
|
|
223
|
+
if (!release || release.releaseDigest.replace(/:/g, "_") !== file.slice(0, -".json".length) || !verifyReleaseDigest(release)) {
|
|
224
|
+
malformed.releases++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
data.releases.push({ member, release_digest: release.releaseDigest, release_member: release.member, git_sha: release.commit, envs: release.targets.map((t) => t.env).join(","), artifacts: json(release.artifactDigests) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { data, malformed };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The part of `node:sqlite` this uses, typed here since the repo's @types/node predates the module. */
|
|
235
|
+
interface SqliteStatement {
|
|
236
|
+
run(...values: Value[]): unknown;
|
|
237
|
+
all(): unknown[];
|
|
238
|
+
columns?(): Array<{ name: string }>;
|
|
239
|
+
}
|
|
240
|
+
interface SqliteModule {
|
|
241
|
+
DatabaseSync: new (path: string) => { exec(sql: string): void; prepare(sql: string): SqliteStatement; close(): void };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** node:sqlite, without the experimental warning some Node.js versions print when it loads. */
|
|
245
|
+
async function sqlite(): Promise<SqliteModule> {
|
|
246
|
+
const emit = process.emitWarning;
|
|
247
|
+
process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => {
|
|
248
|
+
if (/SQLite/.test(typeof warning === "string" ? warning : warning.message)) return;
|
|
249
|
+
return (emit as (...a: unknown[]) => void).call(process, warning, ...rest);
|
|
250
|
+
}) as typeof process.emitWarning;
|
|
251
|
+
try {
|
|
252
|
+
const name = "node:sqlite";
|
|
253
|
+
return (await import(name)) as SqliteModule;
|
|
254
|
+
} catch {
|
|
255
|
+
throw new QueryError(`chant query runs on the SQLite built into Node.js 22.13 or later; this is Node.js ${process.versions.node}`);
|
|
256
|
+
} finally {
|
|
257
|
+
process.emitWarning = emit;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The first word of a statement, past whitespace and comments. */
|
|
262
|
+
export function firstWord(sql: string): string {
|
|
263
|
+
const s = sql.replace(/^(\s+|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/, "");
|
|
264
|
+
return (/^[A-Za-z]+/.exec(s)?.[0] ?? "").toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Run one read-only statement over the tables. */
|
|
268
|
+
export async function runQuery(sql: string, load: QueryLoad): Promise<QueryResult> {
|
|
269
|
+
if (sql.trim() === "") throw new QueryError('chant query needs a statement: chant query "SELECT * FROM resources"');
|
|
270
|
+
if (!["select", "with", "values"].includes(firstWord(sql))) throw new QueryError("chant query runs one SELECT (or WITH, or VALUES) statement; it changes nothing");
|
|
271
|
+
const { DatabaseSync } = await sqlite();
|
|
272
|
+
const db = new DatabaseSync(":memory:");
|
|
273
|
+
try {
|
|
274
|
+
db.exec(`CREATE TABLE ${SCHEMA_TABLE} ("version" INTEGER, "table_name" TEXT, "column_name" TEXT, "type" TEXT, "position" INTEGER)`);
|
|
275
|
+
const describe = db.prepare(`INSERT INTO ${SCHEMA_TABLE} VALUES (?, ?, ?, ?, ?)`);
|
|
276
|
+
for (const table of TABLE_NAMES) {
|
|
277
|
+
const cols = QUERY_TABLES[table] as ReadonlyArray<readonly [string, ColumnType]>;
|
|
278
|
+
db.exec(`CREATE TABLE ${table} (${cols.map(([c, t]) => `"${c}" ${t}`).join(", ")})`);
|
|
279
|
+
cols.forEach(([c, t], i) => describe.run(QUERY_SCHEMA_VERSION, table, c, t, i + 1));
|
|
280
|
+
const insert = db.prepare(`INSERT INTO ${table} VALUES (${cols.map(() => "?").join(", ")})`);
|
|
281
|
+
db.exec("BEGIN");
|
|
282
|
+
for (const row of load.data[table]) insert.run(...cols.map(([c]) => row[c] ?? null));
|
|
283
|
+
db.exec("COMMIT");
|
|
284
|
+
}
|
|
285
|
+
db.exec("PRAGMA query_only = ON");
|
|
286
|
+
let stmt: SqliteStatement;
|
|
287
|
+
try {
|
|
288
|
+
stmt = db.prepare(sql);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
throw new QueryError(`the query does not run: ${(e as Error).message}`);
|
|
291
|
+
}
|
|
292
|
+
let rows: QueryRow[];
|
|
293
|
+
try {
|
|
294
|
+
rows = stmt.all() as QueryRow[];
|
|
295
|
+
} catch (e) {
|
|
296
|
+
throw new QueryError(`the query does not run: ${(e as Error).message}`);
|
|
297
|
+
}
|
|
298
|
+
const columns = typeof stmt.columns === "function" ? stmt.columns().map((c) => c.name) : Object.keys(rows[0] ?? {});
|
|
299
|
+
const tables = Object.fromEntries(TABLE_NAMES.map((t) => [t, load.data[t].length])) as Record<QueryTable, number>;
|
|
300
|
+
return { schemaVersion: QUERY_SCHEMA_VERSION, columns, rows: rows.map((r) => ({ ...r })), tables, malformed: load.malformed };
|
|
301
|
+
} finally {
|
|
302
|
+
db.close();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const cell = (v: Value | undefined): string => (v === null || v === undefined ? "" : String(v));
|
|
307
|
+
|
|
308
|
+
/** The rows as an aligned table, a header and a count. */
|
|
309
|
+
export function formatQueryResult(r: QueryResult): string {
|
|
310
|
+
const widths = r.columns.map((c) => Math.max(c.length, ...r.rows.map((row) => cell(row[c]).length)));
|
|
311
|
+
const line = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
|
|
312
|
+
const out = [line(r.columns), line(widths.map((w) => "-".repeat(w))), ...r.rows.map((row) => line(r.columns.map((c) => cell(row[c]))))];
|
|
313
|
+
out.push(`(${r.rows.length} ${r.rows.length === 1 ? "row" : "rows"})`);
|
|
314
|
+
return out.join("\n");
|
|
315
|
+
}
|
|
@@ -242,6 +242,35 @@ export interface ReleaseRecord {
|
|
|
242
242
|
* deploy that was not made from a release.
|
|
243
243
|
*/
|
|
244
244
|
releaseDigest?: string;
|
|
245
|
+
/**
|
|
246
|
+
* Optional: what `digest` names when it is not an artifact digest (#4274).
|
|
247
|
+
* Absent on a record whose digest is the artifact the deploy promoted, and on
|
|
248
|
+
* every record written before 0.124.35.
|
|
249
|
+
*
|
|
250
|
+
* - `plan`: the component published nothing, and the digest is its plan
|
|
251
|
+
* digest, the one its approval bound (`pr-apply`).
|
|
252
|
+
* - `deploy-input`: the component published nothing and planned nothing, and
|
|
253
|
+
* the digest is its deploy-input digest (`componentInputDigests`,
|
|
254
|
+
* ../components/fan-out-inputs.ts).
|
|
255
|
+
* - `templates`: the digest of the templates the release records for the
|
|
256
|
+
* component (#4222, `releaseTemplatesDigest`).
|
|
257
|
+
*
|
|
258
|
+
* A `plan` or `deploy-input` record says the component applied; it names no
|
|
259
|
+
* artifact, so promote, rollback and redeploy never deploy it
|
|
260
|
+
* ({@link isApplyRecord}).
|
|
261
|
+
*/
|
|
262
|
+
digestKind?: ReleaseDigestKind;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** What a release record's `digest` names when it is not an artifact digest (#4274). */
|
|
266
|
+
export type ReleaseDigestKind = "plan" | "deploy-input" | "templates";
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether `record` only says its component applied (#4274): its digest is a
|
|
270
|
+
* plan or deploy-input digest, not one anything can be deployed from again.
|
|
271
|
+
*/
|
|
272
|
+
export function isApplyRecord(record: Pick<ReleaseRecord, "digestKind">): boolean {
|
|
273
|
+
return record.digestKind === "plan" || record.digestKind === "deploy-input";
|
|
245
274
|
}
|
|
246
275
|
|
|
247
276
|
/** Thrown by `appendReleaseRecord` when the record is missing a required field — a release record can never be recorded partially, since it is never editable afterward. */
|
|
@@ -319,9 +348,21 @@ export function resolveCiRun(env: NodeJS.ProcessEnv = process.env): { runId: str
|
|
|
319
348
|
},
|
|
320
349
|
};
|
|
321
350
|
}
|
|
351
|
+
// #4275: a step of a job the chant runner ran. The forge branches come first, and the runner
|
|
352
|
+
// strips the forge's run variables from its steps, so neither kind of job is taken for the other.
|
|
353
|
+
if (env[CHANT_CI_RUN_ID_ENV] && env[CHANT_CI_RUN_FORGE_ENV] === "chant") {
|
|
354
|
+
return { runId: env[CHANT_CI_RUN_ID_ENV], runOrigin: { forge: "chant" } };
|
|
355
|
+
}
|
|
322
356
|
return undefined;
|
|
323
357
|
}
|
|
324
358
|
|
|
359
|
+
/** The run id `chant ci run` hands each step of a job it runs (#4275), the id its run index lists. */
|
|
360
|
+
export const CHANT_CI_RUN_ID_ENV = "CHANT_CI_RUN_ID";
|
|
361
|
+
/** The run's origin `chant ci run` hands each step with {@link CHANT_CI_RUN_ID_ENV}: always `chant`. */
|
|
362
|
+
export const CHANT_CI_RUN_FORGE_ENV = "CHANT_CI_RUN_FORGE";
|
|
363
|
+
/** The forge's run variables, which a step of a job the chant runner ran never gets (#4275). */
|
|
364
|
+
export const FORGE_RUN_ENV = ["GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_JOB", "CI_PIPELINE_ID", "CI_PIPELINE_URL", "CI_JOB_ID"] as const;
|
|
365
|
+
|
|
325
366
|
/**
|
|
326
367
|
* The CI run the environment names, as {@link resolveCiRun} has it, with the
|
|
327
368
|
* job within the run and the run's attempt when the forge gives them
|
|
@@ -332,7 +373,7 @@ export function resolveCiJob(env: NodeJS.ProcessEnv = process.env): { runId: str
|
|
|
332
373
|
const ci = resolveCiRun(env);
|
|
333
374
|
if (!ci) return undefined;
|
|
334
375
|
const github = ci.runOrigin.forge === "github";
|
|
335
|
-
const jobId = github ? env.GITHUB_JOB : env.CI_JOB_ID;
|
|
376
|
+
const jobId = github ? env.GITHUB_JOB : ci.runOrigin.forge === "gitlab" ? env.CI_JOB_ID : undefined;
|
|
336
377
|
const attempt = github ? Number(env.GITHUB_RUN_ATTEMPT) : NaN;
|
|
337
378
|
return {
|
|
338
379
|
runId: ci.runId,
|
|
@@ -197,7 +197,25 @@ export async function readRunLedger(
|
|
|
197
197
|
): Promise<{ records: OpRunRecord[]; malformed: number }> {
|
|
198
198
|
const content = await readBlobFromPath(environment, fileFor(op), opts);
|
|
199
199
|
if (!content) return { records: [], malformed: 0 };
|
|
200
|
+
return parseRunLedger(content);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The run ledger file name for `op` under `<env>/`, `runs__<op>.jsonl`. */
|
|
204
|
+
export function runLedgerFile(op: string): string {
|
|
205
|
+
return fileFor(op);
|
|
206
|
+
}
|
|
200
207
|
|
|
208
|
+
/** The op a run ledger file name holds, or null when `file` is not one. */
|
|
209
|
+
export function opOfRunLedgerFile(file: string): string | null {
|
|
210
|
+
return file.startsWith(PREFIX) && file.endsWith(".jsonl") && file.length > PREFIX.length + ".jsonl".length ? file.slice(PREFIX.length, -".jsonl".length) : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse the text of one run ledger file the way {@link readRunLedger} reads
|
|
215
|
+
* it (#4297): a reader that fetches the file itself, such as `chant query`,
|
|
216
|
+
* gets the same records and the same malformed count.
|
|
217
|
+
*/
|
|
218
|
+
export function parseRunLedger(content: string): { records: OpRunRecord[]; malformed: number } {
|
|
201
219
|
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
202
220
|
const records: OpRunRecord[] = [];
|
|
203
221
|
let malformed = 0;
|
|
@@ -432,6 +432,16 @@ describe("status", () => {
|
|
|
432
432
|
expect(result.digestB).toBe("sha256:prod-bytes");
|
|
433
433
|
});
|
|
434
434
|
|
|
435
|
+
test("apply records compare on the commit, since their digests take in the environment (#4274)", () => {
|
|
436
|
+
const apply = (env: string, gitSha: string) =>
|
|
437
|
+
record({ component: "infra", env, gitSha, digest: `jcs1-sha256:${env.padEnd(64, "0").slice(0, 64)}`, digestKind: "deploy-input" });
|
|
438
|
+
const same = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "a1")] }, { name: "prod", records: [apply("prod", "a1")] });
|
|
439
|
+
expect(same.same).toBe(true);
|
|
440
|
+
expect(same.comparedOn).toEqual({ a: "git:a1", b: "git:a1" });
|
|
441
|
+
const moved = compareAcrossEnvironments("infra", { name: "staging", records: [apply("staging", "b2")] }, { name: "prod", records: [apply("prod", "a1")] });
|
|
442
|
+
expect(moved.same).toBe(false);
|
|
443
|
+
});
|
|
444
|
+
|
|
435
445
|
test("differing inputDigests are a real difference, whatever the bytes say", () => {
|
|
436
446
|
const result = compareAcrossEnvironments(
|
|
437
447
|
"web",
|
|
@@ -671,6 +681,15 @@ describe("drifted: live evidence contradicts the recorded release (#2513)", () =
|
|
|
671
681
|
expect(row.reconciliation).toBe("reconciled");
|
|
672
682
|
});
|
|
673
683
|
|
|
684
|
+
test("reconciled: an apply record's plan digest is never held against a live digest (#4274)", () => {
|
|
685
|
+
const row = reconcile(
|
|
686
|
+
[entry()],
|
|
687
|
+
new Map([["svc", { now: live({ attributes: { digest: "sha256:other", gitSha: "1111111" } }) }]]),
|
|
688
|
+
{ ...released, digest: `jcs1-sha256:${"a".repeat(64)}`, digestKind: "plan" },
|
|
689
|
+
);
|
|
690
|
+
expect(row.reconciliation).toBe("reconciled");
|
|
691
|
+
});
|
|
692
|
+
|
|
674
693
|
test("drifted: a foreign digest", () => {
|
|
675
694
|
const row = reconcile([entry()], new Map([["svc", { now: live({ attributes: { digest: "sha256:other" } }) }]]));
|
|
676
695
|
expect(row.reconciliation).toBe("drifted");
|
package/src/lifecycle/status.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
import type { ChangeSet, ChangeAction } from "./change-set";
|
|
45
45
|
import type { ResourceMetadata } from "../lexicon";
|
|
46
|
-
import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
|
|
46
|
+
import { isApplyRecord, latestPerComponent, type ReleaseRecord } from "./release-ledger";
|
|
47
47
|
import { isLegacyContentDigest } from "./legacy-digest";
|
|
48
48
|
import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
|
|
49
49
|
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
@@ -647,7 +647,8 @@ function notBefore(since: string, recordedAt: string): boolean {
|
|
|
647
647
|
*/
|
|
648
648
|
function contradictsRecord(recorded: ReleaseRecord, evidence: LiveComponentEvidence): string | undefined {
|
|
649
649
|
for (const id of evidence.identities ?? []) {
|
|
650
|
-
|
|
650
|
+
// #4274: an apply's digest is a plan or deploy-input digest, which no live resource reports.
|
|
651
|
+
if (id.digest !== undefined && !isApplyRecord(recorded)) {
|
|
651
652
|
const matches =
|
|
652
653
|
sameDigest(id.digest, recorded.digest) ||
|
|
653
654
|
(recorded.inputDigest !== undefined && sameDigest(id.digest, recorded.inputDigest));
|
|
@@ -697,7 +698,9 @@ export interface CrossEnvComparison {
|
|
|
697
698
|
* environments legitimately render to different bytes, so cross-environment
|
|
698
699
|
* "is prod running what staging tested" joins on the input side while
|
|
699
700
|
* `digest` proves the exact bytes each cluster got. Records without it
|
|
700
|
-
* compare on `digest` exactly as before.
|
|
701
|
+
* compare on `digest` exactly as before. An apply record (#4274) compares
|
|
702
|
+
* on its `gitSha`, since its plan or deploy-input digest takes in the
|
|
703
|
+
* environment and so differs between any two.
|
|
701
704
|
*/
|
|
702
705
|
same: boolean;
|
|
703
706
|
/** The identities `same` was decided on (`inputDigest ?? digest` per side), when both sides had one and they differ from the digests shown. */
|
|
@@ -711,6 +714,11 @@ export interface CrossEnvComparison {
|
|
|
711
714
|
mixedDigestForms?: true;
|
|
712
715
|
}
|
|
713
716
|
|
|
717
|
+
/** What two environments' records of a component are compared on: the input side when there is one, the commit for an apply (#4274). */
|
|
718
|
+
export function comparableIdentity(record: ReleaseRecord): string {
|
|
719
|
+
return isApplyRecord(record) ? `git:${record.gitSha}` : record.inputDigest ?? record.digest;
|
|
720
|
+
}
|
|
721
|
+
|
|
714
722
|
/** Compare the latest recorded digest for `component` between two environments' release records. */
|
|
715
723
|
export function compareAcrossEnvironments(
|
|
716
724
|
component: string,
|
|
@@ -719,9 +727,9 @@ export function compareAcrossEnvironments(
|
|
|
719
727
|
): CrossEnvComparison {
|
|
720
728
|
const latestA = latestPerComponent(envA.records).get(component);
|
|
721
729
|
const latestB = latestPerComponent(envB.records).get(component);
|
|
722
|
-
const idA = latestA ? latestA
|
|
723
|
-
const idB = latestB ? latestB
|
|
724
|
-
const comparedOnInputs = (latestA
|
|
730
|
+
const idA = latestA ? comparableIdentity(latestA) : undefined;
|
|
731
|
+
const idB = latestB ? comparableIdentity(latestB) : undefined;
|
|
732
|
+
const comparedOnInputs = (latestA !== undefined && idA !== latestA.digest) || (latestB !== undefined && idB !== latestB.digest);
|
|
725
733
|
return {
|
|
726
734
|
component,
|
|
727
735
|
envA: envA.name,
|