-
Notifications
You must be signed in to change notification settings - Fork 88
feat(cloudflare): support prebuilt images in Container resource #1194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T = any> = { | |
| __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<Image> { | ||
| 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<T>( | ||
| id: string, | ||
| props: ContainerProps, | ||
|
|
@@ -215,13 +312,43 @@ export async function Container<T>( | |
| 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) { | ||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What motivated this when
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. addressed in d6d8ea7 |
||
| // 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, | ||
|
|
@@ -230,21 +357,72 @@ export async function Container<T>( | |
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
|
Comment on lines
+369
to
+402
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. my intent was to maintain the previous Image declaration from L235- what would be the advantage of changing to |
||
| } 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is mutually exclusive with
build, should we use a union type so you can't accidentally provide both?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I opted with the runtime error at L316