@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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s SecretStoreAdapter (chant #1830) over the fake cluster — a real
|
|
3
|
+
* `@intentius/chant-k8s-client` with only the HTTP send replaced, the same
|
|
4
|
+
* harness the teardown and prune suites use.
|
|
5
|
+
*
|
|
6
|
+
* What is asserted is the constitutional line as much as the behavior: no
|
|
7
|
+
* return value and no log line of the read half ever carries a `data` value,
|
|
8
|
+
* and the write half consumes each key's material exactly once, at the write,
|
|
9
|
+
* with the ownership marker and the generated-once label stamped on.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect, vi, afterEach } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
SecretMaterial,
|
|
15
|
+
consumeSecretMaterial,
|
|
16
|
+
ensureSecretMaterialization,
|
|
17
|
+
SecretContractMismatchError,
|
|
18
|
+
} from "@intentius/chant/secret-materialization";
|
|
19
|
+
import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
20
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
21
|
+
import type { RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
22
|
+
import { fakeCluster, objectKey } from "./api/fake-cluster";
|
|
23
|
+
import { k8sSecretStore } from "./secret-store";
|
|
24
|
+
import { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE } from "./secret-labels";
|
|
25
|
+
|
|
26
|
+
const PLAINTEXT_A = "plain-material-alpha";
|
|
27
|
+
const PLAINTEXT_B = "plain-material-bravo";
|
|
28
|
+
const LIVE_VALUE_1 = Buffer.from("live-master-bytes", "utf-8").toString("base64");
|
|
29
|
+
const LIVE_VALUE_2 = Buffer.from("live-conf-bytes", "utf-8").toString("base64");
|
|
30
|
+
|
|
31
|
+
/** Every string no read-path return or log may ever contain. */
|
|
32
|
+
const FORBIDDEN = [PLAINTEXT_A, PLAINTEXT_B, LIVE_VALUE_1, LIVE_VALUE_2, "live-master-bytes", "live-conf-bytes"];
|
|
33
|
+
|
|
34
|
+
function liveSecret(): K8sObject {
|
|
35
|
+
return {
|
|
36
|
+
apiVersion: "v1",
|
|
37
|
+
kind: "Secret",
|
|
38
|
+
metadata: {
|
|
39
|
+
name: "master-key",
|
|
40
|
+
namespace: "prod",
|
|
41
|
+
uid: "uid-master-key",
|
|
42
|
+
labels: {
|
|
43
|
+
[LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
|
|
44
|
+
[GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
|
|
45
|
+
},
|
|
46
|
+
annotations: { "example.com/note": "hand-written" },
|
|
47
|
+
},
|
|
48
|
+
type: "Opaque",
|
|
49
|
+
data: { MASTER_KEY: LIVE_VALUE_1, "app.conf": LIVE_VALUE_2 },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clusterWithSecret() {
|
|
54
|
+
return fakeCluster({
|
|
55
|
+
objects: { [objectKey("v1", "Secret", "master-key", "prod")]: liveSecret() },
|
|
56
|
+
respond: (req: RecordedRequest) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function clientOf(cluster: ReturnType<typeof fakeCluster>): Promise<K8sClient> {
|
|
61
|
+
return (await cluster.connector({})).client;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Spies on every console channel; returns everything logged as one string. */
|
|
65
|
+
function spyConsole(): { flush: () => string } {
|
|
66
|
+
const calls: unknown[][] = [];
|
|
67
|
+
for (const level of ["log", "info", "warn", "error", "debug"] as const) {
|
|
68
|
+
vi.spyOn(console, level).mockImplementation((...args: unknown[]) => {
|
|
69
|
+
calls.push(args);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { flush: () => calls.flat().map(String).join("\n") };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("k8sSecretStore — the read half never returns or logs a value", () => {
|
|
80
|
+
test("exists answers presence and nothing else", async () => {
|
|
81
|
+
const cluster = clusterWithSecret();
|
|
82
|
+
const store = k8sSecretStore("prod", await clientOf(cluster));
|
|
83
|
+
await expect(store.exists("master-key")).resolves.toBe(true);
|
|
84
|
+
await expect(store.exists("absent")).resolves.toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("describe projects key NAMES and labels/annotations, never data values", async () => {
|
|
88
|
+
const cluster = clusterWithSecret();
|
|
89
|
+
const spy = spyConsole();
|
|
90
|
+
const store = k8sSecretStore("prod", await clientOf(cluster));
|
|
91
|
+
|
|
92
|
+
const description = await store.describe("master-key");
|
|
93
|
+
|
|
94
|
+
expect(description.keys).toEqual(["MASTER_KEY", "app.conf"]);
|
|
95
|
+
expect(description.metadata?.[GENERATED_ONCE_LABEL_KEY]).toBe(GENERATED_ONCE_LABEL_VALUE);
|
|
96
|
+
expect(description.metadata?.["example.com/note"]).toBe("hand-written");
|
|
97
|
+
// The claim that matters: nothing value-shaped in the return, under any
|
|
98
|
+
// key — the description carries exactly `keys` and `metadata`, and no
|
|
99
|
+
// byte of the stored values appears anywhere in its serialization.
|
|
100
|
+
expect(Object.keys(description).sort()).toEqual(["keys", "metadata"]);
|
|
101
|
+
const serialized = JSON.stringify(description);
|
|
102
|
+
for (const forbidden of FORBIDDEN) expect(serialized).not.toContain(forbidden);
|
|
103
|
+
for (const forbidden of FORBIDDEN) expect(spy.flush()).not.toContain(forbidden);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("ensureSecretMaterialization over the adapter: present-and-matching writes nothing", async () => {
|
|
107
|
+
const cluster = clusterWithSecret();
|
|
108
|
+
const store = k8sSecretStore("prod", await clientOf(cluster));
|
|
109
|
+
const outcome = await ensureSecretMaterialization(store, {
|
|
110
|
+
name: "master-key",
|
|
111
|
+
keys: ["MASTER_KEY", "app.conf"],
|
|
112
|
+
});
|
|
113
|
+
expect(outcome).toEqual({ outcome: "present", name: "master-key", keys: ["MASTER_KEY", "app.conf"] });
|
|
114
|
+
expect(cluster.layer.requests.filter((r) => r.method === "PATCH")).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a key-set mismatch fails loudly naming key names, never values", async () => {
|
|
118
|
+
const cluster = clusterWithSecret();
|
|
119
|
+
const store = k8sSecretStore("prod", await clientOf(cluster));
|
|
120
|
+
const err = await ensureSecretMaterialization(store, {
|
|
121
|
+
name: "master-key",
|
|
122
|
+
keys: ["MASTER_KEY", "MISSING_KEY"],
|
|
123
|
+
}).catch((e: unknown) => e);
|
|
124
|
+
expect(err).toBeInstanceOf(SecretContractMismatchError);
|
|
125
|
+
const message = String((err as Error).message);
|
|
126
|
+
expect(message).toContain("MISSING_KEY");
|
|
127
|
+
expect(message).toContain("app.conf");
|
|
128
|
+
for (const forbidden of FORBIDDEN) expect(message).not.toContain(forbidden);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("k8sSecretStore — create consumes material at the write and stamps the markers", () => {
|
|
133
|
+
test("mints one value per key, consumes each handle exactly once, stamps ownership + generated-once", async () => {
|
|
134
|
+
const cluster = fakeCluster({
|
|
135
|
+
respond: (req: RecordedRequest) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
|
|
136
|
+
});
|
|
137
|
+
const minted: SecretMaterial[] = [];
|
|
138
|
+
const generate = vi.fn((key: string) => {
|
|
139
|
+
const handle = SecretMaterial.mint(key === "MASTER_KEY" ? PLAINTEXT_A : PLAINTEXT_B);
|
|
140
|
+
minted.push(handle);
|
|
141
|
+
return handle;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const store = k8sSecretStore("prod", await clientOf(cluster), {
|
|
145
|
+
marker: { stack: "shop", env: "dev" },
|
|
146
|
+
});
|
|
147
|
+
const outcome = await ensureSecretMaterialization(
|
|
148
|
+
store,
|
|
149
|
+
{ name: "master-key", keys: ["MASTER_KEY", "SECOND_KEY"] },
|
|
150
|
+
generate,
|
|
151
|
+
);
|
|
152
|
+
expect(outcome).toEqual({ outcome: "created", name: "master-key", keys: ["MASTER_KEY", "SECOND_KEY"] });
|
|
153
|
+
|
|
154
|
+
// One mint per declared key, and every handle burned at the write: a
|
|
155
|
+
// second consume of any of them throws.
|
|
156
|
+
expect(generate).toHaveBeenCalledTimes(2);
|
|
157
|
+
for (const handle of minted) {
|
|
158
|
+
expect(() => consumeSecretMaterial(handle)).toThrow(/already consumed/);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const patch = cluster.layer.requests.find((r) => r.method === "PATCH");
|
|
162
|
+
expect(patch).toBeDefined();
|
|
163
|
+
expect(patch!.path).toBe("/api/v1/namespaces/prod/secrets/master-key");
|
|
164
|
+
expect(patch!.query.fieldManager).toBe("chant:shop");
|
|
165
|
+
const body = JSON.parse(String(patch!.body)) as K8sObject;
|
|
166
|
+
expect(body.metadata?.labels).toEqual({
|
|
167
|
+
[LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
|
|
168
|
+
[LABEL_OWNERSHIP_KEYS.stack]: "shop",
|
|
169
|
+
[LABEL_OWNERSHIP_KEYS.env]: "dev",
|
|
170
|
+
[GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
|
|
171
|
+
});
|
|
172
|
+
expect(body.data).toEqual({
|
|
173
|
+
MASTER_KEY: Buffer.from(PLAINTEXT_A, "utf-8").toString("base64"),
|
|
174
|
+
SECOND_KEY: Buffer.from(PLAINTEXT_B, "utf-8").toString("base64"),
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the handle itself never leaks material: stringify/inspect show only the redaction marker", () => {
|
|
179
|
+
const handle = SecretMaterial.mint(PLAINTEXT_A);
|
|
180
|
+
expect(String(handle)).not.toContain(PLAINTEXT_A);
|
|
181
|
+
expect(JSON.stringify({ handle })).not.toContain(PLAINTEXT_A);
|
|
182
|
+
consumeSecretMaterial(handle); // leave no live handle behind
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kubernetes row of the secret-store seam (chant #1830, epic #1365) — a
|
|
3
|
+
* `SecretStoreAdapter` over the typed client, for the `ensure-secret`
|
|
4
|
+
* capability verb and the `ensureSecret(...)` op step (both #1829, both
|
|
5
|
+
* surfaces over core's one materialization engine).
|
|
6
|
+
*
|
|
7
|
+
* The constitutional line, held structurally:
|
|
8
|
+
*
|
|
9
|
+
* - `exists` and `describe` read the live Secret and report key NAMES and
|
|
10
|
+
* labels/annotations only. The `data` values are projected to their key
|
|
11
|
+
* names inside one function frame and the object is dropped — nothing
|
|
12
|
+
* value-shaped is returned, logged, or retained. The mismatch check in core
|
|
13
|
+
* therefore has nothing to compare but names, by construction.
|
|
14
|
+
* - `create` is the only writer. It redeems each key's material through
|
|
15
|
+
* `consumeSecretMaterial` AT the write — the engine hands the generator
|
|
16
|
+
* through this seam and never calls it — and returns nothing.
|
|
17
|
+
*
|
|
18
|
+
* Every minted Secret carries chant's ownership marker plus the
|
|
19
|
+
* `chant.intentius.io/generated-once` label (./secret-labels.ts), which is
|
|
20
|
+
* what both prune paths key their `retained` outcome on: a generated-once
|
|
21
|
+
* Secret is never in any prunable set, because the stored bytes are the only
|
|
22
|
+
* copy of material chant never held.
|
|
23
|
+
*
|
|
24
|
+
* Not exported from the package entry point — like ./teardown.ts, this module
|
|
25
|
+
* names the API client, which must stay off the build path (chant #1074,
|
|
26
|
+
* examples/k8s-client-boundary.test.ts). Consumers reach it by subpath:
|
|
27
|
+
* `@intentius/chant-lexicon-k8s/secret-store`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
SecretMaterialGenerator,
|
|
32
|
+
SecretStoreAdapter,
|
|
33
|
+
SecretStoreDescription,
|
|
34
|
+
} from "@intentius/chant/secret-materialization";
|
|
35
|
+
import { consumeSecretMaterial } from "@intentius/chant/secret-materialization";
|
|
36
|
+
import {
|
|
37
|
+
createEnsureSecretCapability,
|
|
38
|
+
type EnsureSecretInput,
|
|
39
|
+
type EnsureSecretOutput,
|
|
40
|
+
} from "@intentius/chant/components/verbs/ensure-secret";
|
|
41
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
42
|
+
import {
|
|
43
|
+
LABEL_OWNERSHIP_KEYS,
|
|
44
|
+
OWNERSHIP_MANAGED_BY_VALUE,
|
|
45
|
+
ownershipEntries,
|
|
46
|
+
type OwnershipMarker,
|
|
47
|
+
} from "@intentius/chant/ownership";
|
|
48
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
49
|
+
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
50
|
+
import { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE } from "./secret-labels";
|
|
51
|
+
|
|
52
|
+
const SECRET_REF = { apiVersion: "v1", kind: "Secret" } as const;
|
|
53
|
+
|
|
54
|
+
export interface K8sSecretStoreOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Ownership identity to stamp on a minted Secret, next to the
|
|
57
|
+
* generated-once label. Omitted stamps `managed-by=chant` alone — the
|
|
58
|
+
* Secret is still chant-owned and still never prunable.
|
|
59
|
+
*/
|
|
60
|
+
marker?: OwnershipMarker;
|
|
61
|
+
/** chant environment, resolved through `k8s.profiles.<env>.context` when no client is handed in. */
|
|
62
|
+
environment?: string;
|
|
63
|
+
/** Explicit kubectl context, for the op write path. */
|
|
64
|
+
context?: string;
|
|
65
|
+
/** Project directory whose `chant.config.ts` carries the k8s profiles. Defaults to cwd. */
|
|
66
|
+
cwd?: string;
|
|
67
|
+
/** The connector to build a client with when none is handed in. Test seam. */
|
|
68
|
+
connect?: K8sConnector;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the k8s `SecretStoreAdapter` for one namespace.
|
|
73
|
+
*
|
|
74
|
+
* Pass a `client` where the caller already holds one (an activity that
|
|
75
|
+
* connected for the rest of its work); omit it and the adapter connects
|
|
76
|
+
* lazily through the same environment→cluster binding every other k8s path
|
|
77
|
+
* uses (./api/connect.ts).
|
|
78
|
+
*/
|
|
79
|
+
export function k8sSecretStore(
|
|
80
|
+
namespace: string,
|
|
81
|
+
client?: K8sClient,
|
|
82
|
+
options: K8sSecretStoreOptions = {},
|
|
83
|
+
): SecretStoreAdapter {
|
|
84
|
+
let pending: Promise<K8sClient> | undefined = client ? Promise.resolve(client) : undefined;
|
|
85
|
+
const clientOf = (): Promise<K8sClient> =>
|
|
86
|
+
(pending ??= (options.connect ?? defaultK8sConnector)({
|
|
87
|
+
...(options.environment !== undefined ? { environment: options.environment } : {}),
|
|
88
|
+
...(options.context !== undefined ? { context: options.context } : {}),
|
|
89
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
90
|
+
}).then((connected) => connected.client));
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async exists(name: string): Promise<boolean> {
|
|
94
|
+
const c = await clientOf();
|
|
95
|
+
return (await c.readIfPresent({ ...SECRET_REF, name, namespace })) !== undefined;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async describe(name: string): Promise<SecretStoreDescription> {
|
|
99
|
+
const c = await clientOf();
|
|
100
|
+
const live = await c.read({ ...SECRET_REF, name, namespace });
|
|
101
|
+
// Project key names and drop the object in the same frame. The values
|
|
102
|
+
// exist only in `live`, which nothing below touches again — the return
|
|
103
|
+
// carries names and metadata, never a byte of `data`.
|
|
104
|
+
const keys = Object.keys((live.data as Record<string, unknown> | undefined) ?? {}).sort();
|
|
105
|
+
const metadata: Record<string, string> = {
|
|
106
|
+
...(live.metadata?.annotations ?? {}),
|
|
107
|
+
...(live.metadata?.labels ?? {}),
|
|
108
|
+
};
|
|
109
|
+
return { keys, metadata };
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async create(name: string, keys: readonly string[], generate: SecretMaterialGenerator): Promise<void> {
|
|
113
|
+
const c = await clientOf();
|
|
114
|
+
// Mint and consume per key, at the write. `consumeSecretMaterial` burns
|
|
115
|
+
// each handle, so the material cannot be redeemed a second time by
|
|
116
|
+
// anything, this adapter included.
|
|
117
|
+
const data: Record<string, string> = {};
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
data[key] = Buffer.from(consumeSecretMaterial(await generate(key)), "utf-8").toString("base64");
|
|
120
|
+
}
|
|
121
|
+
const secret: K8sObject = {
|
|
122
|
+
apiVersion: SECRET_REF.apiVersion,
|
|
123
|
+
kind: SECRET_REF.kind,
|
|
124
|
+
metadata: {
|
|
125
|
+
name,
|
|
126
|
+
namespace,
|
|
127
|
+
labels: {
|
|
128
|
+
...(options.marker
|
|
129
|
+
? ownershipEntries(LABEL_OWNERSHIP_KEYS, options.marker)
|
|
130
|
+
: { [LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE }),
|
|
131
|
+
[GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
type: "Opaque",
|
|
135
|
+
data,
|
|
136
|
+
};
|
|
137
|
+
// Server-side apply as chant's field manager — the same write path every
|
|
138
|
+
// other chant mutation takes. The result (which echoes `data`) is
|
|
139
|
+
// deliberately discarded.
|
|
140
|
+
await c.apply(secret, {
|
|
141
|
+
...(options.marker ? { fieldManager: `chant:${options.marker.stack}` } : {}),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The real `ensure-secret` capability for a Kubernetes namespace — core's
|
|
149
|
+
* factory (#1829) over this module's adapter. Replaces the starter set's
|
|
150
|
+
* typed stub for a project whose secret store is the cluster:
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* registry.register(k8sEnsureSecretCapability("prod", undefined, {
|
|
154
|
+
* marker: { stack: "shop", env: "prod" },
|
|
155
|
+
* environment: "prod",
|
|
156
|
+
* }));
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export function k8sEnsureSecretCapability(
|
|
160
|
+
namespace: string,
|
|
161
|
+
client?: K8sClient,
|
|
162
|
+
options: K8sSecretStoreOptions = {},
|
|
163
|
+
): Capability<EnsureSecretInput, EnsureSecretOutput> {
|
|
164
|
+
return createEnsureSecretCapability({ store: k8sSecretStore(namespace, client, options) });
|
|
165
|
+
}
|
package/src/serializer.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-w
|
|
|
15
15
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
16
16
|
import { isDefaultLabels, isDefaultAnnotations, type DefaultLabels, type DefaultAnnotations } from "./default-labels";
|
|
17
17
|
import { isRenderedManifestEntity } from "./kustomize/rendered-entity";
|
|
18
|
+
import { isEncryptedSecretFileEntity } from "./sops/entity";
|
|
18
19
|
|
|
19
20
|
const require = createRequire(import.meta.url);
|
|
20
21
|
|
|
@@ -225,7 +226,11 @@ export const k8sSerializer: Serializer = {
|
|
|
225
226
|
// `chant-disable ARGO001` already written (#1349).
|
|
226
227
|
extraRulePrefixes: ["ARGO", "FLUX"],
|
|
227
228
|
|
|
228
|
-
serialize(
|
|
229
|
+
serialize(
|
|
230
|
+
entities: Map<string, Declarable>,
|
|
231
|
+
_outputs?: LexiconOutput[],
|
|
232
|
+
context?: SerializeContext,
|
|
233
|
+
): string | SerializerResult {
|
|
229
234
|
// Build reverse map: entity → name
|
|
230
235
|
const entityNames = new Map<Declarable, string>();
|
|
231
236
|
for (const [name, entity] of entities) {
|
|
@@ -251,11 +256,36 @@ export const k8sSerializer: Serializer = {
|
|
|
251
256
|
|
|
252
257
|
const namespaceDocs: string[] = [];
|
|
253
258
|
const otherDocs: string[] = [];
|
|
259
|
+
/** Sidecar files — committed ciphertext, copied byte-for-byte. */
|
|
260
|
+
const files: Record<string, string> = {};
|
|
261
|
+
/** Basenames within `files` that must never be JSON.parse round-tripped (chant#1937). */
|
|
262
|
+
const verbatimFiles: string[] = [];
|
|
263
|
+
const warnings: string[] = [];
|
|
254
264
|
|
|
255
265
|
for (const [name, entity] of entities) {
|
|
256
266
|
if (isPropertyDeclarable(entity)) continue;
|
|
257
267
|
if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
|
|
258
268
|
|
|
269
|
+
// Committed SOPS ciphertext (epic lex00/iac-cd-bench#6) leaves as a
|
|
270
|
+
// SIDECAR and never as a document in the primary output. That is the
|
|
271
|
+
// load-bearing line: chant's appliers read the primary output, so an
|
|
272
|
+
// undecrypted Secret cannot reach a cluster through them. The bytes are
|
|
273
|
+
// copied exactly as committed — no re-emit, no label merge, no
|
|
274
|
+
// ownership stamp — because re-serializing would break the MAC the
|
|
275
|
+
// `sops` block carries over the plaintext.
|
|
276
|
+
if (isEncryptedSecretFileEntity(entity)) {
|
|
277
|
+
const existing = files[entity.filename];
|
|
278
|
+
if (existing !== undefined && existing !== entity.text) {
|
|
279
|
+
warnings.push(
|
|
280
|
+
`k8s: two committed-encrypted secrets emit the same sidecar filename ` +
|
|
281
|
+
`"${entity.filename}" — the second (${entity.sourcePath}) wins`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
files[entity.filename] = entity.text;
|
|
285
|
+
if (!verbatimFiles.includes(entity.filename)) verbatimFiles.push(entity.filename);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
// A kustomize build root's document (#1548 piece 3) is render-final:
|
|
260
290
|
// the overlay decided every field, so the props ARE the manifest and
|
|
261
291
|
// the spec-inference heuristics below (built for typed declarables)
|
|
@@ -386,7 +416,16 @@ export const k8sSerializer: Serializer = {
|
|
|
386
416
|
}
|
|
387
417
|
}
|
|
388
418
|
|
|
389
|
-
|
|
419
|
+
const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
|
|
420
|
+
// A bare string when there is nothing extra to write, so the common case
|
|
421
|
+
// stays byte-identical to what every existing consumer already reads.
|
|
422
|
+
if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
|
|
423
|
+
return {
|
|
424
|
+
primary,
|
|
425
|
+
files,
|
|
426
|
+
...(verbatimFiles.length > 0 ? { verbatimFiles } : {}),
|
|
427
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
428
|
+
};
|
|
390
429
|
},
|
|
391
430
|
};
|
|
392
431
|
|