@intentius/chant-lexicon-k3s 0.124.31 → 0.124.33

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 (45) hide show
  1. package/dist/codegen/docs-cli.js +3 -0
  2. package/dist/codegen/docs.js +83 -0
  3. package/dist/codegen/generate-cli.js +7 -0
  4. package/dist/codegen/generate-lexicon.js +39 -0
  5. package/dist/codegen/generate-typescript.js +38 -0
  6. package/dist/codegen/generate.js +93 -0
  7. package/dist/codegen/naming.js +34 -0
  8. package/dist/codegen/package.js +23 -0
  9. package/dist/codegen/parse.js +313 -0
  10. package/dist/describe-resources.js +200 -0
  11. package/dist/generated/index.js +10 -0
  12. package/dist/generated/runtime.js +4 -0
  13. package/dist/index.js +14 -0
  14. package/dist/lint/audit-catalog.js +16 -0
  15. package/dist/lint/audit-lineage.js +5 -0
  16. package/dist/lint/post-synth/index.js +16 -0
  17. package/dist/lint/post-synth/k3s-helpers.js +24 -0
  18. package/dist/lint/post-synth/k3s101.js +48 -0
  19. package/dist/lint/post-synth/k3s102.js +52 -0
  20. package/dist/lint/post-synth/k3s103.js +30 -0
  21. package/dist/lint/post-synth/k3s104.js +37 -0
  22. package/dist/lint/post-synth/k3s105.js +44 -0
  23. package/dist/lint/post-synth/k3s106.js +47 -0
  24. package/dist/lint/post-synth/k3s107.js +58 -0
  25. package/dist/lint/rules/index.js +4 -0
  26. package/dist/lint/rules/token-literal.js +78 -0
  27. package/dist/lsp/completions.d.ts.map +1 -1
  28. package/dist/lsp/completions.js +19 -0
  29. package/dist/lsp/hover.d.ts.map +1 -1
  30. package/dist/lsp/hover.js +33 -0
  31. package/dist/op/activities/index.js +21 -0
  32. package/dist/op/activities/k3s.js +112 -0
  33. package/dist/op/builders.js +31 -0
  34. package/dist/package-cli.js +19 -0
  35. package/dist/plugin.d.ts.map +1 -1
  36. package/dist/plugin.js +119 -0
  37. package/dist/serializer.js +169 -0
  38. package/dist/spec/fetch.js +72 -0
  39. package/dist/validate-cli.js +4 -0
  40. package/dist/validate.js +35 -0
  41. package/package.json +7 -7
  42. package/src/codegen/package.ts +2 -2
  43. package/src/lsp/completions.ts +2 -1
  44. package/src/lsp/hover.ts +2 -1
  45. package/src/plugin.ts +2 -1
@@ -0,0 +1,78 @@
1
+ import { ts } from "@intentius/chant/lint/typescript";
2
+ /**
3
+ * K3S001: literal join token in source.
4
+ *
5
+ * `token` / `agent-token` are the cluster's shared join secret, and a
6
+ * committed config declaration is exactly the file that leaks it. The
7
+ * typed surface deliberately carries only the reference forms
8
+ * (`token-file`, `agent-token-file` — #1601), but nothing stops a raw
9
+ * object literal from writing the key anyway; this rule does.
10
+ *
11
+ * Flags a non-empty string-literal `token` / `agent-token` (identifier or
12
+ * quoted) inside a `Server(...)` / `Agent(...)` construction, with or
13
+ * without `new`. A bare `token:` elsewhere in the file is someone else's
14
+ * business.
15
+ */
16
+ export const tokenLiteralRule = {
17
+ id: "K3S001",
18
+ severity: "error",
19
+ category: "security",
20
+ description: "k3s join token declared as a string literal in source",
21
+ check(context) {
22
+ const diagnostics = [];
23
+ const source = context.sourceFile;
24
+ const flag = (node, key) => {
25
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
26
+ diagnostics.push({
27
+ ruleId: "K3S001",
28
+ severity: "error",
29
+ message: `\`${key}\` is a literal in source — a committed k3s config is a leaked cluster join ` +
30
+ "secret. Point `token-file` at a path on the host, or supply K3S_TOKEN_FILE at " +
31
+ "install time; the value itself never belongs in the declaration.",
32
+ file: context.filePath,
33
+ line: line + 1,
34
+ column: character + 1,
35
+ });
36
+ };
37
+ const tokenLiteral = (obj) => {
38
+ for (const prop of obj.properties) {
39
+ if (!ts.isPropertyAssignment(prop))
40
+ continue;
41
+ const name = ts.isIdentifier(prop.name)
42
+ ? prop.name.text
43
+ : ts.isStringLiteral(prop.name)
44
+ ? prop.name.text
45
+ : undefined;
46
+ if (name !== "token" && name !== "agent-token")
47
+ continue;
48
+ if ((ts.isStringLiteral(prop.initializer) ||
49
+ ts.isNoSubstitutionTemplateLiteral(prop.initializer)) &&
50
+ prop.initializer.text.length > 0) {
51
+ return { node: prop.initializer, key: name };
52
+ }
53
+ }
54
+ return undefined;
55
+ };
56
+ const visit = (node) => {
57
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
58
+ const callee = node.expression;
59
+ const name = ts.isIdentifier(callee)
60
+ ? callee.text
61
+ : ts.isPropertyAccessExpression(callee)
62
+ ? callee.name.text
63
+ : undefined;
64
+ if (name === "Server" || name === "Agent") {
65
+ const arg = node.arguments?.[0];
66
+ if (arg && ts.isObjectLiteralExpression(arg)) {
67
+ const literal = tokenLiteral(arg);
68
+ if (literal)
69
+ flag(literal.node, literal.key);
70
+ }
71
+ }
72
+ }
73
+ ts.forEachChild(node, visit);
74
+ };
75
+ visit(source);
76
+ return diagnostics;
77
+ },
78
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/lsp/completions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAiBpF;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,cAAc,EAAE,CAEpE"}
1
+ {"version":3,"file":"completions.d.ts","sourceRoot":"","sources":["../../src/lsp/completions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAiBpF;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,iBAAiB,GAAG,cAAc,EAAE,CAEpE"}
@@ -0,0 +1,19 @@
1
+ import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
3
+ import { LexiconIndex, lexiconCompletions, } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
+ let cachedIndex = null;
6
+ function getIndex() {
7
+ if (cachedIndex)
8
+ return cachedIndex;
9
+ const data = require("../generated/lexicon-k3s.json");
10
+ cachedIndex = new LexiconIndex(data);
11
+ return cachedIndex;
12
+ }
13
+ /**
14
+ * Provide k3s completions based on context — Server, Agent, Registries and
15
+ * the registry property entities, from the generated registry.
16
+ */
17
+ export function completions(ctx) {
18
+ return lexiconCompletions(ctx, getIndex(), "k3s entity");
19
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"hover.d.ts","sourceRoot":"","sources":["../../src/lsp/hover.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAiB1E;;GAEG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAE9D"}
1
+ {"version":3,"file":"hover.d.ts","sourceRoot":"","sources":["../../src/lsp/hover.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAiB1E;;GAEG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAE9D"}
@@ -0,0 +1,33 @@
1
+ import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
3
+ import { LexiconIndex, lexiconHover, } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
+ let cachedIndex = null;
6
+ function getIndex() {
7
+ if (cachedIndex)
8
+ return cachedIndex;
9
+ const data = require("../generated/lexicon-k3s.json");
10
+ cachedIndex = new LexiconIndex(data);
11
+ return cachedIndex;
12
+ }
13
+ /**
14
+ * Provide hover information for k3s entity types.
15
+ */
16
+ export function hover(ctx) {
17
+ return lexiconHover(ctx, getIndex(), resourceHover);
18
+ }
19
+ function resourceHover(className, entry) {
20
+ const lines = [];
21
+ lines.push(`**${className}**`);
22
+ lines.push("");
23
+ lines.push(`k3s type: \`${entry.resourceType}\``);
24
+ lines.push("");
25
+ if (entry.kind === "resource") {
26
+ lines.push("*Resource entity — serialized as a file k3s consumes verbatim: " +
27
+ "config.yaml for Server/Agent, registries.yaml for Registries*");
28
+ }
29
+ else {
30
+ lines.push("*Property entity — a nested value in a Registries declaration*");
31
+ }
32
+ return { contents: lines.join("\n") };
33
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * k3s Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `k3s` lexicon. Lifecycle activities for the
4
+ * reachable-host case (chant#1601, epic #1598):
5
+ * - k3sInstall — run the pinned installer (`INSTALL_K3S_VERSION`), idempotent
6
+ * on an already-installed matching version.
7
+ * - k3sUninstall — the uninstall script, gated the way k3dDown is.
8
+ *
9
+ * Bounded exactly as k3dUp/k3dDown were (chant#1410): no SSH orchestration,
10
+ * no host provisioning. The join token never travels through these activities
11
+ * as a value — only `tokenFile`, a path — see the token-boundary note on
12
+ * {@link k3sInstall} in ./k3s.
13
+ *
14
+ * The step builders (k3sInstall, k3sUninstall) live in core and reach authors
15
+ * through `@intentius/chant/op` like k3dUp/k3dDown. The activities here are
16
+ * dependency-light — they shell out to the k3s installer/uninstall scripts and
17
+ * only pull in the lexicon's version pin, not its declarable surface — so
18
+ * `loadActivities` (`packages/core/src/op/activity-registry.ts`), which imports
19
+ * this module at run time, pulls in nothing expensive.
20
+ */
21
+ export { k3sInstall, k3sUninstall, k3sInstallCommand, k3sInstallEnv, k3sUninstallCommand, k3sUninstallScript, k3sVersionCommand, parseK3sVersion, } from "./k3s.js";
@@ -0,0 +1,112 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { K3S_VERSION } from "../../spec/fetch.js";
4
+ const execAsync = promisify(exec);
5
+ /**
6
+ * `k3s --version` output, first line: `k3s version v1.36.3+k3s1 (hash)`.
7
+ * Returns `undefined` when k3s is not installed or the output doesn't match.
8
+ */
9
+ export function parseK3sVersion(stdout) {
10
+ return stdout.match(/k3s version (\S+)/)?.[1];
11
+ }
12
+ /** Check-install-version command — errors (non-zero exit) when k3s is absent. */
13
+ export function k3sVersionCommand() {
14
+ return "k3s --version";
15
+ }
16
+ /**
17
+ * Build the get.k3s.io installer command. The version pin and the join-token
18
+ * reference (if any) travel as environment variables via {@link k3sInstallEnv},
19
+ * not interpolated into this string — see the token-boundary note on
20
+ * {@link k3sInstall}.
21
+ */
22
+ export function k3sInstallCommand(args) {
23
+ return `curl -sfL https://get.k3s.io | sh -s - ${args.role} --config ${args.configFile}`;
24
+ }
25
+ /**
26
+ * Environment for {@link k3sInstallCommand}: `INSTALL_K3S_VERSION` (the pin)
27
+ * and, only when a `tokenFile` path is given, `K3S_TOKEN_FILE`.
28
+ *
29
+ * ## The token boundary (#1601)
30
+ *
31
+ * This is the only token-shaped input the install activity accepts, and it
32
+ * is a path, not a secret — the join token itself never passes through
33
+ * activity args, never gets logged, and never lands in an emitted
34
+ * config.yaml (K3S001 rejects a literal `token`/`agent-token` at lint). A
35
+ * `K3S_TOKEN` (the literal, env-var form k3s itself supports for agents) is
36
+ * deliberately not modeled here: the reference form the lexicon carries
37
+ * end-to-end is a file path, matching `token-file`/`agent-token-file` on the
38
+ * declared config surface (chant#1365's provenance stance applied at this
39
+ * surface, not a new mechanism).
40
+ */
41
+ export function k3sInstallEnv(args) {
42
+ const env = { INSTALL_K3S_VERSION: args.version ?? K3S_VERSION };
43
+ if (args.tokenFile)
44
+ env.K3S_TOKEN_FILE = args.tokenFile;
45
+ return env;
46
+ }
47
+ /** Path k3s installs its uninstall script at — one per role. */
48
+ export function k3sUninstallScript(role) {
49
+ return role === "agent" ? "/usr/local/bin/k3s-agent-uninstall.sh" : "/usr/local/bin/k3s-uninstall.sh";
50
+ }
51
+ /**
52
+ * Build the uninstall command. Gated the way `k3dDown` is (chant#1410): no
53
+ * pre-check activity, no SSH orchestration — just the native idempotent
54
+ * behavior. Unlike `k3d cluster delete` (idempotent on its own), the k3s
55
+ * uninstall script does not exist at all on a host where k3s was never
56
+ * installed, so the command guards on the script's presence itself rather
57
+ * than assume it errors safely.
58
+ */
59
+ export function k3sUninstallCommand(args) {
60
+ const script = k3sUninstallScript(args.role);
61
+ return `test -x ${script} && ${script} || echo "k3s ${args.role} already uninstalled"`;
62
+ }
63
+ /**
64
+ * Run the pinned k3s installer against a reachable host. Idempotent on an
65
+ * already-installed matching version: if `k3s --version` already reports the
66
+ * target version, the install is skipped. Uses the longInfra profile: a 20m
67
+ * timeout, wide enough for the installer to download and start the k3s binary,
68
+ * and three attempts backing off from 30s.
69
+ *
70
+ * Bounded exactly as `k3dUp`/`k3dDown` were (chant#1410, epic #1598): this
71
+ * drives the case where the host is reachable from where the Op runs. It
72
+ * does not provision the host, and it is not an SSH orchestrator.
73
+ */
74
+ export async function k3sInstall(args, signal) {
75
+ const target = args.version ?? K3S_VERSION;
76
+ try {
77
+ const { stdout } = await execAsync(k3sVersionCommand(), { signal });
78
+ const installed = parseK3sVersion(stdout);
79
+ if (installed === target) {
80
+ console.log(`k3s ${target} already installed (${args.role}) — skipping install`);
81
+ return { version: target, installed: false };
82
+ }
83
+ if (installed) {
84
+ console.log(`k3s ${installed} installed, target is ${target} — reinstalling`);
85
+ }
86
+ }
87
+ catch {
88
+ // `k3s --version` errors when k3s is absent — fall through to install.
89
+ }
90
+ const { stdout, stderr } = await execAsync(k3sInstallCommand(args), {
91
+ signal,
92
+ env: { ...process.env, ...k3sInstallEnv(args) },
93
+ });
94
+ if (stdout)
95
+ console.log(stdout);
96
+ if (stderr)
97
+ console.error(stderr);
98
+ return { version: target, installed: true };
99
+ }
100
+ /**
101
+ * Uninstall k3s from a reachable host. Uses fastIdempotent profile — 5m
102
+ * timeout. A host where k3s was never installed (no uninstall script present)
103
+ * is a no-op success, the same idempotent shape as `k3dDown` against an
104
+ * already-gone cluster.
105
+ */
106
+ export async function k3sUninstall(args, signal) {
107
+ const { stdout, stderr } = await execAsync(k3sUninstallCommand(args), { signal });
108
+ if (stdout)
109
+ console.log(stdout);
110
+ if (stderr)
111
+ console.error(stderr);
112
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Typed step-builder wrappers for this lexicon's activities (chant #1288
3
+ * Stage 2). See `lexicons/k8s/src/op/builders.ts`'s module doc for why these
4
+ * live beside their `*Args` interfaces rather than in core: core cannot
5
+ * import a lexicon's types, so a wrapper whose `opts` IS the activity's own
6
+ * `*Args` interface can only sit in the lexicon that declares that
7
+ * interface. `opts`'s type in each wrapper below IS the activity's own
8
+ * `*Args` interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own
9
+ * `k3sInstall`/`k3sUninstall` are unchanged and produce byte-identical
10
+ * `ActivityStep` output; these are purely additive.
11
+ */
12
+ import { activity, takeProfileAndId, } from "@intentius/chant/op";
13
+ /**
14
+ * Run the pinned k3s installer against a reachable host — the fully typed
15
+ * twin of core's `k3sInstall`. `opts` is {@link K3sInstallArgs} itself,
16
+ * minus the positional `role` (`configFile` remains required). Defaults to
17
+ * the `longInfra` profile.
18
+ */
19
+ export const k3sInstall = (role, opts) => {
20
+ const { args, profile, id } = takeProfileAndId(opts);
21
+ return activity("k3sInstall", { role, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
22
+ };
23
+ /**
24
+ * Uninstall k3s from a reachable host — the fully typed twin of core's
25
+ * `k3sUninstall`. `opts` is {@link K3sUninstallArgs} itself, minus the
26
+ * positional `role`. Defaults to the `fastIdempotent` profile.
27
+ */
28
+ export const k3sUninstall = (role, opts) => {
29
+ const { args, profile, id } = takeProfileAndId(opts);
30
+ return activity("k3sUninstall", { role, ...args }, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
31
+ };
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run bundle` in lexicon-k3s.
4
+ */
5
+ import { generate, writeGeneratedFiles } from "./codegen/generate.js";
6
+ import { packageLexicon } from "./codegen/package.js";
7
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
8
+ import { join, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
11
+ // 1. Generate src/generated/ files (writeGeneratedFiles resolves its own target)
12
+ const genResult = await generate({ verbose: true });
13
+ writeGeneratedFiles(genResult);
14
+ // 2. Run package pipeline and write dist/
15
+ const { spec, stats } = await packageLexicon({ verbose: true });
16
+ const distDir = join(dirname(pkgDir), "dist");
17
+ writeBundleSpec(spec, distDir);
18
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
19
+ console.error(`dist/ written to ${distDir}`);
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAgB7F;;;;GAIG;AACH,eAAO,MAAM,SAAS,EAAE,aA4HvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAiB7F;;;;GAIG;AACH,eAAO,MAAM,SAAS,EAAE,aA4HvB,CAAC"}
package/dist/plugin.js ADDED
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { moduleSourceDir } from "@intentius/chant/module-source";
4
+ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
5
+ import { k3sSerializer } from "./serializer.js";
6
+ import { rules } from "./lint/rules/index.js";
7
+ import { postSynthChecks } from "./lint/post-synth/index.js";
8
+ import { k3sAuditCatalog } from "./lint/audit-catalog.js";
9
+ import { completions } from "./lsp/completions.js";
10
+ import { hover } from "./lsp/hover.js";
11
+ /**
12
+ * k3s lexicon plugin.
13
+ *
14
+ * Implements all required LexiconPlugin lifecycle methods.
15
+ */
16
+ export const k3sPlugin = {
17
+ name: "k3s",
18
+ serializer: k3sSerializer,
19
+ /**
20
+ * Where the upstream pin lives and where to look for a newer one, for
21
+ * the self-upgrade tooling (#523). k3s releases carry a `+k3s1` build
22
+ * suffix; the release tag is the version constant verbatim.
23
+ */
24
+ upstreamPin: {
25
+ file: "src/spec/fetch.ts",
26
+ pattern: /export const K3S_VERSION\s*=\s*"([^"]+)"/,
27
+ replace: (v, line) => line.replace(/= "[^"]+"/, `= "${v}"`),
28
+ upstream: { owner: "k3s-io", repo: "k3s", kind: "releases" },
29
+ },
30
+ /**
31
+ * The marker rides `node-label` (the serializer stamps it there when a
32
+ * build carries ownership) and lands on the registered Node as ordinary
33
+ * Kubernetes labels — read back the same way every label-based lexicon
34
+ * does. See `./describe-resources.ts` (#1603).
35
+ */
36
+ ownershipChannel: {
37
+ keys: LABEL_OWNERSHIP_KEYS,
38
+ reads: ["describeResources"],
39
+ },
40
+ async describeResources(options) {
41
+ const { describeResources } = await import("./describe-resources.js");
42
+ return describeResources(options);
43
+ },
44
+ // ── Required lifecycle methods ────────────────────────────────
45
+ async generate(options) {
46
+ const { generate } = await import("./codegen/generate.js");
47
+ await generate(options);
48
+ },
49
+ async validate(options) {
50
+ const { validate } = await import("./validate.js");
51
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
52
+ const result = await validate();
53
+ printValidationResult(result);
54
+ },
55
+ async coverage(options) {
56
+ // TODO: Implement coverage analysis
57
+ console.error("Coverage analysis not yet implemented");
58
+ },
59
+ async package(options) {
60
+ const { packageLexicon } = await import("./codegen/package.js");
61
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
62
+ const { join, dirname } = await import("path");
63
+ const { fileURLToPath } = await import("url");
64
+ const { spec, stats } = await packageLexicon(options);
65
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
66
+ writeBundleSpec(spec, join(pkgDir, "dist"));
67
+ console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
68
+ },
69
+ // ── Optional extensions ────────────────────────────────────
70
+ lintRules() {
71
+ return rules;
72
+ },
73
+ postSynthChecks() {
74
+ return postSynthChecks;
75
+ },
76
+ auditCatalog() {
77
+ return k3sAuditCatalog;
78
+ },
79
+ skills() {
80
+ const skills = [];
81
+ const dir = moduleSourceDir(import.meta.url);
82
+ const entries = [
83
+ {
84
+ file: "chant-k3s.md",
85
+ name: "chant-k3s",
86
+ description: "Declare k3s host configuration — server/agent config.yaml and registries.yaml — as typed chant source",
87
+ },
88
+ ];
89
+ for (const entry of entries) {
90
+ try {
91
+ const content = readFileSync(join(dir, "skills", entry.file), "utf-8");
92
+ skills.push({ name: entry.name, description: entry.description, content });
93
+ }
94
+ catch {
95
+ // skill file missing from a partial checkout — skip rather than throw
96
+ }
97
+ }
98
+ return skills;
99
+ },
100
+ mcpTools() {
101
+ return []; // TODO: Implement MCP tools
102
+ },
103
+ mcpResources() {
104
+ return []; // TODO: Implement MCP resources
105
+ },
106
+ detectTemplate(data) {
107
+ return false; // TODO: Detect if a template belongs to this lexicon
108
+ },
109
+ completionProvider(ctx) {
110
+ return completions(ctx);
111
+ },
112
+ hoverProvider(ctx) {
113
+ return hover(ctx);
114
+ },
115
+ async docs(options) {
116
+ const { generateDocs } = await import("./codegen/docs.js");
117
+ return generateDocs(options);
118
+ },
119
+ };
@@ -0,0 +1,169 @@
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
+ import { isResourceDeclarable } from "@intentius/chant/declarable";
33
+ import { walkValue } from "@intentius/chant/serializer-walker";
34
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
35
+ import { ownershipEntries, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
36
+ export const SERVER_TYPE = "K3s::Server";
37
+ export const AGENT_TYPE = "K3s::Agent";
38
+ export const REGISTRIES_TYPE = "K3s::Registries";
39
+ /**
40
+ * Keys that lead the document; everything else follows alphabetically.
41
+ * The identity-and-join block first is how the documented examples read,
42
+ * so a chant emission diffs cleanly against them.
43
+ */
44
+ const KEY_PRIORITY = [
45
+ "cluster-init",
46
+ "server",
47
+ "token-file",
48
+ "agent-token-file",
49
+ "data-dir",
50
+ "node-name",
51
+ ];
52
+ const visitor = {
53
+ attrRef: (name) => name,
54
+ resourceRef: (name) => name,
55
+ propertyDeclarable: (entity, walk) => {
56
+ if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
57
+ return undefined;
58
+ }
59
+ const props = entity.props;
60
+ const result = {};
61
+ for (const [key, value] of Object.entries(props)) {
62
+ if (value !== undefined)
63
+ result[key] = walk(value);
64
+ }
65
+ return result;
66
+ },
67
+ };
68
+ function orderedKeys(props) {
69
+ const rest = Object.keys(props)
70
+ .filter((k) => !KEY_PRIORITY.includes(k))
71
+ .sort();
72
+ return [...KEY_PRIORITY.filter((k) => props[k] !== undefined), ...rest];
73
+ }
74
+ /**
75
+ * Append the ownership marker to `node-label`. The key survives into the
76
+ * registered Node's labels, which is the only durable channel a host
77
+ * config file has. An author-set key of the same name wins.
78
+ */
79
+ function stampOwnership(props, ownership) {
80
+ const existing = props["node-label"];
81
+ const labels = existing === undefined ? [] : Array.isArray(existing) ? [...existing] : [String(existing)];
82
+ const present = new Set(labels.map((l) => String(l).split("=")[0]));
83
+ for (const [key, value] of Object.entries(ownershipEntries(LABEL_OWNERSHIP_KEYS, ownership))) {
84
+ if (present.has(key))
85
+ continue;
86
+ labels.push(`${key}=${value}`);
87
+ }
88
+ props["node-label"] = labels;
89
+ }
90
+ function configDocument(entity, entityNames, context) {
91
+ const raw = (isResourceDeclarable(entity) ? entity.props : {});
92
+ const props = (walkValue(raw, entityNames, visitor) ?? {});
93
+ if (context?.ownership)
94
+ stampOwnership(props, context.ownership);
95
+ const doc = {};
96
+ for (const key of orderedKeys(props)) {
97
+ if (props[key] !== undefined)
98
+ doc[key] = props[key];
99
+ }
100
+ return doc;
101
+ }
102
+ function registriesDocument(entity, entityNames) {
103
+ const raw = (isResourceDeclarable(entity) ? entity.props : {});
104
+ const props = (walkValue(raw, entityNames, visitor) ?? {});
105
+ const doc = {};
106
+ // mirrors before configs, matching the documented examples.
107
+ if (props.mirrors !== undefined)
108
+ doc.mirrors = props.mirrors;
109
+ if (props.configs !== undefined)
110
+ doc.configs = props.configs;
111
+ for (const [key, value] of Object.entries(props)) {
112
+ if (key === "mirrors" || key === "configs" || value === undefined)
113
+ continue;
114
+ doc[key] = value;
115
+ }
116
+ return doc;
117
+ }
118
+ function emitDocument(doc) {
119
+ const sections = [];
120
+ for (const [key, value] of Object.entries(doc)) {
121
+ if (value === undefined)
122
+ continue;
123
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
124
+ sections.push(`${key}: ${scalar(value)}`);
125
+ }
126
+ else {
127
+ sections.push(emitYAMLEntry(key, value));
128
+ }
129
+ }
130
+ return sections.join("\n") + "\n";
131
+ }
132
+ function scalar(value) {
133
+ if (typeof value === "boolean")
134
+ return value ? "true" : "false";
135
+ if (typeof value === "number")
136
+ return String(value);
137
+ if (/[:#\[\]{}|>&*!%@`'"]|^[\s\d]|\s$|^$|^(true|false|null|yes|no|on|off)$/i.test(value)) {
138
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
139
+ }
140
+ return value;
141
+ }
142
+ export const k3sSerializer = {
143
+ name: "k3s",
144
+ rulePrefix: "K3S",
145
+ serialize(entities, _outputs, context) {
146
+ const entityNames = new Map();
147
+ for (const [name, entity] of entities)
148
+ entityNames.set(entity, name);
149
+ const configs = [...entities].filter(([, e]) => e.entityType === SERVER_TYPE || e.entityType === AGENT_TYPE);
150
+ const registries = [...entities].filter(([, e]) => e.entityType === REGISTRIES_TYPE);
151
+ let primary = "";
152
+ const files = {};
153
+ configs.forEach(([name, entity], i) => {
154
+ const content = emitDocument(configDocument(entity, entityNames, context));
155
+ if (i === 0)
156
+ primary = content;
157
+ else
158
+ files[`${name}.config.yaml`] = content;
159
+ });
160
+ registries.forEach(([name, entity], i) => {
161
+ const content = emitDocument(registriesDocument(entity, entityNames));
162
+ if (primary === "" && i === 0 && configs.length === 0)
163
+ primary = content;
164
+ else
165
+ files[i === 0 && configs.length > 0 ? "registries.yaml" : `${name}.registries.yaml`] = content;
166
+ });
167
+ return Object.keys(files).length > 0 ? { primary, files } : primary;
168
+ },
169
+ };