| 1 | import * as sqladmin from "@distilled.cloud/gcp/sqladmin_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 { |
| 14 | createInternalLabels, |
| 15 | diffLabels, |
| 16 | hasAlchemyLabels, |
| 17 | stripInternalLabels, |
| 18 | toLabels, |
| 19 | } from "../Labels.ts"; |
| 20 | import type { Providers } from "../Providers.ts"; |
| 21 | import { waitForSqlOperation } from "./operations.ts"; |
| 22 | |
| 23 | const DEFAULT_DATABASE_VERSION = "MYSQL_8_0"; |
| 24 | const DEFAULT_TIER = "db-f1-micro"; |
| 25 | const DEFAULT_EDITION = "ENTERPRISE"; |
| 26 | const DEFAULT_AVAILABILITY = "ZONAL"; |
| 27 | const DEFAULT_ACTIVATION = "ALWAYS"; |
| 28 | const DEFAULT_DISK_TYPE = "PD_SSD"; |
| 29 | const DEFAULT_DISK_SIZE_GB = 10; |
| 30 | const MAX_NAME_LENGTH = 63; |
| 31 | /** |
| 32 | * Generated names stay short enough that the connection name |
| 33 | * `{project}:{region}:{instance}` fits Cloud Run's 97-character limit for |
| 34 | * Cloud SQL volumes (30-character project ids, 23-character regions). |
| 35 | */ |
| 36 | const GENERATED_NAME_MAX_LENGTH = 42; |
| 37 | |
| 38 | export type DatabaseFlag = { |
| 39 | /** Flag name (underscores, not hyphens). */ |
| 40 | name: string; |
| 41 | /** |
| 42 | * Flag value. Boolean flags use `on` / `off`. Omit for flags that take |
| 43 | * no value. |
| 44 | */ |
| 45 | value?: string; |
| 46 | }; |
| 47 | |
| 48 | export type InstanceProps = { |
| 49 | /** |
| 50 | * Cloud SQL instance id (the `{instance}` segment of |
| 51 | * `projects/{project}/instances/{instance}`). If omitted, a unique name |
| 52 | * is generated from the stack, stage, and logical id. Must start with a |
| 53 | * letter, contain only lowercase letters, numbers, and hyphens, and not |
| 54 | * end with a hyphen (1–98 characters). Immutable — changing it replaces |
| 55 | * the instance. |
| 56 | */ |
| 57 | instanceName?: string; |
| 58 | /** |
| 59 | * Region (`us-central1`, `us-east1`, …). Immutable — changing it |
| 60 | * replaces the instance. `US-CENTRAL1` is accepted and normalized to |
| 61 | * `us-central1`. |
| 62 | * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`) |
| 63 | */ |
| 64 | region?: string; |
| 65 | /** |
| 66 | * Database engine and major version (`MYSQL_8_0`, `POSTGRES_15`, …). |
| 67 | * Immutable — changing it replaces the instance. |
| 68 | * @default "MYSQL_8_0" |
| 69 | */ |
| 70 | databaseVersion?: |
| 71 | | sqladmin.DatabaseInstanceDatabaseVersionEnum |
| 72 | | (string & {}); |
| 73 | /** |
| 74 | * Machine type (`db-f1-micro`, `db-custom-1-3840`, …). Changing it |
| 75 | * restarts the instance. |
| 76 | * @default "db-f1-micro" |
| 77 | */ |
| 78 | tier?: string; |
| 79 | /** |
| 80 | * Edition (`ENTERPRISE`, `ENTERPRISE_PLUS`, `DEVELOPER`). |
| 81 | * @default "ENTERPRISE" |
| 82 | */ |
| 83 | edition?: sqladmin.SettingsEditionEnum | (string & {}); |
| 84 | /** |
| 85 | * User labels. Alchemy ownership labels are merged in automatically |
| 86 | * onto `settings.userLabels`. |
| 87 | */ |
| 88 | labels?: Record<string, string>; |
| 89 | /** |
| 90 | * Preferred Compute Engine zone (`us-central1-a`). Changing it may |
| 91 | * restart the instance. |
| 92 | */ |
| 93 | zone?: string; |
| 94 | /** |
| 95 | * High-availability type. `REGIONAL` requires backups. |
| 96 | * @default "ZONAL" |
| 97 | */ |
| 98 | availabilityType?: sqladmin.SettingsAvailabilityTypeEnum | (string & {}); |
| 99 | /** |
| 100 | * When the instance is activated. `NEVER` stops it. |
| 101 | * @default "ALWAYS" |
| 102 | */ |
| 103 | activationPolicy?: sqladmin.SettingsActivationPolicyEnum | (string & {}); |
| 104 | /** |
| 105 | * Data disk size in GiB. Minimum 10. May only increase after create. |
| 106 | * @default 10 |
| 107 | */ |
| 108 | dataDiskSizeGb?: number; |
| 109 | /** |
| 110 | * Data disk type (`PD_SSD`, `PD_HDD`). |
| 111 | * @default "PD_SSD" |
| 112 | */ |
| 113 | dataDiskType?: sqladmin.SettingsDataDiskTypeEnum | (string & {}); |
| 114 | /** |
| 115 | * Assign a public IPv4 address. |
| 116 | * @default true |
| 117 | */ |
| 118 | ipv4Enabled?: boolean; |
| 119 | /** |
| 120 | * Enable automated backups. MySQL also enables binary logs when true. |
| 121 | * @default false |
| 122 | */ |
| 123 | backupEnabled?: boolean; |
| 124 | /** |
| 125 | * Protect against accidental instance deletion. Disabled automatically |
| 126 | * on destroy. |
| 127 | * @default false |
| 128 | */ |
| 129 | deletionProtectionEnabled?: boolean; |
| 130 | /** |
| 131 | * Database flags passed at instance startup. |
| 132 | */ |
| 133 | databaseFlags?: DatabaseFlag[]; |
| 134 | /** |
| 135 | * Allow Cloud SQL `instances.executeSql`. Required for `ExecuteSql`. |
| 136 | * @default false |
| 137 | */ |
| 138 | dataApiAccess?: boolean; |
| 139 | /** |
| 140 | * Initial root password. Create-only. Required to connect to |
| 141 | * PostgreSQL. |
| 142 | */ |
| 143 | rootPassword?: string; |
| 144 | }; |
| 145 | |
| 146 | export type Instance = Resource< |
| 147 | "GCP.SQL.Instance", |
| 148 | InstanceProps, |
| 149 | { |
| 150 | /** Cloud SQL instance id. */ |
| 151 | instanceName: string; |
| 152 | /** Project id. */ |
| 153 | project: string; |
| 154 | /** Region (`us-central1`, …). */ |
| 155 | region: string; |
| 156 | /** Database engine and version. */ |
| 157 | databaseVersion: string | undefined; |
| 158 | /** Installed version including minor, if reported. */ |
| 159 | databaseInstalledVersion: string | undefined; |
| 160 | /** Machine type. */ |
| 161 | tier: string | undefined; |
| 162 | /** Edition (`ENTERPRISE`, …). */ |
| 163 | edition: string | undefined; |
| 164 | /** Serving state (`RUNNABLE`, `PENDING_CREATE`, …). */ |
| 165 | state: string | undefined; |
| 166 | /** User labels (Alchemy ownership labels stripped). */ |
| 167 | labels: Record<string, string>; |
| 168 | /** Connection name `project:region:instance`. */ |
| 169 | connectionName: string | undefined; |
| 170 | /** Preferred / current GCE zone. */ |
| 171 | gceZone: string | undefined; |
| 172 | /** Public IPv4, if any. */ |
| 173 | ipAddress: string | undefined; |
| 174 | /** Whether public IPv4 is enabled. */ |
| 175 | ipv4Enabled: boolean; |
| 176 | /** Whether deletion protection is enabled. */ |
| 177 | deletionProtectionEnabled: boolean; |
| 178 | /** Activation policy (`ALWAYS`, `NEVER`, …). */ |
| 179 | activationPolicy: string | undefined; |
| 180 | /** Availability type (`ZONAL`, `REGIONAL`). */ |
| 181 | availabilityType: string | undefined; |
| 182 | /** Data disk size in GiB. */ |
| 183 | dataDiskSizeGb: number | undefined; |
| 184 | /** Whether automated backups are enabled. */ |
| 185 | backupEnabled: boolean; |
| 186 | /** Service account email used by the instance. */ |
| 187 | serviceAccountEmailAddress: string | undefined; |
| 188 | /** SQL Admin self-link. */ |
| 189 | selfLink: string | undefined; |
| 190 | /** RFC3339 creation timestamp. */ |
| 191 | createTime: string | undefined; |
| 192 | }, |
| 193 | never, |
| 194 | Providers |
| 195 | >; |
| 196 | |
| 197 | /** |
| 198 | * A Cloud SQL database instance. |
| 199 | * |
| 200 | * Changing `instanceName`, `region`, or `databaseVersion` replaces the |
| 201 | * instance. Provisioning typically takes several minutes (often 5–15). |
| 202 | * |
| 203 | * ### Creating an Instance |
| 204 | * **Example:** Generated name, MySQL 8.0 shared-core |
| 205 | * ```typescript |
| 206 | * const db = yield* GCP.SQL.Instance("AppDb", {}); |
| 207 | * ``` |
| 208 | * |
| 209 | * **Example:** Explicit name, labels, and backups off |
| 210 | * ```typescript |
| 211 | * const db = yield* GCP.SQL.Instance("AppDb", { |
| 212 | * instanceName: "app-db", |
| 213 | * region: "us-central1", |
| 214 | * databaseVersion: "MYSQL_8_0", |
| 215 | * tier: "db-f1-micro", |
| 216 | * labels: { env: "prod" }, |
| 217 | * backupEnabled: false, |
| 218 | * deletionProtectionEnabled: false, |
| 219 | * }); |
| 220 | * ``` |
| 221 | * |
| 222 | * ### PostgreSQL |
| 223 | * **Example:** Postgres 15 with a root password |
| 224 | * ```typescript |
| 225 | * const db = yield* GCP.SQL.Instance("AppDb", { |
| 226 | * databaseVersion: "POSTGRES_15", |
| 227 | * rootPassword: "change-me", |
| 228 | * tier: "db-f1-micro", |
| 229 | * }); |
| 230 | * ``` |
| 231 | * |
| 232 | * @resource |
| 233 | * @category SQL |
| 234 | */ |
| 235 | export const Instance = Resource<Instance>("GCP.SQL.Instance"); |
| 236 | |
| 237 | export class InstanceNotResolved extends Data.TaggedError( |
| 238 | "GCP.SQL.InstanceNotResolved", |
| 239 | )<{ |
| 240 | instanceName: string; |
| 241 | project: string; |
| 242 | }> {} |
| 243 | |
| 244 | export class InstanceNotReady extends Data.TaggedError( |
| 245 | "GCP.SQL.InstanceNotReady", |
| 246 | )<{ |
| 247 | instanceName: string; |
| 248 | state: string; |
| 249 | }> {} |
| 250 | |
| 251 | export class InstanceFailed extends Data.TaggedError("GCP.SQL.InstanceFailed")<{ |
| 252 | instanceName: string; |
| 253 | state: string; |
| 254 | }> {} |
| 255 | |
| 256 | export class InstanceStillExists extends Data.TaggedError( |
| 257 | "GCP.SQL.InstanceStillExists", |
| 258 | )<{ |
| 259 | instanceName: string; |
| 260 | }> {} |
| 261 | |
| 262 | const lastSegment = (value: string | undefined): string => { |
| 263 | if (value === undefined || value.length === 0) return ""; |
| 264 | const trimmed = value.replace(/\/+$/, ""); |
| 265 | const parts = trimmed.split("/"); |
| 266 | return parts[parts.length - 1] ?? value; |
| 267 | }; |
| 268 | |
| 269 | const normalizeRegion = (region: string | undefined, defaultRegion: string) => |
| 270 | lastSegment(region ?? defaultRegion).toLowerCase(); |
| 271 | |
| 272 | const normalizeVersion = (version: string | undefined) => |
| 273 | (version ?? DEFAULT_DATABASE_VERSION).toUpperCase(); |
| 274 | |
| 275 | const normalizeTier = (tier: string | undefined) => tier ?? DEFAULT_TIER; |
| 276 | |
| 277 | const normalizeEdition = (edition: string | undefined) => |
| 278 | (edition ?? DEFAULT_EDITION).toUpperCase(); |
| 279 | |
| 280 | const normalizeAvailability = (value: string | undefined) => |
| 281 | (value ?? DEFAULT_AVAILABILITY).toUpperCase(); |
| 282 | |
| 283 | const normalizeActivation = (value: string | undefined) => |
| 284 | (value ?? DEFAULT_ACTIVATION).toUpperCase(); |
| 285 | |
| 286 | const normalizeDiskType = (value: string | undefined) => |
| 287 | (value ?? DEFAULT_DISK_TYPE).toUpperCase(); |
| 288 | |
| 289 | const isMysql = (version: string) => version.toUpperCase().startsWith("MYSQL_"); |
| 290 | |
| 291 | const isPostgres = (version: string) => |
| 292 | version.toUpperCase().startsWith("POSTGRES_"); |
| 293 | |
| 294 | const rfc1035 = (name: string): string => { |
| 295 | let next = name |
| 296 | .toLowerCase() |
| 297 | .replace(/[^a-z0-9-]/g, "-") |
| 298 | .replace(/-+/g, "-") |
| 299 | .replace(/^-+|-+$/g, ""); |
| 300 | if (!/^[a-z]/.test(next)) next = `s${next}`; |
| 301 | next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, ""); |
| 302 | if (next.length === 0) return "instance"; |
| 303 | if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`; |
| 304 | return next.slice(0, MAX_NAME_LENGTH); |
| 305 | }; |
| 306 | |
| 307 | const userLabels = ( |
| 308 | labels: Record<string, string | undefined> | null | undefined, |
| 309 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 310 | |
| 311 | const toName = (id: string, name: string | undefined, existing?: string) => |
| 312 | Effect.gen(function* () { |
| 313 | return ( |
| 314 | name ?? |
| 315 | existing ?? |
| 316 | rfc1035( |
| 317 | yield* createPhysicalName({ |
| 318 | id, |
| 319 | maxLength: GENERATED_NAME_MAX_LENGTH, |
| 320 | lowercase: true, |
| 321 | }), |
| 322 | ) |
| 323 | ); |
| 324 | }); |
| 325 | |
| 326 | const flagsKey = (flags: sqladmin.DatabaseFlagsList | undefined) => |
| 327 | JSON.stringify( |
| 328 | [...(flags ?? [])] |
| 329 | .map((flag) => `${flag.name ?? ""}=${flag.value ?? ""}`) |
| 330 | .sort(), |
| 331 | ); |
| 332 | |
| 333 | const diskSizeOf = (value: string | number | undefined) => { |
| 334 | if (value === undefined) return undefined; |
| 335 | const parsed = typeof value === "number" ? value : Number(value); |
| 336 | return Number.isFinite(parsed) ? parsed : undefined; |
| 337 | }; |
| 338 | |
| 339 | const primaryIp = (instance: sqladmin.DatabaseInstance) => |
| 340 | instance.ipAddresses?.find((item) => item.type === "PRIMARY")?.ipAddress ?? |
| 341 | instance.ipAddresses?.[0]?.ipAddress; |
| 342 | |
| 343 | const toAttrs = ( |
| 344 | instance: sqladmin.DatabaseInstance, |
| 345 | project: string, |
| 346 | defaultRegion: string, |
| 347 | ) => { |
| 348 | const settings = instance.settings; |
| 349 | return { |
| 350 | instanceName: instance.name ?? "", |
| 351 | project: instance.project ?? project, |
| 352 | region: normalizeRegion(instance.region, defaultRegion), |
| 353 | databaseVersion: instance.databaseVersion, |
| 354 | databaseInstalledVersion: instance.databaseInstalledVersion, |
| 355 | tier: settings?.tier, |
| 356 | edition: settings?.edition, |
| 357 | state: instance.state, |
| 358 | labels: userLabels(settings?.userLabels), |
| 359 | connectionName: instance.connectionName, |
| 360 | gceZone: instance.gceZone ?? settings?.locationPreference?.zone, |
| 361 | ipAddress: primaryIp(instance), |
| 362 | ipv4Enabled: settings?.ipConfiguration?.ipv4Enabled !== false, |
| 363 | deletionProtectionEnabled: settings?.deletionProtectionEnabled === true, |
| 364 | activationPolicy: settings?.activationPolicy, |
| 365 | availabilityType: settings?.availabilityType, |
| 366 | dataDiskSizeGb: diskSizeOf(settings?.dataDiskSizeGb), |
| 367 | backupEnabled: settings?.backupConfiguration?.enabled === true, |
| 368 | serviceAccountEmailAddress: instance.serviceAccountEmailAddress, |
| 369 | selfLink: instance.selfLink, |
| 370 | createTime: instance.createTime, |
| 371 | }; |
| 372 | }; |
| 373 | |
| 374 | const getByName = (project: string, instance: string) => |
| 375 | sqladmin |
| 376 | .getInstances({ project, instance }) |
| 377 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 378 | |
| 379 | /** |
| 380 | * Instance create, patch, and delete operations routinely run 5–15 |
| 381 | * minutes; poll every 10s for up to 20 minutes. |
| 382 | */ |
| 383 | const LONG_OPERATION_POLL = { |
| 384 | times: 120, |
| 385 | schedule: Schedule.spaced("10 seconds"), |
| 386 | }; |
| 387 | |
| 388 | const waitForOperation = ( |
| 389 | project: string, |
| 390 | operation: sqladmin.Operation, |
| 391 | options?: { notFoundOk?: boolean }, |
| 392 | ) => |
| 393 | waitForSqlOperation(project, operation, { |
| 394 | budget: "30 minutes", |
| 395 | notFoundOk: true, |
| 396 | }); |
| 397 | |
| 398 | const waitUntilExists = (project: string, instanceName: string) => |
| 399 | getByName(project, instanceName).pipe( |
| 400 | Effect.flatMap((instance) => |
| 401 | instance |
| 402 | ? Effect.succeed(instance) |
| 403 | : Effect.fail(new InstanceNotResolved({ instanceName, project })), |
| 404 | ), |
| 405 | Effect.retry({ |
| 406 | while: (error) => error._tag === "GCP.SQL.InstanceNotResolved", |
| 407 | times: 8, |
| 408 | schedule: Schedule.spaced("2 seconds"), |
| 409 | }), |
| 410 | ); |
| 411 | |
| 412 | const waitUntilRunnable = (project: string, instanceName: string) => |
| 413 | getByName(project, instanceName).pipe( |
| 414 | Effect.filterOrFail( |
| 415 | (instance): instance is sqladmin.DatabaseInstance => |
| 416 | instance !== undefined, |
| 417 | () => new InstanceNotResolved({ instanceName, project }), |
| 418 | ), |
| 419 | Effect.filterOrFail( |
| 420 | (instance) => { |
| 421 | const state = instance.state ?? "SQL_INSTANCE_STATE_UNSPECIFIED"; |
| 422 | return state === "RUNNABLE" || state === "SUSPENDED"; |
| 423 | }, |
| 424 | (instance) => { |
| 425 | const state = instance.state ?? "SQL_INSTANCE_STATE_UNSPECIFIED"; |
| 426 | return state === "FAILED" |
| 427 | ? new InstanceFailed({ instanceName, state }) |
| 428 | : new InstanceNotReady({ instanceName, state }); |
| 429 | }, |
| 430 | ), |
| 431 | Effect.retry({ |
| 432 | while: (error) => |
| 433 | error._tag === "GCP.SQL.InstanceNotReady" || |
| 434 | error._tag === "GCP.SQL.InstanceNotResolved", |
| 435 | ...LONG_OPERATION_POLL, |
| 436 | }), |
| 437 | ); |
| 438 | |
| 439 | const waitUntilGone = (project: string, instanceName: string) => |
| 440 | getByName(project, instanceName).pipe( |
| 441 | Effect.flatMap((instance) => |
| 442 | instance === undefined |
| 443 | ? Effect.void |
| 444 | : Effect.fail(new InstanceStillExists({ instanceName })), |
| 445 | ), |
| 446 | Effect.retry({ |
| 447 | while: (error) => error._tag === "GCP.SQL.InstanceStillExists", |
| 448 | ...LONG_OPERATION_POLL, |
| 449 | }), |
| 450 | ); |
| 451 | |
| 452 | const backupConfiguration = ( |
| 453 | enabled: boolean, |
| 454 | version: string, |
| 455 | ): sqladmin.BackupConfiguration => ({ |
| 456 | enabled, |
| 457 | binaryLogEnabled: enabled && isMysql(version), |
| 458 | pointInTimeRecoveryEnabled: enabled && isPostgres(version), |
| 459 | }); |
| 460 | |
| 461 | const dataApiAccessOf = (enabled: boolean | undefined) => { |
| 462 | if (enabled === true) return "ALLOW_DATA_API"; |
| 463 | if (enabled === false) return "DISALLOW_DATA_API"; |
| 464 | return undefined; |
| 465 | }; |
| 466 | |
| 467 | const insertBody = ( |
| 468 | news: InstanceProps, |
| 469 | instanceName: string, |
| 470 | region: string, |
| 471 | version: string, |
| 472 | desiredLabels: Record<string, string>, |
| 473 | ): sqladmin.DatabaseInstance => { |
| 474 | const backupEnabled = news.backupEnabled === true; |
| 475 | return { |
| 476 | name: instanceName, |
| 477 | region, |
| 478 | databaseVersion: version, |
| 479 | rootPassword: news.rootPassword, |
| 480 | settings: { |
| 481 | tier: normalizeTier(news.tier), |
| 482 | edition: normalizeEdition(news.edition), |
| 483 | activationPolicy: normalizeActivation(news.activationPolicy), |
| 484 | availabilityType: normalizeAvailability(news.availabilityType), |
| 485 | dataDiskSizeGb: String(news.dataDiskSizeGb ?? DEFAULT_DISK_SIZE_GB), |
| 486 | dataDiskType: normalizeDiskType(news.dataDiskType), |
| 487 | deletionProtectionEnabled: news.deletionProtectionEnabled === true, |
| 488 | userLabels: desiredLabels, |
| 489 | storageAutoResize: false, |
| 490 | ipConfiguration: { |
| 491 | ipv4Enabled: news.ipv4Enabled !== false, |
| 492 | }, |
| 493 | backupConfiguration: backupConfiguration(backupEnabled, version), |
| 494 | locationPreference: news.zone ? { zone: news.zone } : undefined, |
| 495 | databaseFlags: news.databaseFlags, |
| 496 | dataApiAccess: dataApiAccessOf(news.dataApiAccess), |
| 497 | }, |
| 498 | }; |
| 499 | }; |
| 500 | |
| 501 | const applyPatch = ( |
| 502 | project: string, |
| 503 | instanceName: string, |
| 504 | body: sqladmin.DatabaseInstance, |
| 505 | ) => |
| 506 | sqladmin |
| 507 | .patchInstances({ |
| 508 | project, |
| 509 | instance: instanceName, |
| 510 | body, |
| 511 | }) |
| 512 | .pipe( |
| 513 | Effect.retry({ |
| 514 | while: (error) => error._tag === "Conflict", |
| 515 | times: 8, |
| 516 | schedule: Schedule.spaced("5 seconds"), |
| 517 | }), |
| 518 | ); |
| 519 | |
| 520 | export const InstanceProvider = () => |
| 521 | Provider.succeed(Instance, { |
| 522 | stables: [ |
| 523 | "instanceName", |
| 524 | "project", |
| 525 | "region", |
| 526 | "databaseVersion", |
| 527 | "connectionName", |
| 528 | "selfLink", |
| 529 | "createTime", |
| 530 | ], |
| 531 | |
| 532 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 533 | if (!isResolved(news)) return undefined; |
| 534 | const env = yield* GcpEnvironment.current; |
| 535 | |
| 536 | const previousName = olds?.instanceName ?? output?.instanceName; |
| 537 | const nextName = news.instanceName ?? previousName; |
| 538 | const previousRegion = normalizeRegion( |
| 539 | olds?.region ?? output?.region, |
| 540 | env.region, |
| 541 | ); |
| 542 | const nextRegion = normalizeRegion( |
| 543 | news.region ?? output?.region, |
| 544 | env.region, |
| 545 | ); |
| 546 | const previousVersion = normalizeVersion( |
| 547 | olds?.databaseVersion ?? output?.databaseVersion, |
| 548 | ); |
| 549 | const nextVersion = normalizeVersion( |
| 550 | news.databaseVersion ?? output?.databaseVersion, |
| 551 | ); |
| 552 | |
| 553 | const nameChanged = |
| 554 | previousName !== undefined && |
| 555 | nextName !== undefined && |
| 556 | previousName !== nextName; |
| 557 | const regionChanged = previousRegion !== nextRegion; |
| 558 | const versionChanged = previousVersion !== nextVersion; |
| 559 | |
| 560 | if (!nameChanged && !regionChanged && !versionChanged) { |
| 561 | return undefined; |
| 562 | } |
| 563 | return { |
| 564 | action: "replace" as const, |
| 565 | deleteFirst: !nameChanged, |
| 566 | }; |
| 567 | }), |
| 568 | |
| 569 | read: Effect.fn(function* ({ id, olds, output }) { |
| 570 | const env = yield* GcpEnvironment.current; |
| 571 | const instanceName = yield* toName( |
| 572 | id, |
| 573 | olds?.instanceName, |
| 574 | output?.instanceName, |
| 575 | ); |
| 576 | const existing = yield* getByName(env.project, instanceName); |
| 577 | if (existing === undefined) return undefined; |
| 578 | const attrs = toAttrs(existing, env.project, env.region); |
| 579 | return (yield* hasAlchemyLabels( |
| 580 | id, |
| 581 | tagRecord(existing.settings?.userLabels), |
| 582 | )) |
| 583 | ? attrs |
| 584 | : Unowned(attrs); |
| 585 | }), |
| 586 | |
| 587 | list: () => |
| 588 | Effect.gen(function* () { |
| 589 | const env = yield* GcpEnvironment.current; |
| 590 | return yield* sqladmin.listInstances |
| 591 | .items({ |
| 592 | project: env.project, |
| 593 | maxResults: 1000, |
| 594 | }) |
| 595 | .pipe( |
| 596 | Stream.filter((instance) => |
| 597 | Object.keys(instance.settings?.userLabels ?? {}).some((key) => |
| 598 | key.startsWith("alchemy-"), |
| 599 | ), |
| 600 | ), |
| 601 | Stream.map((instance) => |
| 602 | toAttrs(instance, env.project, env.region), |
| 603 | ), |
| 604 | Stream.runCollect, |
| 605 | Effect.map((chunk) => Array.from(chunk)), |
| 606 | Effect.catchTag("NotFound", () => Effect.succeed([])), |
| 607 | ); |
| 608 | }), |
| 609 | |
| 610 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 611 | const env = yield* GcpEnvironment.current; |
| 612 | const instanceName = yield* toName( |
| 613 | id, |
| 614 | news.instanceName, |
| 615 | output?.instanceName, |
| 616 | ); |
| 617 | const region = normalizeRegion(news.region ?? output?.region, env.region); |
| 618 | const version = normalizeVersion( |
| 619 | news.databaseVersion ?? output?.databaseVersion, |
| 620 | ); |
| 621 | const desiredLabels = { |
| 622 | ...toLabels(news.labels), |
| 623 | ...(yield* createInternalLabels(id)), |
| 624 | }; |
| 625 | |
| 626 | let current = yield* getByName( |
| 627 | env.project, |
| 628 | output?.instanceName ?? instanceName, |
| 629 | ); |
| 630 | |
| 631 | if (current === undefined) { |
| 632 | const created = yield* sqladmin |
| 633 | .insertInstances({ |
| 634 | project: env.project, |
| 635 | body: insertBody( |
| 636 | news, |
| 637 | instanceName, |
| 638 | region, |
| 639 | version, |
| 640 | desiredLabels, |
| 641 | ), |
| 642 | }) |
| 643 | .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined))); |
| 644 | if (created !== undefined) { |
| 645 | yield* waitForOperation(env.project, created); |
| 646 | } |
| 647 | current = yield* waitUntilExists(env.project, instanceName); |
| 648 | } |
| 649 | |
| 650 | if (current === undefined) { |
| 651 | return yield* new InstanceNotResolved({ |
| 652 | instanceName, |
| 653 | project: env.project, |
| 654 | }); |
| 655 | } |
| 656 | |
| 657 | const state = current.state ?? ""; |
| 658 | if (state !== "RUNNABLE" && state !== "SUSPENDED") { |
| 659 | current = yield* waitUntilRunnable(env.project, instanceName); |
| 660 | } |
| 661 | |
| 662 | const settings = current.settings; |
| 663 | const observedLabels = tagRecord(settings?.userLabels); |
| 664 | const { upsert, removed } = diffLabels(observedLabels, desiredLabels); |
| 665 | const labelsChanged = upsert.length > 0 || removed.length > 0; |
| 666 | |
| 667 | const desiredTier = normalizeTier(news.tier ?? settings?.tier); |
| 668 | const tierChanged = |
| 669 | news.tier !== undefined && |
| 670 | (settings?.tier ?? DEFAULT_TIER) !== desiredTier; |
| 671 | |
| 672 | const desiredEdition = normalizeEdition( |
| 673 | news.edition ?? settings?.edition, |
| 674 | ); |
| 675 | const editionChanged = |
| 676 | news.edition !== undefined && |
| 677 | (settings?.edition ?? DEFAULT_EDITION).toUpperCase() !== desiredEdition; |
| 678 | |
| 679 | const desiredActivation = normalizeActivation( |
| 680 | news.activationPolicy ?? settings?.activationPolicy, |
| 681 | ); |
| 682 | const activationChanged = |
| 683 | news.activationPolicy !== undefined && |
| 684 | (settings?.activationPolicy ?? DEFAULT_ACTIVATION).toUpperCase() !== |
| 685 | desiredActivation; |
| 686 | |
| 687 | const desiredAvailability = normalizeAvailability( |
| 688 | news.availabilityType ?? settings?.availabilityType, |
| 689 | ); |
| 690 | const availabilityChanged = |
| 691 | news.availabilityType !== undefined && |
| 692 | (settings?.availabilityType ?? DEFAULT_AVAILABILITY).toUpperCase() !== |
| 693 | desiredAvailability; |
| 694 | |
| 695 | const desiredProtection = news.deletionProtectionEnabled === true; |
| 696 | const protectionChanged = |
| 697 | (settings?.deletionProtectionEnabled === true) !== desiredProtection; |
| 698 | |
| 699 | const desiredIpv4 = news.ipv4Enabled !== false; |
| 700 | const ipv4Changed = |
| 701 | news.ipv4Enabled !== undefined && |
| 702 | (settings?.ipConfiguration?.ipv4Enabled !== false) !== desiredIpv4; |
| 703 | |
| 704 | const desiredBackup = news.backupEnabled === true; |
| 705 | const backupChanged = |
| 706 | news.backupEnabled !== undefined && |
| 707 | (settings?.backupConfiguration?.enabled === true) !== desiredBackup; |
| 708 | |
| 709 | const desiredDisk = news.dataDiskSizeGb; |
| 710 | const currentDisk = diskSizeOf(settings?.dataDiskSizeGb); |
| 711 | const diskChanged = |
| 712 | desiredDisk !== undefined && |
| 713 | currentDisk !== undefined && |
| 714 | desiredDisk > currentDisk; |
| 715 | |
| 716 | const desiredZone = news.zone; |
| 717 | const zoneChanged = |
| 718 | desiredZone !== undefined && |
| 719 | (current.gceZone ?? settings?.locationPreference?.zone) !== desiredZone; |
| 720 | |
| 721 | const flagsChanged = |
| 722 | news.databaseFlags !== undefined && |
| 723 | flagsKey(settings?.databaseFlags) !== flagsKey(news.databaseFlags); |
| 724 | |
| 725 | const desiredDataApi = dataApiAccessOf(news.dataApiAccess); |
| 726 | const dataApiChanged = |
| 727 | desiredDataApi !== undefined && |
| 728 | (settings?.dataApiAccess ?? "DISALLOW_DATA_API") !== desiredDataApi; |
| 729 | |
| 730 | if ( |
| 731 | labelsChanged || |
| 732 | tierChanged || |
| 733 | editionChanged || |
| 734 | activationChanged || |
| 735 | availabilityChanged || |
| 736 | protectionChanged || |
| 737 | ipv4Changed || |
| 738 | backupChanged || |
| 739 | diskChanged || |
| 740 | zoneChanged || |
| 741 | flagsChanged || |
| 742 | dataApiChanged |
| 743 | ) { |
| 744 | const nextLabels: Record<string, string | null> = { |
| 745 | ...desiredLabels, |
| 746 | }; |
| 747 | for (const key of removed) { |
| 748 | nextLabels[key] = null; |
| 749 | } |
| 750 | const patched = yield* applyPatch(env.project, instanceName, { |
| 751 | settings: { |
| 752 | settingsVersion: settings?.settingsVersion, |
| 753 | userLabels: nextLabels as unknown as Record<string, string>, |
| 754 | tier: desiredTier, |
| 755 | edition: news.edition !== undefined ? desiredEdition : undefined, |
| 756 | activationPolicy: |
| 757 | news.activationPolicy !== undefined |
| 758 | ? desiredActivation |
| 759 | : undefined, |
| 760 | availabilityType: |
| 761 | news.availabilityType !== undefined |
| 762 | ? desiredAvailability |
| 763 | : undefined, |
| 764 | deletionProtectionEnabled: desiredProtection, |
| 765 | dataDiskSizeGb: diskChanged ? String(desiredDisk) : undefined, |
| 766 | ipConfiguration: ipv4Changed |
| 767 | ? { ipv4Enabled: desiredIpv4 } |
| 768 | : undefined, |
| 769 | backupConfiguration: backupChanged |
| 770 | ? backupConfiguration(desiredBackup, version) |
| 771 | : undefined, |
| 772 | locationPreference: zoneChanged ? { zone: desiredZone } : undefined, |
| 773 | databaseFlags: |
| 774 | news.databaseFlags !== undefined ? news.databaseFlags : undefined, |
| 775 | dataApiAccess: desiredDataApi, |
| 776 | }, |
| 777 | }); |
| 778 | yield* waitForOperation(env.project, patched); |
| 779 | current = yield* waitUntilRunnable(env.project, instanceName); |
| 780 | } |
| 781 | |
| 782 | return toAttrs(current, env.project, env.region); |
| 783 | }), |
| 784 | |
| 785 | delete: Effect.fn(function* ({ output }) { |
| 786 | const env = yield* GcpEnvironment.current; |
| 787 | const instanceName = output.instanceName; |
| 788 | const existing = yield* getByName(env.project, instanceName); |
| 789 | if (existing === undefined) return; |
| 790 | |
| 791 | if (existing.settings?.deletionProtectionEnabled === true) { |
| 792 | const patched = yield* applyPatch(env.project, instanceName, { |
| 793 | settings: { |
| 794 | settingsVersion: existing.settings.settingsVersion, |
| 795 | deletionProtectionEnabled: false, |
| 796 | }, |
| 797 | }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 798 | if (patched !== undefined) { |
| 799 | yield* waitForOperation(env.project, patched, { notFoundOk: true }); |
| 800 | } |
| 801 | } |
| 802 | |
| 803 | const operation = yield* sqladmin |
| 804 | .deleteInstances({ |
| 805 | project: env.project, |
| 806 | instance: instanceName, |
| 807 | }) |
| 808 | .pipe( |
| 809 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 810 | Effect.retry({ |
| 811 | while: (error) => error._tag === "Conflict", |
| 812 | times: 8, |
| 813 | schedule: Schedule.spaced("5 seconds"), |
| 814 | }), |
| 815 | ); |
| 816 | if (operation !== undefined) { |
| 817 | yield* waitForOperation(env.project, operation, { notFoundOk: true }); |
| 818 | } |
| 819 | yield* waitUntilGone(env.project, instanceName); |
| 820 | }), |
| 821 | }); |