1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import type { ScopedPlanStatusSession } from "../../Report.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
12import type { AccountID } from "../Environment.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import type { RegionID } from "../Region.ts";
15import type { Providers } from "../Providers.ts";
16import type { VolumeId } from "./Volume.ts";
17
18export type SnapshotId<ID extends string = string> = `snap-${ID}`;
19export const SnapshotId = <ID extends string>(id: ID): ID & SnapshotId<ID> =>
20 `snap-${id}` as ID & SnapshotId<ID>;
21
22export type SnapshotArn =
23 `arn:aws:ec2:${RegionID}:${AccountID}:snapshot/${SnapshotId}`;
24
25export interface SnapshotProps {
26 /**
27 * The ID of the EBS volume to snapshot. Required. A snapshot is an immutable
28 * point-in-time copy, so changing the source volume replaces the snapshot.
29 */
30 volumeId: VolumeId;
31
32 /**
33 * A description for the snapshot. Set at creation time only.
34 */
35 description?: string;
36
37 /**
38 * Tags to assign to the snapshot. Merged with alchemy auto-tags
39 * (alchemy::stack, alchemy::stage, alchemy::id).
40 */
41 tags?: Record<string, string>;
42}
43
44export interface Snapshot extends Resource<
45 "AWS.EC2.Snapshot",
46 SnapshotProps,
47 {
48 /**
49 * The ID of the snapshot.
50 */
51 snapshotId: SnapshotId;
52
53 /**
54 * The Amazon Resource Name (ARN) of the snapshot.
55 */
56 snapshotArn: SnapshotArn;
57
58 /**
59 * The ID of the volume the snapshot was created from.
60 */
61 volumeId: VolumeId;
62
63 /**
64 * The size of the volume, in GiB.
65 */
66 volumeSize: number;
67
68 /**
69 * The current state of the snapshot.
70 */
71 state: ec2.SnapshotState;
72
73 /**
74 * The progress of the snapshot, as a percentage (e.g. `"100%"`).
75 */
76 progress?: string;
77
78 /**
79 * Whether the snapshot is encrypted.
80 */
81 encrypted: boolean;
82
83 /**
84 * The KMS key used to protect the snapshot, if encrypted.
85 */
86 kmsKeyId?: string;
87
88 /**
89 * The ID of the AWS account that owns the snapshot.
90 */
91 ownerId?: string;
92 },
93 never,
94 Providers
95> {}
96/**
97 * A point-in-time backup of an EBS {@link Volume}, stored in S3. Snapshots are
98 * incremental and immutable — you create new {@link Volume}s from them via
99 * `snapshotId`.
100 *
101 * A snapshot is immutable once created; changing the source `volumeId` replaces
102 * it. Creation is asynchronous — the resource waits for the snapshot to reach
103 * the `completed` state before returning.
104 *
105 * ### Creating a Snapshot
106 * **Example:** Snapshot a Volume
107 * ```typescript
108 * const snapshot = yield* AWS.EC2.Snapshot("DailyBackup", {
109 * volumeId: volume.volumeId,
110 * description: "nightly backup of the data volume",
111 * });
112 * ```
113 *
114 * The snapshot captures the volume's state at creation time. Because snapshots
115 * are incremental, only blocks changed since the previous snapshot of the same
116 * volume are stored.
117 *
118 * ### Restoring from a Snapshot
119 * **Example:** Create a Volume from a Snapshot
120 * ```typescript
121 * const restored = yield* AWS.EC2.Volume("Restored", {
122 * availabilityZone: "us-east-1a",
123 * snapshotId: snapshot.snapshotId,
124 * });
125 * ```
126 *
127 * Pass a snapshot's `snapshotId` to {@link Volume} to provision a new volume
128 * pre-populated with the snapshot's data — the standard backup/restore and
129 * clone-across-AZ pattern.
130 *
131 * @resource
132 */
133export const Snapshot = Resource<Snapshot>("AWS.EC2.Snapshot");
134
135export const SnapshotProvider = () =>
136 Provider.effect(
137 Snapshot,
138 Effect.gen(function* () {
139 return {
140 stables: [
141 "snapshotId",
142 "snapshotArn",
143 "volumeId",
144 "volumeSize",
145 "encrypted",
146 "kmsKeyId",
147 "ownerId",
148 ],
149
150 diff: Effect.fn(function* ({ news, olds }) {
151 if (!isResolved(news)) return;
152 // A snapshot is an immutable copy of one volume — changing the source
153 // volume requires a fresh snapshot.
154 if (olds.volumeId !== news.volumeId) {
155 return { action: "replace" };
156 }
157 }),
158
159 reconcile: Effect.fn(function* ({ id, news, output, session }) {
160 const { accountId, region } = yield* AWSEnvironment.current;
161 const alchemyTags = yield* createInternalTags(id);
162 const desiredTags = { ...alchemyTags, ...news.tags };
163
164 // 1. OBSERVE — output is only an id cache.
165 let snapshot: ec2.Snapshot | undefined;
166 if (output?.snapshotId) {
167 const lookup = yield* ec2
168 .describeSnapshots({ SnapshotIds: [output.snapshotId] })
169 .pipe(
170 Effect.catchTag("InvalidSnapshot.NotFound", () =>
171 Effect.succeed({ Snapshots: [] }),
172 ),
173 );
174 snapshot = lookup.Snapshots?.[0];
175 }
176
177 // 2. ENSURE — create the snapshot when missing, then wait until it is
178 // completed.
179 if (snapshot === undefined) {
180 const created = yield* ec2.createSnapshot({
181 VolumeId: news.volumeId,
182 Description: news.description,
183 TagSpecifications: [
184 {
185 ResourceType: "snapshot",
186 Tags: createTagsList(desiredTags),
187 },
188 ],
189 DryRun: false,
190 });
191 const snapshotId = created.SnapshotId! as SnapshotId;
192 yield* session.note(`Snapshot created: ${snapshotId}`);
193 snapshot = yield* waitForSnapshotCompleted(snapshotId, session);
194 }
195
196 const snapshotId = snapshot.SnapshotId! as SnapshotId;
197
198 // 3. SYNC TAGS — diff against observed cloud tags.
199 const currentTags = Object.fromEntries(
200 (snapshot.Tags ?? []).map((t) => [t.Key!, t.Value!]),
201 ) as Record<string, string>;
202 const { removed, upsert } = diffTags(currentTags, desiredTags);
203 if (removed.length > 0) {
204 yield* ec2.deleteTags({
205 Resources: [snapshotId],
206 Tags: removed.map((key) => ({ Key: key })),
207 DryRun: false,
208 });
209 }
210 if (upsert.length > 0) {
211 yield* ec2.createTags({
212 Resources: [snapshotId],
213 Tags: upsert,
214 DryRun: false,
215 });
216 }
217
218 return toSnapshotAttributes(snapshot, region, accountId);
219 }),
220
221 // Enumerate every snapshot owned by this account (never public/shared
222 // snapshots — scope to "self").
223 list: () =>
224 Effect.gen(function* () {
225 const { accountId, region } = yield* AWSEnvironment.current;
226 const chunk = yield* ec2.describeSnapshots
227 .pages({ OwnerIds: ["self"] })
228 .pipe(Stream.runCollect);
229 return Array.from(chunk).flatMap((page) =>
230 (page.Snapshots ?? []).map((s) =>
231 toSnapshotAttributes(s, region, accountId),
232 ),
233 );
234 }),
235
236 delete: Effect.fn(function* ({ output, session }) {
237 const snapshotId = output.snapshotId;
238 yield* session.note(`Deleting snapshot: ${snapshotId}`);
239 yield* ec2
240 .deleteSnapshot({ SnapshotId: snapshotId, DryRun: false })
241 .pipe(
242 Effect.tapError(Effect.logDebug),
243 Effect.catchTag("InvalidSnapshot.NotFound", () => Effect.void),
244 );
245 yield* session.note(`Snapshot ${snapshotId} deleted successfully`);
246 }),
247 };
248 }),
249 );
250
251const toSnapshotAttributes = (
252 snapshot: ec2.Snapshot,
253 region: RegionID,
254 accountId: AccountID,
255): Snapshot["Attributes"] => {
256 const snapshotId = snapshot.SnapshotId! as SnapshotId;
257 return {
258 snapshotId,
259 snapshotArn:
260 `arn:aws:ec2:${region}:${accountId}:snapshot/${snapshotId}` as SnapshotArn,
261 volumeId: snapshot.VolumeId! as VolumeId,
262 volumeSize: snapshot.VolumeSize ?? 0,
263 state: snapshot.State ?? "pending",
264 progress: snapshot.Progress,
265 encrypted: snapshot.Encrypted ?? false,
266 kmsKeyId: snapshot.KmsKeyId,
267 ownerId: snapshot.OwnerId,
268 };
269};
270
271class SnapshotPending extends Data.TaggedError("SnapshotPending")<{
272 snapshotId: string;
273 state: string;
274 progress?: string;
275}> {}
276
277/**
278 * Wait for the snapshot to reach the `completed` state. Bounded so a slow
279 * snapshot fails fast rather than hanging the deploy.
280 */
281const waitForSnapshotCompleted = (
282 snapshotId: string,
283 session?: ScopedPlanStatusSession,
284) =>
285 Effect.gen(function* () {
286 const result = yield* ec2.describeSnapshots({
287 SnapshotIds: [snapshotId],
288 });
289 const snapshot = result.Snapshots?.[0];
290 if (!snapshot) {
291 return yield* Effect.fail(new Error(`Snapshot ${snapshotId} not found`));
292 }
293 if (snapshot.State === "completed") {
294 return snapshot;
295 }
296 if (snapshot.State === "error") {
297 return yield* Effect.fail(
298 new Error(
299 `Snapshot ${snapshotId} entered error state: ${snapshot.StateMessage}`,
300 ),
301 );
302 }
303 return yield* new SnapshotPending({
304 snapshotId,
305 state: snapshot.State!,
306 progress: snapshot.Progress,
307 });
308 }).pipe(
309 Effect.retry({
310 while: (e) => e instanceof SnapshotPending,
311 schedule: Schedule.max([
312 Schedule.fixed(3000),
313 Schedule.recurs(30), // max ~90s
314 ]).pipe(
315 Schedule.tap(({ attempt }) =>
316 session
317 ? session.note(
318 `Waiting for snapshot to complete... (${(attempt + 1) * 3}s)`,
319 )
320 : Effect.void,
321 ),
322 ),
323 }),
324 );