@intentius/chant-lexicon-k3s 0.46.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/describe-resources.d.ts +82 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.d.ts +1 -1
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/k3s106.d.ts +14 -0
- package/dist/lint/post-synth/k3s106.d.ts.map +1 -0
- package/dist/lint/post-synth/k3s107.d.ts +13 -0
- package/dist/lint/post-synth/k3s107.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +22 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/k3s.d.ts +98 -0
- package/dist/op/activities/k3s.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/skills/chant-k3s.md +45 -0
- package/package.json +8 -3
- package/src/describe-resources.test.ts +209 -0
- package/src/describe-resources.ts +258 -0
- package/src/lint/audit-catalog.ts +13 -1
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/k3s106.ts +57 -0
- package/src/lint/post-synth/k3s107.ts +66 -0
- package/src/lint/post-synth/post-synth.test.ts +86 -0
- package/src/op/activities/index.ts +30 -0
- package/src/op/activities/k3s.test.ts +182 -0
- package/src/op/activities/k3s.ts +170 -0
- package/src/plugin.ts +17 -0
- package/src/skills/chant-k3s.md +45 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live observation for declared k3s Server/Agent entities (#1603).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the #1412 k3d answer for the k3s shape, and reuses the same
|
|
5
|
+
* `k8s.profiles.<env>.context` binding helm and the k8s lexicon read
|
|
6
|
+
* (`@intentius/chant/kubectl-context`, #1488): the kubectl context is a
|
|
7
|
+
* property of the cluster chant talks to, not of any one lexicon, so k3s
|
|
8
|
+
* does not invent a second binding namespace for the same thing.
|
|
9
|
+
*
|
|
10
|
+
* ## What this can honestly say
|
|
11
|
+
*
|
|
12
|
+
* A declared `K3s::Server` / `K3s::Agent` is chant's description of one node
|
|
13
|
+
* joining a cluster. From where chant runs — never the host itself — the
|
|
14
|
+
* only observables are ones the apiserver answers:
|
|
15
|
+
*
|
|
16
|
+
* - the declared kubectl context exists and answers at all (`kubectl
|
|
17
|
+
* version`), which also reports the live k3s build so a caller can see
|
|
18
|
+
* it against the pin (`K3S_VERSION`);
|
|
19
|
+
* - the node the entity names is registered and its `Ready` condition.
|
|
20
|
+
*
|
|
21
|
+
* Everything that would require reaching the host directly — whether the
|
|
22
|
+
* `k3s` systemd unit is up, what `/etc/rancher/k3s/config.yaml` currently
|
|
23
|
+
* holds on disk — is out of scope. That is the provisioning boundary #1598
|
|
24
|
+
* draws, and this reader never crosses it.
|
|
25
|
+
*
|
|
26
|
+
* `K3s::Registries` has no live counterpart at all: `registries.yaml` is
|
|
27
|
+
* consumed once at containerd startup and leaves no object an apiserver
|
|
28
|
+
* serves, so it reads `unsupported-kind` — honest, not a gap to close here.
|
|
29
|
+
*
|
|
30
|
+
* ## Node identity
|
|
31
|
+
*
|
|
32
|
+
* The only address chant can back up is a declared `node-name`. Unset, k3s
|
|
33
|
+
* registers the node under the host's own hostname — which chant, running
|
|
34
|
+
* off the host, cannot know or guess. An entity with no `node-name` is
|
|
35
|
+
* `read-failed`, never a guessed address and never `absent`.
|
|
36
|
+
*
|
|
37
|
+
* ## Ownership rides node-label (#1603)
|
|
38
|
+
*
|
|
39
|
+
* The serializer stamps chant's marker into `node-label` when a build carries
|
|
40
|
+
* ownership (`./serializer.ts`), which lands on the registered Node as
|
|
41
|
+
* ordinary Kubernetes labels — the durable channel a host config file has no
|
|
42
|
+
* other way to carry. Read back here the same way every label-based lexicon
|
|
43
|
+
* does (`LABEL_OWNERSHIP_KEYS`).
|
|
44
|
+
*
|
|
45
|
+
* ## Tri-state (#1089, and the #1488 lesson)
|
|
46
|
+
*
|
|
47
|
+
* An unreachable apiserver — context missing, connection refused, no
|
|
48
|
+
* credentials — is `read-failed`/`no-credentials`/`no-binding` for every
|
|
49
|
+
* declared entity, naming the context that was actually read, never
|
|
50
|
+
* `absent`. Only a genuine per-node `NotFound` is absence.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { exec } from "node:child_process";
|
|
54
|
+
import { promisify } from "node:util";
|
|
55
|
+
import type { DescribeResourcesResult } from "@intentius/chant/lexicon";
|
|
56
|
+
import {
|
|
57
|
+
observeEntities,
|
|
58
|
+
type DeclaredEntity,
|
|
59
|
+
type EntityObservation,
|
|
60
|
+
type ObserverAdapter,
|
|
61
|
+
} from "@intentius/chant/observation";
|
|
62
|
+
import { classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
63
|
+
import { loadChantConfigUpward } from "@intentius/chant/config";
|
|
64
|
+
import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
|
|
65
|
+
import { SERVER_TYPE, AGENT_TYPE } from "./serializer";
|
|
66
|
+
import { K3S_VERSION } from "./spec/fetch";
|
|
67
|
+
|
|
68
|
+
/** Injectable command runner, so tests drive every branch without kubectl or a cluster. */
|
|
69
|
+
export type ExecFn = (command: string) => Promise<{ stdout: string }>;
|
|
70
|
+
|
|
71
|
+
const execAsync = promisify(exec);
|
|
72
|
+
|
|
73
|
+
/** Shell-quote one argv element (same convention as helm's release-observe). */
|
|
74
|
+
function q(arg: string): string {
|
|
75
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The environment's kube context (#1488): the declared `k8s.profiles.<env>`
|
|
80
|
+
* binding when present, ambient otherwise. This is deliberately the SAME
|
|
81
|
+
* binding the k8s and helm lexicons read — a cluster is one target however
|
|
82
|
+
* many lexicons observe it — not a `k3s.profiles` of its own.
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveK3sContext(environment: string): Promise<string | undefined> {
|
|
85
|
+
try {
|
|
86
|
+
const { config } = await loadChantConfigUpward(process.cwd());
|
|
87
|
+
return (await resolveClusterTarget(config as Record<string, unknown>, environment, "k3s")).context;
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The declared node identity: the `node-name` flag, chant's only honest
|
|
94
|
+
* source. Absent means k3s will name the node after the host's own hostname
|
|
95
|
+
* — unknowable from here, so there is nothing to query by. */
|
|
96
|
+
export function declaredNodeName(entity: DeclaredEntity): string | undefined {
|
|
97
|
+
const name = (entity.props as Record<string, unknown> | undefined)?.["node-name"];
|
|
98
|
+
return typeof name === "string" && name.length > 0 ? name : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface K3sNodeCondition {
|
|
102
|
+
type?: string;
|
|
103
|
+
status?: string;
|
|
104
|
+
reason?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface K3sNode {
|
|
108
|
+
metadata?: {
|
|
109
|
+
uid?: string;
|
|
110
|
+
labels?: Record<string, string>;
|
|
111
|
+
creationTimestamp?: string;
|
|
112
|
+
};
|
|
113
|
+
status?: {
|
|
114
|
+
nodeInfo?: { kubeletVersion?: string };
|
|
115
|
+
conditions?: K3sNodeCondition[];
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The node's `Ready` condition as a status word: no condition at all reads
|
|
120
|
+
* `unknown` (asked, and the object said nothing) rather than a guess. */
|
|
121
|
+
function nodeReadyStatus(node: K3sNode): string {
|
|
122
|
+
const ready = node.status?.conditions?.find((c) => c.type === "Ready");
|
|
123
|
+
if (!ready) return "unknown";
|
|
124
|
+
if (ready.status === "True") return "Ready";
|
|
125
|
+
return ready.reason && ready.reason.length > 0 ? ready.reason : "NotReady";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
129
|
+
const out: Record<string, unknown> = {};
|
|
130
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
131
|
+
if (v !== undefined) out[k] = v;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface K3sClient {
|
|
137
|
+
exec: ExecFn;
|
|
138
|
+
ctxFlag: string;
|
|
139
|
+
/** `kubectl version`'s serverVersion.gitVersion — the live k3s build. */
|
|
140
|
+
k3sVersion?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function adapter(execFn: ExecFn, environment: string): ObserverAdapter<K3sClient> {
|
|
144
|
+
// Captured so a bind() failure can name the exact context it tried,
|
|
145
|
+
// per the #1488 lesson — a bare kubectl error rarely does.
|
|
146
|
+
let contextTried: string | undefined;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async bind() {
|
|
150
|
+
const context = await resolveK3sContext(environment);
|
|
151
|
+
contextTried = context;
|
|
152
|
+
const ctxFlag = context ? ` --context ${q(context)}` : "";
|
|
153
|
+
|
|
154
|
+
const { stdout } = await execFn(`kubectl version -o json${ctxFlag}`);
|
|
155
|
+
const parsed = JSON.parse(stdout) as { serverVersion?: { gitVersion?: string } };
|
|
156
|
+
return { exec: execFn, ctxFlag, k3sVersion: parsed.serverVersion?.gitVersion };
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
classifyBindFailure(err) {
|
|
160
|
+
const outcome = classifyKubectlFailure(err);
|
|
161
|
+
const named = contextTried
|
|
162
|
+
? `context "${contextTried}" (from k8s.profiles.${environment}.context)`
|
|
163
|
+
: "the ambient kubectl context (no k8s.profiles binding declared)";
|
|
164
|
+
const raw = outcome.kind === "unobserved" ? outcome.detail : err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
165
|
+
const reason = outcome.kind === "unobserved" ? outcome.reason : "read-failed";
|
|
166
|
+
return { reason, detail: `${named}: ${raw}` };
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async read(client, entity): Promise<EntityObservation> {
|
|
170
|
+
if (entity.type !== SERVER_TYPE && entity.type !== AGENT_TYPE) {
|
|
171
|
+
return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const name = declaredNodeName(entity);
|
|
175
|
+
if (!name) {
|
|
176
|
+
return {
|
|
177
|
+
unobserved: {
|
|
178
|
+
reason: "read-failed",
|
|
179
|
+
detail:
|
|
180
|
+
"no `node-name` declared — unset, k3s names the node after the host's own hostname, which chant cannot know from here",
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const command = `kubectl get node ${q(name)} -o json${client.ctxFlag}`;
|
|
186
|
+
try {
|
|
187
|
+
const { stdout } = await client.exec(command);
|
|
188
|
+
const node = JSON.parse(stdout) as K3sNode;
|
|
189
|
+
const ownership = classifyOwnership(node.metadata?.labels, LABEL_OWNERSHIP_KEYS);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
present: {
|
|
193
|
+
type: entity.type,
|
|
194
|
+
physicalId: node.metadata?.uid ?? name,
|
|
195
|
+
status: nodeReadyStatus(node),
|
|
196
|
+
lastUpdated: node.metadata?.creationTimestamp,
|
|
197
|
+
ownership,
|
|
198
|
+
attributes: pruneUndefined({
|
|
199
|
+
nodeName: name,
|
|
200
|
+
kubeletVersion: node.status?.nodeInfo?.kubeletVersion,
|
|
201
|
+
k3sVersion: client.k3sVersion,
|
|
202
|
+
versionMatchesPin: client.k3sVersion ? client.k3sVersion === K3S_VERSION : undefined,
|
|
203
|
+
labels: node.metadata?.labels,
|
|
204
|
+
}),
|
|
205
|
+
},
|
|
206
|
+
queried: command,
|
|
207
|
+
};
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const outcome = classifyKubectlFailure(err);
|
|
210
|
+
if (outcome.kind === "absent") return { absent: true, queried: command };
|
|
211
|
+
return { unobserved: { reason: outcome.reason, detail: outcome.detail }, queried: command };
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface DescribeResourcesOptions {
|
|
218
|
+
environment: string;
|
|
219
|
+
buildOutput: string;
|
|
220
|
+
entityNames: string[];
|
|
221
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
222
|
+
/** Restrict to chant-owned nodes (#1348). Withheld foreign nodes are
|
|
223
|
+
* `filtered`, never a silent drop into `absent`. */
|
|
224
|
+
owned?: boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function describeResources(
|
|
228
|
+
options: DescribeResourcesOptions,
|
|
229
|
+
execFn: ExecFn = execAsync,
|
|
230
|
+
): Promise<DescribeResourcesResult> {
|
|
231
|
+
const declared: DeclaredEntity[] = options.entityNames.map((name) => {
|
|
232
|
+
const entity = options.entities.get(name);
|
|
233
|
+
return {
|
|
234
|
+
name,
|
|
235
|
+
type: entity?.entityType ?? "",
|
|
236
|
+
props: entity?.props ?? {},
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const result = await observeEntities(declared, adapter(execFn, options.environment));
|
|
241
|
+
if (!options.owned) return result;
|
|
242
|
+
|
|
243
|
+
// `--owned`: withhold a node that is present but carries no chant marker.
|
|
244
|
+
// Withheld is `filtered`, never absent — a foreign node still exists.
|
|
245
|
+
const resources = { ...result.resources };
|
|
246
|
+
const unobserved = { ...result.unobserved };
|
|
247
|
+
for (const [name, meta] of Object.entries(result.resources)) {
|
|
248
|
+
if (meta.ownership === "owned") continue;
|
|
249
|
+
delete resources[name];
|
|
250
|
+
unobserved[name] = {
|
|
251
|
+
type: meta.type,
|
|
252
|
+
reason: "filtered",
|
|
253
|
+
detail: "live node carries no chant ownership marker and --owned was requested",
|
|
254
|
+
...(result.queried?.[name] ? { queried: result.queried[name] } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { ...result, resources, unobserved };
|
|
258
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* post-synth check has an entry, or the check contributes nothing to
|
|
5
5
|
* `chant audit`, silently.
|
|
6
6
|
*
|
|
7
|
-
* All
|
|
7
|
+
* All checks read the chant model (`ctx.entities`) rather than the
|
|
8
8
|
* emitted YAML — an emitted config.yaml carries no marker naming its own
|
|
9
9
|
* role — so every entry is constructed directly with `yamlBased: false`.
|
|
10
10
|
*/
|
|
@@ -50,4 +50,16 @@ export const k3sAuditCatalog: Record<string, RuleMeta> = {
|
|
|
50
50
|
"Registry TLS verification disabled",
|
|
51
51
|
"Remove `insecure_skip_verify`; pin the registry CA via `ca_file`.",
|
|
52
52
|
),
|
|
53
|
+
K3S106: entityRule(
|
|
54
|
+
"K3S106",
|
|
55
|
+
"correctness",
|
|
56
|
+
"tls-san missing for a declared bind/advertise address",
|
|
57
|
+
"Add the bind-address / advertise-address value to `tls-san`.",
|
|
58
|
+
),
|
|
59
|
+
K3S107: entityRule(
|
|
60
|
+
"K3S107",
|
|
61
|
+
"correctness",
|
|
62
|
+
"Disabled component still configured",
|
|
63
|
+
"Remove the component's config key, or drop it from `disable`.",
|
|
64
|
+
),
|
|
53
65
|
};
|
|
@@ -5,6 +5,8 @@ import { k3s102 } from "./k3s102";
|
|
|
5
5
|
import { k3s103 } from "./k3s103";
|
|
6
6
|
import { k3s104 } from "./k3s104";
|
|
7
7
|
import { k3s105 } from "./k3s105";
|
|
8
|
+
import { k3s106 } from "./k3s106";
|
|
9
|
+
import { k3s107 } from "./k3s107";
|
|
8
10
|
|
|
9
11
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
10
12
|
k3s101,
|
|
@@ -12,4 +14,6 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
12
14
|
k3s103,
|
|
13
15
|
k3s104,
|
|
14
16
|
k3s105,
|
|
17
|
+
k3s106,
|
|
18
|
+
k3s107,
|
|
15
19
|
];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K3S106: tls-san missing when the server is reachable at a declared
|
|
3
|
+
* address beyond the implicit default.
|
|
4
|
+
*
|
|
5
|
+
* The kube-apiserver certificate is only valid for the addresses baked
|
|
6
|
+
* into it. `bind-address` and `advertise-address` both say "clients reach
|
|
7
|
+
* this server somewhere other than its bare node-ip" — a fixed bind
|
|
8
|
+
* address, or a front-door address for an HA load balancer / VIP. Either
|
|
9
|
+
* one without a matching `tls-san` entry means the first connection
|
|
10
|
+
* through that address hits a certificate mismatch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
PostSynthCheck,
|
|
15
|
+
PostSynthContext,
|
|
16
|
+
PostSynthDiagnostic,
|
|
17
|
+
} from "@intentius/chant/lint/post-synth";
|
|
18
|
+
import { entitiesOfType } from "./k3s-helpers";
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && value.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const k3s106: PostSynthCheck = {
|
|
25
|
+
id: "K3S106",
|
|
26
|
+
description: "tls-san is missing while bind-address or advertise-address is declared",
|
|
27
|
+
|
|
28
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
29
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
30
|
+
for (const entity of entitiesOfType(ctx, "K3s::Server")) {
|
|
31
|
+
const tlsSan = entity.props["tls-san"];
|
|
32
|
+
const hasTlsSan = isNonEmptyString(tlsSan) || (Array.isArray(tlsSan) && tlsSan.length > 0);
|
|
33
|
+
if (hasTlsSan) continue;
|
|
34
|
+
|
|
35
|
+
const bindAddress = entity.props["bind-address"];
|
|
36
|
+
const advertiseAddress = entity.props["advertise-address"];
|
|
37
|
+
const declaredField = isNonEmptyString(bindAddress)
|
|
38
|
+
? "bind-address"
|
|
39
|
+
: isNonEmptyString(advertiseAddress)
|
|
40
|
+
? "advertise-address"
|
|
41
|
+
: undefined;
|
|
42
|
+
if (!declaredField) continue;
|
|
43
|
+
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "K3S106",
|
|
46
|
+
severity: "warning",
|
|
47
|
+
message:
|
|
48
|
+
`"${entity.name}" sets \`${declaredField}\` but declares no \`tls-san\` — ` +
|
|
49
|
+
"the apiserver certificate won't cover that address, and the first client to reach " +
|
|
50
|
+
"it there hits a TLS mismatch. Add it to `tls-san`.",
|
|
51
|
+
entity: entity.name,
|
|
52
|
+
lexicon: "k3s",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return diagnostics;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* K3S107: a packaged component is disabled but also configured.
|
|
3
|
+
*
|
|
4
|
+
* `disable` removes a packaged component entirely — the flags that
|
|
5
|
+
* configure it (`cluster-dns` for coredns, `servicelb-namespace` for
|
|
6
|
+
* servicelb, `default-local-storage-path` for local-storage) then land in
|
|
7
|
+
* config.yaml for a component that never starts. Dead config, and a
|
|
8
|
+
* signal the `disable` and the config drifted out of sync rather than
|
|
9
|
+
* being written together.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
PostSynthCheck,
|
|
14
|
+
PostSynthContext,
|
|
15
|
+
PostSynthDiagnostic,
|
|
16
|
+
} from "@intentius/chant/lint/post-synth";
|
|
17
|
+
import { entitiesOfType } from "./k3s-helpers";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Packaged component name (as it appears in `disable`) to the config keys
|
|
21
|
+
* that only make sense while that component runs. Limited to the
|
|
22
|
+
* components the k3s server flag surface actually has dedicated
|
|
23
|
+
* configuration for — traefik and metrics-server are configured via Helm
|
|
24
|
+
* chart manifests, not server flags, so there is nothing here to conflict.
|
|
25
|
+
*/
|
|
26
|
+
const COMPONENT_CONFIG_KEYS: Record<string, string[]> = {
|
|
27
|
+
coredns: ["cluster-dns"],
|
|
28
|
+
servicelb: ["servicelb-namespace"],
|
|
29
|
+
"local-storage": ["default-local-storage-path"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const k3s107: PostSynthCheck = {
|
|
33
|
+
id: "K3S107",
|
|
34
|
+
description: "disable names a component the config also configures",
|
|
35
|
+
|
|
36
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
37
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
38
|
+
for (const entity of entitiesOfType(ctx, "K3s::Server")) {
|
|
39
|
+
const raw = entity.props.disable;
|
|
40
|
+
const disabled = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
|
|
41
|
+
for (const component of disabled) {
|
|
42
|
+
if (typeof component !== "string") continue;
|
|
43
|
+
const configKeys = COMPONENT_CONFIG_KEYS[component];
|
|
44
|
+
if (!configKeys) continue;
|
|
45
|
+
for (const key of configKeys) {
|
|
46
|
+
const value = entity.props[key];
|
|
47
|
+
const isSet =
|
|
48
|
+
(typeof value === "string" && value.length > 0) ||
|
|
49
|
+
(Array.isArray(value) && value.length > 0);
|
|
50
|
+
if (!isSet) continue;
|
|
51
|
+
diagnostics.push({
|
|
52
|
+
checkId: "K3S107",
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message:
|
|
55
|
+
`"${entity.name}" disables \`${component}\` but also sets \`${key}\` — ` +
|
|
56
|
+
`that config has no effect on a component that never starts. Remove \`${key}\`, ` +
|
|
57
|
+
`or drop \`${component}\` from \`disable\`.`,
|
|
58
|
+
entity: entity.name,
|
|
59
|
+
lexicon: "k3s",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return diagnostics;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -5,6 +5,8 @@ import { k3s102 } from "./k3s102";
|
|
|
5
5
|
import { k3s103 } from "./k3s103";
|
|
6
6
|
import { k3s104 } from "./k3s104";
|
|
7
7
|
import { k3s105 } from "./k3s105";
|
|
8
|
+
import { k3s106 } from "./k3s106";
|
|
9
|
+
import { k3s107 } from "./k3s107";
|
|
8
10
|
|
|
9
11
|
function makeCtx(entities: Record<string, { entityType: string; props: Record<string, unknown> }>): PostSynthContext {
|
|
10
12
|
return {
|
|
@@ -135,3 +137,87 @@ describe("K3S105: TLS verification disabled for a registry", () => {
|
|
|
135
137
|
expect(diags).toHaveLength(0);
|
|
136
138
|
});
|
|
137
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("K3S106: tls-san missing for a declared bind/advertise address", () => {
|
|
142
|
+
test("flags bind-address with no tls-san", () => {
|
|
143
|
+
const diags = k3s106.check(
|
|
144
|
+
makeCtx({ cp: { entityType: "K3s::Server", props: { "bind-address": "10.0.0.10" } } }),
|
|
145
|
+
);
|
|
146
|
+
expect(diags).toHaveLength(1);
|
|
147
|
+
expect(diags[0].checkId).toBe("K3S106");
|
|
148
|
+
expect(diags[0].severity).toBe("warning");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("flags advertise-address with no tls-san", () => {
|
|
152
|
+
const diags = k3s106.check(
|
|
153
|
+
makeCtx({ cp: { entityType: "K3s::Server", props: { "advertise-address": "10.0.0.20" } } }),
|
|
154
|
+
);
|
|
155
|
+
expect(diags).toHaveLength(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("passes bind-address with tls-san set, and neither address declared", () => {
|
|
159
|
+
const diags = k3s106.check(
|
|
160
|
+
makeCtx({
|
|
161
|
+
a: {
|
|
162
|
+
entityType: "K3s::Server",
|
|
163
|
+
props: { "bind-address": "10.0.0.10", "tls-san": ["10.0.0.10"] },
|
|
164
|
+
},
|
|
165
|
+
b: { entityType: "K3s::Server", props: {} },
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
expect(diags).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("K3S107: disabled component still configured", () => {
|
|
173
|
+
test("flags servicelb-namespace with servicelb disabled", () => {
|
|
174
|
+
const diags = k3s107.check(
|
|
175
|
+
makeCtx({
|
|
176
|
+
cp: {
|
|
177
|
+
entityType: "K3s::Server",
|
|
178
|
+
props: { disable: ["servicelb"], "servicelb-namespace": "kube-system" },
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
expect(diags).toHaveLength(1);
|
|
183
|
+
expect(diags[0].checkId).toBe("K3S107");
|
|
184
|
+
expect(diags[0].severity).toBe("warning");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("flags cluster-dns with coredns disabled, and default-local-storage-path with local-storage disabled", () => {
|
|
188
|
+
const diags = k3s107.check(
|
|
189
|
+
makeCtx({
|
|
190
|
+
cp: {
|
|
191
|
+
entityType: "K3s::Server",
|
|
192
|
+
props: {
|
|
193
|
+
disable: ["coredns", "local-storage"],
|
|
194
|
+
"cluster-dns": "10.43.0.10",
|
|
195
|
+
"default-local-storage-path": "/data",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
expect(diags).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("passes disabling traefik (no server flag configures it) and unrelated config", () => {
|
|
204
|
+
const diags = k3s107.check(
|
|
205
|
+
makeCtx({
|
|
206
|
+
cp: {
|
|
207
|
+
entityType: "K3s::Server",
|
|
208
|
+
props: { disable: ["traefik"], "tls-san": ["10.0.0.10"] },
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
expect(diags).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("passes servicelb-namespace with nothing disabled", () => {
|
|
216
|
+
const diags = k3s107.check(
|
|
217
|
+
makeCtx({
|
|
218
|
+
cp: { entityType: "K3s::Server", props: { "servicelb-namespace": "kube-system" } },
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
expect(diags).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k3s Op activities — resolved by the core activity registry when a project's
|
|
3
|
+
* `chant.config.ts` lists the `k3s` lexicon. Lifecycle activities for the
|
|
4
|
+
* reachable-host case (chant#1601, epic #1598):
|
|
5
|
+
* - k3sInstall — run the pinned installer (`INSTALL_K3S_VERSION`), idempotent
|
|
6
|
+
* on an already-installed matching version.
|
|
7
|
+
* - k3sUninstall — the uninstall script, gated the way k3dDown is.
|
|
8
|
+
*
|
|
9
|
+
* Bounded exactly as k3dUp/k3dDown were (chant#1410): no SSH orchestration,
|
|
10
|
+
* no host provisioning. The join token never travels through these activities
|
|
11
|
+
* as a value — only `tokenFile`, a path — see the token-boundary note on
|
|
12
|
+
* {@link k3sInstall} in ./k3s.
|
|
13
|
+
*
|
|
14
|
+
* The step builders (k3sInstall, k3sUninstall) live in core, re-exported from
|
|
15
|
+
* the temporal Op-authoring barrel like k3dUp/k3dDown. The activities here are
|
|
16
|
+
* dependency-light — they shell out to the k3s installer/uninstall scripts and
|
|
17
|
+
* only pull in the lexicon's version pin, not its declarable surface — so a
|
|
18
|
+
* Temporal worker loads them cheaply.
|
|
19
|
+
*/
|
|
20
|
+
export {
|
|
21
|
+
k3sInstall,
|
|
22
|
+
k3sUninstall,
|
|
23
|
+
k3sInstallCommand,
|
|
24
|
+
k3sInstallEnv,
|
|
25
|
+
k3sUninstallCommand,
|
|
26
|
+
k3sUninstallScript,
|
|
27
|
+
k3sVersionCommand,
|
|
28
|
+
parseK3sVersion,
|
|
29
|
+
} from "./k3s";
|
|
30
|
+
export type { K3sRole, K3sInstallArgs, K3sUninstallArgs, K3sInstallResult } from "./k3s";
|