@intentius/chant-lexicon-k8s 0.44.13 → 0.45.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/docs.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.d.ts +15 -1
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/crd/parser.d.ts +28 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +60 -4
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +36 -61
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/integrity.json +6 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8501.d.ts +14 -0
- package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8502.d.ts +13 -0
- package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +126 -63
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WK8501.md +11 -0
- package/dist/okf/rules/WK8502.md +11 -0
- package/dist/rules/crd-schema-helpers.ts +228 -0
- package/dist/rules/wk8501.ts +40 -0
- package/dist/rules/wk8502.ts +39 -0
- package/dist/spec/parse.d.ts +32 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/codegen/docs.ts +0 -1044
- package/src/codegen/generate-lexicon.ts +38 -2
- package/src/codegen/snapshot.test.ts +20 -0
- package/src/components/capability-plugin.test.ts +20 -0
- package/src/components/capability-plugin.ts +5 -2
- package/src/crd/cnpg.test.ts +1 -1
- package/src/crd/infisical.test.ts +1 -1
- package/src/crd/parser.test.ts +117 -1
- package/src/crd/parser.ts +71 -5
- package/src/crd/traefik.test.ts +1 -1
- package/src/deep-observe-hooks.ts +78 -16
- package/src/deep-observe.test.ts +145 -29
- package/src/deep-observe.ts +43 -85
- package/src/generated/lexicon-k8s.json +126 -63
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/post-synth.test.ts +156 -0
- package/src/lint/post-synth/wk8501.ts +40 -0
- package/src/lint/post-synth/wk8502.ts +39 -0
- package/src/list-map-key-table.test.ts +83 -0
- package/src/op/activities/kubectl.ts +2 -2
- package/src/serializer.test.ts +4 -4
- package/src/spec/parse.ts +33 -0
- package/src/validate.ts +7 -4
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
|
|
7
|
-
import type { K8sParseResult } from "../spec/parse";
|
|
7
|
+
import type { K8sParseResult, CrdFieldSchema } from "../spec/parse";
|
|
8
8
|
import { k8sShortName, gvkToApiVersion } from "../spec/parse";
|
|
9
9
|
import type { NamingStrategy } from "./naming";
|
|
10
10
|
import {
|
|
@@ -21,6 +21,12 @@ export interface K8sLexiconEntry {
|
|
|
21
21
|
gvkKind?: string;
|
|
22
22
|
deprecatedProperties?: string[];
|
|
23
23
|
constraints?: Record<string, PropertyConstraints>;
|
|
24
|
+
/**
|
|
25
|
+
* Field schema of a custom resource's `spec` (chant #1372). Only CRD-derived
|
|
26
|
+
* kinds carry one; built-in kinds are typed by the `.d.ts`. Consumed by the
|
|
27
|
+
* WK8501/WK8502 post-synth checks and the MCP catalog.
|
|
28
|
+
*/
|
|
29
|
+
specSchema?: CrdFieldSchema;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -56,6 +62,9 @@ export function generateLexiconJSON(
|
|
|
56
62
|
if (propConstraints && Object.keys(propConstraints).length > 0) {
|
|
57
63
|
entry.constraints = propConstraints;
|
|
58
64
|
}
|
|
65
|
+
if (r?.specSchema) {
|
|
66
|
+
entry.specSchema = r.specSchema;
|
|
67
|
+
}
|
|
59
68
|
return entry;
|
|
60
69
|
},
|
|
61
70
|
buildPropertyEntry: (resourceType, propertyType) => ({
|
|
@@ -65,5 +74,32 @@ export function generateLexiconJSON(
|
|
|
65
74
|
}),
|
|
66
75
|
});
|
|
67
76
|
|
|
68
|
-
return
|
|
77
|
+
return serializeWithCompactSchemas(entries);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pretty-print the registry the way every lexicon does, but keep each
|
|
82
|
+
* `specSchema` on one line (chant #1372). A CRD schema nests deeply — an
|
|
83
|
+
* ApplicationSet or RayCluster embeds a whole PodTemplateSpec — and two-space
|
|
84
|
+
* indentation on that tree quadruples the file for no reader's benefit.
|
|
85
|
+
* Compact, the schemas add ~0.8 MB to the registry; pretty, ~3.9 MB.
|
|
86
|
+
*/
|
|
87
|
+
export function serializeWithCompactSchemas(entries: Record<string, K8sLexiconEntry>): string {
|
|
88
|
+
const compact = new Map<string, string>();
|
|
89
|
+
const stripped: Record<string, unknown> = {};
|
|
90
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
91
|
+
if (!entry.specSchema) {
|
|
92
|
+
stripped[name] = entry;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const { specSchema, ...rest } = entry;
|
|
96
|
+
const token = `__chant_spec_schema_${compact.size}__`;
|
|
97
|
+
compact.set(token, JSON.stringify(specSchema));
|
|
98
|
+
stripped[name] = { ...rest, specSchema: token };
|
|
99
|
+
}
|
|
100
|
+
let out = serializeRegistry(stripped);
|
|
101
|
+
for (const [token, json] of compact) {
|
|
102
|
+
out = out.replace(`"${token}"`, json);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
69
105
|
}
|
|
@@ -92,4 +92,24 @@ describe("snapshot", () => {
|
|
|
92
92
|
expect(dts).toContain("class Service");
|
|
93
93
|
expect(dts).toContain("class Pod");
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
// chant #1372 — CRD field schemas ship in the registry; built-ins do not carry one.
|
|
97
|
+
test.skipIf(!hasGenerated)("every CRD-derived resource entry carries a specSchema, built-ins none", () => {
|
|
98
|
+
const raw = readFileSync(join(generatedDir, "lexicon-k8s.json"), "utf-8");
|
|
99
|
+
const registry = JSON.parse(raw) as Record<string, any>;
|
|
100
|
+
|
|
101
|
+
const crd = registry.Certificate;
|
|
102
|
+
expect(crd.resourceType).toBe("K8s::CertManager::Certificate");
|
|
103
|
+
expect(crd.specSchema.type).toBe("object");
|
|
104
|
+
expect(crd.specSchema.fields.issuerRef.fields.name.type).toBe("string");
|
|
105
|
+
expect(crd.specSchema.required).toContain("secretName");
|
|
106
|
+
|
|
107
|
+
expect(registry.Deployment.specSchema).toBeUndefined();
|
|
108
|
+
expect(registry.Certificate_Status.specSchema).toBeUndefined();
|
|
109
|
+
|
|
110
|
+
// Each schema is written on one line so the file stays reviewable; the
|
|
111
|
+
// pretty-printed form is four times the size for no reader's benefit.
|
|
112
|
+
const line = raw.split("\n").find((l) => l.includes('"specSchema": {'));
|
|
113
|
+
expect(line).toMatch(/^\s+"specSchema": \{.*\}$/);
|
|
114
|
+
});
|
|
95
115
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
4
|
+
import { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./capability-plugin";
|
|
5
|
+
|
|
6
|
+
describe("k8sCapabilityPlugin", () => {
|
|
7
|
+
test("satisfies the CapabilityPlugin contract", () => {
|
|
8
|
+
expect(isCapabilityPlugin(k8sCapabilityPlugin)).toBe(true);
|
|
9
|
+
expect(k8sCapabilityPlugin.capabilities().map((c) => c.kind)).toContain("kubectl-apply");
|
|
10
|
+
expect(k8sCapabilityPlugin.families?.()).toBe(K8S_VERB_FAMILIES);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the plugin's version is the lexicon package's own, not a literal (#1505)", () => {
|
|
14
|
+
const { version } = JSON.parse(
|
|
15
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
|
16
|
+
) as { version: string };
|
|
17
|
+
expect(k8sCapabilityPlugin.version).toBe(version);
|
|
18
|
+
expect(k8sCapabilityPlugin.version).not.toBe("0.41.0");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -25,8 +25,11 @@ export const K8S_VERB_FAMILIES = {
|
|
|
25
25
|
export const k8sCapabilityPlugin: CapabilityPlugin = {
|
|
26
26
|
name: "k8s",
|
|
27
27
|
// The lexicon package's own version (#1505) — lockstep releases bump it, so
|
|
28
|
-
// a literal here was stale one release after it was written.
|
|
29
|
-
|
|
28
|
+
// a literal here was stale one release after it was written. A getter, so
|
|
29
|
+
// the package.json read happens on first access rather than at import time.
|
|
30
|
+
get version(): string {
|
|
31
|
+
return ownPackageVersion(import.meta.url);
|
|
32
|
+
},
|
|
30
33
|
capabilities(): Array<Capability<never, unknown>> {
|
|
31
34
|
return [
|
|
32
35
|
kubectlApplyCapability as Capability<never, unknown>,
|
package/src/crd/cnpg.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { parseYAML } from "@intentius/chant/yaml";
|
|
|
15
15
|
|
|
16
16
|
/** Serialize one declaration the way `chant build` would, and read it back. */
|
|
17
17
|
function synth(logicalName: string, resource: unknown): any {
|
|
18
|
-
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
|
|
18
|
+
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
|
|
19
19
|
return parseYAML(yaml);
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -13,7 +13,7 @@ import { k8sSerializer } from "../serializer";
|
|
|
13
13
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
14
14
|
|
|
15
15
|
function synth(logicalName: string, resource: unknown): any {
|
|
16
|
-
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
|
|
16
|
+
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
|
|
17
17
|
return parseYAML(yaml);
|
|
18
18
|
}
|
|
19
19
|
|
package/src/crd/parser.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { parseCRD, parseCRDSpec } from "./parser";
|
|
2
|
+
import { parseCRD, parseCRDSpec, toFieldSchema } from "./parser";
|
|
3
3
|
|
|
4
4
|
describe("parseCRD", () => {
|
|
5
5
|
test("parses valid CRD YAML", () => {
|
|
@@ -453,3 +453,119 @@ describe("parseCRDSpec", () => {
|
|
|
453
453
|
expect(results[0].propertyTypes.some((pt) => pt.name === "Widget_Status")).toBe(false);
|
|
454
454
|
});
|
|
455
455
|
});
|
|
456
|
+
|
|
457
|
+
// chant #1372 — the spec field schema that ships in the lexicon JSON.
|
|
458
|
+
describe("specSchema (chant #1372)", () => {
|
|
459
|
+
const microVmImage = `
|
|
460
|
+
apiVersion: apiextensions.k8s.io/v1
|
|
461
|
+
kind: CustomResourceDefinition
|
|
462
|
+
metadata:
|
|
463
|
+
name: microvmimages.lambda.aws.amazon.com
|
|
464
|
+
spec:
|
|
465
|
+
group: lambda.aws.amazon.com
|
|
466
|
+
names:
|
|
467
|
+
kind: MicroVMImage
|
|
468
|
+
plural: microvmimages
|
|
469
|
+
scope: Namespaced
|
|
470
|
+
versions:
|
|
471
|
+
- name: v1alpha1
|
|
472
|
+
served: true
|
|
473
|
+
storage: true
|
|
474
|
+
schema:
|
|
475
|
+
openAPIV3Schema:
|
|
476
|
+
type: object
|
|
477
|
+
properties:
|
|
478
|
+
spec:
|
|
479
|
+
type: object
|
|
480
|
+
required: [source]
|
|
481
|
+
properties:
|
|
482
|
+
desiredState:
|
|
483
|
+
type: string
|
|
484
|
+
enum: [Active, Inactive, Deleted]
|
|
485
|
+
description: Lifecycle state wanted for the image.
|
|
486
|
+
memorySizeMiB:
|
|
487
|
+
type: integer
|
|
488
|
+
minimum: 128
|
|
489
|
+
maximum: 10240
|
|
490
|
+
autoActivate:
|
|
491
|
+
type: boolean
|
|
492
|
+
source:
|
|
493
|
+
type: object
|
|
494
|
+
properties:
|
|
495
|
+
s3Bucket:
|
|
496
|
+
type: string
|
|
497
|
+
s3Key:
|
|
498
|
+
type: string
|
|
499
|
+
layers:
|
|
500
|
+
type: array
|
|
501
|
+
items:
|
|
502
|
+
type: object
|
|
503
|
+
properties:
|
|
504
|
+
arn:
|
|
505
|
+
type: string
|
|
506
|
+
annotations:
|
|
507
|
+
type: object
|
|
508
|
+
additionalProperties:
|
|
509
|
+
type: string
|
|
510
|
+
extra:
|
|
511
|
+
type: object
|
|
512
|
+
x-kubernetes-preserve-unknown-fields: true
|
|
513
|
+
port:
|
|
514
|
+
x-kubernetes-int-or-string: true
|
|
515
|
+
status:
|
|
516
|
+
type: object
|
|
517
|
+
properties:
|
|
518
|
+
phase:
|
|
519
|
+
type: string
|
|
520
|
+
`;
|
|
521
|
+
|
|
522
|
+
test("is the spec's field tree — names, scalar types, enums, required — with no prose or bounds", () => {
|
|
523
|
+
const [result] = parseCRD(microVmImage);
|
|
524
|
+
expect(result.specSchema).toEqual({
|
|
525
|
+
type: "object",
|
|
526
|
+
required: ["source"],
|
|
527
|
+
fields: {
|
|
528
|
+
desiredState: { type: "string", enum: ["Active", "Inactive", "Deleted"] },
|
|
529
|
+
memorySizeMiB: { type: "integer" },
|
|
530
|
+
autoActivate: { type: "boolean" },
|
|
531
|
+
source: { type: "object", fields: { s3Bucket: { type: "string" }, s3Key: { type: "string" } } },
|
|
532
|
+
layers: { type: "array", items: { type: "object", fields: { arn: { type: "string" } } } },
|
|
533
|
+
annotations: { type: "object", open: true },
|
|
534
|
+
extra: { type: "object", open: true },
|
|
535
|
+
port: { open: true },
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
// Status never rides along — it is server-owned, not something a spec validator checks.
|
|
539
|
+
expect(JSON.stringify(result.specSchema)).not.toContain("phase");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("absent when the CRD declares no spec schema", () => {
|
|
543
|
+
const crd = `
|
|
544
|
+
apiVersion: apiextensions.k8s.io/v1
|
|
545
|
+
kind: CustomResourceDefinition
|
|
546
|
+
metadata:
|
|
547
|
+
name: bars.example.com
|
|
548
|
+
spec:
|
|
549
|
+
group: example.com
|
|
550
|
+
names:
|
|
551
|
+
kind: Bar
|
|
552
|
+
plural: bars
|
|
553
|
+
scope: Namespaced
|
|
554
|
+
versions:
|
|
555
|
+
- name: v1
|
|
556
|
+
served: true
|
|
557
|
+
storage: true
|
|
558
|
+
`;
|
|
559
|
+
const [result] = parseCRD(crd);
|
|
560
|
+
expect("specSchema" in result).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("toFieldSchema infers object/array from structure when type is missing", () => {
|
|
564
|
+
expect(toFieldSchema({ properties: { a: { type: "string" } } })).toEqual({
|
|
565
|
+
type: "object",
|
|
566
|
+
fields: { a: { type: "string" } },
|
|
567
|
+
});
|
|
568
|
+
expect(toFieldSchema({ items: { type: "integer" } })).toEqual({ type: "array", items: { type: "integer" } });
|
|
569
|
+
expect(toFieldSchema({})).toEqual({});
|
|
570
|
+
});
|
|
571
|
+
});
|
package/src/crd/parser.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* with the full codegen pipeline.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
K8sParseResult,
|
|
12
|
+
ParsedProperty,
|
|
13
|
+
ParsedPropertyType,
|
|
14
|
+
GroupVersionKind,
|
|
15
|
+
CrdFieldSchema,
|
|
16
|
+
} from "../spec/parse";
|
|
11
17
|
import type { CRDSpec } from "./types";
|
|
12
18
|
import { namespaceSegmentForGroup } from "../group-namespace";
|
|
13
19
|
import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
|
|
@@ -66,6 +72,7 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
|
|
|
66
72
|
const properties = schema ? extractProperties(schema) : [];
|
|
67
73
|
const propertyTypes = schema ? extractPropertyTypes(schema, typeName) : [];
|
|
68
74
|
const status = schema ? extractStatusType(schema, typeName) : {};
|
|
75
|
+
const specSchema = schema?.properties?.spec ? toFieldSchema(schema.properties.spec) : undefined;
|
|
69
76
|
|
|
70
77
|
const attributes = [
|
|
71
78
|
{ name: "name", tsType: "string" },
|
|
@@ -85,6 +92,10 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
|
|
|
85
92
|
propertyTypes: status.propertyType ? [...propertyTypes, status.propertyType] : propertyTypes,
|
|
86
93
|
enums: [],
|
|
87
94
|
gvk,
|
|
95
|
+
// chant #1372 — the spec's field schema, shipped in the lexicon JSON so
|
|
96
|
+
// the post-synth checks (WK8501/WK8502) can validate a custom resource
|
|
97
|
+
// the way the API server's structural schema would, before apply.
|
|
98
|
+
...(specSchema ? { specSchema } : {}),
|
|
88
99
|
// chant #1074 — the CRD declares its own plural and scope, so the
|
|
89
100
|
// operation surface for a custom resource comes from the same document its
|
|
90
101
|
// types do, exactly as the OpenAPI `paths` supply them for built-in kinds.
|
|
@@ -265,10 +276,12 @@ function extractStatusType(
|
|
|
265
276
|
const statusSchema = schema.properties?.status;
|
|
266
277
|
if (!statusSchema) return {};
|
|
267
278
|
|
|
268
|
-
// The `.d.ts` accessor is deliberately opaque
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
279
|
+
// The `.d.ts` accessor is deliberately opaque, like the constructor's
|
|
280
|
+
// `spec: Record<string, unknown>`. The writable side is typed through the
|
|
281
|
+
// lexicon JSON instead (`specSchema`, chant #1372, checked by WK8501/WK8502).
|
|
282
|
+
// Status is server-owned and never validated; the property type below only
|
|
283
|
+
// registers its name so the shape is discoverable (chant #1372 reviewed the
|
|
284
|
+
// claim that it fed LSP / validation — it did not, and does not need to).
|
|
272
285
|
const attribute = { name: "status", tsType: "Record<string, unknown>" };
|
|
273
286
|
|
|
274
287
|
// Opaque status (preserve-unknown or no schema) → read-only record only.
|
|
@@ -293,6 +306,59 @@ function extractStatusType(
|
|
|
293
306
|
return { attribute, propertyType };
|
|
294
307
|
}
|
|
295
308
|
|
|
309
|
+
// ── Spec field schema (chant #1372) ────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Reduce an `openAPIV3Schema` node to the compact {@link CrdFieldSchema} the
|
|
313
|
+
* lexicon JSON ships. Walks the whole tree; drops descriptions, formats,
|
|
314
|
+
* defaults and numeric bounds. `allOf`/`oneOf`/`anyOf` are not modelled — a
|
|
315
|
+
* node that relies on them with no `type` of its own comes out untyped, which
|
|
316
|
+
* the validators treat as "anything goes".
|
|
317
|
+
*/
|
|
318
|
+
export function toFieldSchema(node: OpenAPISchema): CrdFieldSchema {
|
|
319
|
+
const out: CrdFieldSchema = {};
|
|
320
|
+
|
|
321
|
+
if (node["x-kubernetes-int-or-string"]) {
|
|
322
|
+
out.open = true;
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const type = node.type;
|
|
327
|
+
if (type === "string" || type === "integer" || type === "number" || type === "boolean" || type === "object" || type === "array") {
|
|
328
|
+
out.type = type;
|
|
329
|
+
} else if (node.properties) {
|
|
330
|
+
out.type = "object";
|
|
331
|
+
} else if (node.items) {
|
|
332
|
+
out.type = "array";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (node.enum && node.enum.length > 0) {
|
|
336
|
+
out.enum = node.enum.map((v) => String(v));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (out.type === "object") {
|
|
340
|
+
if (node.properties) {
|
|
341
|
+
const fields: Record<string, CrdFieldSchema> = {};
|
|
342
|
+
for (const [name, child] of Object.entries(node.properties)) {
|
|
343
|
+
fields[name] = toFieldSchema(child);
|
|
344
|
+
}
|
|
345
|
+
out.fields = fields;
|
|
346
|
+
}
|
|
347
|
+
if (node.required && node.required.length > 0) {
|
|
348
|
+
out.required = [...node.required];
|
|
349
|
+
}
|
|
350
|
+
if (node["x-kubernetes-preserve-unknown-fields"] || node.additionalProperties || !node.properties) {
|
|
351
|
+
out.open = true;
|
|
352
|
+
}
|
|
353
|
+
} else if (out.type === "array") {
|
|
354
|
+
if (node.items) out.items = toFieldSchema(node.items);
|
|
355
|
+
} else if (node["x-kubernetes-preserve-unknown-fields"] && !out.type) {
|
|
356
|
+
out.open = true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
296
362
|
/**
|
|
297
363
|
* Resolve an OpenAPI schema node to a TypeScript type string.
|
|
298
364
|
*/
|
package/src/crd/traefik.test.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { k8sSerializer } from "../serializer";
|
|
|
14
14
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
15
15
|
|
|
16
16
|
function synth(logicalName: string, resource: unknown): any {
|
|
17
|
-
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]]));
|
|
17
|
+
const yaml = k8sSerializer.serialize(new Map([[logicalName, resource as never]])) as string;
|
|
18
18
|
return parseYAML(yaml);
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -22,14 +22,18 @@
|
|
|
22
22
|
* are sets addressed by a well-known identity (containers by name, ports by
|
|
23
23
|
* containerPort+protocol). None of that needs a live object in hand.
|
|
24
24
|
*
|
|
25
|
-
* What does *not* live here is the managed-fields
|
|
26
|
-
* specific field on one specific live object is
|
|
27
|
-
*
|
|
25
|
+
* What does *not* live here is the managed-fields ownership walk — which
|
|
26
|
+
* manager owns one specific field on one specific live object. That is
|
|
27
|
+
* inherently per-object (it depends on *that* object's
|
|
28
28
|
* `metadata.managedFields`, which the declared tree never carries and which
|
|
29
29
|
* differs between two Deployments of the same type), so it cannot be
|
|
30
30
|
* expressed as a fixed rule keyed only by entity type and path — the shape
|
|
31
31
|
* every other hook in this file takes. `./deep-observe.ts` computes it once
|
|
32
|
-
* per resource and
|
|
32
|
+
* per resource and attaches it to the observation as `fieldOwners`. Since
|
|
33
|
+
* chant #1191 ownership no longer prunes anything: a foreign-owned field
|
|
34
|
+
* nobody declared is reported as `undeclared` drift, and the only static
|
|
35
|
+
* subtraction for foreign writes is `K8S_SYSTEM_METADATA_PRUNE_PATTERNS`
|
|
36
|
+
* (core's `managed-fields`), applied below.
|
|
33
37
|
*
|
|
34
38
|
* The *entity-type-agnostic* half of these rules — which fields every
|
|
35
39
|
* Kubernetes API object carries regardless of kind, and the well-known
|
|
@@ -41,20 +45,17 @@
|
|
|
41
45
|
* keyed by chant's own k8s entityType catalog.
|
|
42
46
|
*/
|
|
43
47
|
|
|
44
|
-
import {
|
|
48
|
+
import { existsSync, readFileSync } from "fs";
|
|
49
|
+
import { dirname, join } from "path";
|
|
50
|
+
import { fileURLToPath } from "url";
|
|
45
51
|
import type { DeepArrayElement, DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
|
|
46
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
|
|
54
|
+
K8S_SYSTEM_METADATA_PRUNE_PATTERNS,
|
|
55
|
+
k8sListMapOrderKey,
|
|
56
|
+
} from "@intentius/chant/managed-fields";
|
|
47
57
|
import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
48
58
|
|
|
49
|
-
// This module ships as ESM (tsx strips types from src/ directly), where a bare
|
|
50
|
-
// `require` is not defined — the same reason serializer.ts and the LSP modules
|
|
51
|
-
// already build one. The bug only surfaced on a LIVE deep read that meets an
|
|
52
|
-
// associative list (every k8s estate does: a Deployment's containers), so
|
|
53
|
-
// `lifecycle diff --live` crashed with `require is not defined` on exactly the
|
|
54
|
-
// estates the generated table exists for (#1441 regression, found on
|
|
55
|
-
// kubemicrovm-ops).
|
|
56
|
-
const require = createRequire(import.meta.url);
|
|
57
|
-
|
|
58
59
|
/**
|
|
59
60
|
* Kubernetes-defaulted fields, per entity type, as index-erased property
|
|
60
61
|
* paths. Subtracted only where source never declared the property
|
|
@@ -184,11 +185,63 @@ type ListMapKeyTable = Record<string, string[][]>;
|
|
|
184
185
|
|
|
185
186
|
let cachedListMapKeys: ListMapKeyTable | undefined;
|
|
186
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Where the generated table lives: next to this module, resolved from
|
|
190
|
+
* `import.meta.url`. This module ships as ESM (tsx strips types from src/
|
|
191
|
+
* directly), where a bare `require` is not defined — the #1441 regression
|
|
192
|
+
* crashed `lifecycle diff --live` with `require is not defined` on every
|
|
193
|
+
* estate with a Deployment, and azure's apiVersion registry hit the same
|
|
194
|
+
* class of bug in #1581. A plain fs read has no module-system dependency.
|
|
195
|
+
*/
|
|
196
|
+
export function listMapKeyTablePath(): string {
|
|
197
|
+
return join(dirname(fileURLToPath(import.meta.url)), "generated", "list-map-keys.json");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Load the spec-derived table once. A missing or unreadable file degrades to
|
|
202
|
+
* an EMPTY table, which routes every list through `k8sListMapOrderKey`'s
|
|
203
|
+
* hand-written conventions — the same fallback an unlisted property already
|
|
204
|
+
* takes. The empty table is cached by the caller too, so an absent artifact
|
|
205
|
+
* costs one failed read and one warning, not one per array element
|
|
206
|
+
* (chant #1476).
|
|
207
|
+
*
|
|
208
|
+
* Degrading is the right call here, unlike azure's apiVersion registry
|
|
209
|
+
* (#1581) where a silent fallback bypasses correctness-bearing pins. Without
|
|
210
|
+
* the table, drift detection narrows back to the six hardcoded properties:
|
|
211
|
+
* less coverage, never wrong answers. The warning names the regen command so
|
|
212
|
+
* the narrowing is not silent.
|
|
213
|
+
*/
|
|
214
|
+
export function loadListMapKeyTable(path: string = listMapKeyTablePath()): ListMapKeyTable {
|
|
215
|
+
if (!existsSync(path)) {
|
|
216
|
+
warnMissingTable(path, "not found");
|
|
217
|
+
return {};
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ListMapKeyTable;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
warnMissingTable(path, `unreadable: ${err instanceof Error ? err.message : String(err)}`);
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function warnMissingTable(path: string, reason: string): void {
|
|
228
|
+
console.warn(
|
|
229
|
+
`k8s: list-map-keys table ${reason} (${path}); list identity falls back to the ` +
|
|
230
|
+
"hand-written conventions in @intentius/chant/managed-fields. Run `npm run generate` " +
|
|
231
|
+
"in lexicons/k8s (dev checkout) or reinstall @intentius/chant-lexicon-k8s.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
187
235
|
function listMapKeyTable(): ListMapKeyTable {
|
|
188
|
-
cachedListMapKeys ??=
|
|
236
|
+
cachedListMapKeys ??= loadListMapKeyTable();
|
|
189
237
|
return cachedListMapKeys;
|
|
190
238
|
}
|
|
191
239
|
|
|
240
|
+
/** Test hook: replace (or drop) the cached table so the next lookup reloads it. */
|
|
241
|
+
export function resetListMapKeyTableCache(table?: ListMapKeyTable): void {
|
|
242
|
+
cachedListMapKeys = table;
|
|
243
|
+
}
|
|
244
|
+
|
|
192
245
|
/** Last dotted segment of an index-erased path: `spec.template.spec.containers` → `containers`. */
|
|
193
246
|
function lastSegment(pattern: string): string {
|
|
194
247
|
const at = pattern.lastIndexOf(".");
|
|
@@ -259,6 +312,15 @@ export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
|
259
312
|
|
|
260
313
|
if (node.side !== "live" || node.counterpart !== "absent") return false;
|
|
261
314
|
|
|
315
|
+
// What Kubernetes' own controllers stamp on every object of a kind
|
|
316
|
+
// (rollout counters, selector hashes, client-side-apply bookkeeping) is
|
|
317
|
+
// not drift. This is the static allowlist that stands in for the
|
|
318
|
+
// managed-fields prune since #1191: any *other* foreign-owned field
|
|
319
|
+
// nobody declared — a `kubectl label` from the console — now reaches the
|
|
320
|
+
// diff and reports as `undeclared`, with the accepted baseline as the
|
|
321
|
+
// valve for the ones an estate has decided to live with.
|
|
322
|
+
if (K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has(node.pattern)) return true;
|
|
323
|
+
|
|
262
324
|
// chant's own ownership marker is not drift (see the sets' docs).
|
|
263
325
|
if (K8S_OWNERSHIP_LABEL_PATTERNS.has(node.pattern)) return true;
|
|
264
326
|
if (node.pattern === K8S_MANAGED_BY_LABEL_PATTERN && node.value === OWNERSHIP_MANAGED_BY_VALUE) return true;
|