@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.
- package/dist/codegen/generate-list-map-keys.d.ts +53 -0
- package/dist/codegen/generate-list-map-keys.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/composites/adot-collector.d.ts +7 -1
- package/dist/composites/adot-collector.d.ts.map +1 -1
- package/dist/composites/agic-ingress.d.ts +3 -1
- package/dist/composites/agic-ingress.d.ts.map +1 -1
- package/dist/composites/aks-external-dns-agent.d.ts +6 -1
- package/dist/composites/aks-external-dns-agent.d.ts.map +1 -1
- package/dist/composites/alb-ingress.d.ts +3 -1
- package/dist/composites/alb-ingress.d.ts.map +1 -1
- package/dist/composites/autoscaled-service.d.ts +6 -1
- package/dist/composites/autoscaled-service.d.ts.map +1 -1
- package/dist/composites/azure-disk-storage-class.d.ts +3 -1
- package/dist/composites/azure-disk-storage-class.d.ts.map +1 -1
- package/dist/composites/azure-file-storage-class.d.ts +3 -1
- package/dist/composites/azure-file-storage-class.d.ts.map +1 -1
- package/dist/composites/azure-monitor-collector.d.ts +7 -1
- package/dist/composites/azure-monitor-collector.d.ts.map +1 -1
- package/dist/composites/batch-job.d.ts +1 -1
- package/dist/composites/batch-job.d.ts.map +1 -1
- package/dist/composites/cockroachdb-cluster.d.ts +13 -1
- package/dist/composites/cockroachdb-cluster.d.ts.map +1 -1
- package/dist/composites/config-connector-context.d.ts +3 -1
- package/dist/composites/config-connector-context.d.ts.map +1 -1
- package/dist/composites/configured-app.d.ts +1 -1
- package/dist/composites/configured-app.d.ts.map +1 -1
- package/dist/composites/cron-workload.d.ts +6 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/ebs-storage-class.d.ts +3 -1
- package/dist/composites/ebs-storage-class.d.ts.map +1 -1
- package/dist/composites/efs-storage-class.d.ts +3 -1
- package/dist/composites/efs-storage-class.d.ts.map +1 -1
- package/dist/composites/external-dns-agent.d.ts +6 -1
- package/dist/composites/external-dns-agent.d.ts.map +1 -1
- package/dist/composites/filestore-storage-class.d.ts +3 -1
- package/dist/composites/filestore-storage-class.d.ts.map +1 -1
- package/dist/composites/fluent-bit-agent.d.ts +7 -1
- package/dist/composites/fluent-bit-agent.d.ts.map +1 -1
- package/dist/composites/gce-ingress.d.ts +3 -1
- package/dist/composites/gce-ingress.d.ts.map +1 -1
- package/dist/composites/gce-pd-storage-class.d.ts +3 -1
- package/dist/composites/gce-pd-storage-class.d.ts.map +1 -1
- package/dist/composites/gke-external-dns-agent.d.ts +6 -1
- package/dist/composites/gke-external-dns-agent.d.ts.map +1 -1
- package/dist/composites/gke-fluent-bit-agent.d.ts +7 -1
- package/dist/composites/gke-fluent-bit-agent.d.ts.map +1 -1
- package/dist/composites/gke-gateway.d.ts +4 -1
- package/dist/composites/gke-gateway.d.ts.map +1 -1
- package/dist/composites/gke-otel-collector.d.ts +7 -1
- package/dist/composites/gke-otel-collector.d.ts.map +1 -1
- package/dist/composites/irsa-service-account.d.ts +1 -1
- package/dist/composites/irsa-service-account.d.ts.map +1 -1
- package/dist/composites/metrics-server.d.ts +10 -1
- package/dist/composites/metrics-server.d.ts.map +1 -1
- package/dist/composites/monitored-service.d.ts +1 -1
- package/dist/composites/monitored-service.d.ts.map +1 -1
- package/dist/composites/namespace-env.d.ts +1 -1
- package/dist/composites/namespace-env.d.ts.map +1 -1
- package/dist/composites/network-isolated-app.d.ts +5 -1
- package/dist/composites/network-isolated-app.d.ts.map +1 -1
- package/dist/composites/node-agent.d.ts +1 -1
- package/dist/composites/node-agent.d.ts.map +1 -1
- package/dist/composites/ray-cluster.d.ts +10 -1
- package/dist/composites/ray-cluster.d.ts.map +1 -1
- package/dist/composites/ray-job.d.ts +8 -1
- package/dist/composites/ray-job.d.ts.map +1 -1
- package/dist/composites/ray-service.d.ts +10 -1
- package/dist/composites/ray-service.d.ts.map +1 -1
- package/dist/composites/secure-ingress.d.ts +1 -1
- package/dist/composites/secure-ingress.d.ts.map +1 -1
- package/dist/composites/sidecar-app.d.ts +4 -1
- package/dist/composites/sidecar-app.d.ts.map +1 -1
- package/dist/composites/stateful-app.d.ts +1 -1
- package/dist/composites/stateful-app.d.ts.map +1 -1
- package/dist/composites/web-app.d.ts +1 -1
- package/dist/composites/web-app.d.ts.map +1 -1
- package/dist/composites/worker-pool.d.ts +1 -1
- package/dist/composites/worker-pool.d.ts.map +1 -1
- package/dist/composites/workload-identity-sa.d.ts +1 -1
- package/dist/composites/workload-identity-sa.d.ts.map +1 -1
- package/dist/composites/workload-identity-service-account.d.ts +1 -1
- package/dist/composites/workload-identity-service-account.d.ts.map +1 -1
- package/dist/config-schema.d.ts +40 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +15 -18
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +1 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +17 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/codegen/generate-list-map-keys.test.ts +112 -0
- package/src/codegen/generate-list-map-keys.ts +99 -0
- package/src/codegen/generate.ts +9 -1
- package/src/composites/adot-collector.ts +1 -1
- package/src/composites/agic-ingress.ts +1 -1
- package/src/composites/aks-external-dns-agent.ts +1 -1
- package/src/composites/alb-ingress.ts +1 -1
- package/src/composites/autoscaled-service.ts +1 -1
- package/src/composites/azure-disk-storage-class.ts +1 -1
- package/src/composites/azure-file-storage-class.ts +1 -1
- package/src/composites/azure-monitor-collector.ts +1 -1
- package/src/composites/batch-job.ts +1 -1
- package/src/composites/cockroachdb-cluster.ts +1 -1
- package/src/composites/config-connector-context.ts +1 -1
- package/src/composites/configured-app.ts +1 -1
- package/src/composites/cron-workload.ts +1 -1
- package/src/composites/ebs-storage-class.ts +1 -1
- package/src/composites/efs-storage-class.ts +1 -1
- package/src/composites/external-dns-agent.ts +1 -1
- package/src/composites/filestore-storage-class.ts +1 -1
- package/src/composites/fluent-bit-agent.ts +1 -1
- package/src/composites/gce-ingress.ts +1 -1
- package/src/composites/gce-pd-storage-class.ts +1 -1
- package/src/composites/gke-external-dns-agent.ts +1 -1
- package/src/composites/gke-fluent-bit-agent.ts +1 -1
- package/src/composites/gke-gateway.ts +1 -1
- package/src/composites/gke-otel-collector.ts +1 -1
- package/src/composites/irsa-service-account.ts +1 -1
- package/src/composites/metrics-server.ts +1 -1
- package/src/composites/monitored-service.ts +1 -1
- package/src/composites/namespace-env.ts +1 -1
- package/src/composites/network-isolated-app.ts +1 -1
- package/src/composites/node-agent.ts +1 -1
- package/src/composites/ray-cluster.ts +1 -1
- package/src/composites/ray-job.ts +1 -1
- package/src/composites/ray-service.ts +1 -1
- package/src/composites/secure-ingress.ts +1 -1
- package/src/composites/sidecar-app.ts +1 -1
- package/src/composites/stateful-app.ts +1 -1
- package/src/composites/web-app.ts +1 -1
- package/src/composites/worker-pool.ts +1 -1
- package/src/composites/workload-identity-sa.ts +1 -1
- package/src/composites/workload-identity-service-account.ts +1 -1
- package/src/config-schema.ts +46 -0
- package/src/config.ts +25 -1
- package/src/deep-observe-hooks.ts +90 -10
- package/src/deep-observe.ts +7 -0
- package/src/describe-resources.test.ts +138 -1
- package/src/describe-resources.ts +98 -0
- package/src/generated/list-map-keys.json +166 -0
- package/src/list-map-order-key.test.ts +119 -0
- package/src/plugin.ts +2 -0
- package/src/spec/parse.test.ts +42 -0
- package/src/spec/parse.ts +51 -2
|
@@ -55,7 +55,7 @@ export interface WorkloadIdentityServiceAccountResult {
|
|
|
55
55
|
* });
|
|
56
56
|
* ```
|
|
57
57
|
*/
|
|
58
|
-
export const WorkloadIdentityServiceAccount = Composite
|
|
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
|
|
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
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `k8sListMapOrderKey`
|
|
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:
|
|
204
|
+
orderKey: schemaListMapOrderKey,
|
|
125
205
|
};
|
package/src/deep-observe.ts
CHANGED
|
@@ -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
|
});
|