| 1 | import * as privateca from "@distilled.cloud/gcp/privateca_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { tagRecord } from "../../Tags.ts"; |
| 12 | import { GcpEnvironment } from "../Environment.ts"; |
| 13 | import { waitForOperation as waitForGcpOperation } from "../Operation.ts"; |
| 14 | import { |
| 15 | createInternalLabels, |
| 16 | diffLabels, |
| 17 | hasAlchemyLabels, |
| 18 | stripInternalLabels, |
| 19 | toLabels, |
| 20 | } from "../Labels.ts"; |
| 21 | import type { Providers } from "../Providers.ts"; |
| 22 | |
| 23 | const DEFAULT_TYPE: privateca.CertificateAuthorityTypeEnum = "SELF_SIGNED"; |
| 24 | const DEFAULT_LIFETIME = "315360000s"; |
| 25 | const DEFAULT_ALGORITHM: privateca.KeyVersionSpecAlgorithmEnum = |
| 26 | "EC_P256_SHA256"; |
| 27 | const DEFAULT_DESIRED_STATE: DesiredState = "ENABLED"; |
| 28 | const MAX_NAME_LENGTH = 63; |
| 29 | const STEADY_STATES = new Set([ |
| 30 | "ENABLED", |
| 31 | "DISABLED", |
| 32 | "STAGED", |
| 33 | "AWAITING_USER_ACTIVATION", |
| 34 | ]); |
| 35 | |
| 36 | export type DesiredState = "ENABLED" | "DISABLED" | "STAGED"; |
| 37 | |
| 38 | export type KeyVersionSpec = { |
| 39 | /** |
| 40 | * Algorithm for a Google-managed Cloud KMS key. Managed keys use |
| 41 | * `HSM` protection. Immutable — changing it replaces the CA. |
| 42 | */ |
| 43 | algorithm?: privateca.KeyVersionSpecAlgorithmEnum | (string & {}); |
| 44 | /** |
| 45 | * Existing Cloud KMS CryptoKeyVersion |
| 46 | * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}/cryptoKeyVersions/{version}`. |
| 47 | * Immutable — changing it replaces the CA. |
| 48 | */ |
| 49 | cloudKmsKeyVersion?: string; |
| 50 | }; |
| 51 | |
| 52 | export type Subject = { |
| 53 | /** Common name of the subject. */ |
| 54 | commonName?: string; |
| 55 | /** Organization of the subject. */ |
| 56 | organization?: string; |
| 57 | /** Organizational unit of the subject. */ |
| 58 | organizationalUnit?: string; |
| 59 | /** Locality or city of the subject. */ |
| 60 | locality?: string; |
| 61 | /** Province, territory, or regional state of the subject. */ |
| 62 | province?: string; |
| 63 | /** Country code of the subject. */ |
| 64 | countryCode?: string; |
| 65 | /** Street address of the subject. */ |
| 66 | streetAddress?: string; |
| 67 | /** Postal code of the subject. */ |
| 68 | postalCode?: string; |
| 69 | }; |
| 70 | |
| 71 | export type SubjectAltNames = { |
| 72 | /** Fully-qualified DNS names. */ |
| 73 | dnsNames?: string[]; |
| 74 | /** RFC 3986 URIs. */ |
| 75 | uris?: string[]; |
| 76 | /** RFC 2822 email addresses. */ |
| 77 | emailAddresses?: string[]; |
| 78 | /** IPv4 or IPv6 addresses. */ |
| 79 | ipAddresses?: string[]; |
| 80 | }; |
| 81 | |
| 82 | export type SubjectConfig = { |
| 83 | /** Distinguished name fields. */ |
| 84 | subject?: Subject; |
| 85 | /** Subject alternative names. */ |
| 86 | subjectAltName?: SubjectAltNames; |
| 87 | }; |
| 88 | |
| 89 | export type KeyUsageOptions = { |
| 90 | digitalSignature?: boolean; |
| 91 | contentCommitment?: boolean; |
| 92 | keyEncipherment?: boolean; |
| 93 | dataEncipherment?: boolean; |
| 94 | keyAgreement?: boolean; |
| 95 | certSign?: boolean; |
| 96 | crlSign?: boolean; |
| 97 | encipherOnly?: boolean; |
| 98 | decipherOnly?: boolean; |
| 99 | }; |
| 100 | |
| 101 | export type ExtendedKeyUsageOptions = { |
| 102 | serverAuth?: boolean; |
| 103 | clientAuth?: boolean; |
| 104 | codeSigning?: boolean; |
| 105 | emailProtection?: boolean; |
| 106 | timeStamping?: boolean; |
| 107 | ocspSigning?: boolean; |
| 108 | }; |
| 109 | |
| 110 | export type KeyUsage = { |
| 111 | /** High-level key uses (digital signature, cert sign, …). */ |
| 112 | baseKeyUsage?: KeyUsageOptions; |
| 113 | /** Extended key uses (server auth, client auth, …). */ |
| 114 | extendedKeyUsage?: ExtendedKeyUsageOptions; |
| 115 | }; |
| 116 | |
| 117 | export type CaOptions = { |
| 118 | /** |
| 119 | * Whether this certificate is a CA. Must be `true` for a |
| 120 | * CertificateAuthority. |
| 121 | */ |
| 122 | isCa?: boolean; |
| 123 | /** |
| 124 | * Maximum depth of subordinate CA certificates allowed. Omit to leave |
| 125 | * the path-length constraint off the certificate. |
| 126 | */ |
| 127 | maxIssuerPathLength?: number; |
| 128 | }; |
| 129 | |
| 130 | export type X509Config = { |
| 131 | /** CA basic-constraints options. */ |
| 132 | caOptions?: CaOptions; |
| 133 | /** Key usage extension. */ |
| 134 | keyUsage?: KeyUsage; |
| 135 | /** OCSP endpoints in the Authority Information Access extension. */ |
| 136 | aiaOcspServers?: string[]; |
| 137 | }; |
| 138 | |
| 139 | export type CertificateConfig = { |
| 140 | /** Subject and SAN fields written into the CA certificate. Immutable. */ |
| 141 | subjectConfig?: SubjectConfig; |
| 142 | /** X.509 extensions written into the CA certificate. Immutable. */ |
| 143 | x509Config?: X509Config; |
| 144 | }; |
| 145 | |
| 146 | export type UserDefinedAccessUrls = { |
| 147 | /** URLs where CRL information is published. */ |
| 148 | crlAccessUrls?: string[]; |
| 149 | /** URLs where the issuer CA certificate may be downloaded. */ |
| 150 | aiaIssuingCertificateUrls?: string[]; |
| 151 | }; |
| 152 | |
| 153 | export type SubordinateConfig = { |
| 154 | /** |
| 155 | * Issuing CertificateAuthority resource name |
| 156 | * `projects/{project}/locations/{location}/caPools/{caPool}/certificateAuthorities/{certificateAuthority}`. |
| 157 | */ |
| 158 | certificateAuthority?: string; |
| 159 | /** PEM certificate chain of the issuers (leaf-to-root), excluding this CA. */ |
| 160 | pemIssuerChain?: { |
| 161 | pemCertificates?: string[]; |
| 162 | }; |
| 163 | }; |
| 164 | |
| 165 | export type CertificateAuthorityProps = { |
| 166 | /** |
| 167 | * Parent CaPool. Full name |
| 168 | * `projects/{project}/locations/{location}/caPools/{caPool}` or the |
| 169 | * pool id (combined with `location`). Immutable — changing it replaces |
| 170 | * the CA. |
| 171 | */ |
| 172 | caPool: string; |
| 173 | /** |
| 174 | * Location (`us-central1`, …). Used when `caPool` is a bare id. |
| 175 | * Immutable — changing it replaces the CA. `US-CENTRAL1` is accepted |
| 176 | * and normalized to `us-central1`. |
| 177 | * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`) |
| 178 | */ |
| 179 | location?: string; |
| 180 | /** |
| 181 | * CertificateAuthority id (the last path segment). If omitted, a unique |
| 182 | * name is generated from the stack, stage, and logical id. Must match |
| 183 | * `[a-zA-Z0-9_-]{1,63}`. Immutable — changing it replaces the CA. |
| 184 | */ |
| 185 | certificateAuthorityId?: string; |
| 186 | /** |
| 187 | * `SELF_SIGNED` root or `SUBORDINATE`. Immutable — changing it |
| 188 | * replaces the CA. |
| 189 | * @default "SELF_SIGNED" |
| 190 | */ |
| 191 | type?: privateca.CertificateAuthorityTypeEnum | (string & {}); |
| 192 | /** |
| 193 | * Desired lifetime of the CA certificate (e.g. `"315360000s"`). |
| 194 | * Immutable — changing it replaces the CA. |
| 195 | * @default "315360000s" |
| 196 | */ |
| 197 | lifetime?: string; |
| 198 | /** |
| 199 | * Signing key. Either `algorithm` (Google-managed HSM key) or |
| 200 | * `cloudKmsKeyVersion`. Immutable — changing it replaces the CA. |
| 201 | * @default { algorithm: "EC_P256_SHA256" } |
| 202 | */ |
| 203 | keySpec?: KeyVersionSpec; |
| 204 | /** |
| 205 | * X.509 certificate config (subject + CA extensions). Immutable — |
| 206 | * changing it replaces the CA. When omitted, Alchemy fills a self-signed |
| 207 | * CA subject (`organization: "Alchemy"`, `commonName` = id) with |
| 208 | * `isCa: true` and cert/CRL sign usage. |
| 209 | */ |
| 210 | config?: CertificateConfig; |
| 211 | /** |
| 212 | * User labels. Alchemy ownership labels are merged in automatically. |
| 213 | */ |
| 214 | labels?: Record<string, string>; |
| 215 | /** |
| 216 | * Cloud Storage bucket that publishes the CA certificate and CRLs |
| 217 | * (bucket name only, no `gs://` prefix). Immutable. |
| 218 | */ |
| 219 | gcsBucket?: string; |
| 220 | /** |
| 221 | * User-defined AIA/CRL URLs. The service does not publish to these |
| 222 | * URLs; they are mirrored by the operator. Updatable. |
| 223 | */ |
| 224 | userDefinedAccessUrls?: UserDefinedAccessUrls; |
| 225 | /** |
| 226 | * Issuer information for a subordinate CA. Updatable, but the CA must |
| 227 | * continue to validate against the chain. |
| 228 | */ |
| 229 | subordinateConfig?: SubordinateConfig; |
| 230 | /** |
| 231 | * PEM CA certificate used to activate a `SUBORDINATE` CA that is in |
| 232 | * `AWAITING_USER_ACTIVATION`. Pair with `subordinateConfig`. |
| 233 | */ |
| 234 | pemCaCertificate?: string; |
| 235 | /** |
| 236 | * Target operational state. After create the API leaves the CA in |
| 237 | * `STAGED`. `ENABLED` calls enable; `DISABLED` calls disable; `STAGED` |
| 238 | * leaves the create-time state (it is not possible to return to |
| 239 | * `STAGED` from `ENABLED`/`DISABLED`). |
| 240 | * @default "ENABLED" |
| 241 | */ |
| 242 | desiredState?: DesiredState | (string & {}); |
| 243 | }; |
| 244 | |
| 245 | export type CertificateAuthorityAttrs = { |
| 246 | /** Full resource name `projects/{project}/locations/{location}/caPools/{caPool}/certificateAuthorities/{certificateAuthority}`. */ |
| 247 | name: string; |
| 248 | /** CertificateAuthority id (last path segment). */ |
| 249 | certificateAuthorityId: string; |
| 250 | /** Parent CaPool resource name. */ |
| 251 | caPool: string; |
| 252 | /** Project id. */ |
| 253 | project: string; |
| 254 | /** Location id (`us-central1`, …). */ |
| 255 | location: string; |
| 256 | /** `SELF_SIGNED` or `SUBORDINATE`. */ |
| 257 | type: string; |
| 258 | /** Server-reported state (`ENABLED`, `STAGED`, `DISABLED`, …). */ |
| 259 | state: string | undefined; |
| 260 | /** CaPool tier (`ENTERPRISE`, `DEVOPS`). */ |
| 261 | tier: string | undefined; |
| 262 | /** User labels (Alchemy ownership labels stripped). */ |
| 263 | labels: Record<string, string>; |
| 264 | /** CA certificate lifetime. */ |
| 265 | lifetime: string | undefined; |
| 266 | /** Signing-key spec currently applied. */ |
| 267 | keySpec: KeyVersionSpec | undefined; |
| 268 | /** Cloud Storage bucket used for publishing, if any. */ |
| 269 | gcsBucket: string | undefined; |
| 270 | /** PEM CA certificate chain (self-to-root). */ |
| 271 | pemCaCertificates: string[]; |
| 272 | /** User-defined AIA/CRL URLs. */ |
| 273 | userDefinedAccessUrls: UserDefinedAccessUrls | undefined; |
| 274 | /** Subordinate issuer config, if any. */ |
| 275 | subordinateConfig: SubordinateConfig | undefined; |
| 276 | /** URLs for the CA certificate and CRLs. */ |
| 277 | caCertificateUrl: string | undefined; |
| 278 | /** CRL access URL. */ |
| 279 | crlAccessUrl: string | undefined; |
| 280 | /** RFC3339 creation timestamp. */ |
| 281 | createTime: string | undefined; |
| 282 | /** RFC3339 last-update timestamp. */ |
| 283 | updateTime: string | undefined; |
| 284 | /** RFC3339 soft-delete timestamp, if the CA is `DELETED`. */ |
| 285 | deleteTime: string | undefined; |
| 286 | /** RFC3339 purge timestamp, if the CA is `DELETED`. */ |
| 287 | expireTime: string | undefined; |
| 288 | }; |
| 289 | |
| 290 | export type CertificateAuthority = Resource< |
| 291 | "GCP.PrivateCA.CertificateAuthority", |
| 292 | CertificateAuthorityProps, |
| 293 | CertificateAuthorityAttrs, |
| 294 | never, |
| 295 | Providers |
| 296 | >; |
| 297 | |
| 298 | /** |
| 299 | * A Certificate Authority Service CertificateAuthority — a named CA in a |
| 300 | * CaPool that issues certificates. |
| 301 | * |
| 302 | * Changing `certificateAuthorityId`, `caPool`, `location`, `type`, |
| 303 | * `lifetime`, `keySpec`, `config`, or `gcsBucket` replaces the CA. Labels |
| 304 | * and `userDefinedAccessUrls` update in place. `desiredState` enable/disable |
| 305 | * the CA after create (`STAGED` is the API's create-time state). |
| 306 | * |
| 307 | * Destroy disables an `ENABLED` CA, then deletes it with `skipGracePeriod` |
| 308 | * so the 30-day undelete window is skipped (required for `pnpm nuke:gcp`). |
| 309 | * `STAGED` CAs are deleted directly. Provisioning a Google-managed HSM key |
| 310 | * typically takes under a minute on a `DEVOPS` pool. |
| 311 | * |
| 312 | * ### Creating a Certificate Authority |
| 313 | * **Example:** Generated name in an existing pool |
| 314 | * ```typescript |
| 315 | * const pool = yield* GCP.PrivateCA.CaPool("Pool", { tier: "DEVOPS" }); |
| 316 | * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", { |
| 317 | * caPool: pool.name, |
| 318 | * }); |
| 319 | * ``` |
| 320 | * |
| 321 | * **Example:** Explicit id, labels, and subject |
| 322 | * ```typescript |
| 323 | * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", { |
| 324 | * caPool: pool.name, |
| 325 | * certificateAuthorityId: "app-root", |
| 326 | * type: "SELF_SIGNED", |
| 327 | * lifetime: "315360000s", |
| 328 | * keySpec: { algorithm: "EC_P256_SHA256" }, |
| 329 | * desiredState: "ENABLED", |
| 330 | * labels: { env: "prod" }, |
| 331 | * config: { |
| 332 | * subjectConfig: { |
| 333 | * subject: { |
| 334 | * organization: "Example", |
| 335 | * commonName: "Example Root CA", |
| 336 | * }, |
| 337 | * }, |
| 338 | * x509Config: { |
| 339 | * caOptions: { isCa: true }, |
| 340 | * keyUsage: { |
| 341 | * baseKeyUsage: { |
| 342 | * certSign: true, |
| 343 | * crlSign: true, |
| 344 | * }, |
| 345 | * }, |
| 346 | * }, |
| 347 | * }, |
| 348 | * }); |
| 349 | * ``` |
| 350 | * |
| 351 | * ### Staging a CA |
| 352 | * **Example:** Leave the CA in STAGED after create |
| 353 | * ```typescript |
| 354 | * const ca = yield* GCP.PrivateCA.CertificateAuthority("Root", { |
| 355 | * caPool: pool.name, |
| 356 | * desiredState: "STAGED", |
| 357 | * }); |
| 358 | * ``` |
| 359 | * |
| 360 | * @resource |
| 361 | * @category PrivateCA |
| 362 | */ |
| 363 | export const CertificateAuthority = Resource<CertificateAuthority>( |
| 364 | "GCP.PrivateCA.CertificateAuthority", |
| 365 | ); |
| 366 | |
| 367 | export class CertificateAuthorityNotResolved extends Data.TaggedError( |
| 368 | "GCP.PrivateCA.CertificateAuthorityNotResolved", |
| 369 | )<{ |
| 370 | name: string; |
| 371 | }> {} |
| 372 | |
| 373 | export class CertificateAuthorityNotReady extends Data.TaggedError( |
| 374 | "GCP.PrivateCA.CertificateAuthorityNotReady", |
| 375 | )<{ |
| 376 | name: string; |
| 377 | state: string; |
| 378 | }> {} |
| 379 | |
| 380 | export class CertificateAuthorityStillExists extends Data.TaggedError( |
| 381 | "GCP.PrivateCA.CertificateAuthorityStillExists", |
| 382 | )<{ |
| 383 | name: string; |
| 384 | }> {} |
| 385 | |
| 386 | const lastSegment = (value: string) => { |
| 387 | const trimmed = value.replace(/\/+$/, ""); |
| 388 | const parts = trimmed.split("/"); |
| 389 | return parts[parts.length - 1] || trimmed; |
| 390 | }; |
| 391 | |
| 392 | const normalizeLocation = (location: string) => |
| 393 | lastSegment(location).toLowerCase(); |
| 394 | |
| 395 | const normalizeType = (type: string | undefined) => { |
| 396 | const value = (type ?? DEFAULT_TYPE).toUpperCase(); |
| 397 | return value === "TYPE_UNSPECIFIED" ? DEFAULT_TYPE : value; |
| 398 | }; |
| 399 | |
| 400 | const normalizeDesired = (state: string | undefined): DesiredState => { |
| 401 | const value = (state ?? DEFAULT_DESIRED_STATE).toUpperCase(); |
| 402 | if (value === "DISABLED") return "DISABLED"; |
| 403 | if (value === "STAGED") return "STAGED"; |
| 404 | return "ENABLED"; |
| 405 | }; |
| 406 | |
| 407 | const resourceName = (caPool: string, certificateAuthorityId: string) => |
| 408 | `${caPool}/certificateAuthorities/${certificateAuthorityId}`; |
| 409 | |
| 410 | const parseName = (name: string) => { |
| 411 | const parts = name.split("/").filter((part) => part.length > 0); |
| 412 | const casAt = parts.lastIndexOf("certificateAuthorities"); |
| 413 | const poolsAt = parts.lastIndexOf("caPools"); |
| 414 | const locationsAt = parts.lastIndexOf("locations"); |
| 415 | const projectsAt = parts.lastIndexOf("projects"); |
| 416 | const caPool = poolsAt >= 0 ? parts.slice(0, poolsAt + 2).join("/") : ""; |
| 417 | return { |
| 418 | project: |
| 419 | projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "", |
| 420 | location: |
| 421 | locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "", |
| 422 | caPool, |
| 423 | certificateAuthorityId: |
| 424 | casAt >= 0 && parts[casAt + 1] ? parts[casAt + 1]! : lastSegment(name), |
| 425 | }; |
| 426 | }; |
| 427 | |
| 428 | const resolveParent = (project: string, caPool: string, location: string) => { |
| 429 | if (caPool.includes("/")) { |
| 430 | const parsed = parseName( |
| 431 | caPool.includes("/certificateAuthorities/") |
| 432 | ? caPool |
| 433 | : `${caPool}/certificateAuthorities/_`, |
| 434 | ); |
| 435 | return { |
| 436 | parent: parsed.caPool, |
| 437 | location: parsed.location, |
| 438 | project: parsed.project || project, |
| 439 | }; |
| 440 | } |
| 441 | const loc = normalizeLocation(location); |
| 442 | return { |
| 443 | parent: `projects/${project}/locations/${loc}/caPools/${caPool}`, |
| 444 | location: loc, |
| 445 | project, |
| 446 | }; |
| 447 | }; |
| 448 | |
| 449 | const userLabels = ( |
| 450 | labels: Record<string, string | undefined> | null | undefined, |
| 451 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 452 | |
| 453 | const toId = ( |
| 454 | id: string, |
| 455 | certificateAuthorityId: string | undefined, |
| 456 | existing?: string, |
| 457 | ) => |
| 458 | Effect.gen(function* () { |
| 459 | return ( |
| 460 | certificateAuthorityId ?? |
| 461 | existing ?? |
| 462 | (yield* createPhysicalName({ |
| 463 | id, |
| 464 | maxLength: MAX_NAME_LENGTH, |
| 465 | lowercase: true, |
| 466 | })) |
| 467 | ); |
| 468 | }); |
| 469 | |
| 470 | const keySpecOf = ( |
| 471 | spec: privateca.KeyVersionSpec | KeyVersionSpec | undefined, |
| 472 | ): KeyVersionSpec | undefined => { |
| 473 | if (spec === undefined) return undefined; |
| 474 | return { |
| 475 | algorithm: spec.algorithm, |
| 476 | cloudKmsKeyVersion: spec.cloudKmsKeyVersion, |
| 477 | }; |
| 478 | }; |
| 479 | |
| 480 | const urlsOf = ( |
| 481 | urls: privateca.UserDefinedAccessUrls | UserDefinedAccessUrls | undefined, |
| 482 | ): UserDefinedAccessUrls | undefined => { |
| 483 | if (urls === undefined) return undefined; |
| 484 | return { |
| 485 | crlAccessUrls: urls.crlAccessUrls, |
| 486 | aiaIssuingCertificateUrls: urls.aiaIssuingCertificateUrls, |
| 487 | }; |
| 488 | }; |
| 489 | |
| 490 | const subordinateOf = ( |
| 491 | config: privateca.SubordinateConfig | SubordinateConfig | undefined, |
| 492 | ): SubordinateConfig | undefined => { |
| 493 | if (config === undefined) return undefined; |
| 494 | return { |
| 495 | certificateAuthority: config.certificateAuthority, |
| 496 | pemIssuerChain: |
| 497 | config.pemIssuerChain === undefined |
| 498 | ? undefined |
| 499 | : { pemCertificates: config.pemIssuerChain.pemCertificates }, |
| 500 | }; |
| 501 | }; |
| 502 | |
| 503 | const toAttrs = ( |
| 504 | ca: privateca.CertificateAuthority, |
| 505 | project: string, |
| 506 | ): CertificateAuthorityAttrs => { |
| 507 | const name = ca.name ?? ""; |
| 508 | const parsed = parseName(name); |
| 509 | return { |
| 510 | name, |
| 511 | certificateAuthorityId: parsed.certificateAuthorityId, |
| 512 | caPool: parsed.caPool, |
| 513 | project: parsed.project || project, |
| 514 | location: parsed.location, |
| 515 | type: normalizeType(ca.type), |
| 516 | state: ca.state, |
| 517 | tier: ca.tier, |
| 518 | labels: userLabels(ca.labels), |
| 519 | lifetime: ca.lifetime, |
| 520 | keySpec: keySpecOf(ca.keySpec), |
| 521 | gcsBucket: ca.gcsBucket, |
| 522 | pemCaCertificates: ca.pemCaCertificates ?? [], |
| 523 | userDefinedAccessUrls: urlsOf(ca.userDefinedAccessUrls), |
| 524 | subordinateConfig: subordinateOf(ca.subordinateConfig), |
| 525 | caCertificateUrl: ca.accessUrls?.caCertificateAccessUrl, |
| 526 | crlAccessUrl: ca.accessUrls?.crlAccessUrls?.[0], |
| 527 | createTime: ca.createTime, |
| 528 | updateTime: ca.updateTime, |
| 529 | deleteTime: ca.deleteTime, |
| 530 | expireTime: ca.expireTime, |
| 531 | }; |
| 532 | }; |
| 533 | |
| 534 | const defaultConfig = (certificateAuthorityId: string): CertificateConfig => ({ |
| 535 | subjectConfig: { |
| 536 | subject: { |
| 537 | organization: "Alchemy", |
| 538 | commonName: certificateAuthorityId, |
| 539 | }, |
| 540 | }, |
| 541 | x509Config: { |
| 542 | caOptions: { isCa: true }, |
| 543 | keyUsage: { |
| 544 | baseKeyUsage: { |
| 545 | certSign: true, |
| 546 | crlSign: true, |
| 547 | }, |
| 548 | }, |
| 549 | }, |
| 550 | }); |
| 551 | |
| 552 | const toCreateBody = ( |
| 553 | news: CertificateAuthorityProps, |
| 554 | certificateAuthorityId: string, |
| 555 | desiredLabels: Record<string, string>, |
| 556 | ): privateca.CertificateAuthority => { |
| 557 | const config = news.config ?? defaultConfig(certificateAuthorityId); |
| 558 | return { |
| 559 | type: normalizeType(news.type), |
| 560 | lifetime: news.lifetime ?? DEFAULT_LIFETIME, |
| 561 | keySpec: { |
| 562 | algorithm: news.keySpec?.algorithm ?? DEFAULT_ALGORITHM, |
| 563 | cloudKmsKeyVersion: news.keySpec?.cloudKmsKeyVersion, |
| 564 | }, |
| 565 | config: { |
| 566 | subjectConfig: config.subjectConfig, |
| 567 | x509Config: config.x509Config, |
| 568 | }, |
| 569 | labels: desiredLabels, |
| 570 | gcsBucket: news.gcsBucket, |
| 571 | userDefinedAccessUrls: news.userDefinedAccessUrls, |
| 572 | subordinateConfig: news.subordinateConfig, |
| 573 | }; |
| 574 | }; |
| 575 | |
| 576 | const jsonKey = (value: unknown) => JSON.stringify(value ?? null); |
| 577 | |
| 578 | const keySpecKey = (spec: KeyVersionSpec | undefined) => |
| 579 | jsonKey({ |
| 580 | algorithm: (spec?.algorithm ?? "").toUpperCase(), |
| 581 | cloudKmsKeyVersion: spec?.cloudKmsKeyVersion ?? "", |
| 582 | }); |
| 583 | |
| 584 | const urlsKey = (urls: UserDefinedAccessUrls | undefined) => |
| 585 | jsonKey({ |
| 586 | crl: [...(urls?.crlAccessUrls ?? [])].sort(), |
| 587 | aia: [...(urls?.aiaIssuingCertificateUrls ?? [])].sort(), |
| 588 | }); |
| 589 | |
| 590 | const subordinateKey = (config: SubordinateConfig | undefined) => |
| 591 | jsonKey({ |
| 592 | certificateAuthority: config?.certificateAuthority ?? "", |
| 593 | pemCertificates: [...(config?.pemIssuerChain?.pemCertificates ?? [])], |
| 594 | }); |
| 595 | |
| 596 | const configKey = (config: CertificateConfig | undefined) => jsonKey(config); |
| 597 | |
| 598 | const getByName = (name: string) => |
| 599 | privateca |
| 600 | .getProjectsLocationsCaPoolsCertificateAuthorities({ name }) |
| 601 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 602 | |
| 603 | /** |
| 604 | * Wait for a Certificate Authority Service operation; CA creation and |
| 605 | * activation take a few minutes. ALREADY_EXISTS (code 6) counts as success |
| 606 | * (create race); with `notFoundOk`, so does NOT_FOUND (code 5, delete race) |
| 607 | * and an operation that is already gone. |
| 608 | */ |
| 609 | const waitForOperation = ( |
| 610 | operation: privateca.Operation, |
| 611 | options?: { notFoundOk?: boolean }, |
| 612 | ) => |
| 613 | waitForGcpOperation( |
| 614 | operation, |
| 615 | (name) => privateca.getProjectsLocationsOperations({ name }), |
| 616 | { budget: "15 minutes" }, |
| 617 | ).pipe( |
| 618 | Effect.catchIf( |
| 619 | (error) => |
| 620 | (error._tag === "GCP.OperationFailed" && |
| 621 | (error.code === 6 || |
| 622 | (options?.notFoundOk === true && error.code === 5))) || |
| 623 | (options?.notFoundOk === true && error._tag === "NotFound"), |
| 624 | () => Effect.succeed(operation), |
| 625 | ), |
| 626 | ); |
| 627 | |
| 628 | const waitUntilExists = (name: string) => |
| 629 | getByName(name).pipe( |
| 630 | Effect.flatMap((ca) => |
| 631 | ca |
| 632 | ? Effect.succeed(ca) |
| 633 | : Effect.fail(new CertificateAuthorityNotResolved({ name })), |
| 634 | ), |
| 635 | Effect.retry({ |
| 636 | while: (error) => |
| 637 | error._tag === "GCP.PrivateCA.CertificateAuthorityNotResolved", |
| 638 | times: 8, |
| 639 | schedule: Schedule.spaced("1 second"), |
| 640 | }), |
| 641 | ); |
| 642 | |
| 643 | const waitUntilSteady = (name: string) => |
| 644 | getByName(name).pipe( |
| 645 | Effect.filterOrFail( |
| 646 | (ca): ca is privateca.CertificateAuthority => ca !== undefined, |
| 647 | () => new CertificateAuthorityNotResolved({ name }), |
| 648 | ), |
| 649 | Effect.filterOrFail( |
| 650 | (ca) => STEADY_STATES.has((ca.state ?? "").toUpperCase()), |
| 651 | (ca) => |
| 652 | new CertificateAuthorityNotReady({ |
| 653 | name, |
| 654 | state: ca.state ?? "STATE_UNSPECIFIED", |
| 655 | }), |
| 656 | ), |
| 657 | Effect.retry({ |
| 658 | while: (error) => |
| 659 | error._tag === "GCP.PrivateCA.CertificateAuthorityNotReady" || |
| 660 | error._tag === "GCP.PrivateCA.CertificateAuthorityNotResolved", |
| 661 | times: 10, |
| 662 | schedule: Schedule.spaced("5 seconds"), |
| 663 | }), |
| 664 | ); |
| 665 | |
| 666 | const isGone = (ca: privateca.CertificateAuthority | undefined) => |
| 667 | ca === undefined || (ca.state ?? "").toUpperCase() === "DELETED"; |
| 668 | |
| 669 | const waitUntilGone = (name: string) => |
| 670 | getByName(name).pipe( |
| 671 | Effect.flatMap((ca) => |
| 672 | isGone(ca) |
| 673 | ? Effect.void |
| 674 | : Effect.fail(new CertificateAuthorityStillExists({ name })), |
| 675 | ), |
| 676 | Effect.retry({ |
| 677 | while: (error) => |
| 678 | error._tag === "GCP.PrivateCA.CertificateAuthorityStillExists", |
| 679 | times: 10, |
| 680 | schedule: Schedule.spaced("2 seconds"), |
| 681 | }), |
| 682 | ); |
| 683 | |
| 684 | const runOperation = ( |
| 685 | operation: privateca.Operation | undefined, |
| 686 | options?: { notFoundOk?: boolean }, |
| 687 | ) => |
| 688 | operation === undefined |
| 689 | ? Effect.void |
| 690 | : waitForOperation(operation, options).pipe(Effect.asVoid); |
| 691 | |
| 692 | const listOwned = (project: string) => |
| 693 | privateca.listProjectsLocationsCaPoolsCertificateAuthorities |
| 694 | .pages({ |
| 695 | parent: `projects/${project}/locations/-/caPools/-`, |
| 696 | pageSize: 1000, |
| 697 | }) |
| 698 | .pipe( |
| 699 | Stream.flatMap((page) => |
| 700 | Stream.fromIterable(page.certificateAuthorities ?? []), |
| 701 | ), |
| 702 | Stream.filter( |
| 703 | (ca) => |
| 704 | (ca.state ?? "").toUpperCase() !== "DELETED" && |
| 705 | Object.keys(ca.labels ?? {}).some((key) => |
| 706 | key.startsWith("alchemy-"), |
| 707 | ), |
| 708 | ), |
| 709 | Stream.map((ca) => toAttrs(ca, project)), |
| 710 | Stream.runCollect, |
| 711 | Effect.map((chunk) => Array.from(chunk)), |
| 712 | Effect.catchTag("NotFound", () => Effect.succeed([])), |
| 713 | ); |
| 714 | |
| 715 | const disableIfNeeded = (current: privateca.CertificateAuthority) => { |
| 716 | const name = current.name ?? ""; |
| 717 | const state = (current.state ?? "").toUpperCase(); |
| 718 | // STAGED / DISABLED / AWAITING_USER_ACTIVATION can be deleted directly. |
| 719 | // Disable is only valid from ENABLED. |
| 720 | if (state !== "ENABLED") { |
| 721 | return Effect.succeed(current); |
| 722 | } |
| 723 | return privateca |
| 724 | .disableProjectsLocationsCaPoolsCertificateAuthorities({ |
| 725 | name, |
| 726 | body: { ignoreDependentResources: true }, |
| 727 | }) |
| 728 | .pipe( |
| 729 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 730 | Effect.catchTag("BadRequest", () => Effect.succeed(undefined)), |
| 731 | Effect.catchTag("Conflict", () => Effect.succeed(undefined)), |
| 732 | Effect.flatMap((operation) => runOperation(operation)), |
| 733 | Effect.flatMap(() => waitUntilSteady(name)), |
| 734 | Effect.catchTag("GCP.PrivateCA.CertificateAuthorityNotResolved", () => |
| 735 | Effect.succeed(current), |
| 736 | ), |
| 737 | ); |
| 738 | }; |
| 739 | |
| 740 | const syncDesiredState = ( |
| 741 | name: string, |
| 742 | current: privateca.CertificateAuthority, |
| 743 | desired: DesiredState, |
| 744 | ) => { |
| 745 | const state = (current.state ?? "").toUpperCase(); |
| 746 | if (desired === "ENABLED" && (state === "STAGED" || state === "DISABLED")) { |
| 747 | return privateca |
| 748 | .enableProjectsLocationsCaPoolsCertificateAuthorities({ |
| 749 | name, |
| 750 | body: {}, |
| 751 | }) |
| 752 | .pipe( |
| 753 | Effect.flatMap((operation) => waitForOperation(operation)), |
| 754 | Effect.catchTag("BadRequest", () => Effect.void), |
| 755 | Effect.catchTag("Conflict", () => Effect.void), |
| 756 | Effect.flatMap(() => waitUntilSteady(name)), |
| 757 | ); |
| 758 | } |
| 759 | if (desired === "DISABLED" && (state === "STAGED" || state === "ENABLED")) { |
| 760 | return privateca |
| 761 | .disableProjectsLocationsCaPoolsCertificateAuthorities({ |
| 762 | name, |
| 763 | body: { ignoreDependentResources: true }, |
| 764 | }) |
| 765 | .pipe( |
| 766 | Effect.flatMap((operation) => waitForOperation(operation)), |
| 767 | Effect.catchTag("BadRequest", () => Effect.void), |
| 768 | Effect.catchTag("Conflict", () => Effect.void), |
| 769 | Effect.flatMap(() => waitUntilSteady(name)), |
| 770 | ); |
| 771 | } |
| 772 | return Effect.succeed(current); |
| 773 | }; |
| 774 | |
| 775 | const activateIfNeeded = ( |
| 776 | name: string, |
| 777 | current: privateca.CertificateAuthority, |
| 778 | news: CertificateAuthorityProps, |
| 779 | ) => { |
| 780 | const state = (current.state ?? "").toUpperCase(); |
| 781 | if ( |
| 782 | state !== "AWAITING_USER_ACTIVATION" || |
| 783 | news.pemCaCertificate === undefined || |
| 784 | news.subordinateConfig === undefined |
| 785 | ) { |
| 786 | return Effect.succeed(current); |
| 787 | } |
| 788 | return privateca |
| 789 | .activateProjectsLocationsCaPoolsCertificateAuthorities({ |
| 790 | name, |
| 791 | body: { |
| 792 | pemCaCertificate: news.pemCaCertificate, |
| 793 | subordinateConfig: news.subordinateConfig, |
| 794 | }, |
| 795 | }) |
| 796 | .pipe( |
| 797 | Effect.flatMap((operation) => waitForOperation(operation)), |
| 798 | Effect.flatMap(() => waitUntilSteady(name)), |
| 799 | ); |
| 800 | }; |
| 801 | |
| 802 | const undeleteIfNeeded = (current: privateca.CertificateAuthority) => { |
| 803 | const name = current.name ?? ""; |
| 804 | if ((current.state ?? "").toUpperCase() !== "DELETED") { |
| 805 | return Effect.succeed(current); |
| 806 | } |
| 807 | return privateca |
| 808 | .undeleteProjectsLocationsCaPoolsCertificateAuthorities({ |
| 809 | name, |
| 810 | body: {}, |
| 811 | }) |
| 812 | .pipe( |
| 813 | Effect.flatMap((operation) => waitForOperation(operation)), |
| 814 | Effect.flatMap(() => waitUntilSteady(name)), |
| 815 | ); |
| 816 | }; |
| 817 | |
| 818 | export const CertificateAuthorityProvider = () => |
| 819 | Provider.succeed(CertificateAuthority, { |
| 820 | stables: [ |
| 821 | "name", |
| 822 | "certificateAuthorityId", |
| 823 | "caPool", |
| 824 | "project", |
| 825 | "location", |
| 826 | "type", |
| 827 | "createTime", |
| 828 | ], |
| 829 | |
| 830 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 831 | if (!isResolved(news)) return undefined; |
| 832 | const env = yield* GcpEnvironment.current; |
| 833 | |
| 834 | const previousId = |
| 835 | olds?.certificateAuthorityId ?? output?.certificateAuthorityId; |
| 836 | const nextId = news.certificateAuthorityId ?? previousId; |
| 837 | const previousPool = olds?.caPool ?? output?.caPool ?? ""; |
| 838 | const nextPool = news.caPool; |
| 839 | const previousLocation = normalizeLocation( |
| 840 | olds?.location ?? output?.location ?? env.region, |
| 841 | ); |
| 842 | const nextLocation = normalizeLocation( |
| 843 | news.location ?? output?.location ?? env.region, |
| 844 | ); |
| 845 | const previousType = normalizeType(olds?.type ?? output?.type); |
| 846 | const nextType = normalizeType(news.type ?? output?.type); |
| 847 | const previousLifetime = olds?.lifetime ?? output?.lifetime ?? ""; |
| 848 | const nextLifetime = news.lifetime ?? previousLifetime; |
| 849 | const previousBucket = olds?.gcsBucket ?? output?.gcsBucket ?? ""; |
| 850 | const nextBucket = news.gcsBucket ?? previousBucket; |
| 851 | const previousKey = keySpecKey(olds?.keySpec ?? output?.keySpec); |
| 852 | const nextKey = |
| 853 | news.keySpec === undefined ? previousKey : keySpecKey(news.keySpec); |
| 854 | const configChanged = |
| 855 | news.config !== undefined && |
| 856 | olds?.config !== undefined && |
| 857 | configKey(news.config) !== configKey(olds.config); |
| 858 | |
| 859 | const replace = |
| 860 | (previousId !== undefined && |
| 861 | nextId !== undefined && |
| 862 | nextId !== previousId) || |
| 863 | (previousPool !== "" && nextPool !== previousPool) || |
| 864 | previousLocation !== nextLocation || |
| 865 | previousType !== nextType || |
| 866 | previousLifetime !== nextLifetime || |
| 867 | previousBucket !== nextBucket || |
| 868 | previousKey !== nextKey || |
| 869 | configChanged; |
| 870 | |
| 871 | if (!replace) return undefined; |
| 872 | return { |
| 873 | action: "replace" as const, |
| 874 | deleteFirst: |
| 875 | previousLocation === nextLocation && |
| 876 | previousPool === nextPool && |
| 877 | previousId !== undefined && |
| 878 | nextId === previousId, |
| 879 | }; |
| 880 | }), |
| 881 | |
| 882 | read: Effect.fn(function* ({ id, olds, output }) { |
| 883 | const env = yield* GcpEnvironment.current; |
| 884 | const certificateAuthorityId = yield* toId( |
| 885 | id, |
| 886 | olds?.certificateAuthorityId, |
| 887 | output?.certificateAuthorityId, |
| 888 | ); |
| 889 | const { parent } = resolveParent( |
| 890 | env.project, |
| 891 | olds?.caPool ?? output?.caPool ?? "", |
| 892 | olds?.location ?? output?.location ?? env.region, |
| 893 | ); |
| 894 | const name = output?.name ?? resourceName(parent, certificateAuthorityId); |
| 895 | if (name.endsWith("/certificateAuthorities/") || name.endsWith("/_")) { |
| 896 | return undefined; |
| 897 | } |
| 898 | const existing = yield* getByName(name); |
| 899 | if (existing === undefined) return undefined; |
| 900 | if ((existing.state ?? "").toUpperCase() === "DELETED") { |
| 901 | return undefined; |
| 902 | } |
| 903 | const attrs = toAttrs(existing, env.project); |
| 904 | return (yield* hasAlchemyLabels(id, tagRecord(existing.labels))) |
| 905 | ? attrs |
| 906 | : Unowned(attrs); |
| 907 | }), |
| 908 | |
| 909 | list: () => |
| 910 | Effect.gen(function* () { |
| 911 | const env = yield* GcpEnvironment.current; |
| 912 | return yield* listOwned(env.project); |
| 913 | }), |
| 914 | |
| 915 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 916 | const env = yield* GcpEnvironment.current; |
| 917 | const certificateAuthorityId = yield* toId( |
| 918 | id, |
| 919 | news.certificateAuthorityId, |
| 920 | output?.certificateAuthorityId, |
| 921 | ); |
| 922 | const { parent, project } = resolveParent( |
| 923 | env.project, |
| 924 | news.caPool, |
| 925 | news.location ?? output?.location ?? env.region, |
| 926 | ); |
| 927 | const name = resourceName(parent, certificateAuthorityId); |
| 928 | const desiredLabels = { |
| 929 | ...toLabels(news.labels), |
| 930 | ...(yield* createInternalLabels(id)), |
| 931 | }; |
| 932 | const desiredState = normalizeDesired(news.desiredState); |
| 933 | |
| 934 | let current = yield* getByName(output?.name ?? name); |
| 935 | |
| 936 | if (current === undefined) { |
| 937 | const created = yield* privateca |
| 938 | .createProjectsLocationsCaPoolsCertificateAuthorities({ |
| 939 | parent, |
| 940 | certificateAuthorityId, |
| 941 | body: toCreateBody(news, certificateAuthorityId, desiredLabels), |
| 942 | }) |
| 943 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 944 | yield* runOperation(created); |
| 945 | current = yield* waitUntilExists(name); |
| 946 | } |
| 947 | |
| 948 | if (current === undefined) { |
| 949 | return yield* new CertificateAuthorityNotResolved({ name }); |
| 950 | } |
| 951 | |
| 952 | current = yield* undeleteIfNeeded(current); |
| 953 | |
| 954 | if (!STEADY_STATES.has((current.state ?? "").toUpperCase())) { |
| 955 | current = yield* waitUntilSteady(name); |
| 956 | } |
| 957 | |
| 958 | current = yield* activateIfNeeded(name, current, news); |
| 959 | |
| 960 | const observedLabels = tagRecord(current.labels); |
| 961 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 962 | const labelsChanged = upsert.length > 0 || removed.length > 0; |
| 963 | const urlsChanged = |
| 964 | news.userDefinedAccessUrls !== undefined && |
| 965 | urlsKey(urlsOf(current.userDefinedAccessUrls)) !== |
| 966 | urlsKey(news.userDefinedAccessUrls); |
| 967 | const subordinateChanged = |
| 968 | news.subordinateConfig !== undefined && |
| 969 | subordinateKey(subordinateOf(current.subordinateConfig)) !== |
| 970 | subordinateKey(news.subordinateConfig); |
| 971 | |
| 972 | if (labelsChanged || urlsChanged || subordinateChanged) { |
| 973 | const updateMask = [ |
| 974 | labelsChanged ? "labels" : undefined, |
| 975 | urlsChanged ? "userDefinedAccessUrls" : undefined, |
| 976 | subordinateChanged ? "subordinateConfig" : undefined, |
| 977 | ].filter((field): field is string => field !== undefined); |
| 978 | |
| 979 | const patched = yield* privateca |
| 980 | .patchProjectsLocationsCaPoolsCertificateAuthorities({ |
| 981 | name, |
| 982 | updateMask: updateMask.join(","), |
| 983 | body: { |
| 984 | name, |
| 985 | labels: desiredLabels, |
| 986 | userDefinedAccessUrls: news.userDefinedAccessUrls, |
| 987 | subordinateConfig: news.subordinateConfig, |
| 988 | }, |
| 989 | }) |
| 990 | .pipe(Effect.catchTag("BadRequest", () => Effect.succeed(undefined))); |
| 991 | yield* runOperation(patched); |
| 992 | current = yield* waitUntilSteady(name); |
| 993 | } |
| 994 | |
| 995 | current = yield* syncDesiredState(name, current, desiredState); |
| 996 | |
| 997 | return toAttrs(current, project); |
| 998 | }), |
| 999 | |
| 1000 | delete: Effect.fn(function* ({ output }) { |
| 1001 | const name = output.name; |
| 1002 | const existing = yield* getByName(name); |
| 1003 | if (existing === undefined) return; |
| 1004 | |
| 1005 | if ((existing.state ?? "").toUpperCase() !== "DELETED") { |
| 1006 | yield* disableIfNeeded(existing); |
| 1007 | const operation = yield* privateca |
| 1008 | .deleteProjectsLocationsCaPoolsCertificateAuthorities({ |
| 1009 | name, |
| 1010 | skipGracePeriod: true, |
| 1011 | ignoreActiveCertificates: true, |
| 1012 | ignoreDependentResources: true, |
| 1013 | }) |
| 1014 | .pipe( |
| 1015 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 1016 | Effect.catchTag("BadRequest", () => Effect.succeed(undefined)), |
| 1017 | Effect.retry({ |
| 1018 | while: (error) => error._tag === "Conflict", |
| 1019 | times: 8, |
| 1020 | schedule: Schedule.spaced("5 seconds"), |
| 1021 | }), |
| 1022 | ); |
| 1023 | yield* runOperation(operation, { notFoundOk: true }); |
| 1024 | } |
| 1025 | |
| 1026 | yield* waitUntilGone(name); |
| 1027 | }), |
| 1028 | }); |