1import * as AWS from "@/AWS";
2import { Image, type ImageProps } from "@/AWS/ECR/Image.ts";
3import { Repository } from "@/AWS/ECR/Repository.ts";
4import * as Test from "@/Test/Alchemy";
5import * as ecr from "@distilled.cloud/aws/ecr";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Checked-in build fixture: `FROM scratch` + one COPY'd file, so the build is
16// fully deterministic and never fetches a base image over the network.
17const fixtureDir = `${import.meta.dirname}/fixtures/image`;
18
19test.provider(
20 "PR 1591: build, push, relocate without update, rebuild on content change, destroy",
21 (stack) =>
22 Effect.gen(function* () {
23 const fs = yield* FileSystem.FileSystem;
24 const path = yield* Path.Path;
25 yield* stack.destroy();
26
27 // Keep both relocation and content edits outside the checked-in fixture.
28 const root = yield* fs.makeTempDirectoryScoped({
29 prefix: "alchemy-ecr-image-1591-",
30 });
31 const context = path.join(root, "original");
32 const relocated = path.join(root, "relocated");
33 yield* fs.copy(fixtureDir, context);
34
35 const program = (context: string) =>
36 Effect.gen(function* () {
37 const repository = yield* Repository("ImageRepository", {});
38 return yield* Image("Image", {
39 repositoryUri: repository.repositoryUri,
40 context,
41 });
42 });
43
44 const first = yield* stack.deploy(program(context));
45 expect(first.ownsRepository).toBe(false);
46 expect(first.digest).toMatch(/^sha256:/);
47 expect(first.imageUri).toBe(`${first.repositoryUri}:${first.imageTag}`);
48
49 // Out-of-band: the manifest exists in ECR with the reported digest.
50 const described = yield* ecr.describeImages({
51 repositoryName: first.repositoryName,
52 imageIds: [{ imageTag: first.imageTag }],
53 });
54 expect(described.imageDetails?.[0]?.imageDigest).toBe(first.digest);
55 expect(described.imageDetails?.[0]?.imagePushedAt).toBeDefined();
56
57 const unchangedPlan = yield* stack.plan(program(context));
58 expect(unchangedPlan.resources["Image"]).toMatchObject({
59 action: "noop",
60 });
61 const second = yield* stack.deploy(program(context));
62 expect(second.imageTag).toBe(first.imageTag);
63 expect(second.digest).toBe(first.digest);
64
65 // Renaming preserves the bytes and permissions used by the content hash.
66 yield* fs.rename(context, relocated);
67 const relocatedPlan = yield* stack.plan(program(relocated));
68 expect(relocatedPlan.resources["Image"]).toMatchObject({
69 action: "noop",
70 });
71 const moved = yield* stack.deploy(program(relocated));
72 expect(moved.repositoryUri).toBe(first.repositoryUri);
73 expect(moved.imageUri).toBe(first.imageUri);
74 expect(moved.imageTag).toBe(first.imageTag);
75 expect(moved.digest).toBe(first.digest);
76 const movedDescription = yield* ecr.describeImages({
77 repositoryName: moved.repositoryName,
78 imageIds: [{ imageTag: moved.imageTag }],
79 });
80 expect(movedDescription.imageDetails?.[0]?.imageDigest).toBe(
81 first.digest,
82 );
83 expect(movedDescription.imageDetails?.[0]?.imagePushedAt).toEqual(
84 described.imageDetails?.[0]?.imagePushedAt,
85 );
86
87 const deleted = yield* ecr.batchDeleteImage({
88 repositoryName: moved.repositoryName,
89 imageIds: [{ imageTag: moved.imageTag }],
90 });
91 expect(deleted.failures ?? []).toEqual([]);
92 yield* assertImageDeleted(moved.repositoryName, moved.imageTag);
93 expect(
94 (yield* stack.plan(program(relocated))).resources.Image?.action,
95 ).toBe("update");
96 const repaired = yield* stack.deploy(program(relocated));
97 expect(repaired.imageTag).toBe(first.imageTag);
98 expect(repaired.digest).toBe(first.digest);
99 expect(
100 (yield* stack.plan(program(relocated))).resources.Image?.action,
101 ).toBe("noop");
102
103 // Changed bytes at the relocated path must still schedule a rebuild.
104 yield* fs.writeFileString(
105 path.join(relocated, "hello.txt"),
106 "hello again from alchemy\n",
107 );
108 const changedPlan = yield* stack.plan(program(relocated));
109 expect(changedPlan.resources["Image"]).toMatchObject({
110 action: "update",
111 });
112 const third = yield* stack.deploy(program(relocated));
113 expect(third.repositoryUri).toBe(first.repositoryUri);
114 expect(third.imageTag).not.toBe(first.imageTag);
115 expect(third.digest).not.toBe(first.digest);
116 const redescribed = yield* ecr.describeImages({
117 repositoryName: third.repositoryName,
118 imageIds: [{ imageTag: third.imageTag }],
119 });
120 expect(redescribed.imageDetails?.[0]?.imageDigest).toBe(third.digest);
121
122 yield* stack.destroy();
123
124 // The Repository owns the repo and force-deletes it (taking every
125 // image tag with it).
126 yield* assertRepositoryDeleted(first.repositoryName);
127 }).pipe(Effect.scoped),
128 { tags: ["provider:aws", "provider:aws:ecr", "live"], timeout: 120_000 },
129);
130
131test.provider(
132 "auto-creates and owns a repository when none is given",
133 (stack) =>
134 Effect.gen(function* () {
135 yield* stack.destroy();
136
137 const image = yield* stack.deploy(
138 Effect.gen(function* () {
139 return yield* Image("OwnedImage", {
140 context: fixtureDir,
141 });
142 }),
143 );
144
145 expect(image.ownsRepository).toBe(true);
146 expect(image.digest).toMatch(/^sha256:/);
147
148 // Out-of-band: the auto-created repository holds the pushed manifest.
149 const described = yield* ecr.describeImages({
150 repositoryName: image.repositoryName,
151 imageIds: [{ imageTag: image.imageTag }],
152 });
153 expect(described.imageDetails?.[0]?.imageDigest).toBe(image.digest);
154
155 const ownershipPlan = yield* stack.plan(
156 Image("OwnedImage", {
157 context: fixtureDir,
158 repositoryUri: image.repositoryUri,
159 }),
160 );
161 expect(ownershipPlan.resources.OwnedImage?.action).toBe("update");
162
163 yield* ecr.deleteRepository({
164 repositoryName: image.repositoryName,
165 force: true,
166 });
167 yield* assertRepositoryDeleted(image.repositoryName);
168 const original = Image("OwnedImage", { context: fixtureDir });
169 expect((yield* stack.plan(original)).resources.OwnedImage?.action).toBe(
170 "update",
171 );
172 const restored = yield* stack.deploy(original);
173 expect(restored.repositoryUri).toBe(image.repositoryUri);
174 expect(restored.imageTag).toBe(image.imageTag);
175 expect(restored.ownsRepository).toBe(true);
176 expect(
177 (yield* ecr.describeImages({
178 repositoryName: restored.repositoryName,
179 imageIds: [{ imageTag: restored.imageTag }],
180 })).imageDetails?.[0]?.imageDigest,
181 ).toBe(restored.digest);
182
183 yield* stack.destroy();
184
185 // Destroying an owning Image force-deletes its repository.
186 yield* assertRepositoryDeleted(image.repositoryName);
187 }),
188 { tags: ["provider:aws", "provider:aws:ecr", "live"], timeout: 120_000 },
189);
190
191test.provider(
192 "build arguments, platform, and repository changes still update the image",
193 (stack) =>
194 Effect.gen(function* () {
195 yield* stack.destroy();
196 const path = yield* Path.Path;
197 const program = (
198 options: Pick<ImageProps, "buildArgs" | "platform" | "dockerfile">,
199 alternate = false,
200 ) =>
201 Effect.gen(function* () {
202 const primary = yield* Repository("Primary", {});
203 const secondary = yield* Repository("Secondary", {});
204 return yield* Image("ConfiguredImage", {
205 context: fixtureDir,
206 repositoryUri: alternate
207 ? secondary.repositoryUri
208 : primary.repositoryUri,
209 ...options,
210 });
211 });
212 const firstOptions = { buildArgs: { BUILD_LABEL: "first" } };
213 const first = yield* stack.deploy(program(firstOptions));
214 expect(
215 (yield* stack.plan(
216 program({
217 ...firstOptions,
218 dockerfile: path.join(fixtureDir, "Dockerfile"),
219 platform: "linux/amd64",
220 }),
221 )).resources.ConfiguredImage?.action,
222 ).toBe("noop");
223
224 const secondOptions = { buildArgs: { BUILD_LABEL: "second" } };
225 expect(
226 (yield* stack.plan(program(secondOptions))).resources.ConfiguredImage
227 ?.action,
228 ).toBe("update");
229 const second = yield* stack.deploy(program(secondOptions));
230 expect(second.imageTag).not.toBe(first.imageTag);
231 expect(second.digest).not.toBe(first.digest);
232
233 const armOptions = { ...secondOptions, platform: "linux/arm64" };
234 expect(
235 (yield* stack.plan(program(armOptions))).resources.ConfiguredImage
236 ?.action,
237 ).toBe("update");
238 const arm = yield* stack.deploy(program(armOptions));
239 expect(arm.imageTag).not.toBe(second.imageTag);
240 expect(arm.digest).not.toBe(second.digest);
241
242 expect(
243 (yield* stack.plan(program(armOptions, true))).resources.ConfiguredImage
244 ?.action,
245 ).toBe("update");
246 const moved = yield* stack.deploy(program(armOptions, true));
247 expect(moved.repositoryUri).not.toBe(arm.repositoryUri);
248 expect(moved.imageTag).toBe(arm.imageTag);
249 expect(moved.ownsRepository).toBe(false);
250 expect(
251 (yield* ecr.describeImages({
252 repositoryName: moved.repositoryName,
253 imageIds: [{ imageTag: moved.imageTag }],
254 })).imageDetails?.[0]?.imageDigest,
255 ).toBe(moved.digest);
256 expect(
257 (yield* stack.plan(program(armOptions, true))).resources.ConfiguredImage
258 ?.action,
259 ).toBe("noop");
260
261 yield* stack.destroy();
262 yield* assertRepositoryDeleted(first.repositoryName);
263 yield* assertRepositoryDeleted(moved.repositoryName);
264 }),
265 { tags: ["provider:aws", "provider:aws:ecr", "live"], timeout: 120_000 },
266);
267
268class ImageStillExists extends Data.TaggedError("ImageStillExists") {}
269
270const assertImageDeleted = Effect.fn(function* (
271 repositoryName: string,
272 imageTag: string,
273) {
274 yield* ecr.describeImages({ repositoryName, imageIds: [{ imageTag }] }).pipe(
275 Effect.flatMap(() => Effect.fail(new ImageStillExists())),
276 Effect.retry({
277 while: (error) => error._tag === "ImageStillExists",
278 schedule: Schedule.spaced("1 second"),
279 times: 8,
280 }),
281 Effect.catchTag("ImageNotFoundException", () => Effect.void),
282 );
283});
284
285class RepositoryStillExists extends Data.TaggedError("RepositoryStillExists") {}
286
287const assertRepositoryDeleted = Effect.fn(function* (repositoryName: string) {
288 yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe(
289 Effect.flatMap(() => Effect.fail(new RepositoryStillExists())),
290 Effect.retry({
291 while: (e) => e._tag === "RepositoryStillExists",
292 schedule: Schedule.spaced("1 second"),
293 times: 8,
294 }),
295 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
296 );
297});