| 1 | import * as datasync from "@distilled.cloud/aws/datasync"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { createInternalTags } from "../../Tags.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { |
| 10 | findLocationArnByUri, |
| 11 | readObservedTags, |
| 12 | retryWhileRoleNotAssumable, |
| 13 | syncTags, |
| 14 | } from "./internal.ts"; |
| 15 | |
| 16 | export 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 | |
| 51 | export 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 | */ |
| 96 | export const LocationS3 = Resource<LocationS3>("AWS.DataSync.LocationS3"); |
| 97 | |
| 98 | const bucketNameOf = (bucketArn: string) => bucketArn.split(":::")[1] ?? ""; |
| 99 | |
| 100 | const 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 | |
| 107 | export 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 | ); |