| 1 | import type { |
| 2 | CertificateCheckResponse, |
| 3 | CertificateDetail, |
| 4 | CertificateSummary, |
| 5 | CertificateValidation as FlyCertificateValidation, |
| 6 | DNSRequirements, |
| 7 | } from "@distilled.cloud/fly-io/machines"; |
| 8 | import * as machines from "@distilled.cloud/fly-io/machines"; |
| 9 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | import { isResolved } from "../Diff.ts"; |
| 14 | import * as Provider from "../Provider.ts"; |
| 15 | import { Resource } from "../Resource.ts"; |
| 16 | import { listOwnedApps } from "./App.ts"; |
| 17 | import type { App } from "./App.ts"; |
| 18 | import type { Providers } from "./Providers.ts"; |
| 19 | |
| 20 | /** |
| 21 | * A resource-valued prop: the resource itself, or an Effect that produces |
| 22 | * it (so `yield* App(...)` and `App(...)` both type-check). |
| 23 | */ |
| 24 | type Ref<T> = T | Effect.Effect<T, never, Providers>; |
| 25 | |
| 26 | export type CertificateKind = "acme" | "custom"; |
| 27 | |
| 28 | export type CertificateSource = "custom" | "fly"; |
| 29 | |
| 30 | export type CertificateDnsRequirements = { |
| 31 | a?: string[]; |
| 32 | aaaa?: string[]; |
| 33 | acmeChallenge?: { name?: string; target?: string }; |
| 34 | cname?: string; |
| 35 | ownership?: { appValue?: string; name?: string; orgValue?: string }; |
| 36 | }; |
| 37 | |
| 38 | export type CertificateValidationState = { |
| 39 | alpnConfigured?: boolean; |
| 40 | dnsConfigured?: boolean; |
| 41 | httpConfigured?: boolean; |
| 42 | ownershipTxtConfigured?: boolean; |
| 43 | }; |
| 44 | |
| 45 | export interface CertificateProps { |
| 46 | /** |
| 47 | * Parent Fly App. Accepts a `Fly.App` resource or an Effect that |
| 48 | * produces one. Changing the App replaces the Certificate. |
| 49 | */ |
| 50 | app: Ref<App>; |
| 51 | /** |
| 52 | * Hostname this Certificate covers. Identity of the resource. |
| 53 | * Changing it replaces the Certificate. |
| 54 | */ |
| 55 | hostname: string; |
| 56 | /** |
| 57 | * How the Certificate is issued. `"acme"` requests a Let's Encrypt |
| 58 | * certificate (`createAppAcmeCertificate`). `"custom"` uploads a PEM |
| 59 | * (`createAppCustomCertificate`). Changing kind replaces. |
| 60 | * |
| 61 | * @default "acme" |
| 62 | */ |
| 63 | kind?: CertificateKind; |
| 64 | /** |
| 65 | * PEM-encoded certificate chain. Required when `kind` is `"custom"`. |
| 66 | * Updating it re-uploads in place (custom create upsert, or |
| 67 | * delete+create if the API conflicts). |
| 68 | */ |
| 69 | fullchain?: string; |
| 70 | /** |
| 71 | * PEM-encoded private key. Required when `kind` is `"custom"`. Wrap |
| 72 | * with `Redacted.make` to keep it out of logs. Never stored in |
| 73 | * attributes. |
| 74 | */ |
| 75 | privateKey?: Redacted.Redacted<string> | string; |
| 76 | } |
| 77 | |
| 78 | export type Certificate = Resource< |
| 79 | "Fly.Certificate", |
| 80 | CertificateProps, |
| 81 | { |
| 82 | /** Physical Fly App name the Certificate is attached to. */ |
| 83 | appName: string; |
| 84 | /** Hostname this Certificate covers. Identity of the resource. */ |
| 85 | hostname: string; |
| 86 | /** Observed status (`active`, `pending_validation`, …). */ |
| 87 | status: string | undefined; |
| 88 | /** Whether DNS/ownership validation has completed. */ |
| 89 | configured: boolean | undefined; |
| 90 | /** Whether ACME issuance has been requested for this hostname. */ |
| 91 | acmeRequested: boolean | undefined; |
| 92 | /** DNS records Fly expects for validation. */ |
| 93 | dnsRequirements: CertificateDnsRequirements | undefined; |
| 94 | /** Per-challenge validation flags. */ |
| 95 | validation: CertificateValidationState | undefined; |
| 96 | /** Observed source: `"custom"` for uploaded PEMs, `"fly"` for ACME. */ |
| 97 | source: CertificateSource; |
| 98 | }, |
| 99 | never, |
| 100 | Providers |
| 101 | >; |
| 102 | |
| 103 | /** |
| 104 | * A Fly.Certificate covers a hostname on an {@link App}. Default |
| 105 | * `kind` is `"acme"` (Let's Encrypt). `"custom"` uploads a PEM. |
| 106 | * |
| 107 | * IPs and certificates attach to the App. A {@link Service} publishes |
| 108 | * ports. Fly's proxy terminates TLS on 443 once the certificate is |
| 109 | * `configured`. |
| 110 | * |
| 111 | * @see https://fly.io/docs/machines/api/certificates-resource/ |
| 112 | * |
| 113 | * ### ACME certificates |
| 114 | * Request Let's Encrypt for a hostname. The Service does not change. |
| 115 | * Yield the certificate in the Stack. Point DNS at the App. |
| 116 | * |
| 117 | * **Example:** Let's Encrypt |
| 118 | * ```typescript |
| 119 | * export const Www = Fly.Certificate("Www", { |
| 120 | * app: Site, |
| 121 | * hostname: "www.example.com", |
| 122 | * }); |
| 123 | * ``` |
| 124 | * |
| 125 | * :::caution[Changing `hostname` replaces the Certificate] |
| 126 | * Hostname is the identity. The old hostname is deleted, then the new |
| 127 | * one is created. |
| 128 | * ::: |
| 129 | * |
| 130 | * :::caution[Changing `app` or `kind` replaces the Certificate] |
| 131 | * The certificate is created on the new App or with the new issuer. |
| 132 | * ::: |
| 133 | * |
| 134 | * ### DNS |
| 135 | * Point an A record at a `shared_v4` {@link IpAssignment} and an AAAA |
| 136 | * at `v6`. Plus whatever `dnsRequirements` lists for the ACME |
| 137 | * challenge. Alchemy re-checks via `checkAppCertificate` while |
| 138 | * `configured` is false. |
| 139 | * |
| 140 | * Observed attrs include `status`, `configured`, `acmeRequested`, |
| 141 | * `dnsRequirements`, and `validation`. |
| 142 | * |
| 143 | * **Example:** Yield next to a Service |
| 144 | * ```typescript |
| 145 | * export default Alchemy.Stack( |
| 146 | * "MyApp", |
| 147 | * { providers: Fly.providers(), state: Alchemy.localState() }, |
| 148 | * Effect.gen(function* () { |
| 149 | * const api = yield* Api; |
| 150 | * const ip = yield* PublicIp; |
| 151 | * const v6 = yield* V6; |
| 152 | * const www = yield* Www; |
| 153 | * return { |
| 154 | * url: api.url, |
| 155 | * ip: ip.ip, |
| 156 | * v6: v6.ip, |
| 157 | * dns: www.dnsRequirements, |
| 158 | * }; |
| 159 | * }), |
| 160 | * ); |
| 161 | * ``` |
| 162 | * |
| 163 | * ### Custom certificates |
| 164 | * `"custom"` uploads `fullchain` and `privateKey`. Wrap the key with |
| 165 | * `Redacted.make` so it never logs. Never stored in attributes. |
| 166 | * Updating the PEM re-uploads in place. |
| 167 | * |
| 168 | * **Example:** Upload a PEM |
| 169 | * ```typescript |
| 170 | * export const Www = Fly.Certificate("Www", { |
| 171 | * app: Site, |
| 172 | * hostname: "www.example.com", |
| 173 | * kind: "custom", |
| 174 | * fullchain: pem, |
| 175 | * privateKey: Redacted.make(key), |
| 176 | * }); |
| 177 | * ``` |
| 178 | * |
| 179 | * @resource |
| 180 | * @product Certificate |
| 181 | */ |
| 182 | export const Certificate = Resource<Certificate>("Fly.Certificate"); |
| 183 | |
| 184 | export class CertificateNotCreated extends Data.TaggedError( |
| 185 | "Fly.CertificateNotCreated", |
| 186 | )<{ |
| 187 | appName: string; |
| 188 | hostname: string; |
| 189 | }> {} |
| 190 | |
| 191 | export class CertificateAppMissing extends Data.TaggedError( |
| 192 | "Fly.CertificateAppMissing", |
| 193 | )<{ |
| 194 | hostname: string; |
| 195 | }> {} |
| 196 | |
| 197 | export class CertificateMaterialRequired extends Data.TaggedError( |
| 198 | "Fly.CertificateMaterialRequired", |
| 199 | )<{ |
| 200 | hostname: string; |
| 201 | }> {} |
| 202 | |
| 203 | type ObservedCert = CertificateDetail | CertificateCheckResponse; |
| 204 | |
| 205 | const appNameOf = (value: unknown): string | undefined => { |
| 206 | if (value === null || typeof value !== "object") return undefined; |
| 207 | const rec = value as { appName?: unknown; name?: unknown }; |
| 208 | if (typeof rec.appName === "string" && rec.appName.length > 0) { |
| 209 | return rec.appName; |
| 210 | } |
| 211 | if (typeof rec.name === "string" && rec.name.length > 0) { |
| 212 | return rec.name; |
| 213 | } |
| 214 | return undefined; |
| 215 | }; |
| 216 | |
| 217 | const unwrapSecret = ( |
| 218 | value: Redacted.Redacted<string> | string | undefined, |
| 219 | ): string | undefined => { |
| 220 | if (value === undefined) return undefined; |
| 221 | return Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 222 | }; |
| 223 | |
| 224 | const normalizePem = (pem: string | undefined): string => |
| 225 | (pem ?? "").replace(/\r\n/g, "\n").trim(); |
| 226 | |
| 227 | const desiredKind = (props: Pick<CertificateProps, "kind">): CertificateKind => |
| 228 | props.kind ?? "acme"; |
| 229 | |
| 230 | const kindOf = (source: CertificateSource): CertificateKind => |
| 231 | source === "custom" ? "custom" : "acme"; |
| 232 | |
| 233 | const sourceOf = (detail: ObservedCert): CertificateSource => { |
| 234 | const entries = detail.certificates ?? []; |
| 235 | if (entries.some((entry) => entry.source === "custom")) return "custom"; |
| 236 | if (entries.some((entry) => entry.source === "fly")) return "fly"; |
| 237 | return detail.acme_requested === true ? "fly" : "custom"; |
| 238 | }; |
| 239 | |
| 240 | const sourceFromSummary = (summary: CertificateSummary): CertificateSource => |
| 241 | summary.has_custom_certificate === true ? "custom" : "fly"; |
| 242 | |
| 243 | const toDnsRequirements = ( |
| 244 | req: DNSRequirements | undefined, |
| 245 | ): CertificateDnsRequirements | undefined => { |
| 246 | if (req === undefined) return undefined; |
| 247 | return { |
| 248 | a: req.a, |
| 249 | aaaa: req.aaaa, |
| 250 | acmeChallenge: |
| 251 | req.acme_challenge === undefined |
| 252 | ? undefined |
| 253 | : { |
| 254 | name: req.acme_challenge.name, |
| 255 | target: req.acme_challenge.target, |
| 256 | }, |
| 257 | cname: req.cname, |
| 258 | ownership: |
| 259 | req.ownership === undefined |
| 260 | ? undefined |
| 261 | : { |
| 262 | appValue: req.ownership.app_value, |
| 263 | name: req.ownership.name, |
| 264 | orgValue: req.ownership.org_value, |
| 265 | }, |
| 266 | }; |
| 267 | }; |
| 268 | |
| 269 | const toValidation = ( |
| 270 | validation: FlyCertificateValidation | undefined, |
| 271 | ): CertificateValidationState | undefined => { |
| 272 | if (validation === undefined) return undefined; |
| 273 | return { |
| 274 | alpnConfigured: validation.alpn_configured, |
| 275 | dnsConfigured: validation.dns_configured, |
| 276 | httpConfigured: validation.http_configured, |
| 277 | ownershipTxtConfigured: validation.ownership_txt_configured, |
| 278 | }; |
| 279 | }; |
| 280 | |
| 281 | const toAttrs = ( |
| 282 | appName: string, |
| 283 | detail: ObservedCert, |
| 284 | fallbackHostname?: string, |
| 285 | ): Certificate["Attributes"] => ({ |
| 286 | appName, |
| 287 | hostname: detail.hostname ?? fallbackHostname ?? "", |
| 288 | status: detail.status, |
| 289 | configured: detail.configured, |
| 290 | acmeRequested: detail.acme_requested, |
| 291 | dnsRequirements: toDnsRequirements(detail.dns_requirements), |
| 292 | validation: toValidation(detail.validation), |
| 293 | source: sourceOf(detail), |
| 294 | }); |
| 295 | |
| 296 | const toAttrsFromSummary = ( |
| 297 | appName: string, |
| 298 | summary: CertificateSummary, |
| 299 | ): Certificate["Attributes"] | undefined => { |
| 300 | const hostname = summary.hostname; |
| 301 | if (hostname === undefined || hostname.length === 0) return undefined; |
| 302 | return { |
| 303 | appName, |
| 304 | hostname, |
| 305 | status: summary.status, |
| 306 | configured: summary.configured, |
| 307 | acmeRequested: summary.acme_requested, |
| 308 | dnsRequirements: undefined, |
| 309 | validation: { |
| 310 | alpnConfigured: summary.acme_alpn_configured, |
| 311 | dnsConfigured: summary.acme_dns_configured, |
| 312 | httpConfigured: summary.acme_http_configured, |
| 313 | ownershipTxtConfigured: summary.ownership_txt_configured, |
| 314 | }, |
| 315 | source: sourceFromSummary(summary), |
| 316 | }; |
| 317 | }; |
| 318 | |
| 319 | const getByHostname = (appName: string, hostname: string) => |
| 320 | machines |
| 321 | .getAppCertificate({ app_name: appName, hostname }) |
| 322 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 323 | |
| 324 | const listAppCertificates = (appName: string) => |
| 325 | Effect.gen(function* () { |
| 326 | const all: CertificateSummary[] = []; |
| 327 | let cursor: string | undefined; |
| 328 | for (let i = 0; i < 8; i++) { |
| 329 | const page = yield* machines |
| 330 | .listAppCertificates({ |
| 331 | app_name: appName, |
| 332 | cursor, |
| 333 | limit: 100, |
| 334 | }) |
| 335 | .pipe( |
| 336 | Effect.catchTag(["NotFound", "Forbidden"], () => |
| 337 | Effect.succeed({ |
| 338 | certificates: [] as CertificateSummary[], |
| 339 | next_cursor: undefined, |
| 340 | }), |
| 341 | ), |
| 342 | ); |
| 343 | all.push(...(page.certificates ?? [])); |
| 344 | if (page.next_cursor === undefined || page.next_cursor.length === 0) { |
| 345 | break; |
| 346 | } |
| 347 | cursor = page.next_cursor; |
| 348 | } |
| 349 | return all; |
| 350 | }); |
| 351 | |
| 352 | const waitUntilGone = (appName: string, hostname: string) => |
| 353 | getByHostname(appName, hostname).pipe( |
| 354 | Effect.map((found) => found === undefined), |
| 355 | Effect.repeat({ |
| 356 | schedule: Schedule.spaced("1 second"), |
| 357 | until: (gone) => gone, |
| 358 | times: 8, |
| 359 | }), |
| 360 | ); |
| 361 | |
| 362 | const requireCustomMaterial = ( |
| 363 | hostname: string, |
| 364 | fullchain: string | undefined, |
| 365 | privateKey: string | undefined, |
| 366 | ) => |
| 367 | fullchain === undefined || |
| 368 | fullchain.length === 0 || |
| 369 | privateKey === undefined || |
| 370 | privateKey.length === 0 |
| 371 | ? new CertificateMaterialRequired({ hostname }) |
| 372 | : undefined; |
| 373 | |
| 374 | const createAcme = (appName: string, hostname: string) => |
| 375 | machines |
| 376 | .createAppAcmeCertificate({ |
| 377 | app_name: appName, |
| 378 | hostname, |
| 379 | }) |
| 380 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 381 | |
| 382 | const createCustom = ( |
| 383 | appName: string, |
| 384 | hostname: string, |
| 385 | fullchain: string, |
| 386 | privateKey: string, |
| 387 | ) => |
| 388 | machines |
| 389 | .createAppCustomCertificate({ |
| 390 | app_name: appName, |
| 391 | hostname, |
| 392 | fullchain, |
| 393 | private_key: privateKey, |
| 394 | }) |
| 395 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 396 | |
| 397 | const replaceCustom = ( |
| 398 | appName: string, |
| 399 | hostname: string, |
| 400 | fullchain: string, |
| 401 | privateKey: string, |
| 402 | ) => |
| 403 | Effect.gen(function* () { |
| 404 | yield* machines |
| 405 | .deleteAppCustomCertificate({ |
| 406 | app_name: appName, |
| 407 | hostname, |
| 408 | }) |
| 409 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 410 | yield* machines.createAppCustomCertificate({ |
| 411 | app_name: appName, |
| 412 | hostname, |
| 413 | fullchain, |
| 414 | private_key: privateKey, |
| 415 | }); |
| 416 | }); |
| 417 | |
| 418 | export const CertificateProvider = () => |
| 419 | Provider.succeed(Certificate, { |
| 420 | stables: ["appName", "hostname", "source"], |
| 421 | nuke: { dependsOn: ["Fly.App"] }, |
| 422 | |
| 423 | diff: Effect.fn(function* ({ news, output }) { |
| 424 | if (news === undefined || !isResolved(news)) return undefined; |
| 425 | if (output === undefined) return undefined; |
| 426 | const appName = appNameOf(news.app); |
| 427 | const appChanged = appName !== undefined && appName !== output.appName; |
| 428 | const hostnameChanged = news.hostname !== output.hostname; |
| 429 | const nextKind = desiredKind(news); |
| 430 | const kindChanged = nextKind !== kindOf(output.source); |
| 431 | if (appChanged || hostnameChanged || kindChanged) { |
| 432 | return { |
| 433 | action: "replace" as const, |
| 434 | // Same hostname cannot exist twice — delete the old kind first. |
| 435 | deleteFirst: !hostnameChanged && !appChanged, |
| 436 | }; |
| 437 | } |
| 438 | return undefined; |
| 439 | }), |
| 440 | |
| 441 | read: Effect.fn(function* ({ olds, output }) { |
| 442 | const appName = output?.appName ?? appNameOf(olds?.app); |
| 443 | const hostname = output?.hostname ?? olds?.hostname; |
| 444 | if (appName === undefined || hostname === undefined) return undefined; |
| 445 | const found = yield* getByHostname(appName, hostname); |
| 446 | if (found === undefined) return undefined; |
| 447 | return toAttrs(appName, found, hostname); |
| 448 | }), |
| 449 | |
| 450 | list: Effect.fn(function* () { |
| 451 | const apps = yield* listOwnedApps(); |
| 452 | const rows = yield* Effect.forEach( |
| 453 | apps, |
| 454 | (app) => |
| 455 | listAppCertificates(app.appName).pipe( |
| 456 | Effect.map((certs) => |
| 457 | certs.flatMap((item) => { |
| 458 | const attrs = toAttrsFromSummary(app.appName, item); |
| 459 | return attrs === undefined ? [] : [attrs]; |
| 460 | }), |
| 461 | ), |
| 462 | ), |
| 463 | { concurrency: 8 }, |
| 464 | ); |
| 465 | return rows.flat(); |
| 466 | }), |
| 467 | |
| 468 | reconcile: Effect.fn(function* ({ news, olds, output }) { |
| 469 | const props = news ?? ({} as CertificateProps); |
| 470 | const appName = appNameOf(props.app) ?? output?.appName; |
| 471 | const hostname = props.hostname ?? output?.hostname; |
| 472 | if (appName === undefined) { |
| 473 | return yield* new CertificateAppMissing({ |
| 474 | hostname: hostname ?? "", |
| 475 | }); |
| 476 | } |
| 477 | if (hostname === undefined || hostname.length === 0) { |
| 478 | return yield* new CertificateNotCreated({ |
| 479 | appName, |
| 480 | hostname: "", |
| 481 | }); |
| 482 | } |
| 483 | |
| 484 | const kind = desiredKind(props); |
| 485 | |
| 486 | // Observe by cached identity, then by desired hostname. |
| 487 | let current = |
| 488 | output?.hostname !== undefined |
| 489 | ? yield* getByHostname( |
| 490 | output.appName.length > 0 ? output.appName : appName, |
| 491 | output.hostname, |
| 492 | ) |
| 493 | : undefined; |
| 494 | if ( |
| 495 | current === undefined && |
| 496 | (output?.hostname !== hostname || output?.appName !== appName) |
| 497 | ) { |
| 498 | current = yield* getByHostname(appName, hostname); |
| 499 | } |
| 500 | |
| 501 | let createdThisPass = false; |
| 502 | if (current === undefined) { |
| 503 | if (kind === "custom") { |
| 504 | const fullchain = props.fullchain; |
| 505 | const privateKey = unwrapSecret(props.privateKey); |
| 506 | const missing = requireCustomMaterial( |
| 507 | hostname, |
| 508 | fullchain, |
| 509 | privateKey, |
| 510 | ); |
| 511 | if (missing !== undefined) return yield* missing; |
| 512 | const created = yield* createCustom( |
| 513 | appName, |
| 514 | hostname, |
| 515 | fullchain!, |
| 516 | privateKey!, |
| 517 | ); |
| 518 | current = created ?? (yield* getByHostname(appName, hostname)); |
| 519 | } else { |
| 520 | const created = yield* createAcme(appName, hostname); |
| 521 | current = created ?? (yield* getByHostname(appName, hostname)); |
| 522 | } |
| 523 | createdThisPass = true; |
| 524 | } |
| 525 | |
| 526 | if (current === undefined) { |
| 527 | return yield* new CertificateNotCreated({ appName, hostname }); |
| 528 | } |
| 529 | |
| 530 | if (kind === "custom") { |
| 531 | const fullchain = props.fullchain; |
| 532 | const privateKey = unwrapSecret(props.privateKey); |
| 533 | const missing = requireCustomMaterial(hostname, fullchain, privateKey); |
| 534 | if (missing !== undefined) return yield* missing; |
| 535 | const previousChain = olds?.fullchain; |
| 536 | const previousKey = unwrapSecret(olds?.privateKey); |
| 537 | const materialChanged = |
| 538 | olds === undefined || |
| 539 | normalizePem(fullchain) !== normalizePem(previousChain) || |
| 540 | normalizePem(privateKey) !== normalizePem(previousKey); |
| 541 | if (!createdThisPass && materialChanged) { |
| 542 | const upserted = yield* createCustom( |
| 543 | appName, |
| 544 | hostname, |
| 545 | fullchain!, |
| 546 | privateKey!, |
| 547 | ); |
| 548 | if (upserted === undefined) { |
| 549 | yield* replaceCustom(appName, hostname, fullchain!, privateKey!); |
| 550 | } |
| 551 | current = |
| 552 | upserted ?? (yield* getByHostname(appName, hostname)) ?? current; |
| 553 | } |
| 554 | } else if (current.configured !== true) { |
| 555 | const checked = yield* machines |
| 556 | .checkAppCertificate({ |
| 557 | app_name: appName, |
| 558 | hostname, |
| 559 | }) |
| 560 | .pipe( |
| 561 | Effect.catchTag(["NotFound", "BadRequest"], () => |
| 562 | Effect.succeed(undefined), |
| 563 | ), |
| 564 | ); |
| 565 | if (checked !== undefined) { |
| 566 | current = checked; |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | return toAttrs(appName, current, hostname); |
| 571 | }), |
| 572 | |
| 573 | delete: Effect.fn(function* ({ output }) { |
| 574 | const appName = output.appName; |
| 575 | const hostname = output.hostname; |
| 576 | if (appName.length === 0 || hostname.length === 0) return; |
| 577 | yield* machines |
| 578 | .deleteAppCertificate({ |
| 579 | app_name: appName, |
| 580 | hostname, |
| 581 | }) |
| 582 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 583 | yield* waitUntilGone(appName, hostname); |
| 584 | }), |
| 585 | }); |