From 473c416c97738042ec71cd228ce69303ef7024af Mon Sep 17 00:00:00 2001 From: Alex Dunne Date: Sat, 1 Nov 2025 16:56:19 -0600 Subject: [PATCH 1/2] feat(cloudflare): support prebuilt images in Container resource --- .../docs/providers/cloudflare/container.md | 64 ++++++ alchemy/src/cloudflare/container.ts | 216 ++++++++++++++++-- alchemy/test/cloudflare/container.test.ts | 104 +++++++++ 3 files changed, 365 insertions(+), 19 deletions(-) diff --git a/alchemy-web/src/content/docs/providers/cloudflare/container.md b/alchemy-web/src/content/docs/providers/cloudflare/container.md index a320750d2..6ce7ef524 100644 --- a/alchemy-web/src/content/docs/providers/cloudflare/container.md +++ b/alchemy-web/src/content/docs/providers/cloudflare/container.md @@ -83,6 +83,70 @@ This will build your Dockerfile and prepare it for publishing to Cloudflare's Im > }); > ``` +## Using Prebuilt Images + +Instead of building from source, you can use a prebuilt image. This is useful for faster deployments, promoting images across environments, or using external base images. + +### Cloudflare Registry Image + +Use an image already in the Cloudflare registry: + +```ts +const container = await Container("my-container", { + className: "MyContainer", + image: "my-app:v1.0.0", +}); +``` + +### External Image + +Use an image from an external registry (Docker Hub, GitHub Container Registry, etc.). The image will be automatically pulled and pushed to Cloudflare's registry: + +```ts +const container = await Container("my-container", { + className: "MyContainer", + image: "nginx:alpine", +}); +``` + +> [!NOTE] +> Cloudflare Containers currently only support images hosted in Cloudflare's registry (`registry.cloudflare.com`). External images are automatically pulled and pushed to the Cloudflare registry during deployment. + +### Using RemoteImage Resource + +For more control over external images, use the `RemoteImage` resource: + +```ts +import { RemoteImage } from "alchemy/docker"; + +const baseImage = await RemoteImage("base", { + name: "ghcr.io/my-org/my-app", + tag: "v1.2.3", +}); + +const container = await Container("my-container", { + className: "MyContainer", + image: baseImage, +}); +``` + +### Image Promotion Workflow + +You can use prebuilt images to promote the same image across environments: + +```ts +// Build once in CI/CD +const builtImage = await Container("my-container", { + className: "MyContainer", +}); + +// Promote to production using the same image +const prodContainer = await Container("prod-container", { + className: "MyContainer", + image: builtImage.image.imageRef, +}); +``` + ## Adopting Existing Containers By default, if a container application with the same name already exists, Alchemy will throw an error. However, you can use the `adopt` property to take over management of an existing container application: diff --git a/alchemy/src/cloudflare/container.ts b/alchemy/src/cloudflare/container.ts index 6b1bdf984..ad25cf0d3 100644 --- a/alchemy/src/cloudflare/container.ts +++ b/alchemy/src/cloudflare/container.ts @@ -1,5 +1,6 @@ import type { Context } from "../context.ts"; import { Image, type ImageProps } from "../docker/image.ts"; +import { RemoteImage } from "../docker/remote-image.ts"; import { Resource } from "../resource.ts"; import { Scope } from "../scope.ts"; import { secret } from "../secret.ts"; @@ -24,6 +25,33 @@ export interface ContainerProps */ className: string; + /** + * Use a prebuilt image instead of building one. + * Can be a string (image reference), RemoteImage resource, or Image resource. + * Mutually exclusive with `build` property. + * + * - String: Image reference (e.g., "my-image:v1.0.0" for CF registry or "nginx:alpine" for external) + * - RemoteImage: External image pulled from a registry + * - Image: Previously built image resource + * + * External images (from Docker Hub, GitHub Container Registry, etc.) are automatically + * pulled and pushed to Cloudflare's registry, as Cloudflare Containers currently only + * support images from registry.cloudflare.com. + * + * @example + * // Use an image already in Cloudflare registry + * image: "my-app:v1.0.0" + * + * @example + * // Use an external image (will be automatically pushed to CF registry) + * image: "nginx:alpine" + * + * @example + * // Use a RemoteImage resource + * image: await RemoteImage("base", { name: "ghcr.io/org/app:1.2.3" }) + */ + image?: string | RemoteImage | Image; + /** * Maximum number of container instances that can be running. * Controls horizontal scaling limits. @@ -189,6 +217,75 @@ export type Container = { __phantom?: T; }; +/** + * Extract name from an image reference + * @internal + */ +function extractNameFromImageRef(imageRef: string): string { + // Remove registry host if present + let name = imageRef; + const parts = imageRef.split("/"); + if (parts[0].includes(".") || parts[0].includes(":")) { + // Has registry prefix, remove it + name = parts.slice(1).join("/"); + } + // Remove tag or digest + return name.split(":")[0].split("@")[0]; +} + +/** + * Retag and push an image to Cloudflare registry + * @internal + */ +async function retagAndPushToCloudflare( + sourceImageRef: string, + targetName: string, + targetTag: string, + api: CloudflareApi, +): Promise { + const { DockerApi } = await import("../docker/api.ts"); + const docker = new DockerApi(); + const cloudflareRegistry = getCloudflareContainerRegistry(); + + const credentials = await getContainerCredentials(api); + const cfImageName = `${api.accountId}/${targetName}`; + const cfImageRef = `${cloudflareRegistry}/${cfImageName}:${targetTag}`; + + // Pull the source image if not already local + await docker.pullImage(sourceImageRef); + + // Tag the image for Cloudflare registry + await docker.exec(["tag", sourceImageRef, cfImageRef]); + + // Login to Cloudflare registry and push + await docker.login( + cloudflareRegistry, + credentials.username || credentials.user!, + credentials.password, + ); + + const { stdout: pushOut } = await docker.exec(["push", cfImageRef]); + + // Logout from registry + await docker.logout(cloudflareRegistry); + + // Extract repo digest from push output + let repoDigest: string | undefined; + const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(pushOut); + if (digestMatch) { + const digestHash = digestMatch[1]; + repoDigest = `${cloudflareRegistry}/${cfImageName}@${digestHash}`; + } + + return { + name: cfImageName, + tag: targetTag, + imageRef: cfImageRef, + repoDigest, + builtAt: Date.now(), + }; +} + export async function Container( id: string, props: ContainerProps, @@ -217,11 +314,33 @@ export async function Container( const isDev = scope.local && !props.dev?.remote; if (isDev) { - const image = await Image(id, { - name: `cloudflare-dev/${name}`, // prefix used by Miniflare - tag, - build: props.build, - }); + // In local dev mode, always build if build config is provided + // Otherwise use the provided image or create a placeholder + let image: Image; + if (props.build) { + image = await Image(id, { + name: `cloudflare-dev/${name}`, // prefix used by Miniflare + tag, + build: props.build, + }); + } else if (props.image) { + // Use provided image in dev mode + if (typeof props.image === "string") { + image = { + name: props.image.split(":")[0], + tag: props.image.split(":")[1] || "latest", + imageRef: props.image, + builtAt: Date.now(), + }; + } else { + image = props.image as Image; + } + } else { + throw new Error( + `Container requires either 'image' or 'build' property. ` + + `Specify 'image' to use a prebuilt image or 'build' to build from source.`, + ); + } return { ...output, @@ -229,22 +348,81 @@ export async function Container( }; } + // Validate mutual exclusivity + if (props.image && props.build) { + throw new Error( + `Cannot specify both 'image' and 'build' properties. ` + + `Use 'image' for prebuilt images or 'build' to build from source.`, + ); + } + const api = await createCloudflareApi(props); - const credentials = await getContainerCredentials(api); - const image = await Image(id, { - name: `${api.accountId}/${name}`, - tag, - build: { - platform: props.build?.platform ?? "linux/amd64", - ...props.build, - }, - registry: { - server: "registry.cloudflare.com", - username: credentials.username || credentials.user!, - password: secret(credentials.password), - }, - }); + let image: Image; + + if (props.image) { + // Handle prebuilt image + if (typeof props.image === "string") { + const imageRef = props.image; + + // Check if it's already in Cloudflare registry + if (isCloudflareRegistryLink(imageRef)) { + // Already in CF registry, create a lightweight Image reference + const imageName = extractNameFromImageRef(imageRef); + const imageTag = imageRef.includes(":") + ? imageRef.split(":").pop()!.split("@")[0] + : "latest"; + + image = { + name: imageName, + tag: imageTag, + imageRef: imageRef, + repoDigest: imageRef.includes("@") ? imageRef : undefined, + builtAt: Date.now(), + }; + } else { + // External image - automatically push to CF registry + // Cloudflare Containers currently only support images from registry.cloudflare.com + image = await retagAndPushToCloudflare(imageRef, name, tag, api); + } + } else { + // It's an Image or RemoteImage resource + const sourceImage = props.image as Image | RemoteImage; + const sourceImageRef = sourceImage.imageRef; + + // Check if it's already in CF registry + if (isCloudflareRegistryLink(sourceImageRef)) { + // Already in CF registry, use as-is + image = sourceImage as Image; + } else { + // Not in CF registry - automatically push to CF registry + // Cloudflare Containers currently only support images from registry.cloudflare.com + image = await retagAndPushToCloudflare(sourceImageRef, name, tag, api); + } + } + } else if (props.build) { + // Build from source + const credentials = await getContainerCredentials(api); + + image = await Image(id, { + name: `${api.accountId}/${name}`, + tag, + build: { + platform: props.build?.platform ?? "linux/amd64", + ...props.build, + }, + registry: { + server: getCloudflareContainerRegistry(), + username: credentials.username || credentials.user!, + password: secret(credentials.password), + }, + }); + } else { + throw new Error( + `Container requires either 'image' or 'build' property. ` + + `Specify 'image' to use a prebuilt image or 'build' to build from source.`, + ); + } return { ...output, diff --git a/alchemy/test/cloudflare/container.test.ts b/alchemy/test/cloudflare/container.test.ts index 079401e2f..82f2c654d 100644 --- a/alchemy/test/cloudflare/container.test.ts +++ b/alchemy/test/cloudflare/container.test.ts @@ -9,6 +9,7 @@ import { } from "../../src/cloudflare/index.ts"; import { Worker } from "../../src/cloudflare/worker.ts"; import { destroy } from "../../src/destroy.ts"; +import { RemoteImage } from "../../src/docker/remote-image.ts"; import "../../src/test/vitest.ts"; import { BRANCH_PREFIX } from "../util.ts"; @@ -163,4 +164,107 @@ describe.sequential("Container Resource", () => { await destroy(scope); } }); + + test("use prebuilt CF image without rebuild", async (scope) => { + const containerName = `${BRANCH_PREFIX}-prebuilt-cf-image`; + + try { + // First, build and push an image + const builtContainer = await Container(`${containerName}-build`, { + className: "TestContainer", + name: containerName, + tag: "v1.0.0", + build: { + context: path.join(import.meta.dirname, "container"), + }, + adopt: true, + }); + + // Now use the prebuilt image reference + const prebuiltContainer = await Container(`${containerName}-prebuilt`, { + className: "TestContainer", + image: builtContainer.image.imageRef, + adopt: true, + }); + + expect(prebuiltContainer.image.imageRef).toBe( + builtContainer.image.imageRef, + ); + expect(prebuiltContainer.image.name).toBeTruthy(); + } finally { + await destroy(scope); + } + }); + + test("pull and push external image to CF", async (scope) => { + const containerName = `${BRANCH_PREFIX}-external-image`; + + try { + // Use a small external image - automatically pushed to CF + const container = await Container(containerName, { + className: "TestContainer", + name: containerName, + image: "nginx:alpine", + adopt: true, + }); + + expect(container.image.imageRef).toContain("registry.cloudflare.com"); + expect(container.image.name).toBeTruthy(); + } finally { + await destroy(scope); + } + }); + + test("error when both image and build are specified", async (scope) => { + try { + await expect( + Container(`${BRANCH_PREFIX}-both-image-build`, { + className: "TestContainer", + image: "nginx:alpine", + build: { + context: path.join(import.meta.dirname, "container"), + }, + }), + ).rejects.toThrow(/Cannot specify both 'image' and 'build'/); + } finally { + await destroy(scope); + } + }); + + test("error when neither image nor build are specified", async (scope) => { + try { + await expect( + Container(`${BRANCH_PREFIX}-no-image-no-build`, { + className: "TestContainer", + }), + ).rejects.toThrow(/requires either 'image' or 'build'/); + } finally { + await destroy(scope); + } + }); + + test("use RemoteImage resource", async (scope) => { + const containerName = `${BRANCH_PREFIX}-remote-image-resource`; + + try { + // Create a RemoteImage resource + const remoteImage = await RemoteImage(`${containerName}-remote`, { + name: "nginx", + tag: "alpine", + }); + + // Use it in a container - automatically pushed to CF + const container = await Container(containerName, { + className: "TestContainer", + name: containerName, + image: remoteImage, + adopt: true, + }); + + expect(container.image.imageRef).toContain("registry.cloudflare.com"); + expect(container.image.name).toBeTruthy(); + } finally { + await destroy(scope); + } + }); }); From d6d8ea7591f0072a3d645cd352fb0693dfd98221 Mon Sep 17 00:00:00 2001 From: Alex Dunne Date: Wed, 5 Nov 2025 13:26:50 -0700 Subject: [PATCH 2/2] address feedback re not enforcing mutual exclusivity when isDev --- alchemy/src/cloudflare/container.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/alchemy/src/cloudflare/container.ts b/alchemy/src/cloudflare/container.ts index ad25cf0d3..511eb5db2 100644 --- a/alchemy/src/cloudflare/container.ts +++ b/alchemy/src/cloudflare/container.ts @@ -312,6 +312,14 @@ export async function Container( adopt: props.adopt, }; + // Validate mutual exclusivity + if (props.image && props.build) { + throw new Error( + `Cannot specify both 'image' and 'build' properties. ` + + `Use 'image' for prebuilt images or 'build' to build from source.`, + ); + } + const isDev = scope.local && !props.dev?.remote; if (isDev) { // In local dev mode, always build if build config is provided @@ -348,14 +356,6 @@ export async function Container( }; } - // Validate mutual exclusivity - if (props.image && props.build) { - throw new Error( - `Cannot specify both 'image' and 'build' properties. ` + - `Use 'image' for prebuilt images or 'build' to build from source.`, - ); - } - const api = await createCloudflareApi(props); let image: Image;