@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.
- package/README.md +26 -0
- package/dist/codegen/docs.d.ts +7 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +6 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-lexicon.d.ts +19 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -0
- package/dist/codegen/generate-typescript.d.ts +11 -0
- package/dist/codegen/generate-typescript.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +15 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts +13 -0
- package/dist/codegen/naming.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/codegen/parse.d.ts +57 -0
- package/dist/codegen/parse.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +34 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +22 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/runtime.d.ts +5 -0
- package/dist/generated/runtime.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +9 -0
- package/dist/lint/audit-catalog.d.ts +9 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/k3d101.d.ts +19 -0
- package/dist/lint/post-synth/k3d101.d.ts.map +1 -0
- package/dist/lint/post-synth/k3d102.d.ts +12 -0
- package/dist/lint/post-synth/k3d102.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/registry-proxy-password.d.ts +17 -0
- package/dist/lint/rules/registry-proxy-password.d.ts.map +1 -0
- package/dist/lint/rules/sample.d.ts +8 -0
- package/dist/lint/rules/sample.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +7 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +6 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +184 -0
- package/dist/op/activities/index.d.ts +17 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/k3d.d.ts +93 -0
- package/dist/op/activities/k3d.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/serializer.d.ts +35 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/spec/fetch.d.ts +39 -0
- package/dist/spec/fetch.d.ts.map +1 -0
- package/dist/testdata/load-fixtures.d.ts +16 -0
- package/dist/testdata/load-fixtures.d.ts.map +1 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/codegen/docs.ts +25 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +102 -0
- package/src/codegen/generate.test.ts +154 -0
- package/src/codegen/generate.ts +139 -0
- package/src/codegen/naming.ts +56 -0
- package/src/codegen/package.ts +27 -0
- package/src/codegen/parse.ts +359 -0
- package/src/describe-resources.test.ts +142 -0
- package/src/describe-resources.ts +144 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/index.d.ts +189 -0
- package/src/generated/index.ts +27 -0
- package/src/generated/lexicon-k3d.json +184 -0
- package/src/generated/runtime.ts +4 -0
- package/src/index.ts +8 -0
- package/src/lint/audit-catalog.ts +26 -0
- package/src/lint/post-synth/index.ts +9 -0
- package/src/lint/post-synth/k3d101.ts +87 -0
- package/src/lint/post-synth/k3d102.ts +40 -0
- package/src/lint/post-synth/post-synth.test.ts +76 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/registry-proxy-password.test.ts +53 -0
- package/src/lint/rules/registry-proxy-password.ts +93 -0
- package/src/lsp/completions.test.ts +10 -0
- package/src/lsp/completions.ts +21 -0
- package/src/lsp/hover.test.ts +10 -0
- package/src/lsp/hover.ts +39 -0
- package/src/op/activities/index.ts +24 -0
- package/src/op/activities/k3d.test.ts +168 -0
- package/src/op/activities/k3d.ts +183 -0
- package/src/package-cli.ts +41 -0
- package/src/plugin.test.ts +17 -0
- package/src/plugin.ts +114 -0
- package/src/serializer.acceptance.test.ts +94 -0
- package/src/serializer.test.ts +189 -0
- package/src/serializer.ts +202 -0
- package/src/spec/fetch.ts +66 -0
- package/src/testdata/k3d-schema-fixture.json +431 -0
- package/src/testdata/load-fixtures.ts +27 -0
- package/src/validate-cli.ts +5 -0
- 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
|
+
}
|