1import * as glacier from "@distilled.cloud/aws/glacier";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14// Glacier's REST API takes the AWS account ID as a path segment; `-` means
15// "the account that signed the request".
16const ACCOUNT = "-";
17
18export interface VaultNotificationConfigProps {
19 /**
20 * ARN of the SNS topic that Amazon S3 Glacier publishes job-completion
21 * notifications to.
22 */
23 snsTopic: string;
24 /**
25 * Job-completion events to publish. Valid values are
26 * `ArchiveRetrievalCompleted` and `InventoryRetrievalCompleted`.
27 */
28 events: string[];
29}
30
31export interface VaultProps {
32 /**
33 * Name of the vault. 1-255 characters; allowed characters are a-z, A-Z,
34 * 0-9, `_` (underscore), `-` (hyphen), and `.` (period). Changing the
35 * name replaces the vault.
36 * @default ${app}-${stage}-${id}
37 */
38 vaultName?: string;
39 /**
40 * Notification configuration publishing job-completion events to an SNS
41 * topic. Removing the prop deletes the vault's notification
42 * configuration.
43 */
44 notificationConfig?: VaultNotificationConfigProps;
45 /**
46 * Vault access policy (a resource-based IAM policy document), provided
47 * as a JSON string or a plain object. Removing the prop deletes the
48 * vault's access policy.
49 */
50 accessPolicy?: string | Record<string, any>;
51 /**
52 * Vault lock policy (an IAM policy document enforcing compliance
53 * controls), provided as a JSON string or a plain object. Setting it
54 * initiates the vault lock, leaving it in the `InProgress` state — the
55 * policy is in effect but can still be changed or removed for 24 hours.
56 * Alchemy never calls `CompleteVaultLock`, so the lock is never made
57 * immutable by this resource. Removing the prop aborts an in-progress
58 * lock.
59 */
60 lockPolicy?: string | Record<string, any>;
61 /**
62 * Tags to apply to the vault (up to 10). Merged with internal Alchemy
63 * tags.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface Vault extends Resource<
69 "AWS.Glacier.Vault",
70 VaultProps,
71 {
72 /** The name of the vault. */
73 vaultName: string;
74 /** The ARN of the vault. */
75 vaultArn: string;
76 /** ISO-8601 timestamp of when the vault was created. */
77 creationDate: string;
78 },
79 never,
80 Providers
81> {}
82
83/**
84 * An Amazon S3 Glacier vault — a container for archives in the original
85 * (vault-based) S3 Glacier service.
86 *
87 * Vault creation is idempotent and free; storage is billed per archive.
88 * A vault must be empty to be deleted, which is always the case for vaults
89 * that only ever held configuration.
90 * ### Creating Vaults
91 * **Example:** Basic Vault
92 * ```typescript
93 * import * as Glacier from "alchemy/AWS/Glacier";
94 *
95 * const vault = yield* Glacier.Vault("Backups");
96 * ```
97 *
98 * **Example:** Vault with Tags
99 * ```typescript
100 * const vault = yield* Glacier.Vault("Backups", {
101 * tags: { team: "storage" },
102 * });
103 * ```
104 *
105 * ### Notifications
106 * **Example:** Publish job-completion events to SNS
107 * ```typescript
108 * const topic = yield* SNS.Topic("VaultEvents");
109 * const vault = yield* Glacier.Vault("Backups", {
110 * notificationConfig: {
111 * snsTopic: topic.topicArn,
112 * events: ["ArchiveRetrievalCompleted", "InventoryRetrievalCompleted"],
113 * },
114 * });
115 * ```
116 *
117 * ### Access Control
118 * **Example:** Vault access policy
119 * ```typescript
120 * const vault = yield* Glacier.Vault("Backups", {
121 * accessPolicy: {
122 * Version: "2012-10-17",
123 * Statement: [{
124 * Sid: "deny-archive-deletes",
125 * Effect: "Deny",
126 * Principal: "*",
127 * Action: ["glacier:DeleteArchive"],
128 * Resource: ["arn:aws:glacier:us-east-1:123456789012:vaults/backups"],
129 * }],
130 * },
131 * });
132 * ```
133 *
134 * **Example:** Vault lock policy (left in-progress, never completed)
135 * ```typescript
136 * const vault = yield* Glacier.Vault("Compliance", {
137 * lockPolicy: {
138 * Version: "2012-10-17",
139 * Statement: [{
140 * Sid: "deny-archive-deletes",
141 * Effect: "Deny",
142 * Principal: "*",
143 * Action: ["glacier:DeleteArchive"],
144 * Resource: ["arn:aws:glacier:us-east-1:123456789012:vaults/compliance"],
145 * }],
146 * },
147 * });
148 * ```
149 *
150 * @resource
151 */
152export const Vault = Resource<Vault>("AWS.Glacier.Vault");
153
154/**
155 * Raised when the desired `lockPolicy` differs from (or removes) a vault
156 * lock that is already in the `Locked` state. A locked vault lock policy is
157 * immutable — it can never be changed or removed.
158 */
159export class GlacierVaultLockImmutable extends Data.TaggedError(
160 "GlacierVaultLockImmutable",
161)<{ message: string }> {}
162
163/**
164 * Raised when DescribeVault returns a vault record missing its ARN or
165 * creation date — never expected from the live API.
166 */
167export class GlacierVaultIncomplete extends Data.TaggedError(
168 "GlacierVaultIncomplete",
169)<{ message: string }> {}
170
171// Explicitly-typed pipeable retry helper. Inlining `Effect.retry` in a
172// provider lifecycle op leaks `Retry.Return`'s conditional into declaration
173// emit and widens the provider layer to `unknown` R for every consumer of
174// `AWS.providers()`.
175const retryWhileVaultNotFound = <A, E extends { readonly _tag: string }, R>(
176 self: Effect.Effect<A, E, R>,
177): Effect.Effect<A, E, R> =>
178 Effect.retry(self, {
179 while: (e) => e._tag === "ResourceNotFoundException",
180 schedule: Schedule.max([Schedule.fixed(1000), Schedule.recurs(10)]),
181 });
182
183const toPolicyString = (
184 policy: string | Record<string, any> | undefined,
185): string | undefined =>
186 policy === undefined
187 ? undefined
188 : typeof policy === "string"
189 ? policy
190 : JSON.stringify(policy);
191
192// Structural comparison for policy documents: AWS may re-serialize the
193// stored policy, so compare parsed shapes rather than raw strings. Falls
194// back to raw string equality if either side is not valid JSON.
195const samePolicy = (left: string, right: string): boolean => {
196 if (left === right) return true;
197 try {
198 return (
199 JSON.stringify(JSON.parse(left)) === JSON.stringify(JSON.parse(right))
200 );
201 } catch {
202 return false;
203 }
204};
205
206const sameStringSet = (
207 left: readonly string[],
208 right: readonly string[],
209): boolean => {
210 if (left.length !== right.length) return false;
211 const sortedLeft = [...left].sort();
212 const sortedRight = [...right].sort();
213 return sortedLeft.every((value, i) => value === sortedRight[i]);
214};
215
216export const VaultProvider = () =>
217 Provider.effect(
218 Vault,
219 Effect.gen(function* () {
220 const createVaultName = Effect.fn(function* (
221 id: string,
222 props: VaultProps,
223 ) {
224 return (
225 props.vaultName ?? (yield* createPhysicalName({ id, maxLength: 255 }))
226 );
227 });
228
229 const describeVault = (vaultName: string) =>
230 glacier
231 .describeVault({ accountId: ACCOUNT, vaultName })
232 .pipe(
233 Effect.catchTag("ResourceNotFoundException", () =>
234 Effect.succeed(undefined),
235 ),
236 );
237
238 const readVaultTags = (vaultName: string) =>
239 glacier.listTagsForVault({ accountId: ACCOUNT, vaultName }).pipe(
240 Effect.map((r) => (r.Tags ?? {}) as Record<string, string>),
241 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
242 );
243
244 return Vault.Provider.of({
245 stables: ["vaultName", "vaultArn", "creationDate"],
246
247 // Enumerate every vault in the ambient account/region. Vaults whose
248 // list entry is missing identifying fields (never expected from the
249 // live API) are dropped.
250 list: () =>
251 Effect.gen(function* () {
252 const pages = yield* glacier.listVaults
253 .pages({ accountId: ACCOUNT })
254 .pipe(Stream.runCollect);
255 return Array.from(pages)
256 .flatMap((page) => page.VaultList ?? [])
257 .flatMap((vault) =>
258 vault.VaultName !== undefined &&
259 vault.VaultARN !== undefined &&
260 vault.CreationDate !== undefined
261 ? [
262 {
263 vaultName: vault.VaultName,
264 vaultArn: vault.VaultARN,
265 creationDate: vault.CreationDate,
266 },
267 ]
268 : [],
269 );
270 }),
271
272 read: Effect.fn(function* ({ id, olds, output }) {
273 const vaultName =
274 output?.vaultName ?? (yield* createVaultName(id, olds ?? {}));
275 const found = yield* describeVault(vaultName);
276 if (found?.VaultARN === undefined) return undefined;
277 const attrs = {
278 vaultName,
279 vaultArn: found.VaultARN,
280 creationDate: found.CreationDate ?? "",
281 };
282 const tags = yield* readVaultTags(vaultName);
283 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
284 }),
285
286 diff: Effect.fn(function* ({ id, news, olds }) {
287 if (!isResolved(news)) return undefined;
288 const oldName = yield* createVaultName(id, olds ?? {});
289 const newName = yield* createVaultName(id, news);
290 if (oldName !== newName) {
291 return { action: "replace" } as const;
292 }
293 // undefined → default update path (reconcile syncs the rest)
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output, session }) {
297 const props = news ?? {};
298 const vaultName =
299 output?.vaultName ?? (yield* createVaultName(id, props));
300 const internalTags = yield* createInternalTags(id);
301
302 // 1. OBSERVE — cloud state is authoritative; output is only a
303 // name cache.
304 let live = yield* describeVault(vaultName);
305
306 // 2. ENSURE — CreateVault is an idempotent PUT; re-observe for the
307 // full record (create only returns a Location header). A brief
308 // bounded retry covers read-after-create consistency.
309 if (live === undefined) {
310 yield* glacier.createVault({ accountId: ACCOUNT, vaultName });
311 live = yield* glacier
312 .describeVault({ accountId: ACCOUNT, vaultName })
313 .pipe(retryWhileVaultNotFound);
314 }
315 if (live.VaultARN === undefined || live.CreationDate === undefined) {
316 return yield* Effect.fail(
317 new GlacierVaultIncomplete({
318 message: `DescribeVault for '${vaultName}' returned no VaultARN/CreationDate`,
319 }),
320 );
321 }
322
323 // 3a. SYNC notification configuration — observed vs desired.
324 const observedNotifications = yield* glacier
325 .getVaultNotifications({ accountId: ACCOUNT, vaultName })
326 .pipe(
327 Effect.map((r) => r.vaultNotificationConfig),
328 Effect.catchTag("ResourceNotFoundException", () =>
329 Effect.succeed(undefined),
330 ),
331 );
332 const desiredNotifications = props.notificationConfig;
333 if (desiredNotifications !== undefined) {
334 const inSync =
335 observedNotifications !== undefined &&
336 observedNotifications.SNSTopic ===
337 desiredNotifications.snsTopic &&
338 sameStringSet(
339 observedNotifications.Events ?? [],
340 desiredNotifications.events,
341 );
342 if (!inSync) {
343 yield* glacier.setVaultNotifications({
344 accountId: ACCOUNT,
345 vaultName,
346 vaultNotificationConfig: {
347 SNSTopic: desiredNotifications.snsTopic,
348 Events: desiredNotifications.events,
349 },
350 });
351 }
352 } else if (observedNotifications !== undefined) {
353 yield* glacier
354 .deleteVaultNotifications({ accountId: ACCOUNT, vaultName })
355 .pipe(
356 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
357 );
358 }
359
360 // 3b. SYNC access policy — observed vs desired.
361 const observedPolicy = yield* glacier
362 .getVaultAccessPolicy({ accountId: ACCOUNT, vaultName })
363 .pipe(
364 Effect.map((r) => r.policy?.Policy),
365 Effect.catchTag("ResourceNotFoundException", () =>
366 Effect.succeed(undefined),
367 ),
368 );
369 const desiredPolicy = toPolicyString(props.accessPolicy);
370 if (desiredPolicy !== undefined) {
371 if (
372 observedPolicy === undefined ||
373 !samePolicy(observedPolicy, desiredPolicy)
374 ) {
375 yield* glacier.setVaultAccessPolicy({
376 accountId: ACCOUNT,
377 vaultName,
378 policy: { Policy: desiredPolicy },
379 });
380 }
381 } else if (observedPolicy !== undefined) {
382 yield* glacier
383 .deleteVaultAccessPolicy({ accountId: ACCOUNT, vaultName })
384 .pipe(
385 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
386 );
387 }
388
389 // 3c. SYNC vault lock — observed vs desired. An `InProgress` lock
390 // can be aborted and re-initiated; a `Locked` lock is immutable
391 // forever, so any drift from the desired state is surfaced as a
392 // typed failure rather than silently ignored.
393 const observedLock = yield* glacier
394 .getVaultLock({ accountId: ACCOUNT, vaultName })
395 .pipe(
396 Effect.catchTag("ResourceNotFoundException", () =>
397 Effect.succeed(undefined),
398 ),
399 );
400 const desiredLock = toPolicyString(props.lockPolicy);
401 if (desiredLock !== undefined) {
402 if (observedLock === undefined) {
403 yield* glacier.initiateVaultLock({
404 accountId: ACCOUNT,
405 vaultName,
406 policy: { Policy: desiredLock },
407 });
408 } else if (
409 observedLock.Policy === undefined ||
410 !samePolicy(observedLock.Policy, desiredLock)
411 ) {
412 if (observedLock.State === "Locked") {
413 return yield* Effect.fail(
414 new GlacierVaultLockImmutable({
415 message: `Vault '${vaultName}' lock is in the Locked state; its lock policy can never be changed.`,
416 }),
417 );
418 }
419 // InProgress with a different policy: abort and re-initiate.
420 yield* glacier
421 .abortVaultLock({ accountId: ACCOUNT, vaultName })
422 .pipe(
423 Effect.catchTag(
424 "ResourceNotFoundException",
425 () => Effect.void,
426 ),
427 );
428 yield* glacier.initiateVaultLock({
429 accountId: ACCOUNT,
430 vaultName,
431 policy: { Policy: desiredLock },
432 });
433 }
434 } else if (observedLock !== undefined) {
435 if (observedLock.State === "Locked") {
436 return yield* Effect.fail(
437 new GlacierVaultLockImmutable({
438 message: `Vault '${vaultName}' lock is in the Locked state; it can never be removed.`,
439 }),
440 );
441 }
442 yield* glacier
443 .abortVaultLock({ accountId: ACCOUNT, vaultName })
444 .pipe(
445 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
446 );
447 }
448
449 // 3d. SYNC tags — diff against OBSERVED cloud tags so adoption
450 // converges.
451 const observedTags = yield* readVaultTags(vaultName);
452 const desiredTags: Record<string, string> = {
453 ...props.tags,
454 ...internalTags,
455 };
456 const { upsert, removed } = diffTags(observedTags, desiredTags);
457 if (upsert.length > 0) {
458 yield* glacier.addTagsToVault({
459 accountId: ACCOUNT,
460 vaultName,
461 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
462 });
463 }
464 if (removed.length > 0) {
465 yield* glacier.removeTagsFromVault({
466 accountId: ACCOUNT,
467 vaultName,
468 TagKeys: removed,
469 });
470 }
471
472 // 4. RETURN fresh Attributes.
473 yield* session.note(vaultName);
474 return {
475 vaultName,
476 vaultArn: live.VaultARN,
477 creationDate: live.CreationDate,
478 };
479 }),
480
481 // Idempotent delete. An in-progress vault lock is aborted first so a
482 // lock policy that denies deletes cannot strand the vault; deleting
483 // an already-deleted vault is not an error.
484 delete: Effect.fn(function* ({ output }) {
485 const lock = yield* glacier
486 .getVaultLock({
487 accountId: ACCOUNT,
488 vaultName: output.vaultName,
489 })
490 .pipe(
491 Effect.catchTag("ResourceNotFoundException", () =>
492 Effect.succeed(undefined),
493 ),
494 );
495 if (lock !== undefined && lock.State !== "Locked") {
496 yield* glacier
497 .abortVaultLock({
498 accountId: ACCOUNT,
499 vaultName: output.vaultName,
500 })
501 .pipe(
502 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
503 );
504 }
505 yield* glacier
506 .deleteVault({ accountId: ACCOUNT, vaultName: output.vaultName })
507 .pipe(
508 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
509 );
510 }),
511 });
512 }),
513 );