1/**
2 * INTERNAL — Artifact Registry image-source machinery for GKE workloads.
3 * Not exported from the GCP barrel or `ArtifactRegistry/index.ts`.
4 *
5 * Mirrors `AWS/ECR/ImageSource.ts`: bundle / docker-build / mirror into a
6 * per-workload Docker repository so `Kubernetes.*` workloads on GKE pull
7 * from Artifact Registry.
8 */
9import * as artifactregistry from "@distilled.cloud/gcp/artifactregistry_v1";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as Schedule from "effect/Schedule";
14import type * as rolldown from "rolldown";
15import { AlchemyContext } from "../../AlchemyContext.ts";
16import * as Bundle from "../../Bundle/Bundle.ts";
17import {
18 findCwdForBundle,
19 getStableContextDir,
20 resolveMainPath,
21} from "../../Bundle/TempRoot.ts";
22import { hashDirectory } from "../../Command/Memo.ts";
23import { Docker } from "../../Docker/Docker.ts";
24import {
25 isInlineDockerfile,
26 type InlineDockerfile,
27} from "../../Docker/Dockerfile.ts";
28import {
29 normalizeInstallTargets,
30 resolvePackageInstallIdentity,
31 type PackageInstall,
32} from "../../Bundle/InstalledPackages.ts";
33import { tagRecord } from "../../Tags.ts";
34import {
35 contextRootOf,
36 copyExtraFiles,
37 hashExtraFiles,
38 posixRelUnder,
39 resolveExtraSource,
40 type ExtraFile,
41} from "../../Util/extraFiles.ts";
42import { sha256, sha256Object } from "../../Util/sha256.ts";
43import { Credentials } from "../Credentials.ts";
44import { GcpEnvironment } from "../Environment.ts";
45import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
46import { waitForDeleteOperation, waitForOperation } from "./internal.ts";
47
48export interface BundledImageSource {
49 main: string;
50 image?: string;
51 dockerfile?: string | InlineDockerfile;
52 context?: string;
53 handler?: string;
54 build?: Bundle.BundleConfig;
55 /**
56 * Host files baked into the image next to `main`. With an external
57 * (`isExternal`) `main`, this selects the unbundled Node program mode:
58 * `main` is copied as-is and run by `node` (website serve entries).
59 */
60 extraFiles?: ReadonlyArray<ExtraFile>;
61 /** Packages `npm install`ed into the Node program image. */
62 install?: PackageInstall;
63}
64
65export interface DockerfileImageSource {
66 context?: string;
67 dockerfile?: string | InlineDockerfile;
68}
69
70export interface RegistryImageSource {
71 image: string;
72}
73
74export interface ImageSourceLike {
75 main?: string;
76 handler?: string;
77 build?: BundledImageSource["build"];
78 context?: string;
79 dockerfile?: string | InlineDockerfile;
80 image?: string;
81 extraFiles?: ReadonlyArray<ExtraFile>;
82 install?: PackageInstall;
83}
84
85/** Base image for unbundled Node programs (website serve entries). */
86const NODE_PROGRAM_BASE_IMAGE = "node:26-slim";
87
88const isNodeProgram = (
89 source: ImageSourceLike,
90 isExternal: boolean | undefined,
91) =>
92 isExternal === true &&
93 source.main !== undefined &&
94 source.extraFiles !== undefined;
95
96const nodeProgramManifest = (dependencies: Record<string, string>) =>
97 `${JSON.stringify(
98 { private: true, type: "module", dependencies },
99 null,
100 2,
101 )}\n`;
102
103const nodeProgramDockerfile = (options: {
104 image: string | undefined;
105 entry: string;
106 install: boolean;
107 port: number | undefined;
108}) => {
109 const lines = [
110 `FROM ${options.image ?? NODE_PROGRAM_BASE_IMAGE}`,
111 `WORKDIR /app`,
112 `ENV NODE_ENV=production`,
113 ];
114 if (options.install) {
115 lines.push(
116 `COPY package.json /app/package.json`,
117 // Drop npm's download cache so it does not ship (and push) with the image.
118 `RUN npm install --omit=dev --no-fund --no-audit && npm cache clean --force`,
119 );
120 }
121 lines.push(`COPY . /app`, `ENV HOST=0.0.0.0`);
122 if (options.port !== undefined) {
123 lines.push(
124 `ENV PORT=${String(options.port)}`,
125 `EXPOSE ${String(options.port)}`,
126 );
127 }
128 lines.push(`ENTRYPOINT ["node", ${JSON.stringify(`/app/${options.entry}`)}]`);
129 return `${lines.join("\n")}\n`;
130};
131
132export type ImageSourceKind = "main" | "context" | "image";
133
134export const imageSourceKind = (
135 source: ImageSourceLike,
136): ImageSourceKind | undefined =>
137 source.main !== undefined
138 ? "main"
139 : source.image !== undefined
140 ? "image"
141 : source.context !== undefined || source.dockerfile !== undefined
142 ? "context"
143 : undefined;
144
145export const validateImageSource = (
146 id: string,
147 source: ImageSourceLike,
148): Effect.Effect<void> => {
149 if (source.image !== undefined && source.dockerfile !== undefined) {
150 return Effect.die(
151 new Error(
152 `'${id}': 'image' and 'dockerfile' are both set — declare exactly one environment source`,
153 ),
154 );
155 }
156 if (source.image !== undefined && source.context !== undefined) {
157 return Effect.die(
158 new Error(
159 `'${id}': 'image' and 'context' are both set — declare exactly one environment source`,
160 ),
161 );
162 }
163 if (
164 source.dockerfile !== undefined &&
165 isInlineDockerfile(source.dockerfile) &&
166 source.context !== undefined
167 ) {
168 return Effect.die(
169 new Error(
170 `'${id}': inline 'dockerfile' content builds with no context — use a path dockerfile with 'context', or drop 'context'`,
171 ),
172 );
173 }
174 return Effect.void;
175};
176
177export interface ResolvedImage {
178 imageUri: string;
179 repositoryName: string;
180 repositoryUri: string;
181 codeHash: string;
182}
183
184export interface ResolveImageOptions {
185 id: string;
186 source: ImageSourceLike;
187 repositoryName: string;
188 repositoryUri?: string;
189 tags?: Record<string, string>;
190 platform?: string;
191 port?: number;
192 isExternal?: boolean;
193 bootstrap: (importPath: string) => string;
194 session: { note: (message: string) => Effect.Effect<void> };
195}
196
197const resolveContextPaths = Effect.fn(function* (source: {
198 context: string | undefined;
199 dockerfile?: string;
200}) {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const context = path.resolve(source.context ?? ".");
204 const dockerfile = source.dockerfile
205 ? path.resolve(source.dockerfile)
206 : path.join(context, "Dockerfile");
207 if (!(yield* fs.exists(context))) {
208 return yield* Effect.die(
209 new Error(`Docker build context does not exist: ${context}`),
210 );
211 }
212 if (!(yield* fs.exists(dockerfile))) {
213 return yield* Effect.die(
214 new Error(`Dockerfile does not exist: ${dockerfile}`),
215 );
216 }
217 return { context, dockerfile };
218});
219
220export const computeStaticSourceHash = Effect.fn(function* (
221 source: ImageSourceLike,
222 platform?: string,
223) {
224 const kind = imageSourceKind(source);
225 if (kind === "image") {
226 return (yield* sha256Object({
227 image: source.image!,
228 platform: platform ?? "linux/amd64",
229 })).slice(0, 16);
230 }
231 if (kind === "context") {
232 if (
233 source.dockerfile !== undefined &&
234 isInlineDockerfile(source.dockerfile)
235 ) {
236 if (typeof source.dockerfile.content !== "string") return undefined;
237 return (yield* sha256Object({
238 dockerfile: source.dockerfile.content,
239 platform: platform ?? "linux/amd64",
240 })).slice(0, 16);
241 }
242 const fs = yield* FileSystem.FileSystem;
243 const { context, dockerfile } = yield* resolveContextPaths({
244 context: source.context!,
245 dockerfile: source.dockerfile,
246 });
247 const contextHash = yield* hashDirectory({ cwd: context });
248 const dockerfileContent = yield* fs.readFileString(dockerfile);
249 return (yield* sha256Object({
250 contextHash,
251 dockerfile: dockerfileContent,
252 platform: platform ?? "linux/amd64",
253 })).slice(0, 16);
254 }
255 return undefined;
256});
257
258const IMAGE_NAME = "app";
259
260const resourceName = (
261 project: string,
262 location: string,
263 repositoryId: string,
264) => `projects/${project}/locations/${location}/repositories/${repositoryId}`;
265
266const dockerRepositoryUri = (
267 project: string,
268 location: string,
269 repositoryId: string,
270) => `${location}-docker.pkg.dev/${project}/${repositoryId}/${IMAGE_NAME}`;
271
272const tagResourceName = (
273 project: string,
274 location: string,
275 repositoryId: string,
276 tag: string,
277) =>
278 `${resourceName(project, location, repositoryId)}/packages/${IMAGE_NAME}/tags/${tag}`;
279
280export interface ArtifactRegistryResolveOptions extends ResolveImageOptions {
281 /** Artifact Registry location (`us-central1`, …). */
282 location: string;
283}
284
285export const getArtifactRegistryCredentials = Effect.fn(function* (
286 location: string,
287) {
288 const creds = yield* yield* Credentials;
289 return {
290 username: "oauth2accesstoken",
291 password: creds.accessToken,
292 server: `${location}-docker.pkg.dev`,
293 };
294});
295
296export const buildAndPushArtifactRegistryImage = Effect.fn(function* (
297 docker: Docker["Service"],
298 options: {
299 imageUri: string;
300 context: string;
301 dockerfile?: string;
302 platform?: string;
303 location: string;
304 },
305) {
306 const credentials = yield* getArtifactRegistryCredentials(options.location);
307 yield* docker.image.build({
308 tag: options.imageUri,
309 context: options.context,
310 file: options.dockerfile,
311 platform: options.platform,
312 });
313 yield* docker.image
314 .push(options.imageUri, credentials, options.platform)
315 .pipe(
316 Effect.retry({
317 while: (): boolean => true,
318 schedule: Schedule.exponential("2 seconds"),
319 times: 3,
320 }),
321 );
322 return options.imageUri;
323});
324
325/**
326 * Init-time constructor for the Artifact Registry image-source resolver.
327 */
328/** Delete the Artifact Registry repository `name`; missing is success. */
329export const destroyImageRepository = Effect.fn(function* (name: string) {
330 const operation = yield* artifactregistry
331 .deleteProjectsLocationsRepositories({ name })
332 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
333 if (operation !== undefined) {
334 yield* waitForDeleteOperation(operation);
335 }
336});
337
338/**
339 * Delete the image repository an Effect-native host built into on
340 * reconcile (`{hostId}-src`, normalized by the host's `rfc1035`).
341 *
342 * Not gated on `output.codeHash`: an update that failed after creating the
343 * repository leaves state without a hash. The repository is deleted only
344 * when it carries this logical id's ownership labels, so a same-named
345 * foreign repository is never touched.
346 */
347export const destroyHostImageRepository = Effect.fn(function* (
348 id: string,
349 output: {
350 project: string;
351 location: string;
352 },
353 repositoryId: string,
354) {
355 const name = resourceName(output.project, output.location, repositoryId);
356 const repository = yield* artifactregistry
357 .getProjectsLocationsRepositories({ name })
358 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
359 if (repository === undefined) return;
360 if (!(yield* hasAlchemyLabels(id, tagRecord(repository.labels)))) return;
361 yield* destroyImageRepository(name);
362});
363
364export const makeImageSource = Effect.gen(function* () {
365 const docker = yield* Docker;
366 const { dotAlchemy } = yield* AlchemyContext;
367 const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
368
369 const mainBundleOptions = (
370 source: BundledImageSource,
371 entry: string,
372 cwd: string,
373 plugins?: rolldown.RolldownPluginOption,
374 ): {
375 inputOptions: rolldown.InputOptions;
376 outputOptions: rolldown.OutputOptions;
377 } => ({
378 inputOptions: {
379 ...source.build?.input,
380 input: entry,
381 cwd,
382 platform: "node",
383 external: [
384 "bun",
385 "bun:*",
386 ...((source.build?.input?.external as string[] | undefined) ?? []),
387 ],
388 resolve: {
389 conditionNames: ["bun", "import", "module", "default"],
390 ...source.build?.input?.resolve,
391 },
392 plugins: [source.build?.input?.plugins, plugins],
393 },
394 outputOptions: {
395 ...source.build?.output,
396 format: "esm",
397 sourcemap: source.build?.output?.sourcemap ?? false,
398 minify: source.build?.output?.minify ?? false,
399 entryFileNames: "index.mjs",
400 },
401 });
402
403 const bundleProgram = Effect.fn(function* (options: {
404 source: BundledImageSource;
405 isExternal?: boolean;
406 bootstrap: (importPath: string) => string;
407 }) {
408 const { source } = options;
409 const realMain = yield* resolveMainPath(source.main);
410 const cwd = yield* findCwdForBundle(realMain);
411
412 const buildBundle = Effect.fn(function* (
413 entry: string,
414 plugins?: rolldown.RolldownPluginOption,
415 ) {
416 const opts = mainBundleOptions(source, entry, cwd, plugins);
417 return yield* Bundle.build(
418 opts.inputOptions,
419 opts.outputOptions,
420 source.build,
421 );
422 });
423
424 const bundleOutput = options.isExternal
425 ? yield* buildBundle(realMain)
426 : yield* buildBundle(realMain, virtualEntryPlugin(options.bootstrap));
427
428 const files = bundleOutput.files.map((file) => ({
429 path: file.path,
430 content:
431 typeof file.content === "string"
432 ? new TextEncoder().encode(file.content)
433 : file.content,
434 }));
435
436 return { files, hash: bundleOutput.hash };
437 });
438
439 const generateDockerfile = (
440 source: BundledImageSource,
441 port?: number,
442 envFrom?: string,
443 ) => {
444 const preamble =
445 envFrom !== undefined
446 ? `FROM ${envFrom}`
447 : source.dockerfile !== undefined &&
448 isInlineDockerfile(source.dockerfile)
449 ? String(source.dockerfile.content).trimEnd()
450 : `FROM ${source.image ?? "oven/bun:1"}`;
451 const lines = [
452 preamble,
453 `WORKDIR /app`,
454 `COPY index.mjs /app/index.mjs`,
455 `COPY *.js /app/`,
456 ];
457 if (port !== undefined) {
458 lines.push(`ENV PORT=${String(port)}`, `EXPOSE ${String(port)}`);
459 }
460 lines.push(`ENTRYPOINT ["bun", "/app/index.mjs"]`);
461 return `${lines.join("\n")}\n`;
462 };
463
464 const computeMainCodeHash = Effect.fn(function* (options: {
465 source: BundledImageSource;
466 isExternal?: boolean;
467 bootstrap: (importPath: string) => string;
468 port?: number;
469 platform: string;
470 }) {
471 const bundled = yield* bundleProgram({
472 source: options.source,
473 isExternal: options.isExternal,
474 bootstrap: options.bootstrap,
475 });
476 const df = options.source.dockerfile;
477 const isPathEnv = df !== undefined && !isInlineDockerfile(df);
478 let envIdentity: Record<string, string> = {};
479 if (isPathEnv) {
480 const fs = yield* FileSystem.FileSystem;
481 const { context, dockerfile } = yield* resolveContextPaths({
482 context: options.source.context!,
483 dockerfile: df,
484 });
485 envIdentity = {
486 envContextHash: yield* hashDirectory({ cwd: context }),
487 envDockerfile: yield* fs.readFileString(dockerfile),
488 };
489 }
490 const dockerfile = generateDockerfile(
491 options.source,
492 options.port,
493 isPathEnv ? "<env>" : undefined,
494 );
495 const codeHash = (yield* sha256Object({
496 bundleHash: bundled.hash,
497 dockerfile,
498 platform: options.platform,
499 ...envIdentity,
500 })).slice(0, 16);
501 return { bundled, dockerfile, codeHash };
502 });
503
504 /**
505 * Hash an unbundled Node program: the entry bytes, every extra file
506 * tree, the generated Dockerfile, and the resolved install identity.
507 */
508 const computeNodeProgram = Effect.fn(function* (options: {
509 source: BundledImageSource;
510 port?: number;
511 platform: string;
512 }) {
513 const fs = yield* FileSystem.FileSystem;
514 const path = yield* Path.Path;
515 const { source } = options;
516 const realMain = yield* resolveMainPath(source.main);
517 const extras = source.extraFiles ?? [];
518 const root = contextRootOf(realMain, extras, path, (file) =>
519 resolveExtraSource(file, path),
520 );
521 const entry =
522 posixRelUnder(root, realMain, path) || path.basename(realMain);
523 const requested = yield* normalizeInstallTargets(source.install);
524 const identity =
525 Object.keys(requested).length > 0
526 ? yield* resolvePackageInstallIdentity({
527 cwd: yield* findCwdForBundle(realMain),
528 requested,
529 })
530 : undefined;
531 const packageJson =
532 identity !== undefined && Object.keys(identity.resolved).length > 0
533 ? nodeProgramManifest(identity.resolved)
534 : undefined;
535 const dockerfile = nodeProgramDockerfile({
536 image: source.image,
537 entry,
538 install: packageJson !== undefined,
539 port: options.port,
540 });
541 const codeHash = (yield* sha256Object({
542 main: yield* sha256(yield* fs.readFile(realMain)),
543 entry,
544 dockerfile,
545 packageJson,
546 lockfile: identity?.lockfile,
547 extraFiles: yield* hashExtraFiles(extras),
548 platform: options.platform,
549 })).slice(0, 16);
550 return { realMain, entry, dockerfile, packageJson, codeHash };
551 });
552
553 const ensureRepository = Effect.fn(function* (options: {
554 id: string;
555 repositoryId: string;
556 location: string;
557 tags?: Record<string, string>;
558 }) {
559 const env = yield* GcpEnvironment.current;
560 const name = resourceName(
561 env.project,
562 options.location,
563 options.repositoryId,
564 );
565 const desiredLabels = {
566 ...toLabels(options.tags),
567 ...(yield* createInternalLabels(options.id)),
568 };
569 const existing = yield* artifactregistry
570 .getProjectsLocationsRepositories({ name })
571 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
572 if (existing?.name) {
573 return {
574 name: existing.name,
575 created: false,
576 repositoryUri:
577 existing.registryUri !== undefined && existing.registryUri.length > 0
578 ? `${existing.registryUri.replace(/\/+$/, "")}/${IMAGE_NAME}`
579 : dockerRepositoryUri(
580 env.project,
581 options.location,
582 options.repositoryId,
583 ),
584 };
585 }
586 const created = yield* artifactregistry
587 .createProjectsLocationsRepositories({
588 parent: `projects/${env.project}/locations/${options.location}`,
589 repositoryId: options.repositoryId,
590 body: {
591 format: "DOCKER",
592 mode: "STANDARD_REPOSITORY",
593 labels: desiredLabels,
594 },
595 })
596 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
597 if (created !== undefined) {
598 yield* waitForOperation(created);
599 }
600 const observed = yield* artifactregistry
601 .getProjectsLocationsRepositories({ name })
602 .pipe(
603 Effect.retry({
604 while: (error) => error._tag === "NotFound",
605 times: 8,
606 schedule: Schedule.spaced("1 second"),
607 }),
608 );
609 return {
610 name: observed.name ?? name,
611 // Created by THIS call (a lost create race reports Conflict).
612 created: created !== undefined,
613 repositoryUri:
614 observed.registryUri !== undefined && observed.registryUri.length > 0
615 ? `${observed.registryUri.replace(/\/+$/, "")}/${IMAGE_NAME}`
616 : dockerRepositoryUri(
617 env.project,
618 options.location,
619 options.repositoryId,
620 ),
621 };
622 });
623
624 const describeImage = Effect.fn(function* (
625 project: string,
626 location: string,
627 repositoryId: string,
628 imageTag: string,
629 ) {
630 return yield* artifactregistry
631 .getProjectsLocationsRepositoriesPackagesTags({
632 name: tagResourceName(project, location, repositoryId, imageTag),
633 })
634 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
635 });
636
637 const resolve = Effect.fn(function* (
638 options: ArtifactRegistryResolveOptions,
639 ) {
640 const { id, source, repositoryName, session, location } = options;
641 const platform = options.platform ?? "linux/amd64";
642 const kind = imageSourceKind(source);
643 if (kind === undefined) {
644 return yield* Effect.die(
645 new Error(
646 `'${id}' must declare exactly one image source: 'main' (bundled Effect program), 'context' (Dockerfile build), or 'image' (registry reference)`,
647 ),
648 );
649 }
650 yield* validateImageSource(id, source);
651
652 const ensured = yield* ensureRepository({
653 id,
654 repositoryId: repositoryName,
655 location,
656 tags: options.tags,
657 });
658 // A failed first build/push never reaches state, so destroy could not
659 // find a repository created here. Remove it before surfacing the error.
660 return yield* buildImage(options, ensured.repositoryUri).pipe(
661 Effect.onError(() =>
662 ensured.created
663 ? destroyImageRepository(ensured.name).pipe(Effect.ignore)
664 : Effect.void,
665 ),
666 );
667 });
668
669 const buildImage = Effect.fn(function* (
670 options: ArtifactRegistryResolveOptions,
671 ensuredRepositoryUri: string,
672 ) {
673 const { id, source, repositoryName, session, location } = options;
674 const platform = options.platform ?? "linux/amd64";
675 const kind = imageSourceKind(source);
676 const env = yield* GcpEnvironment.current;
677 const repositoryUri = options.repositoryUri ?? ensuredRepositoryUri;
678
679 if (isNodeProgram(source, options.isExternal)) {
680 const fs = yield* FileSystem.FileSystem;
681 const path = yield* Path.Path;
682 const program = yield* computeNodeProgram({
683 source: source as BundledImageSource,
684 port: options.port,
685 platform,
686 });
687 const { codeHash } = program;
688 const imageUri = `${repositoryUri}:${codeHash}`;
689 if (
690 yield* describeImage(env.project, location, repositoryName, codeHash)
691 ) {
692 return { imageUri, repositoryName, repositoryUri, codeHash };
693 }
694 // Hash-keyed so a stale tree from an earlier build never leaks in.
695 const contextDir = yield* getStableContextDir(
696 program.realMain,
697 dotAlchemy,
698 `${id}-node-${codeHash}`,
699 );
700 yield* docker.materialize({
701 context: contextDir,
702 dockerfile: program.dockerfile,
703 files:
704 program.packageJson !== undefined
705 ? [{ path: "package.json", content: program.packageJson }]
706 : [],
707 });
708 yield* copyExtraFiles(contextDir, source.extraFiles);
709 const entryPath = path.join(contextDir, program.entry);
710 if (
711 !(yield* fs.exists(entryPath).pipe(Effect.orElseSucceed(() => false)))
712 ) {
713 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
714 yield* fs.copy(program.realMain, entryPath, { overwrite: true });
715 }
716 yield* session.note(`Building container image ${imageUri}...`);
717 yield* buildAndPushArtifactRegistryImage(docker, {
718 imageUri,
719 context: contextDir,
720 platform,
721 location,
722 }).pipe(
723 Effect.ensuring(
724 fs.remove(contextDir, { recursive: true }).pipe(Effect.ignore),
725 ),
726 );
727 yield* session.note(`Pushed ${imageUri}`);
728 return { imageUri, repositoryName, repositoryUri, codeHash };
729 }
730
731 if (kind === "main") {
732 const df = source.dockerfile;
733 if (
734 df !== undefined &&
735 isInlineDockerfile(df) &&
736 typeof df.content !== "string"
737 ) {
738 return yield* Effect.die(
739 new Error(
740 `'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
741 ),
742 );
743 }
744 yield* session.note(`Bundling ${id} program...`);
745 const { bundled, dockerfile, codeHash } = yield* computeMainCodeHash({
746 source: source as BundledImageSource,
747 isExternal: options.isExternal,
748 bootstrap: options.bootstrap,
749 port: options.port,
750 platform,
751 });
752 const imageUri = `${repositoryUri}:${codeHash}`;
753 if (
754 yield* describeImage(env.project, location, repositoryName, codeHash)
755 ) {
756 return { imageUri, repositoryName, repositoryUri, codeHash };
757 }
758
759 let envFrom: string | undefined;
760 if (df !== undefined && !isInlineDockerfile(df)) {
761 const envPaths = yield* resolveContextPaths({
762 context: source.context!,
763 dockerfile: df,
764 });
765 envFrom = `alchemy-env-${id.toLowerCase()}:${codeHash}`;
766 yield* session.note(`Building environment image for ${id}...`);
767 yield* docker.image.build({
768 context: envPaths.context,
769 file: envPaths.dockerfile,
770 tag: envFrom,
771 platform,
772 });
773 }
774 const finalDockerfile =
775 envFrom === undefined
776 ? dockerfile
777 : generateDockerfile(
778 source as BundledImageSource,
779 options.port,
780 envFrom,
781 );
782
783 const realMain = yield* resolveMainPath(
784 (source as BundledImageSource).main,
785 );
786 const contextDir = yield* getStableContextDir(
787 realMain,
788 dotAlchemy,
789 `${id}-image`,
790 );
791 yield* docker.materialize({
792 context: contextDir,
793 dockerfile: finalDockerfile,
794 files: bundled.files.map((file, index) => ({
795 path: index === 0 ? "index.mjs" : file.path,
796 content: file.content,
797 })),
798 });
799 yield* session.note(`Building container image ${imageUri}...`);
800 yield* buildAndPushArtifactRegistryImage(docker, {
801 imageUri,
802 context: contextDir,
803 platform,
804 location,
805 });
806 yield* session.note(`Pushed ${imageUri}`);
807 return { imageUri, repositoryName, repositoryUri, codeHash };
808 }
809
810 if (kind === "image") {
811 const ref = (source as RegistryImageSource).image;
812 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
813 const imageUri = `${repositoryUri}:${codeHash}`;
814 if (
815 yield* describeImage(env.project, location, repositoryName, codeHash)
816 ) {
817 return { imageUri, repositoryName, repositoryUri, codeHash };
818 }
819 yield* session.note(`Pulling container image ${ref}...`);
820 yield* docker.image.pull(ref, platform).pipe(Effect.timeout("4 minutes"));
821 yield* docker.image.tag(ref, imageUri);
822 yield* session.note(`Pushing mirrored image ${imageUri}...`);
823 const credentials = yield* getArtifactRegistryCredentials(location);
824 yield* docker.image.push(imageUri, credentials, platform).pipe(
825 Effect.retry({
826 while: (): boolean => true,
827 schedule: Schedule.exponential("2 seconds"),
828 times: 3,
829 }),
830 );
831 yield* session.note(`Pushed ${imageUri}`);
832 return { imageUri, repositoryName, repositoryUri, codeHash };
833 }
834
835 const externalDf = (source as DockerfileImageSource).dockerfile;
836 if (externalDf !== undefined && isInlineDockerfile(externalDf)) {
837 if (typeof externalDf.content !== "string") {
838 return yield* Effect.die(
839 new Error(
840 `'${id}': inline dockerfile content did not resolve to a string — Outputs in Dockerfile.inline must be resolvable at deploy time`,
841 ),
842 );
843 }
844 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
845 const imageUri = `${repositoryUri}:${codeHash}`;
846 if (
847 yield* describeImage(env.project, location, repositoryName, codeHash)
848 ) {
849 return { imageUri, repositoryName, repositoryUri, codeHash };
850 }
851 const contextDir = yield* getStableContextDir(
852 dotAlchemy,
853 dotAlchemy,
854 `${id}-image`,
855 );
856 yield* docker.materialize({
857 context: contextDir,
858 dockerfile: externalDf.content,
859 files: [],
860 });
861 yield* session.note(`Building container image ${imageUri}...`);
862 yield* buildAndPushArtifactRegistryImage(docker, {
863 imageUri,
864 context: contextDir,
865 platform,
866 location,
867 });
868 yield* session.note(`Pushed ${imageUri}`);
869 return { imageUri, repositoryName, repositoryUri, codeHash };
870 }
871
872 const { context, dockerfile } = yield* resolveContextPaths({
873 context: (source as DockerfileImageSource).context!,
874 dockerfile: externalDf,
875 });
876 const codeHash = (yield* computeStaticSourceHash(source, platform))!;
877 const imageUri = `${repositoryUri}:${codeHash}`;
878 if (yield* describeImage(env.project, location, repositoryName, codeHash)) {
879 return { imageUri, repositoryName, repositoryUri, codeHash };
880 }
881 yield* session.note(`Building container image ${imageUri}...`);
882 yield* buildAndPushArtifactRegistryImage(docker, {
883 imageUri,
884 context,
885 dockerfile,
886 platform,
887 location,
888 });
889 yield* session.note(`Pushed ${imageUri}`);
890 return { imageUri, repositoryName, repositoryUri, codeHash };
891 });
892
893 const hash = Effect.fn(function* (options: {
894 source: ImageSourceLike;
895 platform?: string;
896 port?: number;
897 isExternal?: boolean;
898 bootstrap: (importPath: string) => string;
899 }) {
900 const platform = options.platform ?? "linux/amd64";
901 if (isNodeProgram(options.source, options.isExternal)) {
902 const { codeHash } = yield* computeNodeProgram({
903 source: options.source as BundledImageSource,
904 port: options.port,
905 platform,
906 });
907 return codeHash;
908 }
909 if (imageSourceKind(options.source) === "main") {
910 const df = options.source.dockerfile;
911 if (
912 df !== undefined &&
913 isInlineDockerfile(df) &&
914 typeof df.content !== "string"
915 ) {
916 return undefined;
917 }
918 const { codeHash } = yield* computeMainCodeHash({
919 source: options.source as BundledImageSource,
920 isExternal: options.isExternal,
921 bootstrap: options.bootstrap,
922 port: options.port,
923 platform,
924 });
925 return codeHash;
926 }
927 return yield* computeStaticSourceHash(options.source, platform);
928 });
929
930 return {
931 resolve,
932 hash,
933 destroyRepository: destroyImageRepository,
934 resourceName,
935 };
936});
937
938export type ImageSource = Effect.Success<typeof makeImageSource>;