1import * as AWS from "@/AWS";
2import * as Output from "@/Output";
3import * as Test from "@/Test/Alchemy";
4import * as datasync from "@distilled.cloud/aws/datasync";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11const dsTags = (tags: readonly datasync.TagListEntry[] | undefined) =>
12 Object.fromEntries((tags ?? []).map((t) => [t.Key, t.Value]));
13
14// DataSync surfaces a deleted location as the typed LocationNotFound (patched
15// from InvalidRequestException + "…is not found").
16const waitUntilLocationGone = (locationArn: string) =>
17 datasync.describeLocationS3({ LocationArn: locationArn }).pipe(
18 Effect.as(false),
19 Effect.catchTag("LocationNotFound", () => Effect.succeed(true)),
20 Effect.repeat({
21 schedule: Schedule.fixed("1 seconds"),
22 until: (gone) => gone,
23 times: 20,
24 }),
25 );
26
27const trustPolicy = {
28 Version: "2012-10-17" as const,
29 Statement: [
30 {
31 Effect: "Allow" as const,
32 Principal: { Service: "datasync.amazonaws.com" },
33 Action: ["sts:AssumeRole"],
34 },
35 ],
36};
37
38test.provider(
39 "typed LocationNotFound on a nonexistent S3 location",
40 () =>
41 Effect.gen(function* () {
42 const result = yield* datasync
43 .describeLocationS3({
44 LocationArn:
45 "arn:aws:datasync:us-west-2:391965393224:location/loc-00000000000000000",
46 })
47 .pipe(Effect.result);
48 expect(result._tag).toBe("Failure");
49 if (result._tag === "Failure") {
50 const err = result.failure as { _tag: string };
51 expect(err._tag).toBe("LocationNotFound");
52 }
53 }),
54 { tags: ["provider:aws", "provider:aws:datasync", "live"], timeout: 60_000 },
55);
56
57test.provider(
58 "create, update tags, list, delete an S3 location",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 // --- create: bucket + access role + location ---
64 const created = yield* stack.deploy(
65 Effect.gen(function* () {
66 const bucket = yield* AWS.S3.Bucket("DsBucket", {
67 forceDestroy: true,
68 });
69 const role = yield* AWS.IAM.Role("DsRole", {
70 assumeRolePolicyDocument: trustPolicy,
71 inlinePolicies: {
72 DataSyncS3: {
73 Version: "2012-10-17",
74 Statement: [
75 {
76 Effect: "Allow",
77 Action: [
78 "s3:GetBucketLocation",
79 "s3:ListBucket",
80 "s3:ListBucketMultipartUploads",
81 ],
82 Resource: [bucket.bucketArn],
83 },
84 {
85 Effect: "Allow",
86 Action: [
87 "s3:AbortMultipartUpload",
88 "s3:DeleteObject",
89 "s3:GetObject",
90 "s3:ListMultipartUploadParts",
91 "s3:PutObject",
92 ],
93 Resource: [Output.interpolate`${bucket.bucketArn}/*`],
94 },
95 ],
96 },
97 },
98 });
99 const location = yield* AWS.DataSync.LocationS3("Loc", {
100 s3BucketArn: bucket.bucketArn,
101 bucketAccessRoleArn: role.roleArn,
102 subdirectory: "/data",
103 tags: { purpose: "alchemy-datasync-test" },
104 });
105 return { location };
106 }),
107 );
108 expect(created.location.locationArn).toContain(":location/loc-");
109 expect(created.location.locationUri).toContain("s3://");
110 expect(created.location.locationUri).toContain("/data");
111 const arn = created.location.locationArn;
112
113 const observed = yield* datasync.describeLocationS3({ LocationArn: arn });
114 expect(observed.S3Config?.BucketAccessRoleArn).toContain(":role/");
115
116 const tags = dsTags(
117 (yield* datasync.listTagsForResource({ ResourceArn: arn })).Tags,
118 );
119 expect(tags.purpose).toBe("alchemy-datasync-test");
120 expect(tags["alchemy::id"]).toBe("Loc");
121
122 // --- list: the S3 location is enumerable ---
123 const listed = yield* datasync.listLocations({});
124 expect((listed.Locations ?? []).some((l) => l.LocationArn === arn)).toBe(
125 true,
126 );
127
128 // --- update: change tags in place (location itself is immutable) ---
129 const updated = yield* stack.deploy(
130 Effect.gen(function* () {
131 const bucket = yield* AWS.S3.Bucket("DsBucket", {
132 forceDestroy: true,
133 });
134 const role = yield* AWS.IAM.Role("DsRole", {
135 assumeRolePolicyDocument: trustPolicy,
136 inlinePolicies: {
137 DataSyncS3: {
138 Version: "2012-10-17",
139 Statement: [
140 {
141 Effect: "Allow",
142 Action: ["s3:GetBucketLocation", "s3:ListBucket"],
143 Resource: [bucket.bucketArn],
144 },
145 ],
146 },
147 },
148 });
149 const location = yield* AWS.DataSync.LocationS3("Loc", {
150 s3BucketArn: bucket.bucketArn,
151 bucketAccessRoleArn: role.roleArn,
152 subdirectory: "/data",
153 tags: { purpose: "alchemy-datasync-test", stage: "prod" },
154 });
155 return { location };
156 }),
157 );
158 // immutable identity is preserved across a tag-only update
159 expect(updated.location.locationArn).toBe(arn);
160 const tags2 = dsTags(
161 (yield* datasync.listTagsForResource({ ResourceArn: arn })).Tags,
162 );
163 expect(tags2.stage).toBe("prod");
164
165 // --- delete ---
166 yield* stack.destroy();
167 const gone = yield* waitUntilLocationGone(arn);
168 expect(gone).toBe(true);
169 }),
170 {
171 tags: [
172 "provider:aws",
173 "provider:aws:datasync",
174 "provider:aws:iam",
175 "provider:aws:s3",
176 "live",
177 ],
178 timeout: 240_000,
179 },
180);