@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,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
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env teardown over the fake cluster (chant #1222) — the k8s halves of the
|
|
3
|
+
* teardownOwned / executeTeardown capability pair, mirroring the typed-prune
|
|
4
|
+
* tests in op/activities/kubectl.test.ts: a real client over a fake request
|
|
5
|
+
* layer, so discovery, path construction, and the delete path all run for
|
|
6
|
+
* real.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
11
|
+
import { statusBody } from "@intentius/chant-k8s-client/testing";
|
|
12
|
+
import { fakeCluster, objectKey, ownedObject } from "./api/fake-cluster";
|
|
13
|
+
import { teardownOwned, executeTeardown } from "./teardown";
|
|
14
|
+
|
|
15
|
+
const MARKER = { stack: "shop", env: "dev" };
|
|
16
|
+
|
|
17
|
+
function markerLabels(stack: string, env?: string): Record<string, string> {
|
|
18
|
+
return {
|
|
19
|
+
[LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
|
|
20
|
+
[LABEL_OWNERSHIP_KEYS.stack]: stack,
|
|
21
|
+
...(env ? { [LABEL_OWNERSHIP_KEYS.env]: env } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A cluster holding this env's estate plus every kind of near-miss. */
|
|
26
|
+
function estateCluster(overrides: Parameters<typeof fakeCluster>[0] = {}) {
|
|
27
|
+
return fakeCluster({
|
|
28
|
+
objects: {
|
|
29
|
+
// Mine: full marker, this stack, this env.
|
|
30
|
+
[objectKey("apps/v1", "Deployment", "web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "web", "prod-ns", {
|
|
31
|
+
metadata: { labels: markerLabels("shop", "dev") },
|
|
32
|
+
}),
|
|
33
|
+
[objectKey("v1", "Service", "cache", "prod-ns")]: ownedObject("v1", "Service", "cache", "prod-ns", {
|
|
34
|
+
metadata: { labels: markerLabels("shop", "dev") },
|
|
35
|
+
}),
|
|
36
|
+
[objectKey("v1", "Namespace", "dev-ns")]: ownedObject("v1", "Namespace", "dev-ns", undefined, {
|
|
37
|
+
metadata: { labels: markerLabels("shop", "dev") },
|
|
38
|
+
}),
|
|
39
|
+
// Near-misses: foreign stack, foreign env, env-less marker, unmarked.
|
|
40
|
+
[objectKey("apps/v1", "Deployment", "their-web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "their-web", "prod-ns", {
|
|
41
|
+
metadata: { labels: markerLabels("blog", "dev") },
|
|
42
|
+
}),
|
|
43
|
+
[objectKey("apps/v1", "Deployment", "prod-twin", "prod-ns")]: ownedObject("apps/v1", "Deployment", "prod-twin", "prod-ns", {
|
|
44
|
+
metadata: { labels: markerLabels("shop", "prod") },
|
|
45
|
+
}),
|
|
46
|
+
[objectKey("apps/v1", "Deployment", "no-env", "prod-ns")]: ownedObject("apps/v1", "Deployment", "no-env", "prod-ns", {
|
|
47
|
+
metadata: { labels: markerLabels("shop") },
|
|
48
|
+
}),
|
|
49
|
+
[objectKey("apps/v1", "Deployment", "handmade", "prod-ns")]: {
|
|
50
|
+
apiVersion: "apps/v1",
|
|
51
|
+
kind: "Deployment",
|
|
52
|
+
metadata: { name: "handmade", namespace: "prod-ns", labels: { app: "handmade" } },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("teardownOwned — marker-env-scoped enumeration", () => {
|
|
60
|
+
test("selects exactly the objects carrying this stack + env, nothing near", async () => {
|
|
61
|
+
const cluster = estateCluster();
|
|
62
|
+
const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
|
|
63
|
+
expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["dev-ns", "prod-ns/cache", "prod-ns/web"]);
|
|
64
|
+
for (const candidate of enumeration.candidates) {
|
|
65
|
+
expect(candidate.marker).toEqual(MARKER);
|
|
66
|
+
}
|
|
67
|
+
const web = enumeration.candidates.find((c) => c.name === "prod-ns/web")!;
|
|
68
|
+
expect(web.type).toBe("K8s::Apps::Deployment");
|
|
69
|
+
expect(web.physicalId).toBe("uid-web");
|
|
70
|
+
expect(enumeration.holes ?? []).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the selector is sent to the server: managed-by + stack + env", async () => {
|
|
74
|
+
const cluster = estateCluster();
|
|
75
|
+
await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
|
|
76
|
+
const listQueries = cluster.layer.requests
|
|
77
|
+
.filter((r) => r.method === "GET" && r.query.labelSelector !== undefined)
|
|
78
|
+
.map((r) => r.query.labelSelector);
|
|
79
|
+
expect(listQueries.length).toBeGreaterThan(0);
|
|
80
|
+
for (const selector of listQueries) {
|
|
81
|
+
expect(selector).toBe(
|
|
82
|
+
`${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE},` +
|
|
83
|
+
`${LABEL_OWNERSHIP_KEYS.stack}=shop,${LABEL_OWNERSHIP_KEYS.env}=dev`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("an object already terminating is not a candidate", async () => {
|
|
89
|
+
const cluster = fakeCluster({
|
|
90
|
+
objects: {
|
|
91
|
+
[objectKey("apps/v1", "Deployment", "leaving", "prod-ns")]: ownedObject("apps/v1", "Deployment", "leaving", "prod-ns", {
|
|
92
|
+
metadata: { labels: markerLabels("shop", "dev"), deletionTimestamp: "2026-08-23T00:00:00Z" } as never,
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
|
|
97
|
+
expect(enumeration.candidates).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a kind that fails to list is a hole, and the others still enumerate (#1089)", async () => {
|
|
101
|
+
const cluster = estateCluster({
|
|
102
|
+
respond: (req) => {
|
|
103
|
+
if (req.method === "GET" && req.path.endsWith("/deployments") && req.query.labelSelector !== undefined) {
|
|
104
|
+
return { status: 500, body: statusBody(500, "InternalError", "etcd is unhappy") };
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
|
|
110
|
+
expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["dev-ns", "prod-ns/cache"]);
|
|
111
|
+
const hole = (enumeration.holes ?? []).find((h) => h.name === "K8s::Apps::Deployment");
|
|
112
|
+
expect(hole).toBeDefined();
|
|
113
|
+
expect(hole!.reason).toBe("read-failed");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("executeTeardown — typed deletes, namespaces last", () => {
|
|
118
|
+
const candidates = [
|
|
119
|
+
{ name: "dev-ns", type: "K8s::Core::Namespace", marker: MARKER },
|
|
120
|
+
{ name: "prod-ns/web", type: "K8s::Apps::Deployment", physicalId: "uid-web", marker: MARKER },
|
|
121
|
+
{ name: "prod-ns/cache", type: "K8s::Core::Service", marker: MARKER },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
test("deletes every candidate through the typed client, namespace last", async () => {
|
|
125
|
+
const cluster = estateCluster();
|
|
126
|
+
const execution = await executeTeardown({ environment: "dev", marker: MARKER, candidates }, cluster.connector);
|
|
127
|
+
|
|
128
|
+
expect(execution.outcomes.map((o) => ({ name: o.name, outcome: o.outcome }))).toEqual([
|
|
129
|
+
{ name: "prod-ns/web", outcome: "deleted" },
|
|
130
|
+
{ name: "prod-ns/cache", outcome: "deleted" },
|
|
131
|
+
{ name: "dev-ns", outcome: "deleted" },
|
|
132
|
+
]);
|
|
133
|
+
const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
|
|
134
|
+
expect(deletes).toEqual([
|
|
135
|
+
"/apis/apps/v1/namespaces/prod-ns/deployments/web",
|
|
136
|
+
"/api/v1/namespaces/prod-ns/services/cache",
|
|
137
|
+
"/api/v1/namespaces/dev-ns",
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an already-absent candidate is deleted (idempotent), with no DELETE issued", async () => {
|
|
142
|
+
const cluster = fakeCluster({ objects: {} });
|
|
143
|
+
const execution = await executeTeardown(
|
|
144
|
+
{ environment: "dev", marker: MARKER, candidates: [candidates[1]] },
|
|
145
|
+
cluster.connector,
|
|
146
|
+
);
|
|
147
|
+
expect(execution.outcomes).toEqual([
|
|
148
|
+
{
|
|
149
|
+
name: "prod-ns/web",
|
|
150
|
+
type: "K8s::Apps::Deployment",
|
|
151
|
+
physicalId: "uid-web",
|
|
152
|
+
outcome: "deleted",
|
|
153
|
+
detail: "already absent",
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a live object whose marker changed since planning is not-prunable, never deleted", async () => {
|
|
160
|
+
const cluster = fakeCluster({
|
|
161
|
+
objects: {
|
|
162
|
+
[objectKey("apps/v1", "Deployment", "web", "prod-ns")]: ownedObject("apps/v1", "Deployment", "web", "prod-ns", {
|
|
163
|
+
metadata: { labels: markerLabels("blog", "dev") }, // re-stamped by someone else
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const execution = await executeTeardown(
|
|
168
|
+
{ environment: "dev", marker: MARKER, candidates: [candidates[1]] },
|
|
169
|
+
cluster.connector,
|
|
170
|
+
);
|
|
171
|
+
expect(execution.outcomes[0].outcome).toBe("not-prunable");
|
|
172
|
+
expect(execution.outcomes[0].detail).toContain("marker identity");
|
|
173
|
+
expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a refused delete is a failed outcome with the server's message, not a throw", async () => {
|
|
177
|
+
const cluster = estateCluster({
|
|
178
|
+
respond: (req) =>
|
|
179
|
+
req.method === "DELETE"
|
|
180
|
+
? { status: 403, body: statusBody(403, "Forbidden", "RBAC says no") }
|
|
181
|
+
: undefined,
|
|
182
|
+
});
|
|
183
|
+
const execution = await executeTeardown(
|
|
184
|
+
{ environment: "dev", marker: MARKER, candidates: [candidates[1]] },
|
|
185
|
+
cluster.connector,
|
|
186
|
+
);
|
|
187
|
+
expect(execution.outcomes[0].outcome).toBe("failed");
|
|
188
|
+
expect(execution.outcomes[0].detail).toContain("RBAC says no");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a generated-once secret survives the env sweep with a retained row; a plain owned secret still deletes (#1830)", async () => {
|
|
192
|
+
const cluster = fakeCluster({
|
|
193
|
+
objects: {
|
|
194
|
+
[objectKey("v1", "Secret", "master-key", "prod-ns")]: ownedObject("v1", "Secret", "master-key", "prod-ns", {
|
|
195
|
+
metadata: {
|
|
196
|
+
labels: {
|
|
197
|
+
...markerLabels("shop", "dev"),
|
|
198
|
+
"chant.intentius.io/generated-once": "true",
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
[objectKey("v1", "Secret", "plain", "prod-ns")]: ownedObject("v1", "Secret", "plain", "prod-ns", {
|
|
203
|
+
metadata: { labels: markerLabels("shop", "dev") },
|
|
204
|
+
}),
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Both are owned by this env, so both enumerate — the retained row is the
|
|
209
|
+
// execution's loud answer, not a silent hole in the plan.
|
|
210
|
+
const enumeration = await teardownOwned({ environment: "dev", marker: MARKER }, cluster.connector);
|
|
211
|
+
expect(enumeration.candidates.map((c) => c.name).sort()).toEqual(["prod-ns/master-key", "prod-ns/plain"]);
|
|
212
|
+
|
|
213
|
+
const execution = await executeTeardown(
|
|
214
|
+
{ environment: "dev", marker: MARKER, candidates: enumeration.candidates },
|
|
215
|
+
cluster.connector,
|
|
216
|
+
);
|
|
217
|
+
const byName = new Map(execution.outcomes.map((o) => [o.name, o]));
|
|
218
|
+
expect(byName.get("prod-ns/master-key")!.outcome).toBe("retained");
|
|
219
|
+
expect(byName.get("prod-ns/master-key")!.detail).toContain("generated-once");
|
|
220
|
+
expect(byName.get("prod-ns/plain")!.outcome).toBe("deleted");
|
|
221
|
+
|
|
222
|
+
// Never deleted: the only DELETE is the plain secret's.
|
|
223
|
+
const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
|
|
224
|
+
expect(deletes).toEqual(["/api/v1/namespaces/prod-ns/secrets/plain"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a candidate of an unmapped type is not-prunable, not a crash", async () => {
|
|
228
|
+
const cluster = fakeCluster({ objects: {} });
|
|
229
|
+
const execution = await executeTeardown(
|
|
230
|
+
{
|
|
231
|
+
environment: "dev",
|
|
232
|
+
marker: MARKER,
|
|
233
|
+
candidates: [{ name: "mystery", type: "K8s::NotAThing::Widget", marker: MARKER }],
|
|
234
|
+
},
|
|
235
|
+
cluster.connector,
|
|
236
|
+
);
|
|
237
|
+
expect(execution.outcomes[0].outcome).toBe("not-prunable");
|
|
238
|
+
expect(execution.outcomes[0].detail).toContain("no operation mapping");
|
|
239
|
+
});
|
|
240
|
+
});
|