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 * as S3 from "@distilled.cloud/aws/s3";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const waitUntilTaskGone = (taskArn: string) =>
13 datasync.describeTask({ TaskArn: taskArn }).pipe(
14 Effect.as(false),
15 Effect.catchTag("TaskNotFound", () => Effect.succeed(true)),
16 Effect.repeat({
17 schedule: Schedule.fixed("1 seconds"),
18 until: (gone) => gone,
19 times: 20,
20 }),
21 );
22
23const trustPolicy = {
24 Version: "2012-10-17" as const,
25 Statement: [
26 {
27 Effect: "Allow" as const,
28 Principal: { Service: "datasync.amazonaws.com" },
29 Action: ["sts:AssumeRole"],
30 },
31 ],
32};
33
34const s3Policy = (arn: Output.Output<string>) => ({
35 Version: "2012-10-17" as const,
36 Statement: [
37 {
38 Effect: "Allow" as const,
39 Action: [
40 "s3:GetBucketLocation",
41 "s3:ListBucket",
42 "s3:ListBucketMultipartUploads",
43 ],
44 Resource: [arn],
45 },
46 {
47 Effect: "Allow" as const,
48 Action: [
49 "s3:AbortMultipartUpload",
50 "s3:DeleteObject",
51 "s3:GetObject",
52 "s3:GetObjectTagging",
53 "s3:ListMultipartUploadParts",
54 "s3:PutObject",
55 "s3:PutObjectTagging",
56 ],
57 Resource: [Output.interpolate`${arn}/*`],
58 },
59 ],
60});
61
62const buildStack = (options?: datasync.Options) =>
63 Effect.gen(function* () {
64 const src = yield* AWS.S3.Bucket("TaskSrc", { forceDestroy: true });
65 const dst = yield* AWS.S3.Bucket("TaskDst", { forceDestroy: true });
66 const role = yield* AWS.IAM.Role("TaskRole", {
67 assumeRolePolicyDocument: trustPolicy,
68 inlinePolicies: {
69 Src: s3Policy(src.bucketArn),
70 Dst: s3Policy(dst.bucketArn),
71 },
72 });
73 const source = yield* AWS.DataSync.LocationS3("TaskSrcLoc", {
74 s3BucketArn: src.bucketArn,
75 bucketAccessRoleArn: role.roleArn,
76 });
77 const dest = yield* AWS.DataSync.LocationS3("TaskDstLoc", {
78 s3BucketArn: dst.bucketArn,
79 bucketAccessRoleArn: role.roleArn,
80 });
81 const task = yield* AWS.DataSync.Task("BackupTask", {
82 sourceLocationArn: source.locationArn,
83 destinationLocationArn: dest.locationArn,
84 ...(options !== undefined ? { options } : {}),
85 });
86 return { task, src, dst };
87 });
88
89test.provider(
90 "typed TaskNotFound on a nonexistent task",
91 () =>
92 Effect.gen(function* () {
93 const result = yield* datasync
94 .describeTask({
95 TaskArn:
96 "arn:aws:datasync:us-west-2:391965393224:task/task-00000000000000000",
97 })
98 .pipe(Effect.result);
99 expect(result._tag).toBe("Failure");
100 if (result._tag === "Failure") {
101 const err = result.failure as { _tag: string };
102 expect(err._tag).toBe("TaskNotFound");
103 }
104 }),
105 { tags: ["provider:aws", "provider:aws:datasync", "live"], timeout: 60_000 },
106);
107
108test.provider(
109 "create S3 -> S3 task, update options, delete",
110 (stack) =>
111 Effect.gen(function* () {
112 yield* stack.destroy();
113
114 // --- create: two S3 locations + a transfer task ---
115 const created = yield* stack.deploy(buildStack());
116 expect(created.task.taskArn).toContain(":task/task-");
117 expect(created.task.sourceLocationArn).toContain(":location/loc-");
118 expect(created.task.destinationLocationArn).toContain(":location/loc-");
119 const arn = created.task.taskArn;
120
121 const observed = yield* datasync.describeTask({ TaskArn: arn });
122 expect(observed.SourceLocationArn).toBe(created.task.sourceLocationArn);
123 const tags = Object.fromEntries(
124 (
125 (yield* datasync.listTagsForResource({ ResourceArn: arn })).Tags ?? []
126 ).map((t) => [t.Key, t.Value]),
127 );
128 expect(tags["alchemy::id"]).toBe("BackupTask");
129
130 // --- update: change transfer options in place ---
131 const updated = yield* stack.deploy(
132 buildStack({ VerifyMode: "ONLY_FILES_TRANSFERRED" }),
133 );
134 expect(updated.task.taskArn).toBe(arn);
135 const observed2 = yield* datasync.describeTask({ TaskArn: arn });
136 expect(observed2.Options?.VerifyMode).toBe("ONLY_FILES_TRANSFERRED");
137
138 // --- delete ---
139 yield* stack.destroy();
140 const gone = yield* waitUntilTaskGone(arn);
141 expect(gone).toBe(true);
142 }),
143 {
144 tags: [
145 "provider:aws",
146 "provider:aws:datasync",
147 "provider:aws:iam",
148 "provider:aws:s3",
149 "live",
150 ],
151 timeout: 240_000,
152 },
153);
154
155// A live BASIC-mode task execution has 1–4 minutes of launch/prepare
156// overhead, so it is gated behind AWS_TEST_SLOW=1.
157test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
158 "live S3 -> S3 task execution transfers an object",
159 (stack) =>
160 Effect.gen(function* () {
161 yield* stack.destroy();
162
163 const created = yield* stack.deploy(
164 buildStack({ VerifyMode: "ONLY_FILES_TRANSFERRED" }),
165 );
166
167 yield* S3.putObject({
168 Bucket: created.src.bucketName,
169 Key: "hello.txt",
170 Body: "hello datasync",
171 });
172
173 // The freshly-created IAM role's permissions take a while to propagate
174 // to DataSync's location access test (typed LocationAccessTestFailed,
175 // patched from InvalidRequestException + "location access test failed").
176 const started = yield* datasync
177 .startTaskExecution({ TaskArn: created.task.taskArn })
178 .pipe(
179 Effect.retry({
180 while: (e) => e._tag === "LocationAccessTestFailed",
181 schedule: Schedule.max([
182 Schedule.fixed("10 seconds"),
183 Schedule.recurs(12),
184 ]),
185 }),
186 );
187 const finished = yield* datasync
188 .describeTaskExecution({
189 TaskExecutionArn: started.TaskExecutionArn!,
190 })
191 .pipe(
192 Effect.repeat({
193 schedule: Schedule.spaced("15 seconds"),
194 until: (e) => e.Status === "SUCCESS" || e.Status === "ERROR",
195 times: 40,
196 }),
197 );
198 if (finished.Status !== "SUCCESS") {
199 // surface the execution failure detail before the assertion trips
200 yield* Effect.log("task execution result", finished.Result);
201 }
202 expect(finished.Status).toBe("SUCCESS");
203
204 // out-of-band: the object landed in the destination bucket
205 yield* S3.headObject({
206 Bucket: created.dst.bucketName,
207 Key: "hello.txt",
208 });
209
210 yield* stack.destroy();
211 const gone = yield* waitUntilTaskGone(created.task.taskArn);
212 expect(gone).toBe(true);
213 }),
214 {
215 tags: [
216 "provider:aws",
217 "provider:aws:datasync",
218 "provider:aws:iam",
219 "provider:aws:s3",
220 "live",
221 ],
222 timeout: 900_000,
223 },
224);