@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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k3d SimpleConfig JSON Schema parser.
|
|
3
|
+
*
|
|
4
|
+
* Parses the single v1alpha5 schema into multiple entity results — the
|
|
5
|
+
* Cluster resource plus one property entity per nested config object
|
|
6
|
+
* (Metadata, Volume, Registries, Options, etc.).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
|
|
10
|
+
import {
|
|
11
|
+
extractConstraints as coreExtractConstraints,
|
|
12
|
+
constraintsIsEmpty as coreConstraintsIsEmpty,
|
|
13
|
+
primaryType,
|
|
14
|
+
type JsonSchemaProperty,
|
|
15
|
+
} from "@intentius/chant/codegen/json-schema";
|
|
16
|
+
|
|
17
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type { PropertyConstraints };
|
|
20
|
+
export { coreConstraintsIsEmpty as constraintsIsEmpty };
|
|
21
|
+
|
|
22
|
+
export interface ParsedProperty {
|
|
23
|
+
name: string;
|
|
24
|
+
tsType: string;
|
|
25
|
+
required: boolean;
|
|
26
|
+
description?: string;
|
|
27
|
+
enum?: string[];
|
|
28
|
+
constraints: PropertyConstraints;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ParsedPropertyType {
|
|
32
|
+
name: string;
|
|
33
|
+
defType: string;
|
|
34
|
+
properties: ParsedProperty[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ParsedEnum {
|
|
38
|
+
name: string;
|
|
39
|
+
values: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ParsedResource {
|
|
43
|
+
typeName: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
properties: ParsedProperty[];
|
|
46
|
+
attributes: Array<{ name: string; tsType: string }>;
|
|
47
|
+
deprecatedProperties: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface K3dParseResult {
|
|
51
|
+
resource: ParsedResource;
|
|
52
|
+
propertyTypes: ParsedPropertyType[];
|
|
53
|
+
enums: ParsedEnum[];
|
|
54
|
+
isProperty?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Schema types ──────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface SchemaDefinition {
|
|
60
|
+
type?: string | string[];
|
|
61
|
+
description?: string;
|
|
62
|
+
// A property value can be a bare boolean ("additionalProperties": false
|
|
63
|
+
// misplaced inside a properties block — see parseProperties).
|
|
64
|
+
properties?: Record<string, SchemaProperty | boolean>;
|
|
65
|
+
required?: string[];
|
|
66
|
+
enum?: string[];
|
|
67
|
+
$ref?: string;
|
|
68
|
+
items?: SchemaProperty;
|
|
69
|
+
examples?: unknown[];
|
|
70
|
+
const?: unknown;
|
|
71
|
+
default?: unknown;
|
|
72
|
+
additionalProperties?: boolean | SchemaProperty;
|
|
73
|
+
minimum?: number;
|
|
74
|
+
maximum?: number;
|
|
75
|
+
minLength?: number;
|
|
76
|
+
maxLength?: number;
|
|
77
|
+
pattern?: string;
|
|
78
|
+
format?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface SchemaProperty extends SchemaDefinition {}
|
|
82
|
+
|
|
83
|
+
interface ConfigSchema {
|
|
84
|
+
title?: string;
|
|
85
|
+
definitions?: Record<string, SchemaDefinition>;
|
|
86
|
+
properties?: Record<string, SchemaProperty | boolean>;
|
|
87
|
+
required?: string[];
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Entity extraction mapping ──────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const RESOURCE_ENTITIES: Array<{
|
|
94
|
+
typeName: string;
|
|
95
|
+
source: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
}> = [
|
|
98
|
+
{
|
|
99
|
+
typeName: "K3d::Cluster",
|
|
100
|
+
source: "root",
|
|
101
|
+
description: "A k3d cluster (SimpleConfig, apiVersion k3d.io/v1alpha5)",
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const PROPERTY_ENTITIES: Array<{
|
|
106
|
+
typeName: string;
|
|
107
|
+
source: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
}> = [
|
|
110
|
+
{ typeName: "K3d::Metadata", source: "metadata", description: "Cluster metadata (name)" },
|
|
111
|
+
{ typeName: "K3d::KubeAPI", source: "kubeAPI", description: "Kubernetes API server exposure settings" },
|
|
112
|
+
{ typeName: "K3d::Volume", source: "volumes:item", description: "A volume mount with node filters" },
|
|
113
|
+
{ typeName: "K3d::Port", source: "ports:item", description: "A port mapping with node filters" },
|
|
114
|
+
{ typeName: "K3d::File", source: "files:item", description: "A file to copy into nodes" },
|
|
115
|
+
{ typeName: "K3d::EnvVar", source: "env:item", description: "An environment variable with node filters" },
|
|
116
|
+
{ typeName: "K3d::HostAlias", source: "hostAliases:item", description: "An IP to hostnames mapping" },
|
|
117
|
+
{ typeName: "K3d::Registries", source: "registries", description: "Registry configuration" },
|
|
118
|
+
{ typeName: "K3d::RegistryCreate", source: "registries.create", description: "Create a new container image registry alongside the cluster" },
|
|
119
|
+
{ typeName: "K3d::RegistryProxy", source: "registries.create.proxy", description: "Pull-through proxy settings for a created registry" },
|
|
120
|
+
{ typeName: "K3d::Options", source: "options", description: "Cluster options (k3d, k3s, kubeconfig, runtime)" },
|
|
121
|
+
{ typeName: "K3d::K3dOptions", source: "options.k3d", description: "k3d-specific options" },
|
|
122
|
+
{ typeName: "K3d::LoadbalancerOptions", source: "options.k3d.loadbalancer", description: "Loadbalancer options" },
|
|
123
|
+
{ typeName: "K3d::K3sOptions", source: "options.k3s", description: "k3s-specific options" },
|
|
124
|
+
{ typeName: "K3d::K3sExtraArg", source: "options.k3s.extraArgs:item", description: "An extra k3s server/agent argument with node filters" },
|
|
125
|
+
{ typeName: "K3d::NodeLabel", source: "options.k3s.nodeLabels:item", description: "A Kubernetes node label with node filters" },
|
|
126
|
+
{ typeName: "K3d::KubeconfigOptions", source: "options.kubeconfig", description: "Kubeconfig update behaviour" },
|
|
127
|
+
{ typeName: "K3d::RuntimeOptions", source: "options.runtime", description: "Container runtime options" },
|
|
128
|
+
{ typeName: "K3d::RuntimeLabel", source: "options.runtime.labels:item", description: "A container runtime label with node filters" },
|
|
129
|
+
{ typeName: "K3d::Ulimit", source: "options.runtime.ulimits:item", description: "A ulimit setting for nodes" },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// ── Parser ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse the k3d SimpleConfig JSON Schema into multiple entity results.
|
|
136
|
+
*/
|
|
137
|
+
export function parseConfigSchema(data: string | Buffer): K3dParseResult[] {
|
|
138
|
+
const schema: ConfigSchema = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
|
|
139
|
+
const results: K3dParseResult[] = [];
|
|
140
|
+
|
|
141
|
+
for (const entity of RESOURCE_ENTITIES) {
|
|
142
|
+
const result = extractEntity(schema, entity);
|
|
143
|
+
if (result) results.push(result);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const entity of PROPERTY_ENTITIES) {
|
|
147
|
+
const result = extractEntity(schema, entity);
|
|
148
|
+
if (result) {
|
|
149
|
+
result.isProperty = true;
|
|
150
|
+
results.push(result);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractEntity(
|
|
158
|
+
schema: ConfigSchema,
|
|
159
|
+
entity: { typeName: string; source: string; description?: string },
|
|
160
|
+
): K3dParseResult | null {
|
|
161
|
+
const def = resolveSource(schema, entity.source);
|
|
162
|
+
if (!def?.properties) return null;
|
|
163
|
+
|
|
164
|
+
const properties = parseProperties(def.properties, new Set(def.required ?? []), schema);
|
|
165
|
+
|
|
166
|
+
const overrides = PROPERTY_OVERRIDES[entity.typeName];
|
|
167
|
+
if (overrides) {
|
|
168
|
+
for (const prop of properties) {
|
|
169
|
+
if (overrides[prop.name]) {
|
|
170
|
+
prop.tsType = overrides[prop.name];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
resource: {
|
|
177
|
+
typeName: entity.typeName,
|
|
178
|
+
description: entity.description ?? def.description,
|
|
179
|
+
properties,
|
|
180
|
+
attributes: [],
|
|
181
|
+
deprecatedProperties: [],
|
|
182
|
+
},
|
|
183
|
+
propertyTypes: [],
|
|
184
|
+
enums: [],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Source resolution ──────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve an extraction source to a schema definition.
|
|
192
|
+
*
|
|
193
|
+
* Sources are:
|
|
194
|
+
* - "root" — the schema document itself (K3d::Cluster)
|
|
195
|
+
* - a dotted path of property names from the root, e.g. "options.k3d"
|
|
196
|
+
* - a dotted path ending in ":item" for array item schemas,
|
|
197
|
+
* e.g. "volumes:item", "options.k3s.extraArgs:item"
|
|
198
|
+
*/
|
|
199
|
+
function resolveSource(schema: ConfigSchema, source: string): SchemaDefinition | null {
|
|
200
|
+
if (source === "root") {
|
|
201
|
+
return { properties: schema.properties, required: schema.required };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const wantsItem = source.endsWith(":item");
|
|
205
|
+
const path = wantsItem ? source.slice(0, -":item".length) : source;
|
|
206
|
+
|
|
207
|
+
let current: SchemaDefinition | null = { properties: schema.properties };
|
|
208
|
+
for (const seg of path.split(".")) {
|
|
209
|
+
const next: SchemaProperty | boolean | undefined = current?.properties?.[seg];
|
|
210
|
+
if (!next || typeof next === "boolean") return null;
|
|
211
|
+
current = next;
|
|
212
|
+
}
|
|
213
|
+
if (!current) return null;
|
|
214
|
+
|
|
215
|
+
if (wantsItem) {
|
|
216
|
+
return current.items ?? null;
|
|
217
|
+
}
|
|
218
|
+
return current;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveRef(ref: string, schema: ConfigSchema): SchemaDefinition | null {
|
|
222
|
+
const prefix = "#/definitions/";
|
|
223
|
+
if (!ref.startsWith(prefix)) return null;
|
|
224
|
+
const defName = ref.slice(prefix.length);
|
|
225
|
+
return schema.definitions?.[defName] ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Property parsing ──────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function parseProperties(
|
|
231
|
+
properties: Record<string, SchemaProperty | boolean>,
|
|
232
|
+
requiredSet: Set<string>,
|
|
233
|
+
schema: ConfigSchema,
|
|
234
|
+
): ParsedProperty[] {
|
|
235
|
+
const result: ParsedProperty[] = [];
|
|
236
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
237
|
+
// Upstream schema bug (present in v5.9.0): inside `registries.properties`
|
|
238
|
+
// the key `"additionalProperties": false` is nested INSIDE the properties
|
|
239
|
+
// block instead of sitting beside it, so a naive walk would emit a config
|
|
240
|
+
// property literally named "additionalProperties" whose schema is the
|
|
241
|
+
// bare boolean `false`. Drop it — it is a misplaced schema keyword, not a
|
|
242
|
+
// property. Keep this guard on regeneration until upstream fixes the
|
|
243
|
+
// schema, or the bogus property comes back.
|
|
244
|
+
if (name === "additionalProperties" && typeof prop === "boolean") continue;
|
|
245
|
+
if (typeof prop === "boolean") continue;
|
|
246
|
+
|
|
247
|
+
const tsType = resolvePropertyType(prop, schema);
|
|
248
|
+
result.push({
|
|
249
|
+
name,
|
|
250
|
+
tsType,
|
|
251
|
+
required: requiredSet.has(name),
|
|
252
|
+
description: prop.description,
|
|
253
|
+
enum: prop.enum,
|
|
254
|
+
constraints: coreExtractConstraints(prop as JsonSchemaProperty),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolvePropertyType(prop: SchemaProperty, schema: ConfigSchema): string {
|
|
261
|
+
if (!prop) return "any";
|
|
262
|
+
|
|
263
|
+
if (prop.$ref) {
|
|
264
|
+
const def = resolveRef(prop.$ref, schema);
|
|
265
|
+
// The only definition in the v1alpha5 schema is nodeFilters
|
|
266
|
+
// (array of strings); resolve through it structurally.
|
|
267
|
+
if (def) return resolvePropertyType(def, schema);
|
|
268
|
+
return "any";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (prop.enum && prop.enum.length > 0) {
|
|
272
|
+
return prop.enum.map((v) => JSON.stringify(v)).join(" | ");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const pt = primaryType(prop.type);
|
|
276
|
+
switch (pt) {
|
|
277
|
+
case "string": return "string";
|
|
278
|
+
case "integer":
|
|
279
|
+
case "number": return "number";
|
|
280
|
+
case "boolean": return "boolean";
|
|
281
|
+
case "array":
|
|
282
|
+
if (prop.items) {
|
|
283
|
+
const itemType = resolvePropertyType(prop.items, schema);
|
|
284
|
+
if (itemType.includes(" | ")) return `(${itemType})[]`;
|
|
285
|
+
return `${itemType}[]`;
|
|
286
|
+
}
|
|
287
|
+
return "any[]";
|
|
288
|
+
case "object": return "Record<string, any>";
|
|
289
|
+
default:
|
|
290
|
+
// Upstream schema quirk (v5.9.0): `options.k3d.timeout` declares
|
|
291
|
+
// `examples` ("60s", "1m", "1m30s") but no `type` at all. It is a Go
|
|
292
|
+
// time.Duration string upstream, so when a type-less schema carries
|
|
293
|
+
// string examples we treat it as a string (duration) rather than `any`.
|
|
294
|
+
if (Array.isArray(prop.examples) && prop.examples.length > 0 && prop.examples.every((e) => typeof e === "string")) {
|
|
295
|
+
return "string";
|
|
296
|
+
}
|
|
297
|
+
return "any";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Property overrides ────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Cross-reference nested objects to their extracted property entity
|
|
305
|
+
* classes so authors get typed constructors instead of Record<string, any>.
|
|
306
|
+
*/
|
|
307
|
+
const PROPERTY_OVERRIDES: Record<string, Record<string, string>> = {
|
|
308
|
+
"K3d::Cluster": {
|
|
309
|
+
metadata: "Metadata",
|
|
310
|
+
kubeAPI: "KubeAPI",
|
|
311
|
+
volumes: "Volume[]",
|
|
312
|
+
ports: "Port[]",
|
|
313
|
+
files: "File[]",
|
|
314
|
+
env: "EnvVar[]",
|
|
315
|
+
hostAliases: "HostAlias[]",
|
|
316
|
+
registries: "Registries",
|
|
317
|
+
options: "Options",
|
|
318
|
+
},
|
|
319
|
+
"K3d::Registries": {
|
|
320
|
+
create: "RegistryCreate",
|
|
321
|
+
},
|
|
322
|
+
"K3d::RegistryCreate": {
|
|
323
|
+
proxy: "RegistryProxy",
|
|
324
|
+
},
|
|
325
|
+
"K3d::Options": {
|
|
326
|
+
k3d: "K3dOptions",
|
|
327
|
+
k3s: "K3sOptions",
|
|
328
|
+
kubeconfig: "KubeconfigOptions",
|
|
329
|
+
runtime: "RuntimeOptions",
|
|
330
|
+
},
|
|
331
|
+
"K3d::K3dOptions": {
|
|
332
|
+
loadbalancer: "LoadbalancerOptions",
|
|
333
|
+
},
|
|
334
|
+
"K3d::K3sOptions": {
|
|
335
|
+
extraArgs: "K3sExtraArg[]",
|
|
336
|
+
nodeLabels: "NodeLabel[]",
|
|
337
|
+
},
|
|
338
|
+
"K3d::RuntimeOptions": {
|
|
339
|
+
labels: "RuntimeLabel[]",
|
|
340
|
+
ulimits: "Ulimit[]",
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Extract short name: "K3d::Cluster" → "Cluster"
|
|
348
|
+
*/
|
|
349
|
+
export function k3dShortName(typeName: string): string {
|
|
350
|
+
const parts = typeName.split("::");
|
|
351
|
+
return parts[parts.length - 1];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Extract service name: always "K3d" — the lexicon is a single flat service.
|
|
356
|
+
*/
|
|
357
|
+
export function k3dServiceName(_typeName: string): string {
|
|
358
|
+
return "K3d";
|
|
359
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { describeObservationConformance } from "@intentius/chant-test-utils";
|
|
3
|
+
import { normalizeObservation } from "@intentius/chant/observation";
|
|
4
|
+
import { describeResources, declaredClusterName, type ExecFn } from "./describe-resources";
|
|
5
|
+
import { k3dPlugin } from "./plugin";
|
|
6
|
+
|
|
7
|
+
const LIST_CMD = "k3d cluster list -o json";
|
|
8
|
+
|
|
9
|
+
function fakeExec(handlers: Record<string, string | Error>): ExecFn {
|
|
10
|
+
return async (command: string) => {
|
|
11
|
+
for (const [prefix, result] of Object.entries(handlers)) {
|
|
12
|
+
if (command.startsWith(prefix)) {
|
|
13
|
+
if (result instanceof Error) throw result;
|
|
14
|
+
return { stdout: result };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`unexpected command: ${command}`);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const RUNNING_CLUSTER = JSON.stringify([
|
|
22
|
+
{
|
|
23
|
+
name: "probe",
|
|
24
|
+
serversCount: 1,
|
|
25
|
+
serversRunning: 1,
|
|
26
|
+
agentsCount: 0,
|
|
27
|
+
agentsRunning: 0,
|
|
28
|
+
nodes: [{ name: "k3d-probe-server-0", role: "server", State: { Running: true } }],
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const OWNED_LABELS = JSON.stringify({
|
|
33
|
+
"app.kubernetes.io/managed-by": "chant",
|
|
34
|
+
"chant.intentius.io/stack": "probe",
|
|
35
|
+
"chant.intentius.io/env": "local",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function options(names: string[] = ["probe"]) {
|
|
39
|
+
return {
|
|
40
|
+
environment: "local",
|
|
41
|
+
buildOutput: "",
|
|
42
|
+
entityNames: names,
|
|
43
|
+
entities: new Map(names.map((n) => [n, { entityType: "K3d::Cluster", props: {} }])),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("k3d describeResources", () => {
|
|
48
|
+
it("reports a running, chant-labelled cluster as present and owned", async () => {
|
|
49
|
+
const result = await describeResources(
|
|
50
|
+
options(),
|
|
51
|
+
fakeExec({ [LIST_CMD]: RUNNING_CLUSTER, "docker inspect": OWNED_LABELS }),
|
|
52
|
+
);
|
|
53
|
+
const { resources } = normalizeObservation(result);
|
|
54
|
+
expect(resources.probe.status).toBe("running");
|
|
55
|
+
expect(resources.probe.ownership).toBe("owned");
|
|
56
|
+
expect(resources.probe.physicalId).toBe("probe");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reports a cluster without the marker as foreign", async () => {
|
|
60
|
+
const result = await describeResources(
|
|
61
|
+
options(),
|
|
62
|
+
fakeExec({ [LIST_CMD]: RUNNING_CLUSTER, "docker inspect": JSON.stringify({ "k3d.cluster": "probe" }) }),
|
|
63
|
+
);
|
|
64
|
+
const { resources } = normalizeObservation(result);
|
|
65
|
+
expect(resources.probe.ownership).toBe("foreign");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports a missing cluster as absent — in neither map", async () => {
|
|
69
|
+
const result = await describeResources(options(), fakeExec({ [LIST_CMD]: "[]", "docker inspect": "{}" }));
|
|
70
|
+
const { resources, unobserved } = normalizeObservation(result);
|
|
71
|
+
expect(resources.probe).toBeUndefined();
|
|
72
|
+
expect(unobserved.probe).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reports read-failed for everything when the list command fails — never absence", async () => {
|
|
76
|
+
const result = await describeResources(
|
|
77
|
+
options(["probe", "second"]),
|
|
78
|
+
fakeExec({ [LIST_CMD]: new Error("Cannot connect to the Docker daemon") }),
|
|
79
|
+
);
|
|
80
|
+
const { resources, unobserved } = normalizeObservation(result);
|
|
81
|
+
expect(Object.keys(resources)).toHaveLength(0);
|
|
82
|
+
expect(unobserved.probe.reason).toBe("read-failed");
|
|
83
|
+
expect(unobserved.second.reason).toBe("read-failed");
|
|
84
|
+
expect(unobserved.probe.detail).toContain("Docker");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("stays present with ownership unknown when only docker inspect fails", async () => {
|
|
88
|
+
const result = await describeResources(
|
|
89
|
+
options(),
|
|
90
|
+
fakeExec({ [LIST_CMD]: RUNNING_CLUSTER, "docker inspect": new Error("no such container") }),
|
|
91
|
+
);
|
|
92
|
+
const { resources } = normalizeObservation(result);
|
|
93
|
+
expect(resources.probe.status).toBe("running");
|
|
94
|
+
expect(resources.probe.ownership).toBe("unknown");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("reports a stopped cluster's status", async () => {
|
|
98
|
+
const stopped = JSON.stringify([
|
|
99
|
+
{ name: "probe", serversCount: 1, serversRunning: 0, agentsCount: 0, agentsRunning: 0 },
|
|
100
|
+
]);
|
|
101
|
+
const result = await describeResources(
|
|
102
|
+
options(),
|
|
103
|
+
fakeExec({ [LIST_CMD]: stopped, "docker inspect": OWNED_LABELS }),
|
|
104
|
+
);
|
|
105
|
+
const { resources } = normalizeObservation(result);
|
|
106
|
+
expect(resources.probe.status).toBe("stopped");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("resolves the cluster name from metadata.name over the entity name", () => {
|
|
110
|
+
expect(
|
|
111
|
+
declaredClusterName({ name: "entity", type: "K3d::Cluster", props: { metadata: { name: "real" } } }),
|
|
112
|
+
).toBe("real");
|
|
113
|
+
expect(declaredClusterName({ name: "entity", type: "K3d::Cluster", props: {} })).toBe("entity");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describeObservationConformance({
|
|
118
|
+
lexicon: "k3d",
|
|
119
|
+
ownershipChannel: k3dPlugin.ownershipChannel,
|
|
120
|
+
scenarios: [
|
|
121
|
+
{
|
|
122
|
+
name: "running owned cluster",
|
|
123
|
+
declared: ["probe"],
|
|
124
|
+
owned: true,
|
|
125
|
+
expectPresent: ["probe"],
|
|
126
|
+
run: () =>
|
|
127
|
+
describeResources(options(), fakeExec({ [LIST_CMD]: RUNNING_CLUSTER, "docker inspect": OWNED_LABELS })),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "docker down",
|
|
131
|
+
declared: ["probe"],
|
|
132
|
+
expectUnobserved: ["probe"],
|
|
133
|
+
run: () => describeResources(options(), fakeExec({ [LIST_CMD]: new Error("docker: not running") })),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "cluster absent",
|
|
137
|
+
declared: ["probe"],
|
|
138
|
+
expectAbsent: ["probe"],
|
|
139
|
+
run: () => describeResources(options(), fakeExec({ [LIST_CMD]: "[]" })),
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
|
|
20
|
+
import { exec } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import type { DescribeResourcesResult } from "@intentius/chant/lexicon";
|
|
23
|
+
import {
|
|
24
|
+
observeEntities,
|
|
25
|
+
type DeclaredEntity,
|
|
26
|
+
type EntityObservation,
|
|
27
|
+
type ObserverAdapter,
|
|
28
|
+
} from "@intentius/chant/observation";
|
|
29
|
+
import { classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
30
|
+
|
|
31
|
+
const execAsync = promisify(exec);
|
|
32
|
+
|
|
33
|
+
const CLUSTER_TYPE = "K3d::Cluster";
|
|
34
|
+
|
|
35
|
+
/** The two commands the observer runs, injectable for tests. */
|
|
36
|
+
export type ExecFn = (command: string) => Promise<{ stdout: string }>;
|
|
37
|
+
|
|
38
|
+
interface K3dNode {
|
|
39
|
+
name?: string;
|
|
40
|
+
role?: string;
|
|
41
|
+
State?: { Running?: boolean; Status?: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface K3dClusterListEntry {
|
|
45
|
+
name?: string;
|
|
46
|
+
nodes?: K3dNode[];
|
|
47
|
+
serversCount?: number;
|
|
48
|
+
serversRunning?: number;
|
|
49
|
+
agentsCount?: number;
|
|
50
|
+
agentsRunning?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The cluster name a declaration resolves to: metadata.name, else the entity name. */
|
|
54
|
+
export function declaredClusterName(entity: DeclaredEntity): string {
|
|
55
|
+
const metadata = entity.props.metadata as Record<string, unknown> | undefined;
|
|
56
|
+
const name = metadata?.name;
|
|
57
|
+
return typeof name === "string" && name.length > 0 ? name : entity.name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clusterStatus(cluster: K3dClusterListEntry): string {
|
|
61
|
+
const total = cluster.serversCount ?? cluster.nodes?.filter((n) => n.role === "server").length ?? 0;
|
|
62
|
+
const running =
|
|
63
|
+
cluster.serversRunning ??
|
|
64
|
+
cluster.nodes?.filter((n) => n.role === "server" && n.State?.Running).length ??
|
|
65
|
+
0;
|
|
66
|
+
if (total === 0) return "unknown";
|
|
67
|
+
if (running === 0) return "stopped";
|
|
68
|
+
return running < total ? "degraded" : "running";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function adapter(execFn: ExecFn): ObserverAdapter<K3dClusterListEntry[]> {
|
|
72
|
+
return {
|
|
73
|
+
async bind() {
|
|
74
|
+
const { stdout } = await execFn("k3d cluster list -o json");
|
|
75
|
+
const parsed = JSON.parse(stdout || "[]");
|
|
76
|
+
return Array.isArray(parsed) ? (parsed as K3dClusterListEntry[]) : [];
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
classifyBindFailure(err) {
|
|
80
|
+
// k3d missing, Docker stopped, or unparsable output — all mean "could
|
|
81
|
+
// not look", never "not there".
|
|
82
|
+
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
83
|
+
return { reason: "read-failed", detail };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async read(clusters, entity): Promise<EntityObservation> {
|
|
87
|
+
if (entity.type !== CLUSTER_TYPE) {
|
|
88
|
+
return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const name = declaredClusterName(entity);
|
|
92
|
+
const cluster = clusters.find((c) => c.name === name);
|
|
93
|
+
if (!cluster) return { absent: true };
|
|
94
|
+
|
|
95
|
+
// The marker lives on the server node's Docker labels; k3d's list JSON
|
|
96
|
+
// does not surface custom labels, so ask Docker. A cluster that is
|
|
97
|
+
// present but uninspectable still reports present — with the honest
|
|
98
|
+
// `unknown` verdict rather than a guessed one.
|
|
99
|
+
let ownership: "owned" | "foreign" | "unknown" = "unknown";
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execFn(
|
|
102
|
+
`docker inspect k3d-${name}-server-0 --format '{{json .Config.Labels}}'`,
|
|
103
|
+
);
|
|
104
|
+
const labels = JSON.parse(stdout.trim().replace(/^'|'$/g, "") || "{}") as Record<string, unknown>;
|
|
105
|
+
ownership = classifyOwnership(labels, LABEL_OWNERSHIP_KEYS);
|
|
106
|
+
} catch {
|
|
107
|
+
ownership = "unknown";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
present: {
|
|
112
|
+
type: CLUSTER_TYPE,
|
|
113
|
+
physicalId: name,
|
|
114
|
+
status: clusterStatus(cluster),
|
|
115
|
+
ownership,
|
|
116
|
+
attributes: {
|
|
117
|
+
servers: cluster.serversCount ?? undefined,
|
|
118
|
+
agents: cluster.agentsCount ?? undefined,
|
|
119
|
+
serversRunning: cluster.serversRunning ?? undefined,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function describeResources(
|
|
128
|
+
options: {
|
|
129
|
+
entityNames: string[];
|
|
130
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
131
|
+
},
|
|
132
|
+
execFn: ExecFn = execAsync,
|
|
133
|
+
): Promise<DescribeResourcesResult> {
|
|
134
|
+
const declared: DeclaredEntity[] = options.entityNames.map((name) => {
|
|
135
|
+
const entity = options.entities.get(name);
|
|
136
|
+
return {
|
|
137
|
+
name,
|
|
138
|
+
type: entity?.entityType ?? "",
|
|
139
|
+
props: entity?.props ?? {},
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return observeEntities(declared, adapter(execFn));
|
|
144
|
+
}
|
|
File without changes
|