@intentius/chant-lexicon-k3s 0.45.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import {
3
+ k3sInstall,
4
+ k3sUninstall,
5
+ k3sInstallCommand,
6
+ k3sInstallEnv,
7
+ k3sUninstallCommand,
8
+ k3sUninstallScript,
9
+ k3sVersionCommand,
10
+ parseK3sVersion,
11
+ } from "./k3s";
12
+ import { K3S_VERSION } from "../../spec/fetch";
13
+
14
+ // Every command below goes through this mock — no real k3s installer, no
15
+ // real host. `execCalls` records the exact (cmd, opts) pairs so the install/
16
+ // uninstall idempotence paths can be asserted precisely.
17
+ const execCalls: Array<{ cmd: string; opts: unknown }> = [];
18
+ let versionReply: { stdout: string; stderr: string } | Error = new Error("k3s: command not found");
19
+ let uninstallReply: { stdout: string; stderr: string } = { stdout: "", stderr: "" };
20
+
21
+ vi.mock("node:child_process", () => {
22
+ const custom = Symbol.for("nodejs.util.promisify.custom");
23
+ const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
24
+ cb?.(new Error("unmocked exec path"));
25
+ }) as unknown as Record<symbol, unknown>;
26
+ exec[custom] = async (cmd: string, opts?: unknown) => {
27
+ execCalls.push({ cmd, opts });
28
+ if (cmd === "k3s --version") {
29
+ if (versionReply instanceof Error) throw versionReply;
30
+ return versionReply;
31
+ }
32
+ if (cmd.startsWith("test -x")) return uninstallReply;
33
+ return { stdout: "", stderr: "" };
34
+ };
35
+ return { exec };
36
+ });
37
+
38
+ describe("k3sInstallCommand (#1601)", () => {
39
+ test("server role — curl installer piped into `sh -s - server --config <file>`", () => {
40
+ const cmd = k3sInstallCommand({ role: "server", configFile: "/etc/rancher/k3s/config.yaml" });
41
+ expect(cmd).toBe(
42
+ "curl -sfL https://get.k3s.io | sh -s - server --config /etc/rancher/k3s/config.yaml",
43
+ );
44
+ });
45
+
46
+ test("agent role", () => {
47
+ const cmd = k3sInstallCommand({ role: "agent", configFile: "/etc/rancher/k3s/config.yaml" });
48
+ expect(cmd).toContain("sh -s - agent --config");
49
+ });
50
+
51
+ test("never interpolates a token or version into the command string", () => {
52
+ const cmd = k3sInstallCommand({
53
+ role: "server",
54
+ configFile: "/etc/rancher/k3s/config.yaml",
55
+ version: "v9.9.9+k3s1",
56
+ tokenFile: "/etc/rancher/k3s/agent-token",
57
+ });
58
+ expect(cmd).not.toContain("v9.9.9");
59
+ expect(cmd).not.toContain("token");
60
+ expect(cmd).not.toContain("TOKEN");
61
+ });
62
+ });
63
+
64
+ describe("k3sInstallEnv — the token boundary (#1601)", () => {
65
+ test("defaults INSTALL_K3S_VERSION to the lexicon pin, no K3S_TOKEN_FILE without a tokenFile", () => {
66
+ const env = k3sInstallEnv({ role: "server", configFile: "config.yaml" });
67
+ expect(env).toEqual({ INSTALL_K3S_VERSION: K3S_VERSION });
68
+ expect(env.K3S_TOKEN_FILE).toBeUndefined();
69
+ });
70
+
71
+ test("an explicit version overrides the pin", () => {
72
+ const env = k3sInstallEnv({ role: "server", configFile: "config.yaml", version: "v9.9.9+k3s1" });
73
+ expect(env.INSTALL_K3S_VERSION).toBe("v9.9.9+k3s1");
74
+ });
75
+
76
+ test("tokenFile becomes K3S_TOKEN_FILE — a path, never a literal secret value", () => {
77
+ const env = k3sInstallEnv({
78
+ role: "agent",
79
+ configFile: "config.yaml",
80
+ tokenFile: "/etc/rancher/k3s/agent-token",
81
+ });
82
+ expect(env.K3S_TOKEN_FILE).toBe("/etc/rancher/k3s/agent-token");
83
+ });
84
+
85
+ test("K3sInstallArgs has no literal-token field at all — a TypeScript surface check", () => {
86
+ // If a `token` property is ever added to K3sInstallArgs, this object
87
+ // literal starts compiling and the guard below no longer proves anything.
88
+ // @ts-expect-error — token is not part of K3sInstallArgs
89
+ const args: import("./k3s").K3sInstallArgs = { role: "server", configFile: "c.yaml", token: "shhh" };
90
+ void args;
91
+ });
92
+ });
93
+
94
+ describe("k3sUninstallCommand / k3sUninstallScript (#1601)", () => {
95
+ test("server role runs the server uninstall script, guarded by -x", () => {
96
+ expect(k3sUninstallScript("server")).toBe("/usr/local/bin/k3s-uninstall.sh");
97
+ expect(k3sUninstallCommand({ role: "server" })).toBe(
98
+ 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || echo "k3s server already uninstalled"',
99
+ );
100
+ });
101
+
102
+ test("agent role runs the agent uninstall script", () => {
103
+ expect(k3sUninstallScript("agent")).toBe("/usr/local/bin/k3s-agent-uninstall.sh");
104
+ expect(k3sUninstallCommand({ role: "agent" })).toContain("k3s-agent-uninstall.sh");
105
+ });
106
+ });
107
+
108
+ describe("k3sVersionCommand / parseK3sVersion (#1601)", () => {
109
+ test("version command", () => {
110
+ expect(k3sVersionCommand()).toBe("k3s --version");
111
+ });
112
+
113
+ test("parses the version out of `k3s --version` output", () => {
114
+ const stdout = "k3s version v1.36.3+k3s1 (abc1234)\ngo version go1.23.1\n";
115
+ expect(parseK3sVersion(stdout)).toBe("v1.36.3+k3s1");
116
+ });
117
+
118
+ test("returns undefined for unrecognized output", () => {
119
+ expect(parseK3sVersion("bash: k3s: command not found\n")).toBeUndefined();
120
+ });
121
+ });
122
+
123
+ describe("k3sInstall (#1601)", () => {
124
+ test("already-installed matching version: skips install, no INSTALL_K3S_VERSION exec", async () => {
125
+ execCalls.length = 0;
126
+ versionReply = { stdout: `k3s version ${K3S_VERSION} (abc1234)\n`, stderr: "" };
127
+ const result = await k3sInstall({ role: "server", configFile: "/etc/rancher/k3s/config.yaml" });
128
+ expect(result).toEqual({ version: K3S_VERSION, installed: false });
129
+ expect(execCalls).toHaveLength(1);
130
+ expect(execCalls[0].cmd).toBe("k3s --version");
131
+ });
132
+
133
+ test("no k3s present: runs the installer with the version pin in env, not the command string", async () => {
134
+ execCalls.length = 0;
135
+ versionReply = new Error("k3s: command not found");
136
+ const result = await k3sInstall({ role: "server", configFile: "/etc/rancher/k3s/config.yaml" });
137
+ expect(result).toEqual({ version: K3S_VERSION, installed: true });
138
+ expect(execCalls).toHaveLength(2);
139
+ expect(execCalls[0].cmd).toBe("k3s --version");
140
+ expect(execCalls[1].cmd).toBe(
141
+ "curl -sfL https://get.k3s.io | sh -s - server --config /etc/rancher/k3s/config.yaml",
142
+ );
143
+ const opts = execCalls[1].opts as { env?: Record<string, string> };
144
+ expect(opts.env?.INSTALL_K3S_VERSION).toBe(K3S_VERSION);
145
+ });
146
+
147
+ test("mismatched version installed: reinstalls to the target version", async () => {
148
+ execCalls.length = 0;
149
+ versionReply = { stdout: "k3s version v1.30.0+k3s1 (abc1234)\n", stderr: "" };
150
+ const result = await k3sInstall({ role: "agent", configFile: "/etc/rancher/k3s/config.yaml" });
151
+ expect(result).toEqual({ version: K3S_VERSION, installed: true });
152
+ expect(execCalls).toHaveLength(2);
153
+ });
154
+
155
+ test("a tokenFile is threaded into the installer's env as K3S_TOKEN_FILE", async () => {
156
+ execCalls.length = 0;
157
+ versionReply = new Error("k3s: command not found");
158
+ await k3sInstall({
159
+ role: "agent",
160
+ configFile: "/etc/rancher/k3s/config.yaml",
161
+ tokenFile: "/etc/rancher/k3s/agent-token",
162
+ });
163
+ const opts = execCalls[1].opts as { env?: Record<string, string> };
164
+ expect(opts.env?.K3S_TOKEN_FILE).toBe("/etc/rancher/k3s/agent-token");
165
+ });
166
+ });
167
+
168
+ describe("k3sUninstall (#1601)", () => {
169
+ test("runs the uninstall command for the given role", async () => {
170
+ execCalls.length = 0;
171
+ uninstallReply = { stdout: "k3s uninstalled\n", stderr: "" };
172
+ await k3sUninstall({ role: "server" });
173
+ expect(execCalls).toHaveLength(1);
174
+ expect(execCalls[0].cmd).toContain("k3s-uninstall.sh");
175
+ });
176
+
177
+ test("never-installed host: the guarded command still resolves (no-op success)", async () => {
178
+ execCalls.length = 0;
179
+ uninstallReply = { stdout: "k3s server already uninstalled\n", stderr: "" };
180
+ await expect(k3sUninstall({ role: "server" })).resolves.toBeUndefined();
181
+ });
182
+ });
@@ -0,0 +1,170 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { safeHeartbeat } from "@intentius/chant/op";
4
+ import { K3S_VERSION } from "../../spec/fetch";
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /** `k3s server` runs the control plane; `k3s agent` joins one as a worker. */
9
+ export type K3sRole = "server" | "agent";
10
+
11
+ export interface K3sInstallArgs {
12
+ /** Which k3s subcommand the installer configures (`k3s server` / `k3s agent`). */
13
+ role: K3sRole;
14
+ /**
15
+ * Path to the chant-emitted `config.yaml` on the reachable host, passed as
16
+ * `--config` to the installed binary. chant does not write this file itself
17
+ * — a build/apply step upstream of this activity is responsible for getting
18
+ * it onto the host (host provisioning is out of scope, chant#1598).
19
+ */
20
+ configFile: string;
21
+ /**
22
+ * Installer version pin (`INSTALL_K3S_VERSION`). Default: the lexicon's
23
+ * {@link K3S_VERSION} pin — bump that constant, not this arg, to track a
24
+ * new upstream release across the whole lexicon.
25
+ */
26
+ version?: string;
27
+ /**
28
+ * Path to a file on the host holding the cluster join token, passed to the
29
+ * installer as `K3S_TOKEN_FILE`. This is the only token-shaped input this
30
+ * activity accepts — see the "token boundary" note below.
31
+ */
32
+ tokenFile?: string;
33
+ }
34
+
35
+ export interface K3sUninstallArgs {
36
+ /** Which uninstall script to run — k3s ships a separate one per role. */
37
+ role: K3sRole;
38
+ }
39
+
40
+ /** What {@link k3sInstall} resolved. */
41
+ export interface K3sInstallResult {
42
+ /** The k3s version now installed (the target version, whether freshly installed or already present). */
43
+ version: string;
44
+ /** `false` when an already-installed matching version made the install a no-op. */
45
+ installed: boolean;
46
+ }
47
+
48
+ /**
49
+ * `k3s --version` output, first line: `k3s version v1.36.3+k3s1 (hash)`.
50
+ * Returns `undefined` when k3s is not installed or the output doesn't match.
51
+ */
52
+ export function parseK3sVersion(stdout: string): string | undefined {
53
+ return stdout.match(/k3s version (\S+)/)?.[1];
54
+ }
55
+
56
+ /** Check-install-version command — errors (non-zero exit) when k3s is absent. */
57
+ export function k3sVersionCommand(): string {
58
+ return "k3s --version";
59
+ }
60
+
61
+ /**
62
+ * Build the get.k3s.io installer command. The version pin and the join-token
63
+ * reference (if any) travel as environment variables via {@link k3sInstallEnv},
64
+ * not interpolated into this string — see the token-boundary note on
65
+ * {@link k3sInstall}.
66
+ */
67
+ export function k3sInstallCommand(args: K3sInstallArgs): string {
68
+ return `curl -sfL https://get.k3s.io | sh -s - ${args.role} --config ${args.configFile}`;
69
+ }
70
+
71
+ /**
72
+ * Environment for {@link k3sInstallCommand}: `INSTALL_K3S_VERSION` (the pin)
73
+ * and, only when a `tokenFile` path is given, `K3S_TOKEN_FILE`.
74
+ *
75
+ * ## The token boundary (#1601)
76
+ *
77
+ * This is the only token-shaped input the install activity accepts, and it
78
+ * is a path, not a secret — the join token itself never passes through
79
+ * activity args, never gets logged, and never lands in an emitted
80
+ * config.yaml (K3S001 rejects a literal `token`/`agent-token` at lint). A
81
+ * `K3S_TOKEN` (the literal, env-var form k3s itself supports for agents) is
82
+ * deliberately not modeled here: the reference form the lexicon carries
83
+ * end-to-end is a file path, matching `token-file`/`agent-token-file` on the
84
+ * declared config surface (chant#1365's provenance stance applied at this
85
+ * surface, not a new mechanism).
86
+ */
87
+ export function k3sInstallEnv(args: K3sInstallArgs): Record<string, string> {
88
+ const env: Record<string, string> = { INSTALL_K3S_VERSION: args.version ?? K3S_VERSION };
89
+ if (args.tokenFile) env.K3S_TOKEN_FILE = args.tokenFile;
90
+ return env;
91
+ }
92
+
93
+ /** Path k3s installs its uninstall script at — one per role. */
94
+ export function k3sUninstallScript(role: K3sRole): string {
95
+ return role === "agent" ? "/usr/local/bin/k3s-agent-uninstall.sh" : "/usr/local/bin/k3s-uninstall.sh";
96
+ }
97
+
98
+ /**
99
+ * Build the uninstall command. Gated the way `k3dDown` is (chant#1410): no
100
+ * pre-check activity, no SSH orchestration — just the native idempotent
101
+ * behavior. Unlike `k3d cluster delete` (idempotent on its own), the k3s
102
+ * uninstall script does not exist at all on a host where k3s was never
103
+ * installed, so the command guards on the script's presence itself rather
104
+ * than assume it errors safely.
105
+ */
106
+ export function k3sUninstallCommand(args: K3sUninstallArgs): string {
107
+ const script = k3sUninstallScript(args.role);
108
+ return `test -x ${script} && ${script} || echo "k3s ${args.role} already uninstalled"`;
109
+ }
110
+
111
+ /**
112
+ * Run the pinned k3s installer against a reachable host. Idempotent on an
113
+ * already-installed matching version: if `k3s --version` already reports the
114
+ * target version, the install is skipped. Uses longInfra profile — 20m
115
+ * timeout, heartbeat every 15s (the installer downloads and starts the
116
+ * k3s binary).
117
+ *
118
+ * Bounded exactly as `k3dUp`/`k3dDown` were (chant#1410, epic #1598): this
119
+ * drives the case where the host is reachable from where the Op runs. It
120
+ * does not provision the host, and it is not an SSH orchestrator.
121
+ */
122
+ export async function k3sInstall(
123
+ args: K3sInstallArgs,
124
+ signal?: AbortSignal,
125
+ ): Promise<K3sInstallResult> {
126
+ const target = args.version ?? K3S_VERSION;
127
+
128
+ try {
129
+ const { stdout } = await execAsync(k3sVersionCommand(), { signal });
130
+ const installed = parseK3sVersion(stdout);
131
+ if (installed === target) {
132
+ console.log(`k3s ${target} already installed (${args.role}) — skipping install`);
133
+ return { version: target, installed: false };
134
+ }
135
+ if (installed) {
136
+ console.log(`k3s ${installed} installed, target is ${target} — reinstalling`);
137
+ }
138
+ } catch {
139
+ // `k3s --version` errors when k3s is absent — fall through to install.
140
+ }
141
+
142
+ const heartbeatInterval = setInterval(() => {
143
+ safeHeartbeat({ step: "k3s install", role: args.role, version: target });
144
+ }, 15_000);
145
+
146
+ try {
147
+ const { stdout, stderr } = await execAsync(k3sInstallCommand(args), {
148
+ signal,
149
+ env: { ...process.env, ...k3sInstallEnv(args) },
150
+ });
151
+ if (stdout) console.log(stdout);
152
+ if (stderr) console.error(stderr);
153
+ } finally {
154
+ clearInterval(heartbeatInterval);
155
+ }
156
+
157
+ return { version: target, installed: true };
158
+ }
159
+
160
+ /**
161
+ * Uninstall k3s from a reachable host. Uses fastIdempotent profile — 5m
162
+ * timeout. A host where k3s was never installed (no uninstall script present)
163
+ * is a no-op success, the same idempotent shape as `k3dDown` against an
164
+ * already-gone cluster.
165
+ */
166
+ export async function k3sUninstall(args: K3sUninstallArgs, signal?: AbortSignal): Promise<void> {
167
+ const { stdout, stderr } = await execAsync(k3sUninstallCommand(args), { signal });
168
+ if (stdout) console.log(stdout);
169
+ if (stderr) console.error(stderr);
170
+ }
package/src/plugin.ts CHANGED
@@ -6,6 +6,7 @@ import type { McpToolContribution, McpResourceContribution } from "@intentius/ch
6
6
  import { readFileSync } from "fs";
7
7
  import { dirname, join } from "path";
8
8
  import { fileURLToPath } from "url";
9
+ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
9
10
  import { k3sSerializer } from "./serializer";
10
11
  import { rules } from "./lint/rules";
11
12
  import { postSynthChecks } from "./lint/post-synth";
@@ -34,6 +35,22 @@ export const k3sPlugin: LexiconPlugin = {
34
35
  upstream: { owner: "k3s-io", repo: "k3s", kind: "releases" },
35
36
  },
36
37
 
38
+ /**
39
+ * The marker rides `node-label` (the serializer stamps it there when a
40
+ * build carries ownership) and lands on the registered Node as ordinary
41
+ * Kubernetes labels — read back the same way every label-based lexicon
42
+ * does. See `./describe-resources.ts` (#1603).
43
+ */
44
+ ownershipChannel: {
45
+ keys: LABEL_OWNERSHIP_KEYS,
46
+ reads: ["describeResources"],
47
+ },
48
+
49
+ async describeResources(options) {
50
+ const { describeResources } = await import("./describe-resources");
51
+ return describeResources(options);
52
+ },
53
+
37
54
  // ── Required lifecycle methods ────────────────────────────────
38
55
 
39
56
  async generate(options?: { verbose?: boolean }): Promise<void> {
@@ -70,6 +70,49 @@ export const registries = new Registries({
70
70
  Literal `auth.password` / `auth.token` fail K3S102; `insecure_skip_verify`
71
71
  warns via K3S105 — pin the CA instead.
72
72
 
73
+ ## Op lifecycle (reachable-host case)
74
+
75
+ `k3sInstall` / `k3sUninstall` (chant#1601) drive a k3s installer against a
76
+ host the Op runs on or can reach directly — the same boundary `k3dUp`/
77
+ `k3dDown` draw (chant#1410): no host provisioning, no SSH orchestration.
78
+ Requires `"k3s"` in the project's `chant.config.ts` `lexicons`.
79
+
80
+ ```typescript
81
+ import { Op, phase, k3sInstall, k3sUninstall } from "@intentius/chant-lexicon-temporal";
82
+
83
+ export default Op({
84
+ name: "k3s-controlplane",
85
+ phases: [
86
+ phase("Install", [
87
+ k3sInstall("server", { configFile: "/etc/rancher/k3s/config.yaml" }),
88
+ ]),
89
+ ],
90
+ });
91
+ ```
92
+
93
+ `k3sInstall` runs the pinned `get.k3s.io` installer (`INSTALL_K3S_VERSION`
94
+ from the lexicon's pin, or an explicit `version` override) with `--config`
95
+ pointing at a config.yaml already on the host — a build/apply step upstream
96
+ puts it there. It is idempotent on an already-installed matching version:
97
+ `k3s --version` is checked first, and a match skips the install entirely.
98
+ `k3sUninstall` runs the matching uninstall script; a host where k3s was
99
+ never installed is a no-op success.
100
+
101
+ **The token boundary carries through to the Op surface.** There is no
102
+ `token` option on `k3sInstall` — only `tokenFile`, a path passed to the
103
+ installer as `K3S_TOKEN_FILE`. The join secret's value never appears in
104
+ Op-authored TypeScript (which is committed source, same as a config.yaml
105
+ declaration), is never logged, and is never interpolated into the installer
106
+ command string — it travels only as an environment variable set on the
107
+ child process at install time.
108
+
109
+ ```typescript
110
+ k3sInstall("agent", {
111
+ configFile: "/etc/rancher/k3s/config.yaml",
112
+ tokenFile: "/etc/rancher/k3s/agent-token", // a path, not the secret
113
+ });
114
+ ```
115
+
73
116
  ## Output shape
74
117
 
75
118
  One file per declared entity. The first Server/Agent config is the primary
@@ -88,3 +131,5 @@ with any kubectl.
88
131
  | K3S103 | error | an Agent with no `server` to join |
89
132
  | K3S104 | warning | kubeconfig written wider than 0644 |
90
133
  | K3S105 | warning | registry TLS verification disabled |
134
+ | K3S106 | warning | tls-san missing for a declared bind/advertise address |
135
+ | K3S107 | warning | disable names a component the config also configures |