@intentius/chant 0.45.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +10 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +223 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +132 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +4 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +134 -8
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +211 -0
- package/src/cli/commands/build.ts +126 -8
- package/src/cli/commands/check-lexicon.test.ts +45 -1
- package/src/cli/commands/check-lexicon.ts +45 -0
- package/src/cli/commands/lexicon-surface-diff.ts +9 -0
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +32 -7
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +4 -4
- package/src/cli/handlers/lifecycle.test.ts +345 -1
- package/src/cli/handlers/lifecycle.ts +300 -11
- package/src/cli/handlers/search.ts +5 -2
- package/src/cli/main.ts +12 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +7 -0
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/lexicon-regen.ts +19 -1
- package/src/codegen/validate.test.ts +33 -0
- package/src/codegen/validate.ts +21 -2
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +55 -0
- package/src/config.ts +88 -1
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +227 -2
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +568 -0
- package/src/lifecycle/teardown.ts +359 -0
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +10 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +179 -8
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -42,6 +42,29 @@ describe("discoverByDetection (unified, detectTemplate-driven)", () => {
|
|
|
42
42
|
expect(found.some((i) => i.lexicon === "k8s")).toBe(false);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("fountain wins over k8s in a mixed repo — each manifest classifies to its own lexicon (#1566)", async () => {
|
|
46
|
+
const plugins = await loadAuditPlugins();
|
|
47
|
+
const found = discoverByDetection(fixture("audit-fountain"), plugins);
|
|
48
|
+
expect(targets(found)).toEqual(["fountain:agents/fleet.yaml", "k8s:k8s/deploy.yaml"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a multi-document fountain file classifies as one fountain input (no document dropped)", async () => {
|
|
52
|
+
const plugins = await loadAuditPlugins();
|
|
53
|
+
const found = discoverByDetection(fixture("audit-fountain"), plugins);
|
|
54
|
+
const fountain = found.find((i) => i.lexicon === "fountain")!;
|
|
55
|
+
// All three documents ride along in the input's content.
|
|
56
|
+
expect(fountain.content.match(/apiVersion: fountain\.dev\/v1/g)).toHaveLength(3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("without the fountain plugin, fountain YAML falls to k8s (not-installed convention)", () => {
|
|
60
|
+
const files = [{ path: "fleet.yaml", content: "apiVersion: fountain.dev/v1\nkind: Environment\nmetadata:\n name: dev\n" }];
|
|
61
|
+
const k8s = { name: "k8s", detectTemplate: (d: unknown) => !!d && typeof d === "object" && "apiVersion" in (d as object) && "kind" in (d as object) };
|
|
62
|
+
expect(classifyFiles(files, [k8s]).map((i) => i.lexicon)).toEqual(["k8s"]);
|
|
63
|
+
// with a fountain plugin present, fountain claims it first
|
|
64
|
+
const fountain = { name: "fountain", detectTemplate: (d: unknown) => (d as { apiVersion?: unknown })?.apiVersion === "fountain.dev/v1" };
|
|
65
|
+
expect(classifyFiles(files, [k8s, fountain]).map((i) => i.lexicon)).toEqual(["fountain"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
45
68
|
test("detects a Helm chart as a bundle, not loose manifests", async () => {
|
|
46
69
|
const plugins = await loadAuditPlugins();
|
|
47
70
|
const found = discoverByDetection(fixture("audit-helm"), plugins);
|
|
@@ -80,6 +103,7 @@ describe("lexicon hints for unclaimed files (#1623)", () => {
|
|
|
80
103
|
expect(hintLexiconForFile("charts/x/Chart.yaml", "name: x\n")).toBe("helm");
|
|
81
104
|
expect(hintLexiconForFile("infra/main.tf", "resource {}\n")).toBe("terraform");
|
|
82
105
|
expect(hintLexiconForFile("k8s/deploy.yaml", "apiVersion: apps/v1\nkind: Deployment\n")).toBe("k8s");
|
|
106
|
+
expect(hintLexiconForFile("agents/fleet.yaml", "apiVersion: fountain.dev/v1\nkind: Environment\n")).toBe("fountain");
|
|
83
107
|
expect(hintLexiconForFile("gcp/bucket.yaml", "apiVersion: storage.cnrm.cloud.google.com/v1beta1\nkind: StorageBucket\n")).toBe("gcp");
|
|
84
108
|
expect(hintLexiconForFile("cfn/stack.yaml", "AWSTemplateFormatVersion: '2010-09-09'\nResources: {}\n")).toBe("aws");
|
|
85
109
|
expect(hintLexiconForFile("cfn/stack.json", '{"Resources":{"B":{"Type":"AWS::S3::Bucket"}}}')).toBe("aws");
|
package/src/audit/discover.ts
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* - Helm charts are a directory BUNDLE keyed by `Chart.yaml`; the helm checks
|
|
23
23
|
* read `output.files`, so the whole chart is one AuditInput.
|
|
24
24
|
* - k8s `detectTemplate` matches any `apiVersion`+`kind`, including GCP Config
|
|
25
|
-
* Connector (`cnrm.cloud.google.com`) resources
|
|
25
|
+
* Connector (`cnrm.cloud.google.com`) resources and fountain
|
|
26
|
+
* (`fountain.dev/v1`) manifests, so gcp and fountain must be tried first.
|
|
26
27
|
* - aws/azure checks `JSON.parse` their input, so YAML/templated content is
|
|
27
28
|
* normalized to a JSON string here.
|
|
28
29
|
*/
|
|
@@ -34,7 +35,7 @@ import type { LexiconPlugin } from "../lexicon";
|
|
|
34
35
|
import type { AuditInput, AuditLexicon } from "./core";
|
|
35
36
|
|
|
36
37
|
/** Lexicons the auditor knows how to detect and run checks for. */
|
|
37
|
-
export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm"] as const;
|
|
38
|
+
export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm", "fountain"] as const;
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Load the plugins used for detection. Each is loaded in isolation (no
|
|
@@ -170,6 +171,13 @@ const CONTENT_DETECTORS: ContentDetector[] = [
|
|
|
170
171
|
accepts: isYaml,
|
|
171
172
|
detect: (plugin, _name, content) => (anyDoc(plugin, content) ? content : null),
|
|
172
173
|
},
|
|
174
|
+
{
|
|
175
|
+
// fountain (`apiVersion: fountain.dev/v1`) — must win over k8s, whose
|
|
176
|
+
// detectTemplate matches any apiVersion+kind document (#1566).
|
|
177
|
+
lexicon: "fountain",
|
|
178
|
+
accepts: isYaml,
|
|
179
|
+
detect: (plugin, _name, content) => (anyDoc(plugin, content) ? content : null),
|
|
180
|
+
},
|
|
173
181
|
{
|
|
174
182
|
lexicon: "k8s",
|
|
175
183
|
accepts: isYaml,
|
|
@@ -263,6 +271,7 @@ export function hintLexiconForFile(path: string, content: string): LexiconHint |
|
|
|
263
271
|
if (/\.tf$/i.test(name)) return "terraform";
|
|
264
272
|
const head = content.slice(0, 64 * 1024);
|
|
265
273
|
if (/cnrm\.cloud\.google\.com/.test(head)) return "gcp";
|
|
274
|
+
if (/fountain\.dev\/v1/.test(head)) return "fountain";
|
|
266
275
|
if (/AWSTemplateFormatVersion|["']?Type["']?\s*:\s*["']AWS::/.test(head)) return "aws";
|
|
267
276
|
if (/deploymentTemplate\.json/.test(head)) return "azure";
|
|
268
277
|
if (isYaml(name)) {
|
package/src/audit/rules-doc.ts
CHANGED
|
@@ -20,14 +20,24 @@ const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
|
|
|
20
20
|
{ heading: "Azure ARM (AZR)", prefixes: ["AZR"], blurb: "Run against ARM deployment templates (JSON)." },
|
|
21
21
|
{ heading: "GCP Config Connector (WGC)", prefixes: ["WGC"], blurb: "Run against Config Connector (cnrm.cloud.google.com) manifests." },
|
|
22
22
|
{ heading: "Helm (WHM)", prefixes: ["WHM"], blurb: "Run against Helm charts (Chart.yaml + templates)." },
|
|
23
|
+
{ heading: "fountain (FTN)", prefixes: ["FTN"], blurb: "Run against fountain manifests (`apiVersion: fountain.dev/v1`) — standalone `fountain apply` YAML is parsed back into the entity graph, so the same rules fire on `chant build` and `chant audit`." },
|
|
23
24
|
];
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* MDX treats `{expr}` as a JavaScript expression and `<x` as JSX, so rule
|
|
28
|
+
* text containing literal braces (`${VAR}` substitution references) or angle
|
|
29
|
+
* brackets must be escaped or the docs build fails at render time.
|
|
30
|
+
*/
|
|
31
|
+
function mdxEscape(text: string): string {
|
|
32
|
+
return text.replace(/\{/g, "\\{").replace(/</g, "\\<");
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
function ruleBlock(m: RuleMeta): string {
|
|
26
36
|
const tags = `${m.tier} · ${m.fixKind}`;
|
|
27
37
|
const authority = m.authority?.length
|
|
28
38
|
? `\n\nAuthority: ${m.authority.map((a) => `[${a.name}](${a.url})`).join(" · ")}`
|
|
29
39
|
: "";
|
|
30
|
-
return `### ${m.id}\n\n**${m.title}** — ${tags}\n\n${m.remediation}${authority}`;
|
|
40
|
+
return `### ${m.id}\n\n**${mdxEscape(m.title)}** — ${tags}\n\n${mdxEscape(m.remediation)}${authority}`;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/**
|
package/src/build.test.ts
CHANGED
|
@@ -338,6 +338,47 @@ export const discovered = {
|
|
|
338
338
|
expect(result.entities.get("discovered")?.entityType).toBe("Discovered");
|
|
339
339
|
});
|
|
340
340
|
|
|
341
|
+
test("a contributor sees the discovered entities, read-only", async () => {
|
|
342
|
+
await writeFile(
|
|
343
|
+
join(testDir, "app.infra.ts"),
|
|
344
|
+
`
|
|
345
|
+
export const discovered = {
|
|
346
|
+
lexicon: "test",
|
|
347
|
+
entityType: "Discovered",
|
|
348
|
+
props: {},
|
|
349
|
+
[Symbol.for("chant.declarable")]: true,
|
|
350
|
+
};
|
|
351
|
+
`,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
let seenNames: string[] = [];
|
|
355
|
+
const result = await build(testDir, [recordingSerializer([])], undefined, {
|
|
356
|
+
buildRoots: [
|
|
357
|
+
async (ctx) => {
|
|
358
|
+
seenNames = [...(ctx.entities?.keys() ?? [])];
|
|
359
|
+
return { entities: new Map() };
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(result.errors).toEqual([]);
|
|
365
|
+
expect(seenNames).toContain("discovered");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("a later contributor sees what an earlier one contributed", async () => {
|
|
369
|
+
const seen: string[][] = [];
|
|
370
|
+
await build(testDir, [recordingSerializer([])], undefined, {
|
|
371
|
+
buildRoots: [
|
|
372
|
+
async () => ({ entities: new Map([["first", entity("First")]]) }),
|
|
373
|
+
async (ctx) => {
|
|
374
|
+
seen.push([...(ctx.entities?.keys() ?? [])]);
|
|
375
|
+
return { entities: new Map() };
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
expect(seen[0]).toContain("first");
|
|
380
|
+
});
|
|
381
|
+
|
|
341
382
|
test("contributors run exactly once per build", async () => {
|
|
342
383
|
let calls = 0;
|
|
343
384
|
await build(testDir, [], undefined, {
|
package/src/build.ts
CHANGED
|
@@ -2,10 +2,12 @@ import type { Declarable } from "./declarable";
|
|
|
2
2
|
import type { Serializer, SerializerResult } from "./serializer";
|
|
3
3
|
import type { OwnershipMarker } from "./ownership";
|
|
4
4
|
import type { BuildError, DiscoveryErrorType } from "./errors";
|
|
5
|
-
import type { IntrinsicDef, BuildRootContribution } from "./lexicon";
|
|
5
|
+
import type { IntrinsicDef, BuildRootContribution, BuildRootContributor } from "./lexicon";
|
|
6
6
|
import type { BuildParamProvenance } from "./provenance";
|
|
7
7
|
import { DiscoveryError, BuildError as BuildErrorClass } from "./errors";
|
|
8
8
|
import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
|
|
9
|
+
import { isSecretDeclaration } from "./secret-provenance";
|
|
10
|
+
import { splitReceiptEntities } from "./effect-receipt";
|
|
9
11
|
import { AttrRef } from "./attrref";
|
|
10
12
|
import { isAttrRefLike } from "./utils";
|
|
11
13
|
import { isChildProject, type ChildProjectInstance } from "./child-project";
|
|
@@ -112,7 +114,9 @@ export function computeStackGraph(
|
|
|
112
114
|
// Dependency map: node → producers it depends on.
|
|
113
115
|
const nodes = lexiconNames.length
|
|
114
116
|
? [...lexiconNames]
|
|
115
|
-
:
|
|
117
|
+
: // Secret provenance declarations (#1828) never form a stack — their
|
|
118
|
+
// pseudo-lexicon has no serializer and must not appear in the manifest.
|
|
119
|
+
[...new Set([...entities.values()].filter((e) => !isSecretDeclaration(e)).map((e) => e.lexicon))];
|
|
116
120
|
const deps = new Map<string, Set<string>>();
|
|
117
121
|
for (const n of nodes) deps.set(n, new Set());
|
|
118
122
|
for (const { from, to } of edges) {
|
|
@@ -229,7 +233,7 @@ export interface BuildOptions {
|
|
|
229
233
|
* binaries); a contributed name colliding with a discovered entity is a
|
|
230
234
|
* build error, never a silent overwrite.
|
|
231
235
|
*/
|
|
232
|
-
buildRoots?:
|
|
236
|
+
buildRoots?: BuildRootContributor[];
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
export interface BuildResult {
|
|
@@ -294,6 +298,11 @@ export function partitionByLexicon(
|
|
|
294
298
|
for (const [name, entity] of entities) {
|
|
295
299
|
// LexiconOutput instances are collected separately; skip them here
|
|
296
300
|
if (isLexiconOutput(entity)) continue;
|
|
301
|
+
// Secret provenance declarations (#1828) are serializer-neutral: data
|
|
302
|
+
// that lint and lexicons read from the entity map, never output. Keeping
|
|
303
|
+
// them out of every partition means no serializer sees them and no
|
|
304
|
+
// "No serializer found" warning fires for their pseudo-lexicon.
|
|
305
|
+
if (isSecretDeclaration(entity)) continue;
|
|
297
306
|
const lexicon = entity.lexicon;
|
|
298
307
|
if (!partitions.has(lexicon)) {
|
|
299
308
|
partitions.set(lexicon, new Map());
|
|
@@ -590,13 +599,18 @@ export interface BuildRootMergeResult {
|
|
|
590
599
|
*/
|
|
591
600
|
export async function mergeBuildRootEntities(
|
|
592
601
|
entities: Map<string, Declarable>,
|
|
593
|
-
contributors: ReadonlyArray<
|
|
602
|
+
contributors: ReadonlyArray<BuildRootContributor>,
|
|
594
603
|
): Promise<BuildRootMergeResult> {
|
|
595
604
|
const warnings: string[] = [];
|
|
596
605
|
const errors: string[] = [];
|
|
597
606
|
for (const contribute of contributors) {
|
|
598
607
|
try {
|
|
599
|
-
|
|
608
|
+
// The discovered set, read-only, so a contributor can react to what the
|
|
609
|
+
// project DECLARED (a committed-encrypted `declareSecret()` naming a
|
|
610
|
+
// ciphertext file to resolve). Contributors run in order, so this also
|
|
611
|
+
// carries what earlier contributors added; the merge below is still the
|
|
612
|
+
// only writer, which is what keeps the collision refusal meaningful.
|
|
613
|
+
const contribution = await contribute({ entities });
|
|
600
614
|
warnings.push(...(contribution.warnings ?? []));
|
|
601
615
|
for (const [name, entity] of contribution.entities) {
|
|
602
616
|
if (entities.has(name)) {
|
|
@@ -785,20 +799,34 @@ async function buildFromDiscoveryResult(
|
|
|
785
799
|
const outputs = new Map<string, string | SerializerResult>();
|
|
786
800
|
for (const [lexiconName, lexiconEntities] of partitions) {
|
|
787
801
|
const serializer = serializersByName.get(lexiconName);
|
|
802
|
+
// #1832: effect receipts are withheld from the apply-bound entity set at
|
|
803
|
+
// this seam — the narrowest choke point every applier's input flows
|
|
804
|
+
// through, since appliers consume the serialized outputs assembled here.
|
|
805
|
+
// Receipts ride `SerializeContext.receipts` instead, so a lexicon can
|
|
806
|
+
// render them for visibility (#1835) without them ever entering the
|
|
807
|
+
// document an applier writes (or prunes) from. The `effect()` step is the
|
|
808
|
+
// sole receipt writer (epic #1703, decision 3).
|
|
809
|
+
const { applyBound, receipts } = splitReceiptEntities(lexiconEntities);
|
|
788
810
|
if (serializer) {
|
|
789
811
|
const lexiconLexiconOutputs = [
|
|
790
812
|
...(outputsByLexicon.get(lexiconName) ?? []),
|
|
791
813
|
...unassignedOutputs,
|
|
792
814
|
];
|
|
793
|
-
const serialized = serializer.serialize(
|
|
815
|
+
const serialized = serializer.serialize(applyBound, lexiconLexiconOutputs, {
|
|
794
816
|
ownership: options?.ownership,
|
|
795
817
|
config: options?.config,
|
|
818
|
+
...(receipts.size > 0 ? { receipts } : {}),
|
|
796
819
|
});
|
|
797
820
|
// Collect any non-fatal serializer diagnostics into the build warnings.
|
|
798
821
|
if (typeof serialized !== "string" && serialized.warnings) {
|
|
799
822
|
for (const w of serialized.warnings) warnings.push(w);
|
|
800
823
|
}
|
|
801
824
|
outputs.set(lexiconName, serialized);
|
|
825
|
+
} else if (applyBound.size === 0 && receipts.size > 0) {
|
|
826
|
+
// A partition holding only receipts (the core factory's "chant"
|
|
827
|
+
// pseudo-lexicon) has nothing apply-bound to serialize; that is the
|
|
828
|
+
// designed shape until a lexicon materializes the receipt (#1835), not
|
|
829
|
+
// a missing-serializer problem.
|
|
802
830
|
} else {
|
|
803
831
|
warnings.push(`No serializer found for lexicon "${lexiconName}"`);
|
|
804
832
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
apiVersion: fountain.dev/v1
|
|
2
|
+
kind: Environment
|
|
3
|
+
metadata:
|
|
4
|
+
name: dev
|
|
5
|
+
spec:
|
|
6
|
+
networking_type: unrestricted
|
|
7
|
+
env_vars:
|
|
8
|
+
API_URL: https://api.example.com
|
|
9
|
+
AWS_SECRET_ACCESS_KEY: not-a-real-secret
|
|
10
|
+
---
|
|
11
|
+
apiVersion: fountain.dev/v1
|
|
12
|
+
kind: Vault
|
|
13
|
+
metadata:
|
|
14
|
+
name: staging
|
|
15
|
+
spec:
|
|
16
|
+
secrets:
|
|
17
|
+
- key: API_URL
|
|
18
|
+
value: https://staging.example.com
|
|
19
|
+
---
|
|
20
|
+
apiVersion: fountain.dev/v1
|
|
21
|
+
kind: Agent
|
|
22
|
+
metadata:
|
|
23
|
+
name: researcher
|
|
24
|
+
spec:
|
|
25
|
+
model: anthropic/claude-sonnet-4-6
|
|
26
|
+
runtime: claude
|
|
27
|
+
environment: dev
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
apiVersion: fountain.dev/v1
|
|
2
|
+
kind: Environment
|
|
3
|
+
metadata:
|
|
4
|
+
name: dev
|
|
5
|
+
spec:
|
|
6
|
+
networking_type: limited
|
|
7
|
+
networking_config:
|
|
8
|
+
allowed_hosts:
|
|
9
|
+
- github.com
|
|
10
|
+
env_vars:
|
|
11
|
+
API_URL: https://api.example.com
|
|
12
|
+
---
|
|
13
|
+
apiVersion: fountain.dev/v1
|
|
14
|
+
kind: Agent
|
|
15
|
+
metadata:
|
|
16
|
+
name: researcher
|
|
17
|
+
spec:
|
|
18
|
+
model: anthropic/claude-sonnet-4-6
|
|
19
|
+
runtime: claude
|
|
20
|
+
environment: dev
|
|
@@ -105,6 +105,59 @@ describe("auditCommand", () => {
|
|
|
105
105
|
expect([...ids].some((id) => id.startsWith("WK8"))).toBe(false);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test("discovers and audits fountain manifests (not misclassified as k8s) — FTN rules fire (#1566/#1567)", async () => {
|
|
109
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain", import.meta.url));
|
|
110
|
+
const fountain = await discoverLexicon(repo, "fountain");
|
|
111
|
+
expect(fountain.map((f) => f.path)).toEqual(["agents/fleet.yaml"]);
|
|
112
|
+
// The fountain manifest is NOT also picked up as k8s; the plain k8s manifest still is.
|
|
113
|
+
expect((await discoverLexicon(repo, "k8s")).map((f) => f.path)).toEqual(["k8s/deploy.yaml"]);
|
|
114
|
+
|
|
115
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
const ftn = result.findings.filter((f) => f.checkId.startsWith("FTN"));
|
|
118
|
+
const ids = new Set(ftn.map((f) => f.checkId));
|
|
119
|
+
expect(ids).toContain("FTN011"); // unrestricted networking
|
|
120
|
+
expect(ids).toContain("FTN012"); // credential-shaped env_vars key
|
|
121
|
+
expect(ids).toContain("FTN014"); // vault key shadows an environment key
|
|
122
|
+
expect(ftn.find((f) => f.checkId === "FTN012")!.severity).toBe("error");
|
|
123
|
+
for (const f of ftn) {
|
|
124
|
+
expect(f.lexicon).toBe("fountain");
|
|
125
|
+
expect(f.file).toBe("agents/fleet.yaml");
|
|
126
|
+
}
|
|
127
|
+
// No k8s check ran against the fountain manifest.
|
|
128
|
+
for (const f of result.findings.filter((f) => f.lexicon === "k8s")) {
|
|
129
|
+
expect(f.file).not.toBe("agents/fleet.yaml");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fountain findings carry their authority citations in json, sarif, and markdown", async () => {
|
|
134
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain", import.meta.url));
|
|
135
|
+
|
|
136
|
+
const json = await auditCommand({ path: repo, format: "json" });
|
|
137
|
+
const envelope = JSON.parse(json.output) as { findings: Array<{ checkId: string; authority: Array<{ url: string }>; severity: string }> };
|
|
138
|
+
const ftn11 = envelope.findings.find((f) => f.checkId === "FTN011")!;
|
|
139
|
+
expect(ftn11.authority.length).toBeGreaterThan(0);
|
|
140
|
+
expect(ftn11.authority[0].url).toContain("fountain");
|
|
141
|
+
expect(envelope.findings.find((f) => f.checkId === "FTN012")!.severity).toBe("error");
|
|
142
|
+
|
|
143
|
+
const sarif = await auditCommand({ path: repo, format: "sarif" });
|
|
144
|
+
const run = (JSON.parse(sarif.output) as { runs: Array<{ tool: { driver: { rules: Array<{ id: string; helpUri?: string }> } }; results: Array<{ ruleId: string }> }> }).runs[0];
|
|
145
|
+
expect(run.results.some((r) => r.ruleId === "FTN011")).toBe(true);
|
|
146
|
+
expect(run.tool.driver.rules.find((r) => r.id === "FTN011")!.helpUri).toContain("fountain");
|
|
147
|
+
|
|
148
|
+
const md = await auditCommand({ path: repo, format: "markdown" });
|
|
149
|
+
expect(md.output).toContain("FTN011");
|
|
150
|
+
expect(md.output).toContain("FTN014");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a clean fountain manifest set audits quiet", async () => {
|
|
154
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain-clean", import.meta.url));
|
|
155
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
expect(result.findings).toEqual([]);
|
|
158
|
+
expect(result.scanned).toEqual(["fleet.yaml"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
108
161
|
test("discovers and audits a Helm chart (as a bundle, not loose manifests)", async () => {
|
|
109
162
|
const repo = fileURLToPath(new URL("./__fixtures__/audit-helm", import.meta.url));
|
|
110
163
|
const charts = await discoverLexicon(repo, "helm");
|
|
@@ -123,7 +123,7 @@ function renderNoLexicons(target: string, unclaimed: UnclaimedFile[]): string {
|
|
|
123
123
|
lines.push(` ${u.path} -> ${note}`);
|
|
124
124
|
}
|
|
125
125
|
} else {
|
|
126
|
-
lines.push("", "No file under the target looked like CI, Kubernetes, Helm, Docker, CloudFormation, ARM,
|
|
126
|
+
lines.push("", "No file under the target looked like CI, Kubernetes, Helm, Docker, CloudFormation, ARM, Config Connector, or fountain either.");
|
|
127
127
|
}
|
|
128
128
|
lines.push("", "Run it with the lexicons those files need:");
|
|
129
129
|
lines.push(` ${installLine(wanted.length > 0 ? wanted : [...AUDIT_LEXICONS], target)}`);
|
|
@@ -309,6 +309,137 @@ export const testEntity = {
|
|
|
309
309
|
});
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
+
describe("#1221 — dynamic-env legality against declared environments", () => {
|
|
313
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
314
|
+
const runtimePath = resolvePath(thisDir, "../../runtime");
|
|
315
|
+
const paramsPath = resolvePath(thisDir, "../../params");
|
|
316
|
+
|
|
317
|
+
// Serializer exposing the physical name (from params.env interpolation)
|
|
318
|
+
// next to the ownership marker, so one test can assert both are disjoint
|
|
319
|
+
// across two builds.
|
|
320
|
+
const namingSerializer: Serializer = {
|
|
321
|
+
name: "aws",
|
|
322
|
+
rulePrefix: "TEST",
|
|
323
|
+
serialize: (entities, _outputs, context) =>
|
|
324
|
+
JSON.stringify(
|
|
325
|
+
[...entities.values()].map((e) => ({
|
|
326
|
+
name: (e as unknown as { props: { name: string } }).props.name,
|
|
327
|
+
marker: context?.ownership,
|
|
328
|
+
})),
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
async function writeProject(environments: string) {
|
|
333
|
+
await writeFile(
|
|
334
|
+
join(testDir, "chant.config.ts"),
|
|
335
|
+
`
|
|
336
|
+
export default {
|
|
337
|
+
environments: ${environments},
|
|
338
|
+
ownership: { stack: "billing", env: { param: "env" } },
|
|
339
|
+
buildParams: {
|
|
340
|
+
env: { type: "string", default: "dev" },
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
`,
|
|
344
|
+
);
|
|
345
|
+
await writeFile(
|
|
346
|
+
join(testDir, "resources.ts"),
|
|
347
|
+
`
|
|
348
|
+
import { createResource } from ${JSON.stringify(runtimePath)};
|
|
349
|
+
export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
|
|
350
|
+
`,
|
|
351
|
+
);
|
|
352
|
+
await writeFile(
|
|
353
|
+
join(testDir, "main.ts"),
|
|
354
|
+
`
|
|
355
|
+
import { Bucket } from "./resources";
|
|
356
|
+
import { params } from ${JSON.stringify(paramsPath)};
|
|
357
|
+
export const uploads = new Bucket({ name: \`billing-\${params.env}-uploads\` });
|
|
358
|
+
`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function built(): Array<{ name: string; marker?: { stack: string; env?: string } }> {
|
|
363
|
+
return JSON.parse(readFileSync(outputFile, "utf-8"));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function buildWithEnv(env: string) {
|
|
367
|
+
return buildCommand({
|
|
368
|
+
path: testDir,
|
|
369
|
+
output: outputFile,
|
|
370
|
+
format: "json",
|
|
371
|
+
serializers: [namingSerializer],
|
|
372
|
+
params: { env },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
test("--param env=pr-42 is legal when a pr-* pattern entry is declared", async () => {
|
|
377
|
+
await writeProject(`["dev", "prod", "pr-*"]`);
|
|
378
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
379
|
+
try {
|
|
380
|
+
const result = await buildWithEnv("pr-42");
|
|
381
|
+
expect(result.errors).toEqual([]);
|
|
382
|
+
expect(result.success).toBe(true);
|
|
383
|
+
expect(built()).toEqual([{ name: "billing-pr-42-uploads", marker: { stack: "billing", env: "pr-42" } }]);
|
|
384
|
+
} finally {
|
|
385
|
+
errorSpy.mockRestore();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("--param env outside the declared entries (no pattern covers it) fails the build", async () => {
|
|
390
|
+
await writeProject(`["dev", "prod"]`);
|
|
391
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
392
|
+
try {
|
|
393
|
+
const result = await buildWithEnv("pr-42");
|
|
394
|
+
expect(result.success).toBe(false);
|
|
395
|
+
expect(result.errors.some((e) => e.includes('Unknown environment "pr-42"'))).toBe(true);
|
|
396
|
+
} finally {
|
|
397
|
+
errorSpy.mockRestore();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a literal entry still matches by equality", async () => {
|
|
402
|
+
await writeProject(`["dev", "prod", "pr-*"]`);
|
|
403
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
404
|
+
try {
|
|
405
|
+
const result = await buildWithEnv("prod");
|
|
406
|
+
expect(result.errors).toEqual([]);
|
|
407
|
+
expect(built()).toEqual([{ name: "billing-prod-uploads", marker: { stack: "billing", env: "prod" } }]);
|
|
408
|
+
} finally {
|
|
409
|
+
errorSpy.mockRestore();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("two builds with different --param env yield disjoint names and disjoint markers", async () => {
|
|
414
|
+
await writeProject(`["dev", "prod", "pr-*"]`);
|
|
415
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
416
|
+
try {
|
|
417
|
+
expect((await buildWithEnv("pr-1")).success).toBe(true);
|
|
418
|
+
const first = built();
|
|
419
|
+
expect((await buildWithEnv("pr-2")).success).toBe(true);
|
|
420
|
+
const second = built();
|
|
421
|
+
expect(first).toEqual([{ name: "billing-pr-1-uploads", marker: { stack: "billing", env: "pr-1" } }]);
|
|
422
|
+
expect(second).toEqual([{ name: "billing-pr-2-uploads", marker: { stack: "billing", env: "pr-2" } }]);
|
|
423
|
+
expect(first[0].name).not.toBe(second[0].name);
|
|
424
|
+
expect(first[0].marker?.env).not.toBe(second[0].marker?.env);
|
|
425
|
+
} finally {
|
|
426
|
+
errorSpy.mockRestore();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a project with no declared environments accepts any dynamic value, as before", async () => {
|
|
431
|
+
await writeProject(`undefined`);
|
|
432
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
433
|
+
try {
|
|
434
|
+
const result = await buildWithEnv("anything-goes");
|
|
435
|
+
expect(result.errors).toEqual([]);
|
|
436
|
+
expect(built()).toEqual([{ name: "billing-anything-goes-uploads", marker: { stack: "billing", env: "anything-goes" } }]);
|
|
437
|
+
} finally {
|
|
438
|
+
errorSpy.mockRestore();
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
312
443
|
test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
|
|
313
444
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
314
445
|
const runtimePath = resolvePath(thisDir, "../../runtime");
|
|
@@ -574,6 +705,86 @@ export const testEntity = {
|
|
|
574
705
|
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
|
|
575
706
|
});
|
|
576
707
|
|
|
708
|
+
describe("committed-encrypted secret sidecars", () => {
|
|
709
|
+
const CIPHERTEXT = 'apiVersion: v1\nkind: Secret\nstringData:\n T: ENC[AES256_GCM,data:xx]\nsops:\n version: 3.9.4\n';
|
|
710
|
+
|
|
711
|
+
/** A serializer standing in for the k8s one: a sidecar plus a primary doc. */
|
|
712
|
+
const sidecarSerializer: Serializer = {
|
|
713
|
+
name: "multi",
|
|
714
|
+
rulePrefix: "MULTI",
|
|
715
|
+
serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": CIPHERTEXT } }),
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
async function writeProject(fileValue: string): Promise<void> {
|
|
719
|
+
const modulePath = resolvePath(
|
|
720
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
721
|
+
"..",
|
|
722
|
+
"..",
|
|
723
|
+
"secret-provenance",
|
|
724
|
+
);
|
|
725
|
+
await writeFile(
|
|
726
|
+
join(testDir, "infra.ts"),
|
|
727
|
+
`import { declareSecret } from ${JSON.stringify(modulePath)};
|
|
728
|
+
export const dbCredentials = declareSecret({ name: "db-credentials", provenance: "committed-encrypted", file: ${JSON.stringify(fileValue)} });
|
|
729
|
+
export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexicon: "multi", kind: "resource", props: {}, attributes: {} };`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
test("a build with no --output refuses, naming the flag", async () => {
|
|
734
|
+
await writeProject("secrets/db.sops.yaml");
|
|
735
|
+
|
|
736
|
+
const result = await buildCommand({
|
|
737
|
+
path: testDir,
|
|
738
|
+
format: "yaml",
|
|
739
|
+
serializers: [sidecarSerializer],
|
|
740
|
+
// no `output` — the sidecar would be echoed to stderr and lost
|
|
741
|
+
} as BuildOptions);
|
|
742
|
+
|
|
743
|
+
expect(result.success).toBe(false);
|
|
744
|
+
expect(result.errors.join("\n")).toContain("--output");
|
|
745
|
+
expect(result.errors.join("\n")).toContain("db.sops.yaml");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("with --output the sidecar lands beside the primary, byte for byte", async () => {
|
|
749
|
+
await writeProject("secrets/db.sops.yaml");
|
|
750
|
+
const outputPath = join(testDir, "dist", "manifests.yaml");
|
|
751
|
+
|
|
752
|
+
const result = await buildCommand({
|
|
753
|
+
path: testDir,
|
|
754
|
+
output: outputPath,
|
|
755
|
+
format: "yaml",
|
|
756
|
+
serializers: [sidecarSerializer],
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
expect(result.errors).toEqual([]);
|
|
760
|
+
expect(readFileSync(join(testDir, "dist", "db.sops.yaml"), "utf-8")).toBe(CIPHERTEXT);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a declared sidecar is never round-tripped through JSON.parse", async () => {
|
|
764
|
+
// YAML is a JSON superset, so a ciphertext file that happens to parse as
|
|
765
|
+
// JSON would otherwise be rewritten key-sorted (or re-emitted as YAML),
|
|
766
|
+
// breaking the `sops` MAC. Declared files skip that path entirely.
|
|
767
|
+
const jsonish = '{"b": 1, "a": 2}\n';
|
|
768
|
+
const jsonSidecar: Serializer = {
|
|
769
|
+
name: "multi",
|
|
770
|
+
rulePrefix: "MULTI",
|
|
771
|
+
serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": jsonish } }),
|
|
772
|
+
};
|
|
773
|
+
await writeProject("secrets/db.sops.yaml");
|
|
774
|
+
const outputPath = join(testDir, "dist", "manifests.yaml");
|
|
775
|
+
|
|
776
|
+
const result = await buildCommand({
|
|
777
|
+
path: testDir,
|
|
778
|
+
output: outputPath,
|
|
779
|
+
format: "yaml",
|
|
780
|
+
serializers: [jsonSidecar],
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
expect(result.errors).toEqual([]);
|
|
784
|
+
expect(readFileSync(join(testDir, "dist", "db.sops.yaml"), "utf-8")).toBe(jsonish);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
577
788
|
test("op worker files go to <project>/dist/ops even with no --output (#878)", async () => {
|
|
578
789
|
// The generated Op worker must land where `chant run <op> --temporal` reads it
|
|
579
790
|
// (`<project>/dist/ops/<name>/worker.ts`) even when the build has no --output
|