@intentius/chant-lexicon-k8s 0.31.0 → 0.32.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 (103) hide show
  1. package/dist/api/classify.d.ts +10 -0
  2. package/dist/api/classify.d.ts.map +1 -1
  3. package/dist/api/owner-chain.d.ts +46 -0
  4. package/dist/api/owner-chain.d.ts.map +1 -0
  5. package/dist/api/sweep-types.d.ts +17 -0
  6. package/dist/api/sweep-types.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +86 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +146 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +15 -0
  12. package/dist/describe-resources.d.ts.map +1 -1
  13. package/dist/export-resources.d.ts +6 -2
  14. package/dist/export-resources.d.ts.map +1 -1
  15. package/dist/integrity.json +2 -2
  16. package/dist/kube/apply.d.ts +26 -0
  17. package/dist/kube/apply.d.ts.map +1 -0
  18. package/dist/kube/connect.d.ts +30 -0
  19. package/dist/kube/connect.d.ts.map +1 -0
  20. package/dist/kube/delete.d.ts +27 -0
  21. package/dist/kube/delete.d.ts.map +1 -0
  22. package/dist/kube/describe.d.ts +19 -0
  23. package/dist/kube/describe.d.ts.map +1 -0
  24. package/dist/kube/events.d.ts +34 -0
  25. package/dist/kube/events.d.ts.map +1 -0
  26. package/dist/kube/flags.d.ts +40 -0
  27. package/dist/kube/flags.d.ts.map +1 -0
  28. package/dist/kube/get.d.ts +21 -0
  29. package/dist/kube/get.d.ts.map +1 -0
  30. package/dist/kube/group.d.ts +19 -0
  31. package/dist/kube/group.d.ts.map +1 -0
  32. package/dist/kube/logs.d.ts +16 -0
  33. package/dist/kube/logs.d.ts.map +1 -0
  34. package/dist/kube/project.d.ts +59 -0
  35. package/dist/kube/project.d.ts.map +1 -0
  36. package/dist/kube/render.d.ts +75 -0
  37. package/dist/kube/render.d.ts.map +1 -0
  38. package/dist/kube/source.d.ts +19 -0
  39. package/dist/kube/source.d.ts.map +1 -0
  40. package/dist/kube/target.d.ts +52 -0
  41. package/dist/kube/target.d.ts.map +1 -0
  42. package/dist/kube/testing.d.ts +21 -0
  43. package/dist/kube/testing.d.ts.map +1 -0
  44. package/dist/kube/top.d.ts +23 -0
  45. package/dist/kube/top.d.ts.map +1 -0
  46. package/dist/kube/verdict.d.ts +45 -0
  47. package/dist/kube/verdict.d.ts.map +1 -0
  48. package/dist/kube/wait.d.ts +23 -0
  49. package/dist/kube/wait.d.ts.map +1 -0
  50. package/dist/manifest.json +1 -1
  51. package/dist/op/activities/index.d.ts +6 -3
  52. package/dist/op/activities/index.d.ts.map +1 -1
  53. package/dist/op/activities/kubectl.d.ts +91 -9
  54. package/dist/op/activities/kubectl.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/api/classify.ts +13 -0
  58. package/src/api/owner-chain.test.ts +171 -0
  59. package/src/api/owner-chain.ts +144 -0
  60. package/src/api/sweep-types.ts +36 -0
  61. package/src/deep-observe-hooks.ts +125 -0
  62. package/src/deep-observe.test.ts +668 -0
  63. package/src/deep-observe.ts +299 -0
  64. package/src/describe-resources.test.ts +224 -1
  65. package/src/describe-resources.ts +86 -1
  66. package/src/export-resources.ts +6 -22
  67. package/src/kube/apply.test.ts +105 -0
  68. package/src/kube/apply.ts +101 -0
  69. package/src/kube/connect.ts +54 -0
  70. package/src/kube/delete.test.ts +123 -0
  71. package/src/kube/delete.ts +174 -0
  72. package/src/kube/describe.test.ts +106 -0
  73. package/src/kube/describe.ts +195 -0
  74. package/src/kube/events.test.ts +115 -0
  75. package/src/kube/events.ts +160 -0
  76. package/src/kube/flags.test.ts +65 -0
  77. package/src/kube/flags.ts +114 -0
  78. package/src/kube/get.test.ts +195 -0
  79. package/src/kube/get.ts +190 -0
  80. package/src/kube/group.test.ts +33 -0
  81. package/src/kube/group.ts +125 -0
  82. package/src/kube/logs.test.ts +126 -0
  83. package/src/kube/logs.ts +96 -0
  84. package/src/kube/project.test.ts +57 -0
  85. package/src/kube/project.ts +106 -0
  86. package/src/kube/render.test.ts +142 -0
  87. package/src/kube/render.ts +252 -0
  88. package/src/kube/source.test.ts +138 -0
  89. package/src/kube/source.ts +139 -0
  90. package/src/kube/target.test.ts +67 -0
  91. package/src/kube/target.ts +89 -0
  92. package/src/kube/testing.ts +42 -0
  93. package/src/kube/top.test.ts +149 -0
  94. package/src/kube/top.ts +120 -0
  95. package/src/kube/verdict.test.ts +48 -0
  96. package/src/kube/verdict.ts +77 -0
  97. package/src/kube/wait.test.ts +125 -0
  98. package/src/kube/wait.ts +160 -0
  99. package/src/op/activities/index.ts +6 -3
  100. package/src/op/activities/kubectl.test.ts +298 -26
  101. package/src/op/activities/kubectl.ts +313 -20
  102. package/src/plugin.test.ts +58 -1
  103. package/src/plugin.ts +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",
@@ -71,14 +71,14 @@
71
71
  "@types/js-yaml": "^4.0.9"
72
72
  },
73
73
  "optionalDependencies": {
74
- "@intentius/chant-k8s-client": "^0.31.0"
74
+ "@intentius/chant-k8s-client": "^0.32.0"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@intentius/chant": "*",
78
78
  "typescript": "^5.9.3"
79
79
  },
80
80
  "peerDependencies": {
81
- "@intentius/chant": "^0.31.0",
81
+ "@intentius/chant": "^0.32.0",
82
82
  "typescript": "^5.9.3"
83
83
  }
84
84
  }
@@ -96,6 +96,19 @@ export function classifyApiFailure(err: unknown): K8sReadOutcome {
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * The cluster's own discovery reports no such kind at all — distinct from a
101
+ * kind that exists but currently has zero live instances. `classifyApiFailure`
102
+ * folds both into `absent` for the declared-entity read path, where "no
103
+ * instance can exist" is exactly the signal a `create` needs. `chant kube get`
104
+ * (chant #1079) needs the finer distinction: an ad hoc `chant kube get
105
+ * widgets` against a kind the cluster has never heard of should say so, not
106
+ * print "No resources found." as if `widgets` were a real, empty kind.
107
+ */
108
+ export function isUnknownResource(err: unknown): boolean {
109
+ return shapeOf(err).name === NAMES.unknownResource;
110
+ }
111
+
99
112
  /**
100
113
  * Whether a failure kills the whole observation rather than one entity. A
101
114
  * refused binding, a missing client package and a rejected credential plugin
@@ -0,0 +1,171 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { resolveK8sOwnerChain } from "./owner-chain";
3
+ import type { K8sObject } from "@intentius/chant-k8s-client";
4
+
5
+ function pod(uid: string, ownerRefs?: Array<Record<string, unknown>>): K8sObject {
6
+ return {
7
+ apiVersion: "v1",
8
+ kind: "Pod",
9
+ metadata: { name: "p", namespace: "prod", uid, ...(ownerRefs ? { ownerReferences: ownerRefs } : {}) },
10
+ };
11
+ }
12
+
13
+ function ownerRef(kind: string, name: string, uid: string, controller = true): Record<string, unknown> {
14
+ return { apiVersion: "apps/v1", kind, name, uid, controller };
15
+ }
16
+
17
+ describe("resolveK8sOwnerChain (#1077)", () => {
18
+ test("no ownerReferences at all → unowned", async () => {
19
+ const reader = { readIfPresent: vi.fn() };
20
+ const result = await resolveK8sOwnerChain(pod("pod-uid"), {
21
+ declaredByUid: new Map(),
22
+ reader,
23
+ namespace: "prod",
24
+ });
25
+ expect(result).toEqual({ root: "unowned" });
26
+ expect(reader.readIfPresent).not.toHaveBeenCalled();
27
+ });
28
+
29
+ test("owner uid matches a declared entity directly → declared, no extra reads", async () => {
30
+ const reader = { readIfPresent: vi.fn() };
31
+ const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("Deployment", "web", "deploy-uid")]), {
32
+ declaredByUid: new Map([["deploy-uid", "web"]]),
33
+ reader,
34
+ namespace: "prod",
35
+ });
36
+ expect(result).toEqual({ root: "declared", entity: "web" });
37
+ expect(reader.readIfPresent).not.toHaveBeenCalled();
38
+ });
39
+
40
+ test("Pod → ReplicaSet (undeclared, read live) → Deployment (declared) resolves through the intermediate hop", async () => {
41
+ const replicaSet: K8sObject = {
42
+ apiVersion: "apps/v1",
43
+ kind: "ReplicaSet",
44
+ metadata: {
45
+ name: "web-7d9f8c9c8",
46
+ namespace: "prod",
47
+ uid: "rs-uid",
48
+ ownerReferences: [ownerRef("Deployment", "web", "deploy-uid")],
49
+ },
50
+ };
51
+ const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
52
+ const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "web-7d9f8c9c8", "rs-uid")]), {
53
+ declaredByUid: new Map([["deploy-uid", "web"]]),
54
+ reader,
55
+ namespace: "prod",
56
+ });
57
+ expect(result).toEqual({ root: "declared", entity: "web" });
58
+ expect(reader.readIfPresent).toHaveBeenCalledTimes(1);
59
+ expect(reader.readIfPresent).toHaveBeenCalledWith({
60
+ apiVersion: "apps/v1",
61
+ kind: "ReplicaSet",
62
+ name: "web-7d9f8c9c8",
63
+ namespace: "prod",
64
+ });
65
+ });
66
+
67
+ test("chain resolves fully to a live, undeclared root → foreign", async () => {
68
+ const replicaSet: K8sObject = {
69
+ apiVersion: "apps/v1",
70
+ kind: "ReplicaSet",
71
+ metadata: { name: "other-rs", namespace: "prod", uid: "rs-uid" }, // no further owner
72
+ };
73
+ const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
74
+ const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "other-rs", "rs-uid")]), {
75
+ declaredByUid: new Map(), // nothing declared at all
76
+ reader,
77
+ namespace: "prod",
78
+ });
79
+ expect(result).toEqual({ root: "foreign" });
80
+ });
81
+
82
+ test("an owner read that 404s (readIfPresent → undefined) is conservative unknown, not foreign", async () => {
83
+ const reader = { readIfPresent: vi.fn().mockResolvedValue(undefined) };
84
+ const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "gone-rs", "rs-uid")]), {
85
+ declaredByUid: new Map(),
86
+ reader,
87
+ namespace: "prod",
88
+ });
89
+ expect(result).toEqual({ root: "unknown" });
90
+ });
91
+
92
+ test("an owner read that throws (RBAC denial, transport error) is conservative unknown", async () => {
93
+ const reader = { readIfPresent: vi.fn().mockRejectedValue(new Error("Forbidden")) };
94
+ const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "denied-rs", "rs-uid")]), {
95
+ declaredByUid: new Map(),
96
+ reader,
97
+ namespace: "prod",
98
+ });
99
+ expect(result).toEqual({ root: "unknown" });
100
+ });
101
+
102
+ test("a cycle (A owns B, B owns A) terminates and classifies unknown rather than looping forever", async () => {
103
+ const objA: K8sObject = {
104
+ apiVersion: "v1",
105
+ kind: "Widget",
106
+ metadata: { name: "a", namespace: "prod", uid: "a-uid", ownerReferences: [ownerRef("Widget", "b", "b-uid")] },
107
+ };
108
+ const objB: K8sObject = {
109
+ apiVersion: "v1",
110
+ kind: "Widget",
111
+ metadata: { name: "b", namespace: "prod", uid: "b-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
112
+ };
113
+ const reader = {
114
+ readIfPresent: vi.fn(async ({ name }: { name: string }) => (name === "a" ? objA : objB)),
115
+ };
116
+ const start: K8sObject = {
117
+ apiVersion: "v1",
118
+ kind: "Widget",
119
+ metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
120
+ };
121
+ const result = await resolveK8sOwnerChain(start, { declaredByUid: new Map(), reader, namespace: "prod" });
122
+ expect(result).toEqual({ root: "unknown" });
123
+ });
124
+
125
+ test("respects a custom maxDepth, fetching no more than maxDepth + 1 objects", async () => {
126
+ // A chain 5 hops deep, but bounded to 2 — never reaches the declared root.
127
+ const objects: Record<string, K8sObject> = {};
128
+ for (let i = 0; i < 5; i++) {
129
+ objects[`n${i}`] = {
130
+ apiVersion: "v1",
131
+ kind: "Widget",
132
+ metadata: { name: `n${i}`, namespace: "prod", uid: `uid-${i}`, ownerReferences: [ownerRef("Widget", `n${i + 1}`, `uid-${i + 1}`)] },
133
+ };
134
+ }
135
+ const reader = {
136
+ readIfPresent: vi.fn(async ({ name }: { name: string }) => objects[name]),
137
+ };
138
+ const start: K8sObject = {
139
+ apiVersion: "v1",
140
+ kind: "Widget",
141
+ metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "n0", "uid-0")] },
142
+ };
143
+ const result = await resolveK8sOwnerChain(start, {
144
+ declaredByUid: new Map([["uid-4", "deep-entity"]]), // reachable, but past the bound
145
+ reader,
146
+ namespace: "prod",
147
+ maxDepth: 2,
148
+ });
149
+ expect(result).toEqual({ root: "unknown" });
150
+ expect(reader.readIfPresent.mock.calls.length).toBeLessThanOrEqual(3); // maxDepth + 1
151
+ });
152
+
153
+ test("a starting object without a uid cannot be classified — unknown", async () => {
154
+ const reader = { readIfPresent: vi.fn() };
155
+ const noUid: K8sObject = { apiVersion: "v1", kind: "Pod", metadata: { name: "p", namespace: "prod" } };
156
+ const result = await resolveK8sOwnerChain(noUid, { declaredByUid: new Map(), reader, namespace: "prod" });
157
+ expect(result).toEqual({ root: "unknown" });
158
+ expect(reader.readIfPresent).not.toHaveBeenCalled();
159
+ });
160
+
161
+ test("picks the controller:true owner reference when several are present", async () => {
162
+ const reader = { readIfPresent: vi.fn() };
163
+ const refs = [ownerRef("OtherOwner", "shared", "shared-uid", false), ownerRef("Deployment", "web", "deploy-uid", true)];
164
+ const result = await resolveK8sOwnerChain(pod("pod-uid", refs), {
165
+ declaredByUid: new Map([["deploy-uid", "web"]]),
166
+ reader,
167
+ namespace: "prod",
168
+ });
169
+ expect(result).toEqual({ root: "declared", entity: "web" });
170
+ });
171
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Resolving a live Kubernetes object's `ownerReferences` chain up to a
3
+ * declared, chant-observed entity (chant #1077).
4
+ *
5
+ * There is no server-side "who ultimately owns this object" query — Kubernetes'
6
+ * own garbage collector does not have one either; it walks the same chain one
7
+ * hop at a time, reading each intermediate owner. A Pod's `ownerReferences`
8
+ * names its ReplicaSet, which chant never declared; the ReplicaSet's own
9
+ * `ownerReferences` names the Deployment, which is declared. Reaching that
10
+ * declared entity takes reading the ReplicaSet in between.
11
+ *
12
+ * This module's only job is assembling that chain — fetching each hop through
13
+ * the typed client, bounded and cycle-guarded so a corrupt or adversarial
14
+ * chain cannot hang an observation. The bounded/cycle-safe *interpretation* of
15
+ * the assembled chain (declared / unowned / foreign / unknown) is core's
16
+ * `classifyOwnerChain` (`@intentius/chant/owner-chain`) — the issue's own
17
+ * division of labor: core owns the category, the lexicon supplies the chain.
18
+ */
19
+
20
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
21
+ import {
22
+ classifyOwnerChain,
23
+ DEFAULT_MAX_OWNER_CHAIN_DEPTH,
24
+ type OwnerChainNode,
25
+ type OwnerChainVerdict,
26
+ } from "@intentius/chant/owner-chain";
27
+
28
+ interface RawOwnerRef {
29
+ apiVersion?: string;
30
+ kind?: string;
31
+ name?: string;
32
+ uid?: string;
33
+ controller?: boolean;
34
+ }
35
+
36
+ function ownerRefs(obj: K8sObject): RawOwnerRef[] {
37
+ const refs = obj.metadata?.ownerReferences;
38
+ return Array.isArray(refs) ? (refs as RawOwnerRef[]) : [];
39
+ }
40
+
41
+ /**
42
+ * The reference Kubernetes' own garbage collector treats as the controlling
43
+ * parent: the entry with `controller: true`, else the first. An object with
44
+ * several owner references (rare — usually a shared, non-controller owner
45
+ * alongside the controlling one) is walked through its controller, matching
46
+ * what actually recreates the object were it deleted.
47
+ */
48
+ function controllingRef(obj: K8sObject): RawOwnerRef | undefined {
49
+ const refs = ownerRefs(obj);
50
+ return refs.find((r) => r.controller === true) ?? refs[0];
51
+ }
52
+
53
+ /** The client surface this module needs — a single object read, addressed by
54
+ * an owner reference's own coordinates. */
55
+ export type OwnerChainReader = Pick<K8sClient, "readIfPresent">;
56
+
57
+ export interface ResolveOwnerChainOptions {
58
+ /** uid → declared chant entity name, from this observation's own resolved entities. */
59
+ declaredByUid: ReadonlyMap<string, string>;
60
+ /** Reads each intermediate owner. */
61
+ reader: OwnerChainReader;
62
+ /**
63
+ * Namespace the starting object lives in. Owner references are same-namespace
64
+ * only — a namespaced object cannot be owned by an object in another
65
+ * namespace, the same rule Kubernetes' own garbage collector enforces —so
66
+ * every hop is read in this namespace.
67
+ */
68
+ namespace: string | undefined;
69
+ maxDepth?: number;
70
+ }
71
+
72
+ /**
73
+ * Walk `obj`'s owner-reference chain up to a declared entity, a foreign root,
74
+ * or a bound (#1077). Fetches at most `maxDepth + 1` objects (the starting
75
+ * object plus up to `maxDepth` ancestors) — the same bound `classifyOwnerChain`
76
+ * enforces when interpreting the result, so nothing is fetched that the
77
+ * interpretation would not have used anyway.
78
+ */
79
+ export async function resolveK8sOwnerChain(
80
+ obj: K8sObject,
81
+ options: ResolveOwnerChainOptions,
82
+ ): Promise<OwnerChainVerdict> {
83
+ const startUid = obj.metadata?.uid;
84
+ if (!startUid) return { root: "unknown" };
85
+
86
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_OWNER_CHAIN_DEPTH;
87
+ const nodes = new Map<string, OwnerChainNode>();
88
+ const visited = new Set<string>();
89
+
90
+ let uid: string | undefined = startUid;
91
+ let object: K8sObject | undefined = obj;
92
+
93
+ for (let depth = 0; uid !== undefined && depth <= maxDepth; depth++) {
94
+ if (visited.has(uid)) break; // cycle — classifyOwnerChain reads it off `nodes`
95
+ visited.add(uid);
96
+
97
+ const declaredEntity = options.declaredByUid.get(uid);
98
+ if (declaredEntity) {
99
+ nodes.set(uid, { declaredEntity });
100
+ break;
101
+ }
102
+ if (!object) {
103
+ nodes.set(uid, { ownerUnreadable: true });
104
+ break;
105
+ }
106
+
107
+ const ref = controllingRef(object);
108
+ if (!ref?.uid || !ref.kind || !ref.apiVersion || !ref.name) {
109
+ nodes.set(uid, {}); // no further owner — a real, live root
110
+ break;
111
+ }
112
+ nodes.set(uid, { ownerId: ref.uid });
113
+
114
+ if (visited.has(ref.uid)) {
115
+ // About to cycle back to an already-visited node — let the loop's own
116
+ // check classify it next iteration rather than issuing a wasted read.
117
+ uid = ref.uid;
118
+ continue;
119
+ }
120
+ if (options.declaredByUid.has(ref.uid)) {
121
+ // Already known to be a declared entity from this observation's own
122
+ // resolved set — no need to read it, the next iteration resolves it
123
+ // from `declaredByUid` directly.
124
+ uid = ref.uid;
125
+ object = undefined;
126
+ continue;
127
+ }
128
+
129
+ try {
130
+ object = await options.reader.readIfPresent({
131
+ apiVersion: ref.apiVersion,
132
+ kind: ref.kind,
133
+ name: ref.name,
134
+ namespace: options.namespace,
135
+ });
136
+ } catch {
137
+ object = undefined;
138
+ }
139
+ if (!object) nodes.set(ref.uid, { ownerUnreadable: true });
140
+ uid = ref.uid;
141
+ }
142
+
143
+ return classifyOwnerChain(startUid, nodes, maxDepth);
144
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The kinds chant sweeps when nothing narrows the question.
3
+ *
4
+ * A *product* decision — what a bare `chant import` should pull back, and
5
+ * (chant #1075) which kinds an ownership-scoped prune has to look at to notice
6
+ * that a whole kind was removed from source. Not an addressing limit: since
7
+ * chant #1074 removed `KUBECTL_RESOURCE`, a `--selector type=<entity type>`
8
+ * import can name any of the ~180 types the generated operation surface
9
+ * carries, CRDs included.
10
+ *
11
+ * It lives in its own module, with no imports of its own, because both
12
+ * consumers reach it from different directions — `../export-resources.ts`
13
+ * pulls in the whole import parser, and a Temporal worker loading the apply
14
+ * activity should not.
15
+ */
16
+ export const DEFAULT_IMPORT_TYPES: readonly string[] = [
17
+ "K8s::Apps::Deployment",
18
+ "K8s::Apps::StatefulSet",
19
+ "K8s::Apps::DaemonSet",
20
+ "K8s::Apps::ReplicaSet",
21
+ "K8s::Core::Service",
22
+ "K8s::Core::ConfigMap",
23
+ "K8s::Core::Secret",
24
+ "K8s::Core::Namespace",
25
+ "K8s::Core::Pod",
26
+ "K8s::Core::PersistentVolumeClaim",
27
+ "K8s::Core::ServiceAccount",
28
+ "K8s::Batch::Job",
29
+ "K8s::Batch::CronJob",
30
+ "K8s::Networking::Ingress",
31
+ "K8s::Networking::NetworkPolicy",
32
+ "K8s::Rbac::Role",
33
+ "K8s::Rbac::RoleBinding",
34
+ "K8s::Rbac::ClusterRole",
35
+ "K8s::Rbac::ClusterRoleBinding",
36
+ ];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The k8s lexicon's *static* deep-observation noise rules (#1076, epic #1073).
3
+ *
4
+ * Split out from `./deep-observe.ts` for one reason: this file must be safe
5
+ * to import from `plugin.ts` at module load time, because
6
+ * `LexiconPlugin.deepNormalizationHooks` is plain data core reads to
7
+ * normalize the *declared* tree — the half of the contract that runs whether
8
+ * or not a cluster is ever touched (`lifecycle diff` without `--live`,
9
+ * `chant build`, tests that only exercise normalization). `./deep-observe.ts`
10
+ * itself imports `@intentius/chant-k8s-client` for the live read, and chant
11
+ * #1074 made that package's reachability from the build path a structural
12
+ * property (`examples/k8s-client-boundary.test.ts`) rather than a lint rule —
13
+ * so nothing this file exports may pull that package in, directly or
14
+ * transitively. It imports nothing but the core contract's own types.
15
+ *
16
+ * What lives here is deliberately the *entityType-keyed, resource-agnostic*
17
+ * half of the rules: which fields the API server always populates regardless
18
+ * of what a manager wrote (`status`, `metadata.uid`, …), which fields
19
+ * Kubernetes defaults when a manifest is silent about them, and which arrays
20
+ * are sets addressed by a well-known identity (containers by name, ports by
21
+ * containerPort+protocol). None of that needs a live object in hand.
22
+ *
23
+ * What does *not* live here is the managed-fields prune — whether one
24
+ * specific field on one specific live object is chant-owned, foreign-owned,
25
+ * or contested. That is inherently per-object (it depends on *that* object's
26
+ * `metadata.managedFields`, which the declared tree never carries and which
27
+ * differs between two Deployments of the same type), so it cannot be
28
+ * expressed as a fixed rule keyed only by entity type and path — the shape
29
+ * every other hook in this file takes. `./deep-observe.ts` computes it once
30
+ * per resource and layers it on top of the rules below.
31
+ *
32
+ * The *entity-type-agnostic* half of these rules — which fields every
33
+ * Kubernetes API object carries regardless of kind, and the well-known
34
+ * list-map-key ordering conventions (containers/env/volumes/ports) — lives in
35
+ * `@intentius/chant/managed-fields` (chant #1087), because a GCP Config
36
+ * Connector custom resource is a Kubernetes object too and needs the exact
37
+ * same rules without depending on this lexicon's package. What stays here is
38
+ * only what's genuinely k8s-*lexicon*-specific: {@link K8S_SERVICE_DEFAULTS},
39
+ * keyed by chant's own k8s entityType catalog.
40
+ */
41
+
42
+ import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
43
+ import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
44
+
45
+ /**
46
+ * Kubernetes-defaulted fields, per entity type, as index-erased property
47
+ * paths. Subtracted only where source never declared the property
48
+ * (`side === "live" && counterpart === "absent"`) — cdk-real-drift's default
49
+ * subtraction, same convention as AWS/Azure/Temporal's tables.
50
+ *
51
+ * Sparse and evidence-based rather than derived from the generated schema:
52
+ * the k8s OpenAPI spec this lexicon's codegen consumes
53
+ * (`lexicons/k8s/src/spec/parse.ts`) does not carry a `default` value for
54
+ * these fields the way ARM's schema sometimes does, so "per discovery" is not
55
+ * actually expressible today. Widening this table is additive and needs no
56
+ * contract change.
57
+ *
58
+ * `spec.strategy` is listed whole, not as `spec.strategy.type`, for the same
59
+ * reason Temporal's `TEMPORAL_SCHEDULE_DEFAULTS` lists `state` whole: pruning
60
+ * only the leaf would still recurse into the object, and a nested default the
61
+ * table does not separately name (`rollingUpdate.maxSurge`/`maxUnavailable`,
62
+ * both `"25%"` when `spec.strategy` is omitted entirely) would leave behind an
63
+ * empty `strategy: {}` — a value distinct from no `strategy` key at all, and
64
+ * itself a spurious "undeclared" finding. Matching the whole node first, before
65
+ * its children are ever visited, drops the wrapper outright when every field
66
+ * under it is exactly at its default.
67
+ */
68
+ export const K8S_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
69
+ "K8s::Apps::Deployment": {
70
+ "spec.strategy": { type: "RollingUpdate", rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%" } },
71
+ "spec.revisionHistoryLimit": 10,
72
+ "spec.progressDeadlineSeconds": 600,
73
+ "spec.template.spec.dnsPolicy": "ClusterFirst",
74
+ "spec.template.spec.restartPolicy": "Always",
75
+ "spec.template.spec.terminationGracePeriodSeconds": 30,
76
+ "spec.template.spec.schedulerName": "default-scheduler",
77
+ },
78
+ "K8s::Core::Service": {
79
+ "spec.sessionAffinity": "None",
80
+ "spec.type": "ClusterIP",
81
+ },
82
+ };
83
+
84
+ /** Stable JSON with sorted keys — the fallback ordering key for a set-like array without a natural identity field. */
85
+ function canonicalJson(value: unknown): string {
86
+ return (
87
+ JSON.stringify(value, (_k, v: unknown) =>
88
+ v && typeof v === "object" && !Array.isArray(v)
89
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
90
+ : v,
91
+ ) ?? ""
92
+ );
93
+ }
94
+
95
+ /**
96
+ * The k8s lexicon's static noise rules: the generic Kubernetes object
97
+ * envelope (unconditional, by pattern, `@intentius/chant/managed-fields`) and
98
+ * Kubernetes-defaulted fields (gated on `counterpart === "absent"`), plus the
99
+ * array orderings the acceptance criteria name — `x-kubernetes-patch-merge-
100
+ * key`/`list-map-keys` conventions the generated surface does not currently
101
+ * carry (see the module doc), so these are the "else named-by-name
102
+ * conventions" the issue calls for: containers by `name`, `env` by `name`,
103
+ * `volumes` by `name`, container/service `ports` by `containerPort`/`port` +
104
+ * `protocol` — the same conventions `@intentius/chant/managed-fields`'s
105
+ * `k8sListMapOrderKey` implements, reused verbatim.
106
+ *
107
+ * This is the object `k8sPlugin.deepNormalizationHooks` is. It is also what
108
+ * `./deep-observe.ts` layers its per-resource managed-fields prune on top of,
109
+ * so the two normalization passes (the reader's own, and core's later
110
+ * re-normalization of both the declared and the already-normalized live tree
111
+ * — see `packages/core/src/lifecycle/deep-observe.ts`) apply the identical
112
+ * entityType-keyed rules either way.
113
+ */
114
+ export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
115
+ prune(node: DeepNode): boolean {
116
+ if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
117
+
118
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
119
+ const defaults = K8S_SERVICE_DEFAULTS[node.entityType];
120
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
121
+ return canonicalJson(defaults[node.pattern]) === canonicalJson(node.value);
122
+ },
123
+
124
+ orderKey: k8sListMapOrderKey,
125
+ };