@intentius/chant-lexicon-k8s 0.30.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 +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- 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/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- 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 +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- 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 +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -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/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -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 +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- 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/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
|
@@ -1,10 +1,61 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `kubectlApply` — apply a rendered manifest to a cluster.
|
|
3
|
+
*
|
|
4
|
+
* chant #1074 moved this off `kubectl apply -f`. The activity contract is
|
|
5
|
+
* unchanged (a manifest path, an optional context, `Promise<void>`, the
|
|
6
|
+
* `longInfra` profile's 15s heartbeat) because Temporal workers register it by
|
|
7
|
+
* that signature; what changed is underneath. The name is kept for the same
|
|
8
|
+
* reason.
|
|
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
|
+
*
|
|
22
|
+
* Consequences worth knowing:
|
|
23
|
+
*
|
|
24
|
+
* - **A worker image needs no `kubectl` binary.** That was the point.
|
|
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.
|
|
30
|
+
* - **Documents apply in file order**, as `kubectl apply -f` does, and a
|
|
31
|
+
* directory's files are read in sorted order.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { loadAll } from "js-yaml";
|
|
3
37
|
import { safeHeartbeat } from "@intentius/chant/op";
|
|
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";
|
|
45
|
+
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
46
|
+
import { operationFor } from "../../api/operation-surface";
|
|
47
|
+
import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
|
|
4
48
|
|
|
5
|
-
|
|
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";
|
|
6
56
|
|
|
7
57
|
export interface KubectlApplyArgs {
|
|
58
|
+
/** Path to a manifest file, or a directory of them. */
|
|
8
59
|
manifest: string;
|
|
9
60
|
/**
|
|
10
61
|
* kubectl context name. Uses the ambient context if omitted. To target the
|
|
@@ -13,26 +64,341 @@ export interface KubectlApplyArgs {
|
|
|
13
64
|
* `./index.ts` and pass `.context` through.
|
|
14
65
|
*/
|
|
15
66
|
context?: string;
|
|
67
|
+
/**
|
|
68
|
+
* chant environment, used to resolve `k8s.profiles.<env>.context` when no
|
|
69
|
+
* explicit `context` is given. Optional and additive: omitting both keeps
|
|
70
|
+
* the previous behavior of using whatever the kubeconfig selects.
|
|
71
|
+
*/
|
|
72
|
+
environment?: string;
|
|
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
|
+
*/
|
|
78
|
+
fieldManager?: string;
|
|
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
|
+
*/
|
|
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[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read a manifest path — one file, or every YAML/JSON file in a directory. */
|
|
126
|
+
export function readManifestDocuments(path: string): Record<string, unknown>[] {
|
|
127
|
+
const files = statSync(path).isDirectory()
|
|
128
|
+
? readdirSync(path)
|
|
129
|
+
.filter((f) => /\.(ya?ml|json)$/i.test(f))
|
|
130
|
+
.sort()
|
|
131
|
+
.map((f) => join(path, f))
|
|
132
|
+
: [path];
|
|
133
|
+
|
|
134
|
+
const documents: Record<string, unknown>[] = [];
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
for (const doc of loadAll(readFileSync(file, "utf-8"))) {
|
|
137
|
+
// Multi-document YAML files routinely carry empty documents between
|
|
138
|
+
// separators; they are not objects to apply.
|
|
139
|
+
if (doc && typeof doc === "object") documents.push(doc as Record<string, unknown>);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return documents;
|
|
16
143
|
}
|
|
17
144
|
|
|
18
145
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
155
|
+
*/
|
|
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.
|
|
21
201
|
*/
|
|
22
|
-
export async function
|
|
23
|
-
|
|
202
|
+
export async function applyManifest(
|
|
203
|
+
args: KubectlApplyArgs,
|
|
204
|
+
signal?: AbortSignal,
|
|
205
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
206
|
+
): Promise<ApplyManifestResult> {
|
|
207
|
+
const documents = readManifestDocuments(args.manifest);
|
|
208
|
+
const { fieldManager, stack } = await resolveApplyIdentity(args);
|
|
24
209
|
const heartbeatInterval = setInterval(() => {
|
|
25
210
|
safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
|
|
26
211
|
}, 15_000);
|
|
27
212
|
|
|
28
213
|
try {
|
|
29
|
-
const {
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
214
|
+
const { client } = await connect({
|
|
215
|
+
...(args.environment !== undefined ? { environment: args.environment } : {}),
|
|
216
|
+
...(args.context !== undefined ? { context: args.context } : {}),
|
|
217
|
+
...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const applied: AppliedRef[] = [];
|
|
221
|
+
for (const document of documents) {
|
|
222
|
+
const result = await client.apply(document as K8sObject, {
|
|
223
|
+
fieldManager,
|
|
224
|
+
force: args.force ?? false,
|
|
225
|
+
dryRun: args.dryRun,
|
|
226
|
+
signal,
|
|
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);
|
|
237
|
+
safeHeartbeat({
|
|
238
|
+
step: "kubectl apply",
|
|
239
|
+
manifest: args.manifest,
|
|
240
|
+
applied: `${ref.kind}/${ref.name}`,
|
|
241
|
+
});
|
|
242
|
+
console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`);
|
|
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 };
|
|
35
255
|
} finally {
|
|
36
256
|
clearInterval(heartbeatInterval);
|
|
37
257
|
}
|
|
38
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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
waitForReady,
|
|
4
|
+
apiResourceFetcher,
|
|
4
5
|
ReadinessFailedError,
|
|
5
6
|
readinessFor,
|
|
6
7
|
isReady,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
} from "./wait-for-ready";
|
|
12
13
|
// The k8sWait profile marks ReadinessFailedError non-retryable for this activity.
|
|
13
14
|
import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal/config";
|
|
15
|
+
import { fakeCluster, objectKey } from "../../api/fake-cluster";
|
|
14
16
|
|
|
15
17
|
/** A fetcher returning a scripted sequence of objects, repeating the last. */
|
|
16
18
|
function scriptedFetcher(sequence: Array<Record<string, unknown>>): ResourceFetcher {
|
|
@@ -103,3 +105,95 @@ describe("waitForReady", () => {
|
|
|
103
105
|
expect((obj as any).status.state).toBe("running");
|
|
104
106
|
});
|
|
105
107
|
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* chant #1074 — the reader underneath. The activity's contract is unchanged
|
|
111
|
+
* (`kind` is still whatever `kubectl get` accepts), so what has to be proven is
|
|
112
|
+
* that the same strings still resolve, now through the cluster's own API
|
|
113
|
+
* discovery rather than by handing them to a `kubectl` process.
|
|
114
|
+
*/
|
|
115
|
+
describe("apiResourceFetcher (chant #1074)", () => {
|
|
116
|
+
const readyObject = (apiVersion: string, kind: string, name: string, namespace?: string) => ({
|
|
117
|
+
apiVersion,
|
|
118
|
+
kind,
|
|
119
|
+
metadata: { name, ...(namespace ? { namespace } : {}), generation: 1 },
|
|
120
|
+
status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test.each([
|
|
124
|
+
["raycluster.ray.io", "ray.io/v1", "RayCluster", "rayclusters", "K8s::Ray::RayCluster"],
|
|
125
|
+
["certificates", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
|
|
126
|
+
["Certificate", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
|
|
127
|
+
["deployments", "apps/v1", "Deployment", "deployments", "K8s::Apps::Deployment"],
|
|
128
|
+
])("`%s` resolves to %s %s via discovery", async (kindArg, apiVersion, kind, plural, entityType) => {
|
|
129
|
+
const cluster = fakeCluster({
|
|
130
|
+
serves: [entityType],
|
|
131
|
+
objects: { [objectKey(apiVersion, kind, "thing", "prod")]: readyObject(apiVersion, kind, "thing", "prod") },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const obj = await waitForReady(
|
|
135
|
+
{ kind: kindArg, name: "thing", namespace: "prod", intervalMs: 0 },
|
|
136
|
+
undefined,
|
|
137
|
+
apiResourceFetcher(cluster.connector),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect((obj as Record<string, unknown>).kind).toBe(kind);
|
|
141
|
+
expect(cluster.layer.paths()).toContain(
|
|
142
|
+
`${apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`}/namespaces/prod/${plural}/thing`,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("resolution and the connection are done once, not once per poll", async () => {
|
|
147
|
+
const notReady = {
|
|
148
|
+
apiVersion: "cert-manager.io/v1",
|
|
149
|
+
kind: "Certificate",
|
|
150
|
+
metadata: { name: "tls", namespace: "prod", generation: 1 },
|
|
151
|
+
status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "False" }] },
|
|
152
|
+
};
|
|
153
|
+
let polls = 0;
|
|
154
|
+
const cluster = fakeCluster({
|
|
155
|
+
serves: ["K8s::CertManager::Certificate"],
|
|
156
|
+
objects: { [objectKey("cert-manager.io/v1", "Certificate", "tls", "prod")]: notReady },
|
|
157
|
+
respond: (req) => {
|
|
158
|
+
if (!req.path.endsWith("/certificates/tls")) return undefined;
|
|
159
|
+
polls++;
|
|
160
|
+
return polls < 3
|
|
161
|
+
? { body: notReady }
|
|
162
|
+
: { body: { ...notReady, status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] } } };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await waitForReady(
|
|
167
|
+
{ kind: "certificate", name: "tls", namespace: "prod", intervalMs: 0 },
|
|
168
|
+
undefined,
|
|
169
|
+
apiResourceFetcher(cluster.connector),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(polls).toBe(3);
|
|
173
|
+
expect(cluster.connects).toHaveLength(1);
|
|
174
|
+
// Discovery once; the three object reads reuse the cached resource list.
|
|
175
|
+
expect(cluster.layer.paths().filter((p) => p === "/apis/cert-manager.io/v1")).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a kind the cluster does not serve fails loudly instead of polling forever", async () => {
|
|
179
|
+
const cluster = fakeCluster({ serves: ["K8s::Apps::Deployment"] });
|
|
180
|
+
await expect(
|
|
181
|
+
waitForReady({ kind: "widgets", name: "w", intervalMs: 0 }, undefined, apiResourceFetcher(cluster.connector)),
|
|
182
|
+
).rejects.toThrow(/no resource matching "widgets"/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an explicit context is passed to the connector, closing the read/write split", async () => {
|
|
186
|
+
const cluster = fakeCluster({
|
|
187
|
+
serves: ["K8s::Apps::Deployment"],
|
|
188
|
+
objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: readyObject("apps/v1", "Deployment", "web", "prod") },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await waitForReady(
|
|
192
|
+
{ kind: "deployments", name: "web", namespace: "prod", context: "test-context", intervalMs: 0 },
|
|
193
|
+
undefined,
|
|
194
|
+
apiResourceFetcher(cluster.connector),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { exec } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
1
|
import { safeHeartbeat, sleep } from "@intentius/chant/op";
|
|
4
|
-
|
|
5
|
-
const execAsync = promisify(exec);
|
|
2
|
+
import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* waitForReady — block until any operator-backed Kubernetes resource reports
|
|
@@ -11,9 +8,16 @@ const execAsync = promisify(exec);
|
|
|
11
8
|
* Like `waitForArgoSync`, this activity is intentionally **dependency-light**:
|
|
12
9
|
* its signature is primitives + a plain readiness spec, so a Temporal worker
|
|
13
10
|
* loads it without importing the generated CRD declarable surface. It reads the
|
|
14
|
-
* resource
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* resource and evaluates the spec's predicates. It generalizes the bespoke
|
|
12
|
+
* `waitForArgoSync` / `waitForStack` waits — see #365.
|
|
13
|
+
*
|
|
14
|
+
* chant #1074 moved the read from `kubectl get -o json` to the typed API
|
|
15
|
+
* client, so a worker image needs no `kubectl` binary. The signature is
|
|
16
|
+
* unchanged — `kind` is still whatever `kubectl get` accepts, because that is
|
|
17
|
+
* what every existing caller passes, and the client resolves it through the
|
|
18
|
+
* cluster's own API discovery exactly as kubectl does: plural, then singular,
|
|
19
|
+
* then kind, then short name, with anything after the first dot read as the
|
|
20
|
+
* API group.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
// ── Readiness spec (plain data — no generated-type imports) ──────────
|
|
@@ -154,6 +158,11 @@ export interface WaitForReadyArgs {
|
|
|
154
158
|
* `.context` through.
|
|
155
159
|
*/
|
|
156
160
|
context?: string;
|
|
161
|
+
/**
|
|
162
|
+
* chant environment, used to resolve `k8s.profiles.<env>.context` when no
|
|
163
|
+
* explicit `context` is given. Optional and additive.
|
|
164
|
+
*/
|
|
165
|
+
environment?: string;
|
|
157
166
|
/** API group, used to pick a readiness override when `spec` is not given. */
|
|
158
167
|
group?: string;
|
|
159
168
|
/** Explicit readiness spec — wins over the registry/default. */
|
|
@@ -168,15 +177,57 @@ export type ResourceFetcher = (
|
|
|
168
177
|
signal?: AbortSignal,
|
|
169
178
|
) => Promise<Record<string, unknown>>;
|
|
170
179
|
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Read the resource through the typed API client.
|
|
182
|
+
*
|
|
183
|
+
* A client is built once per `waitForReady` call and reused for every poll, so
|
|
184
|
+
* a 20-minute wait does not re-parse the kubeconfig or re-invoke an exec
|
|
185
|
+
* credential plugin on each iteration — and neither does it re-run discovery,
|
|
186
|
+
* which the client caches per API version.
|
|
187
|
+
*/
|
|
188
|
+
export function apiResourceFetcher(connect: K8sConnector = defaultK8sConnector): ResourceFetcher {
|
|
189
|
+
let connection: ReturnType<K8sConnector> | undefined;
|
|
190
|
+
let resolved: { apiVersion: string; kind: string } | undefined;
|
|
191
|
+
|
|
192
|
+
return async (args, signal) => {
|
|
193
|
+
connection ??= connect({
|
|
194
|
+
...(args.environment !== undefined ? { environment: args.environment } : {}),
|
|
195
|
+
...(args.context !== undefined ? { context: args.context } : {}),
|
|
196
|
+
});
|
|
197
|
+
const { client } = await connection;
|
|
198
|
+
|
|
199
|
+
if (!resolved) {
|
|
200
|
+
const info = await client.resolve(
|
|
201
|
+
{ resource: args.kind, ...(args.group ? { group: args.group } : {}) },
|
|
202
|
+
signal,
|
|
203
|
+
);
|
|
204
|
+
if (!info) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`waitForReady: the cluster's API discovery reports no resource matching "${args.kind}"` +
|
|
207
|
+
`${args.group ? ` in group "${args.group}"` : ""} — nothing to wait for`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
resolved = { apiVersion: info.apiVersion, kind: info.kind };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return (await client.read(
|
|
214
|
+
{
|
|
215
|
+
apiVersion: resolved.apiVersion,
|
|
216
|
+
kind: resolved.kind,
|
|
217
|
+
name: args.name,
|
|
218
|
+
...(args.namespace ? { namespace: args.namespace } : {}),
|
|
219
|
+
},
|
|
220
|
+
{ signal },
|
|
221
|
+
)) as Record<string, unknown>;
|
|
222
|
+
};
|
|
177
223
|
}
|
|
178
224
|
|
|
179
|
-
|
|
225
|
+
/**
|
|
226
|
+
* The production reader. Each call builds its own fetcher, so nothing is
|
|
227
|
+
* shared between two unrelated waits; a single `waitForReady` passes one
|
|
228
|
+
* fetcher through all of its polls, which is where the caching matters.
|
|
229
|
+
*/
|
|
230
|
+
export const defaultResourceFetcher: ResourceFetcher = (args, signal) => apiResourceFetcher()(args, signal);
|
|
180
231
|
|
|
181
232
|
/**
|
|
182
233
|
* Poll until the resource satisfies its readiness spec. Throws
|
|
@@ -189,7 +240,7 @@ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchVi
|
|
|
189
240
|
export async function waitForReady(
|
|
190
241
|
args: WaitForReadyArgs,
|
|
191
242
|
signal?: AbortSignal,
|
|
192
|
-
fetcher: ResourceFetcher =
|
|
243
|
+
fetcher: ResourceFetcher = apiResourceFetcher(),
|
|
193
244
|
): Promise<Record<string, unknown>> {
|
|
194
245
|
const spec = args.spec ?? readinessFor(args.group, args.kind);
|
|
195
246
|
const interval = args.intervalMs ?? 15_000;
|