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
45 changes: 38 additions & 7 deletions packages/wb/src/utils/containerImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +33,7 @@ const configSchema = z
.passthrough();

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

interface ContainerImageDeployment {
project: Project;
Expand All @@ -49,25 +54,34 @@ 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] : [])];
const run = async (command: string, args: string[], cwd = project.dirPath): Promise<string> => {
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);
}
}
Comment on lines +70 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since assertCommittedNamedImageSource performs I/O-bound git operations, running these assertions sequentially in a nested loop can be slow if there are multiple containers or images. We can run them in parallel using Promise.all to improve deployment performance.

  const assertions: Promise<void>[] = [];
  for (const container of containers) {
    for (const image of Object.values(container.images ?? {})) {
      if (image.dockerfile) {
        assertions.push(assertCommittedNamedImageSource(configDir, image.dockerfile, image.build_context, run));
      }
    }
  }
  await Promise.all(assertions);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A configuration names one or two images in practice, so the check runs a handful of short git commands before a deploy that builds and uploads container images. Running them in parallel would not change the deploy time noticeably, and the sequential loop stops at the first dirty context with a single clear error. Keeping it sequential.

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 { temporaryDir, temporaryConfig, dispose } = createTemporaryImageFiles(absoluteConfigPath, cancellation);
try {
for (const container of localContainers) {
await reuseContainerImage(container, {
configDir: path.dirname(absoluteConfigPath),
configDir,
workerName,
temporaryDir,
registry,
Expand Down Expand Up @@ -136,7 +150,21 @@ interface CommittedImageSource {
buildVars: (readonly [string, string | undefined])[];
}

async function reuseContainerImage(container: Container, context: ImageContext): Promise<void> {
async function assertCommittedNamedImageSource(
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}`);
}
}

async function reuseContainerImage(container: LocalContainer, context: ImageContext): Promise<void> {
const { registry } = context;
const source = await resolveCommittedImageSource(container, context);
const imageName = container.name || container.class_name;
Expand All @@ -155,7 +183,10 @@ async function reuseContainerImage(container: Container, context: ImageContext):
delete container.image_vars;
}

async function resolveCommittedImageSource(container: Container, context: ImageContext): Promise<CommittedImageSource> {
async function resolveCommittedImageSource(
container: LocalContainer,
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');
Expand Down
46 changes: 44 additions & 2 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) });
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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown> {
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 });
Expand Down Expand Up @@ -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<string, unknown>[];
}[] {
return JSON.parse(fs.readFileSync(path.join(directory, 'deployed.json'), 'utf8'));
}

Expand Down
Loading