1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Artifacts from "../Artifacts.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
9import type { Providers } from "./Providers.ts";
10import {
11 type ImageRegistry,
12 parseCreatedAt,
13 parseRepoDigest,
14 repositoryFromImageRef,
15 withRegistryHost,
16} from "./Registry.ts";
17
18export interface DockerBuildOptions {
19 /**
20 * Build context directory.
21 *
22 * @default Current working directory.
23 */
24 context?: string;
25 /**
26 * Dockerfile path, relative to the context unless absolute.
27 *
28 * @default "Dockerfile"
29 */
30 dockerfile?: string;
31 /** Target platform, e.g. `"linux/amd64"`. */
32 platform?: string;
33 /** Docker build arguments. */
34 args?: Record<string, string>;
35 /** Multi-stage build target. */
36 target?: string;
37 /** Cache sources passed as `--cache-from`. */
38 cacheFrom?: string[];
39 /** Cache destinations passed as `--cache-to`. */
40 cacheTo?: string[];
41 /** Additional Docker build options. */
42 options?: string[];
43}
44
45export interface ImageProps {
46 /**
47 * Repository/name for the built image.
48 *
49 * @default Generated from stack, stage, logical id, and instance id.
50 */
51 name?: string;
52 /** Image tag. @default "latest" */
53 tag?: string;
54 /** Registry credentials for push. */
55 registry?: ImageRegistry;
56 /** Skip registry push even when `registry` is set. @default false */
57 skipPush?: boolean;
58 /** Docker context name or context resource. */
59 context?: Docker.ContextRef;
60 /** Docker build configuration. */
61 build: DockerBuildOptions;
62}
63
64export interface Image extends Resource<
65 "Docker.Image",
66 ImageProps,
67 {
68 /** Image repository/name without tag. */
69 name: string;
70 /** Final image reference. Includes registry host when pushed there. */
71 imageRef: string;
72 /** Local image id after build/tag. */
73 imageId: string;
74 /** Registry digest after push when available. */
75 repoDigest?: string;
76 /** Tag used for the local image. */
77 tag: string;
78 /** Build timestamp in milliseconds since epoch. */
79 builtAt: number;
80 },
81 never,
82 Providers
83> {}
84
85/**
86 * Builds, tags, and optionally pushes Docker images through the active Docker
87 * context.
88 *
89 * This resource uses the Docker CLI and whatever daemon or remote context the
90 * CLI is configured to target. It is separate from `Cloudflare.Container`;
91 * registry image references are the boundary between Docker-managed images and
92 * cloud container platforms.
93 *
94 * `Image` always builds from a Dockerfile. To pull (and optionally re-tag and
95 * push) an existing registry image, use `Docker.RemoteImage`.
96 *
97 *
98 * ### Building Images
99 * **Example:** Build from a Dockerfile
100 * ```typescript
101 * const image = yield* Docker.Image("app", {
102 * name: "my-app",
103 * tag: "latest",
104 * build: {
105 * context: "./app",
106 * dockerfile: "Dockerfile",
107 * args: { NODE_ENV: "production" },
108 * },
109 * });
110 * ```
111 *
112 * ### Registry Push
113 * **Example:** Push with Redacted credentials
114 * ```typescript
115 * const image = yield* Docker.Image("app", {
116 * name: "my-app",
117 * build: { context: "./app" },
118 * registry: {
119 * server: "ghcr.io",
120 * username: "octocat",
121 * password: Config.Redacted("GITHUB_TOKEN"),
122 * },
123 * });
124 * ```
125 *
126 * ### Docker Context
127 * **Example:** Build in a named Docker context
128 * ```typescript
129 * const image = yield* Docker.Image("app", {
130 * name: "my-app",
131 * context: "remote-build",
132 * build: { context: "./app" },
133 * });
134 * ```
135 *
136 * @resource
137 * @product Image
138 */
139export const Image = Resource<Image>("Docker.Image");
140
141export const ImageProvider = () =>
142 Provider.effect(
143 Image,
144 Effect.gen(function* () {
145 const fs = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 const docker = yield* Docker;
148
149 const buildAndInspectImage = Effect.fn(function* (
150 id: string,
151 props: ImageProps,
152 instanceId: string,
153 ) {
154 const name = yield* dockerPhysicalName(id, props, instanceId);
155 const engineContext = dockerContextName(props.context);
156 const tag = props.tag ?? "latest";
157 const ref = `${name}:${tag}`;
158
159 const paths = yield* resolveBuildPaths(props.build);
160 yield* docker.image.build({
161 tag: ref,
162 context: paths.context,
163 file: paths.dockerfile,
164 platform: props.build.platform,
165 target: props.build.target,
166 "build-arg": props.build.args,
167 "cache-from": props.build.cacheFrom,
168 "cache-to": props.build.cacheTo,
169 args: props.build.options,
170 engineContext,
171 });
172
173 // Read the freshly built image's id and creation time straight from
174 // Docker rather than synthesizing a wall-clock timestamp.
175 return {
176 name,
177 tag,
178 image: yield* docker.image.inspect(ref, engineContext),
179 ref,
180 };
181 }, Artifacts.cached("build"));
182
183 const resolveBuildPaths = Effect.fn(function* (
184 build: DockerBuildOptions,
185 ) {
186 const cwd = yield* Effect.sync(() => process.cwd());
187 const context = path.resolve(build.context ?? cwd);
188 const dockerfile = build.dockerfile
189 ? path.isAbsolute(build.dockerfile)
190 ? build.dockerfile
191 : path.resolve(context, build.dockerfile)
192 : path.resolve(context, "Dockerfile");
193 if (!(yield* fs.exists(context))) {
194 return yield* Effect.die(
195 `Docker build context does not exist: ${context}`,
196 );
197 }
198 if (!(yield* fs.exists(dockerfile))) {
199 return yield* Effect.die(`Dockerfile does not exist: ${dockerfile}`);
200 }
201 return { context, dockerfile };
202 });
203
204 return Image.Provider.of({
205 list: () => Effect.succeed([]),
206 read: Effect.fn(function* ({ id, instanceId, olds, output }) {
207 const context = dockerContextName(olds.context);
208 const ref =
209 output?.imageRef ??
210 (yield* dockerPhysicalName(id, olds, instanceId).pipe(
211 Effect.map((name) => `${name}:${olds.tag ?? "latest"}`),
212 ));
213 const image = yield* docker.image
214 .inspect(ref, context)
215 .pipe(
216 Effect.catchReason(
217 "PlatformError",
218 "NotFound",
219 () => Effect.undefined,
220 ),
221 );
222 if (!image) return undefined;
223 return {
224 name: output?.name ?? repositoryFromImageRef(ref),
225 imageRef: ref,
226 imageId: image.Id,
227 repoDigest: output?.repoDigest,
228 tag: output?.tag ?? olds.tag ?? "latest",
229 builtAt: output?.builtAt ?? parseCreatedAt(image.Created),
230 };
231 }),
232 diff: Effect.fn(function* ({ id, instanceId, news, output, olds }) {
233 if (!isResolved(news) || !output) return undefined;
234 if (
235 dockerContextName(olds.context) !== dockerContextName(news.context)
236 ) {
237 return { action: "update" };
238 }
239 const { image } = yield* buildAndInspectImage(id, news, instanceId);
240 if (output?.imageId !== image.Id) {
241 return { action: "update" };
242 }
243 }),
244 reconcile: Effect.fn(function* ({ id, instanceId, news, session }) {
245 const context = dockerContextName(news.context);
246 const { name, tag, image, ref } = yield* buildAndInspectImage(
247 id,
248 news,
249 instanceId,
250 );
251
252 let repoDigest: string | undefined;
253 let targetImageRef: string = ref;
254 if (news.registry && !news.skipPush) {
255 yield* session.note(
256 `Pushing image to registry "${news.registry.server}"`,
257 );
258 targetImageRef = withRegistryHost(ref, news.registry);
259 repoDigest = yield* docker.image
260 .push(ref, news.registry, undefined, context)
261 .pipe(
262 Effect.map((result) => parseRepoDigest(ref, result.stdout)),
263 );
264 }
265
266 return {
267 name,
268 imageRef: targetImageRef,
269 imageId: image.Id,
270 repoDigest,
271 tag,
272 builtAt: parseCreatedAt(image.Created),
273 };
274 }),
275 delete: Effect.fn(({ olds, output }) =>
276 docker.image
277 .remove(output.imageRef, undefined, dockerContextName(olds.context))
278 .pipe(
279 Effect.catchReason(
280 "PlatformError",
281 "NotFound",
282 () => Effect.void,
283 ),
284 ),
285 ),
286 });
287 }),
288 );