Skip to content

Commit fdbc9f6

Browse files
authored
Update buildAndMaybePush to use more robust method to find local digests (#9641)
* Update buildAndMaybePush to use more robust method to find local digests Older versions of docker sometimes will report the digest as "<none>" which would break with the previous implementation. This implementation should correctly grab the values. * Update with PR feedback * Ensure container.configuration.image gets written to Fixes an issue where the user could get into a state where the image has been pushed but the container fails to update it's configuration. On the next push container.configuration.image will not be set and container.image is deleted leading to failures when trying to update the container app.
1 parent 87e13c0 commit fdbc9f6

File tree

9 files changed

+112
-90
lines changed

9 files changed

+112
-90
lines changed

.changeset/orange-teams-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Update container builds to use a more robust method for detecting if the currently built image already exists.

packages/containers-shared/src/images.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,6 @@ import {
1010
verifyDockerInstalled,
1111
} from "./utils";
1212

13-
// Returns a list of docker image ids matching the provided repository:[tag]
14-
export async function getDockerImageDigest(
15-
dockerPath: string,
16-
imageTag: string
17-
): Promise<string> {
18-
return new Promise((resolve, reject) => {
19-
execFile(
20-
dockerPath,
21-
["images", "--digests", "--format", "{{.Digest}}", imageTag],
22-
(error, stdout, stderr) => {
23-
if (error) {
24-
return reject(
25-
new Error(
26-
`Failed getting docker image digest for image: ${imageTag} with error: ${error}.`
27-
)
28-
);
29-
}
30-
return resolve(stdout.trim());
31-
}
32-
);
33-
});
34-
}
35-
3613
export async function pullImage(
3714
dockerPath: string,
3815
options: ContainerDevOptions

packages/wrangler/src/__tests__/cloudchamber/apply.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,14 +225,14 @@ describe("cloudchamber apply", () => {
225225
{
226226
id: "abc",
227227
name: "my-container-app",
228-
max_instances: 3,
228+
max_instances: 4,
229229
instances: 3,
230230
created_at: new Date().toString(),
231231
account_id: "1",
232232
version: 1,
233233
scheduling_policy: SchedulingPolicy.DEFAULT,
234234
configuration: {
235-
image: "./Dockerfile2",
235+
image: "./Dockerfile",
236236
},
237237
constraints: {
238238
tier: 1,
@@ -252,12 +252,11 @@ describe("cloudchamber apply", () => {
252252
253253
├ EDIT my-container-app
254254
255-
│ [containers.configuration]
256-
│ - image = \\"./Dockerfile2\\"
257-
│ + image = \\"./Dockerfile\\"
258-
259-
│ [containers.constraints]
260-
│ ...
255+
│ [[containers]]
256+
│ instances = 0
257+
│ - max_instances = 4
258+
│ + max_instances = 3
259+
│ name = \\"my-container-app\\"
261260
262261
├ NEW my-container-app-2
263262

packages/wrangler/src/__tests__/cloudchamber/build.test.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
dockerImageInspect,
55
dockerLoginManagedRegistry,
66
getCloudflareContainerRegistry,
7-
getDockerImageDigest,
87
runDockerCmd,
98
} from "@cloudflare/containers-shared";
109
import { ensureDiskLimits } from "../../cloudchamber/build";
@@ -31,7 +30,6 @@ vi.mock("@cloudflare/containers-shared", async (importOriginal) => {
3130
runDockerCmd: vi.fn(),
3231
dockerBuild: vi.fn(),
3332
dockerImageInspect: vi.fn(),
34-
getDockerImageDigest: vi.fn(),
3533
});
3634
});
3735

@@ -46,8 +44,7 @@ describe("buildAndMaybePush", () => {
4644

4745
beforeEach(() => {
4846
vi.clearAllMocks();
49-
vi.mocked(dockerImageInspect).mockResolvedValue("53387881 2");
50-
vi.mocked(getDockerImageDigest).mockRejectedValue("failed");
47+
vi.mocked(dockerImageInspect).mockResolvedValue("53387881 2 []");
5148
mkdirSync("./container-context");
5249

5350
writeFileSync("./container-context/Dockerfile", dockerfile);
@@ -77,7 +74,8 @@ describe("buildAndMaybePush", () => {
7774
});
7875
expect(dockerImageInspect).toHaveBeenCalledWith("/custom/docker/path", {
7976
imageTag: `${getCloudflareContainerRegistry()}/test_account_id/test-app:tag`,
80-
formatString: "{{ .Size }} {{ len .RootFS.Layers }}",
77+
formatString:
78+
"{{ .Size }} {{ len .RootFS.Layers }} {{json .RepoDigests}}",
8179
});
8280
expect(runDockerCmd).toHaveBeenCalledWith("/custom/docker/path", [
8381
"push",
@@ -114,14 +112,17 @@ describe("buildAndMaybePush", () => {
114112
expect(dockerImageInspect).toHaveBeenCalledOnce();
115113
expect(dockerImageInspect).toHaveBeenCalledWith("docker", {
116114
imageTag: `${getCloudflareContainerRegistry()}/test_account_id/test-app:tag`,
117-
formatString: "{{ .Size }} {{ len .RootFS.Layers }}",
115+
formatString:
116+
"{{ .Size }} {{ len .RootFS.Layers }} {{json .RepoDigests}}",
118117
});
119118
expect(dockerLoginManagedRegistry).toHaveBeenCalledOnce();
120119
});
121120

122121
it("should be able to build image and not push if it already exists in remote", async () => {
123-
vi.mocked(getDockerImageDigest).mockResolvedValue("three");
124122
vi.mocked(runDockerCmd).mockResolvedValueOnce();
123+
vi.mocked(dockerImageInspect).mockResolvedValue(
124+
'53387881 2 ["registry.cloudflare.com/test_account_id/test-app@sha256:three"]'
125+
);
125126
await runWrangler(
126127
"containers build ./container-context -t test-app:tag -p"
127128
);
@@ -146,7 +147,7 @@ describe("buildAndMaybePush", () => {
146147
[
147148
"manifest",
148149
"inspect",
149-
`${getCloudflareContainerRegistry()}/test_account_id/test-app@three`,
150+
`${getCloudflareContainerRegistry()}/test_account_id/test-app@sha256:three`,
150151
],
151152
"ignore"
152153
);
@@ -158,7 +159,8 @@ describe("buildAndMaybePush", () => {
158159
expect(dockerImageInspect).toHaveBeenCalledOnce();
159160
expect(dockerImageInspect).toHaveBeenCalledWith("docker", {
160161
imageTag: `${getCloudflareContainerRegistry()}/test_account_id/test-app:tag`,
161-
formatString: "{{ .Size }} {{ len .RootFS.Layers }}",
162+
formatString:
163+
"{{ .Size }} {{ len .RootFS.Layers }} {{json .RepoDigests}}",
162164
});
163165
expect(dockerLoginManagedRegistry).toHaveBeenCalledOnce();
164166
});

packages/wrangler/src/__tests__/deploy.test.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Buffer } from "node:buffer";
2-
import { execFile, spawn, spawnSync } from "node:child_process";
2+
import { spawn, spawnSync } from "node:child_process";
33
import { randomFillSync } from "node:crypto";
44
import * as fs from "node:fs";
55
import * as path from "node:path";
@@ -8711,28 +8711,6 @@ addEventListener('fetch', event => {});`
87118711
},
87128712
} as unknown as ChildProcess;
87138713
}
8714-
vi.mocked(execFile)
8715-
// docker images first call
8716-
.mockImplementationOnce((cmd, args, callback) => {
8717-
expect(cmd).toBe("/usr/bin/docker");
8718-
expect(args).toEqual([
8719-
"images",
8720-
"--digests",
8721-
"--format",
8722-
"{{.Digest}}",
8723-
getCloudflareContainerRegistry() +
8724-
"/test_account_id/my-container:Galaxy",
8725-
]);
8726-
if (callback) {
8727-
const back = callback as (
8728-
error: Error | null,
8729-
stdout: string,
8730-
stderr: string
8731-
) => void;
8732-
back(null, "three\n", "");
8733-
}
8734-
return {} as ChildProcess;
8735-
});
87368714

87378715
vi.mocked(spawn)
87388716
// 1. docker build
@@ -8783,7 +8761,7 @@ addEventListener('fetch', event => {});`
87838761
"inspect",
87848762
`${getCloudflareContainerRegistry()}/test_account_id/my-container:Galaxy`,
87858763
"--format",
8786-
"{{ .Size }} {{ len .RootFS.Layers }}",
8764+
"{{ .Size }} {{ len .RootFS.Layers }} {{json .RepoDigests}}",
87878765
]);
87888766

87898767
const stdout = new PassThrough();
@@ -8803,7 +8781,10 @@ addEventListener('fetch', event => {});`
88038781

88048782
// Simulate docker output
88058783
setImmediate(() => {
8806-
stdout.emit("data", "123456 4\n");
8784+
stdout.emit(
8785+
"data",
8786+
`123456 4 ["${getCloudflareContainerRegistry()}/test_account_id/my-container@sha256:three"]`
8787+
);
88078788
});
88088789

88098790
return child as unknown as ChildProcess;

packages/wrangler/src/cloudchamber/apply.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,12 @@ function sortObjectRecursive<T = Record<string | number, unknown>>(
399399
}
400400

401401
export async function apply(
402-
args: { skipDefaults: boolean | undefined; json: boolean; env?: string },
402+
args: {
403+
skipDefaults: boolean | undefined;
404+
json: boolean;
405+
env?: string;
406+
imageUpdateRequired?: boolean;
407+
},
403408
config: Config
404409
) {
405410
startSection(
@@ -476,6 +481,10 @@ export async function apply(
476481
// while configuration.image is deprecated to the user, we still resolve to this for now.
477482
if (!appConfigNoDefaults.configuration?.image && application) {
478483
appConfigNoDefaults.configuration ??= {};
484+
}
485+
486+
if (!args.imageUpdateRequired && application) {
487+
appConfigNoDefaults.configuration ??= {};
479488
appConfigNoDefaults.configuration.image = application.configuration.image;
480489
}
481490

@@ -844,7 +853,14 @@ export async function applyCommand(
844853
config: Config
845854
) {
846855
return apply(
847-
{ skipDefaults: args.skipDefaults, env: args.env, json: args.json },
856+
{
857+
skipDefaults: args.skipDefaults,
858+
env: args.env,
859+
json: args.json,
860+
// For the apply command we want this to default to true
861+
// so that the image can be updated if the user modified it.
862+
imageUpdateRequired: true,
863+
},
848864
config
849865
);
850866
}

packages/wrangler/src/cloudchamber/build.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
dockerImageInspect,
77
dockerLoginManagedRegistry,
88
getCloudflareRegistryWithAccountNamespace,
9-
getDockerImageDigest,
109
isDir,
1110
runDockerCmd,
1211
} from "@cloudflare/containers-shared";
@@ -79,7 +78,7 @@ export async function buildAndMaybePush(
7978
pathToDocker: string,
8079
push: boolean,
8180
containerConfig?: ContainerApp
82-
): Promise<string> {
81+
): Promise<{ image: string; pushed: boolean }> {
8382
try {
8483
// account is also used to check limits below, so it is better to just pull the entire
8584
// account information here
@@ -110,10 +109,11 @@ export async function buildAndMaybePush(
110109
// account's disk size limits
111110
const inspectOutput = await dockerImageInspect(pathToDocker, {
112111
imageTag,
113-
formatString: "{{ .Size }} {{ len .RootFS.Layers }}",
112+
formatString:
113+
"{{ .Size }} {{ len .RootFS.Layers }} {{json .RepoDigests}}",
114114
});
115115

116-
const [sizeStr, layerStr] = inspectOutput.split(" ");
116+
const [sizeStr, layerStr, repoDigests] = inspectOutput.split(" ");
117117
const size = parseInt(sizeStr, 10);
118118
const layers = parseInt(layerStr, 10);
119119

@@ -126,18 +126,46 @@ export async function buildAndMaybePush(
126126
account: account,
127127
containerApp: containerConfig,
128128
});
129-
129+
let pushed = false;
130130
if (push) {
131131
await dockerLoginManagedRegistry(pathToDocker);
132132
try {
133+
// We don't try to parse repoDigests until this point
134+
// because we don't want to fail on parse errors if we
135+
// won't be pushing the image anyways.
136+
//
137+
// A Docker image digest is a unique, cryptographic identifier (SHA-256 hash)
138+
// representing the content of a Docker image. Unlike tags, which can be reused
139+
// or changed, a digest is immutable and ensures that the exact same image is
140+
// pulled every time. This guarantees consistency across different environments
141+
// and deployments.
142+
// From: https://docs.docker.com/dhi/core-concepts/digests/
143+
const parsedDigests = JSON.parse(repoDigests);
144+
145+
if (!Array.isArray(parsedDigests)) {
146+
// If it's not the format we expect, fall back to pushing
147+
// since it's annoying but safe.
148+
throw new Error(
149+
`Expected RepoDigests from docker inspect to be an array but got ${JSON.stringify(parsedDigests)}`
150+
);
151+
}
152+
133153
const repositoryOnly = imageTag.split(":")[0];
134154
// if this succeeds it means this image already exists remotely
135155
// if it fails it means it doesn't exist remotely and should be pushed.
136-
const localDigest = await getDockerImageDigest(pathToDocker, imageTag);
137-
const digest = repositoryOnly + "@" + localDigest;
156+
const digests = parsedDigests.filter(
157+
(d): d is string =>
158+
typeof d === "string" && d.split("@")[0] === repositoryOnly
159+
);
160+
if (digests.length !== 1) {
161+
throw new Error(
162+
`Expected there to only be 1 valid digests for this repository: ${repositoryOnly} but there were ${digests.length}`
163+
);
164+
}
165+
138166
await runDockerCmd(
139167
pathToDocker,
140-
["manifest", "inspect", digest],
168+
["manifest", "inspect", digests[0]],
141169
"ignore"
142170
);
143171

@@ -146,14 +174,20 @@ export async function buildAndMaybePush(
146174
`Untagging built image: ${imageTag} since there was no change.`
147175
);
148176
await runDockerCmd(pathToDocker, ["image", "rm", imageTag]);
149-
return "";
177+
return { image: digests[0], pushed: false };
150178
} catch (error) {
151179
logger.log(`Image does not exist remotely, pushing: ${imageTag}`);
180+
if (error instanceof Error) {
181+
logger.debug(
182+
`Checking for local image ${imageTag} failed with error: ${error.message}`
183+
);
184+
}
152185

153186
await runDockerCmd(pathToDocker, ["push", imageTag]);
187+
pushed = true;
154188
}
155189
}
156-
return imageTag;
190+
return { image: imageTag, pushed: pushed };
157191
} catch (error) {
158192
if (error instanceof Error) {
159193
throw new UserError(error.message);

0 commit comments

Comments
 (0)