1import * as AWS from "@/AWS";
2import { Network } from "@/AWS/EC2/Network.ts";
3import { Cluster } from "@/AWS/EKS/Cluster.ts";
4import { makeEksTransport } from "@/AWS/EKS/KubernetesAdapter.ts";
5import * as Kubernetes from "@/Kubernetes";
6import { readObject } from "@/Kubernetes/internal/client.ts";
7import * as Core from "@/Test/Core";
8import * as Provider from "@/Provider";
9import * as Test from "@/Test/Alchemy";
10import * as dynamodb from "@distilled.cloud/aws/dynamodb";
11import { describe, expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Schedule from "effect/Schedule";
15import * as HttpClient from "effect/http/HttpClient";
16import EksHostApi from "./fixtures/deployment.ts";
17
18const testOptions = {
19 providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()),
20};
21const { test, beforeAll, afterAll } = Test.make(testOptions);
22
23// Ungated probe: `Deployment` is a composite host (in-cluster
24// Deployment/Service plus adapter-owned cloud resources — ECR repo,
25// pod-identity role, PodIdentityAssociation on EKS). It has no faithful
26// single-API enumeration, so `list()` is intentionally empty. This probe
27// proves the provider is registered, its record type-checks (a missing /
28// mistyped `list` collapses Provider.of inference), and it runs live —
29// without paying the ~15-minute EKS control-plane create.
30test.provider(
31 "list returns an empty array (composite host, not enumerable)",
32 () =>
33 Effect.gen(function* () {
34 const provider = yield* Provider.findProvider(Kubernetes.Deployment);
35 const all = yield* provider.list();
36 expect(Array.isArray(all)).toBe(true);
37 expect(all).toEqual([]);
38 }),
39 {
40 tags: [
41 "provider:aws",
42 "provider:kubernetes",
43 "provider:kubernetes:deployment",
44 "live",
45 ],
46 },
47);
48
49// Full end-to-end (gated). An EKS Auto Mode cluster takes ~10–15 min to
50// provision, plus image build/push, pod scheduling, and NLB provisioning —
51// well beyond the routine speed-doctrine ceiling. Gate it behind AWS_TEST_SLOW.
52//
53// It deploys, in two phases (refs read committed stack state, not the in-flight
54// plan): (1) a public network + a `compute: "auto"` cluster (the provider owns
55// the cluster/node IAM roles); (2) the same infra + the `Deployment` fixture,
56// which binds DynamoDB `PutItem`. The bound policy lands on the generated
57// pod-identity role and the table name is injected into the pod; the pod
58// resolves credentials via the EKS Pod Identity container-credentials chain.
59// The test curls the LoadBalancer `/put` route, then asserts the item was
60// written by reading it back out-of-band through the DynamoDB API — proving
61// the binding, pod identity, image pipeline, and server-side-apply path in one
62// shot.
63//
64// NOTE: this path has not been run green live in this factory wave (cluster
65// create alone exceeds the agent budget). It is gated + skip-clean; run on an
66// account with Auto Mode Pod Identity by setting AWS_TEST_SLOW=1.
67
68// Cluster declared at a TOP-LEVEL logical id so the fixture can resolve it
69// with `Cluster.ref("EksHostCluster")` (no namespace nesting). `compute:
70// "auto"` exercises the AutoCluster fold-in: the provider creates and owns the
71// cluster + node IAM roles.
72const infra = Effect.gen(function* () {
73 const network = yield* Network("EksHostNetwork", {
74 cidrBlock: "10.84.0.0/16",
75 availabilityZones: 2,
76 });
77
78 const cluster = yield* Cluster("EksHostCluster", {
79 compute: "auto",
80 resourcesVpcConfig: {
81 subnetIds: network.publicSubnetIds,
82 endpointPublicAccess: true,
83 endpointPrivateAccess: true,
84 },
85 });
86
87 return { cluster, network };
88});
89
90const sharedStack = Core.scratchStack(testOptions, "EksServerHost");
91
92describe.skipIf(!process.env.AWS_TEST_SLOW)(
93 "Kubernetes Deployment E2E",
94 {
95 tags: [
96 "provider:aws",
97 "provider:aws:dynamodb",
98 "provider:aws:ec2",
99 "provider:aws:eks",
100 "provider:kubernetes",
101 "provider:kubernetes:deployment",
102 "provider:kubernetes:helmchart",
103 "live",
104 ],
105 },
106 () => {
107 let baseUrl: string;
108 let helmRelease: Kubernetes.HelmChart["Attributes"];
109 let helmCluster: Cluster["Attributes"];
110
111 beforeAll(
112 Effect.gen(function* () {
113 yield* sharedStack.destroy();
114 // Phase 1: cluster + network only.
115 yield* sharedStack.deploy(infra);
116 // Phase 2: same infra + the Deployment fixture (refs the cluster) +
117 // a HelmChart rendering the local fixture chart onto the cluster.
118 const { host, cluster, release } = yield* sharedStack.deploy(
119 Effect.gen(function* () {
120 const { cluster } = yield* infra;
121 const release = yield* Kubernetes.HelmChart("E2EHelmChart", {
122 cluster,
123 chart: `${import.meta.dirname}/fixtures/chart`,
124 values: {
125 message: "helm-e2e",
126 secondConfigMap: { enabled: true },
127 },
128 });
129 const host = yield* EksHostApi;
130 return { host, cluster, release };
131 }),
132 );
133 helmRelease = release;
134 helmCluster = cluster;
135 // `url` is a full URL (`http://<nlb-hostname>:<port>` — the NLB
136 // listener is the Service port, not 80).
137 expect(host.url).toBeTruthy();
138 baseUrl = host.url!.replace(/\/+$/, "");
139
140 // NLB DNS + pod readiness ramp — retry /health.
141 yield* HttpClient.get(`${baseUrl}/health`).pipe(
142 Effect.flatMap((res) =>
143 res.status === 200
144 ? Effect.succeed(res)
145 : Effect.fail(new Error(`/health ${res.status}`)),
146 ),
147 Effect.tapError((e) => Effect.logWarning(String(e))),
148 Effect.retry({ schedule: Schedule.spaced("10 seconds"), times: 60 }),
149 );
150 }),
151 // Cluster create (~18 min) + image build/push + Auto Mode node launch +
152 // NLB provisioning/DNS + URL readiness poll (~5–10 min) routinely total
153 // 35+ min end-to-end.
154 { timeout: 2_700_000 },
155 );
156
157 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
158 timeout: 600_000,
159 });
160
161 test.provider(
162 "bound DynamoDB PutItem writes an item from inside the pod",
163 () =>
164 Effect.gen(function* () {
165 // Deterministic id: the table is created fresh by this suite's deploy
166 // (beforeAll starts with a destroy), so no stale item can pre-exist,
167 // and a stable id keeps re-runs convergent instead of accreting items.
168 const itemId = "eks-deployment-put-item";
169 const res = yield* HttpClient.get(`${baseUrl}/put?id=${itemId}`).pipe(
170 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 12 }),
171 );
172 expect(res.status).toBe(200);
173 const body = (yield* res.json) as { written: string; table: string };
174 expect(body.written).toBe(itemId);
175
176 // Prove the binding actually reached DynamoDB: read the item back
177 // out-of-band via the control-plane API.
178 const got = yield* dynamodb
179 .getItem({ TableName: body.table, Key: { pk: { S: itemId } } })
180 .pipe(
181 Effect.retry({
182 schedule: Schedule.spaced("2 seconds"),
183 times: 10,
184 }),
185 );
186 expect(got.Item?.pk?.S).toBe(itemId);
187 }),
188 { timeout: 180_000 },
189 );
190
191 test.provider(
192 "HelmChart renders and applies its objects onto the cluster",
193 () =>
194 Effect.gen(function* () {
195 // The chart rendered both ConfigMaps (the conditional one was
196 // toggled on via values) into the default namespace.
197 expect(helmRelease.objects).toHaveLength(2);
198 expect(helmRelease.namespace).toBe("default");
199 // The generic attributes persist the cluster connection for
200 // adapter-based delete.
201 expect(helmRelease.connection.auth.kind).toBe("aws-eks");
202
203 // Read the primary ConfigMap back out-of-band through the
204 // Kubernetes API and prove the values reached the cluster.
205 const configRef = helmRelease.objects.find((object) =>
206 object.name.endsWith("-config"),
207 )!;
208 expect(configRef).toBeDefined();
209 const transport = yield* makeEksTransport({
210 clusterName: helmCluster.clusterName,
211 endpoint: helmCluster.endpoint!,
212 certificateAuthorityData: helmCluster.certificateAuthorityData!,
213 });
214 const applied = (yield* readObject({
215 transport,
216 object: configRef,
217 })) as { data?: Record<string, string> } | undefined;
218 expect(applied?.data?.message).toBe("helm-e2e");
219 expect(applied?.data?.release).toBe(helmRelease.releaseName);
220 }),
221 { timeout: 120_000 },
222 );
223 },
224);