alchemy 0.77.0 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import type { Context } from "../context.ts";
5
5
  import { Resource } from "../resource.ts";
6
6
  import type { Secret } from "../secret.ts";
7
7
  import { DockerApi } from "./api.ts";
8
+ import type { RemoteImage } from "./remote-image.ts";
8
9
 
9
10
  /**
10
11
  * Options for building a Docker image
@@ -40,9 +41,28 @@ export interface DockerBuildOptions {
40
41
  target?: string;
41
42
 
42
43
  /**
43
- * List of images to use for cache
44
+ * Use an external cache source for a build
45
+ *
46
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-from
47
+ *
44
48
  */
45
49
  cacheFrom?: string[];
50
+
51
+ /**
52
+ * Export build cache to an external cache destination
53
+ *
54
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-to
55
+ *
56
+ */
57
+ cacheTo?: string[];
58
+
59
+ /**
60
+ * Additional options to pass to the Docker build command. This serves as an escape hatch for any additional options that are not supported by the other properties.
61
+ *
62
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#options
63
+ *
64
+ */
65
+ options?: string[];
46
66
  }
47
67
 
48
68
  export interface ImageRegistry {
@@ -54,22 +74,12 @@ export interface ImageRegistry {
54
74
  /**
55
75
  * Properties for creating a Docker image
56
76
  */
57
- export interface ImageProps {
58
- /**
59
- * Repository name for the image (e.g., "username/image")
60
- */
61
- name?: string;
62
-
77
+ export type ImageProps = {
63
78
  /**
64
79
  * Tag for the image (e.g., "latest")
65
80
  */
66
81
  tag?: string;
67
82
 
68
- /**
69
- * Build configuration
70
- */
71
- build?: DockerBuildOptions;
72
-
73
83
  /**
74
84
  * Registry credentials
75
85
  */
@@ -79,12 +89,36 @@ export interface ImageProps {
79
89
  * Whether to skip pushing the image to registry
80
90
  */
81
91
  skipPush?: boolean;
82
- }
92
+ } & (
93
+ | {
94
+ /**
95
+ * Image name or reference (e.g., "nginx:alpine")
96
+ */
97
+ image: string | Image | RemoteImage;
98
+ build?: never;
99
+ name?: never;
100
+ }
101
+ | {
102
+ /**
103
+ * Repository name for the image (e.g., "username/image")
104
+ *
105
+ * @default - the id
106
+ */
107
+ name?: string;
108
+ /**
109
+ * Build configuration
110
+ */
111
+ build: DockerBuildOptions;
112
+
113
+ image?: never;
114
+ }
115
+ );
83
116
 
84
117
  /**
85
118
  * Docker Image resource
86
119
  */
87
- export interface Image extends ImageProps {
120
+ export interface Image {
121
+ kind: "Image";
88
122
  /**
89
123
  * Image name
90
124
  */
@@ -109,6 +143,15 @@ export interface Image extends ImageProps {
109
143
  * Time when the image was built
110
144
  */
111
145
  builtAt: number;
146
+ /**
147
+ * Tag for the image
148
+ */
149
+ tag: string;
150
+
151
+ /**
152
+ * Build configuration
153
+ */
154
+ build: DockerBuildOptions | undefined;
112
155
  }
113
156
 
114
157
  /**
@@ -142,12 +185,32 @@ export const Image = Resource(
142
185
  // No action needed for delete as Docker images aren't automatically removed
143
186
  // This is intentional as other resources might depend on the same image
144
187
  return this.destroy();
145
- } else {
146
- // Normalize properties
147
- const tag = props.tag || "latest";
148
- const name = props.name || id;
149
- const imageRef = `${name}:${tag}`;
188
+ }
150
189
 
190
+ const tag = props.tag || "latest";
191
+ const name =
192
+ props.name ||
193
+ (typeof props.image === "string"
194
+ ? props.image
195
+ : props.image?.name
196
+ )?.split(":")[0] ||
197
+ id;
198
+ const imageRef = `${name}:${tag}`;
199
+ let imageId: string | undefined;
200
+ if (props.image) {
201
+ const image =
202
+ typeof props.image === "string" ? props.image : props.image.imageRef;
203
+
204
+ const kind =
205
+ typeof props.image === "object" && props.image.kind === "Image"
206
+ ? "local"
207
+ : "remote";
208
+ if (kind === "remote") {
209
+ await api.pullImage(image);
210
+ }
211
+ await api.tagImage(image, imageRef);
212
+ // TODO: Extract image ID from pull output if available
213
+ } else {
151
214
  let context: string;
152
215
  let dockerfile: string;
153
216
  if (props.build?.dockerfile && props.build?.context) {
@@ -170,7 +233,7 @@ export const Image = Resource(
170
233
  const buildOptions: Record<string, string> = props.build?.args || {};
171
234
 
172
235
  // Add platform if specified
173
- let buildArgs = ["build", "-t", imageRef];
236
+ const buildArgs = ["build", "-t", imageRef];
174
237
 
175
238
  if (props.build?.platform) {
176
239
  buildArgs.push("--platform", props.build.platform);
@@ -188,11 +251,23 @@ export const Image = Resource(
188
251
  }
189
252
  }
190
253
 
254
+ // Add cache destinations if specified
255
+ if (props.build?.cacheTo && props.build.cacheTo.length > 0) {
256
+ for (const cacheTarget of props.build.cacheTo) {
257
+ buildArgs.push("--cache-to", cacheTarget);
258
+ }
259
+ }
260
+
191
261
  // Add build arguments
192
262
  for (const [key, value] of Object.entries(buildOptions)) {
193
263
  buildArgs.push("--build-arg", `${key}=${value}`);
194
264
  }
195
265
 
266
+ // Add build options if specified
267
+ if (props.build?.options && props.build.options.length > 0) {
268
+ buildArgs.push(...props.build.options);
269
+ }
270
+
196
271
  buildArgs.push("-f", dockerfile);
197
272
 
198
273
  // Add context path
@@ -203,77 +278,77 @@ export const Image = Resource(
203
278
 
204
279
  // Extract image ID from build output if available
205
280
  const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout);
206
- const imageId = imageIdMatch ? imageIdMatch[1] : undefined;
207
-
208
- // Handle push if required
209
- let repoDigest: string | undefined;
210
- let finalImageRef = imageRef;
211
- if (props.registry && !props.skipPush) {
212
- const { server, username, password } = props.registry;
213
-
214
- // Ensure the registry server does not have trailing slash
215
- const registryHost = server.replace(/\/$/, "");
216
-
217
- // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
218
- const firstSegment = imageRef.split("/")[0];
219
- const hasRegistryPrefix = firstSegment.includes(".");
220
-
221
- // Compose the target image reference that will be pushed
222
- const targetImage = hasRegistryPrefix
223
- ? imageRef // already fully-qualified
224
- : `${registryHost}/${imageRef}`;
225
-
226
- try {
227
- // Create a temporary directory that will act as an isolated Docker config
228
- // (credentials) directory. This prevents race-conditions when multiple
229
- // concurrent tests perform `docker login` / `logout` by ensuring each
230
- // Image operation has its own credential store.
231
- const tempConfigDir = await fs.mkdtemp(
232
- path.join(os.tmpdir(), "docker-config-"),
233
- );
234
- const api = new DockerApi({ configDir: tempConfigDir });
235
-
236
- // Authenticate to registry using the isolated config directory
237
- await api.login(registryHost, username, password.unencrypted);
238
-
239
- // Tag local image with fully qualified name if necessary
240
- if (targetImage !== imageRef) {
241
- await api.exec(["tag", imageRef, targetImage]);
242
- }
243
-
244
- // Push the image
245
- const { stdout: pushOut } = await api.exec(["push", targetImage]);
246
-
247
- // Attempt to extract the repo digest from push output
248
- const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(
249
- pushOut,
250
- );
251
- if (digestMatch) {
252
- const digestHash = digestMatch[1];
253
- // Strip tag (anything after last :) to build image@digest reference
254
- const [repoWithoutTag] =
255
- targetImage.split(":").length > 2
256
- ? [targetImage] // unlikely but safety
257
- : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
258
- repoDigest = `${repoWithoutTag}@${digestHash}`;
259
- }
260
-
261
- // Update the final image reference to point at the pushed image
262
- finalImageRef = targetImage;
263
- } finally {
264
- // Clean up credentials from the isolated config
265
- await api.logout(registryHost);
281
+ imageId = imageIdMatch ? imageIdMatch[1] : undefined;
282
+ }
283
+
284
+ // Handle push if required
285
+ let repoDigest: string | undefined;
286
+ let finalImageRef = imageRef;
287
+ if (props.registry && !props.skipPush) {
288
+ const { server, username, password } = props.registry;
289
+
290
+ // Ensure the registry server does not have trailing slash
291
+ const registryHost = server.replace(/\/$/, "");
292
+
293
+ // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
294
+ const firstSegment = imageRef.split("/")[0];
295
+ const hasRegistryPrefix = firstSegment.includes(".");
296
+
297
+ // Compose the target image reference that will be pushed
298
+ const targetImage = hasRegistryPrefix
299
+ ? imageRef // already fully-qualified
300
+ : `${registryHost}/${imageRef}`;
301
+
302
+ try {
303
+ // Create a temporary directory that will act as an isolated Docker config
304
+ // (credentials) directory. This prevents race-conditions when multiple
305
+ // concurrent tests perform `docker login` / `logout` by ensuring each
306
+ // Image operation has its own credential store.
307
+ const tempConfigDir = await fs.mkdtemp(
308
+ path.join(os.tmpdir(), "docker-config-"),
309
+ );
310
+ const api = new DockerApi({ configDir: tempConfigDir });
311
+
312
+ // Authenticate to registry using the isolated config directory
313
+ await api.login(registryHost, username, password.unencrypted);
314
+
315
+ // Tag local image with fully qualified name if necessary
316
+ if (targetImage !== imageRef) {
317
+ await api.exec(["tag", imageRef, targetImage]);
266
318
  }
267
- }
268
319
 
269
- return {
270
- ...props,
271
- name,
272
- imageRef: finalImageRef,
273
- imageId,
274
- repoDigest,
275
- builtAt: Date.now(),
276
- };
320
+ // Push the image
321
+ const { stdout: pushOut } = await api.exec(["push", targetImage]);
322
+
323
+ // Attempt to extract the repo digest from push output
324
+ const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(pushOut);
325
+ if (digestMatch) {
326
+ const digestHash = digestMatch[1];
327
+ // Strip tag (anything after last :) to build image@digest reference
328
+ const [repoWithoutTag] =
329
+ targetImage.split(":").length > 2
330
+ ? [targetImage] // unlikely but safety
331
+ : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
332
+ repoDigest = `${repoWithoutTag}@${digestHash}`;
333
+ }
334
+
335
+ // Update the final image reference to point at the pushed image
336
+ finalImageRef = targetImage;
337
+ } finally {
338
+ // Clean up credentials from the isolated config
339
+ await api.logout(registryHost);
340
+ }
277
341
  }
342
+ return {
343
+ kind: "Image",
344
+ ...props,
345
+ tag,
346
+ name,
347
+ imageRef: finalImageRef,
348
+ imageId,
349
+ repoDigest,
350
+ builtAt: Date.now(),
351
+ build: props.build,
352
+ };
278
353
  },
279
354
  );
@@ -26,6 +26,7 @@ export interface RemoteImageProps {
26
26
  * Docker Remote Image resource
27
27
  */
28
28
  export interface RemoteImage extends RemoteImageProps {
29
+ kind: "RemoteImage";
29
30
  /**
30
31
  * Full image reference (name:tag)
31
32
  */
@@ -71,8 +72,10 @@ export const RemoteImage = Resource(
71
72
  await api.pullImage(imageRef);
72
73
 
73
74
  return {
75
+ kind: "RemoteImage",
74
76
  ...props,
75
77
  imageRef,
78
+ tag,
76
79
  createdAt: Date.now(),
77
80
  };
78
81
  }