@intentius/chant-lexicon-k8s 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/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 +6 -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/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/describe-resources.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 +13 -5
- package/dist/kube/apply.d.ts.map +1 -1
- package/dist/lint/audit-catalog.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 +28 -1
- 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/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/plugin.d.ts.map +1 -1
- package/dist/rules/k8s-helpers.ts +55 -1
- package/dist/rules/sops-helpers.ts +76 -0
- package/dist/rules/wk8404.ts +55 -0
- package/dist/rules/wk8405.ts +88 -0
- package/dist/rules/wk8406.ts +60 -0
- package/dist/rules/wk8407.ts +71 -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 +46 -0
- package/dist/teardown.d.ts.map +1 -0
- 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 +273 -0
- package/src/composites/flux-app.ts +23 -0
- package/src/composites/index.ts +11 -0
- package/src/composites/inference-service.ts +140 -0
- package/src/composites/model.ts +150 -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/describe-resources.ts +6 -1
- 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 +6 -0
- package/src/kube/apply.ts +5 -0
- package/src/lifecycle-integration.test.ts +42 -0
- package/src/lint/audit-catalog.ts +7 -0
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/k8s-helpers.ts +55 -1
- 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/wk8404.ts +55 -0
- package/src/lint/post-synth/wk8405.ts +88 -0
- package/src/lint/post-synth/wk8406.ts +60 -0
- package/src/lint/post-synth/wk8407.ts +71 -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/plugin.ts +34 -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 +33 -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 +240 -0
- package/src/teardown.ts +239 -0
- package/src/testdata/sops/db-credentials.sops.yaml +23 -0
|
@@ -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,33 @@ 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
|
+
const warnings: string[] = [];
|
|
254
262
|
|
|
255
263
|
for (const [name, entity] of entities) {
|
|
256
264
|
if (isPropertyDeclarable(entity)) continue;
|
|
257
265
|
if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
|
|
258
266
|
|
|
267
|
+
// Committed SOPS ciphertext (epic lex00/iac-cd-bench#6) leaves as a
|
|
268
|
+
// SIDECAR and never as a document in the primary output. That is the
|
|
269
|
+
// load-bearing line: chant's appliers read the primary output, so an
|
|
270
|
+
// undecrypted Secret cannot reach a cluster through them. The bytes are
|
|
271
|
+
// copied exactly as committed — no re-emit, no label merge, no
|
|
272
|
+
// ownership stamp — because re-serializing would break the MAC the
|
|
273
|
+
// `sops` block carries over the plaintext.
|
|
274
|
+
if (isEncryptedSecretFileEntity(entity)) {
|
|
275
|
+
const existing = files[entity.filename];
|
|
276
|
+
if (existing !== undefined && existing !== entity.text) {
|
|
277
|
+
warnings.push(
|
|
278
|
+
`k8s: two committed-encrypted secrets emit the same sidecar filename ` +
|
|
279
|
+
`"${entity.filename}" — the second (${entity.sourcePath}) wins`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
files[entity.filename] = entity.text;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
// A kustomize build root's document (#1548 piece 3) is render-final:
|
|
260
287
|
// the overlay decided every field, so the props ARE the manifest and
|
|
261
288
|
// the spec-inference heuristics below (built for typed declarables)
|
|
@@ -386,7 +413,11 @@ export const k8sSerializer: Serializer = {
|
|
|
386
413
|
}
|
|
387
414
|
}
|
|
388
415
|
|
|
389
|
-
|
|
416
|
+
const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
|
|
417
|
+
// A bare string when there is nothing extra to write, so the common case
|
|
418
|
+
// stays byte-identical to what every existing consumer already reads.
|
|
419
|
+
if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
|
|
420
|
+
return { primary, files, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
390
421
|
},
|
|
391
422
|
};
|
|
392
423
|
|
|
@@ -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
|
+
});
|