diff --git a/packages/wb/src/utils/containerImages.ts b/packages/wb/src/utils/containerImages.ts index ae5c5df2a..32088a047 100644 --- a/packages/wb/src/utils/containerImages.ts +++ b/packages/wb/src/utils/containerImages.ts @@ -12,13 +12,17 @@ import type { Project } from '../project.js'; import { createWranglerDeployEnv } from './wranglerDeployEnv.js'; const execute = promisify(execFile); +const namedImageSchema = z + .object({ dockerfile: z.string().optional(), build_context: z.string().optional() }) + .passthrough(); const containerSchema = z .object({ class_name: z.string().optional(), name: z.string().optional(), - image: z.string(), + image: z.string().optional(), image_build_context: z.string().optional(), image_vars: z.record(z.string(), z.coerce.string()).optional(), + images: z.record(z.string(), namedImageSchema).optional(), }) .passthrough(); const configSchema = z @@ -29,6 +33,7 @@ const configSchema = z .passthrough(); type Container = z.infer; +type LocalContainer = Container & { image: string }; interface ContainerImageDeployment { project: Project; @@ -49,10 +54,11 @@ export async function withReusableContainerImages({ const config = configSchema.parse(parse(fs.readFileSync(absoluteConfigPath, 'utf8'))); const selected = (envName ? config.env?.[envName] : undefined) ?? config; const containers = selected.containers ?? []; - const localContainers = containers.filter((container) => - fs.existsSync(path.resolve(path.dirname(absoluteConfigPath), container.image)) + const configDir = path.dirname(absoluteConfigPath); + const localContainers = containers.filter( + (container): container is LocalContainer => + container.image !== undefined && fs.existsSync(path.resolve(configDir, container.image)) ); - if (localContainers.length === 0) return deploy(configPath); const env = createWranglerDeployEnv(project.env); const cancellation = new AbortController(); const wranglerArgs = ['--config', absoluteConfigPath, ...(envName ? ['--env', envName] : [])]; @@ -60,6 +66,14 @@ export async function withReusableContainerImages({ const result = await execute(command, args, { cwd, env, signal: cancellation.signal, maxBuffer: 64 * 1024 * 1024 }); return result.stdout.trim(); }; + // Wrangler builds these itself from the working tree, so only committed sources may be there. + for (const container of containers) { + for (const image of Object.values(container.images ?? {})) { + if (image.dockerfile) + await assertCommittedNamedImageSource(configDir, image.dockerfile, image.build_context, run); + } + } + if (localContainers.length === 0) return deploy(configPath); const registry = z .array(z.object({ name: z.string(), tags: z.array(z.string()) })) .parse(JSON.parse(await run('wrangler', ['containers', 'images', 'list', '--json', ...wranglerArgs]))); @@ -67,7 +81,7 @@ export async function withReusableContainerImages({ try { for (const container of localContainers) { await reuseContainerImage(container, { - configDir: path.dirname(absoluteConfigPath), + configDir, workerName, temporaryDir, registry, @@ -136,7 +150,21 @@ interface CommittedImageSource { buildVars: (readonly [string, string | undefined])[]; } -async function reuseContainerImage(container: Container, context: ImageContext): Promise { +async function assertCommittedNamedImageSource( + configDir: string, + dockerfile: string, + buildContext: string | undefined, + run: ImageContext['run'] +): Promise { + const contextDir = path.resolve(configDir, buildContext ?? path.dirname(dockerfile)); + const repository = await run('git', ['rev-parse', '--show-toplevel'], contextDir); + const contextPath = path.relative(repository, contextDir).split(path.sep).join('/') || '.'; + if (await run('git', ['status', '--porcelain', '--untracked-files=all', '--', contextPath], repository)) { + throw new Error(`Commit container build context changes before deploying: ${contextDir}`); + } +} + +async function reuseContainerImage(container: LocalContainer, context: ImageContext): Promise { const { registry } = context; const source = await resolveCommittedImageSource(container, context); const imageName = container.name || container.class_name; @@ -155,7 +183,10 @@ async function reuseContainerImage(container: Container, context: ImageContext): delete container.image_vars; } -async function resolveCommittedImageSource(container: Container, context: ImageContext): Promise { +async function resolveCommittedImageSource( + container: LocalContainer, + context: ImageContext +): Promise { const { configDir, workerName, run } = context; let dockerfile = path.resolve(configDir, container.image); if (fs.statSync(dockerfile).isDirectory()) dockerfile = path.join(dockerfile, 'Dockerfile'); diff --git a/packages/wb/test/unit/containerImages.test.ts b/packages/wb/test/unit/containerImages.test.ts index 2fd1d98e7..42e770833 100644 --- a/packages/wb/test/unit/containerImages.test.ts +++ b/packages/wb/test/unit/containerImages.test.ts @@ -73,7 +73,7 @@ if (path.basename(process.argv[1]) === 'docker') { const image = imageHost.includes('.') || imageHost.includes(':') ? rawImage : registryHost() + '/' + account() + '/' + rawImage; const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); if (rollout !== 'none' && !fs.existsSync(path.resolve(path.dirname(configPath), selected.containers[0].image)) && !registry.some((entry) => entry.account === account() && entry.host === registryHost() && entry.tags.some((tag) => image === entry.host + '/' + entry.account + '/' + entry.name + ':' + tag))) throw new Error('Image is unavailable in deployment account'); - deploys.push({ image, rollout, configPath, main: path.resolve(path.dirname(configPath), config.main) }); + deploys.push({ image, rollout, configPath, main: path.resolve(path.dirname(configPath), config.main), named: selected.containers.filter((container) => container.images) }); fs.writeFileSync(path.join(root, 'deployed.json'), JSON.stringify(deploys)); if (process.env.FAIL_DEPLOY) process.exit(9); if (process.env.HOLD_DEPLOY) { @@ -170,6 +170,25 @@ it.each([false, true])('rejects tracked context edits (staged: %s)', async (stag expect(readDeployments()).toEqual([]); }); +it('passes Durable Object-managed containers with committed sources through to Wrangler', async () => { + const project = createProject('durable-object'); + const runner = addNamedImageContainer(project); + await deploy(project, {}, true, ['--containers-rollout=gradual']); + const [deployment] = readDeployments(); + expect(deployment?.image).toContain('registry.cloudflare.com/account/'); + expect(deployment?.named).toEqual([runner]); +}); + +it('rejects untracked sources of a Durable Object-managed image', async () => { + const project = createProject('durable-object-dirty'); + addNamedImageContainer(project); + fs.writeFileSync(path.join(project, 'runner', 'untracked.txt'), 'local generated output'); + const result = await deploy(project, {}, false, ['--containers-rollout=gradual']); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Commit container build context changes'); + expect(readDeployments()).toEqual([]); +}); + it('changes the image for build variables and resolves staging containers', async () => { const project = createProject('variables'); await deploy(project); @@ -329,6 +348,23 @@ it('cleans the sibling config after failed deployment and does not build after r expect(fs.readdirSync(project).filter((name) => name.startsWith('.wb-deploy-'))).toEqual([]); }); +function addNamedImageContainer(project: string): Record { + fs.mkdirSync(path.join(project, 'runner')); + fs.writeFileSync(path.join(project, 'runner', 'Dockerfile'), 'FROM scratch\n'); + const runner = { + class_name: 'Runner', + scheduling_policy: 'durable_object', + images: { runner: { dockerfile: './runner/Dockerfile' } }, + }; + const configPath = path.join(project, 'wrangler.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.containers.push(runner); + fs.writeFileSync(configPath, JSON.stringify(config)); + git(project, ['add', 'runner/Dockerfile']); + commit(project); + return runner; +} + function createProject(name: string): string { const project = path.join(directory, name); fs.mkdirSync(path.join(project, 'agent'), { recursive: true }); @@ -411,7 +447,13 @@ function spawnDeploy( }); } -function readDeployments(): { image: string; rollout?: string; configPath: string; main: string }[] { +function readDeployments(): { + image: string; + rollout?: string; + configPath: string; + main: string; + named: Record[]; +}[] { return JSON.parse(fs.readFileSync(path.join(directory, 'deployed.json'), 'utf8')); }