1import * as ECR from "@/AWS/ECR";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import crypto from "node:crypto";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Redacted from "effect/Redacted";
9import * as Schedule from "effect/Schedule";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12import * as Stream from "effect/Stream";
13import path from "pathe";
14
15const main = path.resolve(import.meta.dirname, "handler.ts");
16
17// Deterministic, lowercase repository name scoped to this suite (distinct
18// from Repository.test.ts / Image.test.ts repositories).
19export const REPO = "alchemy-test-ecr-bindings";
20
21/** Deterministic layer/config bytes per tag so retried pushes converge. */
22const layerBytesFor = (tag: string) =>
23 Buffer.from(`alchemy ecr layer blob for ${tag}\n`.repeat(8), "utf8");
24const configBytesFor = (tag: string) =>
25 Buffer.from(
26 JSON.stringify({
27 architecture: "amd64",
28 os: "linux",
29 config: {},
30 rootfs: { type: "layers", diff_ids: [`sha256:${"0".repeat(64)}`] },
31 // Distinguish configs per tag so every tag gets its own digest.
32 "alchemy.tag": tag,
33 }),
34 "utf8",
35 );
36
37export class EcrTestFunction extends Lambda.Function<Lambda.Function>()(
38 "EcrTestFunction",
39) {}
40
41export default EcrTestFunction.make(
42 {
43 main,
44 functionUrl: true,
45 // A push fans out several registry calls; AWS's 3s default would
46 // intermittently time out.
47 timeout: Duration.seconds(60),
48 },
49 Effect.gen(function* () {
50 const repository = yield* ECR.Repository("Repo", {
51 repositoryName: REPO,
52 });
53
54 // Marker store for the event source (events may arrive on another
55 // Lambda instance, so they must be observable out-of-band).
56 const bucket = yield* S3.Bucket("EventBucket", { forceDestroy: true });
57 const putObject = yield* S3.PutObject(bucket);
58 const getObject = yield* S3.GetObject(bucket);
59
60 // --- registry-scoped binding ---
61 const getAuthorizationToken = yield* ECR.GetAuthorizationToken();
62
63 // --- repository-scoped bindings ---
64 const describeImages = yield* ECR.DescribeImages(repository);
65 const listImages = yield* ECR.ListImages(repository);
66 const batchGetImage = yield* ECR.BatchGetImage(repository);
67 const getDownloadUrl = yield* ECR.GetDownloadUrlForLayer(repository);
68 const checkLayers = yield* ECR.BatchCheckLayerAvailability(repository);
69 const initiateUpload = yield* ECR.InitiateLayerUpload(repository);
70 const uploadPart = yield* ECR.UploadLayerPart(repository);
71 const completeUpload = yield* ECR.CompleteLayerUpload(repository);
72 const putImage = yield* ECR.PutImage(repository);
73 const batchDeleteImage = yield* ECR.BatchDeleteImage(repository);
74 const startImageScan = yield* ECR.StartImageScan(repository);
75 const describeScanFindings =
76 yield* ECR.DescribeImageScanFindings(repository);
77
78 // --- event source ---
79 // ECR publishes every completed push to the default bus; write a marker
80 // object per event so /events/probe can observe the delivery
81 // out-of-band.
82 yield* ECR.consumeImageActions(
83 { repositories: [REPO], actionTypes: ["PUSH"], results: ["SUCCESS"] },
84 (events) =>
85 Stream.runForEach(events, (event) =>
86 putObject({
87 Key: `events/${event.detail["image-tag"] ?? event.detail["image-digest"]}`,
88 Body: JSON.stringify(event.detail),
89 ContentType: "application/json",
90 }).pipe(Effect.orDie, Effect.asVoid),
91 ),
92 );
93
94 const sha256 = (bytes: Buffer) =>
95 Effect.sync(
96 () =>
97 `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`,
98 );
99
100 // Upload one blob through the initiate → upload → complete flow.
101 // `LayerAlreadyExistsException` means a previous push (or a retried one)
102 // already sealed this digest — the blob is present, so continue.
103 const uploadBlob = Effect.fn(function* (bytes: Buffer) {
104 const digest = yield* sha256(bytes);
105 const { uploadId } = yield* initiateUpload();
106 yield* uploadPart({
107 uploadId: uploadId!,
108 partFirstByte: 0,
109 partLastByte: bytes.length - 1,
110 layerPartBlob: new Uint8Array(bytes),
111 });
112 yield* completeUpload({
113 uploadId: uploadId!,
114 layerDigests: [digest],
115 }).pipe(
116 Effect.catchTag("LayerAlreadyExistsException", () =>
117 Effect.succeed(undefined),
118 ),
119 );
120 return digest;
121 });
122
123 // Push a minimal-but-valid Docker v2 schema 2 image: a config blob and
124 // one layer blob, sealed by a manifest under `tag`.
125 const pushImage = Effect.fn(function* (tag: string) {
126 const configBytes = configBytesFor(tag);
127 const layerBytes = layerBytesFor(tag);
128 const configDigest = yield* uploadBlob(configBytes);
129 const layerDigest = yield* uploadBlob(layerBytes);
130 const manifest = JSON.stringify({
131 schemaVersion: 2,
132 mediaType: "application/vnd.docker.distribution.manifest.v2+json",
133 config: {
134 mediaType: "application/vnd.docker.container.image.v1+json",
135 size: configBytes.length,
136 digest: configDigest,
137 },
138 layers: [
139 {
140 mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
141 size: layerBytes.length,
142 digest: layerDigest,
143 },
144 ],
145 });
146 // A retried push of an identical tag+manifest surfaces the typed
147 // ImageAlreadyExistsException — the image is present, so converge.
148 const put = yield* putImage({
149 imageManifest: manifest,
150 imageManifestMediaType:
151 "application/vnd.docker.distribution.manifest.v2+json",
152 imageTag: tag,
153 }).pipe(
154 Effect.catchTag("ImageAlreadyExistsException", () =>
155 Effect.succeed(undefined),
156 ),
157 );
158 return {
159 digest: put?.image?.imageId?.imageDigest,
160 layerDigest,
161 configDigest,
162 };
163 });
164
165 return {
166 fetch: Effect.gen(function* () {
167 const request = yield* HttpServerRequest;
168 const url = new URL(request.originalUrl);
169 const pathname = url.pathname;
170 const param = (name: string) => url.searchParams.get(name) ?? "";
171
172 // Mint the registry credential; prove it comes back Redacted and
173 // decodes to a `AWS:<password>` Docker login pair.
174 if (request.method === "GET" && pathname === "/token") {
175 const res = yield* getAuthorizationToken();
176 const data = res.authorizationData?.[0];
177 const token = data?.authorizationToken;
178 const raw = Redacted.isRedacted(token)
179 ? Redacted.value(token)
180 : (token ?? "");
181 const decoded = Buffer.from(raw, "base64").toString("utf8");
182 return yield* HttpServerResponse.json({
183 redacted: Redacted.isRedacted(token),
184 length: raw.length,
185 decodesToAwsUser: decoded.startsWith("AWS:"),
186 proxyEndpoint: data?.proxyEndpoint,
187 });
188 }
189
190 if (request.method === "POST" && pathname === "/push") {
191 const res = yield* pushImage(param("tag"));
192 return yield* HttpServerResponse.json(res);
193 }
194
195 if (request.method === "GET" && pathname === "/images") {
196 const res = yield* describeImages({
197 imageIds: [{ imageTag: param("tag") }],
198 });
199 const detail = res.imageDetails?.[0];
200 return yield* HttpServerResponse.json({
201 tags: detail?.imageTags,
202 digest: detail?.imageDigest,
203 sizeInBytes: detail?.imageSizeInBytes,
204 });
205 }
206
207 if (request.method === "GET" && pathname === "/image-ids") {
208 const res = yield* listImages({ filter: { tagStatus: "TAGGED" } });
209 return yield* HttpServerResponse.json({
210 tags: (res.imageIds ?? []).flatMap((id) =>
211 id.imageTag ? [id.imageTag] : [],
212 ),
213 });
214 }
215
216 if (request.method === "GET" && pathname === "/manifest") {
217 const res = yield* batchGetImage({
218 imageIds: [{ imageTag: param("tag") }],
219 });
220 const image = res.images?.[0];
221 return yield* HttpServerResponse.json({
222 mediaType: image?.imageManifestMediaType,
223 manifestLength: image?.imageManifest?.length ?? 0,
224 failures: res.failures?.map((f) => f.failureCode),
225 });
226 }
227
228 if (request.method === "GET" && pathname === "/layer") {
229 const res = yield* getDownloadUrl({ layerDigest: param("digest") });
230 return yield* HttpServerResponse.json({
231 hasUrl: (res.downloadUrl ?? "").startsWith("https://"),
232 layerDigest: res.layerDigest,
233 });
234 }
235
236 if (request.method === "GET" && pathname === "/availability") {
237 const res = yield* checkLayers({
238 layerDigests: [param("digest")],
239 });
240 return yield* HttpServerResponse.json({
241 availability: res.layers?.[0]?.layerAvailability,
242 failures: res.failures?.map((f) => f.failureCode),
243 });
244 }
245
246 // On-demand basic scan. The synthetic image is not a supported OS
247 // image, so ECR may answer with the typed
248 // UnsupportedImageTypeException (or throttle repeat scans with
249 // LimitExceededException) — surface the tag instead of dying.
250 if (request.method === "POST" && pathname === "/scan") {
251 const res = yield* startImageScan({
252 imageId: { imageTag: param("tag") },
253 }).pipe(
254 Effect.map((r) => ({
255 status: r.imageScanStatus?.status,
256 error: undefined,
257 })),
258 Effect.catchTag(
259 [
260 "UnsupportedImageTypeException",
261 "LimitExceededException",
262 "ValidationException",
263 ],
264 (e) => Effect.succeed({ status: undefined, error: e._tag }),
265 ),
266 );
267 return yield* HttpServerResponse.json(res);
268 }
269
270 // Scan findings for the synthetic image — either real findings or
271 // the typed ScanNotFoundException when no scan ever ran.
272 if (request.method === "GET" && pathname === "/scan-findings") {
273 const res = yield* describeScanFindings({
274 imageId: { imageTag: param("tag") },
275 }).pipe(
276 Effect.map((r) => ({
277 status: r.imageScanStatus?.status,
278 error: undefined,
279 })),
280 Effect.catchTag(
281 ["ScanNotFoundException", "ValidationException"],
282 (e) => Effect.succeed({ status: undefined, error: e._tag }),
283 ),
284 );
285 return yield* HttpServerResponse.json(res);
286 }
287
288 // batchDeleteImage reports a missing image via `failures` (not an
289 // error), so this is naturally idempotent.
290 if (request.method === "POST" && pathname === "/delete") {
291 const res = yield* batchDeleteImage({
292 imageIds: [{ imageTag: param("tag") }],
293 });
294 return yield* HttpServerResponse.json({
295 deleted: (res.imageIds ?? []).length,
296 failures: res.failures?.map((f) => f.failureCode),
297 });
298 }
299
300 // Push a fresh tag, then poll for the marker the image-action event
301 // handler wrote to S3 (bounded well under the 60s function timeout).
302 if (request.method === "GET" && pathname === "/events/probe") {
303 const tag = param("tag");
304 yield* pushImage(tag);
305 const seen = yield* getObject({ Key: `events/${tag}` }).pipe(
306 Effect.retry({
307 while: (e): boolean => e._tag === "NoSuchKey",
308 schedule: Schedule.spaced("2 seconds"),
309 times: 12,
310 }),
311 Effect.map(() => true),
312 Effect.catchTag("NoSuchKey", () => Effect.succeed(false)),
313 );
314 return yield* HttpServerResponse.json({ seen, tag });
315 }
316
317 return yield* HttpServerResponse.json(
318 { error: "Not found", method: request.method, pathname },
319 { status: 404 },
320 );
321 }).pipe(
322 // Surface typed errors in the 500 body so the test's transient-retry
323 // logs show the real failure instead of an opaque Internal Server
324 // Error.
325 Effect.catch((e) =>
326 HttpServerResponse.json(
327 {
328 error: (e as { _tag?: string })._tag ?? "UnknownError",
329 message: String(e),
330 },
331 { status: 500 },
332 ),
333 ),
334 Effect.orDie,
335 ),
336 };
337 }).pipe(
338 Effect.provide(
339 Layer.mergeAll(
340 Lambda.EventSource,
341 ECR.GetAuthorizationTokenHttp,
342 ECR.DescribeImagesHttp,
343 ECR.ListImagesHttp,
344 ECR.BatchGetImageHttp,
345 ECR.GetDownloadUrlForLayerHttp,
346 ECR.BatchCheckLayerAvailabilityHttp,
347 ECR.InitiateLayerUploadHttp,
348 ECR.UploadLayerPartHttp,
349 ECR.CompleteLayerUploadHttp,
350 ECR.PutImageHttp,
351 ECR.BatchDeleteImageHttp,
352 ECR.StartImageScanHttp,
353 ECR.DescribeImageScanFindingsHttp,
354 S3.PutObjectHttp,
355 S3.GetObjectHttp,
356 ),
357 ),
358 ),
359);