@intentius/chant-lexicon-k8s 0.46.0 → 0.50.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/components/kubectl-apply.d.ts +5 -0
- package/dist/components/kubectl-apply.d.ts.map +1 -1
- package/dist/composites/flux-app.d.ts +12 -0
- package/dist/composites/flux-app.d.ts.map +1 -1
- package/dist/composites/index.d.ts +8 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/inference-service.d.ts +86 -0
- package/dist/composites/inference-service.d.ts.map +1 -0
- package/dist/composites/model.d.ts +80 -0
- package/dist/composites/model.d.ts.map +1 -0
- package/dist/composites/operator-stack.d.ts +183 -0
- package/dist/composites/operator-stack.d.ts.map +1 -0
- package/dist/composites/vllm-serving-runtime.d.ts +100 -0
- package/dist/composites/vllm-serving-runtime.d.ts.map +1 -0
- package/dist/crd/crd-sources.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/generated/index.d.ts +167 -40
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/group-namespace.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +41 -33
- package/dist/kube/apply.d.ts.map +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/k8s-helpers.d.ts +56 -6
- package/dist/lint/post-synth/k8s-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/sops-helpers.d.ts +38 -0
- package/dist/lint/post-synth/sops-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8005.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8006.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8041.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8042.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8101.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8102.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8103.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8104.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8105.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8201.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8202.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8203.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8204.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8205.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8207.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8208.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8209.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8301.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8302.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8303.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8304.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8305.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8306.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8401.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8402.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8403.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8404.d.ts +12 -0
- package/dist/lint/post-synth/wk8404.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8405.d.ts +13 -0
- package/dist/lint/post-synth/wk8405.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8406.d.ts +12 -0
- package/dist/lint/post-synth/wk8406.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8407.d.ts +21 -0
- package/dist/lint/post-synth/wk8407.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8503.d.ts +39 -0
- package/dist/lint/post-synth/wk8503.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8504.d.ts +28 -0
- package/dist/lint/post-synth/wk8504.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8505.d.ts +28 -0
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +1039 -68
- package/dist/okf/index.md +26 -2
- package/dist/okf/rules/FLUX002.md +0 -1
- package/dist/okf/rules/WK8404.md +11 -0
- package/dist/okf/rules/WK8405.md +17 -0
- package/dist/okf/rules/WK8406.md +11 -0
- package/dist/okf/rules/WK8407.md +15 -0
- package/dist/okf/rules/WK8503.md +17 -0
- package/dist/okf/rules/WK8504.md +11 -0
- package/dist/okf/rules/WK8505.md +15 -0
- package/dist/okf/types/AWSClusterControllerIdentity.md +14 -0
- package/dist/okf/types/AWSManagedCluster.md +14 -0
- package/dist/okf/types/AWSManagedControlPlane.md +14 -0
- package/dist/okf/types/AWSManagedMachinePool.md +14 -0
- package/dist/okf/types/CAPICluster.md +14 -0
- package/dist/okf/types/Certificate.md +1 -0
- package/dist/okf/types/ClusterResourceSet.md +14 -0
- package/dist/okf/types/ClusterServingRuntime.md +14 -0
- package/dist/okf/types/{Cluster.md → CnpgCluster.md} +2 -2
- package/dist/okf/types/DBInstance.md +14 -0
- package/dist/okf/types/{Bucket.md → FluxBucket.md} +2 -6
- package/dist/okf/types/HelmChartProxy.md +14 -0
- package/dist/okf/types/IamRole.md +14 -0
- package/dist/okf/types/InferenceService.md +19 -0
- package/dist/okf/types/InfisicalDynamicSecret.md +4 -0
- package/dist/okf/types/InfisicalSecret.md +4 -0
- package/dist/okf/types/Kustomization.md +1 -0
- package/dist/okf/types/MachinePool.md +14 -0
- package/dist/okf/types/PDB.md +1 -0
- package/dist/okf/types/PodIdentityAssociation.md +14 -0
- package/dist/okf/types/Policy.md +14 -0
- package/dist/okf/types/S3Bucket.md +14 -0
- package/dist/okf/types/ServingRuntime.md +14 -0
- package/dist/okf/types/StatefulSet.md +1 -0
- package/dist/okf/types/User.md +14 -0
- package/dist/op/activities/ensure-secret.d.ts +41 -0
- package/dist/op/activities/ensure-secret.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +9 -0
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/op/builders.d.ts +69 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/argo-helpers.ts +2 -6
- package/dist/rules/crd-schema-helpers.ts +4 -6
- package/dist/rules/k8s-helpers.ts +87 -24
- package/dist/rules/sops-helpers.ts +76 -0
- package/dist/rules/wk8005.ts +27 -32
- package/dist/rules/wk8006.ts +33 -38
- package/dist/rules/wk8041.ts +19 -24
- package/dist/rules/wk8042.ts +17 -22
- package/dist/rules/wk8101.ts +29 -34
- package/dist/rules/wk8102.ts +13 -18
- package/dist/rules/wk8103.ts +17 -22
- package/dist/rules/wk8104.ts +30 -35
- package/dist/rules/wk8105.ts +16 -21
- package/dist/rules/wk8201.ts +26 -31
- package/dist/rules/wk8202.ts +17 -22
- package/dist/rules/wk8203.ts +17 -22
- package/dist/rules/wk8204.ts +46 -51
- package/dist/rules/wk8205.ts +27 -32
- package/dist/rules/wk8207.ts +14 -19
- package/dist/rules/wk8208.ts +14 -19
- package/dist/rules/wk8209.ts +14 -19
- package/dist/rules/wk8301.ts +25 -30
- package/dist/rules/wk8302.ts +19 -24
- package/dist/rules/wk8303.ts +23 -26
- package/dist/rules/wk8304.ts +35 -40
- package/dist/rules/wk8305.ts +39 -42
- package/dist/rules/wk8306.ts +20 -25
- package/dist/rules/wk8401.ts +59 -64
- package/dist/rules/wk8402.ts +13 -18
- package/dist/rules/wk8403.ts +32 -37
- package/dist/rules/wk8404.ts +49 -0
- package/dist/rules/wk8405.ts +85 -0
- package/dist/rules/wk8406.ts +55 -0
- package/dist/rules/wk8407.ts +66 -0
- package/dist/rules/wk8503.ts +220 -0
- package/dist/rules/wk8504.ts +51 -0
- package/dist/rules/wk8505.ts +63 -0
- package/dist/secret-labels.d.ts +31 -0
- package/dist/secret-labels.d.ts.map +1 -0
- package/dist/secret-store.d.ts +73 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sops/encrypted-secret-file.d.ts +52 -0
- package/dist/sops/encrypted-secret-file.d.ts.map +1 -0
- package/dist/sops/entity.d.ts +31 -0
- package/dist/sops/entity.d.ts.map +1 -0
- package/dist/teardown.d.ts.map +1 -1
- package/dist/types/index.d.ts +261 -9
- package/package.json +4 -4
- package/src/components/kubectl-apply.ts +5 -0
- package/src/composites/composites.test.ts +556 -0
- package/src/composites/flux-app.ts +23 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/inference-service.ts +140 -0
- package/src/composites/model.ts +150 -0
- package/src/composites/operator-stack.ts +385 -0
- package/src/composites/vllm-serving-runtime.test.ts +167 -0
- package/src/composites/vllm-serving-runtime.ts +196 -0
- package/src/crd/cnpg.test.ts +8 -3
- package/src/crd/crd-sources.ts +183 -0
- package/src/crd/parser.test.ts +14 -0
- package/src/deep-observe-hooks.ts +24 -0
- package/src/deep-observe.test.ts +89 -0
- package/src/generated/index.d.ts +261 -9
- package/src/generated/index.ts +167 -40
- package/src/generated/lexicon-k8s.json +1039 -68
- package/src/generated/operations.json +272 -0
- package/src/group-namespace.test.ts +31 -0
- package/src/group-namespace.ts +13 -0
- package/src/index.ts +17 -0
- package/src/kube/apply.ts +5 -0
- package/src/lint/audit-catalog.ts +7 -0
- package/src/lint/post-synth/argo-helpers.ts +2 -6
- package/src/lint/post-synth/crd-schema-helpers.ts +4 -6
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/k8s-helpers.test.ts +26 -9
- package/src/lint/post-synth/k8s-helpers.ts +87 -24
- package/src/lint/post-synth/post-synth.test.ts +826 -0
- package/src/lint/post-synth/sops-helpers.ts +76 -0
- package/src/lint/post-synth/wk8005.ts +27 -32
- package/src/lint/post-synth/wk8006.ts +33 -38
- package/src/lint/post-synth/wk8041.ts +19 -24
- package/src/lint/post-synth/wk8042.ts +17 -22
- package/src/lint/post-synth/wk8101.ts +29 -34
- package/src/lint/post-synth/wk8102.ts +13 -18
- package/src/lint/post-synth/wk8103.ts +17 -22
- package/src/lint/post-synth/wk8104.ts +30 -35
- package/src/lint/post-synth/wk8105.ts +16 -21
- package/src/lint/post-synth/wk8201.ts +26 -31
- package/src/lint/post-synth/wk8202.ts +17 -22
- package/src/lint/post-synth/wk8203.ts +17 -22
- package/src/lint/post-synth/wk8204.ts +46 -51
- package/src/lint/post-synth/wk8205.ts +27 -32
- package/src/lint/post-synth/wk8207.ts +14 -19
- package/src/lint/post-synth/wk8208.ts +14 -19
- package/src/lint/post-synth/wk8209.ts +14 -19
- package/src/lint/post-synth/wk8301.ts +25 -30
- package/src/lint/post-synth/wk8302.ts +19 -24
- package/src/lint/post-synth/wk8303.ts +23 -26
- package/src/lint/post-synth/wk8304.ts +35 -40
- package/src/lint/post-synth/wk8305.ts +39 -42
- package/src/lint/post-synth/wk8306.ts +20 -25
- package/src/lint/post-synth/wk8401.ts +59 -64
- package/src/lint/post-synth/wk8402.ts +13 -18
- package/src/lint/post-synth/wk8403.ts +32 -37
- package/src/lint/post-synth/wk8404.ts +49 -0
- package/src/lint/post-synth/wk8405.ts +85 -0
- package/src/lint/post-synth/wk8406.ts +55 -0
- package/src/lint/post-synth/wk8407.ts +66 -0
- package/src/lint/post-synth/wk8503.ts +220 -0
- package/src/lint/post-synth/wk8504.ts +51 -0
- package/src/lint/post-synth/wk8505.ts +63 -0
- package/src/op/activities/ensure-secret.ts +71 -0
- package/src/op/activities/index.ts +7 -0
- package/src/op/activities/kubectl.test.ts +32 -0
- package/src/op/activities/kubectl.ts +28 -5
- package/src/op/activities/wait-for-ready.test.ts +34 -0
- package/src/op/activities/wait-for-ready.ts +26 -0
- package/src/op/builders.test.ts +82 -0
- package/src/op/builders.ts +95 -0
- package/src/plugin.ts +21 -2
- package/src/secret-labels.ts +36 -0
- package/src/secret-store.test.ts +184 -0
- package/src/secret-store.ts +165 -0
- package/src/serializer.ts +41 -2
- package/src/sops/encrypted-secret-file.test.ts +333 -0
- package/src/sops/encrypted-secret-file.ts +264 -0
- package/src/sops/entity.ts +53 -0
- package/src/teardown.test.ts +36 -0
- package/src/teardown.ts +14 -0
- package/src/testdata/sops/db-credentials.sops.yaml +23 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed SOPS ciphertext, end to end. What must hold: a
|
|
3
|
+
* committed-encrypted `declareSecret()` resolves its file at `buildRoots()`,
|
|
4
|
+
* the bytes leave the build BYTE-FOR-BYTE as a sidecar, the primary output
|
|
5
|
+
* never carries them, and every way the claim can be false — missing file,
|
|
6
|
+
* wrong name, no `sops` block, a value that is not `ENC[...]` — refuses with
|
|
7
|
+
* the path in the message and nothing emitted.
|
|
8
|
+
*
|
|
9
|
+
* The fixture (`../testdata/sops/db-credentials.sops.yaml`) is a real
|
|
10
|
+
* sops-shaped document: cleartext structure, `ENC[...]` values, an age
|
|
11
|
+
* recipient block and a `mac`. Nothing in this suite decrypts anything —
|
|
12
|
+
* there is no key, and no code path that could use one.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, test, expect } from "vitest";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { build } from "@intentius/chant";
|
|
21
|
+
import { declareSecret, type CommittedEncryptedSecretDeclaration } from "@intentius/chant/secret-provenance";
|
|
22
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
23
|
+
import {
|
|
24
|
+
encryptedSecretBuildRoot,
|
|
25
|
+
resolveEncryptedSecrets,
|
|
26
|
+
validateEncryptedSecretDocument,
|
|
27
|
+
committedEncryptedDeclarations,
|
|
28
|
+
} from "./encrypted-secret-file";
|
|
29
|
+
import { isEncryptedSecretFileEntity, ENCRYPTED_SECRET_FILE_TYPE } from "./entity";
|
|
30
|
+
import { k8sSerializer } from "../serializer";
|
|
31
|
+
import { k8sPlugin } from "../plugin";
|
|
32
|
+
|
|
33
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const fixtureRoot = join(here, "..", "testdata", "sops");
|
|
35
|
+
const FIXTURE = "db-credentials.sops.yaml";
|
|
36
|
+
const FIXTURE_TEXT = readFileSync(join(fixtureRoot, FIXTURE), "utf-8");
|
|
37
|
+
/** Absolute path to core's factory — a temp project cannot resolve "@intentius/chant". */
|
|
38
|
+
const DECLARE_SECRET_MODULE = join(here, "..", "..", "..", "..", "packages", "core", "src", "secret-provenance");
|
|
39
|
+
|
|
40
|
+
function declaration(
|
|
41
|
+
overrides: Partial<{ name: string; file: string }> = {},
|
|
42
|
+
): CommittedEncryptedSecretDeclaration {
|
|
43
|
+
return declareSecret({
|
|
44
|
+
name: overrides.name ?? "db-credentials",
|
|
45
|
+
provenance: "committed-encrypted",
|
|
46
|
+
file: overrides.file ?? FIXTURE,
|
|
47
|
+
keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function declarations(...decls: CommittedEncryptedSecretDeclaration[]) {
|
|
52
|
+
return new Map(decls.map((d, i) => [`decl${i}`, d]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("resolveEncryptedSecrets — the buildRoots read path", () => {
|
|
56
|
+
test("reads the committed bytes and wraps them in one entity", () => {
|
|
57
|
+
const entities = resolveEncryptedSecrets({
|
|
58
|
+
projectRoot: fixtureRoot,
|
|
59
|
+
declarations: declarations(declaration()),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect([...entities.keys()]).toEqual(["sops:db-credentials"]);
|
|
63
|
+
const entity = entities.get("sops:db-credentials")!;
|
|
64
|
+
expect(isEncryptedSecretFileEntity(entity)).toBe(true);
|
|
65
|
+
if (!isEncryptedSecretFileEntity(entity)) return;
|
|
66
|
+
expect(entity.lexicon).toBe("k8s");
|
|
67
|
+
expect(entity.entityType).toBe(ENCRYPTED_SECRET_FILE_TYPE);
|
|
68
|
+
expect(entity.filename).toBe(FIXTURE);
|
|
69
|
+
expect(entity.secretName).toBe("db-credentials");
|
|
70
|
+
// The namespace is read from the file, not restated by the author — SOPS
|
|
71
|
+
// encrypts values, not structure, so metadata is cleartext.
|
|
72
|
+
expect(entity.namespace).toBe("apps");
|
|
73
|
+
expect(entity.text).toBe(FIXTURE_TEXT);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a declaration in a subdirectory keeps the basename as the sidecar name", () => {
|
|
77
|
+
const entities = resolveEncryptedSecrets({
|
|
78
|
+
projectRoot: join(fixtureRoot, ".."),
|
|
79
|
+
declarations: declarations(declaration({ file: `sops/${FIXTURE}` })),
|
|
80
|
+
});
|
|
81
|
+
const entity = [...entities.values()][0];
|
|
82
|
+
expect(isEncryptedSecretFileEntity(entity) && entity.filename).toBe(FIXTURE);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("no committed-encrypted declarations contributes nothing", async () => {
|
|
86
|
+
const contribution = await encryptedSecretBuildRoot({ projectRoot: fixtureRoot, config: {} });
|
|
87
|
+
expect(contribution.entities.size).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the plugin hook resolves declarations from ctx.entities", async () => {
|
|
91
|
+
const contribution = await k8sPlugin.buildRoots!({
|
|
92
|
+
projectRoot: fixtureRoot,
|
|
93
|
+
config: {},
|
|
94
|
+
entities: new Map<string, Declarable>([["dbCredentials", declaration()]]),
|
|
95
|
+
});
|
|
96
|
+
expect([...contribution.entities.keys()]).toEqual(["sops:db-credentials"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("committedEncryptedDeclarations ignores the other three kinds", () => {
|
|
100
|
+
const entities = new Map<string, Declarable>([
|
|
101
|
+
["a", declareSecret({ name: "a", provenance: "referenced" })],
|
|
102
|
+
["b", declareSecret({ name: "b", provenance: "generated-once", keys: ["k"] })],
|
|
103
|
+
["c", declaration()],
|
|
104
|
+
]);
|
|
105
|
+
expect([...committedEncryptedDeclarations(entities).keys()]).toEqual(["c"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a missing file refuses, naming the resolved path", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
resolveEncryptedSecrets({
|
|
111
|
+
projectRoot: fixtureRoot,
|
|
112
|
+
declarations: declarations(declaration({ name: "gone", file: "nowhere.sops.yaml" })),
|
|
113
|
+
}),
|
|
114
|
+
).toThrow(/not readable at .*nowhere\.sops\.yaml/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("two declarations whose paths share a basename refuse rather than overwrite", async () => {
|
|
118
|
+
const dir = join(tmpdir(), `chant-sops-collide-${Date.now()}-${Math.random()}`);
|
|
119
|
+
await mkdir(join(dir, "a"), { recursive: true });
|
|
120
|
+
await mkdir(join(dir, "b"), { recursive: true });
|
|
121
|
+
try {
|
|
122
|
+
await writeFile(join(dir, "a", "creds.sops.yaml"), FIXTURE_TEXT);
|
|
123
|
+
await writeFile(
|
|
124
|
+
join(dir, "b", "creds.sops.yaml"),
|
|
125
|
+
FIXTURE_TEXT.replace("name: db-credentials", "name: other-credentials"),
|
|
126
|
+
);
|
|
127
|
+
expect(() =>
|
|
128
|
+
resolveEncryptedSecrets({
|
|
129
|
+
projectRoot: dir,
|
|
130
|
+
declarations: declarations(
|
|
131
|
+
declaration({ file: "a/creds.sops.yaml" }),
|
|
132
|
+
declaration({ name: "other-credentials", file: "b/creds.sops.yaml" }),
|
|
133
|
+
),
|
|
134
|
+
}),
|
|
135
|
+
).toThrow(/collides/);
|
|
136
|
+
} finally {
|
|
137
|
+
await rm(dir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("validateEncryptedSecretDocument", () => {
|
|
143
|
+
const declared = { name: "db-credentials", file: FIXTURE };
|
|
144
|
+
|
|
145
|
+
test("the fixture is clean, and its key names (never values) are read back", () => {
|
|
146
|
+
const { problems, document } = validateEncryptedSecretDocument(FIXTURE_TEXT, declared);
|
|
147
|
+
expect(problems).toEqual([]);
|
|
148
|
+
expect(document!.name).toBe("db-credentials");
|
|
149
|
+
expect(document!.namespace).toBe("apps");
|
|
150
|
+
expect(document!.keys.sort()).toEqual(["POSTGRES_PASSWORD", "POSTGRES_USER"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a metadata.name other than the declared one is a problem", () => {
|
|
154
|
+
const text = FIXTURE_TEXT.replace("name: db-credentials", "name: other-credentials");
|
|
155
|
+
const { problems } = validateEncryptedSecretDocument(text, declared);
|
|
156
|
+
expect(problems.join("\n")).toMatch(/metadata\.name "other-credentials"/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a document that is not a v1 Secret is a problem", () => {
|
|
160
|
+
const text = FIXTURE_TEXT.replace("kind: Secret", "kind: ConfigMap");
|
|
161
|
+
const { problems } = validateEncryptedSecretDocument(text, declared);
|
|
162
|
+
expect(problems.join("\n")).toMatch(/not a v1 Secret/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a missing sops block is a problem — the file was never encrypted", () => {
|
|
166
|
+
const text = FIXTURE_TEXT.slice(0, FIXTURE_TEXT.indexOf("sops:"));
|
|
167
|
+
const { problems } = validateEncryptedSecretDocument(text, declared);
|
|
168
|
+
expect(problems.join("\n")).toMatch(/no top-level `sops` block/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a plaintext data value is a problem, reported by KEY name only", () => {
|
|
172
|
+
const text = FIXTURE_TEXT.replace(
|
|
173
|
+
/POSTGRES_PASSWORD: ENC\[[^\]]*\]/,
|
|
174
|
+
"POSTGRES_PASSWORD: hunter2",
|
|
175
|
+
);
|
|
176
|
+
const { problems } = validateEncryptedSecretDocument(text, declared);
|
|
177
|
+
const message = problems.join("\n");
|
|
178
|
+
expect(message).toMatch(/stringData\."POSTGRES_PASSWORD" is not encrypted/);
|
|
179
|
+
// The constitutional line: a value is tested, never echoed.
|
|
180
|
+
expect(message).not.toContain("hunter2");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("multi-document files are refused in v1", () => {
|
|
184
|
+
const { problems } = validateEncryptedSecretDocument(`${FIXTURE_TEXT}\n---\n${FIXTURE_TEXT}`, declared);
|
|
185
|
+
expect(problems.join("\n")).toMatch(/contains 2 YAML documents/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("unparseable YAML is refused, not thrown through", () => {
|
|
189
|
+
const { problems } = validateEncryptedSecretDocument("a:\n - b\n c: [", declared);
|
|
190
|
+
expect(problems.length).toBe(1);
|
|
191
|
+
expect(problems[0]).toMatch(/not valid YAML/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("every problem is reported at once, not one per build", () => {
|
|
195
|
+
const text = FIXTURE_TEXT.replace("name: db-credentials", "name: wrong").replace(
|
|
196
|
+
/POSTGRES_USER: ENC\[[^\]]*\]/,
|
|
197
|
+
"POSTGRES_USER: postgres",
|
|
198
|
+
);
|
|
199
|
+
const { problems } = validateEncryptedSecretDocument(text, declared);
|
|
200
|
+
expect(problems.length).toBeGreaterThanOrEqual(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("resolution refuses on any problem, emitting nothing", () => {
|
|
204
|
+
const dir = join(tmpdir(), `chant-sops-bad-${Date.now()}-${Math.random()}`);
|
|
205
|
+
return (async () => {
|
|
206
|
+
await mkdir(dir, { recursive: true });
|
|
207
|
+
try {
|
|
208
|
+
await writeFile(
|
|
209
|
+
join(dir, FIXTURE),
|
|
210
|
+
FIXTURE_TEXT.replace(/POSTGRES_USER: ENC\[[^\]]*\]/, "POSTGRES_USER: postgres"),
|
|
211
|
+
);
|
|
212
|
+
expect(() =>
|
|
213
|
+
resolveEncryptedSecrets({ projectRoot: dir, declarations: declarations(declaration()) }),
|
|
214
|
+
).toThrow(/does not resolve[\s\S]*is not encrypted/);
|
|
215
|
+
} finally {
|
|
216
|
+
await rm(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("sidecar emission", () => {
|
|
223
|
+
const workload: Declarable = {
|
|
224
|
+
lexicon: "k8s",
|
|
225
|
+
entityType: "K8s::Apps::Deployment",
|
|
226
|
+
kind: "resource",
|
|
227
|
+
props: {
|
|
228
|
+
metadata: { name: "api", namespace: "apps" },
|
|
229
|
+
spec: {
|
|
230
|
+
template: {
|
|
231
|
+
spec: {
|
|
232
|
+
containers: [
|
|
233
|
+
{ name: "api", image: "api:1.0", envFrom: [{ secretRef: { name: "db-credentials" } }] },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
[Symbol.for("chant.declarable")]: true,
|
|
240
|
+
} as unknown as Declarable;
|
|
241
|
+
|
|
242
|
+
function serializeWithCiphertext() {
|
|
243
|
+
const entities = new Map<string, Declarable>([["api", workload]]);
|
|
244
|
+
for (const [name, entity] of resolveEncryptedSecrets({
|
|
245
|
+
projectRoot: fixtureRoot,
|
|
246
|
+
declarations: declarations(declaration()),
|
|
247
|
+
})) {
|
|
248
|
+
entities.set(name, entity);
|
|
249
|
+
}
|
|
250
|
+
return k8sSerializer.serialize(entities);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
test("the ciphertext leaves as a sidecar file, byte for byte", () => {
|
|
254
|
+
const result = serializeWithCiphertext();
|
|
255
|
+
expect(typeof result).toBe("object");
|
|
256
|
+
if (typeof result === "string") return;
|
|
257
|
+
expect(Object.keys(result.files ?? {})).toEqual([FIXTURE]);
|
|
258
|
+
expect(result.files![FIXTURE]).toBe(FIXTURE_TEXT);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("the primary output carries no ciphertext and no Secret document", () => {
|
|
262
|
+
const result = serializeWithCiphertext();
|
|
263
|
+
const primary = typeof result === "string" ? result : result.primary;
|
|
264
|
+
expect(primary).toContain("kind: Deployment");
|
|
265
|
+
expect(primary).not.toContain("ENC[");
|
|
266
|
+
expect(primary).not.toContain("kind: Secret");
|
|
267
|
+
expect(primary).not.toContain("sops");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a build with no ciphertext still returns a bare string — the common case is unchanged", () => {
|
|
271
|
+
const result = k8sSerializer.serialize(new Map<string, Declarable>([["api", workload]]));
|
|
272
|
+
expect(typeof result).toBe("string");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("the whole path through build(): declaration in, sidecar out", async () => {
|
|
276
|
+
const dir = join(tmpdir(), `chant-sops-build-${Date.now()}-${Math.random()}`);
|
|
277
|
+
await mkdir(dir, { recursive: true });
|
|
278
|
+
try {
|
|
279
|
+
await writeFile(join(dir, FIXTURE), FIXTURE_TEXT);
|
|
280
|
+
await writeFile(
|
|
281
|
+
join(dir, "secrets.infra.ts"),
|
|
282
|
+
`import { declareSecret } from ${JSON.stringify(DECLARE_SECRET_MODULE)};
|
|
283
|
+
export const dbCredentials = declareSecret({
|
|
284
|
+
name: "db-credentials",
|
|
285
|
+
provenance: "committed-encrypted",
|
|
286
|
+
file: ${JSON.stringify(FIXTURE)},
|
|
287
|
+
keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
|
|
288
|
+
});
|
|
289
|
+
`,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await build(dir, [k8sSerializer], undefined, {
|
|
293
|
+
buildRoots: [(ctx) => k8sPlugin.buildRoots!({ projectRoot: dir, config: {}, entities: ctx.entities })],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(result.errors).toEqual([]);
|
|
297
|
+
const output = result.outputs.get("k8s")!;
|
|
298
|
+
expect(typeof output).toBe("object");
|
|
299
|
+
if (typeof output === "string") return;
|
|
300
|
+
expect(output.files![FIXTURE]).toBe(FIXTURE_TEXT);
|
|
301
|
+
expect(output.primary).not.toContain("ENC[");
|
|
302
|
+
} finally {
|
|
303
|
+
await rm(dir, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a broken declaration fails the build with a message, not a stack trace", async () => {
|
|
308
|
+
const dir = join(tmpdir(), `chant-sops-build-bad-${Date.now()}-${Math.random()}`);
|
|
309
|
+
await mkdir(dir, { recursive: true });
|
|
310
|
+
try {
|
|
311
|
+
await writeFile(
|
|
312
|
+
join(dir, "secrets.infra.ts"),
|
|
313
|
+
`import { declareSecret } from ${JSON.stringify(DECLARE_SECRET_MODULE)};
|
|
314
|
+
export const dbCredentials = declareSecret({
|
|
315
|
+
name: "db-credentials",
|
|
316
|
+
provenance: "committed-encrypted",
|
|
317
|
+
file: "secrets/never-committed.sops.yaml",
|
|
318
|
+
});
|
|
319
|
+
`,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const result = await build(dir, [k8sSerializer], undefined, {
|
|
323
|
+
buildRoots: [(ctx) => k8sPlugin.buildRoots!({ projectRoot: dir, config: {}, entities: ctx.entities })],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(result.errors.length).toBe(1);
|
|
327
|
+
expect(result.errors[0].message).toContain("never-committed.sops.yaml");
|
|
328
|
+
expect(result.errors[0].message).toContain("not readable");
|
|
329
|
+
} finally {
|
|
330
|
+
await rm(dir, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Committed SOPS ciphertext as a build artifact (epic lex00/iac-cd-bench#6).
|
|
3
|
+
*
|
|
4
|
+
* A `declareSecret({ provenance: "committed-encrypted", file })` names a
|
|
5
|
+
* repo-relative path to ciphertext that lives in git. This module is the
|
|
6
|
+
* resolution stage: at `buildRoots()` — chant's one sanctioned impure seam,
|
|
7
|
+
* the same one the kustomize roots and cedar's `Schema` use — it reads those
|
|
8
|
+
* bytes, validates the document is what the declaration claims, and wraps
|
|
9
|
+
* them in an entity the serializer copies out VERBATIM as a sidecar file.
|
|
10
|
+
*
|
|
11
|
+
* Three properties this is built to keep:
|
|
12
|
+
*
|
|
13
|
+
* - **Never inlined.** The bytes go to `SerializerResult.files`, never into
|
|
14
|
+
* the primary multi-document YAML. chant's own appliers read the primary
|
|
15
|
+
* output, so "chant pushes an undecrypted Secret into a cluster" is
|
|
16
|
+
* structurally impossible rather than merely unlikely. Flux is the only
|
|
17
|
+
* thing that applies the sidecar, and Flux decrypts first.
|
|
18
|
+
* - **Deterministic and offline.** The bytes are copied, not re-serialized:
|
|
19
|
+
* no parse-and-re-emit round trip, no key sorting, no digest. No `sops`
|
|
20
|
+
* binary is invoked in either direction, so a missing binary and a missing
|
|
21
|
+
* age key are not failure modes — the plaintext has no representation here
|
|
22
|
+
* at any stage.
|
|
23
|
+
* - **Fails loudly, not weirdly.** A missing file, a non-Secret document, a
|
|
24
|
+
* name mismatch, a missing `sops` block, or a `data`/`stringData` value
|
|
25
|
+
* that is not `ENC[...]`-shaped refuses with the declared path in the
|
|
26
|
+
* message. That last check is the one that earns the feature: it catches
|
|
27
|
+
* "edited the file and forgot to re-encrypt", the exact failure mode that
|
|
28
|
+
* puts plaintext into git.
|
|
29
|
+
*
|
|
30
|
+
* The entity is deliberately internal — not exported from the package entry
|
|
31
|
+
* point, not constructible by an author. The authored surface is
|
|
32
|
+
* `declareSecret`; a public entity taking inline bytes is the one thing this
|
|
33
|
+
* whole design exists to prevent.
|
|
34
|
+
*
|
|
35
|
+
* Design: docs/design/committed-encrypted-sops-provenance.md §3.
|
|
36
|
+
*/
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { resolve } from "node:path";
|
|
39
|
+
import { loadAll } from "js-yaml";
|
|
40
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
41
|
+
import {
|
|
42
|
+
ENCRYPTED_SECRET_ENTITY_PREFIX,
|
|
43
|
+
ENCRYPTED_SECRET_FILE_MARKER,
|
|
44
|
+
ENCRYPTED_SECRET_FILE_TYPE,
|
|
45
|
+
sidecarFilename,
|
|
46
|
+
type EncryptedSecretFileEntity,
|
|
47
|
+
} from "./entity";
|
|
48
|
+
import type { BuildRootContext, BuildRootContribution } from "@intentius/chant/lexicon";
|
|
49
|
+
import {
|
|
50
|
+
collectSecretDeclarations,
|
|
51
|
+
isCommittedEncryptedSecret,
|
|
52
|
+
type CommittedEncryptedSecretDeclaration,
|
|
53
|
+
} from "@intentius/chant/secret-provenance";
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
ENCRYPTED_SECRET_FILE_MARKER,
|
|
57
|
+
ENCRYPTED_SECRET_FILE_TYPE,
|
|
58
|
+
ENCRYPTED_SECRET_ENTITY_PREFIX,
|
|
59
|
+
isEncryptedSecretFileEntity,
|
|
60
|
+
sidecarFilename,
|
|
61
|
+
type EncryptedSecretFileEntity,
|
|
62
|
+
} from "./entity";
|
|
63
|
+
|
|
64
|
+
/** A parsed ciphertext document, or the reasons it is not one. */
|
|
65
|
+
export interface EncryptedSecretDocument {
|
|
66
|
+
/** `metadata.name`, when the document had one. */
|
|
67
|
+
name?: string;
|
|
68
|
+
/** `metadata.namespace`, when the document had one. */
|
|
69
|
+
namespace?: string;
|
|
70
|
+
/** Key names found under `data`/`stringData` — names only, never values. */
|
|
71
|
+
keys: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate one committed ciphertext document against its declaration.
|
|
76
|
+
*
|
|
77
|
+
* Returns every problem found, so an author sees all of them at once rather
|
|
78
|
+
* than one per build. Both consumers share this: the `buildRoots()` stage
|
|
79
|
+
* turns the list into a refusal, and WK8504 turns it into diagnostics.
|
|
80
|
+
*
|
|
81
|
+
* A value is only ever tested for the `ENC[` prefix, inside this one frame,
|
|
82
|
+
* and only the offending KEY name is reported — the discipline
|
|
83
|
+
* `declareSecret`'s own runtime guard uses.
|
|
84
|
+
*/
|
|
85
|
+
export function validateEncryptedSecretDocument(
|
|
86
|
+
text: string,
|
|
87
|
+
declared: { name: string; file: string },
|
|
88
|
+
): { problems: string[]; document?: EncryptedSecretDocument } {
|
|
89
|
+
const problems: string[] = [];
|
|
90
|
+
|
|
91
|
+
let docs: unknown[];
|
|
92
|
+
try {
|
|
93
|
+
docs = loadAll(text);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
problems: [`${declared.file} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const present = docs.filter((d) => d !== null && d !== undefined);
|
|
101
|
+
if (present.length === 0) {
|
|
102
|
+
return { problems: [`${declared.file} contains no YAML document`] };
|
|
103
|
+
}
|
|
104
|
+
if (present.length > 1) {
|
|
105
|
+
// v1 keys a declaration by one `name`; a declaration-per-document sharing
|
|
106
|
+
// a file, or a `names: string[]` field, is a design change to make
|
|
107
|
+
// deliberately rather than by accident.
|
|
108
|
+
return {
|
|
109
|
+
problems: [
|
|
110
|
+
`${declared.file} contains ${present.length} YAML documents — ` +
|
|
111
|
+
`a committed-encrypted declaration covers exactly one Secret`,
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const doc = present[0];
|
|
117
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
118
|
+
return { problems: [`${declared.file} is not a Kubernetes object`] };
|
|
119
|
+
}
|
|
120
|
+
const record = doc as Record<string, unknown>;
|
|
121
|
+
|
|
122
|
+
if (record.apiVersion !== "v1" || record.kind !== "Secret") {
|
|
123
|
+
problems.push(
|
|
124
|
+
`${declared.file} is not a v1 Secret ` +
|
|
125
|
+
`(apiVersion "${String(record.apiVersion)}", kind "${String(record.kind)}")`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const metadata = asRecord(record.metadata);
|
|
130
|
+
const name = typeof metadata?.name === "string" ? metadata.name : undefined;
|
|
131
|
+
const namespace = typeof metadata?.namespace === "string" ? metadata.namespace : undefined;
|
|
132
|
+
if (name !== declared.name) {
|
|
133
|
+
problems.push(
|
|
134
|
+
`${declared.file} has metadata.name "${name ?? "(none)"}", ` +
|
|
135
|
+
`but the declaration names "${declared.name}"`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// SOPS's own metadata block. Its absence means the file was never encrypted
|
|
140
|
+
// (or was decrypted in place and committed), which is the whole point of
|
|
141
|
+
// checking. `sops.mac` is a MAC over the PLAINTEXT — it is carried by the
|
|
142
|
+
// verbatim copy and must never be read as a semantic value, so nothing here
|
|
143
|
+
// looks past the block's presence.
|
|
144
|
+
if (asRecord(record.sops) === undefined) {
|
|
145
|
+
problems.push(
|
|
146
|
+
`${declared.file} has no top-level \`sops\` block — it does not look encrypted. ` +
|
|
147
|
+
`Run \`sops -e -i\` before committing.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Scoped to data/stringData alone: SOPS's `encrypted_regex` leaves
|
|
152
|
+
// apiVersion, kind and metadata cleartext by design.
|
|
153
|
+
const keys: string[] = [];
|
|
154
|
+
for (const field of ["data", "stringData"] as const) {
|
|
155
|
+
const block = asRecord(record[field]);
|
|
156
|
+
if (block === undefined) continue;
|
|
157
|
+
for (const [key, value] of Object.entries(block)) {
|
|
158
|
+
keys.push(key);
|
|
159
|
+
if (typeof value !== "string" || !value.startsWith("ENC[")) {
|
|
160
|
+
problems.push(
|
|
161
|
+
`${declared.file}: ${field}."${key}" is not encrypted (expected an ENC[...] value) — ` +
|
|
162
|
+
`re-run \`sops -e -i\` before committing`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { problems, document: { name, namespace, keys } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
172
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
173
|
+
? (value as Record<string, unknown>)
|
|
174
|
+
: undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Options for {@link resolveEncryptedSecrets} — `readFile` is injectable for tests. */
|
|
178
|
+
export interface ResolveEncryptedSecretsOptions {
|
|
179
|
+
projectRoot: string;
|
|
180
|
+
declarations: ReadonlyMap<string, CommittedEncryptedSecretDeclaration>;
|
|
181
|
+
readFile?: (path: string) => string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read and validate every committed-encrypted declaration, returning one
|
|
186
|
+
* entity per resolved file. Throws on the first declaration that does not
|
|
187
|
+
* resolve, with every problem for that declaration in the message — a build
|
|
188
|
+
* that would emit a broken or unencrypted Secret must not proceed.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveEncryptedSecrets(
|
|
191
|
+
options: ResolveEncryptedSecretsOptions,
|
|
192
|
+
): Map<string, Declarable> {
|
|
193
|
+
const read = options.readFile ?? ((path: string) => readFileSync(path, "utf-8"));
|
|
194
|
+
const entities = new Map<string, Declarable>();
|
|
195
|
+
const byFilename = new Map<string, string>();
|
|
196
|
+
|
|
197
|
+
for (const decl of options.declarations.values()) {
|
|
198
|
+
const absolute = resolve(options.projectRoot, decl.file);
|
|
199
|
+
let text: string;
|
|
200
|
+
try {
|
|
201
|
+
text = read(absolute);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`declareSecret("${decl.name}"): committed ciphertext not readable at ${absolute} ` +
|
|
205
|
+
`(declared as "${decl.file}") — ${err instanceof Error ? err.message : String(err)}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { problems, document } = validateEncryptedSecretDocument(text, decl);
|
|
210
|
+
if (problems.length > 0) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`declareSecret("${decl.name}"): committed ciphertext does not resolve —\n ` +
|
|
213
|
+
problems.join("\n "),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const filename = sidecarFilename(decl.file);
|
|
218
|
+
const collides = byFilename.get(filename);
|
|
219
|
+
if (collides !== undefined) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`declareSecret("${decl.name}"): sidecar filename "${filename}" collides with the one ` +
|
|
222
|
+
`"${collides}" already emits — additional files share one flat namespace, so two ` +
|
|
223
|
+
`declarations whose paths share a basename would silently overwrite each other`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
byFilename.set(filename, decl.file);
|
|
227
|
+
|
|
228
|
+
const entity: EncryptedSecretFileEntity = {
|
|
229
|
+
lexicon: "k8s",
|
|
230
|
+
entityType: ENCRYPTED_SECRET_FILE_TYPE,
|
|
231
|
+
filename,
|
|
232
|
+
text,
|
|
233
|
+
secretName: decl.name,
|
|
234
|
+
...(document?.namespace !== undefined ? { namespace: document.namespace } : {}),
|
|
235
|
+
sourcePath: decl.file,
|
|
236
|
+
[DECLARABLE_MARKER]: true,
|
|
237
|
+
[ENCRYPTED_SECRET_FILE_MARKER]: true,
|
|
238
|
+
};
|
|
239
|
+
entities.set(`${ENCRYPTED_SECRET_ENTITY_PREFIX}${decl.name}`, entity);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return entities;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every committed-encrypted declaration in an entity map, keyed by entity name. */
|
|
246
|
+
export function committedEncryptedDeclarations(
|
|
247
|
+
entities: ReadonlyMap<string, Declarable>,
|
|
248
|
+
): Map<string, CommittedEncryptedSecretDeclaration> {
|
|
249
|
+
const out = new Map<string, CommittedEncryptedSecretDeclaration>();
|
|
250
|
+
for (const [name, decl] of collectSecretDeclarations(entities)) {
|
|
251
|
+
if (isCommittedEncryptedSecret(decl)) out.set(name, decl);
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The `buildRoots` contribution: one entity per committed-encrypted
|
|
258
|
+
* declaration, holding the file's bytes verbatim.
|
|
259
|
+
*/
|
|
260
|
+
export async function encryptedSecretBuildRoot(ctx: BuildRootContext): Promise<BuildRootContribution> {
|
|
261
|
+
const declarations = committedEncryptedDeclarations(ctx.entities ?? new Map());
|
|
262
|
+
if (declarations.size === 0) return { entities: new Map() };
|
|
263
|
+
return { entities: resolveEncryptedSecrets({ projectRoot: ctx.projectRoot, declarations }) };
|
|
264
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The committed-ciphertext entity: marker, shape and type guard only.
|
|
3
|
+
*
|
|
4
|
+
* Split from `./encrypted-secret-file.ts` so the serializer can recognise the
|
|
5
|
+
* entity without pulling `node:fs` and a YAML parser into its module graph —
|
|
6
|
+
* the resolution stage needs both, the emission stage needs neither.
|
|
7
|
+
*
|
|
8
|
+
* Not exported from the package entry point and not constructible by an
|
|
9
|
+
* author: the authored surface is `declareSecret({ provenance:
|
|
10
|
+
* "committed-encrypted", file })`. A public entity taking inline bytes is
|
|
11
|
+
* exactly what this design exists to prevent.
|
|
12
|
+
*/
|
|
13
|
+
import { basename } from "node:path";
|
|
14
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
15
|
+
|
|
16
|
+
/** Marks an entity whose bytes are committed ciphertext, emitted verbatim. */
|
|
17
|
+
export const ENCRYPTED_SECRET_FILE_MARKER = Symbol.for("chant.k8s.encryptedSecretFile");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The entity's type name. Deliberately outside the `K8s::{Group}::{Kind}`
|
|
21
|
+
* surface a real Secret speaks: an observer must NOT read this against a live
|
|
22
|
+
* cluster — the ciphertext is not the thing in the cluster, the decrypted
|
|
23
|
+
* Secret is — so a type with no generated operation surface, reporting the
|
|
24
|
+
* honest NOT-OBSERVED, is the right answer.
|
|
25
|
+
*/
|
|
26
|
+
export const ENCRYPTED_SECRET_FILE_TYPE = "K8s::Sops::EncryptedSecretFile";
|
|
27
|
+
|
|
28
|
+
/** Entity-map key prefix, colon-separated so it cannot collide with a TS export name. */
|
|
29
|
+
export const ENCRYPTED_SECRET_ENTITY_PREFIX = "sops:";
|
|
30
|
+
|
|
31
|
+
export interface EncryptedSecretFileEntity extends Declarable {
|
|
32
|
+
readonly entityType: typeof ENCRYPTED_SECRET_FILE_TYPE;
|
|
33
|
+
readonly [ENCRYPTED_SECRET_FILE_MARKER]: true;
|
|
34
|
+
/** Sidecar filename written beside the primary output. */
|
|
35
|
+
readonly filename: string;
|
|
36
|
+
/** The committed bytes, exactly as read. */
|
|
37
|
+
readonly text: string;
|
|
38
|
+
/** `metadata.name` of the encrypted Secret — equal to the declaration's name. */
|
|
39
|
+
readonly secretName: string;
|
|
40
|
+
/** `metadata.namespace`, cleartext in the file (SOPS encrypts values, not structure). */
|
|
41
|
+
readonly namespace?: string;
|
|
42
|
+
/** The declared repo-relative path, for diagnostics. */
|
|
43
|
+
readonly sourcePath: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isEncryptedSecretFileEntity(entity: Declarable): entity is EncryptedSecretFileEntity {
|
|
47
|
+
return (entity as unknown as Record<symbol, unknown>)[ENCRYPTED_SECRET_FILE_MARKER] === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The sidecar filename a declaration's file is emitted under. */
|
|
51
|
+
export function sidecarFilename(file: string): string {
|
|
52
|
+
return basename(file);
|
|
53
|
+
}
|