1import * as AWS from "@/AWS";
2import { PublicRepository } from "@/AWS/ECRPublic";
3import * as Test from "@/Test/Alchemy";
4import * as ecrpublic from "@distilled.cloud/aws/ecr-public";
5import { Region } from "@distilled.cloud/aws/Region";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// ECR Public is a global registry hosted only in us-east-1; every out-of-band
14// verification call must target that region regardless of the stack region.
15const pin = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
16 effect.pipe(Effect.provideService(Region, Effect.succeed("us-east-1")));
17
18const findRepository = (repositoryName: string) =>
19 pin(
20 ecrpublic.describeRepositories({ repositoryNames: [repositoryName] }),
21 ).pipe(
22 Effect.map((r) => r.repositories?.[0]),
23 Effect.catchTag("RepositoryNotFoundException", () =>
24 Effect.succeed(undefined),
25 ),
26 );
27
28class RepositoryStillExists extends Data.TaggedError("RepositoryStillExists")<{
29 readonly repositoryName: string;
30}> {}
31
32const assertRepositoryDeleted = (repositoryName: string) =>
33 findRepository(repositoryName).pipe(
34 Effect.flatMap((repo) =>
35 repo === undefined
36 ? Effect.void
37 : Effect.fail(new RepositoryStillExists({ repositoryName })),
38 ),
39 Effect.retry({
40 while: (e) => e._tag === "RepositoryStillExists",
41 schedule: Schedule.max([
42 Schedule.spaced("2 seconds"),
43 Schedule.recurs(15),
44 ]),
45 }),
46 );
47
48test.provider(
49 "create, update catalog data, delete public repository",
50 (stack) =>
51 Effect.gen(function* () {
52 yield* stack.destroy();
53
54 const repo = yield* stack.deploy(
55 Effect.gen(function* () {
56 return yield* PublicRepository("TestPublicRepo", {
57 catalogData: {
58 description: "initial description",
59 architectures: ["x86-64"],
60 operatingSystems: ["Linux"],
61 },
62 tags: { Environment: "test" },
63 });
64 }),
65 );
66
67 expect(repo.repositoryName).toBeDefined();
68 expect(repo.repositoryArn).toContain(":repository/");
69 expect(repo.repositoryUri).toContain("public.ecr.aws");
70
71 const created = yield* findRepository(repo.repositoryName);
72 expect(created?.repositoryArn).toBe(repo.repositoryArn);
73
74 const catalog = yield* pin(
75 ecrpublic.getRepositoryCatalogData({
76 repositoryName: repo.repositoryName,
77 }),
78 );
79 expect(catalog.catalogData?.description).toBe("initial description");
80
81 const tags = yield* pin(
82 ecrpublic.listTagsForResource({ resourceArn: repo.repositoryArn }),
83 ).pipe(
84 Effect.map((r) =>
85 Object.fromEntries((r.tags ?? []).map((t) => [t.Key, t.Value])),
86 ),
87 );
88 expect(tags.Environment).toBe("test");
89 expect(tags["alchemy::id"]).toBe("TestPublicRepo");
90
91 // Update catalog data + tags.
92 const updated = yield* stack.deploy(
93 Effect.gen(function* () {
94 return yield* PublicRepository("TestPublicRepo", {
95 catalogData: {
96 description: "updated description",
97 architectures: ["x86-64", "ARM 64"],
98 operatingSystems: ["Linux"],
99 },
100 tags: { Environment: "test", Extra: "yes" },
101 });
102 }),
103 );
104 expect(updated.repositoryArn).toBe(repo.repositoryArn);
105
106 const catalog2 = yield* pin(
107 ecrpublic.getRepositoryCatalogData({
108 repositoryName: repo.repositoryName,
109 }),
110 );
111 expect(catalog2.catalogData?.description).toBe("updated description");
112
113 const tags2 = yield* pin(
114 ecrpublic.listTagsForResource({ resourceArn: repo.repositoryArn }),
115 ).pipe(
116 Effect.map((r) =>
117 Object.fromEntries((r.tags ?? []).map((t) => [t.Key, t.Value])),
118 ),
119 );
120 expect(tags2.Extra).toBe("yes");
121
122 yield* stack.destroy();
123 yield* assertRepositoryDeleted(repo.repositoryName);
124 }),
125 {
126 tags: ["provider:aws", "provider:aws:ecrpublic", "live"],
127 timeout: 180_000,
128 },
129);