| 1 | import * as ECRPublic from "@/AWS/ECRPublic"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import crypto from "node:crypto"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Redacted from "effect/Redacted"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | // A well-formed-but-nonexistent layer digest — batchCheckLayerAvailability |
| 15 | // reports it as a per-layer failure, proving the repository-scoped grant. |
| 16 | const NONEXISTENT_DIGEST = `sha256:${"0".repeat(64)}`; |
| 17 | |
| 18 | const sha256Hex = (bytes: Uint8Array) => |
| 19 | Effect.sync(() => crypto.createHash("sha256").update(bytes).digest("hex")); |
| 20 | |
| 21 | export class EcrPublicTestFunction extends Lambda.Function<Lambda.Function>()( |
| 22 | "EcrPublicTestFunction", |
| 23 | ) {} |
| 24 | |
| 25 | export default EcrPublicTestFunction.make( |
| 26 | { |
| 27 | main, |
| 28 | functionUrl: true, |
| 29 | // The push-flow route performs several sequential ECR Public calls; the |
| 30 | // AWS default 3s Lambda timeout is too tight. |
| 31 | timeout: Duration.seconds(60), |
| 32 | }, |
| 33 | Effect.gen(function* () { |
| 34 | const repository = yield* ECRPublic.PublicRepository("BindingsRepo", { |
| 35 | catalogData: { |
| 36 | description: "alchemy ECRPublic bindings fixture", |
| 37 | architectures: ["x86-64"], |
| 38 | operatingSystems: ["Linux"], |
| 39 | }, |
| 40 | }); |
| 41 | |
| 42 | // --- repository-scoped bindings --- |
| 43 | const describeImages = yield* ECRPublic.DescribeImages(repository); |
| 44 | const describeImageTags = yield* ECRPublic.DescribeImageTags(repository); |
| 45 | const getRepositoryCatalogData = |
| 46 | yield* ECRPublic.GetRepositoryCatalogData(repository); |
| 47 | const batchCheckLayerAvailability = |
| 48 | yield* ECRPublic.BatchCheckLayerAvailability(repository); |
| 49 | const batchDeleteImage = yield* ECRPublic.BatchDeleteImage(repository); |
| 50 | const initiateLayerUpload = |
| 51 | yield* ECRPublic.InitiateLayerUpload(repository); |
| 52 | const uploadLayerPart = yield* ECRPublic.UploadLayerPart(repository); |
| 53 | const completeLayerUpload = |
| 54 | yield* ECRPublic.CompleteLayerUpload(repository); |
| 55 | const putImage = yield* ECRPublic.PutImage(repository); |
| 56 | |
| 57 | // --- registry-level bindings --- |
| 58 | const getAuthorizationToken = yield* ECRPublic.GetAuthorizationToken(); |
| 59 | const describeRegistries = yield* ECRPublic.DescribeRegistries(); |
| 60 | const getRegistryCatalogData = yield* ECRPublic.GetRegistryCatalogData(); |
| 61 | |
| 62 | const bound = { |
| 63 | describeImages, |
| 64 | describeImageTags, |
| 65 | getRepositoryCatalogData, |
| 66 | batchCheckLayerAvailability, |
| 67 | batchDeleteImage, |
| 68 | initiateLayerUpload, |
| 69 | uploadLayerPart, |
| 70 | completeLayerUpload, |
| 71 | putImage, |
| 72 | getAuthorizationToken, |
| 73 | describeRegistries, |
| 74 | getRegistryCatalogData, |
| 75 | }; |
| 76 | |
| 77 | /** |
| 78 | * Upload one blob as a layer: initiate → upload the single (last) part → |
| 79 | * complete with the blob's sha256. A re-run finds the layer already |
| 80 | * sealed — `LayerAlreadyExistsException` converges to the same digest. |
| 81 | */ |
| 82 | const uploadBlob = Effect.fn(function* (blob: Uint8Array) { |
| 83 | const digest = `sha256:${yield* sha256Hex(blob)}`; |
| 84 | const { uploadId } = yield* initiateLayerUpload(); |
| 85 | yield* uploadLayerPart({ |
| 86 | uploadId: uploadId!, |
| 87 | partFirstByte: 0, |
| 88 | partLastByte: blob.byteLength - 1, |
| 89 | layerPartBlob: blob, |
| 90 | }); |
| 91 | yield* completeLayerUpload({ |
| 92 | uploadId: uploadId!, |
| 93 | layerDigests: [digest], |
| 94 | }).pipe( |
| 95 | Effect.catchTag("LayerAlreadyExistsException", () => Effect.void), |
| 96 | ); |
| 97 | return digest; |
| 98 | }); |
| 99 | |
| 100 | return { |
| 101 | fetch: Effect.gen(function* () { |
| 102 | const request = yield* HttpServerRequest; |
| 103 | const url = new URL(request.originalUrl); |
| 104 | const pathname = url.pathname; |
| 105 | |
| 106 | if (request.method === "GET" && pathname === "/bindings") { |
| 107 | return yield* HttpServerResponse.json({ |
| 108 | bound: Object.keys(bound), |
| 109 | }); |
| 110 | } |
| 111 | |
| 112 | if (request.method === "GET" && pathname === "/registries") { |
| 113 | const result = yield* describeRegistries(); |
| 114 | const registry = result.registries?.[0]; |
| 115 | return yield* HttpServerResponse.json({ |
| 116 | count: (result.registries ?? []).length, |
| 117 | alias: registry?.aliases?.[0]?.name ?? null, |
| 118 | }); |
| 119 | } |
| 120 | |
| 121 | if (request.method === "GET" && pathname === "/registry-catalog") { |
| 122 | const result = yield* getRegistryCatalogData(); |
| 123 | return yield* HttpServerResponse.json({ |
| 124 | tag: "Ok", |
| 125 | displayName: result.registryCatalogData.displayName ?? null, |
| 126 | }); |
| 127 | } |
| 128 | |
| 129 | if (request.method === "GET" && pathname === "/auth-token") { |
| 130 | const result = yield* getAuthorizationToken(); |
| 131 | const token = result.authorizationData?.authorizationToken; |
| 132 | // The token is Redacted in the distilled response — never echo it. |
| 133 | const hasToken = |
| 134 | token !== undefined && |
| 135 | (typeof token === "string" |
| 136 | ? token.length > 0 |
| 137 | : Redacted.value(token).length > 0); |
| 138 | return yield* HttpServerResponse.json({ hasToken }); |
| 139 | } |
| 140 | |
| 141 | if (request.method === "GET" && pathname === "/repo-catalog") { |
| 142 | const result = yield* getRepositoryCatalogData(); |
| 143 | return yield* HttpServerResponse.json({ |
| 144 | description: result.catalogData?.description ?? null, |
| 145 | }); |
| 146 | } |
| 147 | |
| 148 | if (request.method === "GET" && pathname === "/images") { |
| 149 | const result = yield* describeImages(); |
| 150 | return yield* HttpServerResponse.json({ |
| 151 | count: (result.imageDetails ?? []).length, |
| 152 | }); |
| 153 | } |
| 154 | |
| 155 | if (request.method === "GET" && pathname === "/image-tags") { |
| 156 | const result = yield* describeImageTags(); |
| 157 | return yield* HttpServerResponse.json({ |
| 158 | count: (result.imageTagDetails ?? []).length, |
| 159 | }); |
| 160 | } |
| 161 | |
| 162 | if (request.method === "GET" && pathname === "/check-layers") { |
| 163 | const result = yield* batchCheckLayerAvailability({ |
| 164 | layerDigests: [NONEXISTENT_DIGEST], |
| 165 | }); |
| 166 | // ECR Public reports a nonexistent digest either as a per-layer |
| 167 | // failure or as a layer with `layerAvailability: "UNAVAILABLE"`. |
| 168 | return yield* HttpServerResponse.json({ |
| 169 | failures: (result.failures ?? []).length, |
| 170 | unavailable: (result.layers ?? []).filter( |
| 171 | (l) => l.layerAvailability !== "AVAILABLE", |
| 172 | ).length, |
| 173 | }); |
| 174 | } |
| 175 | |
| 176 | if (request.method === "GET" && pathname === "/delete-missing") { |
| 177 | const result = yield* batchDeleteImage({ |
| 178 | imageIds: [{ imageTag: "does-not-exist" }], |
| 179 | }); |
| 180 | return yield* HttpServerResponse.json({ |
| 181 | failureCode: result.failures?.[0]?.failureCode ?? null, |
| 182 | }); |
| 183 | } |
| 184 | |
| 185 | if (request.method === "GET" && pathname === "/push-flow") { |
| 186 | // A genuine (minimal) image push: upload a config blob and a layer |
| 187 | // blob, then put a docker v2 manifest referencing both. Re-runs |
| 188 | // converge — existing layers and an identical manifest are fine. |
| 189 | const layerBlob = new TextEncoder().encode( |
| 190 | "alchemy-ecr-public-bindings-fixture-layer", |
| 191 | ); |
| 192 | const configBlob = new TextEncoder().encode( |
| 193 | JSON.stringify({ |
| 194 | architecture: "amd64", |
| 195 | os: "linux", |
| 196 | config: {}, |
| 197 | rootfs: { type: "layers", diff_ids: [NONEXISTENT_DIGEST] }, |
| 198 | }), |
| 199 | ); |
| 200 | const layerDigest = yield* uploadBlob(layerBlob); |
| 201 | const configDigest = yield* uploadBlob(configBlob); |
| 202 | const imageManifest = JSON.stringify({ |
| 203 | schemaVersion: 2, |
| 204 | mediaType: "application/vnd.docker.distribution.manifest.v2+json", |
| 205 | config: { |
| 206 | mediaType: "application/vnd.docker.container.image.v1+json", |
| 207 | size: configBlob.byteLength, |
| 208 | digest: configDigest, |
| 209 | }, |
| 210 | layers: [ |
| 211 | { |
| 212 | mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", |
| 213 | size: layerBlob.byteLength, |
| 214 | digest: layerDigest, |
| 215 | }, |
| 216 | ], |
| 217 | }); |
| 218 | const result = yield* putImage({ |
| 219 | imageManifest, |
| 220 | imageManifestMediaType: |
| 221 | "application/vnd.docker.distribution.manifest.v2+json", |
| 222 | imageTag: "bindings-test", |
| 223 | }).pipe( |
| 224 | Effect.map((r) => ({ |
| 225 | tag: "Ok", |
| 226 | imageDigest: r.image?.imageId?.imageDigest ?? null, |
| 227 | })), |
| 228 | // Re-running the route pushes the identical manifest+tag again. |
| 229 | Effect.catchTag( |
| 230 | ["ImageAlreadyExistsException", "ImageTagAlreadyExistsException"], |
| 231 | (e) => Effect.succeed({ tag: e._tag, imageDigest: null }), |
| 232 | ), |
| 233 | ); |
| 234 | return yield* HttpServerResponse.json(result); |
| 235 | } |
| 236 | |
| 237 | return yield* HttpServerResponse.json( |
| 238 | { error: "Not found", method: request.method, pathname }, |
| 239 | { status: 404 }, |
| 240 | ); |
| 241 | }).pipe(Effect.orDie), |
| 242 | }; |
| 243 | }).pipe( |
| 244 | Effect.provide( |
| 245 | Layer.mergeAll( |
| 246 | ECRPublic.DescribeImagesHttp, |
| 247 | ECRPublic.DescribeImageTagsHttp, |
| 248 | ECRPublic.GetRepositoryCatalogDataHttp, |
| 249 | ECRPublic.BatchCheckLayerAvailabilityHttp, |
| 250 | ECRPublic.BatchDeleteImageHttp, |
| 251 | ECRPublic.InitiateLayerUploadHttp, |
| 252 | ECRPublic.UploadLayerPartHttp, |
| 253 | ECRPublic.CompleteLayerUploadHttp, |
| 254 | ECRPublic.PutImageHttp, |
| 255 | ECRPublic.GetAuthorizationTokenHttp, |
| 256 | ECRPublic.DescribeRegistriesHttp, |
| 257 | ECRPublic.GetRegistryCatalogDataHttp, |
| 258 | ), |
| 259 | ), |
| 260 | ), |
| 261 | ); |