| 1 | import { Region as AwsRegion } from "@distilled.cloud/aws/Region"; |
| 2 | import * as acm from "@distilled.cloud/aws/acm"; |
| 3 | import * as route53 from "@distilled.cloud/aws/route-53"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource, type ResourceBinding } from "../../Resource.ts"; |
| 11 | import { |
| 12 | createInternalTags, |
| 13 | createTagsList, |
| 14 | diffTags, |
| 15 | hasAlchemyTags, |
| 16 | } from "../../Tags.ts"; |
| 17 | import type { Providers } from "../Providers.ts"; |
| 18 | import { findPublicHostedZoneId } from "../Route53/HostedZoneLookup.ts"; |
| 19 | |
| 20 | export interface CertificateProps { |
| 21 | /** |
| 22 | * Primary domain name for the certificate. |
| 23 | */ |
| 24 | domainName: string; |
| 25 | /** |
| 26 | * Additional domain names to include on the certificate. |
| 27 | */ |
| 28 | subjectAlternativeNames?: string[]; |
| 29 | /** |
| 30 | * Validation method for the certificate request. |
| 31 | * @default "DNS" |
| 32 | */ |
| 33 | validationMethod?: acm.ValidationMethod; |
| 34 | /** |
| 35 | * Route 53 hosted zone used to auto-create DNS validation records. |
| 36 | * |
| 37 | * With `validationMethod: "DNS"` the certificate provider upserts the |
| 38 | * validation records into this zone and waits for issuance. When |
| 39 | * omitted, the most specific PUBLIC hosted zone in the account |
| 40 | * containing `domainName` is inferred; if none matches, validation is |
| 41 | * left to the caller (external DNS) and the certificate is returned |
| 42 | * pending. |
| 43 | */ |
| 44 | hostedZoneId?: string; |
| 45 | /** |
| 46 | * Requested key algorithm. |
| 47 | */ |
| 48 | keyAlgorithm?: acm.KeyAlgorithm; |
| 49 | /** |
| 50 | * Certificate transparency logging preference. |
| 51 | * |
| 52 | * Updated in place via `UpdateCertificateOptions`. Note that AWS no longer |
| 53 | * allows opting new public certificates out of CT logging. |
| 54 | */ |
| 55 | certificateTransparencyLoggingPreference?: "ENABLED" | "DISABLED" | undefined; |
| 56 | /** |
| 57 | * Whether the certificate's private key can be exported with |
| 58 | * `acm:ExportCertificate` (see the `ExportCertificate` binding). |
| 59 | * Exportable public certificates carry an additional charge. |
| 60 | * |
| 61 | * Exportability can only be chosen when the certificate is requested — |
| 62 | * ACM rejects `UpdateCertificateOptions` for it ("Export option for |
| 63 | * certificates cannot be updated") — so changing this on an existing |
| 64 | * certificate forces a replacement. |
| 65 | */ |
| 66 | export?: "ENABLED" | "DISABLED" | undefined; |
| 67 | /** |
| 68 | * AWS region to request the certificate in. |
| 69 | * |
| 70 | * Defaults to `us-east-1` (the region CloudFront viewer certificates must |
| 71 | * live in). Set this to the region of a regional consumer — e.g. an ALB |
| 72 | * HTTPS listener requires the certificate in the load balancer's own |
| 73 | * region. Changing the region replaces the certificate. |
| 74 | * @default "us-east-1" |
| 75 | */ |
| 76 | region?: string; |
| 77 | /** |
| 78 | * User-defined tags to apply to the certificate. |
| 79 | */ |
| 80 | tags?: Record<string, string>; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Binding contract of {@link Certificate}: composites contribute additional |
| 85 | * subject alternative names without a circular input prop (e.g. a site |
| 86 | * attached to an `AWS.Website.Router` binds its hostnames onto the Router's |
| 87 | * certificate). ACM certificates are immutable — a change in the bound SAN |
| 88 | * set plans a REPLACEMENT (new certificate requested and validated first, |
| 89 | * consumers re-pointed, old certificate deleted last). |
| 90 | */ |
| 91 | export type CertificateBinding = { |
| 92 | /** |
| 93 | * Additional subject alternative names merged into the certificate's SAN |
| 94 | * set at reconcile time. |
| 95 | */ |
| 96 | subjectAlternativeNames?: string[]; |
| 97 | }; |
| 98 | |
| 99 | export interface Certificate extends Resource< |
| 100 | "AWS.ACM.Certificate", |
| 101 | CertificateProps, |
| 102 | { |
| 103 | /** |
| 104 | * ARN of the certificate. |
| 105 | */ |
| 106 | certificateArn: string; |
| 107 | /** |
| 108 | * Primary domain name of the certificate. |
| 109 | */ |
| 110 | domainName: string; |
| 111 | /** |
| 112 | * Additional subject alternative names on the certificate. |
| 113 | */ |
| 114 | subjectAlternativeNames: string[]; |
| 115 | /** |
| 116 | * Current ACM certificate status. |
| 117 | */ |
| 118 | status: acm.CertificateStatus | undefined; |
| 119 | /** |
| 120 | * ACM-managed domain validation details, including DNS validation records. |
| 121 | */ |
| 122 | domainValidationOptions: acm.DomainValidation[]; |
| 123 | /** |
| 124 | * Requested validation method. |
| 125 | */ |
| 126 | validationMethod: acm.ValidationMethod | undefined; |
| 127 | /** |
| 128 | * Requested key algorithm. |
| 129 | */ |
| 130 | keyAlgorithm: acm.KeyAlgorithm | undefined; |
| 131 | /** |
| 132 | * Route 53 hosted zone used for automatic DNS validation, when configured. |
| 133 | */ |
| 134 | hostedZoneId: string | undefined; |
| 135 | /** |
| 136 | * Certificate transparency logging preference currently on the certificate. |
| 137 | */ |
| 138 | certificateTransparencyLoggingPreference: |
| 139 | | acm.CertificateTransparencyLoggingPreference |
| 140 | | undefined; |
| 141 | /** |
| 142 | * Whether the certificate's private key is exportable. |
| 143 | */ |
| 144 | export: acm.CertificateExport | undefined; |
| 145 | /** |
| 146 | * Current tags on the certificate. |
| 147 | */ |
| 148 | tags: Record<string, string>; |
| 149 | /** |
| 150 | * Certificate issue timestamp, when issued. |
| 151 | */ |
| 152 | issuedAt: Date | undefined; |
| 153 | /** |
| 154 | * Certificate expiration timestamp, when issued. |
| 155 | */ |
| 156 | notAfter: Date | undefined; |
| 157 | }, |
| 158 | CertificateBinding, |
| 159 | Providers |
| 160 | > {} |
| 161 | |
| 162 | /** |
| 163 | * Effective SAN set: declared props plus bound SANs (see |
| 164 | * {@link CertificateBinding}), deduped. Tolerates both `{ sid, data }` rows |
| 165 | * (provider lifecycle) and bare binding payloads. |
| 166 | * @internal |
| 167 | */ |
| 168 | const resolveEffectiveSans = ( |
| 169 | declared: string[] | undefined, |
| 170 | bindings: |
| 171 | | ReadonlyArray<CertificateBinding | ResourceBinding<CertificateBinding>> |
| 172 | | undefined, |
| 173 | ): string[] | undefined => { |
| 174 | const bound = (bindings ?? []).flatMap((binding) => |
| 175 | "data" in binding && binding.data !== undefined |
| 176 | ? ((binding as ResourceBinding<CertificateBinding>).data |
| 177 | .subjectAlternativeNames ?? []) |
| 178 | : ((binding as CertificateBinding).subjectAlternativeNames ?? []), |
| 179 | ); |
| 180 | if (bound.length === 0) { |
| 181 | return declared; |
| 182 | } |
| 183 | return [...new Set([...(declared ?? []), ...bound])]; |
| 184 | }; |
| 185 | |
| 186 | /** |
| 187 | * An ACM certificate for CloudFront and other AWS endpoints. |
| 188 | * |
| 189 | * `Certificate` requests an ACM certificate in `us-east-1`, which is the |
| 190 | * region required for CloudFront viewer certificates. When `hostedZoneId` is |
| 191 | * provided for DNS validation, the provider creates or updates the Route 53 |
| 192 | * validation records and waits for the certificate to be issued. |
| 193 | * ### Requesting Certificates |
| 194 | * **Example:** DNS-Validated Certificate |
| 195 | * ```typescript |
| 196 | * const cert = yield* Certificate("WebsiteCertificate", { |
| 197 | * domainName: "www.example.com", |
| 198 | * hostedZoneId: "Z1234567890", |
| 199 | * }); |
| 200 | * ``` |
| 201 | * |
| 202 | * **Example:** Certificate With SANs |
| 203 | * ```typescript |
| 204 | * const cert = yield* Certificate("WebsiteCertificate", { |
| 205 | * domainName: "example.com", |
| 206 | * subjectAlternativeNames: ["www.example.com"], |
| 207 | * hostedZoneId: "Z1234567890", |
| 208 | * }); |
| 209 | * ``` |
| 210 | * |
| 211 | * **Example:** Exportable Certificate |
| 212 | * ```typescript |
| 213 | * // `export: "ENABLED"` lets the ExportCertificate binding retrieve the |
| 214 | * // certificate together with its (encrypted) private key at runtime. |
| 215 | * const cert = yield* Certificate("ExportableCertificate", { |
| 216 | * domainName: "www.example.com", |
| 217 | * hostedZoneId: "Z1234567890", |
| 218 | * export: "ENABLED", |
| 219 | * }); |
| 220 | * ``` |
| 221 | * |
| 222 | * ### Certificate Expiry Events |
| 223 | * **Example:** React to Approaching Expiration |
| 224 | * ```typescript |
| 225 | * // ACM emits "ACM Certificate Approaching Expiration" events through |
| 226 | * // EventBridge — consume them with the ACM expiry event source, scoped |
| 227 | * // to this certificate. |
| 228 | * yield* AWS.ACM.consumeExpiryEvents( |
| 229 | * { certificateArns: [cert.certificateArn] }, |
| 230 | * (events) => |
| 231 | * Stream.runForEach(events, (event) => |
| 232 | * Effect.log( |
| 233 | * `${event.detail.CommonName} expires in ${event.detail.DaysToExpiry} days`, |
| 234 | * ), |
| 235 | * ), |
| 236 | * ); |
| 237 | * ``` |
| 238 | * |
| 239 | * @resource |
| 240 | */ |
| 241 | export const Certificate = Resource<Certificate>("AWS.ACM.Certificate"); |
| 242 | |
| 243 | export const CertificateProvider = () => |
| 244 | Provider.effect( |
| 245 | Certificate, |
| 246 | Effect.gen(function* () { |
| 247 | const describeCertificate = Effect.fn(function* (certificateArn: string) { |
| 248 | return yield* acm |
| 249 | .describeCertificate({ CertificateArn: certificateArn }) |
| 250 | .pipe( |
| 251 | Effect.map((response) => response.Certificate), |
| 252 | Effect.catchTag("ResourceNotFoundException", () => |
| 253 | Effect.succeed(undefined), |
| 254 | ), |
| 255 | withCertRegion(regionOfCertificateArn(certificateArn)), |
| 256 | ); |
| 257 | }); |
| 258 | |
| 259 | const listCertificateTags = Effect.fn(function* (certificateArn: string) { |
| 260 | return yield* acm |
| 261 | .listTagsForCertificate({ CertificateArn: certificateArn }) |
| 262 | .pipe( |
| 263 | Effect.map((response) => toTagRecord(response.Tags)), |
| 264 | Effect.catchTag("ResourceNotFoundException", () => |
| 265 | Effect.succeed({}), |
| 266 | ), |
| 267 | withCertRegion(regionOfCertificateArn(certificateArn)), |
| 268 | ); |
| 269 | }); |
| 270 | |
| 271 | const findManagedCertificate = Effect.fn(function* ( |
| 272 | id: string, |
| 273 | props: CertificateProps, |
| 274 | ) { |
| 275 | // Describe candidates lazily as pages stream in and stop at the first |
| 276 | // match, so pagination terminates early instead of draining every page. |
| 277 | return yield* withCertRegion(props.region)( |
| 278 | acm.listCertificates |
| 279 | .items({ |
| 280 | Includes: { |
| 281 | keyTypes: props.keyAlgorithm ? [props.keyAlgorithm] : undefined, |
| 282 | }, |
| 283 | } as any) |
| 284 | .pipe( |
| 285 | Stream.filter( |
| 286 | (summary) => summary.DomainName === props.domainName, |
| 287 | ), |
| 288 | Stream.mapEffect((summary) => |
| 289 | Effect.gen(function* () { |
| 290 | if (!summary.CertificateArn) { |
| 291 | return undefined; |
| 292 | } |
| 293 | const detail = yield* describeCertificate( |
| 294 | summary.CertificateArn, |
| 295 | ); |
| 296 | if (!detail?.CertificateArn) { |
| 297 | return undefined; |
| 298 | } |
| 299 | if ( |
| 300 | detail.DomainName !== props.domainName || |
| 301 | JSON.stringify( |
| 302 | normalizeSanList(detail.SubjectAlternativeNames), |
| 303 | ) !== |
| 304 | JSON.stringify( |
| 305 | normalizeSanList(props.subjectAlternativeNames), |
| 306 | ) |
| 307 | ) { |
| 308 | return undefined; |
| 309 | } |
| 310 | // Exportability is fixed at request time, so a certificate |
| 311 | // with a different export option can never converge to these |
| 312 | // props — it is the doomed half of a replacement, not a |
| 313 | // match. |
| 314 | if ( |
| 315 | (props.export ?? "DISABLED") !== |
| 316 | (detail.Options?.Export ?? "DISABLED") |
| 317 | ) { |
| 318 | return undefined; |
| 319 | } |
| 320 | const tags = yield* listCertificateTags( |
| 321 | detail.CertificateArn, |
| 322 | ); |
| 323 | return (yield* hasAlchemyTags(id, tags)) ? detail : undefined; |
| 324 | }), |
| 325 | ), |
| 326 | Stream.filter((detail) => detail !== undefined), |
| 327 | Stream.runHead, |
| 328 | Effect.map(Option.getOrUndefined), |
| 329 | ), |
| 330 | ); |
| 331 | }); |
| 332 | |
| 333 | const waitForValidationRecords = Effect.fn(function* ( |
| 334 | certificateArn: string, |
| 335 | ) { |
| 336 | return yield* describeCertificate(certificateArn).pipe( |
| 337 | Effect.flatMap((detail) => { |
| 338 | const validations = detail?.DomainValidationOptions ?? []; |
| 339 | if ( |
| 340 | validations.length === 0 || |
| 341 | validations.some((option) => option.ResourceRecord === undefined) |
| 342 | ) { |
| 343 | return Effect.fail( |
| 344 | new Error("CertificateValidationRecordPending"), |
| 345 | ); |
| 346 | } |
| 347 | return Effect.succeed(detail!); |
| 348 | }), |
| 349 | Effect.retry({ |
| 350 | while: (error) => |
| 351 | error instanceof Error && |
| 352 | error.message === "CertificateValidationRecordPending", |
| 353 | schedule: Schedule.max([ |
| 354 | Schedule.fixed("2 seconds"), |
| 355 | Schedule.recurs(60), |
| 356 | ]), |
| 357 | }), |
| 358 | ); |
| 359 | }); |
| 360 | |
| 361 | const waitForIssued = Effect.fn(function* (certificateArn: string) { |
| 362 | return yield* describeCertificate(certificateArn).pipe( |
| 363 | Effect.flatMap((detail) => { |
| 364 | if (!detail?.CertificateArn) { |
| 365 | return Effect.fail(new Error("CertificateNotFound")); |
| 366 | } |
| 367 | if (detail.Status === "ISSUED") { |
| 368 | return Effect.succeed(detail); |
| 369 | } |
| 370 | if (isTerminalFailure(detail.Status)) { |
| 371 | return Effect.fail( |
| 372 | new Error( |
| 373 | `Certificate issuance failed with status ${detail.Status}${detail.FailureReason ? ` (${detail.FailureReason})` : ""}`, |
| 374 | ), |
| 375 | ); |
| 376 | } |
| 377 | return Effect.fail(new Error("CertificatePendingValidation")); |
| 378 | }), |
| 379 | Effect.retry({ |
| 380 | while: (error) => |
| 381 | error instanceof Error && |
| 382 | error.message === "CertificatePendingValidation", |
| 383 | schedule: Schedule.max([ |
| 384 | Schedule.fixed("10 seconds"), |
| 385 | Schedule.recurs(60), |
| 386 | ]), |
| 387 | }), |
| 388 | ); |
| 389 | }); |
| 390 | |
| 391 | const upsertValidationRecords = Effect.fn(function* ( |
| 392 | hostedZoneId: string, |
| 393 | certificate: acm.CertificateDetail, |
| 394 | ) { |
| 395 | const changes = (certificate.DomainValidationOptions ?? []) |
| 396 | .flatMap((option) => |
| 397 | option.ResourceRecord ? [option.ResourceRecord] : [], |
| 398 | ) |
| 399 | .map((record) => ({ |
| 400 | Action: "UPSERT" as const, |
| 401 | ResourceRecordSet: { |
| 402 | Name: record.Name, |
| 403 | Type: record.Type, |
| 404 | TTL: 60, |
| 405 | ResourceRecords: [{ Value: record.Value }], |
| 406 | }, |
| 407 | })); |
| 408 | |
| 409 | if (changes.length === 0) { |
| 410 | return; |
| 411 | } |
| 412 | |
| 413 | const response = yield* route53.changeResourceRecordSets({ |
| 414 | HostedZoneId: normalizeHostedZoneId(hostedZoneId), |
| 415 | ChangeBatch: { |
| 416 | Comment: "Alchemy ACM DNS validation", |
| 417 | Changes: changes, |
| 418 | }, |
| 419 | }); |
| 420 | |
| 421 | yield* waitForRoute53Change(response.ChangeInfo.Id); |
| 422 | }); |
| 423 | |
| 424 | return { |
| 425 | stables: ["certificateArn"], |
| 426 | list: () => |
| 427 | Effect.gen(function* () { |
| 428 | // ACM certificates default to us-east-1 (CloudFront), but a |
| 429 | // `region` prop can place them in the ambient region (e.g. for |
| 430 | // ALB listeners) — enumerate both and dedupe by ARN, then |
| 431 | // hydrate each to the full Attributes shape via describe + tags. |
| 432 | const listPage = acm.listCertificates.pages({}).pipe( |
| 433 | Stream.runCollect, |
| 434 | Effect.map((chunk) => |
| 435 | Array.from(chunk).flatMap( |
| 436 | (page) => page.CertificateSummaryList ?? [], |
| 437 | ), |
| 438 | ), |
| 439 | ); |
| 440 | const summaries = [ |
| 441 | ...new Map( |
| 442 | [...(yield* withAcmRegion(listPage)), ...(yield* listPage)].map( |
| 443 | (summary) => [summary.CertificateArn, summary] as const, |
| 444 | ), |
| 445 | ).values(), |
| 446 | ]; |
| 447 | const rows = yield* Effect.forEach( |
| 448 | summaries, |
| 449 | (summary) => |
| 450 | Effect.gen(function* () { |
| 451 | if (!summary.CertificateArn) { |
| 452 | return undefined; |
| 453 | } |
| 454 | const detail = yield* describeCertificate( |
| 455 | summary.CertificateArn, |
| 456 | ); |
| 457 | if (!detail?.CertificateArn) { |
| 458 | return undefined; |
| 459 | } |
| 460 | const tags = yield* listCertificateTags( |
| 461 | detail.CertificateArn, |
| 462 | ); |
| 463 | return toAttrs( |
| 464 | { |
| 465 | domainName: detail.DomainName ?? "", |
| 466 | validationMethod: |
| 467 | detail.DomainValidationOptions?.[0]?.ValidationMethod, |
| 468 | }, |
| 469 | detail, |
| 470 | tags, |
| 471 | ); |
| 472 | }), |
| 473 | { concurrency: 10 }, |
| 474 | ); |
| 475 | return rows.filter( |
| 476 | (row): row is ReturnType<typeof toAttrs> => row !== undefined, |
| 477 | ); |
| 478 | }), |
| 479 | diff: Effect.fn(function* ({ |
| 480 | olds, |
| 481 | news: _news, |
| 482 | oldBindings, |
| 483 | newBindings: _newBindings, |
| 484 | }) { |
| 485 | if (!isResolved(_news) || !isResolved(_newBindings)) { |
| 486 | return undefined; |
| 487 | } |
| 488 | const news = _news as typeof olds; |
| 489 | const newBindings = |
| 490 | _newBindings as ResourceBinding<CertificateBinding>[]; |
| 491 | if ( |
| 492 | olds.domainName !== news.domainName || |
| 493 | // ACM certificates are immutable: the SAN set — declared props |
| 494 | // plus SANs contributed through the binding contract — cannot |
| 495 | // change in place, so any delta plans a replacement. |
| 496 | !deepEqual( |
| 497 | normalizeSanList( |
| 498 | resolveEffectiveSans(olds.subjectAlternativeNames, oldBindings), |
| 499 | ), |
| 500 | normalizeSanList( |
| 501 | resolveEffectiveSans(news.subjectAlternativeNames, newBindings), |
| 502 | ), |
| 503 | ) || |
| 504 | (olds.validationMethod ?? defaultValidationMethod) !== |
| 505 | (news.validationMethod ?? defaultValidationMethod) || |
| 506 | // An undefined side means "inferred" — only two explicit, |
| 507 | // differing zones are a replacement. |
| 508 | (olds.hostedZoneId !== undefined && |
| 509 | news.hostedZoneId !== undefined && |
| 510 | olds.hostedZoneId !== news.hostedZoneId) || |
| 511 | olds.keyAlgorithm !== news.keyAlgorithm || |
| 512 | // Certificates cannot move regions — a region change replaces. |
| 513 | (olds.region ?? ACM_REGION) !== (news.region ?? ACM_REGION) || |
| 514 | // Exportability is fixed at request time — ACM rejects |
| 515 | // `UpdateCertificateOptions` for it ("Export option for |
| 516 | // certificates cannot be updated"). |
| 517 | (olds.export ?? "DISABLED") !== (news.export ?? "DISABLED") |
| 518 | ) { |
| 519 | return { action: "replace" } as const; |
| 520 | } |
| 521 | // `certificateTransparencyLoggingPreference` is intentionally NOT |
| 522 | // a replacement trigger — it is updated in place via |
| 523 | // `UpdateCertificateOptions` in `reconcile`. |
| 524 | }), |
| 525 | read: Effect.fn(function* ({ id, olds, output }) { |
| 526 | // `olds.domainName` may be `undefined` when a `creating` row was |
| 527 | // persisted before upstream Outputs resolved — without a domain |
| 528 | // there is nothing to search for, so report "not found" and let |
| 529 | // the engine re-drive the create (reconcile finds any managed |
| 530 | // certificate by tags before requesting a new one). |
| 531 | const certificate = output?.certificateArn |
| 532 | ? yield* describeCertificate(output.certificateArn) |
| 533 | : olds?.domainName !== undefined |
| 534 | ? yield* findManagedCertificate(id, olds) |
| 535 | : undefined; |
| 536 | |
| 537 | if (!certificate?.CertificateArn) { |
| 538 | return undefined; |
| 539 | } |
| 540 | |
| 541 | const tags = yield* listCertificateTags(certificate.CertificateArn); |
| 542 | return toAttrs( |
| 543 | olds ?? { domainName: certificate.DomainName! }, |
| 544 | certificate, |
| 545 | tags, |
| 546 | ); |
| 547 | }), |
| 548 | reconcile: Effect.fn(function* ({ |
| 549 | id, |
| 550 | instanceId, |
| 551 | news: _news, |
| 552 | output, |
| 553 | session, |
| 554 | bindings, |
| 555 | }) { |
| 556 | // Fold bound SANs (see `CertificateBinding`) into the desired |
| 557 | // props up front so every downstream step — managed-certificate |
| 558 | // lookup, request, attrs — sees the effective SAN set. |
| 559 | const news: typeof _news = { |
| 560 | ..._news, |
| 561 | subjectAlternativeNames: resolveEffectiveSans( |
| 562 | _news.subjectAlternativeNames, |
| 563 | bindings, |
| 564 | ), |
| 565 | }; |
| 566 | const internalTags = yield* createInternalTags(id); |
| 567 | const desiredTags = { ...internalTags, ...news.tags }; |
| 568 | |
| 569 | // Observe — find the live certificate. Domain + SAN combo plus |
| 570 | // alchemy-owned tags identify a certificate uniquely; if we have |
| 571 | // a cached ARN, prefer that as the fast path. ACM certificates |
| 572 | // can't be renamed, and most fields trigger replace via diff, |
| 573 | // so the only real ensure path is "request if no managed |
| 574 | // certificate exists". |
| 575 | let certificate = output?.certificateArn |
| 576 | ? yield* describeCertificate(output.certificateArn) |
| 577 | : undefined; |
| 578 | if (!certificate?.CertificateArn) { |
| 579 | certificate = yield* findManagedCertificate(id, news); |
| 580 | } |
| 581 | |
| 582 | // Ensure — request a new certificate if none exists. The |
| 583 | // `IdempotencyToken` (derived from `instanceId`) makes the |
| 584 | // request safe to retry. |
| 585 | if (!certificate?.CertificateArn) { |
| 586 | certificate = yield* withCertRegion(news.region)( |
| 587 | acm |
| 588 | .requestCertificate({ |
| 589 | DomainName: news.domainName, |
| 590 | SubjectAlternativeNames: news.subjectAlternativeNames, |
| 591 | ValidationMethod: |
| 592 | news.validationMethod ?? defaultValidationMethod, |
| 593 | KeyAlgorithm: news.keyAlgorithm, |
| 594 | Options: |
| 595 | news.certificateTransparencyLoggingPreference || news.export |
| 596 | ? { |
| 597 | CertificateTransparencyLoggingPreference: |
| 598 | news.certificateTransparencyLoggingPreference, |
| 599 | Export: news.export, |
| 600 | } |
| 601 | : undefined, |
| 602 | IdempotencyToken: instanceId |
| 603 | .replaceAll(/[^a-zA-Z0-9]/g, "") |
| 604 | .slice(0, 32), |
| 605 | Tags: createTagsList(desiredTags), |
| 606 | }) |
| 607 | .pipe( |
| 608 | Effect.flatMap((response) => |
| 609 | response.CertificateArn |
| 610 | ? describeCertificate(response.CertificateArn).pipe( |
| 611 | Effect.map((detail) => detail!), |
| 612 | ) |
| 613 | : Effect.fail( |
| 614 | new Error( |
| 615 | "requestCertificate returned no certificate ARN", |
| 616 | ), |
| 617 | ), |
| 618 | ), |
| 619 | ), |
| 620 | ); |
| 621 | } |
| 622 | |
| 623 | if (!certificate?.CertificateArn) { |
| 624 | return yield* Effect.fail( |
| 625 | new Error("Failed to obtain ACM certificate"), |
| 626 | ); |
| 627 | } |
| 628 | |
| 629 | const certificateArn = certificate.CertificateArn; |
| 630 | yield* session.note(certificateArn); |
| 631 | |
| 632 | // Sync DNS validation: ensure validation records are upserted and |
| 633 | // the cert reaches `ISSUED`. The zone is the explicit |
| 634 | // `hostedZoneId` when given; otherwise the most specific public |
| 635 | // zone containing `domainName` is inferred. When neither yields a |
| 636 | // zone, validation is left to the caller (external DNS) and the |
| 637 | // certificate is returned pending — the pre-inference behavior. |
| 638 | // For an already-issued cert this is a fast-path: we only wait |
| 639 | // for validation records when the cert isn't already issued. |
| 640 | if ( |
| 641 | (news.validationMethod ?? defaultValidationMethod) === "DNS" && |
| 642 | certificate.Status !== "ISSUED" |
| 643 | ) { |
| 644 | const validationZoneId = |
| 645 | news.hostedZoneId ?? |
| 646 | (yield* findPublicHostedZoneId(news.domainName)); |
| 647 | if (validationZoneId !== undefined) { |
| 648 | const withRecords = |
| 649 | yield* waitForValidationRecords(certificateArn); |
| 650 | yield* upsertValidationRecords(validationZoneId, withRecords); |
| 651 | certificate = yield* waitForIssued(certificateArn); |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | // Sync options — only the CT logging preference is mutable in |
| 656 | // place via UpdateCertificateOptions (ACM rejects updating the |
| 657 | // export option; diff treats an `export` change as replacement). |
| 658 | // Diff OBSERVED options (adoption may hand us a certificate with |
| 659 | // foreign options); only call the API when an explicitly-desired |
| 660 | // value differs, and omit `Export` so the fixed option is left |
| 661 | // untouched. |
| 662 | const observedOptions = certificate.Options ?? {}; |
| 663 | const wantsCtChange = |
| 664 | news.certificateTransparencyLoggingPreference !== undefined && |
| 665 | news.certificateTransparencyLoggingPreference !== |
| 666 | observedOptions.CertificateTransparencyLoggingPreference; |
| 667 | if (wantsCtChange) { |
| 668 | yield* withCertRegion(regionOfCertificateArn(certificateArn))( |
| 669 | acm.updateCertificateOptions({ |
| 670 | CertificateArn: certificateArn, |
| 671 | Options: { |
| 672 | CertificateTransparencyLoggingPreference: |
| 673 | news.certificateTransparencyLoggingPreference, |
| 674 | }, |
| 675 | }), |
| 676 | ); |
| 677 | certificate = (yield* describeCertificate(certificateArn))!; |
| 678 | } |
| 679 | |
| 680 | // Sync tags — diff observed cloud tags against desired so |
| 681 | // adoption rewrites ownership tags correctly. |
| 682 | const observedTags = yield* listCertificateTags(certificateArn); |
| 683 | const { removed, upsert } = diffTags(observedTags, desiredTags); |
| 684 | |
| 685 | if (upsert.length > 0) { |
| 686 | yield* withCertRegion(regionOfCertificateArn(certificateArn))( |
| 687 | acm.addTagsToCertificate({ |
| 688 | CertificateArn: certificateArn, |
| 689 | Tags: upsert, |
| 690 | }), |
| 691 | ); |
| 692 | } |
| 693 | if (removed.length > 0) { |
| 694 | yield* withCertRegion(regionOfCertificateArn(certificateArn))( |
| 695 | acm.removeTagsFromCertificate({ |
| 696 | CertificateArn: certificateArn, |
| 697 | Tags: removed.map((Key) => ({ Key })), |
| 698 | }), |
| 699 | ); |
| 700 | } |
| 701 | |
| 702 | // Re-read so the returned attributes reflect any tag mutation. |
| 703 | const finalTags = yield* listCertificateTags(certificateArn); |
| 704 | return toAttrs(news, certificate, finalTags); |
| 705 | }), |
| 706 | delete: Effect.fn(function* ({ output }) { |
| 707 | yield* withCertRegion(regionOfCertificateArn(output.certificateArn))( |
| 708 | acm |
| 709 | .deleteCertificate({ |
| 710 | CertificateArn: output.certificateArn, |
| 711 | }) |
| 712 | .pipe( |
| 713 | // `ResourceInUseException` covers the certificate-swap path: |
| 714 | // when a SAN change replaces the certificate, CloudFront can |
| 715 | // keep reporting the detached old certificate as in-use for a |
| 716 | // few minutes after the distribution update deploys — ride |
| 717 | // that out with a bounded wait instead of failing the delete. |
| 718 | Effect.retry({ |
| 719 | while: (e): boolean => |
| 720 | e._tag === "ConflictException" || |
| 721 | e._tag === "ResourceInUseException", |
| 722 | schedule: Schedule.max([ |
| 723 | Schedule.fixed("10 seconds"), |
| 724 | Schedule.recurs(30), |
| 725 | ]), |
| 726 | }), |
| 727 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 728 | ), |
| 729 | ); |
| 730 | }), |
| 731 | }; |
| 732 | }), |
| 733 | ); |
| 734 | |
| 735 | /** @internal */ |
| 736 | export const waitForRoute53Change = Effect.fn(function* (changeId: string) { |
| 737 | return yield* route53 |
| 738 | .getChange({ |
| 739 | Id: changeId.replace(/^\/change\//, ""), |
| 740 | }) |
| 741 | .pipe( |
| 742 | Effect.map((response) => response.ChangeInfo), |
| 743 | Effect.flatMap((changeInfo) => |
| 744 | changeInfo.Status === "INSYNC" |
| 745 | ? Effect.succeed(changeInfo) |
| 746 | : Effect.fail(new Error("Route53ChangePending")), |
| 747 | ), |
| 748 | Effect.retry({ |
| 749 | while: (error) => |
| 750 | error instanceof Error && error.message === "Route53ChangePending", |
| 751 | schedule: Schedule.max([ |
| 752 | Schedule.fixed("2 seconds"), |
| 753 | Schedule.recurs(60), |
| 754 | ]), |
| 755 | }), |
| 756 | ); |
| 757 | }); |
| 758 | |
| 759 | const ACM_REGION = "us-east-1" as const; |
| 760 | const defaultValidationMethod = "DNS" as const; |
| 761 | |
| 762 | /** |
| 763 | * Region an existing certificate lives in, parsed from its ARN |
| 764 | * (`arn:aws:acm:{region}:{account}:certificate/...`). |
| 765 | */ |
| 766 | const regionOfCertificateArn = (certificateArn: string) => |
| 767 | certificateArn.split(":")[3] || ACM_REGION; |
| 768 | |
| 769 | const withAcmRegion = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 770 | // `AwsRegion`'s service value is an `Effect<RegionName>` (see |
| 771 | // `@distilled.cloud/aws/Region`), so it must be provided as an effect, not a |
| 772 | // bare string — providing a raw string yields a primitive into the run loop. |
| 773 | effect.pipe(Effect.provideService(AwsRegion, Effect.succeed(ACM_REGION))); |
| 774 | |
| 775 | /** |
| 776 | * Pin ACM calls to the certificate's region: the props-requested region for |
| 777 | * new certificates (default `us-east-1`), or the region parsed from an |
| 778 | * existing certificate's ARN. |
| 779 | */ |
| 780 | const withCertRegion = |
| 781 | (region: string | undefined) => |
| 782 | <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 783 | effect.pipe( |
| 784 | Effect.provideService( |
| 785 | AwsRegion, |
| 786 | Effect.succeed((region ?? ACM_REGION) as typeof ACM_REGION), |
| 787 | ), |
| 788 | ); |
| 789 | |
| 790 | const normalizeHostedZoneId = (hostedZoneId: string) => |
| 791 | hostedZoneId.replace(/^\/hostedzone\//, ""); |
| 792 | |
| 793 | const normalizeSanList = (names: string[] | undefined) => |
| 794 | [...(names ?? [])].sort((a, b) => a.localeCompare(b)); |
| 795 | |
| 796 | const toTagRecord = (tags: acm.Tag[] | undefined) => |
| 797 | Object.fromEntries( |
| 798 | (tags ?? []) |
| 799 | .filter( |
| 800 | (tag): tag is { Key: string; Value: string } => |
| 801 | typeof tag.Key === "string" && typeof tag.Value === "string", |
| 802 | ) |
| 803 | .map((tag) => [tag.Key, tag.Value]), |
| 804 | ); |
| 805 | |
| 806 | const toAttrs = ( |
| 807 | props: CertificateProps, |
| 808 | detail: acm.CertificateDetail, |
| 809 | tags: Record<string, string>, |
| 810 | ) => ({ |
| 811 | certificateArn: detail.CertificateArn!, |
| 812 | domainName: detail.DomainName ?? props.domainName, |
| 813 | subjectAlternativeNames: detail.SubjectAlternativeNames ?? [], |
| 814 | status: detail.Status, |
| 815 | domainValidationOptions: detail.DomainValidationOptions ?? [], |
| 816 | validationMethod: props.validationMethod ?? defaultValidationMethod, |
| 817 | keyAlgorithm: detail.KeyAlgorithm ?? props.keyAlgorithm, |
| 818 | hostedZoneId: props.hostedZoneId |
| 819 | ? normalizeHostedZoneId(props.hostedZoneId) |
| 820 | : undefined, |
| 821 | certificateTransparencyLoggingPreference: |
| 822 | detail.Options?.CertificateTransparencyLoggingPreference, |
| 823 | export: detail.Options?.Export, |
| 824 | tags, |
| 825 | issuedAt: detail.IssuedAt, |
| 826 | notAfter: detail.NotAfter, |
| 827 | }); |
| 828 | |
| 829 | const isTerminalFailure = (status: acm.CertificateStatus | undefined) => |
| 830 | status === "FAILED" || status === "VALIDATION_TIMED_OUT"; |