| 1 | import { imageSourceKind, validateImageSource } from "@/AWS/ECR/ImageSource.ts"; |
| 2 | import { |
| 3 | buildFinalDockerfile, |
| 4 | containerEnvPreamble, |
| 5 | validateContainerImageProps, |
| 6 | } from "@/Cloudflare/Containers/ContainerBundle.ts"; |
| 7 | import * as Dockerfile from "@/Docker/Dockerfile.ts"; |
| 8 | import * as Output from "@/Output.ts"; |
| 9 | import { describe, expect, it, test } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Result from "effect/Result"; |
| 12 | |
| 13 | describe( |
| 14 | "Dockerfile.inline", |
| 15 | { tags: ["unit", "provider:docker", "provider:docker:dockerfile", "local"] }, |
| 16 | () => { |
| 17 | test("no interpolations produce plain string content", () => { |
| 18 | const df = Dockerfile.inline`FROM oven/bun:1 |
| 19 | RUN apt-get install -y ffmpeg`; |
| 20 | expect(Dockerfile.isInlineDockerfile(df)).toBe(true); |
| 21 | expect(typeof df.content).toBe("string"); |
| 22 | expect(df.content).toContain("FROM oven/bun:1"); |
| 23 | expect(df.content).toContain("RUN apt-get install -y ffmpeg"); |
| 24 | }); |
| 25 | |
| 26 | test("content is deterministic across constructions", () => { |
| 27 | const a = Dockerfile.inline`FROM oven/bun:1`; |
| 28 | const b = Dockerfile.inline`FROM oven/bun:1`; |
| 29 | expect(a.content).toBe(b.content); |
| 30 | }); |
| 31 | |
| 32 | test("interpolations produce Output content (dependency edge)", () => { |
| 33 | const df = Dockerfile.inline`FROM ${Output.literal("oven/bun:1")} |
| 34 | RUN echo hi`; |
| 35 | expect(Dockerfile.isInlineDockerfile(df)).toBe(true); |
| 36 | expect(Output.isOutput(df.content)).toBe(true); |
| 37 | }); |
| 38 | |
| 39 | test("isInlineDockerfile rejects path strings", () => { |
| 40 | expect(Dockerfile.isInlineDockerfile("./Dockerfile")).toBe(false); |
| 41 | expect(Dockerfile.isInlineDockerfile(undefined)).toBe(false); |
| 42 | expect(Dockerfile.isInlineDockerfile(null)).toBe(false); |
| 43 | }); |
| 44 | }, |
| 45 | ); |
| 46 | |
| 47 | describe( |
| 48 | "AWS ImageSource composition", |
| 49 | { |
| 50 | tags: [ |
| 51 | "unit", |
| 52 | "provider:aws", |
| 53 | "provider:aws:ecr", |
| 54 | "provider:docker", |
| 55 | "provider:docker:dockerfile", |
| 56 | "local", |
| 57 | ], |
| 58 | }, |
| 59 | () => { |
| 60 | test("main wins as the source kind; other fields describe its environment", () => { |
| 61 | expect(imageSourceKind({ main: "./index.ts" })).toBe("main"); |
| 62 | expect(imageSourceKind({ main: "./index.ts", image: "oven/bun:1" })).toBe( |
| 63 | "main", |
| 64 | ); |
| 65 | expect(imageSourceKind({ image: "busybox:stable" })).toBe("image"); |
| 66 | expect(imageSourceKind({ context: "./app" })).toBe("context"); |
| 67 | expect( |
| 68 | imageSourceKind({ dockerfile: Dockerfile.inline`FROM alpine` }), |
| 69 | ).toBe("context"); |
| 70 | expect(imageSourceKind({})).toBeUndefined(); |
| 71 | }); |
| 72 | |
| 73 | it.effect("validateImageSource dies on exclusivity violations", () => |
| 74 | Effect.gen(function* () { |
| 75 | const dies = (source: Parameters<typeof validateImageSource>[1]) => |
| 76 | Effect.gen(function* () { |
| 77 | const result = yield* Effect.result( |
| 78 | validateImageSource("T", source).pipe( |
| 79 | Effect.catchDefect((defect) => Effect.fail(defect as Error)), |
| 80 | ), |
| 81 | ); |
| 82 | return Result.isFailure(result); |
| 83 | }); |
| 84 | |
| 85 | expect( |
| 86 | yield* dies({ image: "busybox", dockerfile: "./Dockerfile" }), |
| 87 | ).toBe(true); |
| 88 | expect(yield* dies({ image: "busybox", context: "./app" })).toBe(true); |
| 89 | expect( |
| 90 | yield* dies({ |
| 91 | dockerfile: Dockerfile.inline`FROM alpine`, |
| 92 | context: "./app", |
| 93 | }), |
| 94 | ).toBe(true); |
| 95 | // Valid combinations pass. |
| 96 | expect(yield* dies({ main: "./i.ts", image: "oven/bun:1" })).toBe( |
| 97 | false, |
| 98 | ); |
| 99 | expect( |
| 100 | yield* dies({ |
| 101 | main: "./i.ts", |
| 102 | dockerfile: Dockerfile.inline`FROM x`, |
| 103 | }), |
| 104 | ).toBe(false); |
| 105 | expect( |
| 106 | yield* dies({ context: "./app", dockerfile: "./app/Dockerfile" }), |
| 107 | ).toBe(false); |
| 108 | }), |
| 109 | ); |
| 110 | }, |
| 111 | ); |
| 112 | |
| 113 | /** |
| 114 | * Run an Effect that may die and report whether it did — validation |
| 115 | * helpers surface invalid props as defects (`Effect.die`), never typed |
| 116 | * errors or raw throws. |
| 117 | */ |
| 118 | const dies = <A>(effect: Effect.Effect<A>) => |
| 119 | Effect.gen(function* () { |
| 120 | const result = yield* Effect.result( |
| 121 | effect.pipe(Effect.catchDefect((defect) => Effect.fail(defect as Error))), |
| 122 | ); |
| 123 | return Result.isFailure(result); |
| 124 | }); |
| 125 | |
| 126 | describe( |
| 127 | "Cloudflare container environment composition", |
| 128 | { |
| 129 | tags: [ |
| 130 | "unit", |
| 131 | "provider:cloudflare", |
| 132 | "provider:cloudflare:container", |
| 133 | "provider:docker", |
| 134 | "provider:docker:dockerfile", |
| 135 | "local", |
| 136 | ], |
| 137 | }, |
| 138 | () => { |
| 139 | it.effect("containerEnvPreamble: image ref becomes FROM line", () => |
| 140 | Effect.gen(function* () { |
| 141 | expect(yield* containerEnvPreamble({ image: "oven/bun:1" })).toBe( |
| 142 | "FROM oven/bun:1", |
| 143 | ); |
| 144 | expect(yield* containerEnvPreamble({})).toBeUndefined(); |
| 145 | }), |
| 146 | ); |
| 147 | |
| 148 | it.effect("containerEnvPreamble: inline content used verbatim", () => |
| 149 | Effect.gen(function* () { |
| 150 | const preamble = yield* containerEnvPreamble({ |
| 151 | dockerfile: Dockerfile.inline`FROM oven/bun:1 |
| 152 | RUN apt-get install -y ffmpeg`, |
| 153 | }); |
| 154 | expect(preamble).toContain("RUN apt-get install -y ffmpeg"); |
| 155 | }), |
| 156 | ); |
| 157 | |
| 158 | it.effect( |
| 159 | "containerEnvPreamble: dies on Dockerfile content passed as image", |
| 160 | () => |
| 161 | Effect.gen(function* () { |
| 162 | expect( |
| 163 | yield* dies( |
| 164 | containerEnvPreamble({ image: "FROM oven/bun:1\nRUN echo hi" }), |
| 165 | ), |
| 166 | ).toBe(true); |
| 167 | }), |
| 168 | ); |
| 169 | |
| 170 | test("buildFinalDockerfile layers the bundle on top of the preamble", () => { |
| 171 | const dockerfile = buildFinalDockerfile( |
| 172 | "FROM oven/bun:1\nRUN apt-get install -y ffmpeg", |
| 173 | "bun", |
| 174 | ); |
| 175 | const lines = dockerfile.split("\n"); |
| 176 | expect(lines[0]).toBe("FROM oven/bun:1"); |
| 177 | expect(lines[1]).toBe("RUN apt-get install -y ffmpeg"); |
| 178 | expect(dockerfile).toContain("COPY index.mjs /app/index.mjs"); |
| 179 | expect(dockerfile).toContain('ENTRYPOINT ["bun", "/app/index.mjs"]'); |
| 180 | }); |
| 181 | |
| 182 | test("buildFinalDockerfile falls back to the runtime default base", () => { |
| 183 | expect(buildFinalDockerfile(undefined, "bun").split("\n")[0]).toBe( |
| 184 | "FROM oven/bun:1", |
| 185 | ); |
| 186 | expect(buildFinalDockerfile(undefined, "node").split("\n")[0]).toBe( |
| 187 | "FROM node:22-slim", |
| 188 | ); |
| 189 | }); |
| 190 | |
| 191 | it.effect("validateContainerImageProps enforces exclusivity", () => |
| 192 | Effect.gen(function* () { |
| 193 | // main + image and main + inline dockerfile are the two environments. |
| 194 | expect( |
| 195 | yield* dies( |
| 196 | validateContainerImageProps({ |
| 197 | main: "./i.ts", |
| 198 | image: "oven/bun:1", |
| 199 | }), |
| 200 | ), |
| 201 | ).toBe(false); |
| 202 | expect( |
| 203 | yield* dies( |
| 204 | validateContainerImageProps({ |
| 205 | main: "./i.ts", |
| 206 | dockerfile: Dockerfile.inline`FROM x`, |
| 207 | }), |
| 208 | ), |
| 209 | ).toBe(false); |
| 210 | expect( |
| 211 | yield* dies( |
| 212 | validateContainerImageProps({ |
| 213 | main: "./i.ts", |
| 214 | image: "oven/bun:1", |
| 215 | dockerfile: Dockerfile.inline`FROM x`, |
| 216 | }), |
| 217 | ), |
| 218 | ).toBe(true); |
| 219 | // A path dockerfile cannot be an environment on Cloudflare. |
| 220 | expect( |
| 221 | yield* dies( |
| 222 | validateContainerImageProps({ |
| 223 | main: "./i.ts", |
| 224 | dockerfile: "./Dockerfile", |
| 225 | }), |
| 226 | ), |
| 227 | ).toBe(true); |
| 228 | expect( |
| 229 | yield* dies( |
| 230 | validateContainerImageProps({ main: "./i.ts", context: "./app" }), |
| 231 | ), |
| 232 | ).toBe(true); |
| 233 | // Without main: image is exclusive with dockerfile/context. |
| 234 | expect( |
| 235 | yield* dies( |
| 236 | validateContainerImageProps({ |
| 237 | image: "busybox", |
| 238 | dockerfile: "./f", |
| 239 | }), |
| 240 | ), |
| 241 | ).toBe(true); |
| 242 | expect( |
| 243 | yield* dies( |
| 244 | validateContainerImageProps({ image: "busybox", context: "./app" }), |
| 245 | ), |
| 246 | ).toBe(true); |
| 247 | // Inline content has no build context. |
| 248 | expect( |
| 249 | yield* dies( |
| 250 | validateContainerImageProps({ |
| 251 | dockerfile: Dockerfile.inline`FROM x`, |
| 252 | context: "./app", |
| 253 | }), |
| 254 | ), |
| 255 | ).toBe(true); |
| 256 | // The plain external build stays valid. |
| 257 | expect( |
| 258 | yield* dies( |
| 259 | validateContainerImageProps({ |
| 260 | context: "./app", |
| 261 | dockerfile: "./app/Dockerfile", |
| 262 | }), |
| 263 | ), |
| 264 | ).toBe(false); |
| 265 | }), |
| 266 | ); |
| 267 | }, |
| 268 | ); |