1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12class VpcOriginPendingDeployment extends Data.TaggedError(
13 "VpcOriginPendingDeployment",
14)<{
15 message: string;
16}> {}
17
18class VpcOriginStillInUse extends Data.TaggedError("VpcOriginStillInUse")<{
19 message: string;
20}> {}
21
22export interface VpcOriginProps {
23 /**
24 * Name of the VPC origin. If omitted, a deterministic name is generated.
25 */
26 name?: string;
27 /**
28 * ARN of the resource the VPC origin fronts (an Application/Network Load
29 * Balancer or an EC2 instance in a VPC). Changing the target ARN forces a
30 * replacement.
31 */
32 arn: string;
33 /**
34 * HTTP port CloudFront uses to connect to the origin.
35 * @default 80
36 */
37 httpPort?: number;
38 /**
39 * HTTPS port CloudFront uses to connect to the origin.
40 * @default 443
41 */
42 httpsPort?: number;
43 /**
44 * Origin protocol policy CloudFront uses to connect to the origin.
45 * @default "https-only"
46 */
47 originProtocolPolicy?: cloudfront.OriginProtocolPolicy;
48 /**
49 * SSL/TLS protocols CloudFront uses when establishing an HTTPS connection.
50 * @default ["TLSv1.2"]
51 */
52 originSslProtocols?: cloudfront.SslProtocol[];
53 /**
54 * User-defined tags to apply to the VPC origin.
55 */
56 tags?: Record<string, string>;
57}
58
59export interface VpcOrigin extends Resource<
60 "AWS.CloudFront.VpcOrigin",
61 VpcOriginProps,
62 {
63 /**
64 * CloudFront-assigned VPC origin identifier.
65 */
66 vpcOriginId: string;
67 /**
68 * ARN of the VPC origin.
69 */
70 vpcOriginArn: string;
71 /**
72 * Current deployment status of the VPC origin.
73 */
74 status: string;
75 /**
76 * Name of the VPC origin.
77 */
78 name: string;
79 /**
80 * ARN of the resource the VPC origin fronts.
81 */
82 arn: string;
83 /**
84 * Creation timestamp.
85 */
86 createdTime: Date | undefined;
87 /**
88 * Last CloudFront modification timestamp.
89 */
90 lastModifiedTime: Date | undefined;
91 /**
92 * Most recent entity tag for update/delete operations.
93 */
94 etag: string | undefined;
95 /**
96 * Current tags on the VPC origin.
97 */
98 tags: Record<string, string>;
99 },
100 never,
101 Providers
102> {}
103
104/**
105 * A CloudFront VPC origin.
106 *
107 * `VpcOrigin` lets a CloudFront distribution route to a private Application
108 * Load Balancer, Network Load Balancer, or EC2 instance inside a VPC without
109 * exposing it to the public internet. Reference the resulting `vpcOriginId`
110 * from a distribution origin's `vpcOriginConfig`.
111 * ### Creating VPC Origins
112 * **Example:** Private ALB Origin
113 * ```typescript
114 * const vpcOrigin = yield* VpcOrigin("AppOrigin", {
115 * arn: loadBalancer.arn,
116 * httpPort: 80,
117 * httpsPort: 443,
118 * originProtocolPolicy: "https-only",
119 * });
120 * ```
121 *
122 * **Example:** Attaching a VPC Origin to a Distribution
123 * ```typescript
124 * const distribution = yield* Distribution("AppCdn", {
125 * origins: [
126 * {
127 * id: "app",
128 * domainName: loadBalancer.dnsName,
129 * vpcOriginConfig: { vpcOriginId: vpcOrigin.vpcOriginId },
130 * },
131 * ],
132 * defaultCacheBehavior: {
133 * targetOriginId: "app",
134 * viewerProtocolPolicy: "redirect-to-https",
135 * },
136 * });
137 * ```
138 *
139 * @resource
140 */
141export const VpcOrigin = Resource<VpcOrigin>("AWS.CloudFront.VpcOrigin");
142
143export const VpcOriginProvider = () =>
144 Provider.effect(
145 VpcOrigin,
146 Effect.gen(function* () {
147 // Observe — locate the VPC origin by id, tolerating a concurrent delete.
148 const getById = Effect.fn(function* (id: string) {
149 const result = yield* cloudfront
150 .getVpcOrigin({ Id: id })
151 .pipe(
152 Effect.catchTag("EntityNotFound", () => Effect.succeed(undefined)),
153 );
154 if (!result?.VpcOrigin?.Id) {
155 return undefined;
156 }
157 return { vpcOrigin: result.VpcOrigin, etag: result.ETag };
158 });
159
160 // Crash recovery — a create can succeed in the cloud but fail to persist
161 // locally. Recover by listing and matching on the fronted ARN.
162 const getByArn = Effect.fn(function* (arn: string) {
163 let marker: string | undefined;
164 do {
165 const listed: cloudfront.ListVpcOriginsResult =
166 yield* cloudfront.listVpcOrigins({ Marker: marker });
167 const summary = listed.VpcOriginList?.Items?.find(
168 (item) => item.OriginEndpointArn === arn,
169 );
170 if (summary?.Id) {
171 return yield* getById(summary.Id);
172 }
173 marker = listed.VpcOriginList?.IsTruncated
174 ? listed.VpcOriginList.NextMarker
175 : undefined;
176 } while (marker);
177 return undefined;
178 });
179
180 const fetchTags = Effect.fn(function* (arn: string) {
181 const response = yield* cloudfront.listTagsForResource({
182 Resource: arn,
183 });
184 return toTagsRecord(response.Tags.Items);
185 });
186
187 // Wait — poll until the VPC origin reaches the terminal `Deployed` state,
188 // mirroring the Distribution deployment wait.
189 const waitForDeployment = Effect.fn(function* (id: string) {
190 return yield* getById(id).pipe(
191 Effect.flatMap((current) =>
192 current?.vpcOrigin.Status === "Deployed"
193 ? Effect.succeed(current)
194 : Effect.fail(
195 new VpcOriginPendingDeployment({
196 message: `VPC origin ${id} is not yet deployed (status=${current?.vpcOrigin.Status ?? "unknown"})`,
197 }),
198 ),
199 ),
200 Effect.retry({
201 while: (error) => error._tag === "VpcOriginPendingDeployment",
202 // CloudFront VPC-origin deployment is slow (global propagation) and
203 // routinely exceeds 10 min; budget ~20 min (120 * 10s) so a real
204 // deploy doesn't fail spuriously.
205 schedule: Schedule.max([
206 Schedule.fixed("10 seconds"),
207 Schedule.recurs(120),
208 ]),
209 }),
210 );
211 });
212
213 const desiredEndpointConfig = (
214 name: string,
215 props: VpcOriginProps,
216 ): cloudfront.VpcOriginEndpointConfig => ({
217 Name: name,
218 Arn: props.arn,
219 HTTPPort: props.httpPort ?? 80,
220 HTTPSPort: props.httpsPort ?? 443,
221 OriginProtocolPolicy: props.originProtocolPolicy ?? "https-only",
222 OriginSslProtocols: {
223 Quantity: (props.originSslProtocols ?? ["TLSv1.2"]).length,
224 Items: props.originSslProtocols ?? ["TLSv1.2"],
225 },
226 });
227
228 const endpointConfigEquals = (
229 a: cloudfront.VpcOriginEndpointConfig | undefined,
230 b: cloudfront.VpcOriginEndpointConfig,
231 ) =>
232 !!a &&
233 a.Name === b.Name &&
234 a.Arn === b.Arn &&
235 a.HTTPPort === b.HTTPPort &&
236 a.HTTPSPort === b.HTTPSPort &&
237 a.OriginProtocolPolicy === b.OriginProtocolPolicy &&
238 (a.OriginSslProtocols?.Items ?? []).join(",") ===
239 (b.OriginSslProtocols?.Items ?? []).join(",");
240
241 const syncTags = Effect.fn(function* (
242 arn: string,
243 observedTags: Record<string, string>,
244 desiredTags: Record<string, string>,
245 ) {
246 const { removed, upsert } = diffTags(observedTags, desiredTags);
247 if (upsert.length > 0) {
248 yield* cloudfront.tagResource({
249 Resource: arn,
250 Tags: { Items: upsert },
251 });
252 }
253 if (removed.length > 0) {
254 yield* cloudfront.untagResource({
255 Resource: arn,
256 TagKeys: { Items: removed },
257 });
258 }
259 });
260
261 return {
262 stables: ["vpcOriginId", "vpcOriginArn"],
263 list: () =>
264 Effect.gen(function* () {
265 const items: VpcOrigin["Attributes"][] = [];
266 let marker: string | undefined;
267 do {
268 const listed: cloudfront.ListVpcOriginsResult =
269 yield* cloudfront.listVpcOrigins({ Marker: marker });
270 for (const summary of listed.VpcOriginList?.Items ?? []) {
271 if (!summary.Id) continue;
272 const current = yield* getById(summary.Id);
273 if (!current) continue;
274 const tags = yield* fetchTags(current.vpcOrigin.Arn);
275 items.push(toAttrs(current.vpcOrigin, current.etag, tags));
276 }
277 marker = listed.VpcOriginList?.IsTruncated
278 ? listed.VpcOriginList.NextMarker
279 : undefined;
280 } while (marker);
281 return items;
282 }),
283 diff: Effect.fn(function* ({ id, olds, news: _news }) {
284 if (!isResolved(_news)) return undefined;
285 const news = _news as VpcOriginProps;
286 // `arn` is create-only — a changed target endpoint forces replace.
287 if (olds?.arn !== undefined && olds.arn !== news.arn) {
288 return { action: "replace" } as const;
289 }
290 // A changed name also forces replace (it is the immutable identity).
291 if (
292 (yield* createName(id, olds ?? ({} as VpcOriginProps))) !==
293 (yield* createName(id, news))
294 ) {
295 return { action: "replace" } as const;
296 }
297 }),
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const existing = output?.vpcOriginId
300 ? yield* getById(output.vpcOriginId)
301 : yield* getByArn((olds ?? ({} as VpcOriginProps)).arn ?? "");
302 if (!existing?.vpcOrigin.Id) {
303 return undefined;
304 }
305 const tags = yield* fetchTags(existing.vpcOrigin.Arn);
306 return toAttrs(existing.vpcOrigin, existing.etag, tags);
307 }),
308 reconcile: Effect.fn(function* ({ id, news, output, session }) {
309 const name = yield* createName(id, news);
310 const desiredTags = {
311 ...(yield* createInternalTags(id)),
312 ...news.tags,
313 };
314
315 // Observe — locate by cached id or recover by fronted ARN.
316 let observed = output?.vpcOriginId
317 ? yield* getById(output.vpcOriginId)
318 : undefined;
319 if (!observed) {
320 observed = yield* getByArn(news.arn);
321 }
322
323 // Ensure — create the VPC origin if it's missing. Tolerate an
324 // `EntityAlreadyExists` race by recovering via the fronted ARN.
325 if (!observed) {
326 const created = yield* cloudfront
327 .createVpcOrigin({
328 VpcOriginEndpointConfig: desiredEndpointConfig(name, news),
329 Tags: { Items: createTagsList(desiredTags) },
330 })
331 .pipe(
332 Effect.map((result) =>
333 result.VpcOrigin?.Id
334 ? { vpcOrigin: result.VpcOrigin, etag: result.ETag }
335 : undefined,
336 ),
337 Effect.catchTag("EntityAlreadyExists", () =>
338 getByArn(news.arn),
339 ),
340 );
341
342 if (!created?.vpcOrigin.Id) {
343 return yield* Effect.fail(
344 new Error("createVpcOrigin returned no identifier"),
345 );
346 }
347
348 yield* session.note(created.vpcOrigin.Id);
349 const deployed = yield* waitForDeployment(created.vpcOrigin.Id);
350 const tags = yield* fetchTags(deployed.vpcOrigin.Arn);
351 return toAttrs(deployed.vpcOrigin, deployed.etag, tags);
352 }
353
354 // Sync endpoint config — patch only when the observed config differs
355 // from desired, using the freshly observed ETag for concurrency.
356 const desired = desiredEndpointConfig(name, news);
357 let current = observed;
358 if (
359 !endpointConfigEquals(
360 observed.vpcOrigin.VpcOriginEndpointConfig,
361 desired,
362 )
363 ) {
364 yield* cloudfront.updateVpcOrigin({
365 Id: observed.vpcOrigin.Id,
366 IfMatch: observed.etag ?? "",
367 VpcOriginEndpointConfig: desired,
368 });
369 current = yield* waitForDeployment(observed.vpcOrigin.Id);
370 } else if (observed.vpcOrigin.Status !== "Deployed") {
371 current = yield* waitForDeployment(observed.vpcOrigin.Id);
372 }
373
374 // Sync tags — diff against observed cloud tags so adoption converges.
375 const observedTags = yield* fetchTags(current.vpcOrigin.Arn);
376 yield* syncTags(current.vpcOrigin.Arn, observedTags, desiredTags);
377
378 yield* session.note(current.vpcOrigin.Id);
379 return toAttrs(current.vpcOrigin, current.etag, desiredTags);
380 }),
381 delete: Effect.fn(function* ({ output }) {
382 if (!output.vpcOriginId) {
383 return;
384 }
385 // Observe current state. `deleteVpcOrigin` requires a terminal
386 // `Deployed` status and the *current* ETag — `output.etag` may be
387 // stale. If the origin is still `Deploying` (e.g. an interrupted
388 // create), CloudFront refuses the delete and leaves the origin — and
389 // its managed ENIs, which then block the VPC/ALB teardown —
390 // orphaned. Wait for it to settle so it becomes deletable.
391 const observed = yield* getById(output.vpcOriginId);
392 if (!observed) {
393 return; // already gone — idempotent
394 }
395 const current =
396 observed.vpcOrigin.Status === "Deployed"
397 ? observed
398 : yield* waitForDeployment(output.vpcOriginId).pipe(
399 Effect.catch(() => Effect.succeed(observed)),
400 );
401 yield* cloudfront
402 .deleteVpcOrigin({
403 Id: output.vpcOriginId,
404 IfMatch: current.etag ?? observed.etag ?? "",
405 })
406 .pipe(
407 Effect.catchTag("EntityNotFound", () => Effect.void),
408 // The VPC origin may still be referenced by a distribution origin
409 // that is mid-removal; retry on the in-use signal.
410 Effect.catchTag("CannotDeleteEntityWhileInUse", (error) =>
411 Effect.fail(
412 new VpcOriginStillInUse({
413 message: error.message ?? "VPC origin still in use",
414 }),
415 ),
416 ),
417 Effect.retry({
418 while: (error) => error._tag === "VpcOriginStillInUse",
419 schedule: Schedule.max([
420 Schedule.fixed("10 seconds"),
421 Schedule.recurs(30),
422 ]),
423 }),
424 );
425 // Block until the origin record is fully gone so dependents (the
426 // fronted ALB/VPC, held by CloudFront's ENIs) can be torn down.
427 yield* Effect.repeat(
428 getById(output.vpcOriginId).pipe(
429 Effect.map((o) => o !== undefined),
430 ),
431 {
432 schedule: Schedule.max([
433 Schedule.fixed("10 seconds"),
434 Schedule.recurs(30),
435 ]),
436 until: (exists) => exists === false,
437 },
438 ).pipe(Effect.catch(() => Effect.void));
439 }),
440 };
441 }),
442 );
443
444const createName = (id: string, props: VpcOriginProps) =>
445 props.name
446 ? Effect.succeed(props.name)
447 : createPhysicalName({
448 id,
449 maxLength: 64,
450 });
451
452const toTagsRecord = (tags: cloudfront.Tag[] | undefined) =>
453 Object.fromEntries(
454 (tags ?? [])
455 .filter(
456 (tag): tag is { Key: string; Value: string } =>
457 typeof tag.Key === "string" && typeof tag.Value === "string",
458 )
459 .map((tag) => [tag.Key, tag.Value]),
460 );
461
462const toAttrs = (
463 vpcOrigin: cloudfront.VpcOrigin,
464 etag: string | undefined,
465 tags: Record<string, string>,
466): VpcOrigin["Attributes"] => ({
467 vpcOriginId: vpcOrigin.Id,
468 vpcOriginArn: vpcOrigin.Arn,
469 status: vpcOrigin.Status,
470 name: vpcOrigin.VpcOriginEndpointConfig.Name,
471 arn: vpcOrigin.VpcOriginEndpointConfig.Arn,
472 createdTime: vpcOrigin.CreatedTime,
473 lastModifiedTime: vpcOrigin.LastModifiedTime,
474 etag,
475 tags,
476});