Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 78 additions & 55 deletions packages/wb/src/utils/containerImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const configSchema = z
.passthrough();

type Container = z.infer<typeof containerSchema>;
type NamedImage = z.infer<typeof namedImageSchema>;
type LocalContainer = Container & { image: string };

interface ContainerImageDeployment {
Expand Down Expand Up @@ -66,28 +67,23 @@ 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])));
const namedImages = containers
.flatMap((container) => Object.values(container.images ?? {}))
.filter((image): image is NamedImage & { dockerfile: string } => image.dockerfile !== undefined);
if (localContainers.length === 0 && namedImages.length === 0) return deploy(configPath);
const { temporaryDir, temporaryConfig, dispose } = createTemporaryImageFiles(absoluteConfigPath, cancellation);
try {
for (const container of localContainers) {
await reuseContainerImage(container, {
configDir,
workerName,
temporaryDir,
registry,
wranglerArgs,
run,
});
// Wrangler builds these itself, so it must read an extracted copy of the committed sources, not the working tree.
for (const [index, image] of namedImages.entries()) {
await stageNamedImage(image, path.join(temporaryDir, `named-${index}`), configDir, run);
}
if (localContainers.length > 0) {
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])));
for (const container of localContainers) {
await reuseContainerImage(container, { configDir, workerName, temporaryDir, registry, wranglerArgs, run });
}
}
fs.writeFileSync(temporaryConfig, JSON.stringify(config));
await deploy(temporaryConfig);
Expand Down Expand Up @@ -142,26 +138,28 @@ interface ImageContext {
run: (command: string, args: string[], cwd?: string) => Promise<string>;
}

interface CommittedImageSource {
key: string;
treeish: string;
interface CommittedTree {
repository: string;
treeish: string;
dockerfilePath: string;
}

interface CommittedImageSource extends CommittedTree {
key: string;
buildVars: (readonly [string, string | undefined])[];
}

async function assertCommittedNamedImageSource(
async function stageNamedImage(
image: NamedImage & { dockerfile: string },
directory: string,
configDir: string,
dockerfile: string,
buildContext: string | undefined,
run: ImageContext['run']
): Promise<void> {
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}`);
}
const dockerfile = resolveDockerfile(path.resolve(configDir, image.dockerfile));
const buildContext = path.resolve(configDir, image.build_context ?? path.dirname(dockerfile));
const tree = await resolveCommittedTree(dockerfile, buildContext, run);
image.dockerfile = await extractCommittedTree(tree, directory, run);
image.build_context = directory;
}

async function reuseContainerImage(container: LocalContainer, context: ImageContext): Promise<void> {
Expand All @@ -188,41 +186,66 @@ async function resolveCommittedImageSource(
context: ImageContext
): Promise<CommittedImageSource> {
const { configDir, workerName, run } = context;
let dockerfile = path.resolve(configDir, container.image);
if (fs.statSync(dockerfile).isDirectory()) dockerfile = path.join(dockerfile, 'Dockerfile');
const dockerfile = resolveDockerfile(path.resolve(configDir, container.image));
const buildContext = path.resolve(configDir, container.image_build_context ?? path.dirname(dockerfile));
const tree = await resolveCommittedTree(dockerfile, buildContext, run);
const treeHash = await run('git', ['rev-parse', tree.treeish], tree.repository);
const vars = container.image_vars ?? {};
const buildVars = Object.keys(vars)
.toSorted()
.map((name) => [name, vars[name]] as const);
const key = createHash('sha256')
.update(
JSON.stringify({
tree: treeHash,
workerName,
dockerfilePath: tree.dockerfilePath,
buildVars,
platform: 'linux/amd64',
})
)
.digest('hex');
return { ...tree, key, buildVars };
}

function resolveDockerfile(dockerfile: string): string {
return fs.statSync(dockerfile).isDirectory() ? path.join(dockerfile, 'Dockerfile') : dockerfile;
}

async function resolveCommittedTree(
dockerfile: string,
buildContext: string,
run: ImageContext['run']
): Promise<CommittedTree> {
const repository = await run('git', ['rev-parse', '--show-toplevel'], buildContext);
const contextPath = path.relative(repository, buildContext).split(path.sep).join('/');
const contextPath = path.relative(repository, buildContext);
const dockerfilePath = path.relative(buildContext, dockerfile);
if (dockerfilePath.startsWith('..') || path.isAbsolute(dockerfilePath)) {
throw new Error('Container image reuse requires the Dockerfile inside its committed build context.');
throw new Error('Container images require the Dockerfile inside their committed build context.');
}
const revision = await run('git', ['rev-parse', 'HEAD'], repository);
const treeish = `${revision}:${contextPath}`;
const tree = await run('git', ['rev-parse', treeish], repository);
if (await run('git', ['diff', revision, '--', contextPath || '.'], repository)) {
throw new Error(`Commit container build context changes before deploying: ${buildContext}`);
}
const vars = container.image_vars ?? {};
const buildVars = Object.keys(vars)
.toSorted()
.map((name) => [name, vars[name]] as const);
const key = createHash('sha256')
.update(JSON.stringify({ tree, workerName, dockerfilePath, buildVars, platform: 'linux/amd64' }))
.digest('hex');
return { key, treeish, repository, dockerfilePath, buildVars };
return { repository, treeish: `${revision}:${contextPath}`, dockerfilePath };
}

async function buildAndPushImage(image: string, source: CommittedImageSource, context: ImageContext): Promise<void> {
const { key, treeish, repository, dockerfilePath, buildVars } = source;
const { temporaryDir, run } = context;
const archive = path.join(temporaryDir, `${key}.tar`);
const directory = path.join(temporaryDir, key);
/** Extracts the committed build context into `directory` and returns the path of its Dockerfile there. */
async function extractCommittedTree(tree: CommittedTree, directory: string, run: ImageContext['run']): Promise<string> {
const archive = `${directory}.tar`;
fs.mkdirSync(directory, { recursive: true });
await run('git', ['archive', '--format=tar', `--output=${archive}`, treeish], repository);
await run('git', ['archive', '--format=tar', `--output=${archive}`, tree.treeish], tree.repository);
await run('tar', ['-xf', archive, '-C', directory]);
const stagedDockerfile = path.join(directory, dockerfilePath);
if (!fs.existsSync(stagedDockerfile)) throw new Error(`Container Dockerfile must be committed: ${dockerfilePath}`);
const stagedDockerfile = path.join(directory, tree.dockerfilePath);
if (!fs.existsSync(stagedDockerfile))
throw new Error(`Container Dockerfile must be committed: ${tree.dockerfilePath}`);
return stagedDockerfile;
}

async function buildAndPushImage(image: string, source: CommittedImageSource, context: ImageContext): Promise<void> {
const { temporaryDir, run } = context;
const directory = path.join(temporaryDir, source.key);
const stagedDockerfile = await extractCommittedTree(source, directory, run);
console.info(`Building container image ${image} from committed context.`);
await run('docker', [
'build',
Expand All @@ -234,7 +257,7 @@ async function buildAndPushImage(image: string, source: CommittedImageSource, co
stagedDockerfile,
'-t',
image,
...buildVars.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]),
...source.buildVars.flatMap(([name, value]) => ['--build-arg', `${name}=${value}`]),
directory,
]);
await run('wrangler', ['containers', 'push', image, ...context.wranglerArgs]);
Expand Down
20 changes: 15 additions & 5 deletions packages/wb/test/unit/containerImages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), named: selected.containers.filter((container) => container.images) });
deploys.push({ image, rollout, configPath, main: path.resolve(path.dirname(configPath), config.main), named: selected.containers.filter((container) => container.images).map((container) => ({ ...container, contexts: Object.values(container.images).map((image) => { const context = path.resolve(path.dirname(configPath), image.build_context ?? path.dirname(image.dockerfile)); return { files: fs.readdirSync(context).toSorted(), dockerfile: fs.readFileSync(path.resolve(path.dirname(configPath), image.dockerfile), 'utf8') }; }) })) });
fs.writeFileSync(path.join(root, 'deployed.json'), JSON.stringify(deploys));
if (process.env.FAIL_DEPLOY) process.exit(9);
if (process.env.HOLD_DEPLOY) {
Expand Down Expand Up @@ -170,19 +170,29 @@ 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 () => {
it('lets Wrangler build Durable Object-managed images from committed sources only', async () => {
const project = createProject('durable-object');
const runner = addNamedImageContainer(project);
fs.writeFileSync(path.join(project, '.gitignore'), 'runner/.env\n');
fs.writeFileSync(path.join(project, 'runner', '.env'), 'SECRET=local');
fs.writeFileSync(path.join(project, 'runner', 'untracked.txt'), 'local generated output');
await deploy(project, {}, true, ['--containers-rollout=gradual']);
const [deployment] = readDeployments();
expect(deployment?.image).toContain('registry.cloudflare.com/account/');
expect(deployment?.named).toEqual([runner]);
expect(deployment?.named).toMatchObject([
{
class_name: runner.class_name,
scheduling_policy: runner.scheduling_policy,
contexts: [{ files: ['Dockerfile'], dockerfile: 'FROM scratch\n' }],
},
]);
expect(fs.readFileSync(path.join(project, 'wrangler.json'), 'utf8')).toContain('./runner/Dockerfile');
});

it('rejects untracked sources of a Durable Object-managed image', async () => {
it('rejects tracked edits to the 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');
fs.writeFileSync(path.join(project, 'runner', 'Dockerfile'), 'FROM busybox\n');
const result = await deploy(project, {}, false, ['--containers-rollout=gradual']);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain('Commit container build context changes');
Expand Down
Loading