@intentius/chant-lexicon-k8s 0.38.0 → 0.41.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 (152) hide show
  1. package/dist/codegen/generate-list-map-keys.d.ts +53 -0
  2. package/dist/codegen/generate-list-map-keys.d.ts.map +1 -0
  3. package/dist/codegen/generate.d.ts.map +1 -1
  4. package/dist/composites/adot-collector.d.ts +7 -1
  5. package/dist/composites/adot-collector.d.ts.map +1 -1
  6. package/dist/composites/agic-ingress.d.ts +3 -1
  7. package/dist/composites/agic-ingress.d.ts.map +1 -1
  8. package/dist/composites/aks-external-dns-agent.d.ts +6 -1
  9. package/dist/composites/aks-external-dns-agent.d.ts.map +1 -1
  10. package/dist/composites/alb-ingress.d.ts +3 -1
  11. package/dist/composites/alb-ingress.d.ts.map +1 -1
  12. package/dist/composites/autoscaled-service.d.ts +6 -1
  13. package/dist/composites/autoscaled-service.d.ts.map +1 -1
  14. package/dist/composites/azure-disk-storage-class.d.ts +3 -1
  15. package/dist/composites/azure-disk-storage-class.d.ts.map +1 -1
  16. package/dist/composites/azure-file-storage-class.d.ts +3 -1
  17. package/dist/composites/azure-file-storage-class.d.ts.map +1 -1
  18. package/dist/composites/azure-monitor-collector.d.ts +7 -1
  19. package/dist/composites/azure-monitor-collector.d.ts.map +1 -1
  20. package/dist/composites/batch-job.d.ts +1 -1
  21. package/dist/composites/batch-job.d.ts.map +1 -1
  22. package/dist/composites/cockroachdb-cluster.d.ts +13 -1
  23. package/dist/composites/cockroachdb-cluster.d.ts.map +1 -1
  24. package/dist/composites/config-connector-context.d.ts +3 -1
  25. package/dist/composites/config-connector-context.d.ts.map +1 -1
  26. package/dist/composites/configured-app.d.ts +1 -1
  27. package/dist/composites/configured-app.d.ts.map +1 -1
  28. package/dist/composites/cron-workload.d.ts +6 -1
  29. package/dist/composites/cron-workload.d.ts.map +1 -1
  30. package/dist/composites/ebs-storage-class.d.ts +3 -1
  31. package/dist/composites/ebs-storage-class.d.ts.map +1 -1
  32. package/dist/composites/efs-storage-class.d.ts +3 -1
  33. package/dist/composites/efs-storage-class.d.ts.map +1 -1
  34. package/dist/composites/external-dns-agent.d.ts +6 -1
  35. package/dist/composites/external-dns-agent.d.ts.map +1 -1
  36. package/dist/composites/filestore-storage-class.d.ts +3 -1
  37. package/dist/composites/filestore-storage-class.d.ts.map +1 -1
  38. package/dist/composites/fluent-bit-agent.d.ts +7 -1
  39. package/dist/composites/fluent-bit-agent.d.ts.map +1 -1
  40. package/dist/composites/gce-ingress.d.ts +3 -1
  41. package/dist/composites/gce-ingress.d.ts.map +1 -1
  42. package/dist/composites/gce-pd-storage-class.d.ts +3 -1
  43. package/dist/composites/gce-pd-storage-class.d.ts.map +1 -1
  44. package/dist/composites/gke-external-dns-agent.d.ts +6 -1
  45. package/dist/composites/gke-external-dns-agent.d.ts.map +1 -1
  46. package/dist/composites/gke-fluent-bit-agent.d.ts +7 -1
  47. package/dist/composites/gke-fluent-bit-agent.d.ts.map +1 -1
  48. package/dist/composites/gke-gateway.d.ts +4 -1
  49. package/dist/composites/gke-gateway.d.ts.map +1 -1
  50. package/dist/composites/gke-otel-collector.d.ts +7 -1
  51. package/dist/composites/gke-otel-collector.d.ts.map +1 -1
  52. package/dist/composites/irsa-service-account.d.ts +1 -1
  53. package/dist/composites/irsa-service-account.d.ts.map +1 -1
  54. package/dist/composites/metrics-server.d.ts +10 -1
  55. package/dist/composites/metrics-server.d.ts.map +1 -1
  56. package/dist/composites/monitored-service.d.ts +1 -1
  57. package/dist/composites/monitored-service.d.ts.map +1 -1
  58. package/dist/composites/namespace-env.d.ts +1 -1
  59. package/dist/composites/namespace-env.d.ts.map +1 -1
  60. package/dist/composites/network-isolated-app.d.ts +5 -1
  61. package/dist/composites/network-isolated-app.d.ts.map +1 -1
  62. package/dist/composites/node-agent.d.ts +1 -1
  63. package/dist/composites/node-agent.d.ts.map +1 -1
  64. package/dist/composites/ray-cluster.d.ts +10 -1
  65. package/dist/composites/ray-cluster.d.ts.map +1 -1
  66. package/dist/composites/ray-job.d.ts +8 -1
  67. package/dist/composites/ray-job.d.ts.map +1 -1
  68. package/dist/composites/ray-service.d.ts +10 -1
  69. package/dist/composites/ray-service.d.ts.map +1 -1
  70. package/dist/composites/secure-ingress.d.ts +1 -1
  71. package/dist/composites/secure-ingress.d.ts.map +1 -1
  72. package/dist/composites/sidecar-app.d.ts +4 -1
  73. package/dist/composites/sidecar-app.d.ts.map +1 -1
  74. package/dist/composites/stateful-app.d.ts +1 -1
  75. package/dist/composites/stateful-app.d.ts.map +1 -1
  76. package/dist/composites/web-app.d.ts +1 -1
  77. package/dist/composites/web-app.d.ts.map +1 -1
  78. package/dist/composites/worker-pool.d.ts +1 -1
  79. package/dist/composites/worker-pool.d.ts.map +1 -1
  80. package/dist/composites/workload-identity-sa.d.ts +1 -1
  81. package/dist/composites/workload-identity-sa.d.ts.map +1 -1
  82. package/dist/composites/workload-identity-service-account.d.ts +1 -1
  83. package/dist/composites/workload-identity-service-account.d.ts.map +1 -1
  84. package/dist/config-schema.d.ts +40 -0
  85. package/dist/config-schema.d.ts.map +1 -0
  86. package/dist/config.d.ts +22 -1
  87. package/dist/config.d.ts.map +1 -1
  88. package/dist/deep-observe-hooks.d.ts +15 -18
  89. package/dist/deep-observe-hooks.d.ts.map +1 -1
  90. package/dist/deep-observe.d.ts.map +1 -1
  91. package/dist/describe-resources.d.ts +1 -0
  92. package/dist/describe-resources.d.ts.map +1 -1
  93. package/dist/integrity.json +2 -2
  94. package/dist/manifest.json +1 -1
  95. package/dist/plugin.d.ts.map +1 -1
  96. package/dist/spec/parse.d.ts +17 -0
  97. package/dist/spec/parse.d.ts.map +1 -1
  98. package/package.json +3 -2
  99. package/src/codegen/generate-list-map-keys.test.ts +112 -0
  100. package/src/codegen/generate-list-map-keys.ts +99 -0
  101. package/src/codegen/generate.ts +9 -1
  102. package/src/composites/adot-collector.ts +1 -1
  103. package/src/composites/agic-ingress.ts +1 -1
  104. package/src/composites/aks-external-dns-agent.ts +1 -1
  105. package/src/composites/alb-ingress.ts +1 -1
  106. package/src/composites/autoscaled-service.ts +1 -1
  107. package/src/composites/azure-disk-storage-class.ts +1 -1
  108. package/src/composites/azure-file-storage-class.ts +1 -1
  109. package/src/composites/azure-monitor-collector.ts +1 -1
  110. package/src/composites/batch-job.ts +1 -1
  111. package/src/composites/cockroachdb-cluster.ts +1 -1
  112. package/src/composites/config-connector-context.ts +1 -1
  113. package/src/composites/configured-app.ts +1 -1
  114. package/src/composites/cron-workload.ts +1 -1
  115. package/src/composites/ebs-storage-class.ts +1 -1
  116. package/src/composites/efs-storage-class.ts +1 -1
  117. package/src/composites/external-dns-agent.ts +1 -1
  118. package/src/composites/filestore-storage-class.ts +1 -1
  119. package/src/composites/fluent-bit-agent.ts +1 -1
  120. package/src/composites/gce-ingress.ts +1 -1
  121. package/src/composites/gce-pd-storage-class.ts +1 -1
  122. package/src/composites/gke-external-dns-agent.ts +1 -1
  123. package/src/composites/gke-fluent-bit-agent.ts +1 -1
  124. package/src/composites/gke-gateway.ts +1 -1
  125. package/src/composites/gke-otel-collector.ts +1 -1
  126. package/src/composites/irsa-service-account.ts +1 -1
  127. package/src/composites/metrics-server.ts +1 -1
  128. package/src/composites/monitored-service.ts +1 -1
  129. package/src/composites/namespace-env.ts +1 -1
  130. package/src/composites/network-isolated-app.ts +1 -1
  131. package/src/composites/node-agent.ts +1 -1
  132. package/src/composites/ray-cluster.ts +1 -1
  133. package/src/composites/ray-job.ts +1 -1
  134. package/src/composites/ray-service.ts +1 -1
  135. package/src/composites/secure-ingress.ts +1 -1
  136. package/src/composites/sidecar-app.ts +1 -1
  137. package/src/composites/stateful-app.ts +1 -1
  138. package/src/composites/web-app.ts +1 -1
  139. package/src/composites/worker-pool.ts +1 -1
  140. package/src/composites/workload-identity-sa.ts +1 -1
  141. package/src/composites/workload-identity-service-account.ts +1 -1
  142. package/src/config-schema.ts +46 -0
  143. package/src/config.ts +25 -1
  144. package/src/deep-observe-hooks.ts +90 -10
  145. package/src/deep-observe.ts +7 -0
  146. package/src/describe-resources.test.ts +138 -1
  147. package/src/describe-resources.ts +98 -0
  148. package/src/generated/list-map-keys.json +166 -0
  149. package/src/list-map-order-key.test.ts +119 -0
  150. package/src/plugin.ts +2 -0
  151. package/src/spec/parse.test.ts +42 -0
  152. package/src/spec/parse.ts +51 -2
@@ -85,7 +85,7 @@ export interface NodeAgentResult {
85
85
  * });
86
86
  * ```
87
87
  */
88
- export const NodeAgent = Composite<NodeAgentProps>((props) => {
88
+ export const NodeAgent = Composite((props: NodeAgentProps) => {
89
89
  const {
90
90
  name,
91
91
  image,
@@ -426,7 +426,7 @@ export function buildRayNetworkPolicy(
426
426
  * });
427
427
  * ```
428
428
  */
429
- export const RayCluster = Composite<RayClusterProps>((props) => {
429
+ export const RayCluster = Composite((props: RayClusterProps) => {
430
430
  const {
431
431
  name,
432
432
  namespace,
@@ -101,7 +101,7 @@ export interface RayJobResult {
101
101
  * });
102
102
  * ```
103
103
  */
104
- export const RayJob = Composite<RayJobProps>((props) => {
104
+ export const RayJob = Composite((props: RayJobProps) => {
105
105
  const {
106
106
  name,
107
107
  namespace,
@@ -102,7 +102,7 @@ export interface RayServiceResult {
102
102
  * });
103
103
  * ```
104
104
  */
105
- export const RayService = Composite<RayServiceProps>((props) => {
105
+ export const RayService = Composite((props: RayServiceProps) => {
106
106
  const {
107
107
  name,
108
108
  namespace,
@@ -68,7 +68,7 @@ export interface SecureIngressResult {
68
68
  * });
69
69
  * ```
70
70
  */
71
- export const SecureIngress = Composite<SecureIngressProps>((props) => {
71
+ export const SecureIngress = Composite((props: SecureIngressProps) => {
72
72
  const {
73
73
  name,
74
74
  hosts,
@@ -110,7 +110,7 @@ export interface SidecarAppResult {
110
110
  * });
111
111
  * ```
112
112
  */
113
- export const SidecarApp = Composite<SidecarAppProps>((props) => {
113
+ export const SidecarApp = Composite((props: SidecarAppProps) => {
114
114
  const {
115
115
  name,
116
116
  image,
@@ -80,7 +80,7 @@ export interface StatefulAppResult {
80
80
  * });
81
81
  * ```
82
82
  */
83
- export const StatefulApp = Composite<StatefulAppProps>((props) => {
83
+ export const StatefulApp = Composite((props: StatefulAppProps) => {
84
84
  const {
85
85
  name,
86
86
  image,
@@ -93,7 +93,7 @@ export interface WebAppResult {
93
93
  * export { deployment, service, ingress };
94
94
  * ```
95
95
  */
96
- export const WebApp = Composite<WebAppProps>((props) => {
96
+ export const WebApp = Composite((props: WebAppProps) => {
97
97
  const {
98
98
  name,
99
99
  image,
@@ -97,7 +97,7 @@ export interface WorkerPoolResult {
97
97
  * });
98
98
  * ```
99
99
  */
100
- export const WorkerPool = Composite<WorkerPoolProps>((props) => {
100
+ export const WorkerPool = Composite((props: WorkerPoolProps) => {
101
101
  const {
102
102
  name,
103
103
  image,
@@ -55,7 +55,7 @@ export interface WorkloadIdentityServiceAccountResult {
55
55
  * });
56
56
  * ```
57
57
  */
58
- export const WorkloadIdentityServiceAccount = Composite<WorkloadIdentityServiceAccountProps>((props) => {
58
+ export const WorkloadIdentityServiceAccount = Composite((props: WorkloadIdentityServiceAccountProps) => {
59
59
  const {
60
60
  name,
61
61
  clientId,
@@ -55,7 +55,7 @@ export interface WorkloadIdentityServiceAccountResult {
55
55
  * });
56
56
  * ```
57
57
  */
58
- export const WorkloadIdentityServiceAccount = Composite<WorkloadIdentityServiceAccountProps>((props) => {
58
+ export const WorkloadIdentityServiceAccount = Composite((props: WorkloadIdentityServiceAccountProps) => {
59
59
  const {
60
60
  name,
61
61
  gcpServiceAccountEmail,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Runtime shape of the `k8s` namespace in `chant.config.ts` (#1344).
3
+ *
4
+ * `./config.ts` describes the namespace for readers and for the `satisfies
5
+ * K8sChantConfig` form it documents. What it could not do is make an unknown
6
+ * key fail: the project config schema is `.passthrough()`, so a typo in
7
+ * `k8s.profiles.prod.contxt` was accepted and ignored — and a missing cluster
8
+ * binding is the failure this lexicon most cares about, since a wrong-cluster
9
+ * read reports every declared resource as missing (#1100).
10
+ *
11
+ * The schema is the runtime half. It is checked against `K8sChantConfig` below,
12
+ * so the two descriptions of the namespace cannot drift.
13
+ */
14
+
15
+ import { z } from "zod";
16
+ import type { ChantConfig } from "@intentius/chant/config";
17
+ import type { K8sChantConfig } from "./config";
18
+
19
+ export const k8sClusterProfileSchema = z.strictObject({
20
+ context: z.string(),
21
+ });
22
+
23
+ export const k8sConfigSchema = z.strictObject({
24
+ profiles: z.record(z.string(), k8sClusterProfileSchema).optional(),
25
+ execCredentialPlugins: z.array(z.string()).optional(),
26
+ });
27
+
28
+ declare module "@intentius/chant/config" {
29
+ interface ChantConfig {
30
+ k8s?: K8sChantConfig;
31
+ }
32
+ }
33
+
34
+ /** Compile-time proof the augmentation reaches `ChantConfig`. */
35
+ export type K8sConfigNamespace = NonNullable<ChantConfig["k8s"]>;
36
+
37
+ /**
38
+ * The schema and the documented interface describe the same namespace. If a
39
+ * field is added to one and not the other, this stops compiling.
40
+ */
41
+ type SchemaMatchesInterface = z.infer<typeof k8sConfigSchema> extends K8sChantConfig
42
+ ? K8sChantConfig extends z.infer<typeof k8sConfigSchema>
43
+ ? true
44
+ : never
45
+ : never;
46
+ export type _SchemaAgreesWithInterface = SchemaMatchesInterface;
package/src/config.ts CHANGED
@@ -39,7 +39,11 @@
39
39
  *
40
40
  * `ChantConfig` uses `.passthrough()` in its Zod schema so the `k8s` key is
41
41
  * accepted at runtime without core changes, exactly like `temporal.profiles`
42
- * (see `lexicons/temporal/src/config.ts`).
42
+ * (see `lexicons/temporal/src/config.ts`). The type side is the declaration
43
+ * merge at the bottom of this file — without it the snippet above compiles
44
+ * only until someone adds `satisfies ChantConfig`, which every example
45
+ * project does and which is the only thing type-checking the rest of the
46
+ * file (#1370).
43
47
  *
44
48
  * Deliberately out of scope here (see the issue for the full proposal):
45
49
  * deriving the binding automatically from a declared `AWS::EKS::Cluster` /
@@ -51,6 +55,8 @@
51
55
  * typed API client to inherit, rather than re-deriving it.
52
56
  */
53
57
 
58
+ import type { ChantConfig } from "@intentius/chant/config";
59
+
54
60
  /** A single environment's cluster binding. */
55
61
  export interface K8sClusterProfile {
56
62
  /** kubectl context name this environment is bound to. */
@@ -78,3 +84,21 @@ export interface K8sChantConfig {
78
84
  */
79
85
  execCredentialPlugins?: string[];
80
86
  }
87
+
88
+ declare module "@intentius/chant/config" {
89
+ interface ChantConfig {
90
+ k8s?: K8sChantConfig;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Compile-time proof that the augmentation above reaches `ChantConfig`, the
96
+ * same guard the forgejo lexicon carries (#1344).
97
+ *
98
+ * Without it this line is `Property 'k8s' does not exist on type
99
+ * 'ChantConfig'` — which is exactly the error a project got for writing the
100
+ * documented snippet with `satisfies ChantConfig` (#1370). It lives here
101
+ * rather than in a test because the root tsconfig excludes test files from
102
+ * typechecking, so a compile-time claim asserted in one is checked by nothing.
103
+ */
104
+ export type K8sConfigNamespace = NonNullable<ChantConfig["k8s"]>;
@@ -11,7 +11,9 @@
11
11
  * #1074 made that package's reachability from the build path a structural
12
12
  * property (`examples/k8s-client-boundary.test.ts`) rather than a lint rule —
13
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.
14
+ * transitively. It imports the core contract's own types, and one generated
15
+ * JSON data file (`./generated/list-map-keys.json`, chant #1441) — data, not a
16
+ * package, so the boundary property is unchanged.
15
17
  *
16
18
  * What lives here is deliberately the *entityType-keyed, resource-agnostic*
17
19
  * half of the rules: which fields the API server always populates regardless
@@ -39,7 +41,7 @@
39
41
  * keyed by chant's own k8s entityType catalog.
40
42
  */
41
43
 
42
- import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
44
+ import type { DeepArrayElement, DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
43
45
  import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
44
46
 
45
47
  /**
@@ -96,13 +98,17 @@ function canonicalJson(value: unknown): string {
96
98
  * The k8s lexicon's static noise rules: the generic Kubernetes object
97
99
  * envelope (unconditional, by pattern, `@intentius/chant/managed-fields`) and
98
100
  * 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.
101
+ * array orderings.
102
+ *
103
+ * The orderings are now taken from the spec itself (chant #1441): codegen
104
+ * carries `x-kubernetes-list-type`/`x-kubernetes-list-map-keys` through into
105
+ * `./generated/list-map-keys.json`, and {@link schemaListMapOrderKey} reads
106
+ * it. The hand-written conventions in
107
+ * `@intentius/chant/managed-fields`'s `k8sListMapOrderKey` containers by
108
+ * `name`, `env` by `name`, `volumes` by `name`, container/service `ports` by
109
+ * `containerPort`/`port` + `protocol` — remain as the fallback for lists the
110
+ * spec does not annotate, and for readers that have no generated table at all
111
+ * (GCP's Config Connector hooks).
106
112
  *
107
113
  * This is the object `k8sPlugin.deepNormalizationHooks` is. It is also what
108
114
  * `./deep-observe.ts` layers its per-resource managed-fields prune on top of,
@@ -111,6 +117,80 @@ function canonicalJson(value: unknown): string {
111
117
  * — see `packages/core/src/lifecycle/deep-observe.ts`) apply the identical
112
118
  * entityType-keyed rules either way.
113
119
  */
120
+ /** Property name → candidate key-field sets, generated from the spec (chant #1441). */
121
+ type ListMapKeyTable = Record<string, string[][]>;
122
+
123
+ let cachedListMapKeys: ListMapKeyTable | undefined;
124
+
125
+ function listMapKeyTable(): ListMapKeyTable {
126
+ cachedListMapKeys ??= require("./generated/list-map-keys.json") as ListMapKeyTable;
127
+ return cachedListMapKeys;
128
+ }
129
+
130
+ /** Last dotted segment of an index-erased path: `spec.template.spec.containers` → `containers`. */
131
+ function lastSegment(pattern: string): string {
132
+ const at = pattern.lastIndexOf(".");
133
+ return at === -1 ? pattern : pattern.slice(at + 1);
134
+ }
135
+
136
+ /**
137
+ * Render one key field's value into a sort key fragment. Numbers are
138
+ * zero-padded so the key orders numerically rather than lexicographically —
139
+ * `"443"` would otherwise sort before `"80"`. Cosmetic, since either order
140
+ * canonicalizes the two sides identically, but a human-sensible order is free
141
+ * to have here (the same reasoning `k8sListMapOrderKey` applies to ports).
142
+ */
143
+ function keyFragment(value: unknown): string {
144
+ if (typeof value === "number") return String(value).padStart(5, "0");
145
+ return typeof value === "string" ? value : canonicalJson(value);
146
+ }
147
+
148
+ /**
149
+ * Join a key set's values the way `k8sListMapOrderKey` already does: the bare
150
+ * values, `/`-separated, with no field names.
151
+ *
152
+ * This is not only a sort key. Core renders it as the element's ADDRESS in
153
+ * drift output — `spec.template.spec.containers[#app].image` — so the format
154
+ * is user-visible and load-bearing. Emitting `name=app` here would rewrite
155
+ * every existing drift path. Bare values keep the six properties that were
156
+ * hardcoded byte-identical, and extend to the rest for free: a container port
157
+ * reads `[#08080/TCP]` exactly as before, a condition reads `[#Ready]`.
158
+ */
159
+ function joinKeyValues(el: Record<string, unknown>, keys: string[]): string {
160
+ return keys.map((k) => keyFragment(el[k])).join("/");
161
+ }
162
+
163
+ /**
164
+ * Order an associative list's element by the identity the SPEC declares for it
165
+ * (chant #1441), falling back to `@intentius/chant/managed-fields`'s hand-written
166
+ * conventions for anything the generated table does not cover.
167
+ *
168
+ * The fallback is not vestigial. The table is built from the properties this
169
+ * lexicon's codegen parsed, so it covers core Kubernetes and any CRD that
170
+ * declares the extensions — but a CRD that declares an associative list
171
+ * WITHOUT `x-kubernetes-list-map-keys`, or a Config Connector resource read
172
+ * through GCP's hooks, still lands on the by-name conventions. Keeping both
173
+ * means widening the spec-derived table can never narrow what was already
174
+ * identified.
175
+ */
176
+ export function schemaListMapOrderKey(element: DeepArrayElement): string | undefined {
177
+ const candidates = listMapKeyTable()[lastSegment(element.pattern)];
178
+ const el = element.element;
179
+
180
+ if (candidates && isRecordLike(el)) {
181
+ for (const keys of candidates) {
182
+ if (!keys.every((k) => el[k] !== undefined)) continue;
183
+ return joinKeyValues(el, keys);
184
+ }
185
+ }
186
+
187
+ return k8sListMapOrderKey(element);
188
+ }
189
+
190
+ function isRecordLike(value: unknown): value is Record<string, unknown> {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+
114
194
  export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
115
195
  prune(node: DeepNode): boolean {
116
196
  if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
@@ -121,5 +201,5 @@ export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
121
201
  return canonicalJson(defaults[node.pattern]) === canonicalJson(node.value);
122
202
  },
123
203
 
124
- orderKey: k8sListMapOrderKey,
204
+ orderKey: schemaListMapOrderKey,
125
205
  };
@@ -284,6 +284,13 @@ export async function observeResourcesDeepK8s(
284
284
  side: "live",
285
285
  hooks: perResourceHooks(sets),
286
286
  }),
287
+ // Who owns each surviving path (#1189). The prune above already
288
+ // dropped confidently-foreign undeclared noise, so what reaches the
289
+ // diff is chant's own fields and contested ones — and for a contested
290
+ // field, naming the manager is the whole question: an operator needs to
291
+ // tell `hpa-controller` doing its job from somebody running
292
+ // `kubectl edit`.
293
+ ...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
287
294
  };
288
295
  } catch (err) {
289
296
  const outcome = classifyApiFailure(err);
@@ -15,7 +15,7 @@ vi.mock("@intentius/chant/config", () => ({
15
15
  loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
16
16
  }));
17
17
 
18
- const { describeResources } = await import("./describe-resources");
18
+ const { describeResources, statusFromObject, unhappyConditions } = await import("./describe-resources");
19
19
  const { fakeCluster, objectKey, ownedObject } = await import("./api/fake-cluster");
20
20
  const { defaultK8sConnector } = await import("./api/connect");
21
21
  const { fakeKubeconfig, statusBody } = await import("@intentius/chant-k8s-client/testing");
@@ -725,3 +725,140 @@ describe("k8s describeResources", () => {
725
725
  });
726
726
  });
727
727
  });
728
+
729
+ // #1397 — a Pod's phase is `Running` from the moment the kubelet admits it, so
730
+ // the phase alone reported a crashlooping Pod as `Running`, which every
731
+ // consumer classifying that word read as healthy.
732
+ describe("statusFromObject — the most specific failing signal (#1397)", () => {
733
+ test("a crashlooping Pod reports the reason, not the phase", () => {
734
+ expect(
735
+ statusFromObject({
736
+ status: {
737
+ phase: "Running",
738
+ containerStatuses: [{ state: { waiting: { reason: "CrashLoopBackOff", message: "back-off 5m0s" } } }],
739
+ },
740
+ } as never),
741
+ ).toBe("CrashLoopBackOff");
742
+ });
743
+
744
+ test("an image-pull failure reports the reason", () => {
745
+ expect(
746
+ statusFromObject({
747
+ status: { phase: "Pending", containerStatuses: [{ state: { waiting: { reason: "ImagePullBackOff" } } }] },
748
+ } as never),
749
+ ).toBe("ImagePullBackOff");
750
+ });
751
+
752
+ test("an unschedulable Pod reports Unschedulable, not Pending", () => {
753
+ expect(
754
+ statusFromObject({
755
+ status: {
756
+ phase: "Pending",
757
+ conditions: [{ type: "PodScheduled", status: "False", reason: "Unschedulable" }],
758
+ },
759
+ } as never),
760
+ ).toBe("Unschedulable");
761
+ });
762
+
763
+ test("ordinary startup is not promoted over the phase", () => {
764
+ // ContainerCreating and PodInitializing are not failure; surfacing them
765
+ // would be noise, and the phase already classifies as progressing.
766
+ for (const reason of ["ContainerCreating", "PodInitializing"]) {
767
+ expect(
768
+ statusFromObject({
769
+ status: { phase: "Pending", containerStatuses: [{ state: { waiting: { reason } } }] },
770
+ } as never),
771
+ ).toBe("Pending");
772
+ }
773
+ });
774
+
775
+ test("a terminated container does not outrank the phase — a Job container exits cleanly", () => {
776
+ expect(
777
+ statusFromObject({
778
+ status: { phase: "Succeeded", containerStatuses: [{ state: { terminated: { reason: "Completed", exitCode: 0 } } }] },
779
+ } as never),
780
+ ).toBe("Succeeded");
781
+ });
782
+
783
+ test("a healthy Pod and a ready Deployment are unchanged", () => {
784
+ expect(statusFromObject({ status: { phase: "Running", containerStatuses: [{ state: { running: {} } }] } } as never)).toBe("Running");
785
+ expect(statusFromObject({ status: { readyReplicas: 3, replicas: 3 } } as never)).toBe("READY");
786
+ expect(statusFromObject({ status: { readyReplicas: 1, replicas: 3 } } as never)).toBe("PROGRESSING(1/3)");
787
+ expect(statusFromObject({} as never)).toBe("PRESENT");
788
+ });
789
+ });
790
+
791
+ // #1401 — #1397 made the status WORD honest; this carries the part that says
792
+ // what to do about it. `Unschedulable` is the reason; "0/3 nodes are available"
793
+ // is the answer.
794
+ describe("unhappyConditions (#1401)", () => {
795
+ test("reports an unschedulable Pod's message, not just its reason", () => {
796
+ expect(
797
+ unhappyConditions({
798
+ status: {
799
+ conditions: [
800
+ {
801
+ type: "PodScheduled",
802
+ status: "False",
803
+ reason: "Unschedulable",
804
+ message: "0/3 nodes are available: 1 node(s) had untolerated taint.",
805
+ },
806
+ ],
807
+ },
808
+ } as never),
809
+ ).toEqual(["PodScheduled=Unschedulable: 0/3 nodes are available: 1 node(s) had untolerated taint."]);
810
+ });
811
+
812
+ test("is absent when every condition is happy — presence means the object has something to say", () => {
813
+ expect(
814
+ unhappyConditions({
815
+ status: { conditions: [{ type: "Ready", status: "True" }, { type: "PodScheduled", status: "True" }] },
816
+ } as never),
817
+ ).toBeUndefined();
818
+ });
819
+
820
+ test("knows the happy polarity is per condition type", () => {
821
+ // Ready/Available/PodScheduled are good when True; the *Pressure and
822
+ // NetworkUnavailable node conditions are good when False. Treating them all
823
+ // the same would report every healthy node as unhappy, or hide a node
824
+ // genuinely under disk pressure.
825
+ const healthyNode = {
826
+ status: {
827
+ conditions: [
828
+ { type: "Ready", status: "True" },
829
+ { type: "MemoryPressure", status: "False" },
830
+ { type: "DiskPressure", status: "False" },
831
+ ],
832
+ },
833
+ };
834
+ expect(unhappyConditions(healthyNode as never)).toBeUndefined();
835
+
836
+ const pressured = {
837
+ status: {
838
+ conditions: [
839
+ { type: "Ready", status: "True" },
840
+ { type: "DiskPressure", status: "True", reason: "KubeletHasDiskPressure" },
841
+ ],
842
+ },
843
+ };
844
+ expect(unhappyConditions(pressured as never)).toEqual(["DiskPressure=KubeletHasDiskPressure"]);
845
+ });
846
+
847
+ test("reports every unhappy condition, since a stuck Deployment has two", () => {
848
+ expect(
849
+ unhappyConditions({
850
+ status: {
851
+ conditions: [
852
+ { type: "Available", status: "False", reason: "MinimumReplicasUnavailable" },
853
+ { type: "Progressing", status: "False", reason: "ProgressDeadlineExceeded", message: "ReplicaSet has timed out." },
854
+ ],
855
+ },
856
+ } as never),
857
+ ).toHaveLength(2);
858
+ });
859
+
860
+ test("is absent for an object with no conditions at all", () => {
861
+ expect(unhappyConditions({ status: {} } as never)).toBeUndefined();
862
+ expect(unhappyConditions({} as never)).toBeUndefined();
863
+ });
864
+ });
@@ -80,6 +80,22 @@ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<strin
80
80
  */
81
81
  export function statusFromObject(obj: K8sObject): string {
82
82
  const status = obj.status;
83
+
84
+ // The most specific FAILING signal wins over the outermost one (#1397).
85
+ //
86
+ // A Pod's phase is `Running` from the moment the kubelet admits it and one
87
+ // container starts — so a crashlooping Pod reported `Running`, which every
88
+ // consumer classifying that word read as healthy. The failure lives one level
89
+ // down, in `containerStatuses[].state.waiting.reason`, and never reached the
90
+ // wire, so nothing downstream could recover it.
91
+ const waiting = failingContainerReason(status);
92
+ if (waiting) return waiting;
93
+
94
+ // Scheduling failure is a condition, not a phase: an unschedulable Pod is
95
+ // `Pending`, which is true and useless next to `Unschedulable`.
96
+ const blocked = failingConditionReason(status);
97
+ if (blocked) return blocked;
98
+
83
99
  const phase = status?.phase;
84
100
  if (typeof phase === "string") return phase;
85
101
  if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
@@ -90,6 +106,85 @@ export function statusFromObject(obj: K8sObject): string {
90
106
  return "PRESENT";
91
107
  }
92
108
 
109
+ /**
110
+ * The reason a container is stuck, if one is — `CrashLoopBackOff`,
111
+ * `ImagePullBackOff`, `CreateContainerConfigError`.
112
+ *
113
+ * Only a WAITING container counts. A terminated one may have exited cleanly as
114
+ * part of a Job, and a running one is not stuck; neither should outrank the
115
+ * phase.
116
+ */
117
+ function failingContainerReason(status: K8sObject["status"]): string | undefined {
118
+ const containers = (status as { containerStatuses?: unknown } | undefined)?.containerStatuses;
119
+ if (!Array.isArray(containers)) return undefined;
120
+ for (const c of containers) {
121
+ const reason = (c as { state?: { waiting?: { reason?: unknown } } })?.state?.waiting?.reason;
122
+ // `ContainerCreating` and `PodInitializing` are ordinary startup, not
123
+ // failure — they already classify as progressing, and promoting them over
124
+ // the phase would just be noisier.
125
+ if (typeof reason === "string" && reason.length > 0 && reason !== "ContainerCreating" && reason !== "PodInitializing") {
126
+ return reason;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+
132
+ /**
133
+ * The reason a blocking condition is False — `Unschedulable` and the like.
134
+ *
135
+ * Only conditions whose truth means "this object is usable"; a False `Ready` on
136
+ * its own is already visible through the phase and the container states above.
137
+ */
138
+ function failingConditionReason(status: K8sObject["status"]): string | undefined {
139
+ const conditions = (status as { conditions?: unknown } | undefined)?.conditions;
140
+ if (!Array.isArray(conditions)) return undefined;
141
+ for (const c of conditions) {
142
+ const cond = c as { type?: unknown; status?: unknown; reason?: unknown };
143
+ if (cond.type !== "PodScheduled") continue;
144
+ if (cond.status !== "False") continue;
145
+ if (typeof cond.reason === "string" && cond.reason.length > 0) return cond.reason;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+
151
+ /**
152
+ * The unhappy conditions on a live object, as `Type=Reason: message` (#1401).
153
+ *
154
+ * #1397 made the status WORD honest — a crashlooping Pod reports
155
+ * `CrashLoopBackOff` rather than `Running`. This carries the part that says
156
+ * what to do about it: `Unschedulable` is the reason, and
157
+ * `0/3 nodes are available: 1 node(s) had untolerated taint` is the answer, and
158
+ * only the first reached the wire.
159
+ *
160
+ * Only conditions that are NOT in their happy state are reported. A `Ready=True`
161
+ * says nothing an operator needs and would bury the one line that does — and a
162
+ * condition's happy polarity is per type: `Ready`/`Available`/`PodScheduled`
163
+ * are good when True, while `Unschedulable`-style and the standard
164
+ * `*Pressure`/`NetworkUnavailable` node conditions are good when False.
165
+ *
166
+ * Absent when everything is happy, so a consumer can treat presence as "this
167
+ * object has something to say".
168
+ */
169
+ const CONDITIONS_GOOD_WHEN_FALSE = /pressure$|^networkunavailable$|^unschedulable$|failed/i;
170
+
171
+ export function unhappyConditions(obj: K8sObject): string[] | undefined {
172
+ const conditions = (obj.status as { conditions?: unknown } | undefined)?.conditions;
173
+ if (!Array.isArray(conditions)) return undefined;
174
+ const out: string[] = [];
175
+ for (const c of conditions) {
176
+ const cond = c as { type?: unknown; status?: unknown; reason?: unknown; message?: unknown };
177
+ if (typeof cond.type !== "string" || typeof cond.status !== "string") continue;
178
+ const goodWhenFalse = CONDITIONS_GOOD_WHEN_FALSE.test(cond.type);
179
+ const happy = goodWhenFalse ? cond.status === "False" : cond.status === "True";
180
+ if (happy) continue;
181
+ const reason = typeof cond.reason === "string" && cond.reason ? `=${cond.reason}` : "";
182
+ const message = typeof cond.message === "string" && cond.message ? `: ${cond.message}` : "";
183
+ out.push(`${cond.type}${reason}${message}`);
184
+ }
185
+ return out.length > 0 ? out : undefined;
186
+ }
187
+
93
188
  interface Declared {
94
189
  entityName: string;
95
190
  entityType: string;
@@ -211,6 +306,8 @@ export async function describeResources(
211
306
  namespace: obj.metadata?.namespace,
212
307
  labels: obj.metadata?.labels,
213
308
  resourceVersion: obj.metadata?.resourceVersion,
309
+ // What the object's own controller says is wrong with it (#1401).
310
+ conditions: unhappyConditions(obj),
214
311
  }),
215
312
  };
216
313
  } catch (err) {
@@ -289,6 +386,7 @@ async function addRuntimeChildren(
289
386
  namespace,
290
387
  labels: pod.metadata?.labels,
291
388
  resourceVersion: pod.metadata?.resourceVersion,
389
+ conditions: unhappyConditions(pod),
292
390
  }),
293
391
  };
294
392
  });