Skip to content

Commit 17d23d8

Browse files
emily-shenCarmenPopoviciuvicb
authored
add tag, cleanup containers at end of dev, rebuild hotkey, lots of refactoring etc. etc. (#9605)
* add tag and set imageName outside of miniflare * delete containers at the end of dev session * rebuild containers hotkey * move build and cleanup into wrangler * hide hotkey * fix tests * set container name to DO class_name not optional `name` field * pr feedback * fix tests * changeset * Update packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts Co-authored-by: Victor Berchet <[email protected]> * Update packages/containers-shared/src/build.ts Co-authored-by: Carmen Popoviciu <[email protected]> --------- Co-authored-by: Carmen Popoviciu <[email protected]> Co-authored-by: Victor Berchet <[email protected]>
1 parent 8a60fe7 commit 17d23d8

File tree

18 files changed

+322
-235
lines changed

18 files changed

+322
-235
lines changed

.changeset/itchy-rice-sink.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/containers-shared": patch
3+
"miniflare": patch
4+
"wrangler": patch
5+
---
6+
7+
Add rebuild hotkey for containers local dev, and clean up containers at the end of a dev session.

packages/containers-shared/src/build.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { spawn } from "child_process";
22
import { readFileSync } from "fs";
3-
import { BuildArgs, Logger } from "./types";
3+
import path from "path";
4+
import { dockerImageInspect } from "./inspect";
5+
import { MF_DEV_CONTAINER_PREFIX } from "./registry";
6+
import { BuildArgs, ContainerDevOptions, Logger } from "./types";
7+
import { verifyDockerInstalled } from "./utils";
48

59
export async function constructBuildCommand(
610
options: BuildArgs,
@@ -65,3 +69,58 @@ export function dockerBuild(
6569
});
6670
});
6771
}
72+
73+
/**
74+
*
75+
* Builds (or pulls - TODO) the container images for local development. This
76+
* will be called before starting the local development server, and by a rebuild
77+
* hotkey during development.
78+
*
79+
* Because this runs when local dev starts, we also do some validation here,
80+
* such as checking if the Docker CLI is installed, and if the container images
81+
* expose any ports.
82+
*/
83+
export async function prepareContainerImagesForDev(
84+
dockerPath: string,
85+
containerOptions: ContainerDevOptions[]
86+
) {
87+
if (process.platform === "win32") {
88+
throw new Error(
89+
"Local development with containers is currently not supported on Windows. You should use WSL instead. You can also set `enable_containers` to false if you do not need to develop the container as part of your application."
90+
);
91+
}
92+
await verifyDockerInstalled(dockerPath);
93+
for (const options of containerOptions) {
94+
await buildContainer(dockerPath, options);
95+
await checkExposedPorts(dockerPath, options.imageTag);
96+
}
97+
}
98+
99+
async function buildContainer(
100+
dockerPath: string,
101+
options: ContainerDevOptions
102+
) {
103+
// just let the tag default to latest
104+
const { buildCmd, dockerfile } = await constructBuildCommand({
105+
tag: options.imageTag,
106+
pathToDockerfile: options.image,
107+
buildContext: options.imageBuildContext ?? path.dirname(options.image),
108+
args: options.args,
109+
platform: "linux/amd64",
110+
});
111+
112+
await dockerBuild(dockerPath, { buildCmd, dockerfile });
113+
}
114+
115+
async function checkExposedPorts(dockerPath: string, imageTag: string) {
116+
const output = await dockerImageInspect(dockerPath, {
117+
imageTag,
118+
formatString: "{{ len .Config.ExposedPorts }}",
119+
});
120+
if (output === "0" && process.platform !== "linux") {
121+
throw new Error(
122+
`The container "${imageTag.replace(MF_DEV_CONTAINER_PREFIX + "/", "")}" does not expose any ports.\n` +
123+
"To develop containers locally on non-Linux platforms, you must expose any ports that you call with `getTCPPort()` in your Dockerfile."
124+
);
125+
}
126+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import { MF_DEV_CONTAINER_PREFIX } from "./registry";
2+
13
// default cloudflare managed registry, can be overriden with the env var - CLOUDFLARE_CONTAINER_REGISTRY
24
export const getCloudflareContainerRegistry = () => {
35
return process.env.CLOUDFLARE_CONTAINER_REGISTRY ?? "registry.cloudflare.com";
46
};
7+
8+
/** Prefixes with the cloudflare-dev namespace. The name should be the container's DO classname, and the tag a build uuid. */
9+
export const getDevContainerImageName = (name: string, tag: string) => {
10+
return `${MF_DEV_CONTAINER_PREFIX}/${name.toLowerCase()}:${tag}`;
11+
};

packages/containers-shared/src/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ export const getCloudflareRegistryWithAccountNamespace = (
99
): string => {
1010
return `${getCloudflareContainerRegistry()}/${accountID}/${tag}`;
1111
};
12+
13+
export const MF_DEV_CONTAINER_PREFIX = "cloudflare-dev";

packages/containers-shared/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,14 @@ export type BuildArgs = {
1818
/** sets --network=host at build time. only used by workers CI. */
1919
setNetworkToHost?: boolean;
2020
};
21+
22+
/** build/pull agnostic container options */
23+
export type ContainerDevOptions = {
24+
/** may be dockerfile or registry link */
25+
image: string;
26+
/** formatted as cloudflare-dev/workername-DOclassname:build-id */
27+
imageTag: string;
28+
imageBuildContext?: string;
29+
/** build time args */
30+
args?: Record<string, string>;
31+
};

packages/containers-shared/src/utils.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn, StdioOptions } from "child_process";
1+
import { execFile, spawn, StdioOptions } from "child_process";
22
import { existsSync, statSync } from "fs";
33

44
/** helper for simple docker command call that don't require any io handling */
@@ -29,6 +29,25 @@ export const runDockerCmd = async (
2929
});
3030
};
3131

32+
export const runDockerCmdWithOutput = async (
33+
dockerPath: string,
34+
args: string[]
35+
): Promise<string> => {
36+
return new Promise((resolve, reject) => {
37+
execFile(dockerPath, args, (error, stdout) => {
38+
if (error) {
39+
return reject(
40+
new Error(
41+
`Failed running docker command: ${error.message}. Command: ${dockerPath} ${args.join(" ")}`
42+
)
43+
);
44+
}
45+
return resolve(stdout.trim());
46+
});
47+
});
48+
};
49+
50+
/** throws when docker is not installed */
3251
export const verifyDockerInstalled = async (dockerPath: string) => {
3352
try {
3453
await runDockerCmd(dockerPath, ["info"], ["inherit", "pipe", "pipe"]);
@@ -47,6 +66,7 @@ export function isDir(path: string) {
4766
return stats.isDirectory();
4867
}
4968

69+
/** returns true if it is a dockerfile, false if it is a registry link, throws if neither */
5070
export const isDockerfile = (image: string): boolean => {
5171
// TODO: move this into config validation
5272
if (existsSync(image)) {
@@ -86,3 +106,50 @@ export const isDockerfile = (image: string): boolean => {
86106
}
87107
return false;
88108
};
109+
110+
/**
111+
* Kills and removes any containers which come from the given image tag
112+
*/
113+
export const cleanupContainers = async (
114+
dockerPath: string,
115+
imageTags: Set<string>
116+
) => {
117+
try {
118+
// Find all containers (stopped and running) for each built image
119+
const containerIds: string[] = [];
120+
for (const imageTag of imageTags) {
121+
containerIds.push(
122+
...(await getContainerIdsFromImage(dockerPath, imageTag))
123+
);
124+
}
125+
126+
if (containerIds.length === 0) {
127+
return true;
128+
}
129+
130+
// Workerd should have stopped all containers, but clean up any in case. Sends a sigkill.
131+
await runDockerCmd(
132+
dockerPath,
133+
["rm", "--force", ...containerIds],
134+
["inherit", "pipe", "pipe"]
135+
);
136+
return true;
137+
} catch (error) {
138+
return false;
139+
}
140+
};
141+
142+
const getContainerIdsFromImage = async (
143+
dockerPath: string,
144+
ancestorImage: string
145+
) => {
146+
const output = await runDockerCmdWithOutput(dockerPath, [
147+
"ps",
148+
"-a",
149+
"--filter",
150+
`ancestor=${ancestorImage}`,
151+
"--format",
152+
"{{.ID}}",
153+
]);
154+
return output.split("\n").filter((line) => line.trim());
155+
};

packages/miniflare/src/index.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ import {
3838
Response,
3939
} from "./http";
4040
import {
41-
ContainerController,
42-
ContainerOptions,
4341
D1_PLUGIN_NAME,
4442
DURABLE_OBJECTS_PLUGIN_NAME,
4543
DurableObjectClassNames,
@@ -906,7 +904,6 @@ export class Miniflare {
906904
#socketPorts?: SocketPorts;
907905
#runtimeDispatcher?: Dispatcher;
908906
#proxyClient?: ProxyClient;
909-
#containerController?: ContainerController;
910907

911908
#cfObject?: Record<string, any> = {};
912909

@@ -1592,10 +1589,6 @@ export class Miniflare {
15921589
innerBindings: Worker_Binding[];
15931590
}[] = [];
15941591

1595-
let containerOptions: {
1596-
[className: string]: ContainerOptions;
1597-
} = {};
1598-
15991592
for (const [key, plugin] of PLUGIN_ENTRIES) {
16001593
const pluginExtensions = await plugin.getExtensions?.({
16011594
// @ts-expect-error `CoreOptionsSchema` has required options which are
@@ -1627,14 +1620,6 @@ export class Miniflare {
16271620
workerOpts.assets.assets.workerName = workerOpts.core.name;
16281621
}
16291622

1630-
if (workerOpts.containers.containers) {
1631-
// we don't care which worker this container belongs to, as they are id'd already by the DO classname
1632-
containerOptions = {
1633-
...containerOptions,
1634-
...workerOpts.containers.containers,
1635-
};
1636-
}
1637-
16381623
// Collect all bindings from this worker
16391624
const workerBindings: Worker_Binding[] = [];
16401625
allWorkerBindings.set(workerName, workerBindings);
@@ -1892,25 +1877,6 @@ export class Miniflare {
18921877
}
18931878
}
18941879

1895-
if (
1896-
Object.keys(containerOptions).length &&
1897-
sharedOpts.containers.enableContainers
1898-
) {
1899-
if (this.#containerController === undefined) {
1900-
this.#containerController = new ContainerController(
1901-
containerOptions,
1902-
this.#sharedOpts.containers,
1903-
this.#log
1904-
);
1905-
await this.#containerController.buildAllContainers();
1906-
} else {
1907-
this.#containerController.updateConfig(
1908-
containerOptions,
1909-
this.#sharedOpts.containers
1910-
);
1911-
}
1912-
}
1913-
19141880
const globalServices = getGlobalServices({
19151881
sharedOptions: sharedOpts.core,
19161882
allWorkerRoutes,
@@ -2669,6 +2635,7 @@ export class Miniflare {
26692635
// Cleanup as much as possible even if `#init()` threw
26702636
await this.#proxyClient?.dispose();
26712637
await this.#runtime?.dispose();
2638+
26722639
await this.#stopLoopbackServer();
26732640
// `rm -rf ${#tmpPath}`, this won't throw if `#tmpPath` doesn't exist
26742641
await fs.promises.rm(this.#tmpPath, { force: true, recursive: true });

packages/miniflare/src/plugins/containers/index.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)