@alchemy.run/kubernetes 0.0.0 → 2.0.0-beta.84
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/LICENSE +201 -0
- package/README.md +41 -4
- package/lib/BuiltinAdapters.d.ts +23 -0
- package/lib/BuiltinAdapters.d.ts.map +1 -0
- package/lib/BuiltinAdapters.js +124 -0
- package/lib/BuiltinAdapters.js.map +1 -0
- package/lib/Client.d.ts +80 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +296 -0
- package/lib/Client.js.map +1 -0
- package/lib/ClusterAdapter.d.ts +322 -0
- package/lib/ClusterAdapter.d.ts.map +1 -0
- package/lib/ClusterAdapter.js +65 -0
- package/lib/ClusterAdapter.js.map +1 -0
- package/lib/Connection.d.ts +210 -0
- package/lib/Connection.d.ts.map +1 -0
- package/lib/Connection.js +58 -0
- package/lib/Connection.js.map +1 -0
- package/lib/Deployment.d.ts +382 -0
- package/lib/Deployment.d.ts.map +1 -0
- package/lib/Deployment.js +533 -0
- package/lib/Deployment.js.map +1 -0
- package/lib/HelmChart.d.ts +147 -0
- package/lib/HelmChart.d.ts.map +1 -0
- package/lib/HelmChart.js +248 -0
- package/lib/HelmChart.js.map +1 -0
- package/lib/Job.d.ts +314 -0
- package/lib/Job.d.ts.map +1 -0
- package/lib/Job.js +426 -0
- package/lib/Job.js.map +1 -0
- package/lib/LocalCluster.d.ts +243 -0
- package/lib/LocalCluster.d.ts.map +1 -0
- package/lib/LocalCluster.js +359 -0
- package/lib/LocalCluster.js.map +1 -0
- package/lib/Manifest.d.ts +148 -0
- package/lib/Manifest.d.ts.map +1 -0
- package/lib/Manifest.js +200 -0
- package/lib/Manifest.js.map +1 -0
- package/lib/Objects.d.ts +60 -0
- package/lib/Objects.d.ts.map +1 -0
- package/lib/Objects.js +132 -0
- package/lib/Objects.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +36 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/declared.d.ts +19 -0
- package/lib/internal/declared.d.ts.map +1 -0
- package/lib/internal/declared.js +435 -0
- package/lib/internal/declared.js.map +1 -0
- package/lib/internal/helm.d.ts +65 -0
- package/lib/internal/helm.d.ts.map +1 -0
- package/lib/internal/helm.js +173 -0
- package/lib/internal/helm.js.map +1 -0
- package/lib/internal/kind.d.ts +17 -0
- package/lib/internal/kind.d.ts.map +1 -0
- package/lib/internal/kind.js +31 -0
- package/lib/internal/kind.js.map +1 -0
- package/lib/internal/kubeconfig.d.ts +79 -0
- package/lib/internal/kubeconfig.d.ts.map +1 -0
- package/lib/internal/kubeconfig.js +222 -0
- package/lib/internal/kubeconfig.js.map +1 -0
- package/lib/internal/registry.d.ts +44 -0
- package/lib/internal/registry.d.ts.map +1 -0
- package/lib/internal/registry.js +72 -0
- package/lib/internal/registry.js.map +1 -0
- package/lib/internal/workload.d.ts +127 -0
- package/lib/internal/workload.d.ts.map +1 -0
- package/lib/internal/workload.js +411 -0
- package/lib/internal/workload.js.map +1 -0
- package/package.json +55 -3
- package/src/BuiltinAdapters.ts +194 -0
- package/src/Client.ts +465 -0
- package/src/ClusterAdapter.ts +370 -0
- package/src/Connection.ts +234 -0
- package/src/Deployment.ts +851 -0
- package/src/HelmChart.ts +388 -0
- package/src/Job.ts +689 -0
- package/src/LocalCluster.ts +631 -0
- package/src/Manifest.ts +295 -0
- package/src/Objects.ts +213 -0
- package/src/Providers.ts +52 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +9 -0
- package/src/internal/declared.ts +471 -0
- package/src/internal/helm.ts +245 -0
- package/src/internal/kind.ts +43 -0
- package/src/internal/kubeconfig.ts +364 -0
- package/src/internal/registry.ts +108 -0
- package/src/internal/workload.ts +503 -0
package/src/Client.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import * as https from "node:https";
|
|
2
|
+
import { isPlainObject, unwrapRedacted } from "alchemy/Util/data";
|
|
3
|
+
import { collectRedactedSecrets, redactJson } from "alchemy/Util/Redaction";
|
|
4
|
+
/**
|
|
5
|
+
* Internal Kubernetes API client: transport-agnostic server-side apply and
|
|
6
|
+
* kind discovery for arbitrary (CRD) manifests. Powers
|
|
7
|
+
* `Kubernetes.Manifest`, `Kubernetes.Deployment`, `Kubernetes.Job`,
|
|
8
|
+
* `Kubernetes.HelmChart`, and the `AWS.EKS.Cluster` kubernetes-object
|
|
9
|
+
* binding channel. Not exported from the Kubernetes index.
|
|
10
|
+
*
|
|
11
|
+
* Authentication is delegated to the connection's {@link ClusterAdapter}:
|
|
12
|
+
* every request mints headers through the resolved
|
|
13
|
+
* {@link ClusterTransport}, so short-lived tokens (EKS SigV4 presigns,
|
|
14
|
+
* exec-plugin credentials) stay fresh.
|
|
15
|
+
*/
|
|
16
|
+
import * as Data from "effect/Data";
|
|
17
|
+
import * as Effect from "effect/Effect";
|
|
18
|
+
import * as Schedule from "effect/Schedule";
|
|
19
|
+
import {
|
|
20
|
+
ClusterNotFoundError,
|
|
21
|
+
findClusterAdapter,
|
|
22
|
+
type ClusterTransport,
|
|
23
|
+
} from "./ClusterAdapter.ts";
|
|
24
|
+
import type { Connection } from "./Connection.ts";
|
|
25
|
+
import { driftMask, hashDriftSelection } from "./internal/declared.ts";
|
|
26
|
+
import {
|
|
27
|
+
buildKubernetesObjectPathWithSpec,
|
|
28
|
+
chunkByApplyRank,
|
|
29
|
+
DEFAULT_APPLY_RANK,
|
|
30
|
+
kubernetesObjectKey,
|
|
31
|
+
lookupKubernetesKindSpec,
|
|
32
|
+
sortRefsForDelete,
|
|
33
|
+
toKubernetesObjectRef,
|
|
34
|
+
type KubernetesObjectDefinition,
|
|
35
|
+
type KubernetesObjectKindSpec,
|
|
36
|
+
type KubernetesObjectRef,
|
|
37
|
+
} from "./Objects.ts";
|
|
38
|
+
|
|
39
|
+
export class KubernetesApiError extends Data.TaggedError("KubernetesApiError")<{
|
|
40
|
+
method: string;
|
|
41
|
+
path: string;
|
|
42
|
+
statusCode: number;
|
|
43
|
+
body: string;
|
|
44
|
+
}> {
|
|
45
|
+
override get message(): string {
|
|
46
|
+
return `${this.method} ${this.path} responded ${this.statusCode}: ${
|
|
47
|
+
this.body.length > 0 ? this.body.slice(0, 1000) : "(empty body)"
|
|
48
|
+
}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fieldManager = "alchemy";
|
|
53
|
+
|
|
54
|
+
/** Deadline for auth-header minting and for each HTTP attempt. */
|
|
55
|
+
const requestTimeout = "10 seconds";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the {@link ClusterTransport} for a connection through its
|
|
59
|
+
* registered adapter.
|
|
60
|
+
*/
|
|
61
|
+
export const connectCluster = (connection: Connection) =>
|
|
62
|
+
findClusterAdapter(connection.auth.kind).pipe(
|
|
63
|
+
Effect.flatMap((adapter) => adapter.connect(connection)),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** 404 from the apiserver, including a removed CRD's discovery 404. */
|
|
67
|
+
export class KubernetesNotFound extends Data.TaggedError("KubernetesNotFound")<{
|
|
68
|
+
method: string;
|
|
69
|
+
path: string;
|
|
70
|
+
body: string;
|
|
71
|
+
}> {
|
|
72
|
+
override get message(): string {
|
|
73
|
+
return `${this.method} ${this.path} responded 404: ${
|
|
74
|
+
this.body.length > 0 ? this.body.slice(0, 1000) : "(empty body)"
|
|
75
|
+
}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The effect's result when the cluster exists. `ClusterNotFoundError`
|
|
81
|
+
* becomes `undefined`; auth and HTTP failures stay failures.
|
|
82
|
+
*/
|
|
83
|
+
export const ifClusterExists = <A, E, R>(effect: Effect.Effect<A, ClusterNotFoundError | E, R>) =>
|
|
84
|
+
effect.pipe(Effect.catchTag("Kubernetes.ClusterNotFoundError", () => Effect.succeed(undefined)));
|
|
85
|
+
|
|
86
|
+
const requestJson = Effect.fn(function* ({
|
|
87
|
+
transport,
|
|
88
|
+
method,
|
|
89
|
+
path,
|
|
90
|
+
body,
|
|
91
|
+
redactions = [],
|
|
92
|
+
}: {
|
|
93
|
+
transport: ClusterTransport;
|
|
94
|
+
method: string;
|
|
95
|
+
path: string;
|
|
96
|
+
body?: Record<string, unknown>;
|
|
97
|
+
/** Secrets already plaintext in `body` (Helm renders them). */
|
|
98
|
+
redactions?: readonly string[];
|
|
99
|
+
}) {
|
|
100
|
+
// Outside the attempt retry: a hung token mint must not be retried as
|
|
101
|
+
// if it were a refused connection.
|
|
102
|
+
const headers = yield* transport.headers.pipe(Effect.timeout(requestTimeout));
|
|
103
|
+
const url = new URL(path, transport.endpoint);
|
|
104
|
+
// JSON.stringify(Redacted) emits the display placeholder. Unwrap only at
|
|
105
|
+
// the wire so plans and state keep the wrapper.
|
|
106
|
+
const prepared = yield* Effect.sync(() => {
|
|
107
|
+
const payload = body === undefined ? undefined : JSON.stringify(unwrapRedacted(body));
|
|
108
|
+
return {
|
|
109
|
+
payload,
|
|
110
|
+
contentLength: payload === undefined ? undefined : Buffer.byteLength(payload),
|
|
111
|
+
ca:
|
|
112
|
+
transport.certificateAuthorityData === undefined
|
|
113
|
+
? undefined
|
|
114
|
+
: Buffer.from(transport.certificateAuthorityData, "base64").toString("utf8"),
|
|
115
|
+
secrets: [...(body === undefined ? [] : collectRedactedSecrets(body)), ...redactions],
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const response = yield* Effect.callback<
|
|
120
|
+
{ readonly statusCode: number; readonly body: string },
|
|
121
|
+
Error
|
|
122
|
+
>((resume, signal) => {
|
|
123
|
+
const request = https.request(
|
|
124
|
+
{
|
|
125
|
+
signal,
|
|
126
|
+
protocol: url.protocol,
|
|
127
|
+
hostname: url.hostname,
|
|
128
|
+
port: url.port || 443,
|
|
129
|
+
path: `${url.pathname}${url.search}`,
|
|
130
|
+
method,
|
|
131
|
+
headers: {
|
|
132
|
+
...headers,
|
|
133
|
+
Accept: "application/json",
|
|
134
|
+
...(prepared.payload === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: {
|
|
137
|
+
"Content-Type": "application/apply-patch+yaml",
|
|
138
|
+
"Content-Length": prepared.contentLength,
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
...(prepared.ca === undefined ? {} : { ca: prepared.ca }),
|
|
142
|
+
...(transport.clientCert
|
|
143
|
+
? {
|
|
144
|
+
cert: transport.clientCert.certificate,
|
|
145
|
+
key: transport.clientCert.key,
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
...(transport.insecureSkipTlsVerify ? { rejectUnauthorized: false } : {}),
|
|
149
|
+
},
|
|
150
|
+
(incoming) => {
|
|
151
|
+
const chunks: Uint8Array[] = [];
|
|
152
|
+
incoming.on("data", (chunk: Uint8Array) => {
|
|
153
|
+
chunks.push(chunk);
|
|
154
|
+
});
|
|
155
|
+
incoming.on("error", (error) => {
|
|
156
|
+
resume(Effect.fail(error));
|
|
157
|
+
});
|
|
158
|
+
incoming.on("aborted", () => {
|
|
159
|
+
resume(Effect.fail(new Error(`Kubernetes ${method} ${path} response aborted`)));
|
|
160
|
+
});
|
|
161
|
+
incoming.on("end", () => {
|
|
162
|
+
if (incoming.complete === false) {
|
|
163
|
+
resume(
|
|
164
|
+
Effect.fail(
|
|
165
|
+
new Error(`Kubernetes ${method} ${path} response ended before completion`),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const statusCode = incoming.statusCode ?? 500;
|
|
171
|
+
resume(
|
|
172
|
+
Effect.sync(() => ({
|
|
173
|
+
statusCode,
|
|
174
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
175
|
+
})),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
request.on("error", (error) => {
|
|
181
|
+
resume(Effect.fail(error instanceof Error ? error : new Error(String(error))));
|
|
182
|
+
});
|
|
183
|
+
if (prepared.payload !== undefined) request.write(prepared.payload);
|
|
184
|
+
request.end();
|
|
185
|
+
// Bun's TLS client ignores AbortSignal. Destroying the socket releases it.
|
|
186
|
+
// Effect.callback already drops a second resume, and this finalizer runs
|
|
187
|
+
// on the same interrupt that aborts `signal`.
|
|
188
|
+
return Effect.sync(() => {
|
|
189
|
+
request.destroy();
|
|
190
|
+
request.socket?.destroy();
|
|
191
|
+
});
|
|
192
|
+
}).pipe(
|
|
193
|
+
Effect.timeout(requestTimeout),
|
|
194
|
+
Effect.mapError(
|
|
195
|
+
(error) =>
|
|
196
|
+
new Error(
|
|
197
|
+
`Failed Kubernetes ${method} ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
// Transport-level failures (ECONNREFUSED/ECONNRESET/ETIMEDOUT/DNS,
|
|
201
|
+
// the per-attempt deadline) are transient — a fresh managed endpoint's
|
|
202
|
+
// load balancer can refuse connections for a short window after the
|
|
203
|
+
// cluster reports ready. Every request here is idempotent (GET / SSA
|
|
204
|
+
// PATCH / DELETE), so retry them. HTTP status errors are classified
|
|
205
|
+
// below and are not retried here.
|
|
206
|
+
Effect.retry({
|
|
207
|
+
schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(8)]),
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
212
|
+
// Error bodies can echo the request. Scrub only failures —
|
|
213
|
+
// a successful dry-run is the object we compare for drift.
|
|
214
|
+
const responseBody = yield* Effect.sync(() => redactJson(response.body, prepared.secrets));
|
|
215
|
+
if (response.statusCode === 404) {
|
|
216
|
+
return yield* new KubernetesNotFound({ method, path, body: responseBody });
|
|
217
|
+
}
|
|
218
|
+
return yield* new KubernetesApiError({
|
|
219
|
+
method,
|
|
220
|
+
path,
|
|
221
|
+
statusCode: response.statusCode,
|
|
222
|
+
body: responseBody,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!response.body.trim()) return undefined;
|
|
227
|
+
return yield* Effect.sync(() => {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.parse(response.body) as unknown;
|
|
230
|
+
} catch {
|
|
231
|
+
return response.body;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─────────────────────────────────────────────────────── kind discovery ──
|
|
237
|
+
|
|
238
|
+
interface ApiResourceList {
|
|
239
|
+
resources?: {
|
|
240
|
+
name?: string;
|
|
241
|
+
kind?: string;
|
|
242
|
+
namespaced?: boolean;
|
|
243
|
+
}[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// One resolution per (endpoint, apiVersion, kind) per process. Plain cached
|
|
247
|
+
// values (no finalizers), so module scope is safe.
|
|
248
|
+
const discoveredKinds = new Map<string, KubernetesObjectKindSpec>();
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve the REST mapping (plural + scope) for an arbitrary kind: static
|
|
252
|
+
* table fast path, then the Kubernetes discovery API (`/apis/{g}/{v}` or
|
|
253
|
+
* `/api/v1`). This is what lets `Kubernetes.Manifest` apply any CRD.
|
|
254
|
+
*/
|
|
255
|
+
export const resolveKindSpec = Effect.fn(function* ({
|
|
256
|
+
transport,
|
|
257
|
+
input,
|
|
258
|
+
}: {
|
|
259
|
+
transport: ClusterTransport;
|
|
260
|
+
input: Pick<KubernetesObjectRef, "apiVersion" | "kind">;
|
|
261
|
+
}) {
|
|
262
|
+
const staticSpec = lookupKubernetesKindSpec(input);
|
|
263
|
+
if (staticSpec) return staticSpec;
|
|
264
|
+
|
|
265
|
+
const cacheKey = `${transport.endpoint}|${input.apiVersion}|${input.kind}`;
|
|
266
|
+
const cached = discoveredKinds.get(cacheKey);
|
|
267
|
+
if (cached) return cached;
|
|
268
|
+
|
|
269
|
+
const discoveryPath = input.apiVersion.includes("/")
|
|
270
|
+
? `/apis/${input.apiVersion}`
|
|
271
|
+
: `/api/${input.apiVersion}`;
|
|
272
|
+
|
|
273
|
+
const listed = (yield* requestJson({
|
|
274
|
+
transport,
|
|
275
|
+
method: "GET",
|
|
276
|
+
path: discoveryPath,
|
|
277
|
+
})) as ApiResourceList;
|
|
278
|
+
|
|
279
|
+
const resource = listed.resources?.find(
|
|
280
|
+
(candidate) =>
|
|
281
|
+
candidate.kind === input.kind &&
|
|
282
|
+
typeof candidate.name === "string" &&
|
|
283
|
+
!candidate.name.includes("/"),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (!resource?.name) {
|
|
287
|
+
return yield* new KubernetesNotFound({
|
|
288
|
+
method: "GET",
|
|
289
|
+
path: discoveryPath,
|
|
290
|
+
body: `Kind '${input.kind}' not found in API group '${input.apiVersion}'`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const spec: KubernetesObjectKindSpec = {
|
|
295
|
+
plural: resource.name,
|
|
296
|
+
scope: resource.namespaced ? "Namespaced" : "Cluster",
|
|
297
|
+
applyRank: DEFAULT_APPLY_RANK,
|
|
298
|
+
};
|
|
299
|
+
discoveredKinds.set(cacheKey, spec);
|
|
300
|
+
return spec;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const buildPath = Effect.fn(function* ({
|
|
304
|
+
transport,
|
|
305
|
+
object,
|
|
306
|
+
}: {
|
|
307
|
+
transport: ClusterTransport;
|
|
308
|
+
object: KubernetesObjectRef;
|
|
309
|
+
}) {
|
|
310
|
+
const spec = yield* resolveKindSpec({ transport, input: object });
|
|
311
|
+
return yield* Effect.try({
|
|
312
|
+
try: () => buildKubernetesObjectPathWithSpec(object, spec),
|
|
313
|
+
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ─────────────────────────────────────────────────────────── object ops ──
|
|
318
|
+
|
|
319
|
+
export const readObject = Effect.fn(function* ({
|
|
320
|
+
transport,
|
|
321
|
+
object,
|
|
322
|
+
}: {
|
|
323
|
+
transport: ClusterTransport;
|
|
324
|
+
object: KubernetesObjectRef;
|
|
325
|
+
}) {
|
|
326
|
+
return yield* requestJson({
|
|
327
|
+
transport,
|
|
328
|
+
method: "GET",
|
|
329
|
+
path: yield* buildPath({ transport, object }),
|
|
330
|
+
}).pipe(
|
|
331
|
+
// Drift reads a whole Helm release at once. A 429 is the apiserver
|
|
332
|
+
// asking for a pause, not a failed object.
|
|
333
|
+
Effect.retry({
|
|
334
|
+
while: (error) => error instanceof KubernetesApiError && error.statusCode === 429,
|
|
335
|
+
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(4)]),
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
export const applyObject = Effect.fn(function* ({
|
|
341
|
+
transport,
|
|
342
|
+
object,
|
|
343
|
+
redactions,
|
|
344
|
+
}: {
|
|
345
|
+
transport: ClusterTransport;
|
|
346
|
+
object: KubernetesObjectDefinition;
|
|
347
|
+
/** Plaintext secrets already rendered into `object` (Helm values). */
|
|
348
|
+
redactions?: readonly string[];
|
|
349
|
+
}) {
|
|
350
|
+
const basePath = yield* buildPath({
|
|
351
|
+
transport,
|
|
352
|
+
object: toKubernetesObjectRef(object),
|
|
353
|
+
});
|
|
354
|
+
const path = `${basePath}?fieldManager=${fieldManager}&force=true`;
|
|
355
|
+
|
|
356
|
+
// A freshly provisioned cluster's API server briefly 5xxes while warming
|
|
357
|
+
// up, and the creator's bootstrap access can propagate asynchronously
|
|
358
|
+
// (401/403 in the first minute).
|
|
359
|
+
return yield* requestJson({
|
|
360
|
+
transport,
|
|
361
|
+
method: "PATCH",
|
|
362
|
+
path,
|
|
363
|
+
body: object,
|
|
364
|
+
redactions,
|
|
365
|
+
}).pipe(
|
|
366
|
+
Effect.retry({
|
|
367
|
+
while: (e): boolean =>
|
|
368
|
+
e instanceof KubernetesApiError &&
|
|
369
|
+
(e.statusCode >= 500 ||
|
|
370
|
+
e.statusCode === 429 ||
|
|
371
|
+
e.statusCode === 401 ||
|
|
372
|
+
e.statusCode === 403),
|
|
373
|
+
schedule: Schedule.max([Schedule.spaced("6 seconds"), Schedule.recurs(10)]),
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
export const deleteObject = Effect.fn(function* ({
|
|
379
|
+
transport,
|
|
380
|
+
object,
|
|
381
|
+
}: {
|
|
382
|
+
transport: ClusterTransport;
|
|
383
|
+
object: KubernetesObjectRef;
|
|
384
|
+
}) {
|
|
385
|
+
yield* buildPath({ transport, object }).pipe(
|
|
386
|
+
Effect.flatMap((path) =>
|
|
387
|
+
requestJson({
|
|
388
|
+
transport,
|
|
389
|
+
method: "DELETE",
|
|
390
|
+
// batch/v1 Jobs orphan their pods on DELETE unless a propagation
|
|
391
|
+
// policy is set; Background matches kubectl's default.
|
|
392
|
+
path: `${path}?propagationPolicy=Background`,
|
|
393
|
+
}),
|
|
394
|
+
),
|
|
395
|
+
Effect.catchTag("KubernetesNotFound", () => Effect.void),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
export const reconcileObjects = Effect.fn(function* ({
|
|
400
|
+
transport,
|
|
401
|
+
previousObjects,
|
|
402
|
+
desiredObjects,
|
|
403
|
+
redactions,
|
|
404
|
+
}: {
|
|
405
|
+
transport: ClusterTransport;
|
|
406
|
+
previousObjects: ReadonlyArray<KubernetesObjectRef>;
|
|
407
|
+
desiredObjects: ReadonlyArray<KubernetesObjectDefinition>;
|
|
408
|
+
redactions?: readonly string[];
|
|
409
|
+
}) {
|
|
410
|
+
const desiredRefs = desiredObjects.map(toKubernetesObjectRef);
|
|
411
|
+
const desiredKeys = new Set(desiredRefs.map(kubernetesObjectKey));
|
|
412
|
+
|
|
413
|
+
const removedObjects = previousObjects.filter(
|
|
414
|
+
(object) => !desiredKeys.has(kubernetesObjectKey(object)),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
for (const object of sortRefsForDelete(removedObjects)) {
|
|
418
|
+
yield* deleteObject({
|
|
419
|
+
transport,
|
|
420
|
+
object,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const baselines = new Map<string, { baselineHash: string; driftMask: unknown }>();
|
|
425
|
+
for (const chunk of chunkByApplyRank(desiredObjects)) {
|
|
426
|
+
yield* Effect.forEach(
|
|
427
|
+
chunk,
|
|
428
|
+
(object) =>
|
|
429
|
+
Effect.gen(function* () {
|
|
430
|
+
const applied = yield* applyObject({
|
|
431
|
+
transport,
|
|
432
|
+
object,
|
|
433
|
+
redactions,
|
|
434
|
+
});
|
|
435
|
+
const ref = toKubernetesObjectRef(object);
|
|
436
|
+
const witnessed = isPlainObject(applied) ? applied : undefined;
|
|
437
|
+
baselines.set(kubernetesObjectKey(ref), {
|
|
438
|
+
driftMask: driftMask(object, witnessed),
|
|
439
|
+
baselineHash: yield* hashDriftSelection(object, witnessed ?? object),
|
|
440
|
+
});
|
|
441
|
+
}),
|
|
442
|
+
{ concurrency: "unbounded" },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return desiredRefs.map((ref) => ({
|
|
447
|
+
...ref,
|
|
448
|
+
...baselines.get(kubernetesObjectKey(ref)),
|
|
449
|
+
}));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
export const deleteObjects = Effect.fn(function* ({
|
|
453
|
+
transport,
|
|
454
|
+
objects,
|
|
455
|
+
}: {
|
|
456
|
+
transport: ClusterTransport;
|
|
457
|
+
objects: ReadonlyArray<KubernetesObjectRef>;
|
|
458
|
+
}) {
|
|
459
|
+
for (const object of sortRefsForDelete(objects)) {
|
|
460
|
+
yield* deleteObject({
|
|
461
|
+
transport,
|
|
462
|
+
object,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
});
|