@intentius/chant-lexicon-k8s 0.58.0 → 0.59.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/api/fake-cluster.d.ts +7 -6
- package/dist/api/fake-cluster.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/cron-schedule.d.ts +26 -0
- package/dist/composites/cron-schedule.d.ts.map +1 -0
- package/dist/composites/cron-workload.d.ts +1 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +1 -1
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config-schema.d.ts +3 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/effect-receipt-row.d.ts +164 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/wk8505.d.ts +3 -1
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/WK8505.md +2 -2
- package/dist/okf/types/Kustomization.md +1 -1
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +113 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/wk8505.ts +4 -2
- package/dist/serializer.d.ts.map +1 -1
- package/dist/subscribe-changes.d.ts +84 -0
- package/dist/subscribe-changes.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/fake-cluster.ts +17 -4
- package/src/codegen/docs.ts +7 -0
- package/src/composites/composites.test.ts +59 -1
- package/src/composites/cron-schedule.ts +47 -0
- package/src/composites/cron-workload.ts +4 -1
- package/src/composites/operator-stack.ts +3 -4
- package/src/config-schema.ts +5 -0
- package/src/config.ts +23 -0
- package/src/deep-observe.ts +26 -7
- package/src/describe-resources.ts +31 -7
- package/src/effect-receipt-row.test.ts +285 -0
- package/src/effect-receipt-row.ts +268 -0
- package/src/index.ts +22 -0
- package/src/lint/audit-catalog.ts +1 -1
- package/src/lint/post-synth/wk8505.ts +4 -2
- package/src/op/activities/index.ts +14 -0
- package/src/op/activities/kubectl.test.ts +38 -0
- package/src/op/activities/kubectl.ts +15 -0
- package/src/plugin.ts +15 -0
- package/src/receipt-store.test.ts +380 -0
- package/src/receipt-store.ts +290 -0
- package/src/serializer.ts +92 -1
- package/src/subscribe-changes.test.ts +368 -0
- package/src/subscribe-changes.ts +210 -0
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* invocations, so "no Kustomization in THIS build sets spec.decryption" can
|
|
16
16
|
* mean "wired up in the other build" as easily as "forgotten" — the design
|
|
17
17
|
* doc's §4 explains why no path-to-build-target join exists to tell those
|
|
18
|
-
* apart.
|
|
18
|
+
* apart. Single-build-root scoped, tracked by chant #1939 as a candidate for
|
|
19
|
+
* an opt-in project-level mode; promote to error if that join ever becomes
|
|
20
|
+
* available.
|
|
19
21
|
*
|
|
20
22
|
* Fires only on claims that actually resolved (`problems.length === 0`, via
|
|
21
23
|
* `resolveEncryptedSecretClaims`), not on every raw declaration. An
|
|
@@ -39,7 +41,7 @@ function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
|
|
|
39
41
|
export const wk8505: PostSynthCheck = {
|
|
40
42
|
id: "WK8505",
|
|
41
43
|
description:
|
|
42
|
-
"committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it",
|
|
44
|
+
"committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it. Sees one build root at a time, so it goes silent when that Kustomization lives in a different build root (chant #1939).",
|
|
43
45
|
|
|
44
46
|
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
45
47
|
const kustomizations = fluxKustomizations(allManifests(ctx));
|
|
@@ -63,3 +63,17 @@ export type {
|
|
|
63
63
|
// truth — see `lexicons/k8s/src/config.ts` for the config shape.
|
|
64
64
|
export { resolveClusterTarget, ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
|
|
65
65
|
export type { ResolvedClusterTarget, K8sClusterProfile, K8sConfigShape } from "@intentius/chant/kubectl-context";
|
|
66
|
+
|
|
67
|
+
// Effect-receipt activities (#2074): core's receipt seam (#1834) bound to
|
|
68
|
+
// this lexicon's ConfigMap-backed store (../../receipt-store.ts), the same
|
|
69
|
+
// way the aws lexicon binds its SSM store (#1835). Re-exported individually:
|
|
70
|
+
// `receiptRead`/`receiptWrite` serve the `effect()` step's
|
|
71
|
+
// read-compare-run-write, `receiptStaleness` serves WatchOp's read-only
|
|
72
|
+
// staleness reporting.
|
|
73
|
+
import { receiptActivities } from "@intentius/chant/op/receipt-store";
|
|
74
|
+
import { k8sReceiptStore } from "../../receipt-store";
|
|
75
|
+
|
|
76
|
+
const boundReceiptActivities = receiptActivities(k8sReceiptStore());
|
|
77
|
+
export const receiptRead = boundReceiptActivities.receiptRead;
|
|
78
|
+
export const receiptWrite = boundReceiptActivities.receiptWrite;
|
|
79
|
+
export const receiptStaleness = boundReceiptActivities.receiptStaleness;
|
|
@@ -494,6 +494,44 @@ describe("the ownership-scoped prune (chant #1075)", () => {
|
|
|
494
494
|
expect(deletes).toEqual(["/api/v1/namespaces/prod/secrets/stale-config"]);
|
|
495
495
|
});
|
|
496
496
|
|
|
497
|
+
test("an effect receipt ConfigMap is excluded from the prunable set and reported retained (#2074)", async () => {
|
|
498
|
+
const cluster = fakeCluster({
|
|
499
|
+
respond: echoApplies,
|
|
500
|
+
objects: {
|
|
501
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
|
|
502
|
+
[objectKey("v1", "ConfigMap", "chant-receipt.demo.prod.db-seed", "prod")]: ownedObject(
|
|
503
|
+
"v1",
|
|
504
|
+
"ConfigMap",
|
|
505
|
+
"chant-receipt.demo.prod.db-seed",
|
|
506
|
+
"prod",
|
|
507
|
+
{
|
|
508
|
+
metadata: {
|
|
509
|
+
labels: {
|
|
510
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
511
|
+
"chant.intentius.io/effect-receipt": "db-seed",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
),
|
|
516
|
+
[objectKey("v1", "ConfigMap", "stale-config", "prod")]: ownedObject("v1", "ConfigMap", "stale-config", "prod"),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
const result = await applyManifest(
|
|
520
|
+
{ manifest: manifest(), deleteMode: "owned-only" },
|
|
521
|
+
undefined,
|
|
522
|
+
cluster.connector,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
expect(result.retained).toEqual([
|
|
526
|
+
{ apiVersion: "v1", kind: "ConfigMap", name: "chant-receipt.demo.prod.db-seed", namespace: "prod" },
|
|
527
|
+
]);
|
|
528
|
+
expect(result.pruned).toEqual([
|
|
529
|
+
{ apiVersion: "v1", kind: "ConfigMap", name: "stale-config", namespace: "prod" },
|
|
530
|
+
]);
|
|
531
|
+
const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
|
|
532
|
+
expect(deletes).toEqual(["/api/v1/namespaces/prod/configmaps/stale-config"]);
|
|
533
|
+
});
|
|
534
|
+
|
|
497
535
|
test("an object without chant's marker is never a candidate", async () => {
|
|
498
536
|
const cluster = clusterWithOrphans();
|
|
499
537
|
await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
|
|
@@ -45,6 +45,7 @@ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
|
45
45
|
import { operationFor } from "../../api/operation-surface";
|
|
46
46
|
import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
|
|
47
47
|
import { isGeneratedOnce } from "../../secret-labels";
|
|
48
|
+
import { isEffectReceiptObject } from "../../effect-receipt-row";
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* How the apply treats chant-owned objects that are no longer declared. The
|
|
@@ -503,6 +504,20 @@ async function pruneOrphans(
|
|
|
503
504
|
);
|
|
504
505
|
continue;
|
|
505
506
|
}
|
|
507
|
+
// An effect receipt never enters the prunable set either (#2074, epic
|
|
508
|
+
// #1703 decision 3). It is chant-owned and is never in ANY apply set,
|
|
509
|
+
// the `effect()` step being its sole writer, which is exactly the shape
|
|
510
|
+
// this sweep deletes. Pruning one would tell the next plan that an effect
|
|
511
|
+
// never ran and re-fire it, or worse, be re-stamped by nothing and leave
|
|
512
|
+
// the estate re-running a migration on every apply.
|
|
513
|
+
if (target.kind === "ConfigMap" && isEffectReceiptObject(item.metadata?.labels)) {
|
|
514
|
+
retained.push(ref);
|
|
515
|
+
console.log(
|
|
516
|
+
`${ref.apiVersion} ${ref.kind}/${ref.name} retained (effect receipt: owned and never declared ` +
|
|
517
|
+
`in an apply set, but never pruned; the effect() step is its sole writer)`,
|
|
518
|
+
);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
506
521
|
candidates.push(ref);
|
|
507
522
|
}
|
|
508
523
|
|
package/src/plugin.ts
CHANGED
|
@@ -738,6 +738,21 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
738
738
|
return exportResources(options);
|
|
739
739
|
},
|
|
740
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The change signal (#1981). A Watch on every declared kind, reported as a
|
|
743
|
+
* bare `onChange()` that wakes an operator tick early. Nothing a frame
|
|
744
|
+
* carries reaches the tick: the seam has no payload, and the tick that runs
|
|
745
|
+
* is the tick the timer would have run. Kubernetes is the one substrate
|
|
746
|
+
* chant reaches whose change stream needs nothing deployed into the observed
|
|
747
|
+
* cluster; see the verdict table in the operator guide. Implementation in
|
|
748
|
+
* ./subscribe-changes.ts, dynamically imported for the same reason
|
|
749
|
+
* describeResources is.
|
|
750
|
+
*/
|
|
751
|
+
async subscribeChanges(options) {
|
|
752
|
+
const { subscribeChanges } = await import("./subscribe-changes");
|
|
753
|
+
return subscribeChanges(options);
|
|
754
|
+
},
|
|
755
|
+
|
|
741
756
|
// Property-level live drift via SSA managed-fields (#1076, epic #1073).
|
|
742
757
|
// The reader lives in ./deep-observe.ts, loaded only through this dynamic
|
|
743
758
|
// import — same reason describeResources/exportResources are — so the API
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s ConfigMap receipt row, end to end (#2074, epic #1703).
|
|
3
|
+
*
|
|
4
|
+
* Every leg runs against ./api/fake-cluster.ts, a real
|
|
5
|
+
* `@intentius/chant-k8s-client` with only the HTTP send replaced, so no
|
|
6
|
+
* ambient kubeconfig is read and no cluster is contacted. The three legs the
|
|
7
|
+
* row has to close:
|
|
8
|
+
*
|
|
9
|
+
* 1. the `effect()` step materializes the receipt (the store's `write`);
|
|
10
|
+
* 2. the k8s observation reads it back (`describeResources` and the deep
|
|
11
|
+
* read), with the stored value on `attributes.value` where core's
|
|
12
|
+
* `readReceiptValue` looks for it;
|
|
13
|
+
* 3. WatchOp's staleness phase (`receiptStaleness`, #1834) fires on it.
|
|
14
|
+
*
|
|
15
|
+
* Plus the two things a receipt must NOT do: show up as property drift or as
|
|
16
|
+
* an unclaimed field, and be swept by the owned-only prune.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from "vitest";
|
|
20
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
21
|
+
|
|
22
|
+
const { k8sReceiptStore, observeReceiptRows, observeReceiptRowsDeep, receiptRowsFor, receiptValueOf } =
|
|
23
|
+
await import("./receipt-store");
|
|
24
|
+
const { describeResources } = await import("./describe-resources");
|
|
25
|
+
const { observeResourcesDeepK8s } = await import("./deep-observe");
|
|
26
|
+
const { k8sDeepNormalizationHooks } = await import("./deep-observe-hooks");
|
|
27
|
+
const { fakeCluster, objectKey } = await import("./api/fake-cluster");
|
|
28
|
+
const {
|
|
29
|
+
EffectReceipt,
|
|
30
|
+
receiptConfigMapRef,
|
|
31
|
+
renderReceiptComment,
|
|
32
|
+
K8S_EFFECT_RECEIPT_ENTITY_TYPE,
|
|
33
|
+
RECEIPT_DATA_KEY,
|
|
34
|
+
RECEIPT_LABEL_KEY,
|
|
35
|
+
} = await import("./effect-receipt-row");
|
|
36
|
+
const { receiptActivities, receiptCheckInput } = await import("@intentius/chant/op/receipt-store");
|
|
37
|
+
const { receiptExpectation, EXISTENCE_EXPECTATION } = await import("@intentius/chant/effect-receipt");
|
|
38
|
+
const { readReceiptValue, planReceipts, observedValueResolver } = await import(
|
|
39
|
+
"@intentius/chant/lifecycle/receipt-plan"
|
|
40
|
+
);
|
|
41
|
+
const { diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
42
|
+
const { normalizeDeepObservation } = await import("@intentius/chant/deep-observation");
|
|
43
|
+
const { statusBody } = await import("@intentius/chant-k8s-client/testing");
|
|
44
|
+
|
|
45
|
+
const IDENTITY = { stack: "demo", environment: "dev", namespace: "default" } as const;
|
|
46
|
+
const REF = receiptConfigMapRef("demo", "dev", "db-seed");
|
|
47
|
+
|
|
48
|
+
const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { version: "0042" } });
|
|
49
|
+
const EXPECTED = receiptExpectation(seeded);
|
|
50
|
+
|
|
51
|
+
/** The build output the serializer produces for a project declaring `seeded`. */
|
|
52
|
+
function buildOutput(namespace = "default"): string {
|
|
53
|
+
return `${renderReceiptComment({
|
|
54
|
+
seeded: {
|
|
55
|
+
kind: "ConfigMap",
|
|
56
|
+
namespace,
|
|
57
|
+
name: REF.name,
|
|
58
|
+
data: { [RECEIPT_DATA_KEY]: EXPECTED },
|
|
59
|
+
},
|
|
60
|
+
})}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A live receipt ConfigMap holding `value`. */
|
|
64
|
+
function liveReceipt(value: string, namespace = "default"): K8sObject {
|
|
65
|
+
return {
|
|
66
|
+
apiVersion: "v1",
|
|
67
|
+
kind: "ConfigMap",
|
|
68
|
+
metadata: {
|
|
69
|
+
name: REF.name,
|
|
70
|
+
namespace,
|
|
71
|
+
uid: "uid-receipt",
|
|
72
|
+
resourceVersion: "3",
|
|
73
|
+
labels: {
|
|
74
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
75
|
+
"chant.intentius.io/stack": "demo",
|
|
76
|
+
"chant.intentius.io/env": "dev",
|
|
77
|
+
[RECEIPT_LABEL_KEY]: "db-seed",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
data: { [RECEIPT_DATA_KEY]: value },
|
|
81
|
+
} as K8sObject;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Echo an applied object back, the way an API server does. */
|
|
85
|
+
const echoApplies = (req: { method: string; body?: unknown }) =>
|
|
86
|
+
req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined;
|
|
87
|
+
|
|
88
|
+
const store = (cluster: { connector: unknown }, namespace = "default") =>
|
|
89
|
+
k8sReceiptStore({
|
|
90
|
+
...IDENTITY,
|
|
91
|
+
namespace,
|
|
92
|
+
connect: cluster.connector as never,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Leg 1: the effect step materializes the receipt ─────────────────────────
|
|
96
|
+
|
|
97
|
+
describe("k8sReceiptStore: the effect() step's sole write", () => {
|
|
98
|
+
test("read answers undefined for a receipt that is not there", async () => {
|
|
99
|
+
const cluster = fakeCluster();
|
|
100
|
+
expect(await store(cluster).read({ name: "seeded", effect: "db-seed", flavor: "hash", inputs: {} })).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("write applies a ConfigMap at the derived address, ownership-marked and receipt-labelled", async () => {
|
|
104
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
105
|
+
await store(cluster).write({ name: "seeded", effect: "db-seed", flavor: "hash", inputs: {} }, EXPECTED);
|
|
106
|
+
|
|
107
|
+
const patch = cluster.layer.requests.find((r) => r.method === "PATCH");
|
|
108
|
+
expect(patch?.path).toBe(`/api/v1/namespaces/default/configmaps/${REF.name}`);
|
|
109
|
+
const applied = JSON.parse(String(patch?.body)) as K8sObject;
|
|
110
|
+
expect(applied.kind).toBe("ConfigMap");
|
|
111
|
+
expect(applied.metadata?.name).toBe("chant-receipt.demo.dev.db-seed");
|
|
112
|
+
expect(applied.metadata?.namespace).toBe("default");
|
|
113
|
+
expect((applied as { data?: Record<string, string> }).data).toEqual({ [RECEIPT_DATA_KEY]: EXPECTED });
|
|
114
|
+
expect(applied.metadata?.labels).toMatchObject({
|
|
115
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
116
|
+
"chant.intentius.io/stack": "demo",
|
|
117
|
+
"chant.intentius.io/env": "dev",
|
|
118
|
+
[RECEIPT_LABEL_KEY]: "db-seed",
|
|
119
|
+
});
|
|
120
|
+
expect(patch?.path).not.toContain("secrets");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("write lands in the configured receipt namespace", async () => {
|
|
124
|
+
const cluster = fakeCluster({ respond: echoApplies });
|
|
125
|
+
await store(cluster, "chant-system").write(
|
|
126
|
+
{ name: "seeded", effect: "db-seed", flavor: "existence", inputs: {} },
|
|
127
|
+
EXISTENCE_EXPECTATION,
|
|
128
|
+
);
|
|
129
|
+
const patch = cluster.layer.requests.find((r) => r.method === "PATCH");
|
|
130
|
+
expect(patch?.path).toBe(`/api/v1/namespaces/chant-system/configmaps/${REF.name}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("read gets back exactly what write stored, the round trip the effect step compares on", async () => {
|
|
134
|
+
const cluster = fakeCluster({
|
|
135
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) },
|
|
136
|
+
});
|
|
137
|
+
const activities = receiptActivities(store(cluster));
|
|
138
|
+
const result = await activities.receiptRead(receiptCheckInput(seeded));
|
|
139
|
+
expect(result.current).toBe(EXPECTED);
|
|
140
|
+
expect(result.expectation).toBe(EXPECTED);
|
|
141
|
+
expect(result.applied).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("receiptValueOf reads only the expectation key, and nothing from an unrelated ConfigMap", () => {
|
|
145
|
+
expect(receiptValueOf(liveReceipt("x"))).toBe("x");
|
|
146
|
+
expect(receiptValueOf({ apiVersion: "v1", kind: "ConfigMap", data: { other: "x" } } as K8sObject)).toBeUndefined();
|
|
147
|
+
expect(receiptValueOf(undefined)).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── Leg 2: the observation reads it back ────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("the k8s observation reads the receipt back", () => {
|
|
154
|
+
const entities = new Map([["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }]]);
|
|
155
|
+
|
|
156
|
+
test("describeResources maps the stored value onto attributes.value, where the plan reads it", async () => {
|
|
157
|
+
const cluster = fakeCluster({
|
|
158
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) },
|
|
159
|
+
});
|
|
160
|
+
const result = await describeResources(
|
|
161
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
162
|
+
cluster.connector,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(result.resources.seeded.type).toBe(K8S_EFFECT_RECEIPT_ENTITY_TYPE);
|
|
166
|
+
expect(result.resources.seeded.status).toBe("EXTERNAL");
|
|
167
|
+
expect(result.resources.seeded.ownership).toBe("owned");
|
|
168
|
+
expect(readReceiptValue(result.resources.seeded.attributes)).toBe(EXPECTED);
|
|
169
|
+
expect(result.unobserved?.seeded).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a receipt that is not there is a real absence, in neither map", async () => {
|
|
173
|
+
const cluster = fakeCluster();
|
|
174
|
+
const result = await describeResources(
|
|
175
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
176
|
+
cluster.connector,
|
|
177
|
+
);
|
|
178
|
+
expect(result.resources.seeded).toBeUndefined();
|
|
179
|
+
expect(result.unobserved?.seeded).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a failed read is a hole with a reason, never 'the effect never ran'", async () => {
|
|
183
|
+
const cluster = fakeCluster({
|
|
184
|
+
respond: (req) =>
|
|
185
|
+
req.path.includes("/configmaps/")
|
|
186
|
+
? { status: 403, body: statusBody(403, "Forbidden", "configmaps is forbidden") }
|
|
187
|
+
: undefined,
|
|
188
|
+
});
|
|
189
|
+
const result = await describeResources(
|
|
190
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
191
|
+
cluster.connector,
|
|
192
|
+
);
|
|
193
|
+
expect(result.resources.seeded).toBeUndefined();
|
|
194
|
+
expect(result.unobserved?.seeded.reason).toBeDefined();
|
|
195
|
+
expect(result.unobserved?.seeded.detail).toContain(REF.name);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("without a receipt block in the build output nothing is read, and the entity is not invented", async () => {
|
|
199
|
+
const cluster = fakeCluster({
|
|
200
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) },
|
|
201
|
+
});
|
|
202
|
+
expect(receiptRowsFor(["seeded"], "")).toEqual(new Map());
|
|
203
|
+
const result = await describeResources(
|
|
204
|
+
{ environment: "dev", buildOutput: "", entityNames: ["seeded"], entities },
|
|
205
|
+
cluster.connector,
|
|
206
|
+
);
|
|
207
|
+
expect(result.resources.seeded).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("the plan turns the reading into an effect row when stale and a noop when applied (#1832)", async () => {
|
|
211
|
+
const cluster = fakeCluster({
|
|
212
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:something-else") },
|
|
213
|
+
});
|
|
214
|
+
const observed = await describeResources(
|
|
215
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
216
|
+
cluster.connector,
|
|
217
|
+
);
|
|
218
|
+
const reading = {
|
|
219
|
+
observed: true,
|
|
220
|
+
present: true,
|
|
221
|
+
value: readReceiptValue(observed.resources.seeded.attributes),
|
|
222
|
+
lexicon: "k8s",
|
|
223
|
+
};
|
|
224
|
+
const stale = planReceipts(
|
|
225
|
+
new Map([["seeded", seeded]]),
|
|
226
|
+
new Map([["seeded", reading]]),
|
|
227
|
+
observedValueResolver(observed.resources),
|
|
228
|
+
);
|
|
229
|
+
expect(stale).toHaveLength(1);
|
|
230
|
+
expect(stale[0].action).toBe("effect");
|
|
231
|
+
|
|
232
|
+
const applied = planReceipts(
|
|
233
|
+
new Map([["seeded", seeded]]),
|
|
234
|
+
new Map([["seeded", { ...reading, value: EXPECTED }]]),
|
|
235
|
+
observedValueResolver(observed.resources),
|
|
236
|
+
);
|
|
237
|
+
expect(applied[0].action).toBe("noop");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── The receipt is neither drift nor an unclaimed field ─────────────────────
|
|
242
|
+
|
|
243
|
+
describe("a receipt is never drift and never an unclaimed field (#2160)", () => {
|
|
244
|
+
const entities = new Map([["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }]]);
|
|
245
|
+
|
|
246
|
+
test("the deep read reports the receipt observed, with no property paths at all", async () => {
|
|
247
|
+
const cluster = fakeCluster({
|
|
248
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) },
|
|
249
|
+
});
|
|
250
|
+
const deep = await observeResourcesDeepK8s(
|
|
251
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
252
|
+
cluster.connector,
|
|
253
|
+
);
|
|
254
|
+
expect(deep.resources.seeded.properties).toEqual({});
|
|
255
|
+
expect(deep.resources.seeded.physicalId).toBe("uid-receipt");
|
|
256
|
+
expect(deep.unobserved?.seeded).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("the deep diff proposes nothing for it: no field drift, no unclaimed path", async () => {
|
|
260
|
+
const cluster = fakeCluster({
|
|
261
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:stale") },
|
|
262
|
+
});
|
|
263
|
+
const deep = await observeResourcesDeepK8s(
|
|
264
|
+
{ environment: "dev", buildOutput: buildOutput(), entityNames: ["seeded"], entities },
|
|
265
|
+
cluster.connector,
|
|
266
|
+
);
|
|
267
|
+
const diff = diffDeepObservation(entities, normalizeDeepObservation(deep), k8sDeepNormalizationHooks);
|
|
268
|
+
expect(diff.drifted).toEqual([]);
|
|
269
|
+
expect(diff.unclaimed).toEqual([]);
|
|
270
|
+
expect(diff.held).toEqual([]);
|
|
271
|
+
expect(diff.unobserved).toEqual([]);
|
|
272
|
+
expect(diff.undeclaredEntities).toEqual([]);
|
|
273
|
+
expect(diff.unchanged).toEqual(["seeded"]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("the receipt ConfigMap is not swept into any other entity's observation", async () => {
|
|
277
|
+
const cluster = fakeCluster({
|
|
278
|
+
objects: {
|
|
279
|
+
[objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED),
|
|
280
|
+
[objectKey("v1", "ConfigMap", "app-config", "default")]: {
|
|
281
|
+
apiVersion: "v1",
|
|
282
|
+
kind: "ConfigMap",
|
|
283
|
+
metadata: { name: "app-config", namespace: "default", uid: "uid-app" },
|
|
284
|
+
data: { a: "1" },
|
|
285
|
+
} as K8sObject,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const result = await describeResources(
|
|
289
|
+
{
|
|
290
|
+
environment: "dev",
|
|
291
|
+
buildOutput: buildOutput(),
|
|
292
|
+
entityNames: ["seeded", "appConfig"],
|
|
293
|
+
entities: new Map([
|
|
294
|
+
["seeded", { entityType: K8S_EFFECT_RECEIPT_ENTITY_TYPE, props: {} }],
|
|
295
|
+
[
|
|
296
|
+
"appConfig",
|
|
297
|
+
{ entityType: "K8s::Core::ConfigMap", props: { metadata: { name: "app-config", namespace: "default" } } },
|
|
298
|
+
],
|
|
299
|
+
]),
|
|
300
|
+
},
|
|
301
|
+
cluster.connector,
|
|
302
|
+
);
|
|
303
|
+
expect(Object.keys(result.resources).sort()).toEqual(["appConfig", "seeded"]);
|
|
304
|
+
expect(result.resources.appConfig.physicalId).toBe("uid-app");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ── Leg 3: WatchOp staleness ────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
describe("WatchOp stale-receipt reporting over the k8s row (#1834)", () => {
|
|
311
|
+
const inputs = [receiptCheckInput(seeded)];
|
|
312
|
+
|
|
313
|
+
test("an absent receipt is reported stale, and nothing is written", async () => {
|
|
314
|
+
const cluster = fakeCluster();
|
|
315
|
+
const { receiptStaleness } = receiptActivities(store(cluster));
|
|
316
|
+
const result = await receiptStaleness({ receipts: inputs });
|
|
317
|
+
expect(result.stale).toBe(true);
|
|
318
|
+
expect(result.findings).toEqual([
|
|
319
|
+
{ receipt: "seeded", effect: "db-seed", kind: "absent", expected: EXPECTED },
|
|
320
|
+
]);
|
|
321
|
+
expect(cluster.layer.requests.some((r) => r.method === "PATCH" || r.method === "DELETE")).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a receipt holding a different value is reported stale with both values", async () => {
|
|
325
|
+
const cluster = fakeCluster({
|
|
326
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt("sha256:old") },
|
|
327
|
+
});
|
|
328
|
+
const { receiptStaleness } = receiptActivities(store(cluster));
|
|
329
|
+
const result = await receiptStaleness({ receipts: inputs });
|
|
330
|
+
expect(result.stale).toBe(true);
|
|
331
|
+
expect(result.findings[0]).toEqual({
|
|
332
|
+
receipt: "seeded",
|
|
333
|
+
effect: "db-seed",
|
|
334
|
+
kind: "differs",
|
|
335
|
+
expected: EXPECTED,
|
|
336
|
+
current: "sha256:old",
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("a receipt that matches is not stale", async () => {
|
|
341
|
+
const cluster = fakeCluster({
|
|
342
|
+
objects: { [objectKey("v1", "ConfigMap", REF.name, "default")]: liveReceipt(EXPECTED) },
|
|
343
|
+
});
|
|
344
|
+
const { receiptStaleness } = receiptActivities(store(cluster));
|
|
345
|
+
expect(await receiptStaleness({ receipts: inputs })).toEqual({ stale: false, findings: [] });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("the activities barrel exports the three receipt activities by the names the registry resolves", async () => {
|
|
349
|
+
const barrel = await import("./op/activities/index");
|
|
350
|
+
expect(typeof barrel.receiptRead).toBe("function");
|
|
351
|
+
expect(typeof barrel.receiptWrite).toBe("function");
|
|
352
|
+
expect(typeof barrel.receiptStaleness).toBe("function");
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── The observation leg's own contract ──────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
describe("observeReceiptRows", () => {
|
|
359
|
+
test("reads only the entities the caller asked about", () => {
|
|
360
|
+
const rows = receiptRowsFor(["seeded"], `${buildOutput()}`);
|
|
361
|
+
expect([...rows.keys()]).toEqual(["seeded"]);
|
|
362
|
+
expect(receiptRowsFor(["other"], buildOutput()).size).toBe(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("the deep leg reports the same holes the thin leg does", async () => {
|
|
366
|
+
const cluster = fakeCluster({
|
|
367
|
+
respond: (req) =>
|
|
368
|
+
req.path.includes("/configmaps/")
|
|
369
|
+
? { status: 500, body: statusBody(500, "InternalError", "boom") }
|
|
370
|
+
: undefined,
|
|
371
|
+
});
|
|
372
|
+
const { client } = await cluster.connector({ environment: "dev" });
|
|
373
|
+
const rows = receiptRowsFor(["seeded"], buildOutput());
|
|
374
|
+
const thin = await observeReceiptRows(client, rows);
|
|
375
|
+
const deep = await observeReceiptRowsDeep(client, rows);
|
|
376
|
+
expect(Object.keys(thin.unobserved)).toEqual(["seeded"]);
|
|
377
|
+
expect(Object.keys(deep.unobserved)).toEqual(["seeded"]);
|
|
378
|
+
expect(deep.resources.seeded).toBeUndefined();
|
|
379
|
+
});
|
|
380
|
+
});
|