@intentius/chant-lexicon-k8s 0.28.0 → 0.30.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/describe-resources.d.ts +10 -5
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/describe-resources.test.ts +71 -12
- package/src/describe-resources.ts +49 -13
- package/src/lifecycle-integration.test.ts +139 -8
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
* name (using the props.metadata.name + props.metadata.namespace from #39's
|
|
8
8
|
* entity-prop pass-through).
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The observation tri-state (#1089) is what the return value carries. A genuine
|
|
11
|
+
* `NotFound` from the API server is an absence, and only that becomes a `create`
|
|
12
|
+
* downstream. An entity type with no entry in `KUBECTL_RESOURCE` — every CRD —
|
|
13
|
+
* was never looked at, and comes back `unsupported-kind`: it may well be running
|
|
14
|
+
* in the cluster, and proposing to create it would be a guess. Auth failures and
|
|
15
|
+
* unreachable API servers come back `no-credentials` / `no-binding` for the same
|
|
16
|
+
* reason. Extending the KUBECTL_RESOURCE map converts unsupported-kind holes into
|
|
17
|
+
* real reads; until then they are holes chant admits to.
|
|
13
18
|
*
|
|
14
19
|
* Before touching any resource, the environment is resolved to a cluster
|
|
15
20
|
* identity (chant #1100) via `resolveClusterTarget` — see `./config.ts` for
|
|
@@ -19,7 +24,7 @@
|
|
|
19
24
|
* error rather than silently reading the wrong cluster. No binding keeps
|
|
20
25
|
* today's behavior (ambient context), with a visible warning.
|
|
21
26
|
*/
|
|
22
|
-
import type {
|
|
27
|
+
import type { ObservationResult } from "@intentius/chant/lexicon";
|
|
23
28
|
/**
|
|
24
29
|
* Map chant entity types to `kubectl get` resource names. Add entries here
|
|
25
30
|
* as new types are needed.
|
|
@@ -34,5 +39,5 @@ export declare function describeResources(options: {
|
|
|
34
39
|
props: Record<string, unknown>;
|
|
35
40
|
}>;
|
|
36
41
|
owned?: boolean;
|
|
37
|
-
}): Promise<
|
|
42
|
+
}): Promise<ObservationResult>;
|
|
38
43
|
//# sourceMappingURL=describe-resources.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAQtG;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAoBnD,CAAC;AAsCF,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAyF7B"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "a14947ba11b4d27cc1387e873f31bcacec4af1e574efe03110ee9f826c74451a",
|
|
5
5
|
"meta.json": "6d112006daa7196eb28619ac5a3893b709dc2b845edbd537db7d7b471e8dc104",
|
|
6
6
|
"types/index.d.ts": "7b873148146a5512c22baabfde6cfcc3248c24c2c7578f68a19544af5dc675f6",
|
|
7
7
|
"rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9",
|
|
51
51
|
"skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5"
|
|
52
52
|
},
|
|
53
|
-
"composite": "
|
|
53
|
+
"composite": "76ad1e3946f333f82f54e8da4c6b1f6e3ba11c7137d7f5b2728a58e786b1341e"
|
|
54
54
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"typescript": "^5.9.3"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@intentius/chant": "^0.
|
|
78
|
+
"@intentius/chant": "^0.30.0",
|
|
79
79
|
"typescript": "^5.9.3"
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -60,13 +60,13 @@ describe("k8s describeResources", () => {
|
|
|
60
60
|
|
|
61
61
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web", "webSvc"], entities });
|
|
62
62
|
|
|
63
|
-
expect(result["web"]).toMatchObject({
|
|
63
|
+
expect(result.resources["web"]).toMatchObject({
|
|
64
64
|
type: "K8s::Apps::Deployment",
|
|
65
65
|
physicalId: "uid-1",
|
|
66
66
|
status: "READY",
|
|
67
67
|
attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" } }),
|
|
68
68
|
});
|
|
69
|
-
expect(result["webSvc"]).toMatchObject({
|
|
69
|
+
expect(result.resources["webSvc"]).toMatchObject({
|
|
70
70
|
type: "K8s::Core::Service",
|
|
71
71
|
physicalId: "uid-2",
|
|
72
72
|
status: "PRESENT",
|
|
@@ -82,10 +82,12 @@ describe("k8s describeResources", () => {
|
|
|
82
82
|
|
|
83
83
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["missing"], entities });
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// A real NotFound is an absence: neither present nor unobserved.
|
|
86
|
+
expect(result.resources).toEqual({});
|
|
87
|
+
expect(result.unobserved ?? {}).toEqual({});
|
|
86
88
|
});
|
|
87
89
|
|
|
88
|
-
test("entity types without kubectl mapping are
|
|
90
|
+
test("entity types without kubectl mapping are reported unobserved, not absent (#1089)", async () => {
|
|
89
91
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
90
92
|
const entities = makeEntities([
|
|
91
93
|
{ name: "exotic", entityType: "K8s::Custom::CRD::SomeOperator", props: { metadata: { name: "x" } } },
|
|
@@ -93,13 +95,69 @@ describe("k8s describeResources", () => {
|
|
|
93
95
|
|
|
94
96
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["exotic"], entities });
|
|
95
97
|
|
|
96
|
-
expect(result).toEqual({});
|
|
97
|
-
expect(
|
|
98
|
+
expect(result.resources).toEqual({});
|
|
99
|
+
expect(result.unobserved?.exotic).toMatchObject({
|
|
100
|
+
type: "K8s::Custom::CRD::SomeOperator",
|
|
101
|
+
reason: "unsupported-kind",
|
|
102
|
+
});
|
|
103
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
98
104
|
const mappingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("kubectl mapping"));
|
|
99
105
|
expect(mappingWarning?.[0]).toContain("K8s::Custom::CRD::SomeOperator");
|
|
100
106
|
warnSpy.mockRestore();
|
|
101
107
|
});
|
|
102
108
|
|
|
109
|
+
// #1089 — a kubectl failure that is not a NotFound proves nothing about the
|
|
110
|
+
// object's existence, and must not reach the change set as an absence.
|
|
111
|
+
test.each([
|
|
112
|
+
["error: You must be logged in to the server (Unauthorized)", "no-credentials"],
|
|
113
|
+
["The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?", "no-binding"],
|
|
114
|
+
["error: unexpected EOF while parsing", "read-failed"],
|
|
115
|
+
])("kubectl failure %j is reported unobserved (%s)", async (stderr, reason) => {
|
|
116
|
+
execMock.mockImplementation(() => { throw Object.assign(new Error("kubectl failed"), { stderr }); });
|
|
117
|
+
const entities = makeEntities([
|
|
118
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
|
|
122
|
+
|
|
123
|
+
expect(result.resources).toEqual({});
|
|
124
|
+
expect(result.unobserved?.web?.reason).toBe(reason);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a CRD kind the server does not serve is a real absence (nothing of that kind can exist)", async () => {
|
|
128
|
+
execMock.mockImplementation(() => {
|
|
129
|
+
throw Object.assign(new Error("kubectl failed"), {
|
|
130
|
+
stderr: 'error: the server doesn\'t have a resource type "widgets"',
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
const entities = makeEntities([
|
|
134
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
|
|
138
|
+
|
|
139
|
+
expect(result.resources).toEqual({});
|
|
140
|
+
expect(result.unobserved ?? {}).toEqual({});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("--owned withholds an unmarked live object as `filtered`, never as absent (#1089)", async () => {
|
|
144
|
+
execMock.mockResolvedValue({
|
|
145
|
+
stdout: JSON.stringify({
|
|
146
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "t", labels: { app: "web" } },
|
|
147
|
+
status: { readyReplicas: 1, replicas: 1 },
|
|
148
|
+
}),
|
|
149
|
+
stderr: "",
|
|
150
|
+
});
|
|
151
|
+
const entities = makeEntities([
|
|
152
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities, owned: true });
|
|
156
|
+
|
|
157
|
+
expect(result.resources).toEqual({});
|
|
158
|
+
expect(result.unobserved?.web?.reason).toBe("filtered");
|
|
159
|
+
});
|
|
160
|
+
|
|
103
161
|
test("Deployment with replicas != readyReplicas reports PROGRESSING", async () => {
|
|
104
162
|
execMock.mockResolvedValue({
|
|
105
163
|
stdout: JSON.stringify({
|
|
@@ -115,7 +173,7 @@ describe("k8s describeResources", () => {
|
|
|
115
173
|
|
|
116
174
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
|
|
117
175
|
|
|
118
|
-
expect(result["web"].status).toBe("PROGRESSING(1/3)");
|
|
176
|
+
expect(result.resources["web"].status).toBe("PROGRESSING(1/3)");
|
|
119
177
|
});
|
|
120
178
|
|
|
121
179
|
test("Pod uses status.phase as the status", async () => {
|
|
@@ -133,7 +191,7 @@ describe("k8s describeResources", () => {
|
|
|
133
191
|
|
|
134
192
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["p"], entities });
|
|
135
193
|
|
|
136
|
-
expect(result["p"].status).toBe("Running");
|
|
194
|
+
expect(result.resources["p"].status).toBe("Running");
|
|
137
195
|
});
|
|
138
196
|
|
|
139
197
|
test("namespace-less resource omits the -n flag", async () => {
|
|
@@ -159,14 +217,15 @@ describe("k8s describeResources", () => {
|
|
|
159
217
|
expect(receivedCmd).toContain("namespace mynamespace");
|
|
160
218
|
});
|
|
161
219
|
|
|
162
|
-
test("entity without metadata.name is
|
|
220
|
+
test("entity without metadata.name is unobserved — nothing was queried", async () => {
|
|
163
221
|
const entities = makeEntities([
|
|
164
222
|
{ name: "broken", entityType: "K8s::Apps::Deployment", props: {} },
|
|
165
223
|
]);
|
|
166
224
|
|
|
167
225
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
|
|
168
226
|
|
|
169
|
-
expect(result).toEqual({});
|
|
227
|
+
expect(result.resources).toEqual({});
|
|
228
|
+
expect(result.unobserved?.broken?.reason).toBe("read-failed");
|
|
170
229
|
expect(execMock).not.toHaveBeenCalled();
|
|
171
230
|
});
|
|
172
231
|
|
|
@@ -196,7 +255,7 @@ describe("k8s describeResources", () => {
|
|
|
196
255
|
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: deploymentEntities() });
|
|
197
256
|
|
|
198
257
|
expect(receivedCmd).toContain("--context prod-eks");
|
|
199
|
-
expect(result["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
|
|
258
|
+
expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
|
|
200
259
|
});
|
|
201
260
|
|
|
202
261
|
test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
|
|
@@ -228,7 +287,7 @@ describe("k8s describeResources", () => {
|
|
|
228
287
|
// Unchanged: no ambient-context probe, no --context flag, same result shape as today.
|
|
229
288
|
expect(receivedCmd).not.toContain("--context");
|
|
230
289
|
expect(receivedCmd).not.toContain("current-context");
|
|
231
|
-
expect(result["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
|
|
290
|
+
expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
|
|
232
291
|
|
|
233
292
|
// Visible, not silent: a warning names the environment and the missing binding.
|
|
234
293
|
const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
|
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
* name (using the props.metadata.name + props.metadata.namespace from #39's
|
|
8
8
|
* entity-prop pass-through).
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The observation tri-state (#1089) is what the return value carries. A genuine
|
|
11
|
+
* `NotFound` from the API server is an absence, and only that becomes a `create`
|
|
12
|
+
* downstream. An entity type with no entry in `KUBECTL_RESOURCE` — every CRD —
|
|
13
|
+
* was never looked at, and comes back `unsupported-kind`: it may well be running
|
|
14
|
+
* in the cluster, and proposing to create it would be a guess. Auth failures and
|
|
15
|
+
* unreachable API servers come back `no-credentials` / `no-binding` for the same
|
|
16
|
+
* reason. Extending the KUBECTL_RESOURCE map converts unsupported-kind holes into
|
|
17
|
+
* real reads; until then they are holes chant admits to.
|
|
13
18
|
*
|
|
14
19
|
* Before touching any resource, the environment is resolved to a cluster
|
|
15
20
|
* identity (chant #1100) via `resolveClusterTarget` — see `./config.ts` for
|
|
@@ -22,10 +27,11 @@
|
|
|
22
27
|
|
|
23
28
|
import { exec } from "node:child_process";
|
|
24
29
|
import { promisify } from "node:util";
|
|
25
|
-
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
30
|
+
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
31
|
+
import { observation } from "@intentius/chant/observation";
|
|
26
32
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
27
33
|
import { loadChantConfig } from "@intentius/chant/config";
|
|
28
|
-
import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
|
|
34
|
+
import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
|
|
29
35
|
|
|
30
36
|
const execAsync = promisify(exec);
|
|
31
37
|
|
|
@@ -97,8 +103,9 @@ export async function describeResources(options: {
|
|
|
97
103
|
entityNames: string[];
|
|
98
104
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
99
105
|
owned?: boolean;
|
|
100
|
-
}): Promise<
|
|
106
|
+
}): Promise<ObservationResult> {
|
|
101
107
|
const result: Record<string, ResourceMetadata> = {};
|
|
108
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
102
109
|
const skippedTypes = new Set<string>();
|
|
103
110
|
|
|
104
111
|
// Resolve the cluster identity for this environment before touching any
|
|
@@ -112,13 +119,29 @@ export async function describeResources(options: {
|
|
|
112
119
|
for (const [entityName, { entityType, props }] of options.entities) {
|
|
113
120
|
const kubectlResource = KUBECTL_RESOURCE[entityType];
|
|
114
121
|
if (!kubectlResource) {
|
|
122
|
+
// No reader for this kind (every CRD). The object may exist; chant has no
|
|
123
|
+
// way to ask. Reporting it as unobserved is what stops `lifecycle plan`
|
|
124
|
+
// proposing to create a CRD that is already in the cluster (#1089).
|
|
115
125
|
skippedTypes.add(entityType);
|
|
126
|
+
unobserved[entityName] = {
|
|
127
|
+
type: entityType,
|
|
128
|
+
reason: "unsupported-kind",
|
|
129
|
+
detail: `no kubectl mapping for ${entityType} — extend KUBECTL_RESOURCE to observe it`,
|
|
130
|
+
};
|
|
116
131
|
continue;
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
120
135
|
const name = metadata?.name;
|
|
121
|
-
if (!name)
|
|
136
|
+
if (!name) {
|
|
137
|
+
// Nothing to query by. Not an absence — chant never issued a read.
|
|
138
|
+
unobserved[entityName] = {
|
|
139
|
+
type: entityType,
|
|
140
|
+
reason: "read-failed",
|
|
141
|
+
detail: "declared entity has no metadata.name to query by",
|
|
142
|
+
};
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
122
145
|
|
|
123
146
|
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
124
147
|
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
|
|
@@ -126,8 +149,16 @@ export async function describeResources(options: {
|
|
|
126
149
|
try {
|
|
127
150
|
const { stdout } = await execAsync(cmd);
|
|
128
151
|
const obj: KubectlResponse = JSON.parse(stdout);
|
|
129
|
-
// owned filter:
|
|
152
|
+
// owned filter: withhold resources not carrying chant's marker label.
|
|
153
|
+
// Withheld is not absent (#1089) — this object exists, it just isn't
|
|
154
|
+
// chant's, and dropping it silently is how `--owned` used to turn a
|
|
155
|
+
// declared-but-foreign resource into a proposed `create`.
|
|
130
156
|
if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
|
|
157
|
+
unobserved[entityName] = {
|
|
158
|
+
type: entityType,
|
|
159
|
+
reason: "filtered",
|
|
160
|
+
detail: "live object carries no chant ownership marker and --owned was requested",
|
|
161
|
+
};
|
|
131
162
|
continue;
|
|
132
163
|
}
|
|
133
164
|
result[entityName] = {
|
|
@@ -142,18 +173,23 @@ export async function describeResources(options: {
|
|
|
142
173
|
resourceVersion: obj.metadata?.resourceVersion,
|
|
143
174
|
}),
|
|
144
175
|
};
|
|
145
|
-
} catch {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Only a real NotFound leaves the entity out (an absence the diff may
|
|
178
|
+
// read as missing and the plan as create). Auth, connectivity, and every
|
|
179
|
+
// other failure prove nothing about existence and are reported as such.
|
|
180
|
+
const outcome = classifyKubectlFailure(err);
|
|
181
|
+
if (outcome.kind === "unobserved") {
|
|
182
|
+
unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
|
|
183
|
+
}
|
|
148
184
|
}
|
|
149
185
|
}
|
|
150
186
|
|
|
151
187
|
if (skippedTypes.size > 0) {
|
|
152
188
|
// eslint-disable-next-line no-console
|
|
153
189
|
console.warn(
|
|
154
|
-
`[k8s]
|
|
190
|
+
`[k8s] no kubectl mapping for ${skippedTypes.size} entity type(s): ${[...skippedTypes].join(", ")} — reported as unobserved (not absent), so no create is proposed for them`,
|
|
155
191
|
);
|
|
156
192
|
}
|
|
157
193
|
|
|
158
|
-
return result;
|
|
194
|
+
return observation(result, unobserved);
|
|
159
195
|
}
|
|
@@ -31,6 +31,9 @@ vi.mock("node:child_process", async () => {
|
|
|
31
31
|
const { k8sPlugin } = await import("./plugin");
|
|
32
32
|
const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
|
|
33
33
|
const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
|
|
34
|
+
const { normalizeObservation } = await import("@intentius/chant/observation");
|
|
35
|
+
const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
|
|
36
|
+
const { describeObservationConformance } = await import("@intentius/chant-test-utils");
|
|
34
37
|
|
|
35
38
|
const liveDeployment = {
|
|
36
39
|
apiVersion: "apps/v1",
|
|
@@ -82,14 +85,16 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
82
85
|
: new Error("not found"),
|
|
83
86
|
);
|
|
84
87
|
|
|
85
|
-
const observedNow =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
const { resources: observedNow } = normalizeObservation(
|
|
89
|
+
await k8sPlugin.describeResources!({
|
|
90
|
+
environment: "prod",
|
|
91
|
+
buildOutput: "",
|
|
92
|
+
entityNames: ["web"],
|
|
93
|
+
entities: new Map([
|
|
94
|
+
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
|
|
95
|
+
]),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
93
98
|
expect(observedNow.web?.type).toBe("K8s::Apps::Deployment");
|
|
94
99
|
|
|
95
100
|
const cs = buildChangeSet("prod", {
|
|
@@ -108,4 +113,130 @@ describe("k8s lifecycle integration (#163)", () => {
|
|
|
108
113
|
});
|
|
109
114
|
expect(cs2.entries.find((e) => e.name === "web")!.action).toBe("noop");
|
|
110
115
|
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The #1089 chain, end to end on the real plugin: a declared CRD the lexicon
|
|
119
|
+
* has no reader for goes describe → plan → component status without ever
|
|
120
|
+
* turning into a create or a "stale" component.
|
|
121
|
+
*/
|
|
122
|
+
test("tri-state chain: an unreadable CRD stays unobserved through describe → plan → status (#1089)", async () => {
|
|
123
|
+
execMock.mockImplementation((cmd?: string) =>
|
|
124
|
+
cmd?.includes("deployment.apps web")
|
|
125
|
+
? {
|
|
126
|
+
stdout: JSON.stringify({
|
|
127
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1" },
|
|
128
|
+
status: { readyReplicas: 3, replicas: 3 },
|
|
129
|
+
}),
|
|
130
|
+
stderr: "",
|
|
131
|
+
}
|
|
132
|
+
: new Error('Error from server (NotFound): deployments.apps "gone" not found'),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const entities = new Map([
|
|
136
|
+
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
|
|
137
|
+
["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone", namespace: "prod" } } }],
|
|
138
|
+
["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget", namespace: "prod" } } }],
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// 1. describe — three declared entities, three different verdicts.
|
|
142
|
+
const observed = normalizeObservation(
|
|
143
|
+
await k8sPlugin.describeResources!({
|
|
144
|
+
environment: "prod",
|
|
145
|
+
buildOutput: "",
|
|
146
|
+
entityNames: [...entities.keys()],
|
|
147
|
+
entities,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
expect(Object.keys(observed.resources)).toEqual(["web"]);
|
|
151
|
+
expect(observed.unobserved.widget.reason).toBe("unsupported-kind");
|
|
152
|
+
expect(observed.unobserved.gone).toBeUndefined(); // NotFound is an absence
|
|
153
|
+
|
|
154
|
+
// 2. plan — the CRD is `unobserved`; only the confirmed-absent one is a create.
|
|
155
|
+
const cs = buildChangeSet("prod", {
|
|
156
|
+
declared: new Set(entities.keys()),
|
|
157
|
+
observedNow: observed.resources,
|
|
158
|
+
observedThen: undefined,
|
|
159
|
+
unobserved: observed.unobserved,
|
|
160
|
+
});
|
|
161
|
+
const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
|
|
162
|
+
expect(byName).toEqual({ web: "noop", gone: "create", widget: "unobserved" });
|
|
163
|
+
|
|
164
|
+
// 3. status — a recorded component whose entity was never read reports
|
|
165
|
+
// `unknown`, not `stale`, and carries no `live` boolean at all.
|
|
166
|
+
const rows = reconcileStatus("prod", [
|
|
167
|
+
{ component: "widget", env: "prod", digest: "sha256:abc", gitSha: "g", runId: "r", timestamp: "2026-01-01T00:00:00Z", actor: "ci" },
|
|
168
|
+
], { liveEvidence: liveEvidenceFromChangeSet(cs) });
|
|
169
|
+
const widgetRow = rows.find((r) => r.component === "widget")!;
|
|
170
|
+
expect(widgetRow.reconciliation).toBe("unknown");
|
|
171
|
+
expect(widgetRow.live).toBeUndefined();
|
|
172
|
+
expect(widgetRow.unobserved?.reason).toBe("unsupported-kind");
|
|
173
|
+
expect(widgetRow.detail).toContain("could not be observed");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The shared conformance suite (#1089) — every observing lexicon runs it.
|
|
178
|
+
describeObservationConformance({
|
|
179
|
+
lexicon: "k8s",
|
|
180
|
+
scenarios: [
|
|
181
|
+
{
|
|
182
|
+
name: "a CRD kind with no reader",
|
|
183
|
+
declared: ["widget", "gone"],
|
|
184
|
+
expectUnobserved: ["widget"],
|
|
185
|
+
expectAbsent: ["gone"],
|
|
186
|
+
run: () => {
|
|
187
|
+
execMock.mockImplementation(() => new Error('Error from server (NotFound): deployments.apps "gone" not found'));
|
|
188
|
+
return k8sPlugin.describeResources!({
|
|
189
|
+
environment: "prod",
|
|
190
|
+
buildOutput: "",
|
|
191
|
+
entityNames: ["widget", "gone"],
|
|
192
|
+
entities: new Map([
|
|
193
|
+
["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget" } } }],
|
|
194
|
+
["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone" } } }],
|
|
195
|
+
]),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "an unreachable API server",
|
|
201
|
+
declared: ["web"],
|
|
202
|
+
expectUnobserved: ["web"],
|
|
203
|
+
run: () => {
|
|
204
|
+
execMock.mockImplementation(() =>
|
|
205
|
+
Object.assign(new Error("kubectl failed"), {
|
|
206
|
+
stderr: "The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
return k8sPlugin.describeResources!({
|
|
210
|
+
environment: "prod",
|
|
211
|
+
buildOutput: "",
|
|
212
|
+
entityNames: ["web"],
|
|
213
|
+
entities: new Map([
|
|
214
|
+
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
|
|
215
|
+
]),
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "a healthy read",
|
|
221
|
+
declared: ["web"],
|
|
222
|
+
expectPresent: ["web"],
|
|
223
|
+
run: () => {
|
|
224
|
+
execMock.mockImplementation(() => ({
|
|
225
|
+
stdout: JSON.stringify({
|
|
226
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1", labels: { "app.kubernetes.io/managed-by": "chant" } },
|
|
227
|
+
status: { readyReplicas: 1, replicas: 1 },
|
|
228
|
+
}),
|
|
229
|
+
stderr: "",
|
|
230
|
+
}));
|
|
231
|
+
return k8sPlugin.describeResources!({
|
|
232
|
+
environment: "prod",
|
|
233
|
+
buildOutput: "",
|
|
234
|
+
entityNames: ["web"],
|
|
235
|
+
entities: new Map([
|
|
236
|
+
["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
|
|
237
|
+
]),
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
],
|
|
111
242
|
});
|