| 1 | import type { |
| 2 | AddOnPlansResponseEdgesItemNode, |
| 3 | AddOnsResponseEdgesItemNode, |
| 4 | CreateAddOnResponseAddOn, |
| 5 | } from "@distilled.cloud/fly-io/addons"; |
| 6 | import * as addons from "@distilled.cloud/fly-io/addons"; |
| 7 | import * as machines from "@distilled.cloud/fly-io/machines"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Duration from "effect/Duration"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Result from "effect/Result"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import { Unowned } from "../AdoptPolicy.ts"; |
| 15 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 16 | import * as Provider from "../Provider.ts"; |
| 17 | import { Resource } from "../Resource.ts"; |
| 18 | import { resolveOrgSlug } from "./Environment.ts"; |
| 19 | import { |
| 20 | createFlyAppName, |
| 21 | matchesAlchemyPhysicalName, |
| 22 | sanitizeFlyAppName, |
| 23 | } from "./Metadata.ts"; |
| 24 | import type { Providers } from "./Providers.ts"; |
| 25 | |
| 26 | export const DEFAULT_REDIS_REGION = "iad"; |
| 27 | export const REDIS_ADDON_TYPE = "upstash_redis"; |
| 28 | export const REDIS_PROVIDER = "upstash_redis"; |
| 29 | export const REDIS_URL_ENV = "REDIS_URL"; |
| 30 | |
| 31 | export { |
| 32 | CommandError as RedisCommandError, |
| 33 | UrlMissing as RedisUrlMissing, |
| 34 | } from "../Redis/index.ts"; |
| 35 | |
| 36 | export interface RedisProps { |
| 37 | /** |
| 38 | * Upstash Redis database name. Globally unique in the org. If omitted, |
| 39 | * a unique name is generated from the stack, stage and logical ID. |
| 40 | * Changing it replaces the database. |
| 41 | */ |
| 42 | name?: string; |
| 43 | /** |
| 44 | * Primary region (`iad`, `ord`, `sjc`, …). Fly cannot move a primary. |
| 45 | * Changing it replaces the database. |
| 46 | * |
| 47 | * @default "iad" |
| 48 | */ |
| 49 | primaryRegion?: string; |
| 50 | /** |
| 51 | * Organization slug. Defaults to the current token's org. Changing it |
| 52 | * replaces the database. |
| 53 | */ |
| 54 | orgSlug?: string; |
| 55 | /** |
| 56 | * Add-on plan id, name, or display name from `addOnPlans`. Default is |
| 57 | * the cheapest listed plan (free or pay-as-you-go). Fixed plans are |
| 58 | * billed. Default tests use the cheapest listed plan. |
| 59 | */ |
| 60 | plan?: string; |
| 61 | /** |
| 62 | * Read-replica regions. Updated in place. Must not include the |
| 63 | * primary region. |
| 64 | */ |
| 65 | readRegions?: string[]; |
| 66 | /** |
| 67 | * Evict keys when memory is full (cache workload). Updated in place. |
| 68 | */ |
| 69 | eviction?: boolean; |
| 70 | /** |
| 71 | * Automatically upgrade a fixed plan when hitting resource limits. |
| 72 | * Ignored on pay-as-you-go. |
| 73 | */ |
| 74 | autoUpgrade?: boolean; |
| 75 | /** |
| 76 | * ProdPack add-on ($200/mo). Paid. Not enabled in default tests. |
| 77 | */ |
| 78 | prodPack?: boolean; |
| 79 | } |
| 80 | |
| 81 | export type Redis = Resource< |
| 82 | "Fly.Redis", |
| 83 | RedisProps, |
| 84 | { |
| 85 | /** Fly GraphQL add-on id. */ |
| 86 | redisId: string; |
| 87 | /** Physical Upstash Redis name. */ |
| 88 | name: string; |
| 89 | /** Primary region code. */ |
| 90 | primaryRegion: string; |
| 91 | /** Observed read-replica regions. */ |
| 92 | readRegions: string[]; |
| 93 | /** Observed status (`ready`, `provisioning`, …). */ |
| 94 | status: string | undefined; |
| 95 | /** Selected plan id, if the API returned one. */ |
| 96 | planId: string | undefined; |
| 97 | /** Observed plan name (`Pay-as-you-go`, `Fixed 250MB`, …). */ |
| 98 | planName: string | undefined; |
| 99 | /** Private 6PN address, if the API returned one. */ |
| 100 | privateIp: string | undefined; |
| 101 | /** Organization slug. */ |
| 102 | orgSlug: string | undefined; |
| 103 | /** Whether eviction is enabled. */ |
| 104 | eviction: boolean | undefined; |
| 105 | /** |
| 106 | * Redacted Upstash connection URL. Bindings transport it to the |
| 107 | * runtime automatically. Service attachments also set `REDIS_URL`. |
| 108 | */ |
| 109 | url: Redacted.Redacted<string> | undefined; |
| 110 | }, |
| 111 | never, |
| 112 | Providers |
| 113 | >; |
| 114 | |
| 115 | /** |
| 116 | * Managed Upstash Redis in a Fly org. Bind {@link ReadRedis}, |
| 117 | * {@link WriteRedis}, or {@link ReadWriteRedis} on a {@link Service}. |
| 118 | * Alchemy transports the redacted `url` Output to the runtime client |
| 119 | * and also writes `REDIS_URL` as an App secret for compatibility. |
| 120 | * Redis is not reachable from CI — drive it over HTTP. |
| 121 | * |
| 122 | * @see https://fly.io/docs/upstash/redis/ |
| 123 | * |
| 124 | * ### Create Redis |
| 125 | * Alchemy generates a unique name unless you pass one. Default region is |
| 126 | * `iad`. Default plan is the cheapest `addOnPlans` row (free or |
| 127 | * pay-as-you-go). |
| 128 | * |
| 129 | * **Example:** Generated name |
| 130 | * ```typescript |
| 131 | * const cache = yield* Fly.Redis("Cache"); |
| 132 | * ``` |
| 133 | * |
| 134 | * :::note |
| 135 | * Prefer omitting `name` in tests and CI so names stay unique and |
| 136 | * reclaimable. |
| 137 | * ::: |
| 138 | * |
| 139 | * ### A stable name |
| 140 | * Pass `name` when you want a stable Upstash database name. |
| 141 | * |
| 142 | * **Example:** Explicit name |
| 143 | * ```typescript |
| 144 | * const cache = yield* Fly.Redis("Cache", { |
| 145 | * name: "my-cache", |
| 146 | * primaryRegion: "iad", |
| 147 | * }); |
| 148 | * ``` |
| 149 | * |
| 150 | * :::caution[Changing `name` replaces Redis] |
| 151 | * Fly cannot rename an add-on. Alchemy deletes the old database first, |
| 152 | * then creates the new one. |
| 153 | * ::: |
| 154 | * |
| 155 | * :::caution[Changing `primaryRegion` replaces Redis] |
| 156 | * The primary region is immutable. The old database is deleted first |
| 157 | * because the name cannot exist twice. |
| 158 | * ::: |
| 159 | * |
| 160 | * ### Bind from a Service |
| 161 | * Yield {@link ReadWriteRedis} (or {@link ReadRedis} / {@link WriteRedis}) |
| 162 | * in Service init. Provide the matching `*Http` layer. |
| 163 | * |
| 164 | * **Example:** Read and write |
| 165 | * ```typescript |
| 166 | * import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 167 | * |
| 168 | * const Cache = Fly.Redis("Cache"); |
| 169 | * |
| 170 | * export default class Api extends Fly.Service<Api>()( |
| 171 | * "Api", |
| 172 | * { app: Site, main: import.meta.url, port: 3000 }, |
| 173 | * Effect.gen(function* () { |
| 174 | * const cache = yield* Fly.ReadWriteRedis(Cache); |
| 175 | * return { |
| 176 | * fetch: Effect.gen(function* () { |
| 177 | * yield* cache.set("marker", "hello"); |
| 178 | * const value = yield* cache.get("marker"); |
| 179 | * return HttpServerResponse.json({ value }); |
| 180 | * }), |
| 181 | * }; |
| 182 | * }).pipe(Effect.provide(Fly.ReadWriteRedisHttp)), |
| 183 | * ) {} |
| 184 | * ``` |
| 185 | * |
| 186 | * ### Eviction |
| 187 | * Enable eviction for cache workloads. Updated in place. |
| 188 | * |
| 189 | * **Example:** Eviction |
| 190 | * ```typescript |
| 191 | * const cache = yield* Fly.Redis("Cache", { |
| 192 | * eviction: true, |
| 193 | * }); |
| 194 | * ``` |
| 195 | * |
| 196 | * ### Read replicas |
| 197 | * `readRegions` are extra replica regions. Updated in place. |
| 198 | * |
| 199 | * **Example:** Replica region |
| 200 | * ```typescript |
| 201 | * const cache = yield* Fly.Redis("Cache", { |
| 202 | * primaryRegion: "iad", |
| 203 | * readRegions: ["sjc"], |
| 204 | * }); |
| 205 | * ``` |
| 206 | * |
| 207 | * ### Plan |
| 208 | * Omit `plan` for the cheapest listed plan. Pass a plan id or display |
| 209 | * name to pin one. Fixed plans are billed. |
| 210 | * |
| 211 | * **Example:** Pin pay-as-you-go |
| 212 | * ```typescript |
| 213 | * const cache = yield* Fly.Redis("Cache", { |
| 214 | * plan: "Pay-as-you-go", |
| 215 | * }); |
| 216 | * ``` |
| 217 | * |
| 218 | * :::caution[Fixed plans are billed] |
| 219 | * Fixed plans are billed monthly. Prefer omitting `plan` unless you |
| 220 | * need a specific size. |
| 221 | * ::: |
| 222 | * |
| 223 | * @resource |
| 224 | * @product Redis |
| 225 | */ |
| 226 | export const Redis = Resource<Redis>("Fly.Redis"); |
| 227 | |
| 228 | export class RedisNotCreated extends Data.TaggedError("Fly.RedisNotCreated")<{ |
| 229 | name: string; |
| 230 | errorMessage?: string; |
| 231 | }> {} |
| 232 | |
| 233 | export class RedisPlanNotFound extends Data.TaggedError( |
| 234 | "Fly.RedisPlanNotFound", |
| 235 | )<{ |
| 236 | plan: string; |
| 237 | }> {} |
| 238 | |
| 239 | export class RedisOrgMissing extends Data.TaggedError("Fly.RedisOrgMissing")<{ |
| 240 | orgSlug: string; |
| 241 | }> {} |
| 242 | |
| 243 | class RedisPending extends Data.TaggedError("Fly.RedisPending")<{ |
| 244 | redisId: string; |
| 245 | status: string; |
| 246 | }> {} |
| 247 | |
| 248 | type ObservedRedis = AddOnsResponseEdgesItemNode | CreateAddOnResponseAddOn; |
| 249 | |
| 250 | type RedisPlan = AddOnPlansResponseEdgesItemNode; |
| 251 | |
| 252 | const backoff = Schedule.min([ |
| 253 | Schedule.exponential(Duration.millis(500), 1.5), |
| 254 | Schedule.spaced(Duration.seconds(5)), |
| 255 | ]); |
| 256 | |
| 257 | const unwrapSensitive = ( |
| 258 | value: string | Redacted.Redacted<string> | null | undefined, |
| 259 | ): string | undefined => { |
| 260 | if (value == null) return undefined; |
| 261 | return Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 262 | }; |
| 263 | |
| 264 | const recordOf = (value: unknown): Record<string, unknown> => { |
| 265 | if (value != null && typeof value === "object" && !Array.isArray(value)) { |
| 266 | return { ...(value as Record<string, unknown>) }; |
| 267 | } |
| 268 | return {}; |
| 269 | }; |
| 270 | |
| 271 | const stringRecordOf = (value: unknown): Record<string, string> => |
| 272 | Object.fromEntries( |
| 273 | Object.entries(recordOf(value)).flatMap(([key, item]) => |
| 274 | typeof item === "string" && item.length > 0 ? [[key, item]] : [], |
| 275 | ), |
| 276 | ); |
| 277 | |
| 278 | const evictionOf = (options: unknown): boolean | undefined => { |
| 279 | const value = recordOf(options).eviction; |
| 280 | return typeof value === "boolean" ? value : undefined; |
| 281 | }; |
| 282 | |
| 283 | const sorted = (regions: readonly string[] | null | undefined): string[] => |
| 284 | [...(regions ?? [])].sort(); |
| 285 | |
| 286 | const pendingStatus = (status: string | null | undefined): boolean => { |
| 287 | if (status == null || status.length === 0) return false; |
| 288 | const value = status.toLowerCase(); |
| 289 | return ( |
| 290 | value === "pending" || |
| 291 | value === "provisioning" || |
| 292 | value === "creating" || |
| 293 | value === "launching" |
| 294 | ); |
| 295 | }; |
| 296 | |
| 297 | const failedStatus = (status: string | null | undefined): boolean => { |
| 298 | if (status == null) return false; |
| 299 | const value = status.toLowerCase(); |
| 300 | return value === "error" || value === "failed" || value === "destroyed"; |
| 301 | }; |
| 302 | |
| 303 | const isLegacyPlanName = (plan: RedisPlan): boolean => { |
| 304 | const normalized = (plan.displayName ?? plan.name ?? "") |
| 305 | .toLowerCase() |
| 306 | .replaceAll(" ", "_"); |
| 307 | return ( |
| 308 | normalized === "pro_2k" || |
| 309 | normalized === "pro_10k" || |
| 310 | normalized === "starter" || |
| 311 | normalized === "standard" |
| 312 | ); |
| 313 | }; |
| 314 | |
| 315 | export const isFixedRedisPlan = ( |
| 316 | plan: Pick<RedisPlan, "name" | "displayName">, |
| 317 | ): boolean => { |
| 318 | const display = (plan.displayName ?? "").toLowerCase(); |
| 319 | const name = (plan.name ?? "").toLowerCase(); |
| 320 | return display.startsWith("fixed ") || name.startsWith("flyio_fixed_"); |
| 321 | }; |
| 322 | |
| 323 | const hasAlchemyMetadata = (metadata: unknown): boolean => { |
| 324 | const tags = stringRecordOf(metadata); |
| 325 | const stack = tags["alchemy::stack"] ?? tags["alchemy.stack"]; |
| 326 | return stack !== undefined && stack.length > 0; |
| 327 | }; |
| 328 | |
| 329 | const isOwnedRedis = (row: ObservedRedis): boolean => |
| 330 | matchesAlchemyPhysicalName(row.name ?? undefined) || |
| 331 | hasAlchemyMetadata(row.metadata); |
| 332 | |
| 333 | const resolveName = (id: string, name: string | undefined, existing?: string) => |
| 334 | Effect.gen(function* () { |
| 335 | if (name !== undefined) return sanitizeFlyAppName(name); |
| 336 | if (existing !== undefined) return existing; |
| 337 | return yield* createFlyAppName(id); |
| 338 | }); |
| 339 | |
| 340 | const urlOf = ( |
| 341 | row: ObservedRedis, |
| 342 | previous?: Redacted.Redacted<string>, |
| 343 | ): Redacted.Redacted<string> | undefined => { |
| 344 | const raw = unwrapSensitive(row.publicUrl); |
| 345 | if (raw !== undefined && raw.length > 0) return Redacted.make(raw); |
| 346 | return previous; |
| 347 | }; |
| 348 | |
| 349 | const toAttrs = ( |
| 350 | row: ObservedRedis, |
| 351 | fallback: { |
| 352 | name: string; |
| 353 | primaryRegion: string; |
| 354 | orgSlug?: string; |
| 355 | planId?: string; |
| 356 | url?: Redacted.Redacted<string>; |
| 357 | }, |
| 358 | ): Redis["Attributes"] => ({ |
| 359 | redisId: row.id, |
| 360 | name: row.name ?? fallback.name, |
| 361 | primaryRegion: row.primaryRegion ?? fallback.primaryRegion, |
| 362 | readRegions: sorted(row.readRegions), |
| 363 | status: row.status ?? undefined, |
| 364 | planId: |
| 365 | "addOnPlan" in row |
| 366 | ? ((row.addOnPlan as { id?: string } | null | undefined)?.id ?? |
| 367 | fallback.planId) |
| 368 | : fallback.planId, |
| 369 | planName: row.addOnPlanName ?? undefined, |
| 370 | privateIp: row.privateIp ?? undefined, |
| 371 | orgSlug: |
| 372 | "organization" in row |
| 373 | ? ((row.organization as { slug?: string | null } | undefined)?.slug ?? |
| 374 | fallback.orgSlug) |
| 375 | : fallback.orgSlug, |
| 376 | eviction: evictionOf(row.options), |
| 377 | url: urlOf(row, fallback.url), |
| 378 | }); |
| 379 | |
| 380 | export const listRedisAddOns = Effect.fn(function* () { |
| 381 | const rows: AddOnsResponseEdgesItemNode[] = []; |
| 382 | let after: string | undefined; |
| 383 | for (let i = 0; i < 8; i++) { |
| 384 | const page = yield* addons.addOns({ |
| 385 | type: REDIS_ADDON_TYPE, |
| 386 | first: 50, |
| 387 | after, |
| 388 | }); |
| 389 | for (const edge of page.edges ?? []) { |
| 390 | if (edge?.node != null) rows.push(edge.node); |
| 391 | } |
| 392 | if (!page.pageInfo.hasNextPage) break; |
| 393 | after = page.pageInfo.endCursor ?? undefined; |
| 394 | if (after === undefined || after.length === 0) break; |
| 395 | } |
| 396 | return rows; |
| 397 | }); |
| 398 | |
| 399 | export const listRedisPlans = Effect.fn(function* () { |
| 400 | const rows: RedisPlan[] = []; |
| 401 | let after: string | undefined; |
| 402 | for (let i = 0; i < 4; i++) { |
| 403 | const page = yield* addons.addOnPlans({ |
| 404 | type: REDIS_ADDON_TYPE, |
| 405 | first: 50, |
| 406 | after, |
| 407 | }); |
| 408 | for (const edge of page.edges ?? []) { |
| 409 | if (edge?.node != null) rows.push(edge.node); |
| 410 | } |
| 411 | if (!page.pageInfo.hasNextPage) break; |
| 412 | after = page.pageInfo.endCursor ?? undefined; |
| 413 | if (after === undefined || after.length === 0) break; |
| 414 | } |
| 415 | return rows; |
| 416 | }); |
| 417 | |
| 418 | export const findRedisAddOn = (input: { id?: string; name?: string }) => |
| 419 | listRedisAddOns().pipe( |
| 420 | Effect.map((rows) => |
| 421 | rows.find((row) => { |
| 422 | if (input.id !== undefined && row.id === input.id) return true; |
| 423 | if (input.name !== undefined && row.name === input.name) return true; |
| 424 | return false; |
| 425 | }), |
| 426 | ), |
| 427 | ); |
| 428 | |
| 429 | const cheapestPlan = (plans: RedisPlan[]) => { |
| 430 | const usable = plans.filter((plan) => !isLegacyPlanName(plan)); |
| 431 | const pool = usable.length > 0 ? usable : plans; |
| 432 | if (pool.length === 0) return undefined; |
| 433 | const free = pool.filter((plan) => (plan.pricePerMonth ?? 0) === 0); |
| 434 | const ranked = (free.length > 0 ? free : pool).slice().sort((left, right) => { |
| 435 | const byPrice = (left.pricePerMonth ?? 0) - (right.pricePerMonth ?? 0); |
| 436 | if (byPrice !== 0) return byPrice; |
| 437 | return (left.displayName ?? left.name ?? "").localeCompare( |
| 438 | right.displayName ?? right.name ?? "", |
| 439 | ); |
| 440 | }); |
| 441 | const payg = ranked.find((plan) => |
| 442 | /pay.?as.?you.?go|free/i.test( |
| 443 | `${plan.displayName ?? ""} ${plan.name ?? ""}`, |
| 444 | ), |
| 445 | ); |
| 446 | return payg ?? ranked[0]; |
| 447 | }; |
| 448 | |
| 449 | const resolvePlan = ( |
| 450 | plan: string | undefined, |
| 451 | existingId: string | undefined, |
| 452 | ) => |
| 453 | Effect.gen(function* () { |
| 454 | const plans = yield* listRedisPlans(); |
| 455 | if (plan !== undefined) { |
| 456 | const found = plans.find( |
| 457 | (item) => |
| 458 | item.id === plan || item.name === plan || item.displayName === plan, |
| 459 | ); |
| 460 | if (found === undefined) { |
| 461 | return yield* new RedisPlanNotFound({ plan }); |
| 462 | } |
| 463 | return found; |
| 464 | } |
| 465 | if (existingId !== undefined) { |
| 466 | const existing = plans.find((item) => item.id === existingId); |
| 467 | if (existing !== undefined) return existing; |
| 468 | } |
| 469 | const cheapest = cheapestPlan(plans); |
| 470 | if (cheapest === undefined) { |
| 471 | return yield* new RedisPlanNotFound({ plan: "default" }); |
| 472 | } |
| 473 | return cheapest; |
| 474 | }); |
| 475 | |
| 476 | const resolveOrganization = (orgSlug: string) => |
| 477 | addons |
| 478 | .organization({ slug: orgSlug }) |
| 479 | .pipe(Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined))); |
| 480 | |
| 481 | const ensureTos = (orgSlug: string, organizationId: string) => |
| 482 | Effect.gen(function* () { |
| 483 | const agreed = yield* addons.agreedToProviderTos({ |
| 484 | slug: orgSlug, |
| 485 | providerName: REDIS_PROVIDER, |
| 486 | }); |
| 487 | if (agreed === true) return; |
| 488 | // Tokens without org-admin cannot write the ToS row. Create still |
| 489 | // succeeds when the org already agreed via flyctl. |
| 490 | yield* Effect.result( |
| 491 | addons.createExtensionTosAgreement({ |
| 492 | input: { |
| 493 | addOnProviderName: REDIS_PROVIDER, |
| 494 | organizationId, |
| 495 | }, |
| 496 | }), |
| 497 | ); |
| 498 | }); |
| 499 | |
| 500 | const waitUntilReady = (id: string, name: string) => |
| 501 | findRedisAddOn({ id, name }).pipe( |
| 502 | Effect.flatMap( |
| 503 | ( |
| 504 | row, |
| 505 | ): Effect.Effect< |
| 506 | AddOnsResponseEdgesItemNode, |
| 507 | RedisPending | RedisNotCreated |
| 508 | > => { |
| 509 | if (row === undefined) { |
| 510 | return Effect.fail( |
| 511 | new RedisPending({ redisId: id, status: "missing" }), |
| 512 | ); |
| 513 | } |
| 514 | if (failedStatus(row.status)) { |
| 515 | return Effect.fail( |
| 516 | new RedisNotCreated({ |
| 517 | name: row.name ?? name, |
| 518 | errorMessage: row.errorMessage ?? row.status ?? undefined, |
| 519 | }), |
| 520 | ); |
| 521 | } |
| 522 | if (pendingStatus(row.status)) { |
| 523 | return Effect.fail( |
| 524 | new RedisPending({ |
| 525 | redisId: id, |
| 526 | status: row.status ?? "provisioning", |
| 527 | }), |
| 528 | ); |
| 529 | } |
| 530 | return Effect.succeed(row); |
| 531 | }, |
| 532 | ), |
| 533 | Effect.retry({ |
| 534 | while: (error) => error._tag === "Fly.RedisPending", |
| 535 | times: 10, |
| 536 | schedule: backoff, |
| 537 | }), |
| 538 | Effect.catchTag("Fly.RedisPending", () => findRedisAddOn({ id, name })), |
| 539 | ); |
| 540 | |
| 541 | const waitUntilGone = (id: string, name: string) => |
| 542 | findRedisAddOn({ id, name }).pipe( |
| 543 | Effect.map((row) => row === undefined), |
| 544 | Effect.repeat({ |
| 545 | schedule: Schedule.spaced("2 seconds"), |
| 546 | until: (gone) => gone, |
| 547 | times: 10, |
| 548 | }), |
| 549 | ); |
| 550 | |
| 551 | const desiredOptions = ( |
| 552 | props: RedisProps, |
| 553 | plan: RedisPlan, |
| 554 | observed: unknown | undefined, |
| 555 | ): Record<string, unknown> => { |
| 556 | const options = recordOf(observed); |
| 557 | if (props.eviction !== undefined) options.eviction = props.eviction; |
| 558 | if (isFixedRedisPlan(plan)) { |
| 559 | if (props.autoUpgrade !== undefined) { |
| 560 | options.auto_upgrade = props.autoUpgrade; |
| 561 | } |
| 562 | } else { |
| 563 | delete options.auto_upgrade; |
| 564 | } |
| 565 | delete options.prod_pack; |
| 566 | return options; |
| 567 | }; |
| 568 | |
| 569 | class RedisSecretVersionMissing extends Data.TaggedError( |
| 570 | "Fly.RedisSecretVersionMissing", |
| 571 | )<{ appName: string }> {} |
| 572 | |
| 573 | const redisSecretVersion = ( |
| 574 | appName: string, |
| 575 | response: machines.AppSecretsUpdateResp | machines.SetAppSecretResponse, |
| 576 | ) => { |
| 577 | const version = response.version ?? response.Version; |
| 578 | return version !== undefined && Number.isSafeInteger(version) && version >= 0 |
| 579 | ? Effect.succeed(version) |
| 580 | : Effect.fail(new RedisSecretVersionMissing({ appName })); |
| 581 | }; |
| 582 | |
| 583 | /** |
| 584 | * Write `REDIS_URL` onto an App from attached Redis add-on names. |
| 585 | * Called from {@link Service} reconcile so the secret exists before |
| 586 | * Machines boot. Returns the highest accepted secret-version floor, or |
| 587 | * `undefined` when no secret was written; this is not a vault snapshot. |
| 588 | */ |
| 589 | export const attachRedisSecrets = Effect.fn(function* ( |
| 590 | appName: string, |
| 591 | attached: readonly { name: string; id?: string }[], |
| 592 | ) { |
| 593 | if (appName.length === 0 || attached.length === 0) return undefined; |
| 594 | const versions: number[] = []; |
| 595 | for (const item of attached) { |
| 596 | const name = item.name; |
| 597 | const id = item.id; |
| 598 | if (name.length === 0 && (id === undefined || id.length === 0)) continue; |
| 599 | const row = yield* findRedisAddOn({ id, name }).pipe( |
| 600 | Effect.flatMap((found) => |
| 601 | found === undefined |
| 602 | ? Effect.fail( |
| 603 | new RedisPending({ |
| 604 | redisId: id ?? "", |
| 605 | status: "missing", |
| 606 | }), |
| 607 | ) |
| 608 | : Effect.succeed(found), |
| 609 | ), |
| 610 | Effect.retry({ |
| 611 | while: (error) => error._tag === "Fly.RedisPending", |
| 612 | times: 8, |
| 613 | schedule: backoff, |
| 614 | }), |
| 615 | Effect.catchTag("Fly.RedisPending", () => findRedisAddOn({ id, name })), |
| 616 | ); |
| 617 | if (row === undefined) { |
| 618 | return yield* new RedisPending({ |
| 619 | redisId: id ?? name, |
| 620 | status: "missing", |
| 621 | }); |
| 622 | } |
| 623 | let url = unwrapSensitive(row.publicUrl); |
| 624 | if ((url === undefined || url.length === 0) && row.id !== undefined) { |
| 625 | const detail = yield* addons |
| 626 | .addOn({ id: row.id }) |
| 627 | .pipe( |
| 628 | Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined)), |
| 629 | ); |
| 630 | url = unwrapSensitive(detail?.publicUrl); |
| 631 | } |
| 632 | if (url === undefined || url.length === 0) { |
| 633 | return yield* new RedisPending({ |
| 634 | redisId: row.id ?? id ?? name, |
| 635 | status: "credentials missing", |
| 636 | }); |
| 637 | } |
| 638 | const value = url; |
| 639 | const update = machines |
| 640 | .updateSecrets({ |
| 641 | app_name: appName, |
| 642 | values: { [REDIS_URL_ENV]: value }, |
| 643 | }) |
| 644 | .pipe( |
| 645 | Effect.flatMap((response) => redisSecretVersion(appName, response)), |
| 646 | ); |
| 647 | const version = yield* update.pipe( |
| 648 | Effect.catchTag("NotFound", () => |
| 649 | machines |
| 650 | .createSecret({ |
| 651 | app_name: appName, |
| 652 | secret_name: REDIS_URL_ENV, |
| 653 | value, |
| 654 | }) |
| 655 | .pipe( |
| 656 | Effect.flatMap((response) => redisSecretVersion(appName, response)), |
| 657 | Effect.catchTag("Conflict", () => update), |
| 658 | ), |
| 659 | ), |
| 660 | ); |
| 661 | versions.push(version); |
| 662 | } |
| 663 | return versions.length > 0 ? Math.max(...versions) : undefined; |
| 664 | }); |
| 665 | |
| 666 | export const RedisProvider = () => |
| 667 | Provider.succeed(Redis, { |
| 668 | stables: ["redisId", "name", "primaryRegion", "orgSlug"], |
| 669 | |
| 670 | diff: Effect.fn(function* ({ news, output }) { |
| 671 | if (news === undefined || !isResolved(news)) return undefined; |
| 672 | if (output === undefined) return undefined; |
| 673 | const desiredName = |
| 674 | news.name !== undefined ? sanitizeFlyAppName(news.name) : output.name; |
| 675 | const nameChanged = desiredName !== output.name; |
| 676 | const desiredRegion = news.primaryRegion ?? DEFAULT_REDIS_REGION; |
| 677 | const regionChanged = desiredRegion !== output.primaryRegion; |
| 678 | const orgChanged = |
| 679 | news.orgSlug !== undefined && news.orgSlug !== output.orgSlug; |
| 680 | if (nameChanged || regionChanged || orgChanged) { |
| 681 | return { |
| 682 | action: "replace" as const, |
| 683 | deleteFirst: !nameChanged, |
| 684 | }; |
| 685 | } |
| 686 | return undefined; |
| 687 | }), |
| 688 | |
| 689 | read: Effect.fn(function* ({ id, olds, output }) { |
| 690 | const name = yield* resolveName(id, olds?.name, output?.name); |
| 691 | const found = |
| 692 | (output?.redisId !== undefined |
| 693 | ? yield* findRedisAddOn({ id: output.redisId, name: output.name }) |
| 694 | : undefined) ?? (yield* findRedisAddOn({ name })); |
| 695 | if (found === undefined) return undefined; |
| 696 | const attrs = toAttrs(found, { |
| 697 | name, |
| 698 | primaryRegion: olds?.primaryRegion ?? DEFAULT_REDIS_REGION, |
| 699 | orgSlug: olds?.orgSlug ?? output?.orgSlug, |
| 700 | url: output?.url, |
| 701 | }); |
| 702 | if (output !== undefined) return attrs; |
| 703 | return isOwnedRedis(found) ? attrs : Unowned(attrs); |
| 704 | }), |
| 705 | |
| 706 | list: Effect.fn(function* () { |
| 707 | const rows = yield* listRedisAddOns(); |
| 708 | return rows.flatMap((row) => { |
| 709 | if (!isOwnedRedis(row)) return []; |
| 710 | const name = row.name; |
| 711 | if (name == null || name.length === 0) return []; |
| 712 | return [ |
| 713 | toAttrs(row, { |
| 714 | name, |
| 715 | primaryRegion: row.primaryRegion ?? DEFAULT_REDIS_REGION, |
| 716 | }), |
| 717 | ]; |
| 718 | }); |
| 719 | }), |
| 720 | |
| 721 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 722 | const props = news ?? {}; |
| 723 | const name = yield* resolveName(id, props.name, output?.name); |
| 724 | const primaryRegion = |
| 725 | props.primaryRegion ?? output?.primaryRegion ?? DEFAULT_REDIS_REGION; |
| 726 | const orgSlug = props.orgSlug ?? (yield* resolveOrgSlug()); |
| 727 | const org = yield* resolveOrganization(orgSlug); |
| 728 | if (org === undefined) { |
| 729 | return yield* new RedisOrgMissing({ orgSlug }); |
| 730 | } |
| 731 | |
| 732 | let current: ObservedRedis | undefined = |
| 733 | output?.redisId !== undefined |
| 734 | ? yield* findRedisAddOn({ |
| 735 | id: output.redisId, |
| 736 | name: output.name, |
| 737 | }) |
| 738 | : undefined; |
| 739 | if ( |
| 740 | current === undefined && |
| 741 | (output === undefined || output.name !== name) |
| 742 | ) { |
| 743 | current = yield* findRedisAddOn({ name }); |
| 744 | } |
| 745 | |
| 746 | const plan = yield* resolvePlan( |
| 747 | props.plan, |
| 748 | current !== undefined && "addOnPlan" in current |
| 749 | ? current.addOnPlan?.id |
| 750 | : output?.planId, |
| 751 | ); |
| 752 | |
| 753 | if (current === undefined) { |
| 754 | yield* ensureTos(orgSlug, org.id); |
| 755 | const created = yield* Effect.result( |
| 756 | addons.createAddOn({ |
| 757 | input: { |
| 758 | type: REDIS_ADDON_TYPE, |
| 759 | name, |
| 760 | organizationId: org.id, |
| 761 | planId: plan.id, |
| 762 | primaryRegion, |
| 763 | readRegions: props.readRegions ?? [], |
| 764 | options: desiredOptions(props, plan, undefined), |
| 765 | }, |
| 766 | }), |
| 767 | ); |
| 768 | if (Result.isSuccess(created)) { |
| 769 | current = created.success.addOn; |
| 770 | } else { |
| 771 | current = yield* findRedisAddOn({ name }); |
| 772 | if (current === undefined) { |
| 773 | return yield* Effect.fail(created.failure); |
| 774 | } |
| 775 | } |
| 776 | } |
| 777 | |
| 778 | if (current === undefined) { |
| 779 | return yield* new RedisNotCreated({ name }); |
| 780 | } |
| 781 | |
| 782 | if (pendingStatus(current.status)) { |
| 783 | const ready = yield* waitUntilReady(current.id, name); |
| 784 | if (ready !== undefined) current = ready; |
| 785 | } |
| 786 | |
| 787 | if (current === undefined) { |
| 788 | return yield* new RedisNotCreated({ name }); |
| 789 | } |
| 790 | |
| 791 | const observedOptions = recordOf(current.options); |
| 792 | const nextOptions = desiredOptions(props, plan, current.options); |
| 793 | const nextReadRegions = sorted( |
| 794 | props.readRegions ?? current.readRegions ?? [], |
| 795 | ); |
| 796 | const observedReadRegions = sorted(current.readRegions); |
| 797 | const observedPlanId = |
| 798 | "addOnPlan" in current ? current.addOnPlan?.id : output?.planId; |
| 799 | const planChanged = |
| 800 | observedPlanId !== undefined && observedPlanId !== plan.id; |
| 801 | const regionsChanged = !deepEqual(observedReadRegions, nextReadRegions); |
| 802 | const optionsChanged = !deepEqual(observedOptions, nextOptions); |
| 803 | const prodPackChanged = props.prodPack !== undefined && planChanged; |
| 804 | |
| 805 | if (planChanged || regionsChanged || optionsChanged || prodPackChanged) { |
| 806 | const updated = yield* Effect.result( |
| 807 | addons.updateAddOn({ |
| 808 | input: { |
| 809 | addOnId: current.id, |
| 810 | planId: plan.id, |
| 811 | readRegions: nextReadRegions, |
| 812 | options: nextOptions, |
| 813 | prodPack: prodPackChanged ? props.prodPack : undefined, |
| 814 | }, |
| 815 | }), |
| 816 | ); |
| 817 | if (Result.isSuccess(updated)) { |
| 818 | current = { |
| 819 | ...current, |
| 820 | ...updated.success.addOn, |
| 821 | }; |
| 822 | } else { |
| 823 | const refreshed = yield* findRedisAddOn({ |
| 824 | id: current.id, |
| 825 | name, |
| 826 | }); |
| 827 | if (refreshed !== undefined) current = refreshed; |
| 828 | } |
| 829 | } |
| 830 | |
| 831 | let latest = (yield* findRedisAddOn({ id: current.id, name })) ?? current; |
| 832 | if ( |
| 833 | unwrapSensitive(latest.publicUrl) === undefined && |
| 834 | latest.id !== undefined |
| 835 | ) { |
| 836 | const detail = yield* addons.addOn({ id: latest.id }); |
| 837 | latest = { ...latest, ...detail }; |
| 838 | } |
| 839 | return toAttrs(latest, { |
| 840 | name, |
| 841 | primaryRegion, |
| 842 | orgSlug, |
| 843 | planId: plan.id, |
| 844 | url: output?.url, |
| 845 | }); |
| 846 | }), |
| 847 | |
| 848 | delete: Effect.fn(function* ({ output }) { |
| 849 | const redisId = output.redisId; |
| 850 | const name = output.name; |
| 851 | if (redisId.length === 0 && (name === undefined || name.length === 0)) { |
| 852 | return; |
| 853 | } |
| 854 | const deleted = yield* Effect.result( |
| 855 | addons.deleteAddOn({ |
| 856 | input: |
| 857 | redisId.length > 0 |
| 858 | ? { addOnId: redisId } |
| 859 | : { name, provider: REDIS_PROVIDER }, |
| 860 | }), |
| 861 | ); |
| 862 | if (Result.isFailure(deleted)) { |
| 863 | const still = yield* findRedisAddOn({ id: redisId, name }); |
| 864 | if (still !== undefined) { |
| 865 | return yield* Effect.fail(deleted.failure); |
| 866 | } |
| 867 | return; |
| 868 | } |
| 869 | yield* waitUntilGone(redisId, name); |
| 870 | }), |
| 871 | }); |