@intentius/chant-lexicon-k3s 0.44.3
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 +36 -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 +3 -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 +93 -0
- package/dist/codegen/parse.d.ts.map +1 -0
- package/dist/generated/index.d.ts +8 -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 +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +10 -0
- package/dist/lint/audit-catalog.d.ts +13 -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/k3s-helpers.d.ts +19 -0
- package/dist/lint/post-synth/k3s-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s101.d.ts +13 -0
- package/dist/lint/post-synth/k3s101.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s102.d.ts +11 -0
- package/dist/lint/post-synth/k3s102.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s103.d.ts +11 -0
- package/dist/lint/post-synth/k3s103.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s104.d.ts +12 -0
- package/dist/lint/post-synth/k3s104.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s105.d.ts +11 -0
- package/dist/lint/post-synth/k3s105.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/token-literal.d.ts +17 -0
- package/dist/lint/rules/token-literal.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 +37 -0
- package/dist/okf/index.md +9 -0
- package/dist/okf/types/Agent.md +55 -0
- package/dist/okf/types/Registries.md +14 -0
- package/dist/okf/types/Server.md +122 -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 +37 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skills/chant-k3s.md +90 -0
- package/dist/spec/fetch.d.ts +39 -0
- package/dist/spec/fetch.d.ts.map +1 -0
- package/dist/types/index.d.ts +370 -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 +68 -0
- package/src/codegen/docs.ts +25 -0
- package/src/codegen/generate-cli.ts +8 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +63 -0
- package/src/codegen/generate.test.ts +157 -0
- package/src/codegen/generate.ts +116 -0
- package/src/codegen/naming.ts +42 -0
- package/src/codegen/package.ts +28 -0
- package/src/codegen/parse.ts +386 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/index.d.ts +370 -0
- package/src/generated/index.ts +13 -0
- package/src/generated/lexicon-k3s.json +37 -0
- package/src/generated/runtime.ts +4 -0
- package/src/index.ts +11 -0
- package/src/lint/audit-catalog.ts +53 -0
- package/src/lint/post-synth/index.ts +15 -0
- package/src/lint/post-synth/k3s-helpers.ts +34 -0
- package/src/lint/post-synth/k3s101.ts +58 -0
- package/src/lint/post-synth/k3s102.ts +59 -0
- package/src/lint/post-synth/k3s103.ts +38 -0
- package/src/lint/post-synth/k3s104.ts +44 -0
- package/src/lint/post-synth/k3s105.ts +52 -0
- package/src/lint/post-synth/post-synth.test.ts +137 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/token-literal.test.ts +53 -0
- package/src/lint/rules/token-literal.ts +87 -0
- package/src/lsp/completions.test.ts +10 -0
- package/src/lsp/completions.ts +25 -0
- package/src/lsp/hover.test.ts +10 -0
- package/src/lsp/hover.ts +43 -0
- package/src/package-cli.ts +24 -0
- package/src/plugin.test.ts +17 -0
- package/src/plugin.ts +129 -0
- package/src/serializer.acceptance.test.ts +137 -0
- package/src/serializer.test.ts +172 -0
- package/src/serializer.ts +191 -0
- package/src/skills/chant-k3s.md +90 -0
- package/src/spec/fetch.ts +80 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +41 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
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 { readFileSync } from "fs";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { k3sSerializer } from "./serializer";
|
|
10
|
+
import { rules } from "./lint/rules";
|
|
11
|
+
import { postSynthChecks } from "./lint/post-synth";
|
|
12
|
+
import { k3sAuditCatalog } from "./lint/audit-catalog";
|
|
13
|
+
import { completions } from "./lsp/completions";
|
|
14
|
+
import { hover } from "./lsp/hover";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* k3s lexicon plugin.
|
|
18
|
+
*
|
|
19
|
+
* Implements all required LexiconPlugin lifecycle methods.
|
|
20
|
+
*/
|
|
21
|
+
export const k3sPlugin: LexiconPlugin = {
|
|
22
|
+
name: "k3s",
|
|
23
|
+
serializer: k3sSerializer,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the upstream pin lives and where to look for a newer one, for
|
|
27
|
+
* the self-upgrade tooling (#523). k3s releases carry a `+k3s1` build
|
|
28
|
+
* suffix; the release tag is the version constant verbatim.
|
|
29
|
+
*/
|
|
30
|
+
upstreamPin: {
|
|
31
|
+
file: "src/spec/fetch.ts",
|
|
32
|
+
pattern: /export const K3S_VERSION\s*=\s*"([^"]+)"/,
|
|
33
|
+
replace: (v: string, line: string) => line.replace(/= "[^"]+"/, `= "${v}"`),
|
|
34
|
+
upstream: { owner: "k3s-io", repo: "k3s", kind: "releases" },
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// ── Required lifecycle methods ────────────────────────────────
|
|
38
|
+
|
|
39
|
+
async generate(options?: { verbose?: boolean }): Promise<void> {
|
|
40
|
+
const { generate } = await import("./codegen/generate");
|
|
41
|
+
await generate(options);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async validate(options?: { verbose?: boolean }): Promise<void> {
|
|
45
|
+
const { validate } = await import("./validate");
|
|
46
|
+
const { printValidationResult } = await import("@intentius/chant/codegen/validate");
|
|
47
|
+
const result = await validate();
|
|
48
|
+
printValidationResult(result);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
|
|
52
|
+
// TODO: Implement coverage analysis
|
|
53
|
+
console.error("Coverage analysis not yet implemented");
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
|
|
57
|
+
const { packageLexicon } = await import("./codegen/package");
|
|
58
|
+
const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
|
|
59
|
+
const { join, dirname } = await import("path");
|
|
60
|
+
const { fileURLToPath } = await import("url");
|
|
61
|
+
|
|
62
|
+
const { spec, stats } = await packageLexicon(options);
|
|
63
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
64
|
+
writeBundleSpec(spec, join(pkgDir, "dist"));
|
|
65
|
+
|
|
66
|
+
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// ── Optional extensions ────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
lintRules() {
|
|
72
|
+
return rules;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
postSynthChecks() {
|
|
76
|
+
return postSynthChecks;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
auditCatalog() {
|
|
80
|
+
return k3sAuditCatalog;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
skills() {
|
|
84
|
+
const skills: SkillDefinition[] = [];
|
|
85
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const entries = [
|
|
87
|
+
{
|
|
88
|
+
file: "chant-k3s.md",
|
|
89
|
+
name: "chant-k3s",
|
|
90
|
+
description:
|
|
91
|
+
"Declare k3s host configuration — server/agent config.yaml and registries.yaml — as typed chant source",
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
try {
|
|
96
|
+
const content = readFileSync(join(dir, "skills", entry.file), "utf-8");
|
|
97
|
+
skills.push({ name: entry.name, description: entry.description, content });
|
|
98
|
+
} catch {
|
|
99
|
+
// skill file missing from a partial checkout — skip rather than throw
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return skills;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
mcpTools() {
|
|
106
|
+
return []; // TODO: Implement MCP tools
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
mcpResources() {
|
|
110
|
+
return []; // TODO: Implement MCP resources
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
detectTemplate(data: unknown) {
|
|
114
|
+
return false; // TODO: Detect if a template belongs to this lexicon
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
completionProvider(ctx: CompletionContext) {
|
|
118
|
+
return completions(ctx);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
hoverProvider(ctx: HoverContext) {
|
|
122
|
+
return hover(ctx);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async docs(options?) {
|
|
126
|
+
const { generateDocs } = await import("./codegen/docs");
|
|
127
|
+
return generateDocs(options);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The acceptance test is k3s, not a fixture (#1600).
|
|
3
|
+
*
|
|
4
|
+
* A fixture comparison proves the serializer matches what someone wrote
|
|
5
|
+
* down. This proves the artifact is a file the native tool consumes: build
|
|
6
|
+
* a declaration, serialize it, mount the output into the pinned rancher/k3s
|
|
7
|
+
* image, and assert `k3s server --config` comes up far enough to accept it
|
|
8
|
+
* — the apiserver answering on the containerized cluster is the pass.
|
|
9
|
+
*
|
|
10
|
+
* A config k3s rejects must fail: the negative case feeds a config with an
|
|
11
|
+
* unknown key and asserts the process exits with the flag-parse error
|
|
12
|
+
* rather than starting.
|
|
13
|
+
*
|
|
14
|
+
* Skipped, with the reason visible in the runner, when Docker is absent —
|
|
15
|
+
* CI may not have it. On-demand, like the gitlab/forgejo runtime E2Es.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
23
|
+
import { k3sSerializer } from "./serializer";
|
|
24
|
+
import { Server } from "./generated/index";
|
|
25
|
+
import { K3S_IMAGE_TAG } from "./spec/fetch";
|
|
26
|
+
|
|
27
|
+
function available(cmd: string): boolean {
|
|
28
|
+
try {
|
|
29
|
+
execSync(cmd, { stdio: "ignore" });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasDocker = available("docker info");
|
|
37
|
+
const skipReason = hasDocker ? "" : "Docker is not running";
|
|
38
|
+
|
|
39
|
+
const IMAGE = `rancher/k3s:${K3S_IMAGE_TAG}`;
|
|
40
|
+
const CONTAINER = `chant-k3s-accept-${process.pid}`;
|
|
41
|
+
|
|
42
|
+
function emit(entities: Map<string, unknown>): string {
|
|
43
|
+
const result = k3sSerializer.serialize(entities as never);
|
|
44
|
+
return typeof result === "string" ? result : result.primary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe.skipIf(!hasDocker)(
|
|
48
|
+
`k3s accepts the emitted config${skipReason ? ` (skipped: ${skipReason})` : ""}`,
|
|
49
|
+
() => {
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try {
|
|
52
|
+
execSync(`docker rm -f ${CONTAINER}`, { stdio: "ignore" });
|
|
53
|
+
} catch {
|
|
54
|
+
// already gone
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it(
|
|
59
|
+
"boots a server from serializer output until the apiserver answers",
|
|
60
|
+
{ timeout: 180_000 },
|
|
61
|
+
() => {
|
|
62
|
+
const yaml = emit(
|
|
63
|
+
new Map([
|
|
64
|
+
[
|
|
65
|
+
"acceptance",
|
|
66
|
+
new Server({
|
|
67
|
+
"write-kubeconfig-mode": "0644",
|
|
68
|
+
disable: ["traefik", "metrics-server"],
|
|
69
|
+
"node-label": ["chant-acceptance=true"],
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-k3s-accept-"));
|
|
76
|
+
const configPath = join(dir, "config.yaml");
|
|
77
|
+
writeFileSync(configPath, yaml);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
execSync(
|
|
81
|
+
`docker run -d --privileged --name ${CONTAINER} ` +
|
|
82
|
+
`-v ${configPath}:/etc/rancher/k3s/config.yaml ` +
|
|
83
|
+
`${IMAGE} server`,
|
|
84
|
+
{ stdio: "ignore" },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Ready when the node registers with the label the config declared.
|
|
88
|
+
const deadline = Date.now() + 150_000;
|
|
89
|
+
let ready = false;
|
|
90
|
+
let lastOut = "";
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const probe = spawnSync(
|
|
93
|
+
"docker",
|
|
94
|
+
["exec", CONTAINER, "kubectl", "get", "nodes", "-l", "chant-acceptance=true", "--no-headers"],
|
|
95
|
+
{ encoding: "utf-8" },
|
|
96
|
+
);
|
|
97
|
+
lastOut = probe.stdout ?? "";
|
|
98
|
+
if (probe.status === 0 && /\sReady\s/.test(lastOut)) {
|
|
99
|
+
ready = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
execSync("sleep 3");
|
|
103
|
+
}
|
|
104
|
+
expect(ready, `node never became Ready; last output: ${lastOut}`).toBe(true);
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it("fails fast on a config k3s rejects", { timeout: 60_000 }, () => {
|
|
112
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-k3s-reject-"));
|
|
113
|
+
const configPath = join(dir, "config.yaml");
|
|
114
|
+
writeFileSync(configPath, "not-a-real-k3s-flag: true\n");
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const run = spawnSync(
|
|
118
|
+
"docker",
|
|
119
|
+
[
|
|
120
|
+
"run",
|
|
121
|
+
"--rm",
|
|
122
|
+
"--privileged",
|
|
123
|
+
"-v",
|
|
124
|
+
`${configPath}:/etc/rancher/k3s/config.yaml`,
|
|
125
|
+
IMAGE,
|
|
126
|
+
"server",
|
|
127
|
+
],
|
|
128
|
+
{ encoding: "utf-8", timeout: 45_000 },
|
|
129
|
+
);
|
|
130
|
+
expect(run.status).not.toBe(0);
|
|
131
|
+
expect(`${run.stdout}\n${run.stderr}`).toMatch(/not-a-real-k3s-flag|flag provided|unknown flag|invalid/i);
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { load } from "js-yaml";
|
|
3
|
+
import { k3sSerializer } from "./serializer";
|
|
4
|
+
import { Agent, Mirror, Registries, RegistryAuth, RegistryConfig, RegistryTLS, Server } from "./generated/index";
|
|
5
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
6
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
7
|
+
|
|
8
|
+
function serializeOne(entities: Record<string, Declarable>): {
|
|
9
|
+
text: string;
|
|
10
|
+
doc: Record<string, unknown>;
|
|
11
|
+
} {
|
|
12
|
+
const result = k3sSerializer.serialize(new Map(Object.entries(entities)));
|
|
13
|
+
const text = typeof result === "string" ? result : result.primary;
|
|
14
|
+
return { text, doc: load(text) as Record<string, unknown> };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("k3s serializer", () => {
|
|
18
|
+
it("has the k3s name and K3S rule prefix", () => {
|
|
19
|
+
expect(k3sSerializer.name).toBe("k3s");
|
|
20
|
+
expect(k3sSerializer.rulePrefix).toBe("K3S");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("serializes an empty map to an empty string", () => {
|
|
24
|
+
expect(k3sSerializer.serialize(new Map())).toBe("");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("emits a server config k3s consumes verbatim — flag names as keys", () => {
|
|
28
|
+
const { doc } = serializeOne({
|
|
29
|
+
server: new Server({
|
|
30
|
+
"cluster-init": true,
|
|
31
|
+
"tls-san": ["10.0.0.10", "cp.example.com"],
|
|
32
|
+
"disable": ["traefik"],
|
|
33
|
+
"write-kubeconfig-mode": "0644",
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
expect(doc["cluster-init"]).toBe(true);
|
|
37
|
+
expect(doc["tls-san"]).toEqual(["10.0.0.10", "cp.example.com"]);
|
|
38
|
+
expect(doc.disable).toEqual(["traefik"]);
|
|
39
|
+
expect(doc["write-kubeconfig-mode"]).toBe("0644");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("leads with the identity-and-join block, then alphabetical", () => {
|
|
43
|
+
const { text } = serializeOne({
|
|
44
|
+
agent: new Agent({
|
|
45
|
+
"node-label": ["tier=edge"],
|
|
46
|
+
server: "https://cp.example.com:6443",
|
|
47
|
+
"token-file": "/etc/rancher/k3s/token",
|
|
48
|
+
"data-dir": "/var/lib/rancher/k3s",
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
const keys = text
|
|
52
|
+
.split("\n")
|
|
53
|
+
.filter((l) => /^[a-z]/.test(l))
|
|
54
|
+
.map((l) => l.split(":")[0]);
|
|
55
|
+
expect(keys).toEqual(["server", "token-file", "data-dir", "node-label"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps booleans and zero — 0 is a value, not an absence", () => {
|
|
59
|
+
const { doc } = serializeOne({
|
|
60
|
+
server: new Server({ "disable-agent": false, "lb-server-port": 0 }),
|
|
61
|
+
});
|
|
62
|
+
expect(doc["disable-agent"]).toBe(false);
|
|
63
|
+
expect(doc["lb-server-port"]).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("accepts a single string where the flag takes a list", () => {
|
|
67
|
+
const { doc } = serializeOne({
|
|
68
|
+
server: new Server({ "tls-san": "cp.example.com" }),
|
|
69
|
+
});
|
|
70
|
+
expect(doc["tls-san"]).toBe("cp.example.com");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("emits one document per config: first primary, the rest in files", () => {
|
|
74
|
+
const result = k3sSerializer.serialize(
|
|
75
|
+
new Map<string, Declarable>([
|
|
76
|
+
["cp", new Server({ "cluster-init": true })],
|
|
77
|
+
["worker", new Agent({ server: "https://cp:6443" })],
|
|
78
|
+
]),
|
|
79
|
+
) as SerializerResult;
|
|
80
|
+
expect(typeof result).toBe("object");
|
|
81
|
+
expect(result.primary).toContain("cluster-init: true");
|
|
82
|
+
expect(Object.keys(result.files ?? {})).toEqual(["worker.config.yaml"]);
|
|
83
|
+
expect(result.files?.["worker.config.yaml"]).toContain("server:");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("emits registries.yaml beside a config, and as primary when alone", () => {
|
|
87
|
+
const registries = new Registries({
|
|
88
|
+
mirrors: { "docker.io": new Mirror({ endpoint: ["https://mirror.example.com"] }) },
|
|
89
|
+
});
|
|
90
|
+
const beside = k3sSerializer.serialize(
|
|
91
|
+
new Map<string, Declarable>([
|
|
92
|
+
["cp", new Server({ "cluster-init": true })],
|
|
93
|
+
["registries", registries],
|
|
94
|
+
]),
|
|
95
|
+
) as SerializerResult;
|
|
96
|
+
expect(Object.keys(beside.files ?? {})).toEqual(["registries.yaml"]);
|
|
97
|
+
const regDoc = load(beside.files!["registries.yaml"]) as Record<string, unknown>;
|
|
98
|
+
expect((regDoc.mirrors as Record<string, unknown>)["docker.io"]).toEqual({
|
|
99
|
+
endpoint: ["https://mirror.example.com"],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const { doc } = serializeOne({ registries });
|
|
103
|
+
expect(doc.mirrors).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("walks nested property entities into plain maps", () => {
|
|
107
|
+
const { doc } = serializeOne({
|
|
108
|
+
registries: new Registries({
|
|
109
|
+
configs: {
|
|
110
|
+
"registry.example.com": new RegistryConfig({
|
|
111
|
+
auth: new RegistryAuth({ username: "puller" }),
|
|
112
|
+
tls: new RegistryTLS({ ca_file: "/etc/ssl/registry-ca.pem" }),
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const config = (doc.configs as Record<string, Record<string, unknown>>)["registry.example.com"];
|
|
118
|
+
expect(config.auth).toEqual({ username: "puller" });
|
|
119
|
+
expect(config.tls).toEqual({ ca_file: "/etc/ssl/registry-ca.pem" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips property entities as standalone documents", () => {
|
|
123
|
+
const result = k3sSerializer.serialize(
|
|
124
|
+
new Map<string, Declarable>([
|
|
125
|
+
["cp", new Server({ "cluster-init": true })],
|
|
126
|
+
["stray", new RegistryAuth({ username: "puller" }) as unknown as Declarable],
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
const text = typeof result === "string" ? result : (result as SerializerResult).primary;
|
|
130
|
+
expect(text).not.toContain("username");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("stamps ownership into node-label when the build carries a marker", () => {
|
|
134
|
+
const result = k3sSerializer.serialize(
|
|
135
|
+
new Map<string, Declarable>([["cp", new Server({ "cluster-init": true })]]),
|
|
136
|
+
undefined,
|
|
137
|
+
{ ownership: { stack: "fountain", env: "local" } },
|
|
138
|
+
);
|
|
139
|
+
const text = typeof result === "string" ? result : (result as SerializerResult).primary;
|
|
140
|
+
const doc = load(text) as Record<string, unknown>;
|
|
141
|
+
expect(doc["node-label"]).toEqual(
|
|
142
|
+
expect.arrayContaining([
|
|
143
|
+
"app.kubernetes.io/managed-by=chant",
|
|
144
|
+
"chant.intentius.io/stack=fountain",
|
|
145
|
+
"chant.intentius.io/env=local",
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("never overwrites an author's own label with the marker", () => {
|
|
151
|
+
const result = k3sSerializer.serialize(
|
|
152
|
+
new Map<string, Declarable>([
|
|
153
|
+
["cp", new Server({ "node-label": ["chant.intentius.io/stack=mine"] })],
|
|
154
|
+
]),
|
|
155
|
+
undefined,
|
|
156
|
+
{ ownership: { stack: "fountain", env: "local" } },
|
|
157
|
+
);
|
|
158
|
+
const text = typeof result === "string" ? result : (result as SerializerResult).primary;
|
|
159
|
+
const doc = load(text) as Record<string, unknown>;
|
|
160
|
+
const stackLabels = (doc["node-label"] as string[]).filter((l) =>
|
|
161
|
+
l.startsWith("chant.intentius.io/stack="),
|
|
162
|
+
);
|
|
163
|
+
expect(stackLabels).toEqual(["chant.intentius.io/stack=mine"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("quotes scalars YAML would otherwise mangle", () => {
|
|
167
|
+
const { doc } = serializeOne({
|
|
168
|
+
agent: new Agent({ server: "https://cp.example.com:6443" }),
|
|
169
|
+
});
|
|
170
|
+
expect(doc.server).toBe("https://cp.example.com:6443");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k3s config YAML serializer.
|
|
3
|
+
*
|
|
4
|
+
* Emits the files k3s consumes verbatim — the walk-away artifacts that
|
|
5
|
+
* justify a lexicon here at all (#1600):
|
|
6
|
+
*
|
|
7
|
+
* K3s::Server / K3s::Agent → config.yaml (`k3s server --config <file>`)
|
|
8
|
+
* K3s::Registries → registries.yaml
|
|
9
|
+
*
|
|
10
|
+
* One document per declared entity: the first config is the primary
|
|
11
|
+
* output, every further entity lands in files[] keyed `<name>.config.yaml`
|
|
12
|
+
* / `<name>.registries.yaml`. A config.yaml's keys are exactly the CLI
|
|
13
|
+
* flag names, so the emitted file diffs cleanly against any hand-written
|
|
14
|
+
* one and `k3s check-config` style tooling sees nothing unusual.
|
|
15
|
+
*
|
|
16
|
+
* ## Ownership rides node-label (#1603)
|
|
17
|
+
*
|
|
18
|
+
* config.yaml has no free-form metadata channel, but `node-label` is a
|
|
19
|
+
* first-class key on both server and agent and lands on the registered
|
|
20
|
+
* Node as ordinary Kubernetes labels. When a build carries ownership,
|
|
21
|
+
* the serializer appends the chant label pair to `node-label` — readable
|
|
22
|
+
* back through any kubectl against the cluster, and inert to k3s itself.
|
|
23
|
+
* An author's own label with the same key wins.
|
|
24
|
+
*
|
|
25
|
+
* ## What never appears
|
|
26
|
+
*
|
|
27
|
+
* `token` / `agent-token` literals are not part of the typed surface
|
|
28
|
+
* (#1601), and K3SC001 fails the build if one arrives through untyped
|
|
29
|
+
* props anyway. The file k3s reads secrets from is the file's business
|
|
30
|
+
* (`token-file`), not the artifact's.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
34
|
+
import { isResourceDeclarable } from "@intentius/chant/declarable";
|
|
35
|
+
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
36
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
37
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
38
|
+
import { emitYAML } from "@intentius/chant/yaml";
|
|
39
|
+
import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
40
|
+
|
|
41
|
+
export const SERVER_TYPE = "K3s::Server";
|
|
42
|
+
export const AGENT_TYPE = "K3s::Agent";
|
|
43
|
+
export const REGISTRIES_TYPE = "K3s::Registries";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Keys that lead the document; everything else follows alphabetically.
|
|
47
|
+
* The identity-and-join block first is how the documented examples read,
|
|
48
|
+
* so a chant emission diffs cleanly against them.
|
|
49
|
+
*/
|
|
50
|
+
const KEY_PRIORITY = [
|
|
51
|
+
"cluster-init",
|
|
52
|
+
"server",
|
|
53
|
+
"token-file",
|
|
54
|
+
"agent-token-file",
|
|
55
|
+
"data-dir",
|
|
56
|
+
"node-name",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const visitor: SerializerVisitor = {
|
|
60
|
+
attrRef: (name) => name,
|
|
61
|
+
resourceRef: (name) => name,
|
|
62
|
+
propertyDeclarable: (entity, walk) => {
|
|
63
|
+
if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const props = entity.props as Record<string, unknown>;
|
|
67
|
+
const result: Record<string, unknown> = {};
|
|
68
|
+
for (const [key, value] of Object.entries(props)) {
|
|
69
|
+
if (value !== undefined) result[key] = walk(value);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function orderedKeys(props: Record<string, unknown>): string[] {
|
|
76
|
+
const rest = Object.keys(props)
|
|
77
|
+
.filter((k) => !KEY_PRIORITY.includes(k))
|
|
78
|
+
.sort();
|
|
79
|
+
return [...KEY_PRIORITY.filter((k) => props[k] !== undefined), ...rest];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Append the ownership marker to `node-label`. The key survives into the
|
|
84
|
+
* registered Node's labels, which is the only durable channel a host
|
|
85
|
+
* config file has. An author-set key of the same name wins.
|
|
86
|
+
*/
|
|
87
|
+
function stampOwnership(
|
|
88
|
+
props: Record<string, unknown>,
|
|
89
|
+
ownership: NonNullable<SerializeContext["ownership"]>,
|
|
90
|
+
): void {
|
|
91
|
+
const existing = props["node-label"];
|
|
92
|
+
const labels: string[] =
|
|
93
|
+
existing === undefined ? [] : Array.isArray(existing) ? [...(existing as string[])] : [String(existing)];
|
|
94
|
+
const present = new Set(labels.map((l) => String(l).split("=")[0]));
|
|
95
|
+
for (const [key, value] of Object.entries(ownershipEntries(LABEL_OWNERSHIP_KEYS, ownership))) {
|
|
96
|
+
if (present.has(key)) continue;
|
|
97
|
+
labels.push(`${key}=${value}`);
|
|
98
|
+
}
|
|
99
|
+
props["node-label"] = labels;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function configDocument(
|
|
103
|
+
entity: Declarable,
|
|
104
|
+
entityNames: Map<Declarable, string>,
|
|
105
|
+
context?: SerializeContext,
|
|
106
|
+
): Record<string, unknown> {
|
|
107
|
+
const raw = (isResourceDeclarable(entity) ? entity.props : {}) as Record<string, unknown>;
|
|
108
|
+
const props = (walkValue(raw, entityNames, visitor) ?? {}) as Record<string, unknown>;
|
|
109
|
+
if (context?.ownership) stampOwnership(props, context.ownership);
|
|
110
|
+
|
|
111
|
+
const doc: Record<string, unknown> = {};
|
|
112
|
+
for (const key of orderedKeys(props)) {
|
|
113
|
+
if (props[key] !== undefined) doc[key] = props[key];
|
|
114
|
+
}
|
|
115
|
+
return doc;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function registriesDocument(
|
|
119
|
+
entity: Declarable,
|
|
120
|
+
entityNames: Map<Declarable, string>,
|
|
121
|
+
): Record<string, unknown> {
|
|
122
|
+
const raw = (isResourceDeclarable(entity) ? entity.props : {}) as Record<string, unknown>;
|
|
123
|
+
const props = (walkValue(raw, entityNames, visitor) ?? {}) as Record<string, unknown>;
|
|
124
|
+
const doc: Record<string, unknown> = {};
|
|
125
|
+
// mirrors before configs, matching the documented examples.
|
|
126
|
+
if (props.mirrors !== undefined) doc.mirrors = props.mirrors;
|
|
127
|
+
if (props.configs !== undefined) doc.configs = props.configs;
|
|
128
|
+
for (const [key, value] of Object.entries(props)) {
|
|
129
|
+
if (key === "mirrors" || key === "configs" || value === undefined) continue;
|
|
130
|
+
doc[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return doc;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function emitDocument(doc: Record<string, unknown>): string {
|
|
136
|
+
const sections: string[] = [];
|
|
137
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
138
|
+
if (value === undefined) continue;
|
|
139
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
140
|
+
sections.push(`${key}: ${scalar(value)}`);
|
|
141
|
+
} else {
|
|
142
|
+
sections.push(`${key}:` + emitYAML(value, 1));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return sections.join("\n") + "\n";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scalar(value: string | number | boolean): string {
|
|
149
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
150
|
+
if (typeof value === "number") return String(value);
|
|
151
|
+
if (/[:#\[\]{}|>&*!%@`'"]|^[\s\d]|\s$|^$|^(true|false|null|yes|no|on|off)$/i.test(value)) {
|
|
152
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const k3sSerializer: Serializer = {
|
|
158
|
+
name: "k3s",
|
|
159
|
+
rulePrefix: "K3S",
|
|
160
|
+
|
|
161
|
+
serialize(
|
|
162
|
+
entities: Map<string, Declarable>,
|
|
163
|
+
_outputs?: LexiconOutput[],
|
|
164
|
+
context?: SerializeContext,
|
|
165
|
+
): string | SerializerResult {
|
|
166
|
+
const entityNames = new Map<Declarable, string>();
|
|
167
|
+
for (const [name, entity] of entities) entityNames.set(entity, name);
|
|
168
|
+
|
|
169
|
+
const configs = [...entities].filter(
|
|
170
|
+
([, e]) => e.entityType === SERVER_TYPE || e.entityType === AGENT_TYPE,
|
|
171
|
+
);
|
|
172
|
+
const registries = [...entities].filter(([, e]) => e.entityType === REGISTRIES_TYPE);
|
|
173
|
+
|
|
174
|
+
let primary = "";
|
|
175
|
+
const files: Record<string, string> = {};
|
|
176
|
+
|
|
177
|
+
configs.forEach(([name, entity], i) => {
|
|
178
|
+
const content = emitDocument(configDocument(entity, entityNames, context));
|
|
179
|
+
if (i === 0) primary = content;
|
|
180
|
+
else files[`${name}.config.yaml`] = content;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
registries.forEach(([name, entity], i) => {
|
|
184
|
+
const content = emitDocument(registriesDocument(entity, entityNames));
|
|
185
|
+
if (primary === "" && i === 0 && configs.length === 0) primary = content;
|
|
186
|
+
else files[i === 0 && configs.length > 0 ? "registries.yaml" : `${name}.registries.yaml`] = content;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return Object.keys(files).length > 0 ? { primary, files } : primary;
|
|
190
|
+
},
|
|
191
|
+
};
|