@intentius/chant-lexicon-k3d 0.124.30 → 0.124.32

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,102 @@
1
+ /**
2
+ * Live observation for declared k3d clusters (#1412).
3
+ *
4
+ * The read is two commands: `k3d cluster list -o json` for presence and
5
+ * running state, and `docker inspect` on the cluster's first server node for
6
+ * the ownership marker — k3d's list output carries a `runtimeLabels` field
7
+ * but only k3d's own `k3d.*` labels appear in it, so the custom labels the
8
+ * serializer stamps via `options.runtime.labels` are only visible to Docker.
9
+ * Verified live before this was written; see #1412.
10
+ *
11
+ * The tri-state is the whole report (#1089): a declared cluster is present,
12
+ * absent, or not-observed-with-a-reason. k3d or Docker being unavailable is
13
+ * `read-failed` for everything — never absence, because proposing to create
14
+ * a cluster while Docker is stopped is the false finding the contract
15
+ * exists to prevent. There is no property-level drift here on purpose: the
16
+ * declared config is an input to creation, not a spec k3d reconciles
17
+ * against, so node counts diverging is not "drift" anyone can act on.
18
+ */
19
+ import { exec } from "node:child_process";
20
+ import { promisify } from "node:util";
21
+ import { observeEntities, } from "@intentius/chant/observation";
22
+ import { classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
23
+ const execAsync = promisify(exec);
24
+ const CLUSTER_TYPE = "K3d::Cluster";
25
+ /** The cluster name a declaration resolves to: metadata.name, else the entity name. */
26
+ export function declaredClusterName(entity) {
27
+ const metadata = entity.props.metadata;
28
+ const name = metadata?.name;
29
+ return typeof name === "string" && name.length > 0 ? name : entity.name;
30
+ }
31
+ function clusterStatus(cluster) {
32
+ const total = cluster.serversCount ?? cluster.nodes?.filter((n) => n.role === "server").length ?? 0;
33
+ const running = cluster.serversRunning ??
34
+ cluster.nodes?.filter((n) => n.role === "server" && n.State?.Running).length ??
35
+ 0;
36
+ if (total === 0)
37
+ return "unknown";
38
+ if (running === 0)
39
+ return "stopped";
40
+ return running < total ? "degraded" : "running";
41
+ }
42
+ function adapter(execFn) {
43
+ return {
44
+ async bind() {
45
+ const { stdout } = await execFn("k3d cluster list -o json");
46
+ const parsed = JSON.parse(stdout || "[]");
47
+ return Array.isArray(parsed) ? parsed : [];
48
+ },
49
+ classifyBindFailure(err) {
50
+ // k3d missing, Docker stopped, or unparsable output — all mean "could
51
+ // not look", never "not there".
52
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
53
+ return { reason: "read-failed", detail };
54
+ },
55
+ async read(clusters, entity) {
56
+ if (entity.type !== CLUSTER_TYPE) {
57
+ return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
58
+ }
59
+ const name = declaredClusterName(entity);
60
+ const cluster = clusters.find((c) => c.name === name);
61
+ if (!cluster)
62
+ return { absent: true };
63
+ // The marker lives on the server node's Docker labels; k3d's list JSON
64
+ // does not surface custom labels, so ask Docker. A cluster that is
65
+ // present but uninspectable still reports present — with the honest
66
+ // `unknown` verdict rather than a guessed one.
67
+ let ownership = "unknown";
68
+ try {
69
+ const { stdout } = await execFn(`docker inspect k3d-${name}-server-0 --format '{{json .Config.Labels}}'`);
70
+ const labels = JSON.parse(stdout.trim().replace(/^'|'$/g, "") || "{}");
71
+ ownership = classifyOwnership(labels, LABEL_OWNERSHIP_KEYS);
72
+ }
73
+ catch {
74
+ ownership = "unknown";
75
+ }
76
+ return {
77
+ present: {
78
+ type: CLUSTER_TYPE,
79
+ physicalId: name,
80
+ status: clusterStatus(cluster),
81
+ ownership,
82
+ attributes: {
83
+ servers: cluster.serversCount ?? undefined,
84
+ agents: cluster.agentsCount ?? undefined,
85
+ serversRunning: cluster.serversRunning ?? undefined,
86
+ },
87
+ },
88
+ };
89
+ },
90
+ };
91
+ }
92
+ export async function describeResources(options, execFn = execAsync) {
93
+ const declared = options.entityNames.map((name) => {
94
+ const entity = options.entities.get(name);
95
+ return {
96
+ name,
97
+ type: entity?.entityType ?? "",
98
+ props: entity?.props ?? {},
99
+ };
100
+ });
101
+ return observeEntities(declared, adapter(execFn));
102
+ }
@@ -0,0 +1,24 @@
1
+ // Code generated by chant generate. DO NOT EDIT.
2
+ import { createResource, createProperty } from "./runtime.js";
3
+ export const Cluster = /* @__PURE__ */ createResource("K3d::Cluster", "k3d", {});
4
+ export const EnvVar = /* @__PURE__ */ createProperty("K3d::EnvVar", "k3d");
5
+ export const File = /* @__PURE__ */ createProperty("K3d::File", "k3d");
6
+ export const HostAlias = /* @__PURE__ */ createProperty("K3d::HostAlias", "k3d");
7
+ export const K3dOptions = /* @__PURE__ */ createProperty("K3d::K3dOptions", "k3d");
8
+ export const K3sExtraArg = /* @__PURE__ */ createProperty("K3d::K3sExtraArg", "k3d");
9
+ export const K3sOptions = /* @__PURE__ */ createProperty("K3d::K3sOptions", "k3d");
10
+ export const KubeAPI = /* @__PURE__ */ createProperty("K3d::KubeAPI", "k3d");
11
+ export const KubeconfigOptions = /* @__PURE__ */ createProperty("K3d::KubeconfigOptions", "k3d");
12
+ export const LoadbalancerOptions = /* @__PURE__ */ createProperty("K3d::LoadbalancerOptions", "k3d");
13
+ export const Metadata = /* @__PURE__ */ createProperty("K3d::Metadata", "k3d");
14
+ export const NodeLabel = /* @__PURE__ */ createProperty("K3d::NodeLabel", "k3d");
15
+ export const Options = /* @__PURE__ */ createProperty("K3d::Options", "k3d");
16
+ export const Port = /* @__PURE__ */ createProperty("K3d::Port", "k3d");
17
+ export const Registries = /* @__PURE__ */ createProperty("K3d::Registries", "k3d");
18
+ export const RegistryCreate = /* @__PURE__ */ createProperty("K3d::RegistryCreate", "k3d");
19
+ export const RegistryProxy = /* @__PURE__ */ createProperty("K3d::RegistryProxy", "k3d");
20
+ export const RuntimeLabel = /* @__PURE__ */ createProperty("K3d::RuntimeLabel", "k3d");
21
+ export const RuntimeOptions = /* @__PURE__ */ createProperty("K3d::RuntimeOptions", "k3d");
22
+ export const Ulimit = /* @__PURE__ */ createProperty("K3d::Ulimit", "k3d");
23
+ export const Volume = /* @__PURE__ */ createProperty("K3d::Volume", "k3d");
24
+ // Re-exports for convenience
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Runtime factory constructors — re-exported from core.
3
+ */
4
+ export { createResource, createProperty } from "@intentius/chant/runtime";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Typed Op step-builder wrappers (chant #1288 Stage 2) — k3dUp/k3dDown with
2
+ // authoring-time types derived from this lexicon's own *Args interfaces (see
3
+ // ./op/builders.ts's module doc). Opt-in: `@intentius/chant/op`'s same-named
4
+ // exports are core's original untyped builders, unchanged.
5
+ export { k3dUp, k3dDown } from "./op/builders.js";
6
+ // Plugin
7
+ export { k3dPlugin } from "./plugin.js";
8
+ // Serializer
9
+ export { k3dSerializer } from "./serializer.js";
10
+ // Generated resources — export everything from generated index
11
+ export * from "./generated/index.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The k3d lexicon's chant audit catalog — metadata for its post-synth
3
+ * checks, contributed via k3dPlugin.auditCatalog() (#687, #1346). Every
4
+ * post-synth check has an entry, or the check contributes nothing to
5
+ * `chant audit`, silently.
6
+ */
7
+ import { auditRule } from "@intentius/chant/audit/catalog";
8
+ export const k3dAuditCatalog = {
9
+ K3D101: auditRule("K3D101", "merge-worthy", "guidance", "nodeFilter matches no node", 'Use a real k3d selector: "all", "loadbalancer", or "server"/"agent" with ":<index>" or ":*".', { category: "correctness" }),
10
+ K3D102: auditRule("K3D102", "merge-worthy", "guidance", "Registry proxy password in the emitted cluster config", "Remove the credential from the declaration; connect the registry via registries.use or configure the proxy outside chant.", { category: "security" }),
11
+ };
@@ -0,0 +1,6 @@
1
+ import { k3d101 } from "./k3d101.js";
2
+ import { k3d102 } from "./k3d102.js";
3
+ export const postSynthChecks = [
4
+ k3d101,
5
+ k3d102,
6
+ ];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * K3D101: malformed nodeFilters.
3
+ *
4
+ * `nodeFilters` are k3d's own selector syntax and the schema types them as
5
+ * bare strings, so a typo validates, applies to nothing, and reports
6
+ * nothing — the port mapping or volume silently lands nowhere. This is the
7
+ * silent-at-create failure worth an error.
8
+ *
9
+ * Valid forms (k3d v5): `all`, `loadbalancer`, or `server`/`agent` with an
10
+ * optional `:<index>` / `:*`, and for port mappings an optional trailing
11
+ * `:direct` / `:proxy`.
12
+ */
13
+ import { load, loadAll } from "js-yaml";
14
+ const VALID_FILTER = /^(all|loadbalancer|server|agent)(:(\d+|\*))?(:(direct|proxy))?$/;
15
+ export function k3dDocuments(ctx) {
16
+ const docs = [];
17
+ for (const [outputName, output] of ctx.outputs) {
18
+ const texts = typeof output === "string"
19
+ ? [[outputName, output]]
20
+ : [
21
+ [outputName, output.primary],
22
+ ...Object.entries(output.files ?? {}),
23
+ ];
24
+ for (const [source, text] of texts) {
25
+ if (!text || !text.includes("k3d.io/"))
26
+ continue;
27
+ try {
28
+ for (const doc of loadAll(text))
29
+ docs.push({ source, doc });
30
+ }
31
+ catch {
32
+ try {
33
+ docs.push({ source, doc: load(text) });
34
+ }
35
+ catch {
36
+ // not YAML — someone else's output
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return docs;
42
+ }
43
+ function collectFilters(value, path, out) {
44
+ if (Array.isArray(value)) {
45
+ value.forEach((v, i) => collectFilters(v, `${path}[${i}]`, out));
46
+ return;
47
+ }
48
+ if (typeof value !== "object" || value === null)
49
+ return;
50
+ for (const [key, v] of Object.entries(value)) {
51
+ if (key === "nodeFilters" && Array.isArray(v)) {
52
+ v.forEach((f, i) => {
53
+ if (typeof f === "string")
54
+ out.push({ path: `${path}.nodeFilters[${i}]`, filter: f });
55
+ });
56
+ continue;
57
+ }
58
+ collectFilters(v, path ? `${path}.${key}` : key, out);
59
+ }
60
+ }
61
+ export const k3d101 = {
62
+ id: "K3D101",
63
+ description: "A nodeFilter that matches no node applies to nothing, silently",
64
+ check(ctx) {
65
+ const diagnostics = [];
66
+ for (const { source, doc } of k3dDocuments(ctx)) {
67
+ const filters = [];
68
+ collectFilters(doc, "", filters);
69
+ for (const { path, filter } of filters) {
70
+ if (VALID_FILTER.test(filter))
71
+ continue;
72
+ diagnostics.push({
73
+ checkId: "K3D101",
74
+ severity: "error",
75
+ message: `${source}: nodeFilter "${filter}" at ${path} is not a k3d selector — valid forms are ` +
76
+ `"all", "loadbalancer", "server"/"agent" with optional ":<index>" or ":*", plus ` +
77
+ `":direct"/":proxy" on port mappings. k3d applies an unmatched filter to nothing and says nothing.`,
78
+ lexicon: "k3d",
79
+ });
80
+ }
81
+ }
82
+ return diagnostics;
83
+ },
84
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * K3D102: registry proxy credential in the emitted config.
3
+ *
4
+ * The other half of K3D001: the lint rule sees literals in source, this
5
+ * sees what actually landed in the artifact — a value that arrived through
6
+ * a variable, a helper, or anything else the AST rule cannot follow. The
7
+ * emitted config is the file people commit, attach to issues and copy to
8
+ * other machines, which is exactly where a registry credential must not be.
9
+ */
10
+ import { k3dDocuments } from "./k3d101.js";
11
+ export const k3d102 = {
12
+ id: "K3D102",
13
+ description: "The emitted cluster config carries a registry proxy password",
14
+ check(ctx) {
15
+ const diagnostics = [];
16
+ for (const { source, doc } of k3dDocuments(ctx)) {
17
+ if (typeof doc !== "object" || doc === null)
18
+ continue;
19
+ const registries = doc.registries;
20
+ const create = registries?.create;
21
+ const proxy = create?.proxy;
22
+ const password = proxy?.password;
23
+ if (typeof password === "string" && password.length > 0) {
24
+ diagnostics.push({
25
+ checkId: "K3D102",
26
+ severity: "error",
27
+ message: `${source}: registries.create.proxy.password is present in the emitted config — ` +
28
+ `this file is the walk-away artifact and a credential inside it is a credential in source. ` +
29
+ `Connect a pre-existing registry via registries.use, or supply the proxy credential outside chant.`,
30
+ lexicon: "k3d",
31
+ });
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,4 @@
1
+ import { registryProxyPasswordRule } from "./registry-proxy-password.js";
2
+ export { registryProxyPasswordRule } from "./registry-proxy-password.js";
3
+ /** All lint rules provided by this lexicon (imported by plugin.ts's lintRules()). */
4
+ export const rules = [registryProxyPasswordRule];
@@ -0,0 +1,81 @@
1
+ import { ts } from "@intentius/chant/lint/typescript";
2
+ /**
3
+ * K3D001: literal registry proxy password in source.
4
+ *
5
+ * The v1alpha5 schema types `registries.create.proxy.password` as a plain
6
+ * string, so nothing upstream stops a declaration from carrying the registry
7
+ * credential verbatim — and a cluster config is exactly the kind of file
8
+ * that gets committed. A literal here is a credential in source, which is
9
+ * the line chant does not cross anywhere else (FTN001, WK8005).
10
+ *
11
+ * Flags a string-literal `password` when it is unambiguously the k3d proxy
12
+ * one: inside a `RegistryProxy(...)` construction, or inside an object
13
+ * assigned to a `proxy` property. A bare `password:` elsewhere in the file
14
+ * is someone else's business.
15
+ */
16
+ export const registryProxyPasswordRule = {
17
+ id: "K3D001",
18
+ severity: "error",
19
+ category: "security",
20
+ description: "Registry proxy password declared as a string literal in source",
21
+ check(context) {
22
+ const diagnostics = [];
23
+ const source = context.sourceFile;
24
+ const flag = (node) => {
25
+ const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
26
+ diagnostics.push({
27
+ ruleId: "K3D001",
28
+ severity: "error",
29
+ message: "registries.create.proxy.password is a literal in source — a committed cluster config " +
30
+ "is a leaked registry credential. Keep the value out of the declaration: connect a " +
31
+ "pre-existing registry via registries.use, or supply the proxy credential outside chant.",
32
+ file: context.filePath,
33
+ line: line + 1,
34
+ column: character + 1,
35
+ });
36
+ };
37
+ const passwordLiteral = (obj) => {
38
+ for (const prop of obj.properties) {
39
+ if (ts.isPropertyAssignment(prop) &&
40
+ ts.isIdentifier(prop.name) &&
41
+ prop.name.text === "password" &&
42
+ (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) &&
43
+ prop.initializer.text.length > 0) {
44
+ return prop.initializer;
45
+ }
46
+ }
47
+ return undefined;
48
+ };
49
+ const visit = (node) => {
50
+ // RegistryProxy({ ..., password: "..." }) — with or without `new`.
51
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
52
+ const callee = node.expression;
53
+ const name = ts.isIdentifier(callee)
54
+ ? callee.text
55
+ : ts.isPropertyAccessExpression(callee)
56
+ ? callee.name.text
57
+ : undefined;
58
+ if (name === "RegistryProxy") {
59
+ const arg = node.arguments?.[0];
60
+ if (arg && ts.isObjectLiteralExpression(arg)) {
61
+ const literal = passwordLiteral(arg);
62
+ if (literal)
63
+ flag(literal);
64
+ }
65
+ }
66
+ }
67
+ // proxy: { ..., password: "..." } — the plain-object form.
68
+ if (ts.isPropertyAssignment(node) &&
69
+ ts.isIdentifier(node.name) &&
70
+ node.name.text === "proxy" &&
71
+ ts.isObjectLiteralExpression(node.initializer)) {
72
+ const literal = passwordLiteral(node.initializer);
73
+ if (literal)
74
+ flag(literal);
75
+ }
76
+ ts.forEachChild(node, visit);
77
+ };
78
+ visit(source);
79
+ return diagnostics;
80
+ },
81
+ };
@@ -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;AAapF;;;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;AAapF;;;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-k3d.json");
10
+ cachedIndex = new LexiconIndex(data);
11
+ return cachedIndex;
12
+ }
13
+ /**
14
+ * Provide k3d completions based on context — Cluster and its property
15
+ * entities, from the generated registry.
16
+ */
17
+ export function completions(ctx) {
18
+ return lexiconCompletions(ctx, getIndex(), "k3d 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;AAa1E;;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;AAa1E;;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-k3d.json");
10
+ cachedIndex = new LexiconIndex(data);
11
+ return cachedIndex;
12
+ }
13
+ /**
14
+ * Provide hover information for k3d 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(`k3d type: \`${entry.resourceType}\``);
24
+ lines.push("");
25
+ if (entry.kind === "resource") {
26
+ lines.push("*Resource entity — serialized as a `k3d.io/v1alpha5` SimpleConfig " +
27
+ "document that `k3d cluster create --config` consumes verbatim*");
28
+ }
29
+ else {
30
+ lines.push("*Property entity — a nested value in a Cluster declaration*");
31
+ }
32
+ return { contents: lines.join("\n") };
33
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * k3d Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `k3d` lexicon. Relocated from the k8s lexicon
4
+ * (chant #1410) so the local-cluster tooling lives with its product:
5
+ * - k3dUp / k3dDown — boot/tear down a local k3d cluster. Since #1411 the
6
+ * kubeconfig default is safe: the caller's default kubeconfig and current
7
+ * context are left alone unless explicitly requested, and `k3dUp` returns
8
+ * `{ context, kubeconfigPath? }` so later steps know what to talk to.
9
+ *
10
+ * The step builders (k3dUp, k3dDown) stay in core and reach authors through
11
+ * `@intentius/chant/op` like the other core builders. The activities are
12
+ * dependency-light — they shell out to the k3d CLI and do not import the k3d
13
+ * declarable surface — so `loadActivities`
14
+ * (`packages/core/src/op/activity-registry.ts`), which imports this module at
15
+ * run time, pulls in nothing expensive.
16
+ */
17
+ export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand, k3dKubeconfigWriteCommand, k3dContextName, } from "./k3d.js";
@@ -0,0 +1,113 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(exec);
4
+ /** `k3d cluster list <name> --no-headers` — non-empty stdout means the cluster exists. */
5
+ export function k3dExistsCommand(name) {
6
+ return `k3d cluster list ${name} --no-headers`;
7
+ }
8
+ /** kubectl context name k3d assigns a cluster: `k3d-<name>`. */
9
+ export function k3dContextName(name) {
10
+ return `k3d-${name}`;
11
+ }
12
+ /** `k3d kubeconfig write <name>` — prints the path of the written kubeconfig. */
13
+ export function k3dKubeconfigWriteCommand(name) {
14
+ return `k3d kubeconfig write ${name}`;
15
+ }
16
+ /**
17
+ * Build the `k3d cluster create` command.
18
+ *
19
+ * Kubeconfig behavior: upstream k3d merges the new cluster into the default
20
+ * kubeconfig and switches the current context. Chant defaults both to `false`
21
+ * (see {@link K3dUpArgs.updateDefaultKubeconfig}) — but only forces its
22
+ * defaults when no `configFile` is given. Each kubeconfig flag is emitted when
23
+ * the caller set it explicitly, or when there is no `configFile`; with a
24
+ * `configFile` and the arg unset, the flag is omitted so the declared config's
25
+ * `options.kubeconfig` governs.
26
+ */
27
+ export function k3dUpCommand(args) {
28
+ const parts = ["k3d", "cluster", "create", args.name];
29
+ if (args.servers !== undefined)
30
+ parts.push(`--servers ${args.servers}`);
31
+ if (args.agents !== undefined)
32
+ parts.push(`--agents ${args.agents}`);
33
+ if (args.image)
34
+ parts.push(`--image ${args.image}`);
35
+ for (const p of args.ports ?? [])
36
+ parts.push(`-p "${p}"`);
37
+ if (args.registryCreate)
38
+ parts.push(`--registry-create ${args.registryCreate}`);
39
+ if (args.configFile)
40
+ parts.push(`--config ${args.configFile}`);
41
+ if (!args.configFile || args.updateDefaultKubeconfig !== undefined) {
42
+ parts.push(`--kubeconfig-update-default=${args.updateDefaultKubeconfig ?? false}`);
43
+ }
44
+ if (!args.configFile || args.switchCurrentContext !== undefined) {
45
+ parts.push(`--kubeconfig-switch-context=${args.switchCurrentContext ?? false}`);
46
+ }
47
+ parts.push("--wait");
48
+ parts.push(`--timeout ${args.timeout ?? "120s"}`);
49
+ return parts.join(" ");
50
+ }
51
+ /** Build the `k3d cluster delete` command. */
52
+ export function k3dDownCommand(args) {
53
+ return `k3d cluster delete ${args.name}`;
54
+ }
55
+ /**
56
+ * Resolve and report how to reach the cluster. Only an explicit
57
+ * `updateDefaultKubeconfig: true` proves the context landed in the default
58
+ * kubeconfig; in every other case (chant's `false` default, or an opaque
59
+ * `configFile` whose `options.kubeconfig` we cannot see) a dedicated
60
+ * kubeconfig is written so the returned path always works.
61
+ */
62
+ async function resolveConnection(args, signal) {
63
+ const context = k3dContextName(args.name);
64
+ if (args.updateDefaultKubeconfig === true) {
65
+ console.log(`k3d cluster "${args.name}" ready — context "${context}" (merged into the default kubeconfig)`);
66
+ return { context };
67
+ }
68
+ const { stdout } = await execAsync(k3dKubeconfigWriteCommand(args.name), { signal });
69
+ const kubeconfigPath = stdout.trim();
70
+ console.log(`k3d cluster "${args.name}" ready — context "${context}", kubeconfig: ${kubeconfigPath}`);
71
+ return { context, kubeconfigPath };
72
+ }
73
+ /**
74
+ * Create a local k3d cluster (vanilla Kubernetes in Docker). Idempotent: if a
75
+ * cluster of the same name already exists it is left as-is. Uses the longInfra
76
+ * profile: a 20m timeout, wide enough that creation can pull the k3s image, and
77
+ * three attempts backing off from 30s.
78
+ *
79
+ * Unlike the upstream CLI, this does NOT touch the caller's default kubeconfig
80
+ * or current context unless asked (see {@link K3dUpArgs.updateDefaultKubeconfig}).
81
+ * It returns the context name and, when the default kubeconfig was left alone,
82
+ * the path of a dedicated kubeconfig — pass those to the steps that apply
83
+ * manifests afterwards.
84
+ */
85
+ export async function k3dUp(args, signal) {
86
+ try {
87
+ const { stdout } = await execAsync(k3dExistsCommand(args.name), { signal });
88
+ if (stdout.trim()) {
89
+ console.log(`k3d cluster "${args.name}" already exists — skipping create`);
90
+ return await resolveConnection(args, signal);
91
+ }
92
+ }
93
+ catch {
94
+ // `cluster list` errors when the cluster is absent — fall through to create.
95
+ }
96
+ const { stdout, stderr } = await execAsync(k3dUpCommand(args), { signal });
97
+ if (stdout)
98
+ console.log(stdout);
99
+ if (stderr)
100
+ console.error(stderr);
101
+ return resolveConnection(args, signal);
102
+ }
103
+ /**
104
+ * Delete a local k3d cluster. Uses fastIdempotent profile — 5m timeout.
105
+ * `k3d cluster delete` is a no-op success when the cluster is already gone.
106
+ */
107
+ export async function k3dDown(args, signal) {
108
+ const { stdout, stderr } = await execAsync(k3dDownCommand(args), { signal });
109
+ if (stdout)
110
+ console.log(stdout);
111
+ if (stderr)
112
+ console.error(stderr);
113
+ }
@@ -0,0 +1,30 @@
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
+ * `k3dUp`/`k3dDown` are unchanged and produce byte-identical `ActivityStep`
10
+ * output; these are purely additive.
11
+ */
12
+ import { activity, takeProfileAndId, } from "@intentius/chant/op";
13
+ /**
14
+ * Create a local k3d cluster — the fully typed twin of core's `k3dUp`.
15
+ * `opts` is {@link K3dUpArgs} itself, minus the positional `name`. Defaults
16
+ * to the `longInfra` profile.
17
+ */
18
+ export const k3dUp = (name, opts) => {
19
+ const { args, profile, id } = takeProfileAndId(opts);
20
+ return activity("k3dUp", { name, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
21
+ };
22
+ /**
23
+ * Delete a local k3d cluster — the fully typed twin of core's `k3dDown`.
24
+ * `opts` is {@link K3dDownArgs} itself, minus the positional `name`.
25
+ * Defaults to the `fastIdempotent` profile.
26
+ */
27
+ export const k3dDown = (name, opts) => {
28
+ const { args, profile, id } = takeProfileAndId(opts);
29
+ return activity("k3dDown", { name, ...args }, { profile: profile ?? "fastIdempotent", ...(id ? { id } : {}) });
30
+ };
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run bundle` in lexicon-k3d.
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}`);