@intentius/chant-lexicon-k8s 0.58.0 → 0.60.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.
Files changed (61) hide show
  1. package/dist/api/fake-cluster.d.ts +7 -6
  2. package/dist/api/fake-cluster.d.ts.map +1 -1
  3. package/dist/codegen/docs.d.ts.map +1 -1
  4. package/dist/composites/cron-schedule.d.ts +26 -0
  5. package/dist/composites/cron-schedule.d.ts.map +1 -0
  6. package/dist/composites/cron-workload.d.ts +1 -1
  7. package/dist/composites/cron-workload.d.ts.map +1 -1
  8. package/dist/composites/operator-stack.d.ts +1 -1
  9. package/dist/composites/operator-stack.d.ts.map +1 -1
  10. package/dist/config-schema.d.ts +3 -0
  11. package/dist/config-schema.d.ts.map +1 -1
  12. package/dist/config.d.ts +22 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/describe-resources.d.ts.map +1 -1
  16. package/dist/effect-receipt-row.d.ts +164 -0
  17. package/dist/effect-receipt-row.d.ts.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/integrity.json +3 -3
  21. package/dist/lint/post-synth/wk8505.d.ts +3 -1
  22. package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
  23. package/dist/manifest.json +1 -1
  24. package/dist/okf/index.md +1 -1
  25. package/dist/okf/rules/WK8505.md +2 -2
  26. package/dist/okf/types/Kustomization.md +1 -1
  27. package/dist/op/activities/index.d.ts +6 -0
  28. package/dist/op/activities/index.d.ts.map +1 -1
  29. package/dist/op/activities/kubectl.d.ts.map +1 -1
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/receipt-store.d.ts +113 -0
  32. package/dist/receipt-store.d.ts.map +1 -0
  33. package/dist/rules/wk8505.ts +4 -2
  34. package/dist/serializer.d.ts.map +1 -1
  35. package/dist/subscribe-changes.d.ts +84 -0
  36. package/dist/subscribe-changes.d.ts.map +1 -0
  37. package/package.json +3 -3
  38. package/src/api/fake-cluster.ts +17 -4
  39. package/src/codegen/docs.ts +7 -0
  40. package/src/composites/composites.test.ts +59 -1
  41. package/src/composites/cron-schedule.ts +47 -0
  42. package/src/composites/cron-workload.ts +4 -1
  43. package/src/composites/operator-stack.ts +3 -4
  44. package/src/config-schema.ts +5 -0
  45. package/src/config.ts +23 -0
  46. package/src/deep-observe.ts +26 -7
  47. package/src/describe-resources.ts +31 -7
  48. package/src/effect-receipt-row.test.ts +285 -0
  49. package/src/effect-receipt-row.ts +268 -0
  50. package/src/index.ts +22 -0
  51. package/src/lint/audit-catalog.ts +1 -1
  52. package/src/lint/post-synth/wk8505.ts +4 -2
  53. package/src/op/activities/index.ts +14 -0
  54. package/src/op/activities/kubectl.test.ts +38 -0
  55. package/src/op/activities/kubectl.ts +15 -0
  56. package/src/plugin.ts +15 -0
  57. package/src/receipt-store.test.ts +380 -0
  58. package/src/receipt-store.ts +290 -0
  59. package/src/serializer.ts +92 -1
  60. package/src/subscribe-changes.test.ts +368 -0
  61. package/src/subscribe-changes.ts +210 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * `subscribeChanges` over the typed API client (chant #1981).
3
+ *
4
+ * Every case drives the real client against the fake cluster harness, so
5
+ * kubeconfig parsing, context selection, discovery, path construction and the
6
+ * auth path all run for real and only the socket is fake. No k3d, no cluster,
7
+ * no network.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import type { LexiconPlugin, SubscribeChangesOptions } from "@intentius/chant/lexicon";
12
+ import { fakeWatchStream, watchFrame, expiredWatchFrame, statusBody } from "@intentius/chant-k8s-client/testing";
13
+ import type { RecordedRequest } from "@intentius/chant-k8s-client/testing";
14
+ import { collectChangeSubscribers } from "@intentius/chant/cli/plugins";
15
+ import { createChangeSignalGate } from "@intentius/chant/op";
16
+ import { fakeCluster, objectKey, ownedObject } from "./api/fake-cluster";
17
+ import { subscribeChanges, watchTargets, MAX_WATCHES } from "./subscribe-changes";
18
+
19
+ type Entity = { name: string; entityType: string; props: Record<string, unknown> };
20
+
21
+ function makeEntities(records: Entity[]) {
22
+ return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
23
+ }
24
+
25
+ const webDeployment: Entity = {
26
+ name: "web",
27
+ entityType: "K8s::Apps::Deployment",
28
+ props: { metadata: { name: "web", namespace: "prod" } },
29
+ };
30
+ const webService: Entity = {
31
+ name: "webSvc",
32
+ entityType: "K8s::Core::Service",
33
+ props: { metadata: { name: "web-svc", namespace: "prod" } },
34
+ };
35
+ const prodNamespace: Entity = {
36
+ name: "prodNs",
37
+ entityType: "K8s::Core::Namespace",
38
+ props: { metadata: { name: "prod" } },
39
+ };
40
+
41
+ async function waitFor(predicate: () => boolean, maxWaitMs = 3_000): Promise<void> {
42
+ const deadline = Date.now() + maxWaitMs;
43
+ while (!predicate() && Date.now() < deadline) {
44
+ await new Promise((r) => setTimeout(r, 5));
45
+ }
46
+ }
47
+
48
+ /** Collects the signal side of a subscription: wakes, errors, and the abort. */
49
+ function recorder(entities: Map<string, { entityType: string; props: Record<string, unknown> }>) {
50
+ const controller = new AbortController();
51
+ const changes: unknown[][] = [];
52
+ const errors: string[] = [];
53
+ const options: SubscribeChangesOptions = {
54
+ environment: "prod",
55
+ entities,
56
+ // Recorded with its arguments, so a test can prove there were none.
57
+ onChange: (...args: unknown[]) => changes.push(args),
58
+ onError: (message) => errors.push(message),
59
+ signal: controller.signal,
60
+ };
61
+ return { controller, changes, errors, options };
62
+ }
63
+
64
+ /** A cluster whose watches are driven by streams the test pushes frames into. */
65
+ function watchingCluster(streams: Map<string, ReturnType<typeof fakeWatchStream>>, objects: Record<string, ReturnType<typeof ownedObject>> = {}) {
66
+ const watches: RecordedRequest[] = [];
67
+ const cluster = fakeCluster({
68
+ objects,
69
+ respond: (request) => {
70
+ if (request.query.watch !== "1") return undefined;
71
+ watches.push(request);
72
+ const stream = streams.get(request.path);
73
+ if (!stream) return { status: 403, body: statusBody(403, "Forbidden", `watch on ${request.path} denied`) };
74
+ return { stream };
75
+ },
76
+ });
77
+ return { cluster, watches };
78
+ }
79
+
80
+ describe("watchTargets: the scope a declared estate implies", () => {
81
+ test("one target per (kind, namespace), deduplicated and ordered", () => {
82
+ const { targets, unaddressable } = watchTargets(
83
+ makeEntities([
84
+ webDeployment,
85
+ { ...webDeployment, name: "api" },
86
+ { name: "other", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "o", namespace: "staging" } } },
87
+ webService,
88
+ ]),
89
+ "default",
90
+ );
91
+ expect(unaddressable).toEqual([]);
92
+ expect(targets).toEqual([
93
+ { apiVersion: "apps/v1", kind: "Deployment", namespace: "prod" },
94
+ { apiVersion: "apps/v1", kind: "Deployment", namespace: "staging" },
95
+ { apiVersion: "v1", kind: "Service", namespace: "prod" },
96
+ ]);
97
+ });
98
+
99
+ test("a namespaced entity declaring no namespace falls back to the client's default", () => {
100
+ const { targets } = watchTargets(
101
+ makeEntities([{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }]),
102
+ "team-a",
103
+ );
104
+ expect(targets).toEqual([{ apiVersion: "apps/v1", kind: "Deployment", namespace: "team-a" }]);
105
+ });
106
+
107
+ test("a cluster-scoped kind carries no namespace at all", () => {
108
+ const { targets } = watchTargets(makeEntities([prodNamespace]), "default");
109
+ expect(targets).toEqual([{ apiVersion: "v1", kind: "Namespace" }]);
110
+ });
111
+
112
+ test("a type with no API address is named as unaddressable, never widened to something else", () => {
113
+ const { targets, unaddressable } = watchTargets(
114
+ makeEntities([webDeployment, { name: "weird", entityType: "Nonsense::Made::Up", props: {} }]),
115
+ "default",
116
+ );
117
+ expect(unaddressable).toEqual(["Nonsense::Made::Up"]);
118
+ expect(targets).toHaveLength(1);
119
+ });
120
+ });
121
+
122
+ describe("subscribeChanges", () => {
123
+ test("watches exactly the declared kinds and namespaces, and nothing else", async () => {
124
+ const streams = new Map([
125
+ ["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()],
126
+ ["/api/v1/namespaces/prod/services", fakeWatchStream()],
127
+ ]);
128
+ const { cluster, watches } = watchingCluster(streams);
129
+ const { controller, errors, options } = recorder(makeEntities([webDeployment, webService]));
130
+
131
+ const subscription = await subscribeChanges(options, cluster.connector);
132
+ await waitFor(() => watches.length >= 2);
133
+
134
+ expect(watches.map((w) => w.path).sort()).toEqual([
135
+ "/api/v1/namespaces/prod/services",
136
+ "/apis/apps/v1/namespaces/prod/deployments",
137
+ ]);
138
+ for (const w of watches) {
139
+ expect(w.query.resourceVersion).toBe("1"); // the list's own version
140
+ expect(w.headers.Authorization).toBe("Bearer test-token");
141
+ }
142
+ expect(errors).toEqual([]);
143
+
144
+ controller.abort();
145
+ await subscription.close();
146
+ });
147
+
148
+ test("a watch event wakes the caller with no payload at all", async () => {
149
+ const stream = fakeWatchStream();
150
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", stream]]);
151
+ const { cluster, watches } = watchingCluster(streams);
152
+ const { controller, changes, options } = recorder(makeEntities([webDeployment]));
153
+
154
+ const subscription = await subscribeChanges(options, cluster.connector);
155
+ await waitFor(() => watches.length >= 1);
156
+
157
+ // A frame with everything a fabricated observation would want in it.
158
+ stream.push(
159
+ watchFrame("DELETED", {
160
+ apiVersion: "apps/v1",
161
+ kind: "Deployment",
162
+ metadata: { name: "web", namespace: "prod", resourceVersion: "9" },
163
+ status: { replicas: 0 },
164
+ }),
165
+ );
166
+ await waitFor(() => changes.length >= 1);
167
+
168
+ // None of it arrives. `onChange` was called with zero arguments, which is
169
+ // the whole guarantee: there is no channel from a frame to a change set,
170
+ // a snapshot row, or a diff line.
171
+ expect(changes).toEqual([[]]);
172
+
173
+ controller.abort();
174
+ await subscription.close();
175
+ });
176
+
177
+ test("a 410 Gone is absorbed by the client and never surfaces as a lost signal", async () => {
178
+ const first = fakeWatchStream();
179
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", first]]);
180
+ const { cluster, watches } = watchingCluster(streams);
181
+ const { controller, changes, errors, options } = recorder(makeEntities([webDeployment]));
182
+
183
+ const subscription = await subscribeChanges(options, cluster.connector);
184
+ await waitFor(() => watches.length >= 1);
185
+
186
+ first.push(expiredWatchFrame());
187
+ // The client re-lists and reopens on the same path, so the same stream is
188
+ // handed back; the subscription is still live and still silent about it.
189
+ await waitFor(() => watches.length >= 2);
190
+ first.push(watchFrame("MODIFIED", { metadata: { resourceVersion: "12" } }));
191
+ await waitFor(() => changes.length >= 1);
192
+ expect(errors).toEqual([]);
193
+
194
+ controller.abort();
195
+ await subscription.close();
196
+ });
197
+
198
+ test("a killed watch reports once and stops, leaving the caller to re-subscribe", async () => {
199
+ // No stream registered for the Service path: that watch is refused, which
200
+ // is what a killed or forbidden watch looks like from here.
201
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()]]);
202
+ const { cluster } = watchingCluster(streams);
203
+ const { controller, errors, options } = recorder(makeEntities([webDeployment, webService]));
204
+
205
+ const subscription = await subscribeChanges(options, cluster.connector);
206
+ await waitFor(() => errors.length >= 1);
207
+ expect(errors).toHaveLength(1);
208
+ expect(errors[0]).toContain("v1 Service in prod");
209
+ expect(errors[0]).toContain("denied");
210
+
211
+ controller.abort();
212
+ await subscription.close();
213
+ // Still exactly one: a dying subscription is reported once, not per stream.
214
+ expect(errors).toHaveLength(1);
215
+ });
216
+
217
+ test("nothing declared is a refusal by name, not a cluster-wide watch", async () => {
218
+ const { cluster } = watchingCluster(new Map());
219
+ const { options } = recorder(new Map());
220
+ await expect(subscribeChanges(options, cluster.connector)).rejects.toThrow(/nothing to watch/);
221
+ expect(cluster.layer.requests).toEqual([]);
222
+ });
223
+
224
+ test("an estate past the connection ceiling refuses rather than opening a partial watch", async () => {
225
+ const many = makeEntities(
226
+ Array.from({ length: MAX_WATCHES + 1 }, (_, i) => ({
227
+ name: `web-${i}`,
228
+ entityType: "K8s::Apps::Deployment",
229
+ props: { metadata: { name: `web-${i}`, namespace: `ns-${i}` } },
230
+ })),
231
+ );
232
+ const { cluster, watches } = watchingCluster(new Map());
233
+ const { options } = recorder(many);
234
+
235
+ await expect(subscribeChanges(options, cluster.connector)).rejects.toThrow(
236
+ new RegExp(`past the ${MAX_WATCHES} ceiling`),
237
+ );
238
+ expect(watches).toEqual([]);
239
+ });
240
+
241
+ test("a kind chant has no API address for is named, and the rest is still watched", async () => {
242
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()]]);
243
+ const { cluster, watches } = watchingCluster(streams);
244
+ const { controller, errors, options } = recorder(
245
+ makeEntities([webDeployment, { name: "weird", entityType: "Nonsense::Made::Up", props: {} }]),
246
+ );
247
+
248
+ const subscription = await subscribeChanges(options, cluster.connector);
249
+ await waitFor(() => watches.length >= 1);
250
+ expect(errors[0]).toContain("Nonsense::Made::Up");
251
+ expect(errors[0]).toContain("on the timer alone");
252
+
253
+ controller.abort();
254
+ await subscription.close();
255
+ });
256
+
257
+ test("close() releases every stream, and aborting the caller's signal does the same", async () => {
258
+ const streams = new Map([
259
+ ["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()],
260
+ ["/api/v1/namespaces/prod/services", fakeWatchStream()],
261
+ ]);
262
+ const { cluster, watches } = watchingCluster(streams);
263
+ const { controller, errors, options } = recorder(makeEntities([webDeployment, webService]));
264
+
265
+ const subscription = await subscribeChanges(options, cluster.connector);
266
+ await waitFor(() => watches.length >= 2);
267
+
268
+ await subscription.close();
269
+ await subscription.close(); // idempotent
270
+ const watchesAtClose = watches.length;
271
+
272
+ // Nothing reopens after the close, and a close is not a failure.
273
+ await new Promise((r) => setTimeout(r, 50));
274
+ expect(watches.length).toBe(watchesAtClose);
275
+ expect(errors).toEqual([]);
276
+
277
+ controller.abort(); // after the fact, and harmless
278
+ });
279
+
280
+ test("aborting the signal closes the subscription without a close() call", async () => {
281
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()]]);
282
+ const { cluster, watches } = watchingCluster(streams);
283
+ const { controller, errors, options } = recorder(makeEntities([webDeployment]));
284
+
285
+ const subscription = await subscribeChanges(options, cluster.connector);
286
+ await waitFor(() => watches.length >= 1);
287
+
288
+ controller.abort();
289
+ await subscription.close();
290
+ expect(errors).toEqual([]);
291
+ });
292
+
293
+ test("the connector resolves the environment's binding, exactly as a read does", async () => {
294
+ const streams = new Map([["/apis/apps/v1/namespaces/prod/deployments", fakeWatchStream()]]);
295
+ const { cluster } = watchingCluster(streams, {
296
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
297
+ });
298
+ const { controller, options } = recorder(makeEntities([webDeployment]));
299
+
300
+ const subscription = await subscribeChanges({ ...options, cwd: "/somewhere" }, cluster.connector);
301
+ expect(cluster.connects).toEqual([{ environment: "prod", cwd: "/somewhere" }]);
302
+
303
+ controller.abort();
304
+ await subscription.close();
305
+ });
306
+
307
+ test("a connector that refuses the binding refuses the subscription, before any watch", async () => {
308
+ const { options } = recorder(makeEntities([webDeployment]));
309
+ const refusing = async () => {
310
+ throw new Error('the kubeconfig has no context named "prod-eks"');
311
+ };
312
+ await expect(subscribeChanges(options, refusing as never)).rejects.toThrow(/no context named "prod-eks"/);
313
+ });
314
+ });
315
+
316
+ /**
317
+ * The two halves joined: the real k8s subscription, bound the way `chant
318
+ * operator` binds it, driving the operator's real wake gate. Everything below
319
+ * `client.watch` is faked and nothing else is: no cluster, no k3d, and no
320
+ * stand-in for the seam under test.
321
+ */
322
+ describe("the wake path end to end, against the fake cluster", () => {
323
+ test("a change to a declared resource wakes the gate well inside a second", async () => {
324
+ const path = "/apis/apps/v1/namespaces/prod/deployments";
325
+ const stream = fakeWatchStream();
326
+ const { cluster, watches } = watchingCluster(new Map([[path, stream]]));
327
+
328
+ // Bound exactly as the CLI binds it: a plugin with the seam, one
329
+ // environment, this lexicon's own declared entities.
330
+ const plugin = {
331
+ name: "k8s",
332
+ serializer: { name: "k8s", serialize: () => "" },
333
+ generate: async () => {},
334
+ validate: async () => {},
335
+ coverage: async () => {},
336
+ package: async () => {},
337
+ subscribeChanges: (options: SubscribeChangesOptions) => subscribeChanges(options, cluster.connector),
338
+ } as unknown as LexiconPlugin;
339
+
340
+ const subscribers = collectChangeSubscribers([plugin], {
341
+ environment: "prod",
342
+ entities: new Map([["k8s", makeEntities([webDeployment])]]),
343
+ });
344
+ expect(subscribers).toHaveLength(1);
345
+
346
+ const gate = createChangeSignalGate({ floorMs: 0 });
347
+ const controller = new AbortController();
348
+ const errors: string[] = [];
349
+ const subscription = await subscribers[0].subscribe({
350
+ onChange: () => gate.signal(),
351
+ onError: (message) => errors.push(message),
352
+ signal: controller.signal,
353
+ });
354
+ await waitFor(() => watches.length >= 1);
355
+
356
+ gate.roundStarted();
357
+ const sleeping = gate.wait(60_000, controller.signal); // a full minute of timer
358
+ const startedAt = Date.now();
359
+ stream.push(watchFrame("MODIFIED", { metadata: { name: "web", namespace: "prod", resourceVersion: "8" } }));
360
+
361
+ expect(await sleeping).toBe("signal");
362
+ expect(Date.now() - startedAt).toBeLessThan(1_000);
363
+ expect(errors).toEqual([]);
364
+
365
+ controller.abort();
366
+ await subscription.close();
367
+ });
368
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
3
+ *
4
+ * Kubernetes is the one substrate chant reaches where a change stream is
5
+ * complete, trustworthy, and needs nothing deployed into the cluster being
6
+ * observed: the Watch API is served for every kind the API server serves, is
7
+ * `resourceVersion`-based so a reconnect has a defined resume point, and is
8
+ * authorized by the same read credentials `describeResources` already uses.
9
+ * Every cloud substrate fails on that last point. Subscribing to EventBridge,
10
+ * Cloud Asset Inventory or Event Grid means writing infrastructure into the
11
+ * account being watched, which inverts the property that makes a read-only
12
+ * watch safe to point at production. The verdict table in the operator guide
13
+ * records that per lexicon; this file is the one place it came out `yes`.
14
+ *
15
+ * ## What this is allowed to conclude: nothing
16
+ *
17
+ * A watch event never becomes an observation. The frames are read, and then
18
+ * discarded. The only thing that leaves this module is a no-argument
19
+ * `onChange()`, which wakes an operator tick that re-observes the estate from
20
+ * scratch through the ordinary read path. There is no code here that could
21
+ * turn a `DELETED` frame into a proposed `create`, because there is no channel
22
+ * from a frame to anything but a function call with no parameters.
23
+ *
24
+ * That is also why a missed event costs nothing. A `410 Gone`, a dropped
25
+ * connection, a subscription that never got established: all of them slow
26
+ * detection back to the operator's timer, and none of them make the estate
27
+ * read as clean.
28
+ *
29
+ * ## Scope
30
+ *
31
+ * One watch per (kind, namespace) the declared entities name. The kinds come
32
+ * from the same generated operation surface `describeResources` addresses
33
+ * entities through; the namespaces come from the declarations themselves,
34
+ * falling back to the client's own default for a namespaced entity that
35
+ * declares none. Nothing widens that: a project declaring three Deployments in
36
+ * one namespace opens one connection, not a cluster-wide firehose.
37
+ */
38
+
39
+ import type { ChangeSubscription, SubscribeChangesOptions } from "@intentius/chant/lexicon";
40
+ import type { K8sClient, WatchHandle } from "@intentius/chant-k8s-client";
41
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
42
+ import { operationFor } from "./api/operation-surface";
43
+
44
+ /**
45
+ * The most connections one subscription will hold open.
46
+ *
47
+ * A watch is a long-lived HTTP/2 stream against the API server, and one per
48
+ * (kind, namespace) is cheap right up until an estate declares eighty kinds
49
+ * across a dozen namespaces. Past this ceiling the honest move is to refuse
50
+ * the whole subscription and say so, rather than to open some arbitrary
51
+ * prefix of it: a partial watch is a signal that goes quiet for exactly the
52
+ * resources nobody chose to drop. The operator then runs on its timer, which
53
+ * is what it did before this existed.
54
+ */
55
+ export const MAX_WATCHES = 32;
56
+
57
+ /** One thing to watch: a kind, and the namespace to watch it in. */
58
+ interface WatchTarget {
59
+ apiVersion: string;
60
+ kind: string;
61
+ /** Absent for a cluster-scoped kind. */
62
+ namespace?: string;
63
+ }
64
+
65
+ /**
66
+ * The distinct (kind, namespace) pairs a declared estate implies.
67
+ *
68
+ * Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
69
+ * scope every time and a test can assert on it.
70
+ */
71
+ export function watchTargets(
72
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
73
+ defaultNamespace: string,
74
+ ): { targets: WatchTarget[]; unaddressable: string[] } {
75
+ const byKey = new Map<string, WatchTarget>();
76
+ const unaddressable = new Set<string>();
77
+
78
+ for (const [, entity] of entities) {
79
+ const operation = operationFor(entity.entityType);
80
+ if (!operation) {
81
+ // chant knows no API address for this type, the same hole
82
+ // `describeResources` reports as `unsupported-kind`. Nothing to watch,
83
+ // and never a reason to widen to something else.
84
+ unaddressable.add(entity.entityType);
85
+ continue;
86
+ }
87
+ const declared = (entity.props.metadata as { namespace?: string } | undefined)?.namespace;
88
+ const namespace =
89
+ operation.scope === "Namespaced" ? (declared ?? defaultNamespace) : undefined;
90
+ const key = `${operation.apiVersion}|${operation.kind}|${namespace ?? ""}`;
91
+ if (!byKey.has(key)) {
92
+ byKey.set(key, {
93
+ apiVersion: operation.apiVersion,
94
+ kind: operation.kind,
95
+ ...(namespace ? { namespace } : {}),
96
+ });
97
+ }
98
+ }
99
+
100
+ return {
101
+ targets: [...byKey.values()].sort((a, b) =>
102
+ `${a.apiVersion}|${a.kind}|${a.namespace ?? ""}`.localeCompare(
103
+ `${b.apiVersion}|${b.kind}|${b.namespace ?? ""}`,
104
+ ),
105
+ ),
106
+ unaddressable: [...unaddressable].sort(),
107
+ };
108
+ }
109
+
110
+ /** Human phrasing of one target, for a log line. */
111
+ function targetText(target: WatchTarget): string {
112
+ return `${target.apiVersion} ${target.kind}${target.namespace ? ` in ${target.namespace}` : ""}`;
113
+ }
114
+
115
+ /**
116
+ * Open one watch per declared (kind, namespace) and report every event as a
117
+ * bare `onChange()`.
118
+ *
119
+ * Throws only for a failure that makes the whole subscription impossible: no
120
+ * entities in scope, a cluster binding that will not resolve, a scope past the
121
+ * ceiling. The operator turns that into one logged line and keeps polling.
122
+ * Once the subscription is live, nothing throws: a watch that dies reports
123
+ * through `onError` and the operator re-subscribes on its next round.
124
+ */
125
+ export async function subscribeChanges(
126
+ options: SubscribeChangesOptions,
127
+ connect: K8sConnector = defaultK8sConnector,
128
+ ): Promise<ChangeSubscription> {
129
+ const entities = options.entities;
130
+ if (!entities || entities.size === 0) {
131
+ throw new Error(
132
+ "no declared k8s entities in scope. A change signal watches what the project declares, and there is nothing to watch",
133
+ );
134
+ }
135
+
136
+ // The same connect path the read takes, so the cluster a signal comes from
137
+ // is the cluster a tick would read. A binding that refuses, refuses here.
138
+ const { client }: { client: K8sClient } = await connect({
139
+ environment: options.environment,
140
+ cwd: options.cwd,
141
+ });
142
+
143
+ const { targets, unaddressable } = watchTargets(entities, client.defaultNamespace);
144
+ if (unaddressable.length > 0) {
145
+ // A hole in the signal, reported the way a hole in an observation is:
146
+ // named, not swallowed. The timer still covers these kinds.
147
+ options.onError?.(
148
+ `no API address for ${unaddressable.join(", ")}, so changes to those kinds are found on the timer alone`,
149
+ );
150
+ }
151
+ if (targets.length === 0) {
152
+ throw new Error("no watchable k8s kinds among the declared entities");
153
+ }
154
+ if (targets.length > MAX_WATCHES) {
155
+ throw new Error(
156
+ `the declared estate needs ${targets.length} watch connections, past the ${MAX_WATCHES} ceiling, so it is ` +
157
+ "running on the operator's timer rather than opening a partial watch that would go quiet for the rest",
158
+ );
159
+ }
160
+
161
+ const handles: WatchHandle[] = [];
162
+ /** Reported once for the whole subscription: the operator re-subscribes as a whole. */
163
+ let reported = false;
164
+ const reportOnce = (message: string) => {
165
+ if (reported) return;
166
+ reported = true;
167
+ options.onError?.(message);
168
+ };
169
+
170
+ const stopOnAbort = () => {
171
+ void closeAll();
172
+ };
173
+ let closing: Promise<void> | undefined;
174
+ const closeAll = (): Promise<void> => {
175
+ if (!closing) {
176
+ options.signal.removeEventListener("abort", stopOnAbort);
177
+ const open = handles.splice(0, handles.length);
178
+ closing = Promise.allSettled(open.map((h) => h.close())).then(() => undefined);
179
+ }
180
+ return closing;
181
+ };
182
+
183
+ try {
184
+ for (const target of targets) {
185
+ handles.push(
186
+ await client.watch(
187
+ { apiVersion: target.apiVersion, kind: target.kind },
188
+ {
189
+ ...(target.namespace ? { namespace: target.namespace } : {}),
190
+ signal: options.signal,
191
+ // The whole consumption of a watch event, in one line: it happened,
192
+ // so look again. The frame is not read, not stored, not passed on.
193
+ onEvent: () => options.onChange(),
194
+ onError: (message) => reportOnce(`watch on ${targetText(target)} ended: ${message}`),
195
+ },
196
+ ),
197
+ );
198
+ }
199
+ } catch (err) {
200
+ // A partial open is not a subscription. Unwind what did open, and let the
201
+ // operator report one failure and keep its timer.
202
+ await closeAll();
203
+ throw err;
204
+ }
205
+
206
+ if (options.signal.aborted) await closeAll();
207
+ else options.signal.addEventListener("abort", stopOnAbort, { once: true });
208
+
209
+ return { close: closeAll };
210
+ }