1import * as datasync from "@distilled.cloud/aws/datasync";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags } from "../../Tags.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 findLocationArnByUri,
11 readObservedTags,
12 retryWhileRoleNotAssumable,
13 syncTags,
14} from "./internal.ts";
15
16export interface LocationS3Props {
17 /**
18 * ARN of the S3 bucket this location points at, e.g.
19 * `arn:aws:s3:::my-bucket`. Cannot be changed after creation (replacement).
20 */
21 s3BucketArn: string;
22 /**
23 * ARN of the IAM role DataSync assumes to access the bucket. The role's
24 * trust policy must allow `datasync.amazonaws.com` and grant the relevant
25 * S3 permissions. Cannot be changed after creation (replacement).
26 */
27 bucketAccessRoleArn: string;
28 /**
29 * Prefix within the bucket to sync, e.g. `/data`. Cannot be changed after
30 * creation (replacement).
31 * @default "/" (the bucket root)
32 */
33 subdirectory?: string;
34 /**
35 * S3 storage class DataSync writes objects as when this location is a
36 * transfer destination. Cannot be changed after creation (replacement).
37 * @default "STANDARD"
38 */
39 s3StorageClass?: datasync.S3StorageClass;
40 /**
41 * ARNs of DataSync agents used to connect to an S3-on-Outposts bucket.
42 * Omit for standard S3 buckets.
43 */
44 agentArns?: string[];
45 /**
46 * Tags to apply to the location. Merged with internal Alchemy tags.
47 */
48 tags?: Record<string, string>;
49}
50
51export interface LocationS3 extends Resource<
52 "AWS.DataSync.LocationS3",
53 LocationS3Props,
54 {
55 /** ARN of the DataSync location. */
56 locationArn: string;
57 /** URI of the location (`s3://…`). */
58 locationUri: string;
59 },
60 {},
61 Providers
62> {}
63
64/**
65 * A DataSync location backed by an Amazon S3 bucket. Locations are the
66 * source and destination endpoints referenced by a {@link Task}.
67 *
68 * S3 locations are immutable apart from their tags: any change to the
69 * bucket, subdirectory, storage class, or access role replaces the location.
70 * Reconcile is idempotent across state loss — the location is re-discovered
71 * by its deterministic `s3://…` URI.
72 *
73 * ### Creating S3 Locations
74 * **Example:** Bucket root
75 * ```typescript
76 * import * as AWS from "alchemy/AWS";
77 *
78 * const source = yield* AWS.DataSync.LocationS3("Source", {
79 * s3BucketArn: bucket.bucketArn,
80 * bucketAccessRoleArn: role.roleArn,
81 * });
82 * ```
83 *
84 * **Example:** Prefix + storage class
85 * ```typescript
86 * const dest = yield* AWS.DataSync.LocationS3("Dest", {
87 * s3BucketArn: bucket.bucketArn,
88 * bucketAccessRoleArn: role.roleArn,
89 * subdirectory: "/archive",
90 * s3StorageClass: "STANDARD_IA",
91 * });
92 * ```
93 *
94 * @resource
95 */
96export const LocationS3 = Resource<LocationS3>("AWS.DataSync.LocationS3");
97
98const bucketNameOf = (bucketArn: string) => bucketArn.split(":::")[1] ?? "";
99
100const expectedUriOf = (props: LocationS3Props): string => {
101 const name = bucketNameOf(props.s3BucketArn);
102 const sub = props.subdirectory ?? "/";
103 const normalized = sub.startsWith("/") ? sub : `/${sub}`;
104 return `s3://${name}${normalized}`;
105};
106
107export const LocationS3Provider = () =>
108 Provider.effect(
109 LocationS3,
110 Effect.gen(function* () {
111 const describe = Effect.fn(function* (locationArn: string) {
112 return yield* datasync
113 .describeLocationS3({ LocationArn: locationArn })
114 .pipe(
115 Effect.catchTag("LocationNotFound", () =>
116 Effect.succeed(undefined),
117 ),
118 );
119 });
120
121 return LocationS3.Provider.of({
122 stables: ["locationArn", "locationUri"],
123
124 list: () =>
125 datasync.listLocations.pages({}).pipe(
126 Stream.runCollect,
127 Effect.map((chunk) =>
128 Array.from(chunk)
129 .flatMap((p) => p.Locations ?? [])
130 .filter((l) => l.LocationUri?.startsWith("s3://"))
131 .map((l) => ({
132 locationArn: l.LocationArn!,
133 locationUri: l.LocationUri!,
134 })),
135 ),
136 ),
137
138 read: Effect.fn(function* ({ olds, output }) {
139 const arn =
140 output?.locationArn ??
141 (yield* findLocationArnByUri(expectedUriOf(olds)));
142 if (arn === undefined) return undefined;
143 const loc = yield* describe(arn);
144 if (loc === undefined) return undefined;
145 return {
146 locationArn: loc.LocationArn!,
147 locationUri: loc.LocationUri!,
148 };
149 }),
150
151 diff: Effect.fn(function* ({ news, olds }) {
152 if (!isResolved(news)) return undefined;
153 const replaced =
154 news.s3BucketArn !== olds.s3BucketArn ||
155 news.bucketAccessRoleArn !== olds.bucketAccessRoleArn ||
156 (news.subdirectory ?? "/") !== (olds.subdirectory ?? "/") ||
157 (news.s3StorageClass ?? "STANDARD") !==
158 (olds.s3StorageClass ?? "STANDARD");
159 if (replaced) return { action: "replace" } as const;
160 }),
161
162 reconcile: Effect.fn(function* ({ id, news, output, session }) {
163 const internalTags = yield* createInternalTags(id);
164 const desiredTags = { ...news.tags, ...internalTags };
165
166 // 1. OBSERVE — output cache, else re-discover by deterministic URI.
167 let arn =
168 output?.locationArn ??
169 (yield* findLocationArnByUri(expectedUriOf(news)));
170 let loc = arn ? yield* describe(arn) : undefined;
171
172 // 2. ENSURE — create if missing.
173 if (loc === undefined) {
174 const created = yield* retryWhileRoleNotAssumable(
175 datasync.createLocationS3({
176 S3BucketArn: news.s3BucketArn,
177 S3Config: { BucketAccessRoleArn: news.bucketAccessRoleArn },
178 Subdirectory: news.subdirectory,
179 S3StorageClass: news.s3StorageClass,
180 AgentArns: news.agentArns,
181 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
182 Key,
183 Value,
184 })),
185 }),
186 );
187 arn = created.LocationArn!;
188 loc = yield* describe(arn);
189 }
190
191 // 3. SYNC tags against observed cloud state.
192 const observed = yield* readObservedTags(arn!);
193 yield* syncTags(arn!, observed, desiredTags);
194
195 yield* session.note(arn!);
196 return {
197 locationArn: loc!.LocationArn!,
198 locationUri: loc!.LocationUri!,
199 };
200 }),
201
202 delete: Effect.fn(function* ({ output }) {
203 yield* datasync
204 .deleteLocation({ LocationArn: output.locationArn })
205 .pipe(Effect.catchTag("LocationNotFound", () => Effect.void));
206 }),
207 });
208 }),
209 );