@intentius/chant-lexicon-k3d 0.0.1

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 (109) hide show
  1. package/README.md +26 -0
  2. package/dist/codegen/docs.d.ts +7 -0
  3. package/dist/codegen/docs.d.ts.map +1 -0
  4. package/dist/codegen/generate-cli.d.ts +6 -0
  5. package/dist/codegen/generate-cli.d.ts.map +1 -0
  6. package/dist/codegen/generate-lexicon.d.ts +19 -0
  7. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  8. package/dist/codegen/generate-typescript.d.ts +11 -0
  9. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  10. package/dist/codegen/generate.d.ts +15 -0
  11. package/dist/codegen/generate.d.ts.map +1 -0
  12. package/dist/codegen/naming.d.ts +13 -0
  13. package/dist/codegen/naming.d.ts.map +1 -0
  14. package/dist/codegen/package.d.ts +17 -0
  15. package/dist/codegen/package.d.ts.map +1 -0
  16. package/dist/codegen/parse.d.ts +57 -0
  17. package/dist/codegen/parse.d.ts.map +1 -0
  18. package/dist/describe-resources.d.ts +34 -0
  19. package/dist/describe-resources.d.ts.map +1 -0
  20. package/dist/generated/index.d.ts +22 -0
  21. package/dist/generated/index.d.ts.map +1 -0
  22. package/dist/generated/runtime.d.ts +5 -0
  23. package/dist/generated/runtime.d.ts.map +1 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/integrity.json +9 -0
  27. package/dist/lint/audit-catalog.d.ts +9 -0
  28. package/dist/lint/audit-catalog.d.ts.map +1 -0
  29. package/dist/lint/post-synth/index.d.ts +3 -0
  30. package/dist/lint/post-synth/index.d.ts.map +1 -0
  31. package/dist/lint/post-synth/k3d101.d.ts +19 -0
  32. package/dist/lint/post-synth/k3d101.d.ts.map +1 -0
  33. package/dist/lint/post-synth/k3d102.d.ts +12 -0
  34. package/dist/lint/post-synth/k3d102.d.ts.map +1 -0
  35. package/dist/lint/rules/index.d.ts +5 -0
  36. package/dist/lint/rules/index.d.ts.map +1 -0
  37. package/dist/lint/rules/registry-proxy-password.d.ts +17 -0
  38. package/dist/lint/rules/registry-proxy-password.d.ts.map +1 -0
  39. package/dist/lint/rules/sample.d.ts +8 -0
  40. package/dist/lint/rules/sample.d.ts.map +1 -0
  41. package/dist/lsp/completions.d.ts +7 -0
  42. package/dist/lsp/completions.d.ts.map +1 -0
  43. package/dist/lsp/hover.d.ts +6 -0
  44. package/dist/lsp/hover.d.ts.map +1 -0
  45. package/dist/manifest.json +6 -0
  46. package/dist/meta.json +184 -0
  47. package/dist/op/activities/index.d.ts +17 -0
  48. package/dist/op/activities/index.d.ts.map +1 -0
  49. package/dist/op/activities/k3d.d.ts +93 -0
  50. package/dist/op/activities/k3d.d.ts.map +1 -0
  51. package/dist/package-cli.d.ts +3 -0
  52. package/dist/package-cli.d.ts.map +1 -0
  53. package/dist/plugin.d.ts +8 -0
  54. package/dist/plugin.d.ts.map +1 -0
  55. package/dist/serializer.d.ts +35 -0
  56. package/dist/serializer.d.ts.map +1 -0
  57. package/dist/spec/fetch.d.ts +39 -0
  58. package/dist/spec/fetch.d.ts.map +1 -0
  59. package/dist/testdata/load-fixtures.d.ts +16 -0
  60. package/dist/testdata/load-fixtures.d.ts.map +1 -0
  61. package/dist/types/index.d.ts +189 -0
  62. package/dist/validate-cli.d.ts +3 -0
  63. package/dist/validate-cli.d.ts.map +1 -0
  64. package/dist/validate.d.ts +15 -0
  65. package/dist/validate.d.ts.map +1 -0
  66. package/package.json +58 -0
  67. package/src/codegen/docs.ts +25 -0
  68. package/src/codegen/generate-cli.ts +36 -0
  69. package/src/codegen/generate-lexicon.ts +58 -0
  70. package/src/codegen/generate-typescript.ts +102 -0
  71. package/src/codegen/generate.test.ts +154 -0
  72. package/src/codegen/generate.ts +139 -0
  73. package/src/codegen/naming.ts +56 -0
  74. package/src/codegen/package.ts +27 -0
  75. package/src/codegen/parse.ts +359 -0
  76. package/src/describe-resources.test.ts +142 -0
  77. package/src/describe-resources.ts +144 -0
  78. package/src/generated/.gitkeep +0 -0
  79. package/src/generated/index.d.ts +189 -0
  80. package/src/generated/index.ts +27 -0
  81. package/src/generated/lexicon-k3d.json +184 -0
  82. package/src/generated/runtime.ts +4 -0
  83. package/src/index.ts +8 -0
  84. package/src/lint/audit-catalog.ts +26 -0
  85. package/src/lint/post-synth/index.ts +9 -0
  86. package/src/lint/post-synth/k3d101.ts +87 -0
  87. package/src/lint/post-synth/k3d102.ts +40 -0
  88. package/src/lint/post-synth/post-synth.test.ts +76 -0
  89. package/src/lint/rules/index.ts +7 -0
  90. package/src/lint/rules/registry-proxy-password.test.ts +53 -0
  91. package/src/lint/rules/registry-proxy-password.ts +93 -0
  92. package/src/lsp/completions.test.ts +10 -0
  93. package/src/lsp/completions.ts +21 -0
  94. package/src/lsp/hover.test.ts +10 -0
  95. package/src/lsp/hover.ts +39 -0
  96. package/src/op/activities/index.ts +24 -0
  97. package/src/op/activities/k3d.test.ts +168 -0
  98. package/src/op/activities/k3d.ts +183 -0
  99. package/src/package-cli.ts +41 -0
  100. package/src/plugin.test.ts +17 -0
  101. package/src/plugin.ts +114 -0
  102. package/src/serializer.acceptance.test.ts +94 -0
  103. package/src/serializer.test.ts +189 -0
  104. package/src/serializer.ts +202 -0
  105. package/src/spec/fetch.ts +66 -0
  106. package/src/testdata/k3d-schema-fixture.json +431 -0
  107. package/src/testdata/load-fixtures.ts +27 -0
  108. package/src/validate-cli.ts +5 -0
  109. package/src/validate.ts +28 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { LexiconPlugin, SkillDefinition, IntrinsicDef } from "@intentius/chant/lexicon";
2
+ import type { LintRule } from "@intentius/chant/lint/rule";
3
+ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
4
+ import type { CompletionContext, CompletionItem, HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
5
+ import type { McpToolContribution, McpResourceContribution } from "@intentius/chant/mcp/types";
6
+ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
7
+ import { k3dSerializer } from "./serializer";
8
+ import { rules } from "./lint/rules";
9
+ import { postSynthChecks } from "./lint/post-synth";
10
+ import { k3dAuditCatalog } from "./lint/audit-catalog";
11
+ import { completions } from "./lsp/completions";
12
+ import { hover } from "./lsp/hover";
13
+
14
+ /**
15
+ * k3d lexicon plugin.
16
+ *
17
+ * Implements all required LexiconPlugin lifecycle methods.
18
+ */
19
+ export const k3dPlugin: LexiconPlugin = {
20
+ name: "k3d",
21
+ serializer: k3dSerializer,
22
+
23
+ /**
24
+ * The marker is stamped as Docker labels on every node via
25
+ * `options.runtime.labels` (the serializer does this when a build carries
26
+ * ownership) and read back through `docker inspect` — k3d's own list
27
+ * output does not expose custom labels (#1412, verified live). Labels
28
+ * survive `k3d cluster stop`/`start`.
29
+ */
30
+ ownershipChannel: {
31
+ keys: LABEL_OWNERSHIP_KEYS,
32
+ reads: ["describeResources"],
33
+ },
34
+
35
+ async describeResources(options) {
36
+ const { describeResources } = await import("./describe-resources");
37
+ return describeResources(options);
38
+ },
39
+
40
+ // ── Required lifecycle methods ────────────────────────────────
41
+
42
+ async generate(options?: { verbose?: boolean }): Promise<void> {
43
+ const { generate } = await import("./codegen/generate");
44
+ await generate(options);
45
+ },
46
+
47
+ async validate(options?: { verbose?: boolean }): Promise<void> {
48
+ const { validate } = await import("./validate");
49
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
50
+ const result = await validate();
51
+ printValidationResult(result);
52
+ },
53
+
54
+ async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
55
+ // TODO: Implement coverage analysis
56
+ console.error("Coverage analysis not yet implemented");
57
+ },
58
+
59
+ async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
60
+ const { packageLexicon } = await import("./codegen/package");
61
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
62
+ const { join, dirname } = await import("path");
63
+ const { fileURLToPath } = await import("url");
64
+
65
+ const { spec, stats } = await packageLexicon(options);
66
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
67
+ writeBundleSpec(spec, join(pkgDir, "dist"));
68
+
69
+ console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
70
+ },
71
+
72
+ // ── Optional extensions ────────────────────────────────────
73
+
74
+ lintRules() {
75
+ return rules;
76
+ },
77
+
78
+ postSynthChecks() {
79
+ return postSynthChecks;
80
+ },
81
+
82
+ auditCatalog() {
83
+ return k3dAuditCatalog;
84
+ },
85
+
86
+ skills() {
87
+ return []; // TODO: Add skills
88
+ },
89
+
90
+ mcpTools() {
91
+ return []; // TODO: Implement MCP tools
92
+ },
93
+
94
+ mcpResources() {
95
+ return []; // TODO: Implement MCP resources
96
+ },
97
+
98
+ detectTemplate(data: unknown) {
99
+ return false; // TODO: Detect if a template belongs to this lexicon
100
+ },
101
+
102
+ completionProvider(ctx: CompletionContext) {
103
+ return completions(ctx);
104
+ },
105
+
106
+ hoverProvider(ctx: HoverContext) {
107
+ return hover(ctx);
108
+ },
109
+
110
+ async docs(options?) {
111
+ const { generateDocs } = await import("./codegen/docs");
112
+ return generateDocs(options);
113
+ },
114
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The acceptance test is k3d, not a fixture (#1408).
3
+ *
4
+ * A fixture comparison proves the serializer matches what someone wrote down.
5
+ * This proves the artifact is a file the native tool consumes: build a
6
+ * declaration, serialize it, hand the output to `k3d cluster create --config`,
7
+ * assert a cluster comes up, delete it.
8
+ *
9
+ * Skipped, with the reason visible in the runner, when Docker or k3d is
10
+ * absent — CI may have neither. The test's own config pins the safe
11
+ * kubeconfig options regardless of serializer defaults: a test that switches
12
+ * the developer's active context is exactly the surprise this lexicon exists
13
+ * to make explicit.
14
+ */
15
+
16
+ import { execSync } from "node:child_process";
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, describe, expect, it } from "vitest";
21
+ import { k3dSerializer } from "./serializer";
22
+ import { Cluster, K3dOptions, KubeconfigOptions, Options } from "./generated/index";
23
+
24
+ function available(cmd: string): boolean {
25
+ try {
26
+ execSync(cmd, { stdio: "ignore" });
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ const hasK3d = available("k3d version");
34
+ const hasDocker = available("docker info");
35
+ const enabled = hasK3d && hasDocker;
36
+ const skipReason = !hasK3d ? "k3d is not installed" : !hasDocker ? "Docker is not running" : "";
37
+
38
+ // Distinctive name; the deletes below run even on failure so a failed run
39
+ // does not leave a Docker network and containers behind on someone's laptop.
40
+ const CLUSTER = `chant-k3d-accept-${process.pid}`;
41
+
42
+ describe.skipIf(!enabled)(`k3d accepts the emitted config${skipReason ? ` (skipped: ${skipReason})` : ""}`, () => {
43
+ afterAll(() => {
44
+ try {
45
+ execSync(`k3d cluster delete ${CLUSTER}`, { stdio: "ignore" });
46
+ } catch {
47
+ // already gone — deletion is the success case here
48
+ }
49
+ });
50
+
51
+ it("creates a working cluster from serializer output, then tears it down", () => {
52
+ const declaration = new Cluster({
53
+ metadata: { name: CLUSTER },
54
+ servers: 1,
55
+ agents: 0,
56
+ options: new Options({
57
+ k3d: new K3dOptions({ disableLoadbalancer: true, wait: true }),
58
+ kubeconfig: new KubeconfigOptions({
59
+ updateDefaultKubeconfig: false,
60
+ switchCurrentContext: false,
61
+ }),
62
+ }),
63
+ });
64
+
65
+ const result = k3dSerializer.serialize(new Map([["acceptance", declaration]]));
66
+ const yaml = typeof result === "string" ? result : result.primary;
67
+
68
+ const dir = mkdtempSync(join(tmpdir(), "chant-k3d-accept-"));
69
+ const configPath = join(dir, "cluster.yaml");
70
+ writeFileSync(configPath, yaml);
71
+
72
+ try {
73
+ const before = execSync("kubectl config current-context 2>/dev/null || true").toString().trim();
74
+
75
+ execSync(`k3d cluster create --config ${configPath}`, {
76
+ stdio: "pipe",
77
+ timeout: 240_000,
78
+ });
79
+
80
+ const listed = execSync(`k3d cluster list ${CLUSTER} --no-headers`).toString();
81
+ expect(listed).toContain(CLUSTER);
82
+
83
+ // The safe kubeconfig options held: the active context is what it was.
84
+ const after = execSync("kubectl config current-context 2>/dev/null || true").toString().trim();
85
+ expect(after).toBe(before);
86
+
87
+ execSync(`k3d cluster delete ${CLUSTER}`, { stdio: "pipe", timeout: 120_000 });
88
+ const gone = execSync(`k3d cluster list -o json`).toString();
89
+ expect(gone).not.toContain(`"${CLUSTER}"`);
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ }, 300_000);
94
+ });
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { load } from "js-yaml";
3
+ import { k3dSerializer } from "./serializer";
4
+ import {
5
+ Cluster,
6
+ KubeconfigOptions,
7
+ Options,
8
+ Port,
9
+ RuntimeOptions,
10
+ Volume,
11
+ } from "./generated/index";
12
+ import type { Declarable } from "@intentius/chant/declarable";
13
+
14
+ function serializeOne(entities: Record<string, Declarable>): {
15
+ text: string;
16
+ doc: Record<string, unknown>;
17
+ } {
18
+ const result = k3dSerializer.serialize(new Map(Object.entries(entities)));
19
+ const text = typeof result === "string" ? result : result.primary;
20
+ return { text, doc: load(text) as Record<string, unknown> };
21
+ }
22
+
23
+ describe("k3d serializer", () => {
24
+ it("has the k3d name and K3D rule prefix", () => {
25
+ expect(k3dSerializer.name).toBe("k3d");
26
+ expect(k3dSerializer.rulePrefix).toBe("K3D");
27
+ });
28
+
29
+ it("emits apiVersion and kind literals even when undeclared", () => {
30
+ const { doc } = serializeOne({ probe: new Cluster({ servers: 1 }) });
31
+ expect(doc.apiVersion).toBe("k3d.io/v1alpha5");
32
+ expect(doc.kind).toBe("Simple");
33
+ });
34
+
35
+ it("defaults metadata.name from the entity name and never overrides an explicit one", () => {
36
+ const { doc } = serializeOne({ "my-cluster": new Cluster({ servers: 1 }) });
37
+ expect((doc.metadata as Record<string, unknown>).name).toBe("my-cluster");
38
+
39
+ const { doc: explicit } = serializeOne({
40
+ probe: new Cluster({ servers: 1, metadata: { name: "elsewhere" } }),
41
+ });
42
+ expect((explicit.metadata as Record<string, unknown>).name).toBe("elsewhere");
43
+ });
44
+
45
+ it("keeps agents: 0 — zero is meaningful, not falsy", () => {
46
+ const { text, doc } = serializeOne({ probe: new Cluster({ servers: 1, agents: 0 }) });
47
+ expect(doc.agents).toBe(0);
48
+ expect(text).toMatch(/^agents: 0$/m);
49
+ });
50
+
51
+ it("keeps booleans booleans", () => {
52
+ const { text } = serializeOne({
53
+ probe: new Cluster({
54
+ servers: 1,
55
+ options: new Options({
56
+ kubeconfig: new KubeconfigOptions({
57
+ updateDefaultKubeconfig: false,
58
+ switchCurrentContext: false,
59
+ }),
60
+ }),
61
+ }),
62
+ });
63
+ expect(text).toContain("updateDefaultKubeconfig: false");
64
+ expect(text).not.toContain('"false"');
65
+ expect(text).not.toContain("'false'");
66
+ });
67
+
68
+ it("injects chant's safe kubeconfig defaults when the declaration says nothing", () => {
69
+ const { doc } = serializeOne({ probe: new Cluster({ servers: 1 }) });
70
+ const options = doc.options as Record<string, Record<string, unknown>>;
71
+ expect(options.kubeconfig).toEqual({
72
+ updateDefaultKubeconfig: false,
73
+ switchCurrentContext: false,
74
+ });
75
+ });
76
+
77
+ it("passes a declared kubeconfig block through exactly as written", () => {
78
+ const { doc } = serializeOne({
79
+ probe: new Cluster({
80
+ servers: 1,
81
+ options: new Options({
82
+ kubeconfig: new KubeconfigOptions({ updateDefaultKubeconfig: true }),
83
+ }),
84
+ }),
85
+ });
86
+ const options = doc.options as Record<string, Record<string, unknown>>;
87
+ expect(options.kubeconfig).toEqual({ updateDefaultKubeconfig: true });
88
+ });
89
+
90
+ it("passes nodeFilters through untouched, k3d syntax and all", () => {
91
+ const { doc } = serializeOne({
92
+ probe: new Cluster({
93
+ servers: 1,
94
+ ports: [new Port({ port: "8080:80", nodeFilters: ["loadbalancer"] })],
95
+ volumes: [new Volume({ volume: "/tmp/x:/x", nodeFilters: ["server:0", "agent:*"] })],
96
+ }),
97
+ });
98
+ const ports = doc.ports as Array<Record<string, unknown>>;
99
+ expect(ports[0].nodeFilters).toEqual(["loadbalancer"]);
100
+ const volumes = doc.volumes as Array<Record<string, unknown>>;
101
+ expect(volumes[0].nodeFilters).toEqual(["server:0", "agent:*"]);
102
+ });
103
+
104
+ it("round-trips: emitted YAML parses back to the declared values", () => {
105
+ const declared = {
106
+ servers: 3,
107
+ agents: 2,
108
+ image: "rancher/k3s:v1.31.4-k3s1",
109
+ options: new Options({
110
+ k3d: { disableLoadbalancer: true },
111
+ runtime: new RuntimeOptions({
112
+ labels: [
113
+ { label: "app.kubernetes.io/managed-by=chant", nodeFilters: ["server:*"] },
114
+ ],
115
+ }),
116
+ }),
117
+ };
118
+ const { doc } = serializeOne({ probe: new Cluster(declared) });
119
+ expect(doc.servers).toBe(3);
120
+ expect(doc.agents).toBe(2);
121
+ expect(doc.image).toBe("rancher/k3s:v1.31.4-k3s1");
122
+ const options = doc.options as Record<string, unknown>;
123
+ expect((options.k3d as Record<string, unknown>).disableLoadbalancer).toBe(true);
124
+ const runtime = options.runtime as Record<string, unknown>;
125
+ expect(runtime.labels).toEqual([
126
+ { label: "app.kubernetes.io/managed-by=chant", nodeFilters: ["server:*"] },
127
+ ]);
128
+ });
129
+
130
+ it("stamps ownership as runtime labels on every node when the build carries a marker", () => {
131
+ const result = k3dSerializer.serialize(
132
+ new Map<string, Declarable>([["probe", new Cluster({ servers: 1 })]]),
133
+ undefined,
134
+ { ownership: { stack: "fountain", env: "local" } },
135
+ );
136
+ const text = typeof result === "string" ? result : result.primary;
137
+ const doc = load(text) as Record<string, unknown>;
138
+ const runtime = (doc.options as Record<string, Record<string, unknown>>).runtime;
139
+ expect(runtime.labels).toEqual(
140
+ expect.arrayContaining([
141
+ { label: "app.kubernetes.io/managed-by=chant", nodeFilters: ["all"] },
142
+ { label: "chant.intentius.io/stack=fountain", nodeFilters: ["all"] },
143
+ { label: "chant.intentius.io/env=local", nodeFilters: ["all"] },
144
+ ]),
145
+ );
146
+ });
147
+
148
+ it("an author's own label with the marker key wins over the stamp", () => {
149
+ const result = k3dSerializer.serialize(
150
+ new Map<string, Declarable>([
151
+ [
152
+ "probe",
153
+ new Cluster({
154
+ servers: 1,
155
+ options: new Options({
156
+ runtime: new RuntimeOptions({
157
+ labels: [{ label: "chant.intentius.io/stack=mine", nodeFilters: ["server:0"] }],
158
+ }),
159
+ }),
160
+ }),
161
+ ],
162
+ ]),
163
+ undefined,
164
+ { ownership: { stack: "fountain", env: "local" } },
165
+ );
166
+ const text = typeof result === "string" ? result : result.primary;
167
+ const doc = load(text) as Record<string, unknown>;
168
+ const labels = (doc.options as Record<string, Record<string, unknown>>).runtime.labels as Array<
169
+ Record<string, unknown>
170
+ >;
171
+ const stackLabels = labels.filter((l) => String(l.label).startsWith("chant.intentius.io/stack="));
172
+ expect(stackLabels).toEqual([{ label: "chant.intentius.io/stack=mine", nodeFilters: ["server:0"] }]);
173
+ });
174
+
175
+ it("emits one document per cluster: first primary, the rest in files", () => {
176
+ const result = k3dSerializer.serialize(
177
+ new Map<string, Declarable>([
178
+ ["first", new Cluster({ servers: 1 })],
179
+ ["second", new Cluster({ servers: 1 })],
180
+ ]),
181
+ );
182
+ expect(typeof result).not.toBe("string");
183
+ if (typeof result !== "string") {
184
+ expect(result.primary).toContain("name: first");
185
+ expect(Object.keys(result.files ?? {})).toEqual(["second.k3d.yaml"]);
186
+ expect(result.files?.["second.k3d.yaml"]).toContain("name: second");
187
+ }
188
+ });
189
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * k3d SimpleConfig YAML serializer.
3
+ *
4
+ * Emits the config file `k3d cluster create --config` consumes verbatim —
5
+ * the walk-away artifact that justifies a lexicon here at all. One YAML
6
+ * document per declared Cluster: the first is the primary output, any
7
+ * further clusters land in files[] keyed `<name>.k3d.yaml`.
8
+ *
9
+ * Three things are non-negotiable in the output (#1408):
10
+ *
11
+ * - `apiVersion` and `kind` are literal schema properties, not derived
12
+ * from a type name. They default when undeclared and are never dropped.
13
+ * - Booleans stay booleans and zero stays zero. `agents: 0` is the normal
14
+ * local shape and `updateDefaultKubeconfig: "false"` is truthy.
15
+ * - `nodeFilters` pass through untouched — they are k3d's own syntax
16
+ * (`server:0`, `agent:*`, `loadbalancer`) and rewriting them here would
17
+ * be inventing a dialect.
18
+ *
19
+ * ## The kubeconfig default is chant's, not upstream's
20
+ *
21
+ * When a declaration says nothing about `options.kubeconfig`, the emitted
22
+ * config pins `updateDefaultKubeconfig: false` and `switchCurrentContext:
23
+ * false` — the opposite of k3d's own defaults, chosen deliberately (#1411):
24
+ * a tool that reconciles infrastructure must not rewrite ~/.kube/config or
25
+ * repoint the caller's shell as a side effect. Injecting the values into the
26
+ * artifact rather than relying on activity flags means the emitted YAML says
27
+ * on its face what creating the cluster will do, and `k3d --config` applied
28
+ * by hand behaves identically. A declaration that sets `options.kubeconfig`
29
+ * itself — either key — is passed through exactly as written; upstream
30
+ * defaults then apply to whatever it left unset, because a partially
31
+ * explicit block is the author taking the wheel.
32
+ */
33
+
34
+ import type { Declarable } from "@intentius/chant/declarable";
35
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
36
+ import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
37
+ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
38
+ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
39
+ import { emitYAML } from "@intentius/chant/yaml";
40
+ import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
41
+
42
+ const CLUSTER_TYPE = "K3d::Cluster";
43
+ const API_VERSION = "k3d.io/v1alpha5";
44
+ const KIND = "Simple";
45
+
46
+ /**
47
+ * Top-level key order, matching the schema's own property order so a chant
48
+ * emission diffs cleanly against upstream's documented examples.
49
+ */
50
+ const KEY_ORDER = [
51
+ "apiVersion",
52
+ "kind",
53
+ "metadata",
54
+ "servers",
55
+ "agents",
56
+ "kubeAPI",
57
+ "image",
58
+ "network",
59
+ "subnet",
60
+ "token",
61
+ "volumes",
62
+ "ports",
63
+ "files",
64
+ "env",
65
+ "registries",
66
+ "hostAliases",
67
+ "options",
68
+ ];
69
+
70
+ const visitor: SerializerVisitor = {
71
+ attrRef: (name) => name,
72
+ resourceRef: (name) => name,
73
+ propertyDeclarable: (entity, walk) => {
74
+ if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
75
+ return undefined;
76
+ }
77
+ const props = entity.props as Record<string, unknown>;
78
+ const result: Record<string, unknown> = {};
79
+ for (const [key, value] of Object.entries(props)) {
80
+ if (value !== undefined) result[key] = walk(value);
81
+ }
82
+ return result;
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Stamp the ownership marker as Docker labels on every node, via
88
+ * `options.runtime.labels` (#1412). The labels survive `k3d cluster stop`/
89
+ * `start` and are readable back through `docker inspect` — verified live;
90
+ * `k3d cluster list -o json` does not expose custom labels, so the read
91
+ * side goes to Docker. An author's own label with the same key wins.
92
+ */
93
+ function stampOwnership(
94
+ options: Record<string, unknown>,
95
+ ownership: NonNullable<SerializeContext["ownership"]>,
96
+ ): void {
97
+ const runtime = { ...((options.runtime as Record<string, unknown> | undefined) ?? {}) };
98
+ const labels = Array.isArray(runtime.labels) ? [...(runtime.labels as unknown[])] : [];
99
+ const present = new Set(
100
+ labels
101
+ .map((l) => (typeof l === "object" && l !== null ? (l as Record<string, unknown>).label : undefined))
102
+ .filter((l): l is string => typeof l === "string")
103
+ .map((l) => l.split("=")[0]),
104
+ );
105
+ for (const [key, value] of Object.entries(ownershipEntries(LABEL_OWNERSHIP_KEYS, ownership))) {
106
+ if (present.has(key)) continue;
107
+ labels.push({ label: `${key}=${value}`, nodeFilters: ["all"] });
108
+ }
109
+ runtime.labels = labels;
110
+ options.runtime = runtime;
111
+ }
112
+
113
+ function clusterDocument(
114
+ name: string,
115
+ cluster: Declarable,
116
+ entityNames: Map<Declarable, string>,
117
+ context?: SerializeContext,
118
+ ): Record<string, unknown> {
119
+ const raw = (isResourceDeclarable(cluster) ? cluster.props : {}) as Record<string, unknown>;
120
+ const props = (walkValue(raw, entityNames, visitor) ?? {}) as Record<string, unknown>;
121
+
122
+ const doc: Record<string, unknown> = {};
123
+ doc.apiVersion = props.apiVersion ?? API_VERSION;
124
+ doc.kind = props.kind ?? KIND;
125
+
126
+ // metadata.name defaults from the entity's declared name — the schema wants
127
+ // a hostname there and the export name is the one identity the author has
128
+ // already chosen. An explicit metadata wins untouched.
129
+ const metadata = props.metadata as Record<string, unknown> | undefined;
130
+ doc.metadata = metadata?.name !== undefined ? metadata : { ...(metadata ?? {}), name: name };
131
+
132
+ for (const key of KEY_ORDER) {
133
+ if (key === "apiVersion" || key === "kind" || key === "metadata" || key === "options") continue;
134
+ if (props[key] !== undefined) doc[key] = props[key];
135
+ }
136
+
137
+ const options = { ...((props.options as Record<string, unknown> | undefined) ?? {}) };
138
+ if (options.kubeconfig === undefined) {
139
+ // chant's deliberate inversion of upstream's true/true — see module doc.
140
+ options.kubeconfig = { updateDefaultKubeconfig: false, switchCurrentContext: false };
141
+ }
142
+ if (context?.ownership) stampOwnership(options, context.ownership);
143
+ doc.options = options;
144
+
145
+ // Anything declared outside the known order still emits — the schema is
146
+ // additionalProperties: false, so this only ever carries what a future
147
+ // schema version adds before KEY_ORDER learns about it.
148
+ for (const [key, value] of Object.entries(props)) {
149
+ if (KEY_ORDER.includes(key) || value === undefined) continue;
150
+ doc[key] = value;
151
+ }
152
+
153
+ return doc;
154
+ }
155
+
156
+ function emitDocument(doc: Record<string, unknown>): string {
157
+ const sections: string[] = [];
158
+ for (const [key, value] of Object.entries(doc)) {
159
+ if (value === undefined) continue;
160
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
161
+ sections.push(`${key}: ${scalar(value)}`);
162
+ } else {
163
+ sections.push(`${key}:` + emitYAML(value, 1));
164
+ }
165
+ }
166
+ return sections.join("\n") + "\n";
167
+ }
168
+
169
+ function scalar(value: string | number | boolean): string {
170
+ if (typeof value === "boolean") return value ? "true" : "false";
171
+ if (typeof value === "number") return String(value);
172
+ if (/[:#\[\]{}|>&*!%@`'"]|^[\s\d]|\s$|^$|^(true|false|null|yes|no|on|off)$/i.test(value)) {
173
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
174
+ }
175
+ return value;
176
+ }
177
+
178
+ export const k3dSerializer: Serializer = {
179
+ name: "k3d",
180
+ rulePrefix: "K3D",
181
+
182
+ serialize(
183
+ entities: Map<string, Declarable>,
184
+ _outputs?: LexiconOutput[],
185
+ context?: SerializeContext,
186
+ ): string | SerializerResult {
187
+ const entityNames = new Map<Declarable, string>();
188
+ for (const [name, entity] of entities) entityNames.set(entity, name);
189
+
190
+ const clusters = [...entities].filter(([, e]) => e.entityType === CLUSTER_TYPE);
191
+
192
+ let primary = "";
193
+ const files: Record<string, string> = {};
194
+ clusters.forEach(([name, cluster], i) => {
195
+ const content = emitDocument(clusterDocument(name, cluster, entityNames, context));
196
+ if (i === 0) primary = content;
197
+ else files[`${name}.k3d.yaml`] = content;
198
+ });
199
+
200
+ return Object.keys(files).length > 0 ? { primary, files } : primary;
201
+ },
202
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * k3d SimpleConfig schema fetching — downloads the JSON Schema and
3
+ * caches it locally.
4
+ */
5
+
6
+ import { join } from "path";
7
+ import { homedir } from "os";
8
+ import { fetchWithCache, clearCacheFile } from "@intentius/chant/codegen/fetch";
9
+
10
+ /**
11
+ * Pinned upstream k3d release.
12
+ *
13
+ * The schema is fetched from the tagged source tree, not a moving branch:
14
+ * https://raw.githubusercontent.com/k3d-io/k3d/v5.9.0/pkg/config/v1alpha5/schema.json
15
+ *
16
+ * v1alpha5 is the config apiVersion this lexicon models. Older config
17
+ * versions (v1alpha2..v1alpha4) each ship their own migrate.go upstream —
18
+ * k3d migrates old configs itself, so modelling superseded versions here
19
+ * would duplicate upstream's job. Bump this constant (and re-generate)
20
+ * when adopting a newer k3d release.
21
+ */
22
+ export const K3D_VERSION = "v5.9.0";
23
+
24
+ const SCHEMA_URL = `https://raw.githubusercontent.com/k3d-io/k3d/${K3D_VERSION}/pkg/config/v1alpha5/schema.json`;
25
+
26
+ /**
27
+ * Get the cache file path for the k3d schema.
28
+ */
29
+ export function getCachePath(): string {
30
+ return join(homedir(), ".chant", `k3d-schema-${K3D_VERSION}.json`);
31
+ }
32
+
33
+ /**
34
+ * Fetch the k3d SimpleConfig JSON Schema.
35
+ * Uses local file caching with 24-hour TTL.
36
+ */
37
+ export async function fetchConfigSchema(force?: boolean): Promise<Buffer> {
38
+ return fetchWithCache(
39
+ {
40
+ url: SCHEMA_URL,
41
+ cacheFile: getCachePath(),
42
+ },
43
+ force,
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Fetch the config schema as a Map<typeName, Buffer> compatible with the
49
+ * generatePipeline fetchSchemas callback.
50
+ *
51
+ * Single document keyed by "K3d::Cluster" — the parse step splits it into
52
+ * multiple entities.
53
+ */
54
+ export async function fetchSchemas(force?: boolean): Promise<Map<string, Buffer>> {
55
+ const data = await fetchConfigSchema(force);
56
+ const schemas = new Map<string, Buffer>();
57
+ schemas.set("K3d::Cluster", data);
58
+ return schemas;
59
+ }
60
+
61
+ /**
62
+ * Clear the cached schema file.
63
+ */
64
+ export function clearCache(): void {
65
+ clearCacheFile(getCachePath());
66
+ }