@intentius/chant-lexicon-k8s 0.30.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 (137) hide show
  1. package/dist/api/classify.d.ts +65 -0
  2. package/dist/api/classify.d.ts.map +1 -0
  3. package/dist/api/connect.d.ts +58 -0
  4. package/dist/api/connect.d.ts.map +1 -0
  5. package/dist/api/fake-cluster.d.ts +55 -0
  6. package/dist/api/fake-cluster.d.ts.map +1 -0
  7. package/dist/api/operation-surface.d.ts +64 -0
  8. package/dist/api/operation-surface.d.ts.map +1 -0
  9. package/dist/api/owner-chain.d.ts +46 -0
  10. package/dist/api/owner-chain.d.ts.map +1 -0
  11. package/dist/api/sweep-types.d.ts +17 -0
  12. package/dist/api/sweep-types.d.ts.map +1 -0
  13. package/dist/codegen/generate-operations.d.ts +29 -0
  14. package/dist/codegen/generate-operations.d.ts.map +1 -0
  15. package/dist/codegen/generate.d.ts.map +1 -1
  16. package/dist/config.d.ts +17 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/crd/parser.d.ts.map +1 -1
  19. package/dist/crd/types.d.ts +7 -0
  20. package/dist/crd/types.d.ts.map +1 -1
  21. package/dist/deep-observe-hooks.d.ts +86 -0
  22. package/dist/deep-observe-hooks.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +146 -0
  24. package/dist/deep-observe.d.ts.map +1 -0
  25. package/dist/describe-resources.d.ts +58 -26
  26. package/dist/describe-resources.d.ts.map +1 -1
  27. package/dist/export-resources.d.ts +31 -1
  28. package/dist/export-resources.d.ts.map +1 -1
  29. package/dist/integrity.json +2 -2
  30. package/dist/kube/apply.d.ts +26 -0
  31. package/dist/kube/apply.d.ts.map +1 -0
  32. package/dist/kube/connect.d.ts +30 -0
  33. package/dist/kube/connect.d.ts.map +1 -0
  34. package/dist/kube/delete.d.ts +27 -0
  35. package/dist/kube/delete.d.ts.map +1 -0
  36. package/dist/kube/describe.d.ts +19 -0
  37. package/dist/kube/describe.d.ts.map +1 -0
  38. package/dist/kube/events.d.ts +34 -0
  39. package/dist/kube/events.d.ts.map +1 -0
  40. package/dist/kube/flags.d.ts +40 -0
  41. package/dist/kube/flags.d.ts.map +1 -0
  42. package/dist/kube/get.d.ts +21 -0
  43. package/dist/kube/get.d.ts.map +1 -0
  44. package/dist/kube/group.d.ts +19 -0
  45. package/dist/kube/group.d.ts.map +1 -0
  46. package/dist/kube/logs.d.ts +16 -0
  47. package/dist/kube/logs.d.ts.map +1 -0
  48. package/dist/kube/project.d.ts +59 -0
  49. package/dist/kube/project.d.ts.map +1 -0
  50. package/dist/kube/render.d.ts +75 -0
  51. package/dist/kube/render.d.ts.map +1 -0
  52. package/dist/kube/source.d.ts +19 -0
  53. package/dist/kube/source.d.ts.map +1 -0
  54. package/dist/kube/target.d.ts +52 -0
  55. package/dist/kube/target.d.ts.map +1 -0
  56. package/dist/kube/testing.d.ts +21 -0
  57. package/dist/kube/testing.d.ts.map +1 -0
  58. package/dist/kube/top.d.ts +23 -0
  59. package/dist/kube/top.d.ts.map +1 -0
  60. package/dist/kube/verdict.d.ts +45 -0
  61. package/dist/kube/verdict.d.ts.map +1 -0
  62. package/dist/kube/wait.d.ts +23 -0
  63. package/dist/kube/wait.d.ts.map +1 -0
  64. package/dist/manifest.json +1 -1
  65. package/dist/op/activities/index.d.ts +7 -4
  66. package/dist/op/activities/index.d.ts.map +1 -1
  67. package/dist/op/activities/kubectl.d.ts +120 -2
  68. package/dist/op/activities/kubectl.d.ts.map +1 -1
  69. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  70. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  71. package/dist/plugin.d.ts.map +1 -1
  72. package/dist/spec/parse.d.ts +42 -0
  73. package/dist/spec/parse.d.ts.map +1 -1
  74. package/package.json +5 -2
  75. package/src/api/classify.test.ts +133 -0
  76. package/src/api/classify.ts +144 -0
  77. package/src/api/connect.ts +104 -0
  78. package/src/api/fake-cluster.ts +218 -0
  79. package/src/api/operation-surface.test.ts +116 -0
  80. package/src/api/operation-surface.ts +129 -0
  81. package/src/api/owner-chain.test.ts +171 -0
  82. package/src/api/owner-chain.ts +144 -0
  83. package/src/api/sweep-types.ts +36 -0
  84. package/src/codegen/generate-operations.ts +56 -0
  85. package/src/codegen/generate.ts +9 -0
  86. package/src/config.ts +17 -0
  87. package/src/crd/parser.ts +8 -0
  88. package/src/crd/types.ts +7 -0
  89. package/src/deep-observe-hooks.ts +125 -0
  90. package/src/deep-observe.test.ts +668 -0
  91. package/src/deep-observe.ts +299 -0
  92. package/src/describe-resources.test.ts +619 -191
  93. package/src/describe-resources.ts +218 -117
  94. package/src/export-resources-io.test.ts +76 -51
  95. package/src/export-resources.ts +47 -35
  96. package/src/generated/operations.json +2156 -0
  97. package/src/kube/apply.test.ts +105 -0
  98. package/src/kube/apply.ts +101 -0
  99. package/src/kube/connect.ts +54 -0
  100. package/src/kube/delete.test.ts +123 -0
  101. package/src/kube/delete.ts +174 -0
  102. package/src/kube/describe.test.ts +106 -0
  103. package/src/kube/describe.ts +195 -0
  104. package/src/kube/events.test.ts +115 -0
  105. package/src/kube/events.ts +160 -0
  106. package/src/kube/flags.test.ts +65 -0
  107. package/src/kube/flags.ts +114 -0
  108. package/src/kube/get.test.ts +195 -0
  109. package/src/kube/get.ts +190 -0
  110. package/src/kube/group.test.ts +33 -0
  111. package/src/kube/group.ts +125 -0
  112. package/src/kube/logs.test.ts +126 -0
  113. package/src/kube/logs.ts +96 -0
  114. package/src/kube/project.test.ts +57 -0
  115. package/src/kube/project.ts +106 -0
  116. package/src/kube/render.test.ts +142 -0
  117. package/src/kube/render.ts +252 -0
  118. package/src/kube/source.test.ts +138 -0
  119. package/src/kube/source.ts +139 -0
  120. package/src/kube/target.test.ts +67 -0
  121. package/src/kube/target.ts +89 -0
  122. package/src/kube/testing.ts +42 -0
  123. package/src/kube/top.test.ts +149 -0
  124. package/src/kube/top.ts +120 -0
  125. package/src/kube/verdict.test.ts +48 -0
  126. package/src/kube/verdict.ts +77 -0
  127. package/src/kube/wait.test.ts +125 -0
  128. package/src/kube/wait.ts +160 -0
  129. package/src/lifecycle-integration.test.ts +132 -92
  130. package/src/op/activities/index.ts +7 -3
  131. package/src/op/activities/kubectl.test.ts +420 -0
  132. package/src/op/activities/kubectl.ts +379 -13
  133. package/src/op/activities/wait-for-ready.test.ts +94 -0
  134. package/src/op/activities/wait-for-ready.ts +66 -15
  135. package/src/plugin.test.ts +58 -1
  136. package/src/plugin.ts +29 -0
  137. package/src/spec/parse.ts +93 -1
@@ -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,56 @@
1
+ /**
2
+ * The generated operation surface — chant #1074.
3
+ *
4
+ * `describeResources` used to reach the cluster through a hand-written
5
+ * `entityType → kubectl resource` map with twenty entries in it. Every one of
6
+ * the other ~180 generated resource types, and every CRD, fell off the end of
7
+ * it. The map was hand-maintained precisely because nothing derived it, and
8
+ * nothing derived it because the codegen pass that produces the types never
9
+ * emitted the addressing half.
10
+ *
11
+ * It does now. This artifact is written by the same `generate()` run that
12
+ * writes `lexicon-k8s.json` and `index.d.ts`, out of the same parsed results,
13
+ * so a resource that has a declarable class necessarily has an operation entry
14
+ * with the same apiVersion and kind. `operation-surface.test.ts` asserts that
15
+ * correspondence rather than trusting it.
16
+ *
17
+ * What it is not: an authority on what a given cluster serves. `plural` and
18
+ * `scope` are what the schema says; the live client confirms both against the
19
+ * cluster's own discovery before addressing anything, because a cluster can
20
+ * serve a different version of a CRD than the one chant generated from.
21
+ */
22
+
23
+ import type { K8sParseResult } from "../spec/parse";
24
+ import { gvkToApiVersion } from "../spec/parse";
25
+ import { pluralizeKind, type K8sOperationDescriptor, type K8sOperationTable } from "../api/operation-surface";
26
+
27
+ export type { K8sOperationDescriptor, K8sOperationTable };
28
+
29
+ /** Build the operation table from the same parsed results the types come from. */
30
+ export function buildOperationTable(results: K8sParseResult[]): K8sOperationTable {
31
+ const table: K8sOperationTable = {};
32
+ for (const result of results) {
33
+ if (result.isProperty) continue;
34
+ const entityType = result.resource.typeName;
35
+ // A later result for the same type wins nothing — the first parse of a
36
+ // preferred version is canonical, matching the registry's own precedence.
37
+ if (table[entityType]) continue;
38
+ table[entityType] = {
39
+ entityType,
40
+ apiVersion: gvkToApiVersion(result.gvk),
41
+ kind: result.gvk.kind,
42
+ plural: result.operation?.plural ?? pluralizeKind(result.gvk.kind),
43
+ scope: result.operation?.scope ?? "Namespaced",
44
+ verbs: result.operation?.verbs ?? [],
45
+ };
46
+ }
47
+ return table;
48
+ }
49
+
50
+ /** Serialize the table, key-sorted so regeneration produces a stable diff. */
51
+ export function generateOperationsJSON(results: K8sParseResult[]): string {
52
+ const table = buildOperationTable(results);
53
+ const sorted: K8sOperationTable = {};
54
+ for (const key of Object.keys(table).sort()) sorted[key] = table[key];
55
+ return `${JSON.stringify(sorted, null, 2)}\n`;
56
+ }
@@ -17,6 +17,7 @@ import { loadMultipleCRDs } from "../crd/loader";
17
17
  import { CRD_SOURCES } from "../crd/crd-sources";
18
18
  import { NamingStrategy, propertyTypeName, extractDefName } from "./naming";
19
19
  import { generateLexiconJSON } from "./generate-lexicon";
20
+ import { generateOperationsJSON } from "./generate-operations";
20
21
  import { generateTypeScriptDeclarations } from "./generate-typescript";
21
22
  import {
22
23
  generateRuntimeIndex as coreGenerateRuntimeIndex,
@@ -98,6 +99,13 @@ export async function generate(opts: K8sGenerateOptions = {}): Promise<GenerateR
98
99
  generateRuntimeIndex: (results, naming) => {
99
100
  return generateRuntimeIndex(results, naming as NamingStrategy);
100
101
  },
102
+
103
+ // chant #1074 — the operation surface, out of the same results the types
104
+ // and the registry come out of, so the live client cannot address a kind
105
+ // differently from how the declarable surface names it.
106
+ generateExtraArtifacts: (results) => ({
107
+ "operations.json": generateOperationsJSON(results),
108
+ }),
101
109
  };
102
110
 
103
111
  return generatePipeline(config, opts);
@@ -114,6 +122,7 @@ export function writeGeneratedFiles(result: GenerateResult, baseDir: string): vo
114
122
  "index.d.ts": result.typesDTS,
115
123
  "index.ts": result.indexTS,
116
124
  "runtime.ts": `/**\n * Runtime factory constructors — re-exported from core.\n */\nexport { createResource, createProperty } from "@intentius/chant/runtime";\n`,
125
+ ...(result.extraArtifacts ?? {}),
117
126
  },
118
127
  });
119
128
  }
package/src/config.ts CHANGED
@@ -60,4 +60,21 @@ export interface K8sClusterProfile {
60
60
  export interface K8sChantConfig {
61
61
  /** Named environment → cluster bindings, keyed by environment name. */
62
62
  profiles?: Record<string, K8sClusterProfile>;
63
+ /**
64
+ * Exec credential-plugin commands chant may execute (chant #1074).
65
+ *
66
+ * On EKS, AKS and GKE, kubeconfig authentication is a subprocess:
67
+ * `aws eks get-token`, `kubelogin`, `gke-gcloud-auth-plugin`. Those three
68
+ * plus `kubectl` are allowed by default. Anything else the kubeconfig names
69
+ * is refused, because an exec plugin is an arbitrary binary named in a file
70
+ * chant did not write. Setting this **replaces** the default list.
71
+ *
72
+ * ```ts
73
+ * k8s: {
74
+ * profiles: { prod: { context: "prod-eks" } },
75
+ * execCredentialPlugins: ["aws", "my-org-oidc-helper"],
76
+ * } satisfies K8sChantConfig
77
+ * ```
78
+ */
79
+ execCredentialPlugins?: string[];
63
80
  }
package/src/crd/parser.ts CHANGED
@@ -124,6 +124,14 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
124
124
  propertyTypes: status.propertyType ? [...propertyTypes, status.propertyType] : propertyTypes,
125
125
  enums: [],
126
126
  gvk,
127
+ // chant #1074 — the CRD declares its own plural and scope, so the
128
+ // operation surface for a custom resource comes from the same document its
129
+ // types do, exactly as the OpenAPI `paths` supply them for built-in kinds.
130
+ operation: {
131
+ plural: spec.names.plural,
132
+ scope: spec.scope ?? "Namespaced",
133
+ verbs: ["delete", "get", "list", "patch", "post", "put", "watch"],
134
+ },
127
135
  });
128
136
 
129
137
  return results;
package/src/crd/types.ts CHANGED
@@ -34,6 +34,13 @@ export interface CRDSource {
34
34
  export interface CRDSpec {
35
35
  /** API group (e.g. "cert-manager.io") */
36
36
  group: string;
37
+ /**
38
+ * Whether instances are namespaced. Declared by the CRD itself, so it is the
39
+ * authoritative source for a custom resource's scope — the equivalent of what
40
+ * the OpenAPI `paths` say for built-in kinds (chant #1074). Defaults to
41
+ * `Namespaced`, matching the API server's own default.
42
+ */
43
+ scope?: "Namespaced" | "Cluster";
37
44
  /** Name variants for the CRD */
38
45
  names: {
39
46
  kind: string;
@@ -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
+ };