| 1 | import * as cloudfront from "@distilled.cloud/aws/cloudfront"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | class VpcOriginPendingDeployment extends Data.TaggedError( |
| 13 | "VpcOriginPendingDeployment", |
| 14 | )<{ |
| 15 | message: string; |
| 16 | }> {} |
| 17 | |
| 18 | class VpcOriginStillInUse extends Data.TaggedError("VpcOriginStillInUse")<{ |
| 19 | message: string; |
| 20 | }> {} |
| 21 | |
| 22 | export 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 | |
| 59 | export 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 | */ |
| 141 | export const VpcOrigin = Resource<VpcOrigin>("AWS.CloudFront.VpcOrigin"); |
| 142 | |
| 143 | export 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 | |
| 444 | const createName = (id: string, props: VpcOriginProps) => |
| 445 | props.name |
| 446 | ? Effect.succeed(props.name) |
| 447 | : createPhysicalName({ |
| 448 | id, |
| 449 | maxLength: 64, |
| 450 | }); |
| 451 | |
| 452 | const 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 | |
| 462 | const 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 | }); |