@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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WK8505: committed-encrypted secret with no Flux decryption wiring
|
|
3
|
+
*
|
|
4
|
+
* Flux is the thing that actually decrypts a committed-encrypted secret's
|
|
5
|
+
* sidecar, via `spec.decryption` on the reconciling `Kustomization`
|
|
6
|
+
* (`FluxAppFor`'s `decryption` option). Nothing forces the two calls to be
|
|
7
|
+
* wired together: `declareSecret({ provenance: "committed-encrypted" })` and
|
|
8
|
+
* `FluxAppFor(...)` are independent, so it is easy to add the ciphertext
|
|
9
|
+
* without adding the wiring — the build succeeds, WK8504 passes because the
|
|
10
|
+
* file resolves cleanly, and Flux still applies the sidecar as raw
|
|
11
|
+
* `ENC[...]` bytes because nothing told it to decrypt first.
|
|
12
|
+
*
|
|
13
|
+
* Warning, not error. The reference project shape (`examples/flux-apps`)
|
|
14
|
+
* runs the workload build and the Flux build as separate `chant build`
|
|
15
|
+
* invocations, so "no Kustomization in THIS build sets spec.decryption" can
|
|
16
|
+
* mean "wired up in the other build" as easily as "forgotten" — the design
|
|
17
|
+
* doc's §4 explains why no path-to-build-target join exists to tell those
|
|
18
|
+
* apart. Promote to error if that join ever becomes available.
|
|
19
|
+
*
|
|
20
|
+
* Fires only on claims that actually resolved (`problems.length === 0`, via
|
|
21
|
+
* `resolveEncryptedSecretClaims`), not on every raw declaration. An
|
|
22
|
+
* unresolved declaration is already WK8504's job at error severity; firing
|
|
23
|
+
* this warning on top of it would report a Flux wiring gap for a Secret the
|
|
24
|
+
* build has not confirmed exists.
|
|
25
|
+
*/
|
|
26
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
27
|
+
import { allManifests, manifestsOfKind } from "./argo-helpers";
|
|
28
|
+
import type { K8sManifest } from "./k8s-helpers";
|
|
29
|
+
import { resolveEncryptedSecretClaims } from "./sops-helpers";
|
|
30
|
+
|
|
31
|
+
/** Flux Kustomizations only — the kustomize.config.k8s.io kind shares the name. */
|
|
32
|
+
function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
|
|
33
|
+
return manifestsOfKind(manifests, "Kustomization").filter((m) => {
|
|
34
|
+
const apiVersion = m.apiVersion;
|
|
35
|
+
return typeof apiVersion !== "string" || apiVersion.startsWith("kustomize.toolkit.fluxcd.io/");
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const wk8505: PostSynthCheck = {
|
|
40
|
+
id: "WK8505",
|
|
41
|
+
description:
|
|
42
|
+
"committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it",
|
|
43
|
+
|
|
44
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
45
|
+
const kustomizations = fluxKustomizations(allManifests(ctx));
|
|
46
|
+
if (kustomizations.length === 0) return [];
|
|
47
|
+
if (kustomizations.some((k) => k.spec?.decryption !== undefined)) return [];
|
|
48
|
+
|
|
49
|
+
const resolved = resolveEncryptedSecretClaims(ctx).filter((claim) => claim.problems.length === 0);
|
|
50
|
+
|
|
51
|
+
return resolved.map((claim) => ({
|
|
52
|
+
checkId: "WK8505",
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message:
|
|
55
|
+
`declareSecret("${claim.declaration.name}") is committed-encrypted, but no Flux Kustomization ` +
|
|
56
|
+
`in this build sets spec.decryption — Flux will apply "${claim.filename}" without decrypting it. ` +
|
|
57
|
+
`Add decryption: "sops" to the FluxAppFor reconciling the path that carries it, or ignore if that ` +
|
|
58
|
+
`Kustomization is declared in a different build.`,
|
|
59
|
+
entity: claim.declaration.name,
|
|
60
|
+
lexicon: "k8s",
|
|
61
|
+
}));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensureSecret` — the activity behind core's `ensureSecret(...)` op step
|
|
3
|
+
* builder (#1829), provided by the store's lexicon as that builder promises
|
|
4
|
+
* (#1830). Registered by name through the activity registry when a project's
|
|
5
|
+
* `chant.config.ts` lists the `k8s` lexicon, exactly like `kubectlApply`.
|
|
6
|
+
*
|
|
7
|
+
* All it does is connect (the same environment→cluster binding every k8s
|
|
8
|
+
* path uses), build the namespace-scoped store adapter, and run core's one
|
|
9
|
+
* materialization engine over it. The contract — present means done, never
|
|
10
|
+
* mint over an existing value, mismatches fail naming key names only, no
|
|
11
|
+
* output carries material — lives in core (`secret-materialization.ts`) and
|
|
12
|
+
* in the adapter (../../secret-store.ts), not here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
ensureSecretMaterialization,
|
|
17
|
+
type EnsureSecretOutcome,
|
|
18
|
+
} from "@intentius/chant/secret-materialization";
|
|
19
|
+
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
20
|
+
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
21
|
+
import { k8sSecretStore } from "../../secret-store";
|
|
22
|
+
|
|
23
|
+
export interface EnsureSecretActivityArgs {
|
|
24
|
+
/** The Secret's `metadata.name`. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The declared key-set. Creation mints one value per key; verification compares names only. */
|
|
27
|
+
keys: string[];
|
|
28
|
+
/** Declared metadata (labels/annotations) an existing Secret must carry. Mismatches name the KEY. */
|
|
29
|
+
metadata?: Record<string, string>;
|
|
30
|
+
/** Target namespace. Omitted uses the connected context's default namespace. */
|
|
31
|
+
namespace?: string;
|
|
32
|
+
/** chant environment, resolved through `k8s.profiles.<env>.context`. */
|
|
33
|
+
environment?: string;
|
|
34
|
+
/** Explicit kubectl context — same precedence as `kubectlApply`. */
|
|
35
|
+
context?: string;
|
|
36
|
+
/** Project directory whose `chant.config.ts` carries the k8s profiles. */
|
|
37
|
+
cwd?: string;
|
|
38
|
+
/** Ownership stack to stamp on a minted Secret (with `env`, the full marker). */
|
|
39
|
+
stack?: string;
|
|
40
|
+
/** Ownership env to stamp alongside `stack`. */
|
|
41
|
+
env?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure a `generated-once` Secret exists in the target cluster. Returns
|
|
46
|
+
* names only — see core's `EnsureSecretOutcome`.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureSecret(
|
|
49
|
+
args: EnsureSecretActivityArgs,
|
|
50
|
+
_signal?: AbortSignal,
|
|
51
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
52
|
+
): Promise<EnsureSecretOutcome> {
|
|
53
|
+
const { client } = await connect({
|
|
54
|
+
...(args.environment !== undefined ? { environment: args.environment } : {}),
|
|
55
|
+
...(args.context !== undefined ? { context: args.context } : {}),
|
|
56
|
+
...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const marker: OwnershipMarker | undefined = args.stack
|
|
60
|
+
? { stack: args.stack, ...(args.env ? { env: args.env } : {}) }
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
const store = k8sSecretStore(args.namespace ?? client.defaultNamespace, client, {
|
|
64
|
+
...(marker ? { marker } : {}),
|
|
65
|
+
});
|
|
66
|
+
return ensureSecretMaterialization(store, {
|
|
67
|
+
name: args.name,
|
|
68
|
+
keys: args.keys,
|
|
69
|
+
...(args.metadata ? { metadata: args.metadata } : {}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
export { kubectlApply, applyManifest, readManifestDocuments } from "./kubectl";
|
|
21
21
|
export type { KubectlApplyArgs, ApplyManifestResult, AppliedRef, ApplyDeleteMode } from "./kubectl";
|
|
22
22
|
|
|
23
|
+
// Generated-once secret materialization (#1830) — the activity behind core's
|
|
24
|
+
// `ensureSecret(...)` step builder (#1829). Read-then-write over the
|
|
25
|
+
// namespace-scoped store adapter in ../../secret-store.ts; no output carries
|
|
26
|
+
// secret material.
|
|
27
|
+
export { ensureSecret } from "./ensure-secret";
|
|
28
|
+
export type { EnsureSecretActivityArgs } from "./ensure-secret";
|
|
29
|
+
|
|
23
30
|
export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo";
|
|
24
31
|
export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo";
|
|
25
32
|
|
|
@@ -461,6 +461,38 @@ describe("the ownership-scoped prune (chant #1075)", () => {
|
|
|
461
461
|
expect(result.pruned.map((p) => p.name)).toEqual(["old"]);
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
+
test("a generated-once secret is excluded from the prunable set and reported retained; a plain owned secret still prunes (#1830)", async () => {
|
|
465
|
+
const cluster = fakeCluster({
|
|
466
|
+
respond: echoApplies,
|
|
467
|
+
objects: {
|
|
468
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
|
|
469
|
+
[objectKey("v1", "Secret", "master-key", "prod")]: ownedObject("v1", "Secret", "master-key", "prod", {
|
|
470
|
+
metadata: {
|
|
471
|
+
labels: {
|
|
472
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
473
|
+
"chant.intentius.io/generated-once": "true",
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
}),
|
|
477
|
+
[objectKey("v1", "Secret", "stale-config", "prod")]: ownedObject("v1", "Secret", "stale-config", "prod"),
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
const result = await applyManifest(
|
|
481
|
+
{ manifest: manifest(), deleteMode: "owned-only" },
|
|
482
|
+
undefined,
|
|
483
|
+
cluster.connector,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
expect(result.retained).toEqual([
|
|
487
|
+
{ apiVersion: "v1", kind: "Secret", name: "master-key", namespace: "prod" },
|
|
488
|
+
]);
|
|
489
|
+
expect(result.pruned).toEqual([
|
|
490
|
+
{ apiVersion: "v1", kind: "Secret", name: "stale-config", namespace: "prod" },
|
|
491
|
+
]);
|
|
492
|
+
const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
|
|
493
|
+
expect(deletes).toEqual(["/api/v1/namespaces/prod/secrets/stale-config"]);
|
|
494
|
+
});
|
|
495
|
+
|
|
464
496
|
test("an object without chant's marker is never a candidate", async () => {
|
|
465
497
|
const cluster = clusterWithOrphans();
|
|
466
498
|
await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
|
|
@@ -45,6 +45,7 @@ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
|
45
45
|
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
46
46
|
import { operationFor } from "../../api/operation-surface";
|
|
47
47
|
import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
|
|
48
|
+
import { isGeneratedOnce } from "../../secret-labels";
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* How the apply treats chant-owned objects that are no longer declared. The
|
|
@@ -133,6 +134,15 @@ export interface ApplyManifestResult {
|
|
|
133
134
|
applied: AppliedRef[];
|
|
134
135
|
/** Objects deleted because they carried chant's marker and are no longer declared. */
|
|
135
136
|
pruned: AppliedRef[];
|
|
137
|
+
/**
|
|
138
|
+
* Owned, no longer declared, and deliberately KEPT (#1830, epic #1365
|
|
139
|
+
* decision 5): Secrets carrying the `chant.intentius.io/generated-once`
|
|
140
|
+
* label never enter the prunable set — the stored bytes are the only copy
|
|
141
|
+
* of material chant never held. Reported loudly here and on the console;
|
|
142
|
+
* deletion is an explicit act (`kubectl delete`, or a future gated op),
|
|
143
|
+
* never a prune's. Present whenever the prune ran.
|
|
144
|
+
*/
|
|
145
|
+
retained?: AppliedRef[];
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/** Read a manifest path — one file, or every YAML/JSON file in a directory. */
|
|
@@ -351,15 +361,15 @@ export async function applyManifest(
|
|
|
351
361
|
}
|
|
352
362
|
|
|
353
363
|
const deleteMode = args.deleteMode ?? "never";
|
|
354
|
-
const pruned =
|
|
364
|
+
const { pruned, retained } =
|
|
355
365
|
deleteMode === "never" || args.dryRun
|
|
356
|
-
? []
|
|
366
|
+
? { pruned: [], retained: [] }
|
|
357
367
|
: await pruneOrphans(client, applied, {
|
|
358
368
|
...(stack !== undefined ? { stack } : {}),
|
|
359
369
|
signal,
|
|
360
370
|
});
|
|
361
371
|
|
|
362
|
-
return { fieldManager, applied, pruned };
|
|
372
|
+
return { fieldManager, applied, pruned, retained };
|
|
363
373
|
} finally {
|
|
364
374
|
clearInterval(heartbeatInterval);
|
|
365
375
|
}
|
|
@@ -407,7 +417,7 @@ async function pruneOrphans(
|
|
|
407
417
|
client: K8sClient,
|
|
408
418
|
applied: readonly AppliedRef[],
|
|
409
419
|
options: { stack?: string; signal?: AbortSignal },
|
|
410
|
-
): Promise<AppliedRef[]> {
|
|
420
|
+
): Promise<{ pruned: AppliedRef[]; retained: AppliedRef[] }> {
|
|
411
421
|
const stack = options.stack;
|
|
412
422
|
const selector = [
|
|
413
423
|
`${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
|
|
@@ -476,6 +486,7 @@ async function pruneOrphans(
|
|
|
476
486
|
});
|
|
477
487
|
|
|
478
488
|
const candidates: AppliedRef[] = [];
|
|
489
|
+
const retained: AppliedRef[] = [];
|
|
479
490
|
const seen = new Set<string>();
|
|
480
491
|
for (const { target, item } of found.flat()) {
|
|
481
492
|
const name = item.metadata?.name;
|
|
@@ -494,6 +505,18 @@ async function pruneOrphans(
|
|
|
494
505
|
const key = refKey(ref);
|
|
495
506
|
if (declared.has(key) || seen.has(key)) continue;
|
|
496
507
|
seen.add(key);
|
|
508
|
+
// A generated-once Secret never enters the prunable set (#1830, epic
|
|
509
|
+
// #1365 decision 5): its bytes are the only copy of material chant never
|
|
510
|
+
// held, so no sweep may destroy them. Kept, and said loudly — deletion is
|
|
511
|
+
// an explicit act (`kubectl delete`, or a future gated op).
|
|
512
|
+
if (target.kind === "Secret" && isGeneratedOnce(item.metadata?.labels)) {
|
|
513
|
+
retained.push(ref);
|
|
514
|
+
console.log(
|
|
515
|
+
`${ref.apiVersion} ${ref.kind}/${ref.name} retained (generated-once — owned and no longer declared, ` +
|
|
516
|
+
`but never pruned; delete it explicitly if you mean to)`,
|
|
517
|
+
);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
497
520
|
candidates.push(ref);
|
|
498
521
|
}
|
|
499
522
|
|
|
@@ -504,7 +527,7 @@ async function pruneOrphans(
|
|
|
504
527
|
safeHeartbeat({ step: "prune", pruned: `${ref.kind}/${ref.name}` });
|
|
505
528
|
console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
|
|
506
529
|
}
|
|
507
|
-
return pruned;
|
|
530
|
+
return { pruned, retained };
|
|
508
531
|
}
|
|
509
532
|
|
|
510
533
|
function refKey(ref: AppliedRef): string {
|
|
@@ -76,6 +76,40 @@ describe("readiness model", () => {
|
|
|
76
76
|
expect(firstTerminal(ready([{ type: "Ready", status: "False", reason: "AuthenticationFailed" } as never]), git)).toBeDefined();
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("CNRM overrides fail fast on Ready=False wedge reasons, DependencyNotReady is not terminal (#1725)", () => {
|
|
80
|
+
// ContainerCluster and ContainerNodePool back examples/cockroachdb-multi-region-gke's
|
|
81
|
+
// `longInfra` waits — a cluster wedged at UpdateFailed used to poll out
|
|
82
|
+
// the whole timeout instead of failing fast.
|
|
83
|
+
const cluster = readinessFor("container.cnrm.cloud.google.com", "ContainerCluster");
|
|
84
|
+
expect(cluster).not.toBe(DEFAULT_READINESS);
|
|
85
|
+
expect(isReady(ready([{ type: "Ready", status: "True" }]), cluster)).toBe(true);
|
|
86
|
+
|
|
87
|
+
const updateFailed = ready([{ type: "Ready", status: "False", reason: "UpdateFailed" } as never]);
|
|
88
|
+
expect(isReady(updateFailed, cluster)).toBe(false);
|
|
89
|
+
expect(firstTerminal(updateFailed, cluster)).toBeDefined();
|
|
90
|
+
|
|
91
|
+
const managementConflict = ready([{ type: "Ready", status: "False", reason: "ManagementConflict" } as never]);
|
|
92
|
+
expect(firstTerminal(managementConflict, cluster)).toBeDefined();
|
|
93
|
+
|
|
94
|
+
// DependencyNotReady is the ordinary "still waiting on another CNRM
|
|
95
|
+
// resource" state (e.g. a cluster waiting on its network) — not a wedge.
|
|
96
|
+
const dependencyNotReady = ready([{ type: "Ready", status: "False", reason: "DependencyNotReady" } as never]);
|
|
97
|
+
expect(firstTerminal(dependencyNotReady, cluster)).toBeUndefined();
|
|
98
|
+
|
|
99
|
+
// In-progress reconciles are not terminal either.
|
|
100
|
+
const updating = ready([{ type: "Ready", status: "False", reason: "Updating" } as never]);
|
|
101
|
+
expect(firstTerminal(updating, cluster)).toBeUndefined();
|
|
102
|
+
|
|
103
|
+
const nodePool = readinessFor("container.cnrm.cloud.google.com", "ContainerNodePool");
|
|
104
|
+
expect(nodePool).not.toBe(DEFAULT_READINESS);
|
|
105
|
+
expect(
|
|
106
|
+
firstTerminal(ready([{ type: "Ready", status: "False", reason: "UpdateFailed" } as never]), nodePool),
|
|
107
|
+
).toBeDefined();
|
|
108
|
+
expect(
|
|
109
|
+
firstTerminal(ready([{ type: "Ready", status: "False", reason: "DependencyNotReady" } as never]), nodePool),
|
|
110
|
+
).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
79
113
|
test("Argo override uses health/sync, not a Ready condition", () => {
|
|
80
114
|
const spec = readinessFor("argoproj.io", "Application");
|
|
81
115
|
expect(spec).not.toBe(DEFAULT_READINESS);
|
|
@@ -98,6 +98,30 @@ const FLUX_TERMINAL = (reasons: string[]): ReadinessSpec => ({
|
|
|
98
98
|
observedGeneration: true,
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Config Connector (CNRM) wedge reasons (#1725): CNRM is kstatus-conformant —
|
|
103
|
+
* `Ready=True` + `observedGeneration` is the correct ready predicate, same as
|
|
104
|
+
* `DEFAULT_READINESS` — so, like `FLUX_TERMINAL`, the value added here is
|
|
105
|
+
* failing fast on a `Ready=False` reason that will not heal by waiting.
|
|
106
|
+
* `UpdateFailed` is CNRM giving up on a reconcile (bad spec, quota, API
|
|
107
|
+
* error); `ManagementConflict` is two controllers fighting over the same GCP
|
|
108
|
+
* resource. Neither resolves on its own.
|
|
109
|
+
*
|
|
110
|
+
* `DependencyNotReady` is deliberately excluded: it is the normal state while
|
|
111
|
+
* a resource waits on another one it depends on (e.g. a `ContainerCluster`
|
|
112
|
+
* waiting on its network) — exactly the "still being created" case this spec
|
|
113
|
+
* has to distinguish from a real wedge, not fold into it.
|
|
114
|
+
*
|
|
115
|
+
* One entry per kind, matching `FLUX_TERMINAL`'s convention, rather than a
|
|
116
|
+
* group-level key: CNRM's kinds don't share a single apiGroup (`ContainerCluster`
|
|
117
|
+
* and `ContainerNodePool` are `container.cnrm.cloud.google.com`; `SQLInstance`
|
|
118
|
+
* would be `sql.cnrm.cloud.google.com`, etc.), so a group-level key would need
|
|
119
|
+
* prefix matching against `*.cnrm.cloud.google.com` — a real change to
|
|
120
|
+
* `readinessFor`'s exact-match lookup for a handful of entries. Not worth it
|
|
121
|
+
* until more CNRM kinds actually need the override.
|
|
122
|
+
*/
|
|
123
|
+
const CNRM_TERMINAL = (): ReadinessSpec => FLUX_TERMINAL(["UpdateFailed", "ManagementConflict"]);
|
|
124
|
+
|
|
101
125
|
export const READINESS_OVERRIDES: Record<string, ReadinessSpec> = {
|
|
102
126
|
"argoproj.io/Application": {
|
|
103
127
|
ready: [
|
|
@@ -139,6 +163,8 @@ export const READINESS_OVERRIDES: Record<string, ReadinessSpec> = {
|
|
|
139
163
|
"ChartPackageError",
|
|
140
164
|
"StorageOperationFailed",
|
|
141
165
|
]),
|
|
166
|
+
"container.cnrm.cloud.google.com/ContainerCluster": CNRM_TERMINAL(),
|
|
167
|
+
"container.cnrm.cloud.google.com/ContainerNodePool": CNRM_TERMINAL(),
|
|
142
168
|
};
|
|
143
169
|
|
|
144
170
|
/** Resolve the readiness spec for a resource: registry override, else default. */
|
package/src/plugin.ts
CHANGED
|
@@ -31,6 +31,8 @@ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
|
31
31
|
import { k8sConfigSchema } from "./config-schema";
|
|
32
32
|
import type { K8sChantConfig } from "./config";
|
|
33
33
|
import { renderKustomizeRoots } from "./kustomize/root";
|
|
34
|
+
import { encryptedSecretBuildRoot } from "./sops/encrypted-secret-file";
|
|
35
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
34
36
|
|
|
35
37
|
export const k8sPlugin: LexiconPlugin = {
|
|
36
38
|
name: "k8s",
|
|
@@ -79,10 +81,27 @@ export const k8sPlugin: LexiconPlugin = {
|
|
|
79
81
|
// fallback) and the documents join the build as verbatim manifest entities:
|
|
80
82
|
// serialized with ownership stamping, checked post-synth, observed by
|
|
81
83
|
// `lifecycle diff --live`. See ./kustomize/root.ts.
|
|
84
|
+
//
|
|
85
|
+
// The same hook also resolves committed-encrypted `declareSecret()`
|
|
86
|
+
// declarations: it reads the ciphertext each one names and contributes it
|
|
87
|
+
// as a verbatim sidecar entity (see ./sops/encrypted-secret-file.ts). Both
|
|
88
|
+
// halves are one filesystem read at the one sanctioned impure seam.
|
|
82
89
|
async buildRoots(ctx) {
|
|
90
|
+
const entities = new Map<string, Declarable>();
|
|
91
|
+
const warnings: string[] = [];
|
|
92
|
+
|
|
83
93
|
const roots = (ctx.config as { k8s?: K8sChantConfig }).k8s?.kustomize?.roots ?? [];
|
|
84
|
-
if (roots.length
|
|
85
|
-
|
|
94
|
+
if (roots.length > 0) {
|
|
95
|
+
const rendered = await renderKustomizeRoots({ projectRoot: ctx.projectRoot, roots });
|
|
96
|
+
for (const [name, entity] of rendered.entities) entities.set(name, entity);
|
|
97
|
+
warnings.push(...rendered.warnings);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const encrypted = await encryptedSecretBuildRoot(ctx);
|
|
101
|
+
for (const [name, entity] of encrypted.entities) entities.set(name, entity);
|
|
102
|
+
warnings.push(...(encrypted.warnings ?? []));
|
|
103
|
+
|
|
104
|
+
return { entities, warnings };
|
|
86
105
|
},
|
|
87
106
|
|
|
88
107
|
// K8s YAML has no template interpolation functions like CloudFormation's
|
|
@@ -678,6 +697,19 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
678
697
|
return describeResources(options);
|
|
679
698
|
},
|
|
680
699
|
|
|
700
|
+
// Env teardown (#1222): enumeration + execution over the marker label
|
|
701
|
+
// selector (managed-by + stack + env), through the same typed client the
|
|
702
|
+
// prune uses. Execution deletes namespaces last. See ./teardown.ts.
|
|
703
|
+
async teardownOwned(options) {
|
|
704
|
+
const { teardownOwned } = await import("./teardown");
|
|
705
|
+
return teardownOwned(options);
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async executeTeardown(options) {
|
|
709
|
+
const { executeTeardown } = await import("./teardown");
|
|
710
|
+
return executeTeardown(options);
|
|
711
|
+
},
|
|
712
|
+
|
|
681
713
|
/**
|
|
682
714
|
* Deploy-unit presence + health for `chant components status --live`
|
|
683
715
|
* (#1495 piece 3): a Kubernetes deploy unit is the label selector chant's
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `generated-once` marker label (chant #1830, epic #1365 decision 5).
|
|
3
|
+
*
|
|
4
|
+
* `k8sSecretStore` (./secret-store.ts) stamps this label onto every Secret it
|
|
5
|
+
* mints, next to the ownership marker. Both k8s prune paths key on it:
|
|
6
|
+
*
|
|
7
|
+
* - the apply-path prune (`applyManifest`'s owned-only sweep,
|
|
8
|
+
* op/activities/kubectl.ts) excludes a labeled Secret from the prunable set
|
|
9
|
+
* and reports it `retained` instead of deleting it;
|
|
10
|
+
* - env teardown (./teardown.ts `executeTeardown`) reports the same `retained`
|
|
11
|
+
* outcome for it, loudly, and never deletes it.
|
|
12
|
+
*
|
|
13
|
+
* The rationale is the materialization contract: the stored bytes are the ONLY
|
|
14
|
+
* copy of material chant never held, so no sweep may destroy them. Deletion is
|
|
15
|
+
* an explicit human act — `kubectl delete secret <name>`, or a future gated
|
|
16
|
+
* op — never a side effect of "no longer declared".
|
|
17
|
+
*
|
|
18
|
+
* Dependency-light on purpose: no client import, so the constants are usable
|
|
19
|
+
* from the build path, the prune paths, and the docs/tests alike.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The label key a generated-once Secret carries. */
|
|
23
|
+
export const GENERATED_ONCE_LABEL_KEY = "chant.intentius.io/generated-once";
|
|
24
|
+
|
|
25
|
+
/** The label value `k8sSecretStore` stamps. */
|
|
26
|
+
export const GENERATED_ONCE_LABEL_VALUE = "true";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when a live object's labels mark it generated-once. Any non-empty
|
|
30
|
+
* value counts: the label's presence is the claim, and a sweep must err on
|
|
31
|
+
* the side of keeping.
|
|
32
|
+
*/
|
|
33
|
+
export function isGeneratedOnce(labels: Record<string, unknown> | undefined): boolean {
|
|
34
|
+
const value = labels?.[GENERATED_ONCE_LABEL_KEY];
|
|
35
|
+
return typeof value === "string" && value.length > 0;
|
|
36
|
+
}
|
|
@@ -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
|
+
});
|