alchemy-kubernetes-addons 0.1.0-alpha.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/index.mjs ADDED
@@ -0,0 +1,1600 @@
1
+ import { RandomProvider, Resource, Stack, Stage, isResolved, makeRandom } from "alchemy";
2
+ import * as Output from "alchemy/Output";
3
+ import * as Provider from "alchemy/Provider";
4
+ import * as Kubernetes from "alchemy/Kubernetes";
5
+ import { findClusterAdapter, toConnection } from "alchemy/Kubernetes";
6
+ import * as Config from "effect/Config";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Layer from "effect/Layer";
9
+ import * as Redacted from "effect/Redacted";
10
+ import * as Duration from "effect/Duration";
11
+ import { request } from "node:http";
12
+ import { request as request$1 } from "node:https";
13
+ import { createHash } from "node:crypto";
14
+ import * as Cloudflare from "alchemy/Cloudflare";
15
+ import { encodeBase64, hashSync, truncates } from "bcryptjs";
16
+ //#region src/client.ts
17
+ var KubernetesApiError = class extends Error {
18
+ statusCode;
19
+ body;
20
+ name = "KubernetesApiError";
21
+ constructor(statusCode, body) {
22
+ super(`Kubernetes API returned ${String(statusCode)}`);
23
+ this.statusCode = statusCode;
24
+ this.body = body;
25
+ }
26
+ };
27
+ const connectCluster = Effect.fn(function* (cluster) {
28
+ const connection = toConnection(cluster);
29
+ return {
30
+ connection,
31
+ transport: yield* (yield* findClusterAdapter(connection.auth.kind)).connect(connection)
32
+ };
33
+ });
34
+ const plurals = /* @__PURE__ */ new Map([
35
+ ["v1/Secret", "secrets"],
36
+ ["apiextensions.k8s.io/v1/CustomResourceDefinition", "customresourcedefinitions"],
37
+ ["apps/v1/Deployment", "deployments"],
38
+ ["apps/v1/DaemonSet", "daemonsets"],
39
+ ["apps/v1/StatefulSet", "statefulsets"],
40
+ ["batch/v1/Job", "jobs"],
41
+ ["cert-manager.io/v1/ClusterIssuer", "clusterissuers"]
42
+ ]);
43
+ const clusterScopedKinds = /* @__PURE__ */ new Set(["CustomResourceDefinition", "ClusterIssuer"]);
44
+ const objectPath = (object) => {
45
+ const plural = plurals.get(`${object.apiVersion}/${object.kind}`);
46
+ if (plural === void 0) throw new Error(`Readiness is not implemented for ${object.apiVersion}/${object.kind}`);
47
+ return `${object.apiVersion === "v1" ? "/api/v1" : `/apis/${object.apiVersion}`}${clusterScopedKinds.has(object.kind) ? "" : `/namespaces/${encodeURIComponent(object.namespace ?? "default")}`}/${plural}/${encodeURIComponent(object.name)}`;
48
+ };
49
+ const requestJson = Effect.fn(function* ({ transport, method, path, body, timeoutMs = 3e4 }) {
50
+ const headers = yield* transport.headers;
51
+ const url = new URL(path, transport.endpoint);
52
+ if (url.protocol !== "http:" && url.protocol !== "https:") return yield* Effect.fail(/* @__PURE__ */ new Error(`Unsupported Kubernetes API protocol: ${url.protocol}`));
53
+ if (url.protocol === "http:" && ![
54
+ "127.0.0.1",
55
+ "::1",
56
+ "localhost"
57
+ ].includes(url.hostname)) return yield* Effect.fail(/* @__PURE__ */ new Error("Refusing to send Kubernetes credentials over remote HTTP"));
58
+ const payload = body === void 0 ? void 0 : JSON.stringify(body);
59
+ return yield* Effect.tryPromise({
60
+ try: () => new Promise((resolve, reject) => {
61
+ const request$2 = (url.protocol === "https:" ? request$1 : request)({
62
+ protocol: url.protocol,
63
+ hostname: url.hostname,
64
+ port: url.port || void 0,
65
+ path: `${url.pathname}${url.search}`,
66
+ method,
67
+ headers: {
68
+ ...headers,
69
+ Accept: "application/json",
70
+ ...payload === void 0 ? {} : {
71
+ "Content-Type": "application/apply-patch+yaml",
72
+ "Content-Length": Buffer.byteLength(payload)
73
+ }
74
+ },
75
+ ...url.protocol === "https:" && transport.certificateAuthorityData !== void 0 ? { ca: Buffer.from(transport.certificateAuthorityData, "base64").toString("utf8") } : {},
76
+ ...url.protocol === "https:" && transport.clientCert !== void 0 ? {
77
+ cert: transport.clientCert.certificate,
78
+ key: transport.clientCert.key
79
+ } : {},
80
+ ...url.protocol === "https:" && transport.insecureSkipTlsVerify ? { rejectUnauthorized: false } : {}
81
+ }, (response) => {
82
+ const chunks = [];
83
+ response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
84
+ response.on("end", () => {
85
+ const responseBody = Buffer.concat(chunks).toString("utf8");
86
+ const statusCode = response.statusCode ?? 500;
87
+ if (statusCode < 200 || statusCode >= 300) {
88
+ reject(new KubernetesApiError(statusCode, responseBody));
89
+ return;
90
+ }
91
+ if (responseBody.trim().length === 0) {
92
+ resolve(void 0);
93
+ return;
94
+ }
95
+ try {
96
+ resolve(JSON.parse(responseBody));
97
+ } catch {
98
+ reject(/* @__PURE__ */ new Error("Kubernetes API returned invalid JSON"));
99
+ }
100
+ });
101
+ });
102
+ request$2.setTimeout(timeoutMs, () => request$2.destroy(/* @__PURE__ */ new Error("Kubernetes API request timed out")));
103
+ request$2.on("error", reject);
104
+ if (payload !== void 0) request$2.write(payload);
105
+ request$2.end();
106
+ }),
107
+ catch: (error) => error instanceof KubernetesApiError ? error : /* @__PURE__ */ new Error("Kubernetes API request failed")
108
+ });
109
+ });
110
+ const conditionSummary = (conditions) => conditions.filter(({ type }) => [
111
+ "Established",
112
+ "NamesAccepted",
113
+ "Progressing",
114
+ "Available",
115
+ "ReplicaFailure",
116
+ "Complete",
117
+ "Failed",
118
+ "FailureTarget",
119
+ "SuccessCriteriaMet",
120
+ "Suspended",
121
+ "Ready"
122
+ ].includes(type ?? "")).map(({ type, status }) => `${type}=${[
123
+ "True",
124
+ "False",
125
+ "Unknown"
126
+ ].includes(status ?? "") ? status : "Unknown"}`).join(", ") || "none";
127
+ const count = (value) => {
128
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
129
+ return value;
130
+ };
131
+ const kubernetesObjectReadiness = (object) => {
132
+ const value = object;
133
+ const generation = count(value.metadata?.generation);
134
+ const observed = count(value.status?.observedGeneration);
135
+ const conditions = value.status?.conditions ?? [];
136
+ const summary = conditionSummary(conditions);
137
+ if (value.kind === "CustomResourceDefinition") return {
138
+ ready: conditions.some(({ type, status }) => type === "Established" && status === "True"),
139
+ terminal: conditions.some(({ type, status }) => type === "NamesAccepted" && status === "False"),
140
+ detail: `conditions: ${summary}`
141
+ };
142
+ if (value.kind === "Deployment") {
143
+ const desired = value.spec?.replicas === void 0 ? 1 : count(value.spec.replicas);
144
+ const available = count(value.status?.availableReplicas);
145
+ return {
146
+ ready: observed >= generation && available >= desired,
147
+ terminal: conditions.some(({ type, status, reason }) => type === "Progressing" && status === "False" && reason === "ProgressDeadlineExceeded"),
148
+ detail: `observedGeneration=${String(observed)}/${String(generation)}, availableReplicas=${String(available)}/${String(desired)}, conditions: ${summary}`
149
+ };
150
+ }
151
+ if (value.kind === "DaemonSet") {
152
+ const desired = count(value.status?.desiredNumberScheduled);
153
+ const available = count(value.status?.numberAvailable);
154
+ return {
155
+ ready: observed >= generation && available >= desired,
156
+ terminal: false,
157
+ detail: `observedGeneration=${String(observed)}/${String(generation)}, numberAvailable=${String(available)}/${String(desired)}, conditions: ${summary}`
158
+ };
159
+ }
160
+ if (value.kind === "StatefulSet") {
161
+ const desired = value.spec?.replicas === void 0 ? 1 : count(value.spec.replicas);
162
+ const ready = count(value.status?.readyReplicas);
163
+ return {
164
+ ready: observed >= generation && ready >= desired,
165
+ terminal: false,
166
+ detail: `observedGeneration=${String(observed)}/${String(generation)}, readyReplicas=${String(ready)}/${String(desired)}, conditions: ${summary}`
167
+ };
168
+ }
169
+ if (value.kind === "Job") return {
170
+ ready: conditions.some(({ type, status }) => type === "Complete" && status === "True"),
171
+ terminal: conditions.some(({ type, status }) => type === "Failed" && status === "True"),
172
+ detail: `conditions: ${summary}`
173
+ };
174
+ if (value.kind === "ClusterIssuer") return {
175
+ ready: conditions.some(({ type, status }) => type === "Ready" && status === "True"),
176
+ terminal: false,
177
+ detail: `conditions: ${summary}`
178
+ };
179
+ return {
180
+ ready: true,
181
+ terminal: false,
182
+ detail: "no readiness gate"
183
+ };
184
+ };
185
+ const waitedKinds = /* @__PURE__ */ new Set([
186
+ "CustomResourceDefinition",
187
+ "Deployment",
188
+ "DaemonSet",
189
+ "StatefulSet",
190
+ "Job",
191
+ "ClusterIssuer"
192
+ ]);
193
+ const objectLabel = (object) => `${object.apiVersion}/${object.kind} ${object.namespace ? `${object.namespace}/` : ""}${object.name}`;
194
+ var KubernetesReadinessError = class extends Error {
195
+ object;
196
+ timedOut;
197
+ name = "KubernetesReadinessError";
198
+ constructor(message, object, timedOut) {
199
+ super(message);
200
+ this.object = object;
201
+ this.timedOut = timedOut;
202
+ }
203
+ };
204
+ const waitForObjectsReady = Effect.fn(function* ({ transport, objects, timeoutSeconds }) {
205
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) return yield* Effect.fail(/* @__PURE__ */ new Error("Kubernetes readiness timeoutSeconds must be greater than zero"));
206
+ const waiting = objects.filter(({ kind }) => waitedKinds.has(kind));
207
+ if (waiting.length === 0) return;
208
+ const deadline = Date.now() + timeoutSeconds * 1e3;
209
+ let lastPending;
210
+ while (true) {
211
+ if (Date.now() >= deadline) {
212
+ const pending = lastPending ?? {
213
+ object: waiting[0],
214
+ readiness: { detail: "deadline reached" }
215
+ };
216
+ return yield* Effect.fail(new KubernetesReadinessError(`Timed out waiting for ${objectLabel(pending.object)}: ${pending.readiness.detail}`, pending.object, true));
217
+ }
218
+ lastPending = void 0;
219
+ for (const object of waiting) {
220
+ const remaining = deadline - Date.now();
221
+ if (remaining <= 0) break;
222
+ const observed = yield* requestJson({
223
+ transport,
224
+ method: "GET",
225
+ path: objectPath(object),
226
+ timeoutMs: Math.min(3e4, remaining)
227
+ }).pipe(Effect.catchIf((error) => error instanceof KubernetesApiError && error.statusCode === 404, () => Effect.succeed(void 0)), Effect.mapError((error) => new KubernetesReadinessError(`Failed to inspect ${objectLabel(object)}${error instanceof KubernetesApiError ? `: Kubernetes API returned ${String(error.statusCode)}` : ""}`, object, false)));
228
+ const readiness = observed === void 0 ? {
229
+ ready: false,
230
+ terminal: false,
231
+ detail: "object not found"
232
+ } : kubernetesObjectReadiness(observed);
233
+ if (readiness.terminal) return yield* Effect.fail(new KubernetesReadinessError(`${objectLabel(object)} reached a terminal failure: ${readiness.detail}`, object, false));
234
+ if (!readiness.ready && lastPending === void 0) lastPending = {
235
+ object,
236
+ readiness
237
+ };
238
+ }
239
+ if (lastPending === void 0 && Date.now() < deadline) return;
240
+ if (Date.now() >= deadline) {
241
+ const pending = lastPending ?? {
242
+ object: waiting[0],
243
+ readiness: { detail: "deadline reached" }
244
+ };
245
+ return yield* Effect.fail(new KubernetesReadinessError(`Timed out waiting for ${objectLabel(pending.object)}: ${pending.readiness.detail}`, pending.object, true));
246
+ }
247
+ yield* Effect.sleep(Duration.millis(Math.min(2e3, deadline - Date.now())));
248
+ }
249
+ });
250
+ //#endregion
251
+ //#region src/cloudflare-external-dns.ts
252
+ const EXTERNAL_DNS_CHART_VERSION = "1.21.1";
253
+ const EXTERNAL_DNS_IMAGE_REPOSITORY = "registry.k8s.io/external-dns/external-dns";
254
+ const EXTERNAL_DNS_IMAGE_TAG = `v0.21.0@sha256:f53faaf71cb270d1ca9dce6ea0c94bfebf1a18696263487f0fbc74b9bf2bd7ff`;
255
+ const EXTERNAL_DNS_IMAGE = `${EXTERNAL_DNS_IMAGE_REPOSITORY}:${EXTERNAL_DNS_IMAGE_TAG}`;
256
+ const kubernetesName$5 = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
257
+ const cloudflareExternalDnsOwnerId = (stack, stage, logicalId) => {
258
+ const identity = `${stack}/${stage}/${logicalId}`;
259
+ return `${identity.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "external-dns"}-${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
260
+ };
261
+ const cloudflareExternalDnsTokenPolicies = (zoneId) => [{
262
+ effect: "allow",
263
+ permissionGroups: [
264
+ "Zone Read",
265
+ "DNS Read",
266
+ "DNS Write"
267
+ ],
268
+ resources: { [`com.cloudflare.api.account.zone.${zoneId}`]: "*" }
269
+ }];
270
+ const validateCloudflareExternalDnsProps = (props) => {
271
+ const namespace = props.namespace ?? "external-dns";
272
+ const releaseName = props.releaseName ?? "external-dns";
273
+ if (!kubernetesName$5.test(namespace) || namespace.length > 63) throw new Error(`Invalid ExternalDNS namespace: ${namespace}`);
274
+ if (!kubernetesName$5.test(releaseName) || releaseName.length > 53) throw new Error(`Invalid ExternalDNS release name: ${releaseName}`);
275
+ if (props.policy !== "sync" && props.policy !== "upsert-only") throw new Error("ExternalDNS policy must be explicitly set to sync or upsert-only");
276
+ if (typeof props.tokenRevision === "string" && props.tokenRevision.trim() === "") throw new Error("ExternalDNS tokenRevision must not be empty");
277
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("ExternalDNS timeoutSeconds must be greater than zero");
278
+ };
279
+ const planCloudflareExternalDns = ({ zoneId, domain, policy, proxied, releaseName, txtOwnerId, secretName, namespaceRevision, secretRevision, tokenRevision }) => ({ values: {
280
+ fullnameOverride: releaseName,
281
+ image: {
282
+ repository: EXTERNAL_DNS_IMAGE_REPOSITORY,
283
+ tag: EXTERNAL_DNS_IMAGE_TAG,
284
+ pullPolicy: "IfNotPresent"
285
+ },
286
+ replicaCount: 1,
287
+ deploymentStrategy: { type: "Recreate" },
288
+ provider: { name: "cloudflare" },
289
+ sources: ["service", "ingress"],
290
+ policy,
291
+ registry: "txt",
292
+ txtOwnerId,
293
+ domainFilters: [domain],
294
+ triggerLoopOnEvent: true,
295
+ extraArgs: {
296
+ "zone-id-filter": zoneId,
297
+ "cloudflare-proxied": proxied,
298
+ "cloudflare-dns-records-per-page": 5e3
299
+ },
300
+ env: [{
301
+ name: "CF_API_TOKEN",
302
+ valueFrom: { secretKeyRef: {
303
+ name: secretName,
304
+ key: "api-token"
305
+ } }
306
+ }],
307
+ serviceAccount: {
308
+ create: true,
309
+ automountServiceAccountToken: true
310
+ },
311
+ automountServiceAccountToken: true,
312
+ rbac: { create: true },
313
+ namespaced: false,
314
+ service: { enabled: true },
315
+ serviceMonitor: { enabled: false },
316
+ resources: {
317
+ requests: {
318
+ cpu: "10m",
319
+ memory: "64Mi"
320
+ },
321
+ limits: { memory: "128Mi" }
322
+ },
323
+ podSecurityContext: {
324
+ runAsNonRoot: true,
325
+ fsGroup: 65534,
326
+ seccompProfile: { type: "RuntimeDefault" }
327
+ },
328
+ securityContext: {
329
+ privileged: false,
330
+ allowPrivilegeEscalation: false,
331
+ readOnlyRootFilesystem: true,
332
+ runAsNonRoot: true,
333
+ runAsUser: 65532,
334
+ runAsGroup: 65532,
335
+ capabilities: { drop: ["ALL"] }
336
+ },
337
+ podAnnotations: {
338
+ "alchemy.run/namespace-revision": namespaceRevision,
339
+ "alchemy.run/secret-revision": secretRevision,
340
+ ...tokenRevision === void 0 ? {} : { "alchemy.run/token-revision": tokenRevision }
341
+ }
342
+ } });
343
+ /** Install a zone-scoped Cloudflare ExternalDNS controller. */
344
+ const CloudflareExternalDns = (id, props) => Effect.gen(function* () {
345
+ yield* Effect.try(() => validateCloudflareExternalDnsProps(props));
346
+ const stack = yield* Stack;
347
+ const stage = yield* Stage;
348
+ const namespaceName = props.namespace ?? "external-dns";
349
+ const releaseName = props.releaseName ?? "external-dns";
350
+ const txtOwnerId = cloudflareExternalDnsOwnerId(stack.name, stage, id);
351
+ const secretName = `${releaseName}-token`;
352
+ const namespace = yield* Kubernetes.Manifest(`${id}Namespace`, {
353
+ cluster: props.cluster,
354
+ manifest: {
355
+ apiVersion: "v1",
356
+ kind: "Namespace",
357
+ metadata: { name: namespaceName }
358
+ }
359
+ });
360
+ let token;
361
+ if (props.token === void 0) token = (yield* Cloudflare.ApiToken.AccountApiToken(`${id}Token`, {
362
+ accountId: props.zone.accountId,
363
+ policies: Output.map(props.zone.zoneId, (zoneId) => cloudflareExternalDnsTokenPolicies(zoneId))
364
+ })).value;
365
+ else token = props.token;
366
+ const secret = yield* Secret(`${id}Secret`, {
367
+ cluster: props.cluster,
368
+ namespace: namespace.name,
369
+ name: secretName,
370
+ stringData: { "api-token": token }
371
+ });
372
+ const namespaceRevision = Output.map(namespace.uid, (uid) => uid ?? "created");
373
+ const secretRevision = Output.map(secret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown");
374
+ const plan = planCloudflareExternalDns({
375
+ zoneId: props.zone.zoneId,
376
+ domain: props.zone.name,
377
+ policy: props.policy,
378
+ proxied: props.proxied ?? false,
379
+ releaseName,
380
+ txtOwnerId,
381
+ secretName,
382
+ namespaceRevision,
383
+ secretRevision,
384
+ tokenRevision: props.tokenRevision
385
+ });
386
+ yield* ReadyHelmChart(`${id}Chart`, {
387
+ cluster: props.cluster,
388
+ chart: "external-dns",
389
+ repo: "https://kubernetes-sigs.github.io/external-dns/",
390
+ version: EXTERNAL_DNS_CHART_VERSION,
391
+ releaseName,
392
+ namespace: namespaceName,
393
+ createNamespace: false,
394
+ timeoutSeconds: props.timeoutSeconds ?? 300,
395
+ values: plan.values
396
+ });
397
+ return {
398
+ zone: props.zone,
399
+ zoneId: props.zone.zoneId,
400
+ domain: props.zone.name,
401
+ namespace: namespaceName,
402
+ releaseName,
403
+ txtOwnerId,
404
+ tokenManaged: props.token === void 0
405
+ };
406
+ });
407
+ //#endregion
408
+ //#region src/cert-manager.ts
409
+ const CERT_MANAGER_CHART = "oci://quay.io/jetstack/charts/cert-manager";
410
+ const CERT_MANAGER_CHART_VERSION = "v1.21.1";
411
+ const CERT_MANAGER_IMAGE_VERSION = "v1.21.1";
412
+ const kubernetesName$4 = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
413
+ const validateCertManagerProps = (props) => {
414
+ const namespace = props.namespace ?? "cert-manager";
415
+ const releaseName = props.releaseName ?? "cert-manager";
416
+ if (!kubernetesName$4.test(namespace) || namespace.length > 63) throw new Error(`Invalid cert-manager namespace: ${namespace}`);
417
+ if (!kubernetesName$4.test(releaseName) || releaseName.length > 53) throw new Error(`Invalid cert-manager release name: ${releaseName}`);
418
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("cert-manager timeoutSeconds must be greater than zero");
419
+ };
420
+ const certManagerHelmValues = (namespace, namespaceRevision) => ({
421
+ crds: {
422
+ enabled: true,
423
+ keep: true
424
+ },
425
+ startupapicheck: { enabled: false },
426
+ global: {
427
+ leaderElection: { namespace },
428
+ rbac: {
429
+ create: true,
430
+ aggregateClusterRoles: false
431
+ }
432
+ },
433
+ image: {
434
+ tag: CERT_MANAGER_IMAGE_VERSION,
435
+ pullPolicy: "IfNotPresent"
436
+ },
437
+ acmesolver: { image: {
438
+ tag: CERT_MANAGER_IMAGE_VERSION,
439
+ pullPolicy: "IfNotPresent"
440
+ } },
441
+ replicaCount: 1,
442
+ automountServiceAccountToken: true,
443
+ podAnnotations: { "alchemy.run/namespace-revision": namespaceRevision },
444
+ resources: {
445
+ requests: {
446
+ cpu: "10m",
447
+ memory: "64Mi"
448
+ },
449
+ limits: { memory: "256Mi" }
450
+ },
451
+ securityContext: {
452
+ runAsNonRoot: true,
453
+ seccompProfile: { type: "RuntimeDefault" }
454
+ },
455
+ containerSecurityContext: {
456
+ privileged: false,
457
+ allowPrivilegeEscalation: false,
458
+ readOnlyRootFilesystem: true,
459
+ capabilities: { drop: ["ALL"] }
460
+ },
461
+ webhook: {
462
+ replicaCount: 1,
463
+ timeoutSeconds: 10,
464
+ automountServiceAccountToken: true,
465
+ image: {
466
+ tag: CERT_MANAGER_IMAGE_VERSION,
467
+ pullPolicy: "IfNotPresent"
468
+ },
469
+ podAnnotations: { "alchemy.run/namespace-revision": namespaceRevision },
470
+ resources: {
471
+ requests: {
472
+ cpu: "10m",
473
+ memory: "32Mi"
474
+ },
475
+ limits: { memory: "128Mi" }
476
+ },
477
+ securityContext: {
478
+ runAsNonRoot: true,
479
+ seccompProfile: { type: "RuntimeDefault" }
480
+ },
481
+ containerSecurityContext: {
482
+ privileged: false,
483
+ allowPrivilegeEscalation: false,
484
+ readOnlyRootFilesystem: true,
485
+ capabilities: { drop: ["ALL"] }
486
+ }
487
+ },
488
+ cainjector: {
489
+ enabled: true,
490
+ replicaCount: 1,
491
+ automountServiceAccountToken: true,
492
+ image: {
493
+ tag: CERT_MANAGER_IMAGE_VERSION,
494
+ pullPolicy: "IfNotPresent"
495
+ },
496
+ podAnnotations: { "alchemy.run/namespace-revision": namespaceRevision },
497
+ resources: {
498
+ requests: {
499
+ cpu: "10m",
500
+ memory: "64Mi"
501
+ },
502
+ limits: { memory: "256Mi" }
503
+ },
504
+ securityContext: {
505
+ runAsNonRoot: true,
506
+ seccompProfile: { type: "RuntimeDefault" }
507
+ },
508
+ containerSecurityContext: {
509
+ privileged: false,
510
+ allowPrivilegeEscalation: false,
511
+ readOnlyRootFilesystem: true,
512
+ capabilities: { drop: ["ALL"] }
513
+ }
514
+ },
515
+ prometheus: {
516
+ enabled: true,
517
+ servicemonitor: { enabled: false }
518
+ }
519
+ });
520
+ /** Install cert-manager and wait for its CRDs and control plane. */
521
+ const CertManager = (id, props) => Effect.gen(function* () {
522
+ yield* Effect.try(() => validateCertManagerProps(props));
523
+ const namespaceName = props.namespace ?? "cert-manager";
524
+ const releaseName = props.releaseName ?? "cert-manager";
525
+ const namespace = yield* Kubernetes.Manifest(`${id}Namespace`, {
526
+ cluster: props.cluster,
527
+ manifest: {
528
+ apiVersion: "v1",
529
+ kind: "Namespace",
530
+ metadata: { name: namespaceName }
531
+ }
532
+ });
533
+ const namespaceRevision = Output.map(namespace.uid, (uid) => uid ?? "created");
534
+ const chart = yield* Kubernetes.HelmChart(`${id}Chart`, {
535
+ cluster: props.cluster,
536
+ chart: CERT_MANAGER_CHART,
537
+ version: CERT_MANAGER_CHART_VERSION,
538
+ releaseName,
539
+ namespace: namespace.name,
540
+ createNamespace: false,
541
+ includeCrds: true,
542
+ values: certManagerHelmValues(namespaceName, namespaceRevision)
543
+ });
544
+ const readiness = yield* Readiness(`${id}Readiness`, {
545
+ cluster: chart.connection,
546
+ objects: chart.objects,
547
+ revision: chart.code.hash,
548
+ timeoutSeconds: props.timeoutSeconds ?? 300
549
+ });
550
+ return {
551
+ namespace: namespace.name,
552
+ clusterResourceNamespace: namespace.name,
553
+ releaseName,
554
+ chart,
555
+ readiness
556
+ };
557
+ });
558
+ //#endregion
559
+ //#region src/cloudflare-acme-issuer.ts
560
+ const LETSENCRYPT_STAGING_URL = "https://acme-staging-v02.api.letsencrypt.org/directory";
561
+ const LETSENCRYPT_PRODUCTION_URL = "https://acme-v02.api.letsencrypt.org/directory";
562
+ const kubernetesName$3 = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
563
+ const cloudflareAcmeIssuerName = (id, environment) => {
564
+ const suffix = `-${environment}`;
565
+ return `${(id.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "letsencrypt").slice(0, 63 - suffix.length).replace(/-$/g, "")}${suffix}`;
566
+ };
567
+ const cloudflareAcmeIssuerTokenPolicies = (zoneId) => [{
568
+ effect: "allow",
569
+ permissionGroups: ["Zone Read", "DNS Write"],
570
+ resources: { [`com.cloudflare.api.account.zone.${zoneId}`]: "*" }
571
+ }];
572
+ const validateCloudflareAcmeIssuerProps = (props) => {
573
+ const name = props.name ?? cloudflareAcmeIssuerName("letsencrypt", props.environment);
574
+ if (!kubernetesName$3.test(name) || name.length > 63) throw new Error(`Invalid Cloudflare ACME issuer name: ${name}`);
575
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(props.email)) throw new Error("Cloudflare ACME issuer email must be valid");
576
+ if (props.environment !== "staging" && props.environment !== "production") throw new Error("Cloudflare ACME environment must be staging or production");
577
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("Cloudflare ACME timeoutSeconds must be greater than zero");
578
+ };
579
+ const cloudflareAcmeIssuerManifest = ({ name, email, environment, domain, tokenSecretName, accountKeySecretName, certManagerRevision, tokenSecretRevision }) => ({
580
+ apiVersion: "cert-manager.io/v1",
581
+ kind: "ClusterIssuer",
582
+ metadata: {
583
+ name,
584
+ annotations: {
585
+ "alchemy.run/cert-manager-revision": certManagerRevision,
586
+ "alchemy.run/token-secret-revision": tokenSecretRevision
587
+ }
588
+ },
589
+ spec: { acme: {
590
+ email,
591
+ server: environment === "staging" ? LETSENCRYPT_STAGING_URL : LETSENCRYPT_PRODUCTION_URL,
592
+ privateKeySecretRef: { name: accountKeySecretName },
593
+ solvers: [{
594
+ selector: { dnsZones: [domain] },
595
+ dns01: { cloudflare: { apiTokenSecretRef: {
596
+ name: tokenSecretName,
597
+ key: "api-token"
598
+ } } }
599
+ }]
600
+ } }
601
+ });
602
+ /** Create a zone-scoped Cloudflare DNS-01 ClusterIssuer. */
603
+ const CloudflareAcmeIssuer = (id, props) => Effect.gen(function* () {
604
+ yield* Effect.try(() => validateCloudflareAcmeIssuerProps(props));
605
+ const name = props.name ?? cloudflareAcmeIssuerName(id, props.environment);
606
+ const tokenSecretName = `${name}-cloudflare`;
607
+ const accountKeySecretName = `${name}-account-key`;
608
+ let token;
609
+ if (props.token === void 0) token = (yield* Cloudflare.ApiToken.AccountApiToken(`${id}Token`, {
610
+ accountId: props.zone.accountId,
611
+ policies: Output.map(props.zone.zoneId, (zoneId) => cloudflareAcmeIssuerTokenPolicies(zoneId))
612
+ })).value;
613
+ else token = props.token;
614
+ const secret = yield* Secret(`${id}Secret`, {
615
+ cluster: props.cluster,
616
+ namespace: props.certManager.clusterResourceNamespace,
617
+ name: tokenSecretName,
618
+ stringData: { "api-token": token }
619
+ });
620
+ const tokenSecretRevision = Output.map(secret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown");
621
+ const issuer = yield* Kubernetes.Manifest(`${id}Issuer`, {
622
+ cluster: props.cluster,
623
+ manifest: cloudflareAcmeIssuerManifest({
624
+ name,
625
+ email: props.email,
626
+ environment: props.environment,
627
+ domain: props.zone.name,
628
+ tokenSecretName,
629
+ accountKeySecretName,
630
+ certManagerRevision: props.certManager.readiness.revision,
631
+ tokenSecretRevision
632
+ })
633
+ });
634
+ const readiness = yield* Readiness(`${id}Readiness`, {
635
+ cluster: issuer.connection,
636
+ objects: [issuer.ref],
637
+ revision: Output.map(issuer.uid, (uid) => uid ?? "applied"),
638
+ timeoutSeconds: props.timeoutSeconds ?? 300
639
+ });
640
+ const readyName = Output.map(readiness.revision, () => name);
641
+ return {
642
+ name: readyName,
643
+ issuerRef: {
644
+ name: readyName,
645
+ kind: "ClusterIssuer",
646
+ group: "cert-manager.io"
647
+ },
648
+ environment: props.environment,
649
+ tokenManaged: props.token === void 0,
650
+ tokenSecretName,
651
+ accountKeySecretName,
652
+ readiness
653
+ };
654
+ });
655
+ //#endregion
656
+ //#region src/otel-collector.ts
657
+ const OTEL_COLLECTOR_CHART_VERSION = "0.171.0";
658
+ const OTEL_COLLECTOR_IMAGE_REPOSITORY = "otel/opentelemetry-collector-contrib";
659
+ const OTEL_COLLECTOR_IMAGE_TAG = "0.158.0";
660
+ const OTEL_COLLECTOR_IMAGE_DIGEST = "sha256:c5918f78992ee73b0d6f0e599423ac5ec52dd5d9726733114d6eca53d5a32ed5";
661
+ const OTEL_COLLECTOR_IMAGE = `${OTEL_COLLECTOR_IMAGE_REPOSITORY}@${OTEL_COLLECTOR_IMAGE_DIGEST}`;
662
+ const kubernetesName$2 = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
663
+ const httpHeaderName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
664
+ const signals = [
665
+ "traces",
666
+ "logs",
667
+ "metrics"
668
+ ];
669
+ const validateOtelCollectorProps = (props) => {
670
+ const authenticationNamespace = props.destination.authentication?.secretRef.namespace;
671
+ const namespace = props.namespace ?? authenticationNamespace ?? "otel-collector";
672
+ const releaseName = props.releaseName ?? "otel-collector";
673
+ const createNamespace = props.createNamespace ?? authenticationNamespace === void 0;
674
+ if (!kubernetesName$2.test(namespace) || namespace.length > 63) throw new Error(`Invalid OTEL collector namespace: ${namespace}`);
675
+ if (!kubernetesName$2.test(releaseName) || releaseName.length > 55) throw new Error(`Invalid OTEL collector release name: ${releaseName}`);
676
+ if (authenticationNamespace !== void 0 && namespace !== authenticationNamespace) throw new Error("OTEL collector and its authentication Secret must use the same namespace");
677
+ if (authenticationNamespace !== void 0 && createNamespace) throw new Error("OTEL collector cannot own a namespace that already contains its authentication Secret");
678
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("OTEL collector timeoutSeconds must be greater than zero");
679
+ const endpoints = props.destination.endpoints;
680
+ if (endpoints.url === void 0 && endpoints.traces === void 0 && endpoints.logs === void 0 && endpoints.metrics === void 0) throw new Error("OTEL collector destination must configure at least one signal");
681
+ for (const signal of signals) {
682
+ const signalOptions = endpoints[signal];
683
+ if (typeof signalOptions?.url === "string" && signalOptions.url.trim() === "") throw new Error(`OTEL collector ${signal} endpoint must not be empty`);
684
+ const headers = signalOptions?.headers ?? endpoints.headers ?? {};
685
+ const lowerNames = /* @__PURE__ */ new Set();
686
+ for (const name of Object.keys(headers)) {
687
+ if (!httpHeaderName.test(name)) throw new Error(`Invalid OTEL ${signal} header name: ${name}`);
688
+ const lowerName = name.toLowerCase();
689
+ if (lowerNames.has(lowerName)) throw new Error(`Duplicate OTEL ${signal} header name: ${name}`);
690
+ lowerNames.add(lowerName);
691
+ if (lowerName === "authorization" && props.destination.authentication?.type === "basic") throw new Error(`OTEL ${signal} headers cannot override Basic authentication`);
692
+ }
693
+ }
694
+ };
695
+ const planOtelCollector = ({ releaseName, destination, namespaceRevision, headerSecretName, headerSecretRevision, basicSecretRevision }) => {
696
+ const hasBase = destination.endpoints.url !== void 0;
697
+ const enabledSignals = signals.filter((signal) => hasBase || destination.endpoints[signal] !== void 0);
698
+ const headerBindings = [];
699
+ for (const signal of enabledSignals) {
700
+ const headers = destination.endpoints[signal]?.headers ?? destination.endpoints.headers ?? {};
701
+ for (const [index, [name, value]] of Object.entries(headers).sort(([left], [right]) => left.localeCompare(right)).entries()) headerBindings.push({
702
+ signal,
703
+ name,
704
+ value,
705
+ secretKey: `${signal}-${String(index)}`,
706
+ envName: `OTEL_DESTINATION_${signal.toUpperCase()}_HEADER_${String(index)}`
707
+ });
708
+ }
709
+ const basic = destination.authentication;
710
+ const exporters = {};
711
+ const pipelines = {};
712
+ for (const signal of enabledSignals) {
713
+ const signalOptions = destination.endpoints[signal];
714
+ const exporterName = `otlp_http/${signal}`;
715
+ const headers = Object.fromEntries(headerBindings.filter((binding) => binding.signal === signal).map((binding) => [binding.name, "${env:" + binding.envName + "}"]));
716
+ exporters[exporterName] = {
717
+ ...signalOptions === void 0 ? { endpoint: destination.endpoints.url } : { [`${signal}_endpoint`]: signalOptions.url },
718
+ encoding: destination.encoding ?? "json",
719
+ compression: "gzip",
720
+ ...Object.keys(headers).length === 0 ? {} : { headers },
721
+ ...basic === void 0 ? {} : { auth: { authenticator: "basicauth/destination" } },
722
+ retry_on_failure: {
723
+ enabled: true,
724
+ initial_interval: "1s",
725
+ max_interval: "30s",
726
+ max_elapsed_time: "0s"
727
+ },
728
+ sending_queue: {
729
+ enabled: true,
730
+ num_consumers: 4,
731
+ queue_size: 1e4
732
+ }
733
+ };
734
+ pipelines[signal] = {
735
+ receivers: ["otlp"],
736
+ processors: ["memory_limiter", "batch"],
737
+ exporters: [exporterName]
738
+ };
739
+ }
740
+ const extensions = { health_check: { endpoint: "${env:MY_POD_IP}:13133" } };
741
+ if (basic !== void 0) extensions["basicauth/destination"] = { client_auth: {
742
+ username: "${env:OTEL_DESTINATION_USERNAME}",
743
+ password: "${env:OTEL_DESTINATION_PASSWORD}"
744
+ } };
745
+ const extraEnvs = headerBindings.map((binding) => ({
746
+ name: binding.envName,
747
+ valueFrom: { secretKeyRef: {
748
+ name: headerSecretName,
749
+ key: binding.secretKey
750
+ } }
751
+ }));
752
+ if (basic !== void 0) extraEnvs.push({
753
+ name: "OTEL_DESTINATION_USERNAME",
754
+ valueFrom: { secretKeyRef: {
755
+ name: basic.secretRef.name,
756
+ key: basic.secretRef.usernameKey
757
+ } }
758
+ }, {
759
+ name: "OTEL_DESTINATION_PASSWORD",
760
+ valueFrom: { secretKeyRef: {
761
+ name: basic.secretRef.name,
762
+ key: basic.secretRef.passwordKey
763
+ } }
764
+ });
765
+ return {
766
+ signals: [...enabledSignals],
767
+ headerSecretData: Object.fromEntries(headerBindings.map((binding) => [binding.secretKey, binding.value])),
768
+ values: {
769
+ fullnameOverride: releaseName,
770
+ mode: "deployment",
771
+ replicaCount: 1,
772
+ image: {
773
+ repository: OTEL_COLLECTOR_IMAGE_REPOSITORY,
774
+ tag: OTEL_COLLECTOR_IMAGE_TAG,
775
+ digest: OTEL_COLLECTOR_IMAGE_DIGEST,
776
+ pullPolicy: "IfNotPresent"
777
+ },
778
+ command: { name: "otelcol-contrib" },
779
+ alternateConfig: {
780
+ extensions,
781
+ receivers: { otlp: { protocols: { http: { endpoint: "${env:MY_POD_IP}:4318" } } } },
782
+ processors: {
783
+ memory_limiter: {
784
+ check_interval: "1s",
785
+ limit_percentage: 75,
786
+ spike_limit_percentage: 15
787
+ },
788
+ batch: {}
789
+ },
790
+ exporters,
791
+ service: {
792
+ extensions: Object.keys(extensions),
793
+ pipelines,
794
+ telemetry: { logs: { level: "info" } }
795
+ }
796
+ },
797
+ extraEnvs,
798
+ ports: {
799
+ otlp: { enabled: false },
800
+ "otlp-http": {
801
+ enabled: true,
802
+ containerPort: 4318,
803
+ servicePort: 4318,
804
+ hostPort: null,
805
+ protocol: "TCP"
806
+ },
807
+ "jaeger-compact": { enabled: false },
808
+ "jaeger-thrift": { enabled: false },
809
+ "jaeger-grpc": { enabled: false },
810
+ zipkin: { enabled: false },
811
+ metrics: { enabled: false }
812
+ },
813
+ service: { type: "ClusterIP" },
814
+ ingress: { enabled: false },
815
+ serviceAccount: {
816
+ create: true,
817
+ automountServiceAccountToken: false
818
+ },
819
+ clusterRole: { create: false },
820
+ resources: {
821
+ requests: {
822
+ cpu: "100m",
823
+ memory: "128Mi"
824
+ },
825
+ limits: { memory: "512Mi" }
826
+ },
827
+ podSecurityContext: {
828
+ runAsNonRoot: true,
829
+ runAsUser: 10001,
830
+ runAsGroup: 10001,
831
+ fsGroup: 10001,
832
+ seccompProfile: { type: "RuntimeDefault" }
833
+ },
834
+ securityContext: {
835
+ allowPrivilegeEscalation: false,
836
+ readOnlyRootFilesystem: true,
837
+ capabilities: { drop: ["ALL"] }
838
+ },
839
+ podAnnotations: {
840
+ "alchemy.run/namespace-revision": namespaceRevision,
841
+ ...headerSecretRevision === void 0 ? {} : { "alchemy.run/header-secret-revision": headerSecretRevision },
842
+ ...basicSecretRevision === void 0 ? {} : { "alchemy.run/basic-secret-revision": basicSecretRevision }
843
+ }
844
+ }
845
+ };
846
+ };
847
+ const otelCollectorEndpoints = (serviceName, namespace) => {
848
+ const otelEndpoint = `http://${serviceName}.${namespace}.svc.cluster.local:4318`;
849
+ return {
850
+ otelEndpoint,
851
+ otelTracesEndpoint: `${otelEndpoint}/v1/traces`,
852
+ otelLogsEndpoint: `${otelEndpoint}/v1/logs`,
853
+ otelMetricsEndpoint: `${otelEndpoint}/v1/metrics`,
854
+ otlp: { url: otelEndpoint }
855
+ };
856
+ };
857
+ /** Deploy a minimal OTLP/HTTP gateway for one downstream destination. */
858
+ const OtelCollector = (id, props) => Effect.gen(function* () {
859
+ yield* Effect.try(() => validateOtelCollectorProps(props));
860
+ const authenticationNamespace = props.destination.authentication?.secretRef.namespace;
861
+ const namespaceName = props.namespace ?? authenticationNamespace ?? "otel-collector";
862
+ const releaseName = props.releaseName ?? "otel-collector";
863
+ const createNamespace = props.createNamespace ?? authenticationNamespace === void 0;
864
+ let namespaceRevision = "external";
865
+ let secretNamespace = namespaceName;
866
+ if (createNamespace) {
867
+ const namespace = yield* Kubernetes.Manifest(`${id}Namespace`, {
868
+ cluster: props.cluster,
869
+ manifest: {
870
+ apiVersion: "v1",
871
+ kind: "Namespace",
872
+ metadata: { name: namespaceName }
873
+ }
874
+ });
875
+ namespaceRevision = Output.map(namespace.uid, (uid) => uid ?? "created");
876
+ secretNamespace = namespace.name;
877
+ }
878
+ const preliminary = planOtelCollector({
879
+ releaseName,
880
+ destination: props.destination,
881
+ namespaceRevision,
882
+ headerSecretName: `${releaseName}-headers`
883
+ });
884
+ let headerSecretRevision;
885
+ if (Object.keys(preliminary.headerSecretData).length > 0) {
886
+ const headerSecret = yield* Secret(`${id}Headers`, {
887
+ cluster: props.cluster,
888
+ namespace: secretNamespace,
889
+ name: `${releaseName}-headers`,
890
+ stringData: preliminary.headerSecretData
891
+ });
892
+ headerSecretRevision = Output.map(headerSecret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown");
893
+ }
894
+ const basicSecretRevision = props.destination.authentication?.secretRef.resourceVersion;
895
+ const plan = planOtelCollector({
896
+ releaseName,
897
+ destination: props.destination,
898
+ namespaceRevision,
899
+ headerSecretName: `${releaseName}-headers`,
900
+ headerSecretRevision,
901
+ basicSecretRevision
902
+ });
903
+ yield* ReadyHelmChart(`${id}Chart`, {
904
+ cluster: props.cluster,
905
+ chart: "opentelemetry-collector",
906
+ repo: "https://open-telemetry.github.io/opentelemetry-helm-charts",
907
+ version: OTEL_COLLECTOR_CHART_VERSION,
908
+ releaseName,
909
+ namespace: namespaceName,
910
+ createNamespace: false,
911
+ timeoutSeconds: props.timeoutSeconds ?? 300,
912
+ values: plan.values
913
+ });
914
+ const endpoints = otelCollectorEndpoints(releaseName, namespaceName);
915
+ return {
916
+ namespace: namespaceName,
917
+ releaseName,
918
+ serviceName: releaseName,
919
+ signals: plan.signals,
920
+ ...endpoints
921
+ };
922
+ });
923
+ //#endregion
924
+ //#region src/parseable.ts
925
+ const PARSEABLE_CHART_VERSION = "3.2.1";
926
+ const PARSEABLE_IMAGE_REPOSITORY = "quay.io/parseablehq/parseable";
927
+ const PARSEABLE_IMAGE_TAG = "v3.1.0";
928
+ const PARSEABLE_IMAGE = `${PARSEABLE_IMAGE_REPOSITORY}:${PARSEABLE_IMAGE_TAG}`;
929
+ const kubernetesName$1 = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
930
+ const hostname = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
931
+ const validateParseableProps = (props) => {
932
+ const namespace = props.namespace ?? "parseable";
933
+ const releaseName = props.releaseName ?? "parseable";
934
+ if (!kubernetesName$1.test(namespace) || namespace.length > 63) throw new Error(`Invalid Parseable namespace: ${namespace}`);
935
+ if (!kubernetesName$1.test(releaseName) || releaseName.length > 44) throw new Error(`Invalid Parseable release name: ${releaseName}`);
936
+ if ((props.admin?.username ?? "admin").trim() === "") throw new Error("Parseable admin username must not be empty");
937
+ if ((props.staging?.size ?? "5Gi").trim() === "") throw new Error("Parseable staging size must not be empty");
938
+ if (Object.values({
939
+ logs: props.streams?.logs ?? "otel-logs",
940
+ traces: props.streams?.traces ?? "otel-traces",
941
+ metrics: props.streams?.metrics ?? "otel-metrics"
942
+ }).some((stream) => stream.trim() === "")) throw new Error("Parseable stream names must not be empty");
943
+ if (props.storage.sessionToken !== void 0) throw new Error("Parseable does not support S3 session tokens; use static bucket-scoped credentials");
944
+ if (props.ingress !== void 0 && !hostname.test(props.ingress.host)) throw new Error(`Invalid Parseable ingress host: ${props.ingress.host}`);
945
+ if (props.ingress?.className !== void 0 && !hostname.test(props.ingress.className)) throw new Error(`Invalid Parseable ingress class: ${props.ingress.className}`);
946
+ if (props.ingress?.tlsSecretName !== void 0 && !hostname.test(props.ingress.tlsSecretName)) throw new Error(`Invalid Parseable TLS Secret name: ${props.ingress.tlsSecretName}`);
947
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("Parseable timeoutSeconds must be greater than zero");
948
+ };
949
+ const parseableHelmValues = ({ releaseName, secretName, secretResourceVersion, forcePathStyle, staging }) => ({ parseable: {
950
+ fullnameOverride: releaseName,
951
+ deploymentMode: "standalone",
952
+ image: {
953
+ repository: PARSEABLE_IMAGE_REPOSITORY,
954
+ tag: PARSEABLE_IMAGE_TAG,
955
+ pullPolicy: "IfNotPresent"
956
+ },
957
+ store: {
958
+ type: "s3-store",
959
+ secretName
960
+ },
961
+ standalone: { unified: {
962
+ env: {
963
+ P_CHECK_UPDATE: "false",
964
+ P_SEND_ANONYMOUS_USAGE_DATA: "false",
965
+ P_S3_PATH_STYLE: String(forcePathStyle ?? false)
966
+ },
967
+ persistence: {
968
+ data: { enabled: false },
969
+ staging: {
970
+ enabled: true,
971
+ storageClass: staging.storageClass ?? "",
972
+ accessMode: "ReadWriteOnce",
973
+ size: staging.size ?? "5Gi"
974
+ }
975
+ },
976
+ podAnnotations: { "alchemy.run/parseable-secret-resource-version": secretResourceVersion }
977
+ } }
978
+ } });
979
+ const parseableIngressManifest = (namespace, serviceName, ingress) => ({
980
+ apiVersion: "networking.k8s.io/v1",
981
+ kind: "Ingress",
982
+ metadata: {
983
+ name: serviceName,
984
+ namespace
985
+ },
986
+ spec: {
987
+ ...ingress.className === void 0 ? {} : { ingressClassName: ingress.className },
988
+ ...ingress.tlsSecretName === void 0 ? {} : { tls: [{
989
+ hosts: [ingress.host],
990
+ secretName: ingress.tlsSecretName
991
+ }] },
992
+ rules: [{
993
+ host: ingress.host,
994
+ http: { paths: [{
995
+ path: "/",
996
+ pathType: "Prefix",
997
+ backend: { service: {
998
+ name: serviceName,
999
+ port: { number: 80 }
1000
+ } }
1001
+ }] }
1002
+ }]
1003
+ }
1004
+ });
1005
+ const parseableOtlpEndpoints = (internalUrl, streams) => {
1006
+ const otelTracesEndpoint = `${internalUrl}/v1/traces`;
1007
+ const otelLogsEndpoint = `${internalUrl}/v1/logs`;
1008
+ const otelMetricsEndpoint = `${internalUrl}/v1/metrics`;
1009
+ return {
1010
+ otelEndpoint: internalUrl,
1011
+ otelTracesEndpoint,
1012
+ otelLogsEndpoint,
1013
+ otelMetricsEndpoint,
1014
+ endpoints: {
1015
+ url: internalUrl,
1016
+ traces: {
1017
+ url: otelTracesEndpoint,
1018
+ headers: {
1019
+ "X-P-Stream": streams.traces,
1020
+ "X-P-Log-Source": "otel-traces"
1021
+ }
1022
+ },
1023
+ logs: {
1024
+ url: otelLogsEndpoint,
1025
+ headers: {
1026
+ "X-P-Stream": streams.logs,
1027
+ "X-P-Log-Source": "otel-logs"
1028
+ }
1029
+ },
1030
+ metrics: {
1031
+ url: otelMetricsEndpoint,
1032
+ headers: {
1033
+ "X-P-Stream": streams.metrics,
1034
+ "X-P-Log-Source": "otel-metrics"
1035
+ }
1036
+ }
1037
+ }
1038
+ };
1039
+ };
1040
+ /** Deploy Parseable OSS with S3 durability and its bundled web UI. */
1041
+ const Parseable = (id, props) => Effect.gen(function* () {
1042
+ yield* Effect.try(() => validateParseableProps(props));
1043
+ const namespaceName = props.namespace ?? "parseable";
1044
+ const releaseName = props.releaseName ?? "parseable";
1045
+ const serviceName = `${releaseName}-standalone-service`;
1046
+ const streams = {
1047
+ logs: props.streams?.logs ?? "otel-logs",
1048
+ traces: props.streams?.traces ?? "otel-traces",
1049
+ metrics: props.streams?.metrics ?? "otel-metrics"
1050
+ };
1051
+ const username = props.admin?.username ?? "admin";
1052
+ const password = props.admin?.password ?? (yield* makeRandom(`${id}AdminPassword`));
1053
+ const namespace = yield* Kubernetes.Manifest(`${id}Namespace`, {
1054
+ cluster: props.cluster,
1055
+ manifest: {
1056
+ apiVersion: "v1",
1057
+ kind: "Namespace",
1058
+ metadata: { name: namespaceName }
1059
+ }
1060
+ });
1061
+ const secret = yield* Secret(`${id}Credentials`, {
1062
+ cluster: props.cluster,
1063
+ namespace: namespace.name,
1064
+ name: `${releaseName}-env`,
1065
+ stringData: {
1066
+ addr: "0.0.0.0:8000",
1067
+ username,
1068
+ password,
1069
+ "s3.url": props.storage.endpoint,
1070
+ "s3.access.key": props.storage.accessKeyId,
1071
+ "s3.secret.key": props.storage.secretAccessKey,
1072
+ "s3.bucket": props.storage.bucket,
1073
+ "s3.region": props.storage.region
1074
+ }
1075
+ });
1076
+ const chart = yield* ReadyHelmChart(`${id}Chart`, {
1077
+ cluster: props.cluster,
1078
+ chart: "parseable",
1079
+ repo: "https://charts.parseable.com",
1080
+ version: PARSEABLE_CHART_VERSION,
1081
+ releaseName,
1082
+ namespace: namespaceName,
1083
+ createNamespace: false,
1084
+ timeoutSeconds: props.timeoutSeconds ?? 300,
1085
+ values: parseableHelmValues({
1086
+ releaseName,
1087
+ secretName: secret.name,
1088
+ secretResourceVersion: Output.map(secret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown"),
1089
+ forcePathStyle: props.storage.forcePathStyle,
1090
+ staging: props.staging ?? {}
1091
+ })
1092
+ });
1093
+ if (props.ingress !== void 0) yield* Kubernetes.Manifest(`${id}Ingress`, {
1094
+ cluster: chart.connection,
1095
+ manifest: parseableIngressManifest(namespaceName, serviceName, props.ingress)
1096
+ });
1097
+ const internalUrl = `http://${serviceName}.${namespaceName}.svc.cluster.local`;
1098
+ const uiUrl = props.ingress === void 0 ? internalUrl : `${props.ingress.tlsSecretName === void 0 ? "http" : "https"}://${props.ingress.host}`;
1099
+ const otlp = parseableOtlpEndpoints(internalUrl, streams);
1100
+ return {
1101
+ namespace: namespaceName,
1102
+ releaseName,
1103
+ serviceName,
1104
+ streams,
1105
+ internalUrl,
1106
+ uiUrl,
1107
+ otelEndpoint: otlp.otelEndpoint,
1108
+ otelTracesEndpoint: otlp.otelTracesEndpoint,
1109
+ otelLogsEndpoint: otlp.otelLogsEndpoint,
1110
+ otelMetricsEndpoint: otlp.otelMetricsEndpoint,
1111
+ admin: {
1112
+ username,
1113
+ password
1114
+ },
1115
+ credentialsSecretRef: {
1116
+ namespace: namespaceName,
1117
+ name: `${releaseName}-env`,
1118
+ usernameKey: "username",
1119
+ passwordKey: "password",
1120
+ resourceVersion: secret.resourceVersion
1121
+ },
1122
+ endpoints: otlp.endpoints,
1123
+ ...props.ingress === void 0 ? {} : { ingress: {
1124
+ host: props.ingress.host,
1125
+ url: uiUrl,
1126
+ tls: props.ingress.tlsSecretName !== void 0
1127
+ } }
1128
+ };
1129
+ });
1130
+ //#endregion
1131
+ //#region src/container-registry.ts
1132
+ const CONTAINER_REGISTRY_CHART = "oci://ghcr.io/project-zot/helm-charts/zot";
1133
+ const CONTAINER_REGISTRY_CHART_VERSION = "0.1.122";
1134
+ const CONTAINER_REGISTRY_IMAGE_REPOSITORY = "ghcr.io/project-zot/zot-minimal";
1135
+ const CONTAINER_REGISTRY_IMAGE_TAG = `v2.1.20@sha256:73f26433b341f4a319963f7c5e169858663a10565e4037e71605737daee202ee`;
1136
+ const CONTAINER_REGISTRY_IMAGE = `${CONTAINER_REGISTRY_IMAGE_REPOSITORY}:${CONTAINER_REGISTRY_IMAGE_TAG}`;
1137
+ const kubernetesName = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
1138
+ const dnsSubdomain = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
1139
+ const goDuration = /^(?:[1-9]\d*(?:\.\d+)?(?:ms|s|m|h))+$/;
1140
+ const utcWindow = /^(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d$/;
1141
+ const validateContainerRegistryProps = (props) => {
1142
+ const namespace = props.namespace ?? "registry";
1143
+ const releaseName = props.releaseName ?? "registry";
1144
+ const username = props.credentials?.username ?? "registry";
1145
+ const pullSecretName = props.pullSecrets?.name ?? `${releaseName}-pull`;
1146
+ const storagePrefix = props.storagePrefix ?? "registry";
1147
+ const garbageCollection = {
1148
+ interval: props.garbageCollection?.interval ?? "24h",
1149
+ delay: props.garbageCollection?.delay ?? "24h",
1150
+ timeWindowUtc: props.garbageCollection?.timeWindowUtc ?? "02:00-04:00"
1151
+ };
1152
+ if (!kubernetesName.test(namespace) || namespace.length > 63) throw new Error(`Invalid container registry namespace: ${namespace}`);
1153
+ if (!kubernetesName.test(releaseName) || releaseName.length > 53) throw new Error(`Invalid container registry release name: ${releaseName}`);
1154
+ if (username.trim() === "" || username.includes(":") || username.includes("\n") || username.includes("\r")) throw new Error("Container registry username must be non-empty and contain no colon or newline");
1155
+ if (props.credentials?.password !== void 0 && Redacted.value(props.credentials.password) === "") throw new Error("Container registry password must not be empty");
1156
+ if (props.credentials?.password !== void 0 && truncates(Redacted.value(props.credentials.password))) throw new Error("Container registry password must not exceed 72 UTF-8 bytes");
1157
+ if (!dnsSubdomain.test(props.ingress.host)) throw new Error(`Invalid container registry ingress host: ${props.ingress.host}`);
1158
+ if (props.ingress.className !== void 0 && !dnsSubdomain.test(props.ingress.className)) throw new Error(`Invalid container registry ingress class: ${props.ingress.className}`);
1159
+ if (!kubernetesName.test(props.ingress.tlsSecretName) || props.ingress.tlsSecretName.length > 63) throw new Error(`Invalid container registry TLS Secret name: ${props.ingress.tlsSecretName}`);
1160
+ if (!kubernetesName.test(pullSecretName) || pullSecretName.length > 63) throw new Error(`Invalid container registry pull Secret name: ${pullSecretName}`);
1161
+ const pullNamespaces = props.pullSecrets?.namespaces ?? [];
1162
+ if (pullNamespaces.some((name) => !kubernetesName.test(name) || name.length > 63)) throw new Error("Invalid container registry pull Secret namespace");
1163
+ if (new Set(pullNamespaces).size !== pullNamespaces.length) throw new Error("Container registry pull Secret namespaces must not contain duplicates");
1164
+ if (storagePrefix === "" || storagePrefix.startsWith("/") || storagePrefix.endsWith("/") || storagePrefix.split("/").some((part) => part === "" || part === "." || part === "..") || !/^[A-Za-z0-9._/-]+$/.test(storagePrefix)) throw new Error("Container registry storagePrefix must be a safe relative S3 prefix");
1165
+ if (props.storage.region.trim() === "" || props.storage.bucket.trim() === "" || props.storage.accessKeyId.trim() === "" || Redacted.value(props.storage.secretAccessKey) === "" || props.storage.sessionToken !== void 0 && Redacted.value(props.storage.sessionToken) === "") throw new Error("Container registry S3 access fields must not be empty");
1166
+ let endpoint;
1167
+ try {
1168
+ endpoint = new URL(props.storage.endpoint);
1169
+ } catch {
1170
+ throw new Error("Container registry S3 endpoint must be a valid URL");
1171
+ }
1172
+ if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") throw new Error("Container registry S3 endpoint must be an HTTP(S) URL without credentials, query, or fragment");
1173
+ if (!goDuration.test(garbageCollection.interval) || !goDuration.test(garbageCollection.delay)) throw new Error("Container registry garbage-collection interval and delay must be positive Go durations");
1174
+ if (!utcWindow.test(garbageCollection.timeWindowUtc) || garbageCollection.timeWindowUtc.slice(0, 5) === garbageCollection.timeWindowUtc.slice(6)) throw new Error("Container registry garbage-collection window must be a non-empty UTC HH:MM-HH:MM range");
1175
+ if (props.timeoutSeconds !== void 0 && (!Number.isFinite(props.timeoutSeconds) || props.timeoutSeconds <= 0)) throw new Error("Container registry timeoutSeconds must be greater than zero");
1176
+ };
1177
+ const containerRegistryHtpasswd = (username, password, saltSeed) => {
1178
+ const plainPassword = Redacted.value(password);
1179
+ if (truncates(plainPassword)) throw new Error("Container registry password must not exceed 72 UTF-8 bytes");
1180
+ const plainSaltSeed = Redacted.value(saltSeed);
1181
+ if (!/^[a-f0-9]{32}$/.test(plainSaltSeed)) throw new Error("Container registry bcrypt salt seed must be 16 bytes");
1182
+ const salt = `$2b$12$${encodeBase64(Buffer.from(plainSaltSeed, "hex"), 16)}`;
1183
+ return Redacted.make(`${username}:${hashSync(plainPassword, salt)}\n`);
1184
+ };
1185
+ const containerRegistryDockerConfig = (host, username, password) => {
1186
+ const plainPassword = Redacted.value(password);
1187
+ return Redacted.make(JSON.stringify({ auths: { [host]: {
1188
+ username,
1189
+ password: plainPassword,
1190
+ auth: Buffer.from(`${username}:${plainPassword}`).toString("base64")
1191
+ } } }));
1192
+ };
1193
+ const containerRegistryHelmValues = ({ releaseName, storage, storagePrefix, storageSecretName, storageSecretRevision, authSecretName, authSecretRevision, namespaceRevision, ingress, username, garbageCollection }) => {
1194
+ const endpoint = new URL(storage.endpoint);
1195
+ const configuration = {
1196
+ distSpecVersion: "1.1.1",
1197
+ storage: {
1198
+ rootDirectory: "/var/lib/registry",
1199
+ dedupe: false,
1200
+ gc: true,
1201
+ gcDelay: garbageCollection.delay,
1202
+ gcInterval: garbageCollection.interval,
1203
+ gcTimeWindow: garbageCollection.timeWindowUtc,
1204
+ fastRestart: false,
1205
+ redirectBlobURL: false,
1206
+ storageDriver: {
1207
+ name: "s3",
1208
+ region: storage.region,
1209
+ bucket: storage.bucket,
1210
+ regionendpoint: storage.endpoint,
1211
+ rootdirectory: `/${storagePrefix}`,
1212
+ forcepathstyle: storage.forcePathStyle ?? false,
1213
+ secure: endpoint.protocol === "https:",
1214
+ skipverify: false,
1215
+ v4auth: true
1216
+ }
1217
+ },
1218
+ http: {
1219
+ address: "0.0.0.0",
1220
+ port: "5000",
1221
+ readTimeout: "15m",
1222
+ writeTimeout: "15m",
1223
+ auth: { htpasswd: { path: "/secrets/auth/htpasswd" } },
1224
+ accessControl: {
1225
+ repositories: { "**": { defaultPolicy: [] } },
1226
+ adminPolicy: {
1227
+ users: [username],
1228
+ actions: [
1229
+ "read",
1230
+ "create",
1231
+ "update",
1232
+ "delete"
1233
+ ]
1234
+ }
1235
+ }
1236
+ },
1237
+ log: { level: "info" }
1238
+ };
1239
+ return {
1240
+ fullnameOverride: releaseName,
1241
+ replicaCount: 1,
1242
+ image: {
1243
+ repository: CONTAINER_REGISTRY_IMAGE_REPOSITORY,
1244
+ tag: CONTAINER_REGISTRY_IMAGE_TAG,
1245
+ pullPolicy: "IfNotPresent"
1246
+ },
1247
+ strategy: { type: "Recreate" },
1248
+ service: {
1249
+ type: "ClusterIP",
1250
+ port: 5e3
1251
+ },
1252
+ ingress: {
1253
+ enabled: true,
1254
+ annotations: ingress.annotations ?? {},
1255
+ className: ingress.className ?? "",
1256
+ pathtype: "Prefix",
1257
+ hosts: [{
1258
+ host: ingress.host,
1259
+ paths: [{ path: "/" }]
1260
+ }],
1261
+ tls: [{
1262
+ secretName: ingress.tlsSecretName,
1263
+ hosts: [ingress.host]
1264
+ }]
1265
+ },
1266
+ mountConfig: true,
1267
+ configFiles: { "config.json": JSON.stringify(configuration) },
1268
+ externalSecrets: [{
1269
+ secretName: authSecretName,
1270
+ mountPath: "/secrets/auth"
1271
+ }],
1272
+ persistence: false,
1273
+ env: [
1274
+ {
1275
+ name: "AWS_ACCESS_KEY_ID",
1276
+ valueFrom: { secretKeyRef: {
1277
+ name: storageSecretName,
1278
+ key: "access-key-id"
1279
+ } }
1280
+ },
1281
+ {
1282
+ name: "AWS_SECRET_ACCESS_KEY",
1283
+ valueFrom: { secretKeyRef: {
1284
+ name: storageSecretName,
1285
+ key: "secret-access-key"
1286
+ } }
1287
+ },
1288
+ ...storage.sessionToken === void 0 ? [] : [{
1289
+ name: "AWS_SESSION_TOKEN",
1290
+ valueFrom: { secretKeyRef: {
1291
+ name: storageSecretName,
1292
+ key: "session-token"
1293
+ } }
1294
+ }]
1295
+ ],
1296
+ serviceAccount: {
1297
+ create: false,
1298
+ name: releaseName
1299
+ },
1300
+ resources: {
1301
+ requests: {
1302
+ cpu: "25m",
1303
+ memory: "64Mi"
1304
+ },
1305
+ limits: { memory: "512Mi" }
1306
+ },
1307
+ podSecurityContext: {
1308
+ runAsNonRoot: true,
1309
+ runAsUser: 65532,
1310
+ runAsGroup: 65532,
1311
+ fsGroup: 65532,
1312
+ fsGroupChangePolicy: "OnRootMismatch",
1313
+ seccompProfile: { type: "RuntimeDefault" }
1314
+ },
1315
+ securityContext: {
1316
+ privileged: false,
1317
+ allowPrivilegeEscalation: false,
1318
+ readOnlyRootFilesystem: true,
1319
+ runAsNonRoot: true,
1320
+ runAsUser: 65532,
1321
+ runAsGroup: 65532,
1322
+ capabilities: { drop: ["ALL"] }
1323
+ },
1324
+ extraVolumeMounts: [{
1325
+ name: "tmp",
1326
+ mountPath: "/tmp"
1327
+ }],
1328
+ extraVolumes: [{
1329
+ name: "tmp",
1330
+ emptyDir: {}
1331
+ }],
1332
+ metrics: {
1333
+ enabled: false,
1334
+ serviceMonitor: { enabled: false }
1335
+ },
1336
+ podAnnotations: {
1337
+ "alchemy.run/namespace-revision": namespaceRevision,
1338
+ "alchemy.run/storage-secret-revision": storageSecretRevision,
1339
+ "alchemy.run/auth-secret-revision": authSecretRevision
1340
+ }
1341
+ };
1342
+ };
1343
+ /** Deploy a private, HTTPS-only, S3-backed OCI/Docker registry. */
1344
+ const ContainerRegistry = (id, props) => Effect.gen(function* () {
1345
+ yield* Effect.try(() => validateContainerRegistryProps(props));
1346
+ const namespaceName = props.namespace ?? "registry";
1347
+ const releaseName = props.releaseName ?? "registry";
1348
+ const createNamespace = props.createNamespace ?? true;
1349
+ const username = props.credentials?.username ?? "registry";
1350
+ const password = props.credentials?.password ?? (yield* makeRandom(`${id}RegistryPassword`));
1351
+ const saltSeed = yield* makeRandom(`${id}RegistryBcryptSalt`, { bytes: 16 });
1352
+ const htpasswd = Output.map(Output.all(Output.asOutput(password), Output.asOutput(saltSeed)), ([resolvedPassword, resolvedSaltSeed]) => containerRegistryHtpasswd(username, resolvedPassword, resolvedSaltSeed));
1353
+ let namespaceRevision = "external";
1354
+ let secretNamespace = namespaceName;
1355
+ if (createNamespace) {
1356
+ const namespace = yield* Kubernetes.Manifest(`${id}Namespace`, {
1357
+ cluster: props.cluster,
1358
+ manifest: {
1359
+ apiVersion: "v1",
1360
+ kind: "Namespace",
1361
+ metadata: { name: namespaceName }
1362
+ }
1363
+ });
1364
+ namespaceRevision = Output.map(namespace.uid, (uid) => uid ?? "created");
1365
+ secretNamespace = namespace.name;
1366
+ }
1367
+ const storageSecretName = `${releaseName}-storage`;
1368
+ const storageSecret = yield* Secret(`${id}StorageCredentials`, {
1369
+ cluster: props.cluster,
1370
+ namespace: secretNamespace,
1371
+ name: storageSecretName,
1372
+ stringData: {
1373
+ "access-key-id": props.storage.accessKeyId,
1374
+ "secret-access-key": props.storage.secretAccessKey,
1375
+ ...props.storage.sessionToken === void 0 ? {} : { "session-token": props.storage.sessionToken }
1376
+ }
1377
+ });
1378
+ const authSecretName = `${releaseName}-auth`;
1379
+ const authSecret = yield* Secret(`${id}Authentication`, {
1380
+ cluster: props.cluster,
1381
+ namespace: secretNamespace,
1382
+ name: authSecretName,
1383
+ stringData: { htpasswd }
1384
+ });
1385
+ const serviceAccount = yield* Kubernetes.Manifest(`${id}ServiceAccount`, {
1386
+ cluster: authSecret.connection,
1387
+ manifest: {
1388
+ apiVersion: "v1",
1389
+ kind: "ServiceAccount",
1390
+ metadata: {
1391
+ name: releaseName,
1392
+ namespace: namespaceName
1393
+ },
1394
+ automountServiceAccountToken: false
1395
+ }
1396
+ });
1397
+ const dockerConfig = Output.map(Output.asOutput(password), (resolved) => containerRegistryDockerConfig(props.ingress.host, username, resolved));
1398
+ const pullSecretName = props.pullSecrets?.name ?? `${releaseName}-pull`;
1399
+ const pullSecretRefs = [];
1400
+ for (const targetNamespace of props.pullSecrets?.namespaces ?? []) {
1401
+ const pullSecret = yield* Secret(`${id}PullSecret${targetNamespace}`, {
1402
+ cluster: props.cluster,
1403
+ namespace: targetNamespace,
1404
+ name: pullSecretName,
1405
+ type: "kubernetes.io/dockerconfigjson",
1406
+ stringData: { ".dockerconfigjson": dockerConfig }
1407
+ });
1408
+ pullSecretRefs.push({
1409
+ namespace: targetNamespace,
1410
+ name: pullSecretName,
1411
+ resourceVersion: pullSecret.resourceVersion
1412
+ });
1413
+ }
1414
+ const values = containerRegistryHelmValues({
1415
+ releaseName,
1416
+ storage: props.storage,
1417
+ storagePrefix: props.storagePrefix ?? "registry",
1418
+ storageSecretName,
1419
+ storageSecretRevision: Output.map(storageSecret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown"),
1420
+ authSecretName,
1421
+ authSecretRevision: Output.map(authSecret.resourceVersion, (resourceVersion) => resourceVersion ?? "unknown"),
1422
+ namespaceRevision,
1423
+ ingress: props.ingress,
1424
+ username,
1425
+ garbageCollection: {
1426
+ interval: props.garbageCollection?.interval ?? "24h",
1427
+ delay: props.garbageCollection?.delay ?? "24h",
1428
+ timeWindowUtc: props.garbageCollection?.timeWindowUtc ?? "02:00-04:00"
1429
+ }
1430
+ });
1431
+ yield* ReadyHelmChart(`${id}Chart`, {
1432
+ cluster: serviceAccount.connection,
1433
+ chart: CONTAINER_REGISTRY_CHART,
1434
+ version: CONTAINER_REGISTRY_CHART_VERSION,
1435
+ releaseName,
1436
+ namespace: namespaceName,
1437
+ createNamespace: false,
1438
+ timeoutSeconds: props.timeoutSeconds ?? 300,
1439
+ values
1440
+ });
1441
+ const url = `https://${props.ingress.host}`;
1442
+ return {
1443
+ namespace: namespaceName,
1444
+ releaseName,
1445
+ serviceName: releaseName,
1446
+ host: props.ingress.host,
1447
+ url,
1448
+ imagePrefix: props.ingress.host,
1449
+ internalUrl: `http://${releaseName}.${namespaceName}.svc.cluster.local:5000`,
1450
+ credentials: {
1451
+ username,
1452
+ password
1453
+ },
1454
+ pullSecretRefs,
1455
+ ingress: {
1456
+ host: props.ingress.host,
1457
+ url,
1458
+ tls: true
1459
+ }
1460
+ };
1461
+ });
1462
+ //#endregion
1463
+ //#region src/index.ts
1464
+ var Providers = class extends Provider.ProviderCollection()("KubernetesAddons") {};
1465
+ const SecretResource = Resource("KubernetesAddons.Secret");
1466
+ const toRedacted = (value) => Redacted.isRedacted(value) ? value : Redacted.make(value);
1467
+ const liftValue = (value) => Config.isConfig(value) ? Config.map(value, toRedacted) : Effect.isEffect(value) ? Effect.map(value, toRedacted) : Output.isOutput(value) ? Output.map(value, toRedacted) : toRedacted(value);
1468
+ const redactProps = Effect.fn(function* (props) {
1469
+ const stringData = yield* Effect.all(Object.entries(props.stringData).map(([key, value]) => Effect.isEffect(value) && !Config.isConfig(value) ? Effect.map(Effect.orDie(Effect.mapError(value, () => /* @__PURE__ */ new Error("Failed to resolve Kubernetes Secret input"))), (resolved) => [key, toRedacted(resolved)]) : Effect.succeed([key, liftValue(value)])), { concurrency: "unbounded" });
1470
+ return {
1471
+ ...props,
1472
+ stringData: Object.fromEntries(stringData)
1473
+ };
1474
+ });
1475
+ /** Write-only Kubernetes Secret with Redacted request-boundary handling. */
1476
+ const Secret = Object.assign((id, props) => Effect.isEffect(props) ? SecretResource(id, Effect.flatMap(props, redactProps)) : SecretResource(id, redactProps(props)), SecretResource);
1477
+ const safeSecretError = (operation, name, namespace, error) => /* @__PURE__ */ new Error(`Failed to ${operation} Kubernetes Secret ${namespace}/${name}${error instanceof KubernetesApiError ? `: Kubernetes API returned ${String(error.statusCode)}` : ""}`);
1478
+ const connectionIdentity = (connection) => {
1479
+ const auth = Object.fromEntries(Object.entries(connection.auth).filter(([key]) => ![
1480
+ "token",
1481
+ "key",
1482
+ "certificate",
1483
+ "env"
1484
+ ].includes(key)));
1485
+ return JSON.stringify({
1486
+ endpoint: connection.endpoint,
1487
+ auth
1488
+ });
1489
+ };
1490
+ const secretRef = (value) => ({
1491
+ apiVersion: "v1",
1492
+ kind: "Secret",
1493
+ name: value.name,
1494
+ namespace: value.namespace
1495
+ });
1496
+ const SecretProvider = () => Provider.succeed(Secret, {
1497
+ stables: [
1498
+ "connection",
1499
+ "name",
1500
+ "namespace"
1501
+ ],
1502
+ list: () => Effect.succeed([]),
1503
+ diff: ({ olds = {}, news }) => Effect.sync(() => {
1504
+ if (!isResolved(news)) return void 0;
1505
+ if (olds.cluster !== void 0 && connectionIdentity(Kubernetes.toConnection(olds.cluster)) !== connectionIdentity(Kubernetes.toConnection(news.cluster)) || olds.name !== news.name || (olds.namespace ?? "default") !== (news.namespace ?? "default")) return { action: "replace" };
1506
+ }),
1507
+ read: ({ output }) => Effect.gen(function* () {
1508
+ if (output === void 0) return void 0;
1509
+ const connected = yield* connectCluster(output.connection).pipe(Effect.catchIf((error) => error instanceof Kubernetes.ClusterNotFoundError, () => Effect.succeed(void 0)), Effect.mapError((error) => safeSecretError("read", output.name, output.namespace, error)));
1510
+ if (connected === void 0) return void 0;
1511
+ const observed = yield* requestJson({
1512
+ transport: connected.transport,
1513
+ method: "GET",
1514
+ path: objectPath(secretRef(output))
1515
+ }).pipe(Effect.catchIf((error) => error instanceof KubernetesApiError && error.statusCode === 404, () => Effect.succeed(void 0)), Effect.mapError((error) => safeSecretError("read", output.name, output.namespace, error)));
1516
+ if (observed === void 0) return void 0;
1517
+ const secret = observed;
1518
+ return {
1519
+ ...output,
1520
+ type: secret.type ?? output.type,
1521
+ uid: secret.metadata?.uid ?? output.uid,
1522
+ resourceVersion: secret.metadata?.resourceVersion ?? output.resourceVersion
1523
+ };
1524
+ }),
1525
+ reconcile: ({ news, session }) => Effect.gen(function* () {
1526
+ const namespace = news.namespace ?? "default";
1527
+ const connected = yield* connectCluster(news.cluster).pipe(Effect.mapError((error) => safeSecretError("apply", news.name, namespace, error)));
1528
+ const result = yield* requestJson({
1529
+ transport: connected.transport,
1530
+ method: "PATCH",
1531
+ path: `${objectPath(secretRef({
1532
+ name: news.name,
1533
+ namespace
1534
+ }))}?fieldManager=alchemy-kubernetes-addons&force=true`,
1535
+ body: {
1536
+ apiVersion: "v1",
1537
+ kind: "Secret",
1538
+ metadata: {
1539
+ name: news.name,
1540
+ namespace
1541
+ },
1542
+ type: news.type ?? "Opaque",
1543
+ ...news.immutable === void 0 ? {} : { immutable: news.immutable },
1544
+ stringData: Object.fromEntries(Object.entries(news.stringData).map(([key, value]) => [key, Redacted.value(value)]))
1545
+ }
1546
+ }).pipe(Effect.mapError((error) => safeSecretError("apply", news.name, namespace, error)));
1547
+ yield* session.note(`Applied Kubernetes Secret ${namespace}/${news.name}`);
1548
+ return {
1549
+ connection: connected.connection,
1550
+ name: news.name,
1551
+ namespace,
1552
+ type: result.type ?? news.type ?? "Opaque",
1553
+ uid: result.metadata?.uid,
1554
+ resourceVersion: result.metadata?.resourceVersion
1555
+ };
1556
+ }),
1557
+ delete: ({ output }) => Effect.gen(function* () {
1558
+ const connected = yield* connectCluster(output.connection).pipe(Effect.catchIf((error) => error instanceof Kubernetes.ClusterNotFoundError, () => Effect.succeed(void 0)), Effect.mapError((error) => safeSecretError("delete", output.name, output.namespace, error)));
1559
+ if (connected === void 0) return;
1560
+ yield* requestJson({
1561
+ transport: connected.transport,
1562
+ method: "DELETE",
1563
+ path: objectPath(secretRef(output))
1564
+ }).pipe(Effect.catchIf((error) => error instanceof KubernetesApiError && error.statusCode === 404, () => Effect.void), Effect.mapError((error) => safeSecretError("delete", output.name, output.namespace, error)));
1565
+ })
1566
+ });
1567
+ const Readiness = Resource("KubernetesAddons.Readiness");
1568
+ const ReadinessProvider = () => Provider.succeed(Readiness, {
1569
+ list: () => Effect.succeed([]),
1570
+ read: ({ output }) => Effect.succeed(output),
1571
+ reconcile: ({ news }) => Effect.gen(function* () {
1572
+ const connected = yield* connectCluster(news.cluster).pipe(Effect.mapError(() => /* @__PURE__ */ new Error("Failed to connect to Kubernetes for readiness")));
1573
+ yield* waitForObjectsReady({
1574
+ transport: connected.transport,
1575
+ objects: news.objects,
1576
+ timeoutSeconds: news.timeoutSeconds ?? 300
1577
+ });
1578
+ return {
1579
+ connection: connected.connection,
1580
+ objects: [...news.objects],
1581
+ revision: news.revision
1582
+ };
1583
+ }),
1584
+ delete: () => Effect.void
1585
+ });
1586
+ /** Apply an Alchemy Helm chart, then wait for its CRDs and workloads. */
1587
+ const ReadyHelmChart = (id, props) => Effect.gen(function* () {
1588
+ const { timeoutSeconds, ...chartProps } = props;
1589
+ const chart = yield* Kubernetes.HelmChart(id, chartProps);
1590
+ yield* Readiness(`${id}Readiness`, {
1591
+ cluster: chart.connection,
1592
+ objects: chart.objects,
1593
+ revision: chart.code.hash,
1594
+ timeoutSeconds: timeoutSeconds ?? 300
1595
+ });
1596
+ return chart;
1597
+ });
1598
+ const providers = () => Layer.effect(Providers, Provider.collection([Secret, Readiness])).pipe(Layer.provide(Layer.mergeAll(SecretProvider(), ReadinessProvider())), Layer.provideMerge(RandomProvider()));
1599
+ //#endregion
1600
+ export { CERT_MANAGER_CHART, CERT_MANAGER_CHART_VERSION, CERT_MANAGER_IMAGE_VERSION, CONTAINER_REGISTRY_CHART, CONTAINER_REGISTRY_CHART_VERSION, CONTAINER_REGISTRY_IMAGE, CertManager, CloudflareAcmeIssuer, CloudflareExternalDns, ContainerRegistry, EXTERNAL_DNS_CHART_VERSION, EXTERNAL_DNS_IMAGE, KubernetesReadinessError, LETSENCRYPT_PRODUCTION_URL, LETSENCRYPT_STAGING_URL, OTEL_COLLECTOR_CHART_VERSION, OTEL_COLLECTOR_IMAGE, OtelCollector, PARSEABLE_CHART_VERSION, PARSEABLE_IMAGE, Parseable, Providers, Readiness, ReadinessProvider, ReadyHelmChart, Secret, SecretProvider, certManagerHelmValues, cloudflareAcmeIssuerManifest, cloudflareAcmeIssuerName, cloudflareAcmeIssuerTokenPolicies, containerRegistryDockerConfig, containerRegistryHelmValues, containerRegistryHtpasswd, kubernetesObjectReadiness, parseableHelmValues, planCloudflareExternalDns, planOtelCollector, providers, validateCertManagerProps, validateCloudflareAcmeIssuerProps, validateContainerRegistryProps };