| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Network } from "@/AWS/EC2/Network.ts"; |
| 3 | import { Cluster } from "@/AWS/EKS/Cluster.ts"; |
| 4 | import { makeEksTransport } from "@/AWS/EKS/KubernetesAdapter.ts"; |
| 5 | import * as Kubernetes from "@/Kubernetes"; |
| 6 | import { readObject } from "@/Kubernetes/internal/client.ts"; |
| 7 | import * as Core from "@/Test/Core"; |
| 8 | import * as Provider from "@/Provider"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import * as dynamodb from "@distilled.cloud/aws/dynamodb"; |
| 11 | import { describe, expect } from "alchemy-test"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Schedule from "effect/Schedule"; |
| 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 16 | import EksHostApi from "./fixtures/deployment.ts"; |
| 17 | |
| 18 | const testOptions = { |
| 19 | providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), |
| 20 | }; |
| 21 | const { 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. |
| 30 | test.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. |
| 72 | const 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 | |
| 90 | const sharedStack = Core.scratchStack(testOptions, "EksServerHost"); |
| 91 | |
| 92 | describe.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 | ); |