diff --git a/packages/alchemy/src/Cloudflare/Containers/ContainerBundle.ts b/packages/alchemy/src/Cloudflare/Containers/ContainerBundle.ts index 9da35e734..04d85079c 100644 --- a/packages/alchemy/src/Cloudflare/Containers/ContainerBundle.ts +++ b/packages/alchemy/src/Cloudflare/Containers/ContainerBundle.ts @@ -15,10 +15,7 @@ import { createPhysicalName } from "../../PhysicalName.ts"; import { Self } from "../../Self.ts"; import { Stack } from "../../Stack.ts"; import { sha256Object } from "../../Util/sha256.ts"; -import type { - ContainerApplication, - ContainerApplicationProps, -} from "./ContainerApplication.ts"; +import type { ContainerApplicationProps } from "./ContainerApplication.ts"; /** * Fold the runtime-context `env` map (populated by `Binding.Service`s and @@ -44,35 +41,24 @@ import type { * * Explicit `props.environmentVariables` win on a name collision. */ -export const foldEnvIntoEnvironmentVariables = ( +export const makeContainerEnv = ( props: ContainerApplicationProps, - accountId?: string, -): ContainerApplication.EnvironmentVariable[] | undefined => { - const explicitNames = new Set( - (props.environmentVariables ?? []).map((e) => e.name), - ); - const environmentVariables: ContainerApplication.EnvironmentVariable[] = [ - ...(props.environmentVariables ?? []), - ...(accountId !== undefined && - !explicitNames.has("ALCHEMY_CLOUDFLARE_ACCOUNT_ID") - ? [{ name: "ALCHEMY_CLOUDFLARE_ACCOUNT_ID", value: accountId }] - : []), - ...Object.entries(props.env ?? {}) - .filter( - ([name, value]) => - value !== undefined && - !Output.isOutput(value) && - !explicitNames.has(name) && - name !== "ALCHEMY_CLOUDFLARE_ACCOUNT_ID", - ) - .map(([name, value]) => ({ - name, - value: Redacted.isRedacted(value) - ? Redacted.value(value as Redacted.Redacted) - : (value as string), - })), - ]; - return environmentVariables.length > 0 ? environmentVariables : undefined; + accountId: string, +) => { + const env: Record> = {}; + for (const [name, value] of Object.entries(props.env ?? {})) { + if (Output.isOutput(value) || value === undefined) { + continue; + } + env[name] = value; + } + for (const value of props.environmentVariables ?? []) { + env[value.name] = value.value; + } + if (!env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID) { + env.ALCHEMY_CLOUDFLARE_ACCOUNT_ID = accountId; + } + return env; }; /** diff --git a/packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts b/packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts index e0ae08fb3..f836c9e0f 100644 --- a/packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Containers/ContainerProvider.ts @@ -2,6 +2,7 @@ import * as Containers from "@distilled.cloud/cloudflare/containers"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import { Unowned } from "../../AdoptPolicy.ts"; import { AlchemyContext } from "../../AlchemyContext.ts"; @@ -24,7 +25,7 @@ import { buildFinalDockerfile, bundleContainerProgram, createContainerApplicationName, - foldEnvIntoEnvironmentVariables, + makeContainerEnv, } from "./ContainerBundle.ts"; import { ContainerPlatform } from "./ContainerPlatform.ts"; @@ -105,8 +106,8 @@ export const LiveContainerProvider = () => const desiredConfiguration = ( props: ContainerApplicationProps, + env: Record>, imageRef: string, - accountId: string, ) => normalizeNulls({ image: imageRef, @@ -127,10 +128,10 @@ export const LiveContainerProvider = () => vcpu: props.vcpu, memory: props.memory, disk: props.disk, - environmentVariables: foldEnvIntoEnvironmentVariables( - props, - accountId, - ), + environmentVariables: Object.entries(env).map(([name, value]) => ({ + name, + value: Redacted.isRedacted(value) ? Redacted.value(value) : value, + })), labels: props.labels, network: props.network, command: props.command, @@ -165,6 +166,7 @@ export const LiveContainerProvider = () => const computeImage = Effect.fn(function* ( id: string, props: ContainerApplicationProps, + env: Record>, ) { const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createApplicationName(id, props.name); @@ -211,9 +213,9 @@ export const LiveContainerProvider = () => imageRef: makeRef(imageHash), imageHash, dev: { - context: contextDir, - dockerfile: path.join(contextDir, "Dockerfile"), - env: props.env, + context: path.relative(process.cwd(), contextDir), + dockerfile: "Dockerfile", + env, }, }; } @@ -229,7 +231,7 @@ export const LiveContainerProvider = () => imageRef: makeRef(imageHash), imageHash, // The local runtime pulls this image directly (no build context). - dev: { imageUri: props.image, env: props.env }, + dev: { imageUri: props.image, env }, }; } @@ -250,7 +252,11 @@ export const LiveContainerProvider = () => imageHash, // The local runtime builds the user's Dockerfile against the same // (already real-path'd) context directory. - dev: { context, dockerfile, env: props.env }, + dev: { + context: path.relative(process.cwd(), context), + dockerfile: path.relative(context, dockerfile), + env, + }, }; }); @@ -552,11 +558,13 @@ export const LiveContainerProvider = () => yield* Effect.logInfo( `Cloudflare Container update: preparing ${existing.applicationName}`, ); + const env = makeContainerEnv(news, accountId); const { build, imageRef, imageHash, dev } = yield* computeImage( id, news, + env, ); - const configuration = desiredConfiguration(news, imageRef, accountId); + const configuration = desiredConfiguration(news, env, imageRef); if (imageHash !== existing.hash?.image) { yield* buildAndPushImage(id, news, build, imageRef, session); @@ -688,8 +696,12 @@ export const LiveContainerProvider = () => return { action: "update", stables: ["accountId"] } as const; } - const { imageHash } = yield* computeImage(id, news); - if (imageHash !== output.hash?.image) { + const { imageHash, dev } = yield* computeImage( + id, + news, + makeContainerEnv(news, accountId), + ); + if (imageHash !== output.hash?.image || !deepEqual(dev, output.dev)) { return { action: "update" } as const; } }), @@ -700,11 +712,13 @@ export const LiveContainerProvider = () => ); const { accountId } = yield* yield* CloudflareEnvironment; + const env = makeContainerEnv(news, accountId); const { build, imageRef, imageHash, dev } = yield* computeImage( id, news, + env, ); - const configuration = desiredConfiguration(news, imageRef, accountId); + const configuration = desiredConfiguration(news, env, imageRef); yield* buildAndPushImage(id, news, build, imageRef, session); // Precreate intentionally omits the Durable Object attachment so the @@ -742,11 +756,13 @@ export const LiveContainerProvider = () => ); const durableObjects = yield* getDurableObjects(bindings); const { accountId } = yield* yield* CloudflareEnvironment; + const env = makeContainerEnv(news, accountId); const { build, imageRef, imageHash, dev } = yield* computeImage( id, news, + env, ); - const configuration = desiredConfiguration(news, imageRef, accountId); + const configuration = desiredConfiguration(news, env, imageRef); // Observe — re-fetch the cached application to confirm it still // exists. Cloudflare reports a deleted container application as diff --git a/packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts b/packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts index d9ab49e06..d3ae6b418 100644 --- a/packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Containers/LocalContainerProvider.ts @@ -1,17 +1,23 @@ import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Artifacts from "../../Artifacts.ts"; +import { hashDirectory } from "../../Command/Memo.ts"; import { isResolved } from "../../Diff.ts"; import * as RpcProvider from "../../Local/RpcProvider.ts"; +import { sha256Object } from "../../Util/sha256.ts"; import { normalizeNulls } from "../../Util/stable.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import { generateLocalId, LOCAL_ENTRY_URL } from "../LocalRuntime.ts"; import type { ContainerApplication, ContainerApplicationProps, + DevContainerImage, } from "./ContainerApplication.ts"; import { createContainerApplicationName, - foldEnvIntoEnvironmentVariables, + makeContainerEnv, prepareContainerBuildContext, } from "./ContainerBundle.ts"; import { ContainerPlatform } from "./ContainerPlatform.ts"; @@ -20,9 +26,10 @@ import { ContainerPlatform } from "./ContainerPlatform.ts"; * Local (dev) provider for Cloudflare Container applications. * * The Docker build/run is owned by `@distilled.cloud/cloudflare-runtime`; this - * provider's only job is to bundle the container program once and materialize - * it into a stable Docker build context, then surface that context as a - * `dev: ContainerImage.Build` output so the runtime can `docker build` it. + * provider's only job is to resolve the `dev` image the runtime should use — + * a build context to `docker build` (Effect-native `main` or a user-supplied + * Dockerfile) or a remote image to `docker pull` — mirroring the three image + * variants of the live provider's `computeImage`. * * Everything else on the attributes is a placeholder: the real * `applicationId`/`configuration`/etc. only exist once the live provider @@ -35,21 +42,72 @@ export const LocalContainerProvider = () => ContainerPlatform, LOCAL_ENTRY_URL, Effect.gen(function* () { - // Bundle the container entrypoint and write it (plus the generated - // Dockerfile) into a stable build context directory. `Docker.build` in - // cloudflare-runtime reads `dockerfile` as a file path and uses - // `context` as the build context, so we point `dev` at both. The - // build-context materialization is shared with the live provider (see - // `prepareContainerBuildContext`); we only add run-scoped caching here so - // repeated reconciles in a single dev session don't re-bundle. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Resolve the `dev` image plus a content hash for change detection. + // Cached per run (`Artifacts.cached`, keyed by resource id) so repeated + // diffs/reconciles in a single dev session don't re-bundle or re-hash. + // + // IMPORTANT: the cached result must stay env-free. The cache is warmed + // by `precreate`, which runs against unresolved props — binding-derived + // env values (e.g. an ApiToken's value/accountId) are still unresolved + // `Output`s there and get skipped. Caching env here would freeze that + // incomplete env and start the container without its bindings; + // `makeAttributes` attaches the freshly-computed env instead. const prepareImage = (id: string, news: ContainerApplicationProps) => - prepareContainerBuildContext(id, news).pipe( - Artifacts.cached("container-image"), - ); + Effect.gen(function* () { + // Variant 1 — Effect-native program. Bundle `main` and write it + // (plus the generated Dockerfile) into a stable build context + // directory. `Docker.build` in cloudflare-runtime reads `dockerfile` + // as a file path and uses `context` as the build context, so we + // point `dev` at both. The build-context materialization is shared + // with the live provider (see `prepareContainerBuildContext`). + if (news.main) { + const { context, dockerfile, hash } = + yield* prepareContainerBuildContext(id, news); + return { + dev: { + context: path.relative(process.cwd(), context), + dockerfile: path.relative(context, dockerfile), + } as DevContainerImage, + hash, + }; + } + + // Variant 2 — pre-built remote image. The runtime pulls it + // directly; there is nothing to build. + if (news.image) { + return { + dev: { imageUri: news.image } as DevContainerImage, + hash: yield* sha256Object({ image: news.image }), + }; + } + + // Variant 3 — user-supplied Dockerfile + build context directory. + // The runtime builds the user's Dockerfile against the (real-path'd) + // context, exactly like the live provider's `external` variant. + const context = yield* fs.realPath(news.context ?? "."); + const dockerfile = news.dockerfile + ? yield* fs.realPath(news.dockerfile) + : path.join(context, "Dockerfile"); + const contextHash = yield* hashDirectory({ cwd: context }); + const dockerfileContent = yield* fs.readFileString(dockerfile); + return { + dev: { + context: path.relative(process.cwd(), context), + dockerfile: path.relative(context, dockerfile), + } as DevContainerImage, + hash: yield* sha256Object({ + contextHash, + dockerfile: dockerfileContent, + }), + }; + }).pipe(Artifacts.cached(`container-image:${id}`)); const placeholderConfiguration = ( props: ContainerApplicationProps, - accountId: string, + env: Record>, ) => normalizeNulls({ image: "local", @@ -60,10 +118,10 @@ export const LocalContainerProvider = () => vcpu: props.vcpu, memory: props.memory, disk: props.disk, - environmentVariables: foldEnvIntoEnvironmentVariables( - props, - accountId, - ), + environmentVariables: Object.entries(env).map(([name, value]) => ({ + name, + value: Redacted.isRedacted(value) ? Redacted.value(value) : value, + })), labels: props.labels, network: props.network, command: props.command, @@ -83,7 +141,8 @@ export const LocalContainerProvider = () => output: ContainerApplication["Attributes"] | undefined; }) { const { accountId } = yield* yield* CloudflareEnvironment; - const { context, hash, dockerfile } = yield* prepareImage(id, news); + const env = makeContainerEnv(news, accountId); + const { dev, hash } = yield* prepareImage(id, news); return { applicationId: output?.applicationId ?? generateLocalId(), applicationName: yield* createContainerApplicationName(id, news.name), @@ -93,11 +152,11 @@ export const LocalContainerProvider = () => maxInstances: news.maxInstances ?? 1, constraints: news.constraints, affinities: news.affinities, - configuration: placeholderConfiguration(news, accountId), + configuration: placeholderConfiguration(news, env), durableObjects: undefined, createdAt: new Date().toISOString(), version: 1, - dev: { context, dockerfile, env: news.env }, + dev: { ...dev, env }, hash: { image: hash }, } satisfies ContainerApplication["Attributes"]; }); diff --git a/packages/alchemy/test/Cloudflare/Container/Container.test.ts b/packages/alchemy/test/Cloudflare/Container/Container.test.ts index c938563f4..247c226db 100644 --- a/packages/alchemy/test/Cloudflare/Container/Container.test.ts +++ b/packages/alchemy/test/Cloudflare/Container/Container.test.ts @@ -10,20 +10,163 @@ import EffectfulStack from "./fixtures/effectful/stack.ts"; import ExternalStack from "./fixtures/external/stack.ts"; import RemoteStack from "./fixtures/remote/stack.ts"; -const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ - providers: Cloudflare.providers(), - state: Cloudflare.state(), -}); +describe.concurrent.each([ + { + dev: true, + stage: "test-local", + timeout: 30_000, + }, + { + dev: false, + stage: "test-live", + timeout: 300_000, + }, +])("Container (dev: $dev)", ({ dev, stage, timeout }) => { + // We need to create a new test context for each test case because the + // local runner stops running after the root scope is closed, which happens + // in an afterAll hook registered by `Test.make`. + const make = () => + Test.make({ + providers: Cloudflare.providers(), + state: Cloudflare.state(), + stage, + dev, + }); + + const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", + ); -const logLevel = Effect.provideService( - MinimumLogLevel, - process.env.DEBUG ? "Debug" : "Info", -); + // Container image build + push + worker/DO deploy comfortably exceeds the + // default 120s hook budget, so give every deploy/destroy plenty of room. + const HOOK_TIMEOUT = 600_000; + + /** + * Effect-native container (`main`): the entrypoint Effect is bundled into a + * generated image. The Durable Object proxies into the container two ways — + * RPC (`container.ping()` / `container.readObject()`) and HTTP over its + * port-3000 server (`getTcpPort(3000).fetch`). The bucket tests prove the + * full end-to-end R2 path: a value written through the DO's NATIVE binding is + * read back from inside the container over its scoped HTTP token, surfaced + * both via RPC and via fetch. + */ + describe("effectful container (main)", () => { + const { test, beforeAll, afterAll, deploy, destroy } = make(); + const stack = beforeAll(deploy(EffectfulStack), { + timeout: HOOK_TIMEOUT, + }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(EffectfulStack), { + timeout: HOOK_TIMEOUT, + }); + + test( + "RPC: ping round-trips into the container", + Effect.gen(function* () { + const { url } = yield* stack; + + const pong = yield* fetchReady(new URL("/ping", url), "pong"); + expect(pong).toContain("pong"); + }).pipe(logLevel), + { timeout }, + ); + + test( + "fetch: serves over its TCP port", + Effect.gen(function* () { + const { url } = yield* stack; + + const hello = yield* fetchReady( + new URL("/hello", url), + "effectful container", + ); + expect(hello).toContain("effectful container"); + }).pipe(logLevel), + { timeout }, + ); -// Container image build + push + worker/DO deploy comfortably exceeds the -// default 120s hook budget, so give every deploy/destroy plenty of room. -const HOOK_TIMEOUT = 600_000; -const TEST_TIMEOUT = 300_000; + test( + "RPC: reads an R2 object from inside the container", + Effect.gen(function* () { + const { url } = yield* stack; + + yield* seed(url, "rpc.txt", "hello-rpc"); + const body = yield* fetchReady( + new URL("/rpc?key=rpc.txt", url), + "hello-rpc", + ); + expect(body).toContain("hello-rpc"); + }).pipe(logLevel), + { timeout }, + ); + + test( + "fetch: reads an R2 object from inside the container", + Effect.gen(function* () { + const { url } = yield* stack; + + yield* seed(url, "fetch.txt", "hello-fetch"); + const body = yield* fetchReady( + new URL("/fetch?key=fetch.txt", url), + "hello-fetch", + ); + expect(body).toContain("hello-fetch"); + }).pipe(logLevel), + { timeout }, + ); + }); + + /** + * External container (`context` / `dockerfile`): Alchemy builds the user's + * Dockerfile against the context directory (nginx serving a static page on + * port 8080) and the DO proxies a request to it. + */ + describe("external container (context/dockerfile)", () => { + const { test, beforeAll, afterAll, deploy, destroy } = make(); + const stack = beforeAll(deploy(ExternalStack), { timeout: HOOK_TIMEOUT }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(ExternalStack), { + timeout: HOOK_TIMEOUT, + }); + + test( + "builds the user Dockerfile and serves it over its TCP port", + Effect.gen(function* () { + const { url } = yield* stack; + + const hello = yield* fetchReady( + new URL("/hello", url), + "external container", + ); + expect(hello).toContain("external container"); + }).pipe(logLevel), + { timeout }, + ); + }); + + /** + * Remote container (`image`): Alchemy pulls a pre-built public image and + * re-pushes it to Cloudflare's registry without building anything; the DO + * proxies a request to it. + */ + describe("remote container (image)", () => { + const { test, beforeAll, afterAll, deploy, destroy } = make(); + const stack = beforeAll(deploy(RemoteStack), { timeout: HOOK_TIMEOUT }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(RemoteStack), { + timeout: HOOK_TIMEOUT, + }); + + test( + "pulls and re-pushes the remote image and serves it over its TCP port", + Effect.gen(function* () { + const { url } = yield* stack; + + const hello = yield* fetchReady(new URL("/hello", url), "method"); + expect(hello).toContain("method"); + }).pipe(logLevel), + { timeout }, + ); + }); +}); // Note on image choice for the non-Effect (`image`/`dockerfile`) variants: // stock nginx images crash-loop inside Cloudflare's container sandbox because @@ -51,28 +194,32 @@ const DEPLOY_PLACEHOLDER = "Alchemy worker is being deployed..."; // Force `Connection: close` so each readiness attempt opens a fresh connection // and can land on an edge that already has the new deploy (a pooled keep-alive // socket stays pinned to one edge metal and can keep reading the stale body). -const freshConn = HttpClient.mapRequest( - HttpClientRequest.setHeader("connection", "close"), +const freshConn = HttpClient.HttpClient.pipe( + Effect.map( + HttpClient.mapRequest(HttpClientRequest.setHeader("connection", "close")), + ), ); // Retry a freshly-deployed worker route until it answers 200 with a body that // contains `expected` — rejecting both transient non-200s and the deploy stub. // Each attempt is bounded so a worker that is hung waiting on its container // surfaces as a retryable failure rather than blocking the whole test budget. -const fetchReady = (url: string, expected: string) => +const fetchReady = (url: URL, expected: string) => Effect.gen(function* () { - const client = freshConn(yield* HttpClient.HttpClient); + const client = yield* freshConn; return yield* client.get(url).pipe( Effect.flatMap((r) => - r.status !== 200 - ? Effect.fail(new Error(`Worker not ready: ${r.status}`)) - : Effect.flatMap(r.text, (body) => - body.includes(DEPLOY_PLACEHOLDER) || !body.includes(expected) + r.text.pipe( + Effect.flatMap((body) => + r.status !== 200 + ? Effect.fail(new Error(`Worker not ready: ${r.status} ${body}`)) + : body.includes(DEPLOY_PLACEHOLDER) || !body.includes(expected) ? Effect.fail(new Error(`not ready: got ${body}`)) : Effect.succeed(body), - ), + ), + ), ), - Effect.timeout("30 seconds"), + Effect.timeout("10 seconds"), Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), ); }); @@ -81,11 +228,11 @@ const fetchReady = (url: string, expected: string) => // retrying through cold-start until the producer accepts it (200). const seed = (url: string, key: string, value: string) => Effect.gen(function* () { - const client = freshConn(yield* HttpClient.HttpClient); + const client = yield* freshConn; return yield* client .execute( HttpClientRequest.put( - `${url}/seed?key=${encodeURIComponent(key)}`, + new URL(`/seed?key=${encodeURIComponent(key)}`, url), ).pipe(HttpClientRequest.bodyText(value)), ) .pipe( @@ -94,118 +241,10 @@ const seed = (url: string, key: string, value: string) => ? Effect.succeed(r) : Effect.fail(new Error(`seed not ready: ${r.status}`)), ), - Effect.timeout("30 seconds"), - Effect.retry({ schedule: readinessSchedule, times: readinessRetries }), - ); - }); - -/** - * Effect-native container (`main`): the entrypoint Effect is bundled into a - * generated image. The Durable Object proxies into the container two ways — - * RPC (`container.ping()` / `container.readObject()`) and HTTP over its - * port-3000 server (`getTcpPort(3000).fetch`). The bucket tests prove the - * full end-to-end R2 path: a value written through the DO's NATIVE binding is - * read back from inside the container over its scoped HTTP token, surfaced - * both via RPC and via fetch. - */ -describe("effectful container (main)", () => { - const stack = beforeAll(deploy(EffectfulStack), { timeout: HOOK_TIMEOUT }); - afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(EffectfulStack), { - timeout: HOOK_TIMEOUT, - }); - - test( - "RPC: ping round-trips into the container", - Effect.gen(function* () { - const { url } = yield* stack; - - const pong = yield* fetchReady(`${url}/ping`, "pong"); - expect(pong).toContain("pong"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); - - test( - "fetch: serves over its TCP port", - Effect.gen(function* () { - const { url } = yield* stack; - - const hello = yield* fetchReady(`${url}/hello`, "effectful container"); - expect(hello).toContain("effectful container"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); - - test( - "RPC: reads an R2 object from inside the container", - Effect.gen(function* () { - const { url } = yield* stack; - - yield* seed(url, "rpc.txt", "hello-rpc"); - const body = yield* fetchReady(`${url}/rpc?key=rpc.txt`, "hello-rpc"); - expect(body).toContain("hello-rpc"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); - - test( - "fetch: reads an R2 object from inside the container", - Effect.gen(function* () { - const { url } = yield* stack; - - yield* seed(url, "fetch.txt", "hello-fetch"); - const body = yield* fetchReady( - `${url}/fetch?key=fetch.txt`, - "hello-fetch", + Effect.timeout("10 seconds"), + Effect.retry({ + schedule: readinessSchedule, + times: readinessRetries, + }), ); - expect(body).toContain("hello-fetch"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); -}); - -/** - * External container (`context` / `dockerfile`): Alchemy builds the user's - * Dockerfile against the context directory (nginx serving a static page on - * port 8080) and the DO proxies a request to it. - */ -describe("external container (context/dockerfile)", () => { - const stack = beforeAll(deploy(ExternalStack), { timeout: HOOK_TIMEOUT }); - afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(ExternalStack), { - timeout: HOOK_TIMEOUT, }); - - test( - "builds the user Dockerfile and serves it over its TCP port", - Effect.gen(function* () { - const { url } = yield* stack; - - const hello = yield* fetchReady(`${url}/hello`, "external container"); - expect(hello).toContain("external container"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); -}); - -/** - * Remote container (`image`): Alchemy pulls a pre-built public image and - * re-pushes it to Cloudflare's registry without building anything; the DO - * proxies a request to it. - */ -describe("remote container (image)", () => { - const stack = beforeAll(deploy(RemoteStack), { timeout: HOOK_TIMEOUT }); - afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(RemoteStack), { - timeout: HOOK_TIMEOUT, - }); - - test( - "pulls and re-pushes the remote image and serves it over its TCP port", - Effect.gen(function* () { - const { url } = yield* stack; - - const hello = yield* fetchReady(`${url}/hello`, "method"); - expect(hello).toContain("method"); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, - ); -}); diff --git a/packages/alchemy/test/Cloudflare/Container/LocalContainer.test.ts b/packages/alchemy/test/Cloudflare/Container/LocalContainer.test.ts new file mode 100644 index 000000000..fbc184a38 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Container/LocalContainer.test.ts @@ -0,0 +1,72 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import LocalRemoteStack from "./fixtures/remote/local-stack.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Cloudflare.providers(), + state: Cloudflare.state(), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// First request has to wait for the local runtime to `docker pull` the image +// and boot the container, so give it plenty of room. +const HOOK_TIMEOUT = 300_000; +const TEST_TIMEOUT = 240_000; + +const readinessSchedule = Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("3 seconds"), +]); + +/** + * Remote container (`image`) under `alchemy dev`: the local provider must + * resolve `dev: { imageUri }` so the local runtime can `docker pull` the + * pre-built image (regression: it previously died with "Container requires a + * `main` entrypoint" because it only handled the Effect-native variant). + * The DO proxies a request to the echo server running inside the container. + */ +describe("local remote container (image)", () => { + const stack = beforeAll(deploy(LocalRemoteStack), { timeout: HOOK_TIMEOUT }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(LocalRemoteStack), { + timeout: HOOK_TIMEOUT, + }); + + test( + "pulls the remote image and serves it over its TCP port", + Effect.gen(function* () { + const { url } = yield* stack; + const client = yield* HttpClient.HttpClient; + + // `new URL` (not `${url}/hello`): the dev url has a trailing slash, and + // a `//hello` path is protocol-relative — the local dev proxy would + // resolve it to host "hello" and die with a DNS error. + const body = yield* client.get(new URL("/hello", url)).pipe( + Effect.flatMap((r) => + r.status !== 200 + ? Effect.fail(new Error(`not ready: ${r.status}`)) + : Effect.flatMap(r.text, (text) => + // mendhak/http-https-echo echoes the request (method, path, + // headers) back as JSON. + text.includes("method") + ? Effect.succeed(text) + : Effect.fail(new Error(`not ready: got ${text}`)), + ), + ), + Effect.timeout("30 seconds"), + Effect.retry({ schedule: readinessSchedule, times: 30 }), + ); + expect(body).toContain("method"); + }).pipe(logLevel), + { timeout: TEST_TIMEOUT }, + ); +}); diff --git a/packages/alchemy/test/Cloudflare/Container/fixtures/remote/local-stack.ts b/packages/alchemy/test/Cloudflare/Container/fixtures/remote/local-stack.ts new file mode 100644 index 000000000..1de389723 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Container/fixtures/remote/local-stack.ts @@ -0,0 +1,18 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Alchemy from "@/index.ts"; +import * as Effect from "effect/Effect"; +import RemoteContainerWorker from "./worker.ts"; + +/** + * Same worker/DO/container arrangement as `stack.ts`, under a distinct stack + * name so the local-dev test (`LocalContainer.test.ts`) never shares state + * with the live `Container.test.ts` deployment. + */ +export default Alchemy.Stack( + "LocalRemoteContainerStack", + { providers: Cloudflare.providers(), state: Cloudflare.state() }, + Effect.gen(function* () { + const worker = yield* RemoteContainerWorker; + return { url: worker.url.as() }; + }), +);