@intentius/chant-lexicon-k8s 0.31.0 → 0.32.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/dist/api/classify.d.ts +10 -0
- package/dist/api/classify.d.ts.map +1 -1
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +15 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +6 -2
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +6 -3
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +91 -9
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/classify.ts +13 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +224 -1
- package/src/describe-resources.ts +86 -1
- package/src/export-resources.ts +6 -22
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/op/activities/index.ts +6 -3
- package/src/op/activities/kubectl.test.ts +298 -26
- package/src/op/activities/kubectl.ts +313 -20
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
|
@@ -7,16 +7,26 @@
|
|
|
7
7
|
* that signature; what changed is underneath. The name is kept for the same
|
|
8
8
|
* reason.
|
|
9
9
|
*
|
|
10
|
+
* chant #1075 finished the job on two axes:
|
|
11
|
+
*
|
|
12
|
+
* - **Identity.** The field manager is no longer the bare `chant` but
|
|
13
|
+
* `chant:<stack>`, derived from the project's `ownership.stack` — the same
|
|
14
|
+
* identity the ownership label already carries. See
|
|
15
|
+
* `@intentius/chant-k8s-client`'s `field-manager.ts`.
|
|
16
|
+
* - **Deletes.** `nativeApply`'s kubectl branch used to shell
|
|
17
|
+
* `kubectl apply --prune --selector <marker>`; that branch now routes here,
|
|
18
|
+
* so the prune had to come with it. {@link applyManifest} implements it
|
|
19
|
+
* against the typed client, scoped to the ownership marker exactly as the
|
|
20
|
+
* shelled prune was, and never touching an object that does not carry it.
|
|
21
|
+
*
|
|
10
22
|
* Consequences worth knowing:
|
|
11
23
|
*
|
|
12
24
|
* - **A worker image needs no `kubectl` binary.** That was the point.
|
|
13
|
-
* - **It is a server-side apply**,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
17
|
-
* chant
|
|
18
|
-
* with another field manager arrives here as a typed 409 rather than a line
|
|
19
|
-
* of stderr; #1075 is where it gets a proper presentation.
|
|
25
|
+
* - **It is a server-side apply**, rather than the client-side three-way merge
|
|
26
|
+
* `kubectl apply` performs by default. A conflict with another field manager
|
|
27
|
+
* arrives as a `FieldManagerConflictError` naming the competing manager and
|
|
28
|
+
* the contested paths — never as a silent overwrite, and never force-resolved
|
|
29
|
+
* on chant's initiative.
|
|
20
30
|
* - **Documents apply in file order**, as `kubectl apply -f` does, and a
|
|
21
31
|
* directory's files are read in sorted order.
|
|
22
32
|
*/
|
|
@@ -25,8 +35,24 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
25
35
|
import { join } from "node:path";
|
|
26
36
|
import { loadAll } from "js-yaml";
|
|
27
37
|
import { safeHeartbeat } from "@intentius/chant/op";
|
|
28
|
-
import
|
|
38
|
+
import { loadChantConfig, resolveOwnershipMarker } from "@intentius/chant/config";
|
|
39
|
+
import {
|
|
40
|
+
hasOwnershipMarker,
|
|
41
|
+
LABEL_OWNERSHIP_KEYS,
|
|
42
|
+
OWNERSHIP_MANAGED_BY_VALUE,
|
|
43
|
+
} from "@intentius/chant/ownership";
|
|
44
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
29
45
|
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
46
|
+
import { operationFor } from "../../api/operation-surface";
|
|
47
|
+
import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How the apply treats chant-owned objects that are no longer declared. The
|
|
51
|
+
* same three values `nativeApply` has always taken; `gated` differs from
|
|
52
|
+
* `owned-only` only in that the workflow pauses for approval first, which is
|
|
53
|
+
* the composite's business, not this activity's.
|
|
54
|
+
*/
|
|
55
|
+
export type ApplyDeleteMode = "never" | "owned-only" | "gated";
|
|
30
56
|
|
|
31
57
|
export interface KubectlApplyArgs {
|
|
32
58
|
/** Path to a manifest file, or a directory of them. */
|
|
@@ -44,10 +70,56 @@ export interface KubectlApplyArgs {
|
|
|
44
70
|
* the previous behavior of using whatever the kubeconfig selects.
|
|
45
71
|
*/
|
|
46
72
|
environment?: string;
|
|
47
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Field manager recorded on the applied fields. Omitted derives it from the
|
|
75
|
+
* project's `ownership.stack` (chant #1075) — `chant:<stack>`, or the bare
|
|
76
|
+
* `chant` when the project sets none.
|
|
77
|
+
*/
|
|
48
78
|
fieldManager?: string;
|
|
49
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Ownership stack, when the caller already knows it and would rather not
|
|
81
|
+
* have the project config read. Ignored if `fieldManager` is set.
|
|
82
|
+
*/
|
|
83
|
+
stack?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Take ownership of fields another manager owns instead of failing with a
|
|
86
|
+
* `FieldManagerConflictError`. **Never defaulted on** — see chant #1075.
|
|
87
|
+
*/
|
|
50
88
|
force?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Delete chant-owned objects that are no longer declared. Default `never`.
|
|
91
|
+
* Deletes are scoped to the ownership marker, so an object chant did not
|
|
92
|
+
* stamp is never a candidate.
|
|
93
|
+
*/
|
|
94
|
+
deleteMode?: ApplyDeleteMode;
|
|
95
|
+
/**
|
|
96
|
+
* Server-side dry run — every document is validated and would-be-applied,
|
|
97
|
+
* nothing is persisted, and pruning is skipped entirely (a prune candidate
|
|
98
|
+
* list computed from a state that was never written would be misleading).
|
|
99
|
+
* Optional and additive (chant #1079): `chant kube apply`'s confirmation
|
|
100
|
+
* gate uses this to preview an apply before anything mutates the cluster;
|
|
101
|
+
* Op callers keep the previous behavior by leaving it unset.
|
|
102
|
+
*/
|
|
103
|
+
dryRun?: boolean;
|
|
104
|
+
/** Project directory whose `chant.config.ts` carries `ownership`. Defaults to cwd. */
|
|
105
|
+
cwd?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One object the apply touched. */
|
|
109
|
+
export interface AppliedRef {
|
|
110
|
+
apiVersion: string;
|
|
111
|
+
kind: string;
|
|
112
|
+
name: string;
|
|
113
|
+
namespace?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What {@link applyManifest} did. */
|
|
117
|
+
export interface ApplyManifestResult {
|
|
118
|
+
/** The field manager every object was applied as. */
|
|
119
|
+
fieldManager: string;
|
|
120
|
+
applied: AppliedRef[];
|
|
121
|
+
/** Objects deleted because they carried chant's marker and are no longer declared. */
|
|
122
|
+
pruned: AppliedRef[];
|
|
51
123
|
}
|
|
52
124
|
|
|
53
125
|
/** Read a manifest path — one file, or every YAML/JSON file in a directory. */
|
|
@@ -71,15 +143,69 @@ export function readManifestDocuments(path: string): Record<string, unknown>[] {
|
|
|
71
143
|
}
|
|
72
144
|
|
|
73
145
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
146
|
+
* The field manager to apply as: an explicit one, else `chant:<stack>` from a
|
|
147
|
+
* caller-supplied stack, else `chant:<stack>` from the project's `ownership`
|
|
148
|
+
* config, else the bare `chant`.
|
|
149
|
+
*
|
|
150
|
+
* A project config that cannot be read falls back to the bare `chant` with a
|
|
151
|
+
* warning rather than failing the apply — an activity handed a manifest path
|
|
152
|
+
* and nothing else is a supported way to call this, and its cwd need not be a
|
|
153
|
+
* chant project at all. The warning is there because a *silent* downgrade
|
|
154
|
+
* would change which fields chant owns without saying so.
|
|
76
155
|
*/
|
|
77
|
-
export async function
|
|
156
|
+
export async function resolveFieldManager(args: KubectlApplyArgs): Promise<string> {
|
|
157
|
+
return (await resolveApplyIdentity(args)).fieldManager;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The stack and the field manager together, resolved once. The prune needs the
|
|
162
|
+
* stack for its selector and the apply needs the manager; reading the project
|
|
163
|
+
* config twice for one apply would be silly, and could disagree with itself if
|
|
164
|
+
* the file changed in between.
|
|
165
|
+
*/
|
|
166
|
+
async function resolveApplyIdentity(
|
|
167
|
+
args: KubectlApplyArgs,
|
|
168
|
+
): Promise<{ fieldManager: string; stack?: string }> {
|
|
169
|
+
const { fieldManagerFor, assertValidFieldManager } = await import("@intentius/chant-k8s-client");
|
|
170
|
+
|
|
171
|
+
let stack = args.stack;
|
|
172
|
+
if (stack === undefined) {
|
|
173
|
+
try {
|
|
174
|
+
const { config } = await loadChantConfig(args.cwd ?? process.cwd());
|
|
175
|
+
stack = resolveOwnershipMarker(config)?.stack;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn(
|
|
178
|
+
`[k8s] could not read the project config to derive a field manager ` +
|
|
179
|
+
`(${err instanceof Error ? err.message : String(err)}); applying as the unqualified "chant"`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (args.fieldManager !== undefined) {
|
|
185
|
+
assertValidFieldManager(args.fieldManager);
|
|
186
|
+
return { fieldManager: args.fieldManager, ...(stack !== undefined ? { stack } : {}) };
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
fieldManager: fieldManagerFor(stack !== undefined ? { stack } : undefined),
|
|
190
|
+
...(stack !== undefined ? { stack } : {}),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Apply every document in `args.manifest`, then prune when asked.
|
|
196
|
+
*
|
|
197
|
+
* Returns what it did. `kubectlApply` is the registered activity and keeps its
|
|
198
|
+
* `Promise<void>` contract; this is the function `nativeApply` calls, because a
|
|
199
|
+
* dispatcher that reports "applied 12, pruned 2" is worth more than one that
|
|
200
|
+
* reports the shell command it ran.
|
|
201
|
+
*/
|
|
202
|
+
export async function applyManifest(
|
|
78
203
|
args: KubectlApplyArgs,
|
|
79
204
|
signal?: AbortSignal,
|
|
80
205
|
connect: K8sConnector = defaultK8sConnector,
|
|
81
|
-
): Promise<
|
|
206
|
+
): Promise<ApplyManifestResult> {
|
|
82
207
|
const documents = readManifestDocuments(args.manifest);
|
|
208
|
+
const { fieldManager, stack } = await resolveApplyIdentity(args);
|
|
83
209
|
const heartbeatInterval = setInterval(() => {
|
|
84
210
|
safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
|
|
85
211
|
}, 15_000);
|
|
@@ -88,24 +214,191 @@ export async function kubectlApply(
|
|
|
88
214
|
const { client } = await connect({
|
|
89
215
|
...(args.environment !== undefined ? { environment: args.environment } : {}),
|
|
90
216
|
...(args.context !== undefined ? { context: args.context } : {}),
|
|
217
|
+
...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
91
218
|
});
|
|
92
219
|
|
|
220
|
+
const applied: AppliedRef[] = [];
|
|
93
221
|
for (const document of documents) {
|
|
94
|
-
const
|
|
95
|
-
fieldManager
|
|
222
|
+
const result = await client.apply(document as K8sObject, {
|
|
223
|
+
fieldManager,
|
|
96
224
|
force: args.force ?? false,
|
|
225
|
+
dryRun: args.dryRun,
|
|
97
226
|
signal,
|
|
98
227
|
});
|
|
228
|
+
const ref: AppliedRef = {
|
|
229
|
+
apiVersion: String(result.apiVersion ?? document.apiVersion ?? ""),
|
|
230
|
+
kind: String(result.kind ?? document.kind ?? ""),
|
|
231
|
+
name: String(result.metadata?.name ?? ""),
|
|
232
|
+
...(result.metadata?.namespace !== undefined
|
|
233
|
+
? { namespace: String(result.metadata.namespace) }
|
|
234
|
+
: {}),
|
|
235
|
+
};
|
|
236
|
+
applied.push(ref);
|
|
99
237
|
safeHeartbeat({
|
|
100
238
|
step: "kubectl apply",
|
|
101
239
|
manifest: args.manifest,
|
|
102
|
-
applied: `${
|
|
240
|
+
applied: `${ref.kind}/${ref.name}`,
|
|
103
241
|
});
|
|
104
|
-
console.log(
|
|
105
|
-
`${String(applied.apiVersion ?? document.apiVersion)} ${String(applied.kind ?? document.kind)}/${String(applied.metadata?.name ?? "")} applied`,
|
|
106
|
-
);
|
|
242
|
+
console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`);
|
|
107
243
|
}
|
|
244
|
+
|
|
245
|
+
const deleteMode = args.deleteMode ?? "never";
|
|
246
|
+
const pruned =
|
|
247
|
+
deleteMode === "never" || args.dryRun
|
|
248
|
+
? []
|
|
249
|
+
: await pruneOrphans(client, applied, {
|
|
250
|
+
...(stack !== undefined ? { stack } : {}),
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return { fieldManager, applied, pruned };
|
|
108
255
|
} finally {
|
|
109
256
|
clearInterval(heartbeatInterval);
|
|
110
257
|
}
|
|
111
258
|
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Apply every document in `args.manifest`.
|
|
262
|
+
* Uses longInfra profile — 20m timeout, heartbeat every 15s.
|
|
263
|
+
*/
|
|
264
|
+
export async function kubectlApply(
|
|
265
|
+
args: KubectlApplyArgs,
|
|
266
|
+
signal?: AbortSignal,
|
|
267
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
await applyManifest(args, signal, connect);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Delete chant-owned objects that the apply set no longer declares.
|
|
274
|
+
*
|
|
275
|
+
* This replaces `kubectl apply --prune --selector <marker>=chant`, and keeps
|
|
276
|
+
* its two safety properties exactly:
|
|
277
|
+
*
|
|
278
|
+
* - **Marker-scoped.** Only objects carrying chant's managed-by label are
|
|
279
|
+
* candidates; the label selector is sent to the server, and the marker is
|
|
280
|
+
* re-checked on every object before it is deleted. An unmarked resource is
|
|
281
|
+
* never a candidate, so a hand-made object in the same namespace is safe.
|
|
282
|
+
* - **Namespace-scoped to the apply.** Namespaced kinds are swept only in the
|
|
283
|
+
* namespaces the apply set touched (the context's default namespace when it
|
|
284
|
+
* named none), which is what kubectl's prune does. A manifest that mentions
|
|
285
|
+
* `prod` never reaches into `staging`. Whether a kind *is* namespaced comes
|
|
286
|
+
* from the cluster's own discovery, never from whether a document happened
|
|
287
|
+
* to name a namespace — guessing that wrong is the one way this could widen
|
|
288
|
+
* into a cluster-wide sweep.
|
|
289
|
+
*
|
|
290
|
+
* Which *kinds* get swept is the union of the kinds the apply set contains and
|
|
291
|
+
* chant's default sweep set (`DEFAULT_IMPORT_TYPES` — the same list `chant
|
|
292
|
+
* import` uses when nothing narrows it). The union matters: a kind removed
|
|
293
|
+
* from source entirely appears nowhere in the apply set, and pruning only what
|
|
294
|
+
* is still declared would leave it behind forever. Per-namespace lists rather
|
|
295
|
+
* than one cluster-wide list per kind, so a namespace-scoped service account
|
|
296
|
+
* can still prune.
|
|
297
|
+
*/
|
|
298
|
+
async function pruneOrphans(
|
|
299
|
+
client: K8sClient,
|
|
300
|
+
applied: readonly AppliedRef[],
|
|
301
|
+
options: { stack?: string; signal?: AbortSignal },
|
|
302
|
+
): Promise<AppliedRef[]> {
|
|
303
|
+
const stack = options.stack;
|
|
304
|
+
const selector = [
|
|
305
|
+
`${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
|
|
306
|
+
...(stack ? [`${LABEL_OWNERSHIP_KEYS.stack}=${stack}`] : []),
|
|
307
|
+
].join(",");
|
|
308
|
+
|
|
309
|
+
const namespaces = [...new Set(applied.map((a) => a.namespace).filter((n): n is string => !!n))];
|
|
310
|
+
if (namespaces.length === 0 && applied.length > 0) namespaces.push(client.defaultNamespace);
|
|
311
|
+
const declared = new Set(applied.map(refKey));
|
|
312
|
+
|
|
313
|
+
// Every kind that could hold an orphan: the apply set's own, plus the
|
|
314
|
+
// default sweep set so a kind deleted from source entirely is still reached.
|
|
315
|
+
const sweeps = new Map<string, { apiVersion: string; kind: string }>();
|
|
316
|
+
for (const entityType of DEFAULT_IMPORT_TYPES) {
|
|
317
|
+
const operation = operationFor(entityType);
|
|
318
|
+
if (!operation) continue;
|
|
319
|
+
sweeps.set(`${operation.apiVersion}/${operation.kind}`, {
|
|
320
|
+
apiVersion: operation.apiVersion,
|
|
321
|
+
kind: operation.kind,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
for (const ref of applied) {
|
|
325
|
+
if (!ref.apiVersion || !ref.kind) continue;
|
|
326
|
+
sweeps.set(`${ref.apiVersion}/${ref.kind}`, { apiVersion: ref.apiVersion, kind: ref.kind });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// (kind, namespace) pairs to list. Scope comes from the cluster's discovery,
|
|
330
|
+
// which is the only thing that knows it for the version this cluster serves;
|
|
331
|
+
// a kind discovery does not report is dropped rather than swept blindly.
|
|
332
|
+
const resolved = await client.concurrently([...sweeps.values()], async (sweep) => {
|
|
333
|
+
const info = await client
|
|
334
|
+
.resolve({ apiVersion: sweep.apiVersion, kind: sweep.kind }, options.signal)
|
|
335
|
+
.catch(() => undefined);
|
|
336
|
+
return info ? { ...sweep, namespaced: info.namespaced } : undefined;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const targets: Array<{ apiVersion: string; kind: string; namespace?: string }> = [];
|
|
340
|
+
for (const sweep of resolved) {
|
|
341
|
+
if (!sweep) continue;
|
|
342
|
+
if (!sweep.namespaced) {
|
|
343
|
+
targets.push({ apiVersion: sweep.apiVersion, kind: sweep.kind });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
for (const namespace of namespaces) {
|
|
347
|
+
targets.push({ apiVersion: sweep.apiVersion, kind: sweep.kind, namespace });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const found = await client.concurrently(targets, async (target) => {
|
|
352
|
+
try {
|
|
353
|
+
const items = await client.list(
|
|
354
|
+
{ apiVersion: target.apiVersion, kind: target.kind },
|
|
355
|
+
{
|
|
356
|
+
labelSelector: selector,
|
|
357
|
+
...(target.namespace !== undefined ? { namespace: target.namespace } : {}),
|
|
358
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
return items.map((item) => ({ target, item }));
|
|
362
|
+
} catch {
|
|
363
|
+
// Kind not served here, or the read was denied. Neither is evidence that
|
|
364
|
+
// something should be deleted, so the sweep skips it rather than failing
|
|
365
|
+
// the apply that already succeeded.
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const candidates: AppliedRef[] = [];
|
|
371
|
+
const seen = new Set<string>();
|
|
372
|
+
for (const { target, item } of found.flat()) {
|
|
373
|
+
const name = item.metadata?.name;
|
|
374
|
+
if (!name) continue;
|
|
375
|
+
// The server filtered by label already; this re-check is the one that
|
|
376
|
+
// matters, because a delete is not undoable.
|
|
377
|
+
if (!hasOwnershipMarker(item.metadata?.labels, LABEL_OWNERSHIP_KEYS)) continue;
|
|
378
|
+
// Already terminating — deleting again is noise, not progress.
|
|
379
|
+
if (item.metadata?.deletionTimestamp !== undefined) continue;
|
|
380
|
+
const ref: AppliedRef = {
|
|
381
|
+
apiVersion: target.apiVersion,
|
|
382
|
+
kind: target.kind,
|
|
383
|
+
name,
|
|
384
|
+
...(item.metadata?.namespace !== undefined ? { namespace: item.metadata.namespace } : {}),
|
|
385
|
+
};
|
|
386
|
+
const key = refKey(ref);
|
|
387
|
+
if (declared.has(key) || seen.has(key)) continue;
|
|
388
|
+
seen.add(key);
|
|
389
|
+
candidates.push(ref);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const pruned: AppliedRef[] = [];
|
|
393
|
+
for (const ref of candidates) {
|
|
394
|
+
await client.delete(ref, { ...(options.signal ? { signal: options.signal } : {}) });
|
|
395
|
+
pruned.push(ref);
|
|
396
|
+
safeHeartbeat({ step: "prune", pruned: `${ref.kind}/${ref.name}` });
|
|
397
|
+
console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
|
|
398
|
+
}
|
|
399
|
+
return pruned;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function refKey(ref: AppliedRef): string {
|
|
403
|
+
return `${ref.apiVersion}/${ref.kind}/${ref.namespace ?? ""}/${ref.name}`;
|
|
404
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { join, dirname } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
@@ -211,4 +211,61 @@ describe("k8sPlugin", () => {
|
|
|
211
211
|
test("package() method exists", () => {
|
|
212
212
|
expect(typeof k8sPlugin.package).toBe("function");
|
|
213
213
|
});
|
|
214
|
+
|
|
215
|
+
// chant #1078 — the command-group seam's first real tenant: `commands()`
|
|
216
|
+
// mounts a "kube" verb group, reserving the namespace #1079's `chant kube`
|
|
217
|
+
// terminal surface will add its own verbs to.
|
|
218
|
+
describe("commands() — the `kube` command group (#1078)", () => {
|
|
219
|
+
test("contributes a group named kube with a version verb", () => {
|
|
220
|
+
const group = k8sPlugin.commands!();
|
|
221
|
+
expect(group.name).toBe("kube");
|
|
222
|
+
expect(group.commands.map((c) => c.name)).toContain("version");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("kube version prints the pinned K8S_SCHEMA_VERSION", async () => {
|
|
226
|
+
const { K8S_SCHEMA_VERSION } = await import("./spec/fetch");
|
|
227
|
+
const group = k8sPlugin.commands!();
|
|
228
|
+
const version = group.commands.find((c) => c.name === "version")!;
|
|
229
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
230
|
+
try {
|
|
231
|
+
const exitCode = await version.handler({ verb: "version", rawArgs: [] });
|
|
232
|
+
expect(exitCode).toBe(0);
|
|
233
|
+
expect(logSpy).toHaveBeenCalledWith(K8S_SCHEMA_VERSION);
|
|
234
|
+
} finally {
|
|
235
|
+
logSpy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("kube version --format json emits a JSON envelope", async () => {
|
|
240
|
+
const { K8S_SCHEMA_VERSION } = await import("./spec/fetch");
|
|
241
|
+
const group = k8sPlugin.commands!();
|
|
242
|
+
const version = group.commands.find((c) => c.name === "version")!;
|
|
243
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
244
|
+
try {
|
|
245
|
+
const exitCode = await version.handler({ verb: "version", rawArgs: ["--format", "json"] });
|
|
246
|
+
expect(exitCode).toBe(0);
|
|
247
|
+
expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ schemaVersion: K8S_SCHEMA_VERSION }));
|
|
248
|
+
} finally {
|
|
249
|
+
logSpy.mockRestore();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("kube version --format=json (joined form, #1127 discipline) works identically", async () => {
|
|
254
|
+
const group = k8sPlugin.commands!();
|
|
255
|
+
const version = group.commands.find((c) => c.name === "version")!;
|
|
256
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
257
|
+
try {
|
|
258
|
+
await version.handler({ verb: "version", rawArgs: ["--format=json"] });
|
|
259
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("schemaVersion"));
|
|
260
|
+
} finally {
|
|
261
|
+
logSpy.mockRestore();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("kube version rejects an unknown flag (#1127 discipline applies to mounted commands)", async () => {
|
|
266
|
+
const group = k8sPlugin.commands!();
|
|
267
|
+
const version = group.commands.find((c) => c.name === "version")!;
|
|
268
|
+
await expect(version.handler({ verb: "version", rawArgs: ["--bogus"] })).rejects.toThrow(/Unknown flag: --bogus/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
214
271
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { LexiconPlugin, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
|
|
9
|
+
import type { CommandGroup } from "@intentius/chant/cli/command-group";
|
|
10
|
+
import { kubeCommandGroup } from "./kube/group";
|
|
9
11
|
import { detectTemplate } from "./detect";
|
|
10
12
|
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
11
13
|
import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
|
|
@@ -23,6 +25,7 @@ import { k8sCompletions } from "./lsp/completions";
|
|
|
23
25
|
import { k8sHover } from "./lsp/hover";
|
|
24
26
|
import { K8sParser } from "./import/parser";
|
|
25
27
|
import { K8sGenerator } from "./import/generator";
|
|
28
|
+
import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
26
29
|
|
|
27
30
|
export const k8sPlugin: LexiconPlugin = {
|
|
28
31
|
name: "k8s",
|
|
@@ -37,6 +40,17 @@ export const k8sPlugin: LexiconPlugin = {
|
|
|
37
40
|
upstream: { owner: "kubernetes", repo: "kubernetes", kind: "releases" },
|
|
38
41
|
},
|
|
39
42
|
|
|
43
|
+
// chant #1078/#1079 — the command-group seam's tenant. `kube` mounts the
|
|
44
|
+
// terminal surface over the typed client: kubectl-compatible reads (get,
|
|
45
|
+
// describe, logs, events, top, wait), chant's own additions (source, the
|
|
46
|
+
// `get` verdict column, -o chant), and gated writes (apply, delete) that
|
|
47
|
+
// route through the exact same machinery the Op activities use — never a
|
|
48
|
+
// parallel implementation. See ./kube/group.ts for the full verb list;
|
|
49
|
+
// this plugin only mounts it.
|
|
50
|
+
commands(): CommandGroup {
|
|
51
|
+
return kubeCommandGroup();
|
|
52
|
+
},
|
|
53
|
+
|
|
40
54
|
lintRules(): LintRule[] {
|
|
41
55
|
return [
|
|
42
56
|
hardcodedNamespaceRule,
|
|
@@ -617,4 +631,19 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
617
631
|
const { exportResources } = await import("./export-resources");
|
|
618
632
|
return exportResources(options);
|
|
619
633
|
},
|
|
634
|
+
|
|
635
|
+
// Property-level live drift via SSA managed-fields (#1076, epic #1073).
|
|
636
|
+
// The reader lives in ./deep-observe.ts, loaded only through this dynamic
|
|
637
|
+
// import — same reason describeResources/exportResources are — so the API
|
|
638
|
+
// client never becomes reachable from the build path (chant #1074,
|
|
639
|
+
// examples/k8s-client-boundary.test.ts). deepNormalizationHooks is plain
|
|
640
|
+
// data with no client dependency (./deep-observe-hooks.ts) and is imported
|
|
641
|
+
// statically above, because core applies it to the *declared* tree whether
|
|
642
|
+
// or not a live read ever happens.
|
|
643
|
+
async observeResourcesDeep(options) {
|
|
644
|
+
const { observeResourcesDeepK8s } = await import("./deep-observe");
|
|
645
|
+
return observeResourcesDeepK8s(options);
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
deepNormalizationHooks: k8sDeepNormalizationHooks,
|
|
620
649
|
};
|