@intentius/chant-lexicon-k3d 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/codegen/docs.d.ts +7 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +6 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-lexicon.d.ts +19 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -0
- package/dist/codegen/generate-typescript.d.ts +11 -0
- package/dist/codegen/generate-typescript.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +15 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts +13 -0
- package/dist/codegen/naming.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/codegen/parse.d.ts +57 -0
- package/dist/codegen/parse.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +34 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +22 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/runtime.d.ts +5 -0
- package/dist/generated/runtime.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/integrity.json +9 -0
- package/dist/lint/audit-catalog.d.ts +9 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/post-synth/k3d101.d.ts +19 -0
- package/dist/lint/post-synth/k3d101.d.ts.map +1 -0
- package/dist/lint/post-synth/k3d102.d.ts +12 -0
- package/dist/lint/post-synth/k3d102.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lint/rules/registry-proxy-password.d.ts +17 -0
- package/dist/lint/rules/registry-proxy-password.d.ts.map +1 -0
- package/dist/lint/rules/sample.d.ts +8 -0
- package/dist/lint/rules/sample.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +7 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +6 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +184 -0
- package/dist/op/activities/index.d.ts +17 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/k3d.d.ts +93 -0
- package/dist/op/activities/k3d.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/serializer.d.ts +35 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/spec/fetch.d.ts +39 -0
- package/dist/spec/fetch.d.ts.map +1 -0
- package/dist/testdata/load-fixtures.d.ts +16 -0
- package/dist/testdata/load-fixtures.d.ts.map +1 -0
- package/dist/types/index.d.ts +189 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/codegen/docs.ts +25 -0
- package/src/codegen/generate-cli.ts +36 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +102 -0
- package/src/codegen/generate.test.ts +154 -0
- package/src/codegen/generate.ts +139 -0
- package/src/codegen/naming.ts +56 -0
- package/src/codegen/package.ts +27 -0
- package/src/codegen/parse.ts +359 -0
- package/src/describe-resources.test.ts +142 -0
- package/src/describe-resources.ts +144 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/index.d.ts +189 -0
- package/src/generated/index.ts +27 -0
- package/src/generated/lexicon-k3d.json +184 -0
- package/src/generated/runtime.ts +4 -0
- package/src/index.ts +8 -0
- package/src/lint/audit-catalog.ts +26 -0
- package/src/lint/post-synth/index.ts +9 -0
- package/src/lint/post-synth/k3d101.ts +87 -0
- package/src/lint/post-synth/k3d102.ts +40 -0
- package/src/lint/post-synth/post-synth.test.ts +76 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/registry-proxy-password.test.ts +53 -0
- package/src/lint/rules/registry-proxy-password.ts +93 -0
- package/src/lsp/completions.test.ts +10 -0
- package/src/lsp/completions.ts +21 -0
- package/src/lsp/hover.test.ts +10 -0
- package/src/lsp/hover.ts +39 -0
- package/src/op/activities/index.ts +24 -0
- package/src/op/activities/k3d.test.ts +168 -0
- package/src/op/activities/k3d.ts +183 -0
- package/src/package-cli.ts +41 -0
- package/src/plugin.test.ts +17 -0
- package/src/plugin.ts +114 -0
- package/src/serializer.acceptance.test.ts +94 -0
- package/src/serializer.test.ts +189 -0
- package/src/serializer.ts +202 -0
- package/src/spec/fetch.ts +66 -0
- package/src/testdata/k3d-schema-fixture.json +431 -0
- package/src/testdata/load-fixtures.ts +27 -0
- package/src/validate-cli.ts +5 -0
- package/src/validate.ts +28 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
4
|
+
import { registryProxyPasswordRule } from "./registry-proxy-password";
|
|
5
|
+
|
|
6
|
+
function createContext(code: string, fileName = "cluster.ts"): LintContext {
|
|
7
|
+
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
|
|
8
|
+
return { sourceFile, entities: [], filePath: fileName };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("K3D001: registry proxy password literal", () => {
|
|
12
|
+
test("flags a literal password inside RegistryProxy(...)", () => {
|
|
13
|
+
const diags = registryProxyPasswordRule.check(
|
|
14
|
+
createContext(`const p = RegistryProxy({ remoteURL: "https://registry-1.docker.io", password: "hunter2" });`),
|
|
15
|
+
);
|
|
16
|
+
expect(diags).toHaveLength(1);
|
|
17
|
+
expect(diags[0].ruleId).toBe("K3D001");
|
|
18
|
+
expect(diags[0].severity).toBe("error");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("flags a literal password inside new RegistryProxy(...)", () => {
|
|
22
|
+
const diags = registryProxyPasswordRule.check(
|
|
23
|
+
createContext(`const p = new RegistryProxy({ password: "hunter2" });`),
|
|
24
|
+
);
|
|
25
|
+
expect(diags).toHaveLength(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("flags a literal password in a plain proxy object", () => {
|
|
29
|
+
const diags = registryProxyPasswordRule.check(
|
|
30
|
+
createContext(`const r = RegistryCreate({ proxy: { remoteURL: "https://x", password: \`hunter2\` } });`),
|
|
31
|
+
);
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not flag a password that is not a literal", () => {
|
|
36
|
+
const diags = registryProxyPasswordRule.check(
|
|
37
|
+
createContext(`const p = RegistryProxy({ password: process.env.REGISTRY_PASSWORD });`),
|
|
38
|
+
);
|
|
39
|
+
expect(diags).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("does not flag unrelated password properties", () => {
|
|
43
|
+
const diags = registryProxyPasswordRule.check(
|
|
44
|
+
createContext(`const config = { database: { password: "not-k3d's-business" } };`),
|
|
45
|
+
);
|
|
46
|
+
expect(diags).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not flag an empty literal", () => {
|
|
50
|
+
const diags = registryProxyPasswordRule.check(createContext(`const p = RegistryProxy({ password: "" });`));
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* K3D001: literal registry proxy password in source.
|
|
6
|
+
*
|
|
7
|
+
* The v1alpha5 schema types `registries.create.proxy.password` as a plain
|
|
8
|
+
* string, so nothing upstream stops a declaration from carrying the registry
|
|
9
|
+
* credential verbatim — and a cluster config is exactly the kind of file
|
|
10
|
+
* that gets committed. A literal here is a credential in source, which is
|
|
11
|
+
* the line chant does not cross anywhere else (FTN001, WK8005).
|
|
12
|
+
*
|
|
13
|
+
* Flags a string-literal `password` when it is unambiguously the k3d proxy
|
|
14
|
+
* one: inside a `RegistryProxy(...)` construction, or inside an object
|
|
15
|
+
* assigned to a `proxy` property. A bare `password:` elsewhere in the file
|
|
16
|
+
* is someone else's business.
|
|
17
|
+
*/
|
|
18
|
+
export const registryProxyPasswordRule: LintRule = {
|
|
19
|
+
id: "K3D001",
|
|
20
|
+
severity: "error",
|
|
21
|
+
category: "security",
|
|
22
|
+
description: "Registry proxy password declared as a string literal in source",
|
|
23
|
+
|
|
24
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
25
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
26
|
+
const source = context.sourceFile;
|
|
27
|
+
|
|
28
|
+
const flag = (node: ts.Node) => {
|
|
29
|
+
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
ruleId: "K3D001",
|
|
32
|
+
severity: "error",
|
|
33
|
+
message:
|
|
34
|
+
"registries.create.proxy.password is a literal in source — a committed cluster config " +
|
|
35
|
+
"is a leaked registry credential. Keep the value out of the declaration: connect a " +
|
|
36
|
+
"pre-existing registry via registries.use, or supply the proxy credential outside chant.",
|
|
37
|
+
file: context.filePath,
|
|
38
|
+
line: line + 1,
|
|
39
|
+
column: character + 1,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const passwordLiteral = (obj: ts.ObjectLiteralExpression): ts.Node | undefined => {
|
|
44
|
+
for (const prop of obj.properties) {
|
|
45
|
+
if (
|
|
46
|
+
ts.isPropertyAssignment(prop) &&
|
|
47
|
+
ts.isIdentifier(prop.name) &&
|
|
48
|
+
prop.name.text === "password" &&
|
|
49
|
+
(ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer)) &&
|
|
50
|
+
prop.initializer.text.length > 0
|
|
51
|
+
) {
|
|
52
|
+
return prop.initializer;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const visit = (node: ts.Node) => {
|
|
59
|
+
// RegistryProxy({ ..., password: "..." }) — with or without `new`.
|
|
60
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
61
|
+
const callee = node.expression;
|
|
62
|
+
const name = ts.isIdentifier(callee)
|
|
63
|
+
? callee.text
|
|
64
|
+
: ts.isPropertyAccessExpression(callee)
|
|
65
|
+
? callee.name.text
|
|
66
|
+
: undefined;
|
|
67
|
+
if (name === "RegistryProxy") {
|
|
68
|
+
const arg = node.arguments?.[0];
|
|
69
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
70
|
+
const literal = passwordLiteral(arg);
|
|
71
|
+
if (literal) flag(literal);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// proxy: { ..., password: "..." } — the plain-object form.
|
|
77
|
+
if (
|
|
78
|
+
ts.isPropertyAssignment(node) &&
|
|
79
|
+
ts.isIdentifier(node.name) &&
|
|
80
|
+
node.name.text === "proxy" &&
|
|
81
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
82
|
+
) {
|
|
83
|
+
const literal = passwordLiteral(node.initializer);
|
|
84
|
+
if (literal) flag(literal);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ts.forEachChild(node, visit);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
visit(source);
|
|
91
|
+
return diagnostics;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { completions } from "./completions";
|
|
3
|
+
|
|
4
|
+
describe("LSP completions", () => {
|
|
5
|
+
it("returns an array", () => {
|
|
6
|
+
// TODO: Replace with a real CompletionContext
|
|
7
|
+
const result = completions({} as any);
|
|
8
|
+
expect(Array.isArray(result)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
7
|
+
|
|
8
|
+
function getIndex(): LexiconIndex {
|
|
9
|
+
if (cachedIndex) return cachedIndex;
|
|
10
|
+
const data = require("../generated/lexicon-k3d.json") as Record<string, LexiconEntry>;
|
|
11
|
+
cachedIndex = new LexiconIndex(data);
|
|
12
|
+
return cachedIndex;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provide k3d completions based on context — Cluster and its property
|
|
17
|
+
* entities, from the generated registry.
|
|
18
|
+
*/
|
|
19
|
+
export function completions(ctx: CompletionContext): CompletionItem[] {
|
|
20
|
+
return lexiconCompletions(ctx, getIndex(), "k3d entity");
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hover } from "./hover";
|
|
3
|
+
|
|
4
|
+
describe("LSP hover", () => {
|
|
5
|
+
it("returns undefined for unknown context", () => {
|
|
6
|
+
// TODO: Replace with a real HoverContext
|
|
7
|
+
const result = hover({} as any);
|
|
8
|
+
expect(result).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
});
|
package/src/lsp/hover.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
7
|
+
|
|
8
|
+
function getIndex(): LexiconIndex {
|
|
9
|
+
if (cachedIndex) return cachedIndex;
|
|
10
|
+
const data = require("../generated/lexicon-k3d.json") as Record<string, LexiconEntry>;
|
|
11
|
+
cachedIndex = new LexiconIndex(data);
|
|
12
|
+
return cachedIndex;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provide hover information for k3d entity types.
|
|
17
|
+
*/
|
|
18
|
+
export function hover(ctx: HoverContext): HoverInfo | undefined {
|
|
19
|
+
return lexiconHover(ctx, getIndex(), resourceHover);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resourceHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
|
|
25
|
+
lines.push(`**${className}**`);
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push(`k3d type: \`${entry.resourceType}\``);
|
|
28
|
+
lines.push("");
|
|
29
|
+
if (entry.kind === "resource") {
|
|
30
|
+
lines.push(
|
|
31
|
+
"*Resource entity — serialized as a `k3d.io/v1alpha5` SimpleConfig " +
|
|
32
|
+
"document that `k3d cluster create --config` consumes verbatim*",
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
lines.push("*Property entity — a nested value in a Cluster declaration*");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { contents: lines.join("\n") };
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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, re-exported from the
|
|
11
|
+
* temporal Op-authoring barrel 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 a Temporal worker loads them cheaply.
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
k3dUp,
|
|
17
|
+
k3dDown,
|
|
18
|
+
k3dUpCommand,
|
|
19
|
+
k3dDownCommand,
|
|
20
|
+
k3dExistsCommand,
|
|
21
|
+
k3dKubeconfigWriteCommand,
|
|
22
|
+
k3dContextName,
|
|
23
|
+
} from "./k3d";
|
|
24
|
+
export type { K3dUpArgs, K3dDownArgs, K3dUpResult } from "./k3d";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
k3dUp,
|
|
4
|
+
k3dUpCommand,
|
|
5
|
+
k3dDownCommand,
|
|
6
|
+
k3dExistsCommand,
|
|
7
|
+
k3dKubeconfigWriteCommand,
|
|
8
|
+
k3dContextName,
|
|
9
|
+
} from "./k3d";
|
|
10
|
+
|
|
11
|
+
// The return-shape tests below exercise k3dUp's already-exists skip path, so
|
|
12
|
+
// every command goes through this mock — no real cluster, no k3d binary.
|
|
13
|
+
vi.mock("node:child_process", () => {
|
|
14
|
+
const custom = Symbol.for("nodejs.util.promisify.custom");
|
|
15
|
+
const exec = ((_cmd: string, _opts: unknown, cb?: (...a: unknown[]) => void) => {
|
|
16
|
+
cb?.(new Error("unmocked exec path"));
|
|
17
|
+
}) as unknown as Record<symbol, unknown>;
|
|
18
|
+
exec[custom] = async (cmd: string) => {
|
|
19
|
+
if (cmd.startsWith("k3d cluster list")) return { stdout: "dev up\n", stderr: "" };
|
|
20
|
+
if (cmd.startsWith("k3d kubeconfig write")) {
|
|
21
|
+
return { stdout: "/home/user/.config/k3d/kubeconfig-dev.yaml\n", stderr: "" };
|
|
22
|
+
}
|
|
23
|
+
return { stdout: "", stderr: "" };
|
|
24
|
+
};
|
|
25
|
+
return { exec };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("k3dUpCommand (#704, kubeconfig defaults #1411)", () => {
|
|
29
|
+
test("minimal — name only: safe kubeconfig defaults, --wait with a 120s timeout", () => {
|
|
30
|
+
const cmd = k3dUpCommand({ name: "chant-local" });
|
|
31
|
+
expect(cmd).toBe(
|
|
32
|
+
"k3d cluster create chant-local --kubeconfig-update-default=false --kubeconfig-switch-context=false --wait --timeout 120s",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("servers/agents/image flags", () => {
|
|
37
|
+
const cmd = k3dUpCommand({
|
|
38
|
+
name: "dev",
|
|
39
|
+
servers: 1,
|
|
40
|
+
agents: 2,
|
|
41
|
+
image: "rancher/k3s:v1.31.4-k3s1",
|
|
42
|
+
});
|
|
43
|
+
expect(cmd).toContain("--servers 1");
|
|
44
|
+
expect(cmd).toContain("--agents 2");
|
|
45
|
+
expect(cmd).toContain("--image rancher/k3s:v1.31.4-k3s1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("each port becomes a quoted -p flag", () => {
|
|
49
|
+
const cmd = k3dUpCommand({
|
|
50
|
+
name: "dev",
|
|
51
|
+
ports: ["8080:80@loadbalancer", "8443:443@loadbalancer"],
|
|
52
|
+
});
|
|
53
|
+
expect(cmd).toContain('-p "8080:80@loadbalancer"');
|
|
54
|
+
expect(cmd).toContain('-p "8443:443@loadbalancer"');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("registry and config file", () => {
|
|
58
|
+
const cmd = k3dUpCommand({
|
|
59
|
+
name: "dev",
|
|
60
|
+
registryCreate: "chant-registry",
|
|
61
|
+
configFile: "k3d.yaml",
|
|
62
|
+
});
|
|
63
|
+
expect(cmd).toContain("--registry-create chant-registry");
|
|
64
|
+
expect(cmd).toContain("--config k3d.yaml");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("custom timeout overrides the default", () => {
|
|
68
|
+
const cmd = k3dUpCommand({ name: "dev", timeout: "300s" });
|
|
69
|
+
expect(cmd).toContain("--timeout 300s");
|
|
70
|
+
expect(cmd).not.toContain("120s");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("zero servers is emitted (not dropped as falsy)", () => {
|
|
74
|
+
// servers: 0 is unusual but must round-trip — guarded by `!== undefined`.
|
|
75
|
+
const cmd = k3dUpCommand({ name: "dev", servers: 0 });
|
|
76
|
+
expect(cmd).toContain("--servers 0");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("k3dUpCommand — kubeconfig flag mapping (#1411)", () => {
|
|
81
|
+
test("no configFile, args unset: both flags forced to chant's safe false", () => {
|
|
82
|
+
const cmd = k3dUpCommand({ name: "dev" });
|
|
83
|
+
expect(cmd).toContain("--kubeconfig-update-default=false");
|
|
84
|
+
expect(cmd).toContain("--kubeconfig-switch-context=false");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("configFile present, args unset: no kubeconfig flags — the declared config governs", () => {
|
|
88
|
+
// k3d gives CLI flags precedence over config, so emitting our defaults
|
|
89
|
+
// here would silently override a config's declared `options.kubeconfig`.
|
|
90
|
+
const cmd = k3dUpCommand({ name: "dev", configFile: "k3d.yaml" });
|
|
91
|
+
expect(cmd).not.toContain("--kubeconfig-update-default");
|
|
92
|
+
expect(cmd).not.toContain("--kubeconfig-switch-context");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("explicit true is passed through, even alongside a configFile", () => {
|
|
96
|
+
const cmd = k3dUpCommand({
|
|
97
|
+
name: "dev",
|
|
98
|
+
configFile: "k3d.yaml",
|
|
99
|
+
updateDefaultKubeconfig: true,
|
|
100
|
+
switchCurrentContext: true,
|
|
101
|
+
});
|
|
102
|
+
expect(cmd).toContain("--kubeconfig-update-default=true");
|
|
103
|
+
expect(cmd).toContain("--kubeconfig-switch-context=true");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("explicit false alongside a configFile is also passed (caller overrides config)", () => {
|
|
107
|
+
const cmd = k3dUpCommand({
|
|
108
|
+
name: "dev",
|
|
109
|
+
configFile: "k3d.yaml",
|
|
110
|
+
updateDefaultKubeconfig: false,
|
|
111
|
+
});
|
|
112
|
+
expect(cmd).toContain("--kubeconfig-update-default=false");
|
|
113
|
+
// switchCurrentContext stays unset — the config governs it.
|
|
114
|
+
expect(cmd).not.toContain("--kubeconfig-switch-context");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("flags can be set independently without a configFile", () => {
|
|
118
|
+
const cmd = k3dUpCommand({ name: "dev", switchCurrentContext: true });
|
|
119
|
+
expect(cmd).toContain("--kubeconfig-update-default=false");
|
|
120
|
+
expect(cmd).toContain("--kubeconfig-switch-context=true");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("k3dUp result shape (#1411)", () => {
|
|
125
|
+
test("default (kubeconfig untouched): returns context + dedicated kubeconfig path", async () => {
|
|
126
|
+
// The mocked `cluster list` reports the cluster as existing, so this runs
|
|
127
|
+
// the skip path; the mocked `kubeconfig write` prints a path.
|
|
128
|
+
const result = await k3dUp({ name: "dev" });
|
|
129
|
+
expect(result).toEqual({
|
|
130
|
+
context: "k3d-dev",
|
|
131
|
+
kubeconfigPath: "/home/user/.config/k3d/kubeconfig-dev.yaml",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("explicit updateDefaultKubeconfig: true — context only, no dedicated kubeconfig", async () => {
|
|
136
|
+
const result = await k3dUp({ name: "dev", updateDefaultKubeconfig: true });
|
|
137
|
+
expect(result).toEqual({ context: "k3d-dev" });
|
|
138
|
+
expect(result.kubeconfigPath).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("k3dDownCommand (#704)", () => {
|
|
143
|
+
test("deletes the named cluster", () => {
|
|
144
|
+
expect(k3dDownCommand({ name: "chant-local" })).toBe(
|
|
145
|
+
"k3d cluster delete chant-local",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("k3dExistsCommand (#704)", () => {
|
|
151
|
+
test("lists a single cluster with no headers for an emptiness check", () => {
|
|
152
|
+
expect(k3dExistsCommand("chant-local")).toBe(
|
|
153
|
+
"k3d cluster list chant-local --no-headers",
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("k3dKubeconfigWriteCommand / k3dContextName (#1411)", () => {
|
|
159
|
+
test("writes the named cluster's kubeconfig", () => {
|
|
160
|
+
expect(k3dKubeconfigWriteCommand("chant-local")).toBe(
|
|
161
|
+
"k3d kubeconfig write chant-local",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("context name is k3d-<name>", () => {
|
|
166
|
+
expect(k3dContextName("chant-local")).toBe("k3d-chant-local");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { safeHeartbeat } from "@intentius/chant/op";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
export interface K3dUpArgs {
|
|
8
|
+
/** Cluster name (`k3d cluster create <name>`). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Number of server (control-plane) nodes. */
|
|
11
|
+
servers?: number;
|
|
12
|
+
/** Number of agent (worker) nodes. */
|
|
13
|
+
agents?: number;
|
|
14
|
+
/** k3s image, e.g. `rancher/k3s:v1.31.4-k3s1`. */
|
|
15
|
+
image?: string;
|
|
16
|
+
/** Port mappings, e.g. `["8080:80@loadbalancer"]`. */
|
|
17
|
+
ports?: string[];
|
|
18
|
+
/** Create a managed local registry with this name (`--registry-create`). */
|
|
19
|
+
registryCreate?: string;
|
|
20
|
+
/** Path to a k3d config file (`--config`). */
|
|
21
|
+
configFile?: string;
|
|
22
|
+
/** Readiness timeout for `--wait`. Default: `120s`. */
|
|
23
|
+
timeout?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Merge the new cluster into the caller's default kubeconfig
|
|
26
|
+
* (`--kubeconfig-update-default=<bool>`). Chant's default is `false` —
|
|
27
|
+
* deliberately the opposite of upstream k3d's `true`: a tool that reconciles
|
|
28
|
+
* infrastructure must not repoint the caller's shell. The context-hijack
|
|
29
|
+
* false-failure during #1394's scoping is the receipt. When the default
|
|
30
|
+
* kubeconfig is left alone, {@link k3dUp} writes a dedicated kubeconfig and
|
|
31
|
+
* returns its path so later steps can still reach the cluster.
|
|
32
|
+
*
|
|
33
|
+
* With a `configFile` and this arg unset, no flag is passed at all — the
|
|
34
|
+
* declared config's own `options.kubeconfig` governs (k3d gives CLI flags
|
|
35
|
+
* precedence over config, so a declared `true` keeps working).
|
|
36
|
+
*/
|
|
37
|
+
updateDefaultKubeconfig?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Switch the caller's current kubectl context to the new cluster
|
|
40
|
+
* (`--kubeconfig-switch-context=<bool>`). Chant's default is `false` — see
|
|
41
|
+
* {@link K3dUpArgs.updateDefaultKubeconfig} for why chant inverts upstream's
|
|
42
|
+
* default. Same `configFile` rule: unset alongside a `configFile`, no flag
|
|
43
|
+
* is passed and the config governs.
|
|
44
|
+
*/
|
|
45
|
+
switchCurrentContext?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface K3dDownArgs {
|
|
49
|
+
/** Cluster name to delete. */
|
|
50
|
+
name: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What {@link k3dUp} resolved for talking to the cluster. */
|
|
54
|
+
export interface K3dUpResult {
|
|
55
|
+
/** kubectl context name for the cluster — always `k3d-<name>`. */
|
|
56
|
+
context: string;
|
|
57
|
+
/**
|
|
58
|
+
* Path of the dedicated kubeconfig (`k3d kubeconfig write <name>`), present
|
|
59
|
+
* whenever the caller did not explicitly merge into the default kubeconfig —
|
|
60
|
+
* without it the context exists nowhere a later `kubectl` step could find it.
|
|
61
|
+
*/
|
|
62
|
+
kubeconfigPath?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `k3d cluster list <name> --no-headers` — non-empty stdout means the cluster exists. */
|
|
66
|
+
export function k3dExistsCommand(name: string): string {
|
|
67
|
+
return `k3d cluster list ${name} --no-headers`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** kubectl context name k3d assigns a cluster: `k3d-<name>`. */
|
|
71
|
+
export function k3dContextName(name: string): string {
|
|
72
|
+
return `k3d-${name}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `k3d kubeconfig write <name>` — prints the path of the written kubeconfig. */
|
|
76
|
+
export function k3dKubeconfigWriteCommand(name: string): string {
|
|
77
|
+
return `k3d kubeconfig write ${name}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the `k3d cluster create` command.
|
|
82
|
+
*
|
|
83
|
+
* Kubeconfig behavior: upstream k3d merges the new cluster into the default
|
|
84
|
+
* kubeconfig and switches the current context. Chant defaults both to `false`
|
|
85
|
+
* (see {@link K3dUpArgs.updateDefaultKubeconfig}) — but only forces its
|
|
86
|
+
* defaults when no `configFile` is given. Each kubeconfig flag is emitted when
|
|
87
|
+
* the caller set it explicitly, or when there is no `configFile`; with a
|
|
88
|
+
* `configFile` and the arg unset, the flag is omitted so the declared config's
|
|
89
|
+
* `options.kubeconfig` governs.
|
|
90
|
+
*/
|
|
91
|
+
export function k3dUpCommand(args: K3dUpArgs): string {
|
|
92
|
+
const parts = ["k3d", "cluster", "create", args.name];
|
|
93
|
+
if (args.servers !== undefined) parts.push(`--servers ${args.servers}`);
|
|
94
|
+
if (args.agents !== undefined) parts.push(`--agents ${args.agents}`);
|
|
95
|
+
if (args.image) parts.push(`--image ${args.image}`);
|
|
96
|
+
for (const p of args.ports ?? []) parts.push(`-p "${p}"`);
|
|
97
|
+
if (args.registryCreate) parts.push(`--registry-create ${args.registryCreate}`);
|
|
98
|
+
if (args.configFile) parts.push(`--config ${args.configFile}`);
|
|
99
|
+
if (!args.configFile || args.updateDefaultKubeconfig !== undefined) {
|
|
100
|
+
parts.push(`--kubeconfig-update-default=${args.updateDefaultKubeconfig ?? false}`);
|
|
101
|
+
}
|
|
102
|
+
if (!args.configFile || args.switchCurrentContext !== undefined) {
|
|
103
|
+
parts.push(`--kubeconfig-switch-context=${args.switchCurrentContext ?? false}`);
|
|
104
|
+
}
|
|
105
|
+
parts.push("--wait");
|
|
106
|
+
parts.push(`--timeout ${args.timeout ?? "120s"}`);
|
|
107
|
+
return parts.join(" ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build the `k3d cluster delete` command. */
|
|
111
|
+
export function k3dDownCommand(args: K3dDownArgs): string {
|
|
112
|
+
return `k3d cluster delete ${args.name}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve and report how to reach the cluster. Only an explicit
|
|
117
|
+
* `updateDefaultKubeconfig: true` proves the context landed in the default
|
|
118
|
+
* kubeconfig; in every other case (chant's `false` default, or an opaque
|
|
119
|
+
* `configFile` whose `options.kubeconfig` we cannot see) a dedicated
|
|
120
|
+
* kubeconfig is written so the returned path always works.
|
|
121
|
+
*/
|
|
122
|
+
async function resolveConnection(args: K3dUpArgs, signal?: AbortSignal): Promise<K3dUpResult> {
|
|
123
|
+
const context = k3dContextName(args.name);
|
|
124
|
+
if (args.updateDefaultKubeconfig === true) {
|
|
125
|
+
console.log(
|
|
126
|
+
`k3d cluster "${args.name}" ready — context "${context}" (merged into the default kubeconfig)`,
|
|
127
|
+
);
|
|
128
|
+
return { context };
|
|
129
|
+
}
|
|
130
|
+
const { stdout } = await execAsync(k3dKubeconfigWriteCommand(args.name), { signal });
|
|
131
|
+
const kubeconfigPath = stdout.trim();
|
|
132
|
+
console.log(
|
|
133
|
+
`k3d cluster "${args.name}" ready — context "${context}", kubeconfig: ${kubeconfigPath}`,
|
|
134
|
+
);
|
|
135
|
+
return { context, kubeconfigPath };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a local k3d cluster (vanilla Kubernetes in Docker). Idempotent: if a
|
|
140
|
+
* cluster of the same name already exists it is left as-is. Uses longInfra
|
|
141
|
+
* profile — 20m timeout, heartbeat every 15s (creation may pull the k3s image).
|
|
142
|
+
*
|
|
143
|
+
* Unlike the upstream CLI, this does NOT touch the caller's default kubeconfig
|
|
144
|
+
* or current context unless asked (see {@link K3dUpArgs.updateDefaultKubeconfig}).
|
|
145
|
+
* It returns the context name and, when the default kubeconfig was left alone,
|
|
146
|
+
* the path of a dedicated kubeconfig — pass those to the steps that apply
|
|
147
|
+
* manifests afterwards.
|
|
148
|
+
*/
|
|
149
|
+
export async function k3dUp(args: K3dUpArgs, signal?: AbortSignal): Promise<K3dUpResult> {
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = await execAsync(k3dExistsCommand(args.name), { signal });
|
|
152
|
+
if (stdout.trim()) {
|
|
153
|
+
console.log(`k3d cluster "${args.name}" already exists — skipping create`);
|
|
154
|
+
return await resolveConnection(args, signal);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// `cluster list` errors when the cluster is absent — fall through to create.
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const heartbeatInterval = setInterval(() => {
|
|
161
|
+
safeHeartbeat({ step: "k3d cluster create", cluster: args.name });
|
|
162
|
+
}, 15_000);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const { stdout, stderr } = await execAsync(k3dUpCommand(args), { signal });
|
|
166
|
+
if (stdout) console.log(stdout);
|
|
167
|
+
if (stderr) console.error(stderr);
|
|
168
|
+
} finally {
|
|
169
|
+
clearInterval(heartbeatInterval);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return resolveConnection(args, signal);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete a local k3d cluster. Uses fastIdempotent profile — 5m timeout.
|
|
177
|
+
* `k3d cluster delete` is a no-op success when the cluster is already gone.
|
|
178
|
+
*/
|
|
179
|
+
export async function k3dDown(args: K3dDownArgs, signal?: AbortSignal): Promise<void> {
|
|
180
|
+
const { stdout, stderr } = await execAsync(k3dDownCommand(args), { signal });
|
|
181
|
+
if (stdout) console.log(stdout);
|
|
182
|
+
if (stderr) console.error(stderr);
|
|
183
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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";
|
|
6
|
+
import { packageLexicon } from "./codegen/package";
|
|
7
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
const pkgDir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
// 1. Generate src/generated/ files (writeGeneratedFiles resolves its own target)
|
|
14
|
+
const genResult = await generate({ verbose: true });
|
|
15
|
+
writeGeneratedFiles(genResult);
|
|
16
|
+
|
|
17
|
+
// 2. Run package pipeline and write dist/
|
|
18
|
+
const { spec, stats } = await packageLexicon({ verbose: true });
|
|
19
|
+
|
|
20
|
+
const distDir = join(dirname(pkgDir), "dist");
|
|
21
|
+
mkdirSync(join(distDir, "types"), { recursive: true });
|
|
22
|
+
mkdirSync(join(distDir, "rules"), { recursive: true });
|
|
23
|
+
mkdirSync(join(distDir, "skills"), { recursive: true });
|
|
24
|
+
|
|
25
|
+
writeFileSync(join(distDir, "manifest.json"), JSON.stringify(spec.manifest, null, 2));
|
|
26
|
+
writeFileSync(join(distDir, "meta.json"), spec.registry);
|
|
27
|
+
writeFileSync(join(distDir, "types", "index.d.ts"), spec.typesDTS);
|
|
28
|
+
|
|
29
|
+
for (const [name, content] of spec.rules) {
|
|
30
|
+
writeFileSync(join(distDir, "rules", name), content);
|
|
31
|
+
}
|
|
32
|
+
for (const [name, content] of spec.skills) {
|
|
33
|
+
writeFileSync(join(distDir, "skills", name), content);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (spec.integrity) {
|
|
37
|
+
writeFileSync(join(distDir, "integrity.json"), JSON.stringify(spec.integrity, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
41
|
+
console.error(`dist/ written to ${distDir}`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { k3dPlugin } from "./plugin";
|
|
3
|
+
import { isLexiconPlugin } from "@intentius/chant/lexicon";
|
|
4
|
+
|
|
5
|
+
describe("k3d plugin", () => {
|
|
6
|
+
it("is a valid LexiconPlugin", () => {
|
|
7
|
+
expect(isLexiconPlugin(k3dPlugin)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("has the correct name", () => {
|
|
11
|
+
expect(k3dPlugin.name).toBe("k3d");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("has a serializer", () => {
|
|
15
|
+
expect(k3dPlugin.serializer).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
});
|