1import * as ecr from "@distilled.cloud/aws/ecr";
2import * as Effect from "effect/Effect";
3import {
4 makeContainerImageSource,
5 type ImageRegistryTarget,
6 type ImageSourceLike,
7} from "../../Docker/ImageSource.ts";
8import { getEcrRegistryCredentials } from "./Image.ts";
9
10/**
11 * INTERNAL — the ECR target for the shared container image-source machinery
12 * ([Docker/ImageSource.ts](../../Docker/ImageSource.ts)), used by the AWS
13 * container platforms (`AWS.ECS.Task`, `AWS.ECS.Service`, `AWS.EKS.*`).
14 *
15 * NOT exported from the AWS barrel or `ECR/index.ts`. Consumers import the
16 * module path directly.
17 *
18 * Every source lands in an auto-created (caller-named) private ECR
19 * repository so the compute platform pulls from a registry that is reliable
20 * from private VPCs and IAM-authenticated.
21 */
22
23export {
24 computeStaticSourceHash,
25 imageSourceKind,
26 validateImageSource,
27 type BundledImageSource,
28 type DockerfileImageSource,
29 type ImageSourceKind,
30 type ImageSourceLike,
31 type ImageSourceProps,
32 type RegistryImageSource,
33} from "../../Docker/ImageSource.ts";
34
35/** The resolved (built/mirrored + pushed) image. */
36export interface ResolvedImage {
37 /** Full image reference, `<repositoryUri>:<codeHash>`. */
38 imageUri: string;
39 /** Name of the ECR repository the image was pushed to. */
40 repositoryName: string;
41 /** URI of the ECR repository the image was pushed to. */
42 repositoryUri: string;
43 /** Content hash identifying the image (also the image tag). */
44 codeHash: string;
45}
46
47export interface ResolveImageOptions {
48 /** Logical resource id — keys the stable build-context directory. */
49 id: string;
50 /**
51 * The props bag carrying the image source fields (`main` / `context` /
52 * `image` plus their modifiers).
53 */
54 source: ImageSourceLike;
55 /** Name of the ECR repository to (auto-create and) push into. */
56 repositoryName: string;
57 /**
58 * Known repository URI (from prior Attributes). When provided, repository
59 * creation is skipped.
60 */
61 repositoryUri?: string;
62 /** Tags applied to the auto-created repository. */
63 tags?: Record<string, string>;
64 /**
65 * Target image platform.
66 * @default "linux/amd64"
67 */
68 platform?: string;
69 /**
70 * Port the generated Dockerfile should `ENV PORT=` + `EXPOSE`
71 * (`main` source only).
72 */
73 port?: number;
74 /**
75 * True when the resource was declared without an inline Effect impl —
76 * `main` is then bundled as-is without the virtual-entry bootstrap.
77 */
78 isExternal?: boolean;
79 /**
80 * The virtual-entry bootstrap wrapped around `main` for Effect-native
81 * programs: receives the resolved entry import path and returns the
82 * generated entry module source. Platform-specific (server vs one-shot
83 * differ per platform), so the caller supplies it.
84 */
85 bootstrap: (importPath: string) => string;
86 /** Plan-status session used to emit build/push progress notes. */
87 session: { note: (message: string) => Effect.Effect<void> };
88}
89
90/**
91 * The generated entry for `AWS.ECS.Task` / `AWS.ECS.Service` containers: a
92 * shim importing only `alchemy/Runtime/Bootstrap/Ecs` (resolvable from any
93 * consumer — `alchemy` is its direct dependency) plus the user's `main`.
94 * Everything the runtime needs lives in that real module, so the virtual
95 * entry never imports alchemy's own dependencies (`@distilled.cloud/*`,
96 * `@effect/platform-bun`), which an isolated install cannot resolve from
97 * the consumer's project.
98 */
99export const makeBunBootstrap =
100 (handler: string) =>
101 (importPath: string): string =>
102 `
103import { bootstrap } from "alchemy/Runtime/Bootstrap/Ecs";
104import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
105
106await bootstrap(entrypoint);
107`;
108
109/**
110 * Init-time constructor for the ECR image-source resolver: the shared
111 * container image pipeline pushing into an auto-created ECR repository.
112 */
113export const makeImageSource = Effect.gen(function* () {
114 const images = yield* makeContainerImageSource;
115
116 /**
117 * Ensure the target ECR repository exists. Idempotent: tolerates
118 * `RepositoryAlreadyExistsException` as a race / re-run and re-describes.
119 */
120 const ensureRepository = Effect.fn(function* (options: {
121 repositoryName: string;
122 tags?: Record<string, string>;
123 }) {
124 const created = yield* ecr
125 .createRepository({
126 repositoryName: options.repositoryName,
127 imageTagMutability: "MUTABLE",
128 imageScanningConfiguration: {
129 scanOnPush: true,
130 },
131 tags: Object.entries(options.tags ?? {}).map(([Key, Value]) => ({
132 Key,
133 Value,
134 })),
135 })
136 .pipe(
137 Effect.catchTag("RepositoryAlreadyExistsException", () =>
138 Effect.gen(function* () {
139 const existing = yield* ecr.describeRepositories({
140 repositoryNames: [options.repositoryName],
141 });
142 return {
143 repository: existing.repositories?.[0],
144 };
145 }),
146 ),
147 );
148 const repository = created.repository;
149 if (!repository?.repositoryUri) {
150 return yield* Effect.die(
151 new Error(
152 `Failed to resolve ECR repository '${options.repositoryName}'`,
153 ),
154 );
155 }
156 return repository.repositoryUri;
157 });
158
159 /** Observe a pushed tag in ECR. Missing repository or tag → false. */
160 const hasTag = (repositoryName: string) =>
161 Effect.fn(function* (imageTag: string) {
162 const described = yield* ecr
163 .describeImages({
164 repositoryName,
165 imageIds: [{ imageTag }],
166 })
167 .pipe(
168 Effect.catchTag(
169 ["ImageNotFoundException", "RepositoryNotFoundException"],
170 () => Effect.succeed(undefined),
171 ),
172 );
173 return described?.imageDetails?.[0] !== undefined;
174 });
175
176 /**
177 * Resolve the image for a props bag: ensure the repository, compute the
178 * content-addressed tag, then build/mirror + push only when that exact
179 * tag is not already in ECR (crash-safe convergence).
180 */
181 const resolve = Effect.fn(function* (options: ResolveImageOptions) {
182 const target = {
183 repositoryUri:
184 options.repositoryUri !== undefined
185 ? Effect.succeed(options.repositoryUri)
186 : ensureRepository({
187 repositoryName: options.repositoryName,
188 tags: options.tags,
189 }),
190 hasTag: hasTag(options.repositoryName),
191 credentials: getEcrRegistryCredentials,
192 } satisfies ImageRegistryTarget<any>;
193 const resolved = yield* images.resolve(options, target);
194 return {
195 imageUri: resolved.imageUri,
196 repositoryName: options.repositoryName,
197 repositoryUri: resolved.repositoryUri,
198 codeHash: resolved.codeHash,
199 } satisfies ResolvedImage;
200 });
201
202 return { resolve, hash: images.hash, watchMain: images.watchMain };
203});
204
205/** The resolver service returned by {@link makeImageSource}. */
206export interface ImageSource extends Effect.Success<typeof makeImageSource> {}