| 1 | import type { AddOnsResponseEdgesItemNode } from "@distilled.cloud/fly-io/addons"; |
| 2 | import * as addons from "@distilled.cloud/fly-io/addons"; |
| 3 | import * as machines from "@distilled.cloud/fly-io/machines"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Redacted from "effect/Redacted"; |
| 8 | import * as Result from "effect/Result"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import { Unowned } from "../AdoptPolicy.ts"; |
| 11 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 12 | import * as Provider from "../Provider.ts"; |
| 13 | import { Resource } from "../Resource.ts"; |
| 14 | import { createInternalTags } from "../Tags.ts"; |
| 15 | import { resolveOrgSlug } from "./Environment.ts"; |
| 16 | import { TigrisCredentialsMissing } from "./Errors.ts"; |
| 17 | import { createFlyAppName, matchesAlchemyPhysicalName } from "./Metadata.ts"; |
| 18 | import type { Providers } from "./Providers.ts"; |
| 19 | |
| 20 | /** Env-var names Tigris HTTP bindings write onto an App (same as `fly storage create`). */ |
| 21 | export const BUCKET_SECRET_NAMES = [ |
| 22 | "AWS_ACCESS_KEY_ID", |
| 23 | "AWS_SECRET_ACCESS_KEY", |
| 24 | "AWS_ENDPOINT_URL_S3", |
| 25 | "AWS_REGION", |
| 26 | "BUCKET_NAME", |
| 27 | ] as const; |
| 28 | |
| 29 | export type BucketSecretName = (typeof BUCKET_SECRET_NAMES)[number]; |
| 30 | |
| 31 | /** |
| 32 | * Shadow bucket used to migrate from another S3-compatible store. |
| 33 | * Every field is required when set. |
| 34 | */ |
| 35 | export interface BucketShadow { |
| 36 | /** Shadow-bucket access key id. */ |
| 37 | accessKey: string; |
| 38 | /** Shadow-bucket secret. Wrap with `Redacted.make`. */ |
| 39 | secretKey: Redacted.Redacted<string> | string; |
| 40 | /** Shadow-bucket region. */ |
| 41 | region: string; |
| 42 | /** Shadow-bucket name. */ |
| 43 | name: string; |
| 44 | /** Shadow-bucket S3 endpoint URL. */ |
| 45 | endpoint: string; |
| 46 | /** |
| 47 | * Dual-write new objects back to the shadow bucket. |
| 48 | * |
| 49 | * @default false |
| 50 | */ |
| 51 | writeThrough?: boolean; |
| 52 | } |
| 53 | |
| 54 | export interface BucketProps { |
| 55 | /** |
| 56 | * Tigris bucket / add-on name. Globally unique, DNS-compatible |
| 57 | * (lowercase letters, digits, hyphens), must start with a letter, |
| 58 | * max 63 characters. If omitted, a unique name is generated from |
| 59 | * the stack, stage and logical ID. Changing it replaces the Bucket. |
| 60 | */ |
| 61 | name?: string; |
| 62 | /** |
| 63 | * Organization slug. Defaults to the current token's org |
| 64 | * (`getCurrentToken`). Changing it replaces the Bucket. |
| 65 | */ |
| 66 | orgSlug?: string; |
| 67 | /** |
| 68 | * Serve objects without credentials. Default is private. |
| 69 | * |
| 70 | * @default false |
| 71 | */ |
| 72 | public?: boolean; |
| 73 | /** |
| 74 | * Custom domain for public website hosting. Maps to Tigris |
| 75 | * `options.website.domain_name`. Empty string when omitted |
| 76 | * (the Tigris API requires the field). |
| 77 | */ |
| 78 | domainName?: string; |
| 79 | /** |
| 80 | * Tigris accelerate. Default is off. |
| 81 | * |
| 82 | * @default false |
| 83 | */ |
| 84 | accelerate?: boolean; |
| 85 | /** |
| 86 | * Shadow bucket for zero-downtime migration from another |
| 87 | * S3-compatible store. Every nested field is required when set. |
| 88 | */ |
| 89 | shadowBucket?: BucketShadow; |
| 90 | } |
| 91 | |
| 92 | export type Bucket = Resource< |
| 93 | "Fly.Bucket", |
| 94 | BucketProps, |
| 95 | { |
| 96 | /** Fly GraphQL add-on id. Identity of the Bucket. */ |
| 97 | addOnId: string; |
| 98 | /** Physical Tigris add-on / bucket name. */ |
| 99 | name: string; |
| 100 | /** Observed provisioning status (`ready`, …). */ |
| 101 | status: string | undefined; |
| 102 | /** Organization slug. */ |
| 103 | orgSlug: string | undefined; |
| 104 | /** Whether the bucket is public. */ |
| 105 | public: boolean; |
| 106 | /** Custom website domain, if set. */ |
| 107 | domainName: string | undefined; |
| 108 | /** Tigris accelerate. */ |
| 109 | accelerate: boolean; |
| 110 | /** Tigris SSO dashboard URL, if the API returned one. */ |
| 111 | ssoLink: string | undefined; |
| 112 | /** RFC3339 creation timestamp. */ |
| 113 | createdAt: string | undefined; |
| 114 | /** |
| 115 | * `AWS_ACCESS_KEY_ID` from `createAddOn.environment`. Never logged. |
| 116 | */ |
| 117 | accessKeyId: Redacted.Redacted<string> | undefined; |
| 118 | /** |
| 119 | * `AWS_SECRET_ACCESS_KEY` from `createAddOn.environment`. Never logged. |
| 120 | */ |
| 121 | secretAccessKey: Redacted.Redacted<string> | undefined; |
| 122 | /** |
| 123 | * `AWS_ENDPOINT_URL_S3` from `createAddOn.environment`. Never logged. |
| 124 | */ |
| 125 | endpoint: Redacted.Redacted<string> | undefined; |
| 126 | /** |
| 127 | * `AWS_REGION` from `createAddOn.environment`. Never logged. |
| 128 | */ |
| 129 | region: Redacted.Redacted<string> | undefined; |
| 130 | /** |
| 131 | * `BUCKET_NAME` from `createAddOn.environment`. Never logged. |
| 132 | */ |
| 133 | bucketName: Redacted.Redacted<string> | undefined; |
| 134 | }, |
| 135 | never, |
| 136 | Providers |
| 137 | >; |
| 138 | |
| 139 | /** |
| 140 | * A Fly.Bucket is Tigris S3-compatible object storage, billed through |
| 141 | * Fly. It is an org-scoped GraphQL add-on (`AddOnType` `tigris`). |
| 142 | * |
| 143 | * @see https://fly.io/docs/tigris/ |
| 144 | * |
| 145 | * ### Create a Bucket |
| 146 | * Alchemy generates a unique name unless you pass one. |
| 147 | * |
| 148 | * **Example:** Generated name |
| 149 | * ```typescript |
| 150 | * const data = yield* Fly.Bucket("Data"); |
| 151 | * ``` |
| 152 | * |
| 153 | * ### A stable name |
| 154 | * Pass `name` when you need a stable Tigris bucket name. |
| 155 | * |
| 156 | * **Example:** Explicit name |
| 157 | * ```typescript |
| 158 | * const data = yield* Fly.Bucket("Data", { |
| 159 | * name: "my-bucket", |
| 160 | * }); |
| 161 | * ``` |
| 162 | * |
| 163 | * :::caution[Changing `name` replaces the Bucket] |
| 164 | * Tigris cannot rename a bucket. Alchemy creates the new name, then |
| 165 | * deletes the old one. |
| 166 | * ::: |
| 167 | * |
| 168 | * ### Organization |
| 169 | * Org defaults to the current token. Pass `orgSlug` to pin it. |
| 170 | * |
| 171 | * **Example:** Pin an org |
| 172 | * ```typescript |
| 173 | * const data = yield* Fly.Bucket("Data", { |
| 174 | * orgSlug: "my-org", |
| 175 | * }); |
| 176 | * ``` |
| 177 | * |
| 178 | * :::caution[Changing `orgSlug` replaces the Bucket] |
| 179 | * The bucket is created in the new org. The old bucket is deleted. |
| 180 | * ::: |
| 181 | * |
| 182 | * ### Public access |
| 183 | * Buckets are private by default. `public: true` serves objects |
| 184 | * without credentials. |
| 185 | * |
| 186 | * **Example:** Public bucket |
| 187 | * ```typescript |
| 188 | * const assets = yield* Fly.Bucket("Assets", { |
| 189 | * public: true, |
| 190 | * }); |
| 191 | * ``` |
| 192 | * |
| 193 | * ### Custom domain |
| 194 | * `domainName` sets `website.domain_name` on the add-on. Point a |
| 195 | * CNAME at `{name}.t3.tigrisbucket.io`. |
| 196 | * |
| 197 | * **Example:** Custom domain |
| 198 | * ```typescript |
| 199 | * const assets = yield* Fly.Bucket("Assets", { |
| 200 | * public: true, |
| 201 | * domainName: "assets.example.com", |
| 202 | * }); |
| 203 | * ``` |
| 204 | * |
| 205 | * ### Put and get objects |
| 206 | * Tigris speaks the S3 API. Bind {@link PutObject} / {@link GetObject} |
| 207 | * (and the other S3 object ops) in Service init. Alchemy injects the |
| 208 | * Tigris endpoint and credentials; you never read `AWS_*` yourself. |
| 209 | * |
| 210 | * **Example:** Put an object from a Service |
| 211 | * ```typescript |
| 212 | * import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 213 | * |
| 214 | * export default class Api extends Fly.Service<Api>()( |
| 215 | * "Api", |
| 216 | * { app: Site, main: import.meta.url, port: 3000 }, |
| 217 | * Effect.gen(function* () { |
| 218 | * const putObject = yield* Fly.PutObject(Data); |
| 219 | * const getObject = yield* Fly.GetObject(Data); |
| 220 | * |
| 221 | * return { |
| 222 | * fetch: Effect.gen(function* () { |
| 223 | * yield* putObject({ |
| 224 | * Key: "hello.txt", |
| 225 | * Body: "hello", |
| 226 | * ContentType: "text/plain", |
| 227 | * }); |
| 228 | * const obj = yield* getObject({ Key: "hello.txt" }); |
| 229 | * return HttpServerResponse.json({ ok: obj.ETag !== undefined }); |
| 230 | * }), |
| 231 | * }; |
| 232 | * }).pipe(Effect.provide([Fly.PutObjectHttp, Fly.GetObjectHttp])), |
| 233 | * ) {} |
| 234 | * ``` |
| 235 | * |
| 236 | * ### Fly.Secret |
| 237 | * You can also set each key yourself with {@link Secret}. |
| 238 | * |
| 239 | * **Example:** Manual secrets |
| 240 | * ```typescript |
| 241 | * yield* Fly.Secret("BucketName", { |
| 242 | * app: Site, |
| 243 | * name: "BUCKET_NAME", |
| 244 | * value: Data.bucketName, |
| 245 | * }); |
| 246 | * ``` |
| 247 | * |
| 248 | * @resource |
| 249 | * @product Bucket |
| 250 | */ |
| 251 | export const Bucket = Resource<Bucket>("Fly.Bucket"); |
| 252 | |
| 253 | export class BucketNotCreated extends Data.TaggedError("Fly.BucketNotCreated")<{ |
| 254 | name: string; |
| 255 | }> {} |
| 256 | |
| 257 | export class BucketOrgMissing extends Data.TaggedError("Fly.BucketOrgMissing")<{ |
| 258 | orgSlug: string; |
| 259 | }> {} |
| 260 | |
| 261 | export class BucketProvisionFailed extends Data.TaggedError( |
| 262 | "Fly.BucketProvisionFailed", |
| 263 | )<{ |
| 264 | name: string; |
| 265 | status: string | undefined; |
| 266 | errorMessage: string | undefined; |
| 267 | }> {} |
| 268 | |
| 269 | class BucketPending extends Data.TaggedError("Fly.BucketPending")<{ |
| 270 | name: string; |
| 271 | status: string; |
| 272 | }> {} |
| 273 | |
| 274 | const TIGRIS = "tigris" as const; |
| 275 | |
| 276 | const backoff = Schedule.min([ |
| 277 | Schedule.exponential(Duration.millis(500), 1.5), |
| 278 | Schedule.spaced(Duration.seconds(5)), |
| 279 | ]); |
| 280 | |
| 281 | const sanitizeFlyBucketName = (name: string): string => { |
| 282 | const lowered = name |
| 283 | .toLowerCase() |
| 284 | .replace(/[^a-z0-9-]+/g, "-") |
| 285 | .replace(/-+/g, "-") |
| 286 | .replace(/^-+|-+$/g, ""); |
| 287 | const clipped = |
| 288 | lowered.length > 63 ? lowered.slice(0, 63).replace(/-+$/g, "") : lowered; |
| 289 | const raw = clipped.length === 0 ? "f" : clipped; |
| 290 | return /^[a-z]/.test(raw) ? raw : `f${raw}`.slice(0, 63); |
| 291 | }; |
| 292 | |
| 293 | const resolveBucketName = ( |
| 294 | id: string, |
| 295 | name: string | undefined, |
| 296 | existing?: string, |
| 297 | ) => |
| 298 | Effect.gen(function* () { |
| 299 | if (name !== undefined) return sanitizeFlyBucketName(name); |
| 300 | if (existing !== undefined) return existing; |
| 301 | return yield* createFlyAppName(id); |
| 302 | }); |
| 303 | |
| 304 | const asRecord = (value: unknown): Record<string, unknown> => { |
| 305 | if (Redacted.isRedacted(value)) { |
| 306 | return asRecord(Redacted.value(value)); |
| 307 | } |
| 308 | if (typeof value === "string") { |
| 309 | try { |
| 310 | return asRecord(JSON.parse(value)); |
| 311 | } catch { |
| 312 | return {}; |
| 313 | } |
| 314 | } |
| 315 | if (value !== null && typeof value === "object" && !Array.isArray(value)) { |
| 316 | return value as Record<string, unknown>; |
| 317 | } |
| 318 | return {}; |
| 319 | }; |
| 320 | |
| 321 | const asString = (value: unknown): string | undefined => { |
| 322 | if (typeof value === "string") { |
| 323 | return value.length > 0 ? value : undefined; |
| 324 | } |
| 325 | if (Redacted.isRedacted(value)) { |
| 326 | return asString(Redacted.value(value)); |
| 327 | } |
| 328 | return undefined; |
| 329 | }; |
| 330 | |
| 331 | const redact = ( |
| 332 | value: string | undefined, |
| 333 | ): Redacted.Redacted<string> | undefined => |
| 334 | value !== undefined && value.length > 0 ? Redacted.make(value) : undefined; |
| 335 | |
| 336 | const keepSecret = ( |
| 337 | next: Redacted.Redacted<string> | undefined, |
| 338 | previous: Redacted.Redacted<string> | undefined, |
| 339 | ): Redacted.Redacted<string> | undefined => next ?? previous; |
| 340 | |
| 341 | const envString = ( |
| 342 | env: Record<string, unknown>, |
| 343 | key: BucketSecretName, |
| 344 | ): string | undefined => asString(env[key]); |
| 345 | |
| 346 | type ObservedAddOn = Pick< |
| 347 | AddOnsResponseEdgesItemNode, |
| 348 | | "id" |
| 349 | | "name" |
| 350 | | "status" |
| 351 | | "errorMessage" |
| 352 | | "environment" |
| 353 | | "options" |
| 354 | | "metadata" |
| 355 | | "ssoLink" |
| 356 | | "createdAt" |
| 357 | > & { |
| 358 | organization?: { slug?: string | null; rawSlug?: string } | null; |
| 359 | }; |
| 360 | |
| 361 | const unwrapSecret = (value: Redacted.Redacted<string> | string): string => |
| 362 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 363 | |
| 364 | const desiredOptions = (props: BucketProps): Record<string, unknown> => { |
| 365 | const options: Record<string, unknown> = { |
| 366 | public: props.public ?? false, |
| 367 | accelerate: props.accelerate ?? false, |
| 368 | website: { domain_name: props.domainName ?? "" }, |
| 369 | }; |
| 370 | if (props.shadowBucket !== undefined) { |
| 371 | options.shadow_bucket = { |
| 372 | access_key: props.shadowBucket.accessKey, |
| 373 | secret_key: unwrapSecret(props.shadowBucket.secretKey), |
| 374 | region: props.shadowBucket.region, |
| 375 | name: props.shadowBucket.name, |
| 376 | endpoint: props.shadowBucket.endpoint, |
| 377 | write_through: props.shadowBucket.writeThrough ?? false, |
| 378 | }; |
| 379 | } |
| 380 | return options; |
| 381 | }; |
| 382 | |
| 383 | const websiteDomain = ( |
| 384 | options: Record<string, unknown>, |
| 385 | ): string | undefined => { |
| 386 | const website = asRecord(options.website); |
| 387 | return asString(website.domain_name); |
| 388 | }; |
| 389 | |
| 390 | const toAttrs = ( |
| 391 | addOn: ObservedAddOn, |
| 392 | previous?: Bucket["Attributes"], |
| 393 | fallback?: { name?: string; orgSlug?: string }, |
| 394 | ): Bucket["Attributes"] => { |
| 395 | const options = asRecord(addOn.options); |
| 396 | const env = asRecord(addOn.environment); |
| 397 | const name = addOn.name ?? fallback?.name ?? previous?.name ?? ""; |
| 398 | const orgSlug = |
| 399 | asString(addOn.organization?.slug) ?? |
| 400 | asString(addOn.organization?.rawSlug) ?? |
| 401 | fallback?.orgSlug ?? |
| 402 | previous?.orgSlug; |
| 403 | return { |
| 404 | addOnId: addOn.id, |
| 405 | name, |
| 406 | status: addOn.status ?? undefined, |
| 407 | orgSlug, |
| 408 | public: |
| 409 | typeof options.public === "boolean" |
| 410 | ? options.public |
| 411 | : (previous?.public ?? false), |
| 412 | domainName: websiteDomain(options), |
| 413 | accelerate: options.accelerate === true, |
| 414 | ssoLink: addOn.ssoLink ?? undefined, |
| 415 | createdAt: addOn.createdAt, |
| 416 | accessKeyId: keepSecret( |
| 417 | redact(envString(env, "AWS_ACCESS_KEY_ID")), |
| 418 | previous?.accessKeyId, |
| 419 | ), |
| 420 | secretAccessKey: keepSecret( |
| 421 | redact(envString(env, "AWS_SECRET_ACCESS_KEY")), |
| 422 | previous?.secretAccessKey, |
| 423 | ), |
| 424 | endpoint: keepSecret( |
| 425 | redact(envString(env, "AWS_ENDPOINT_URL_S3")), |
| 426 | previous?.endpoint, |
| 427 | ), |
| 428 | region: keepSecret(redact(envString(env, "AWS_REGION")), previous?.region), |
| 429 | bucketName: keepSecret( |
| 430 | redact(envString(env, "BUCKET_NAME") ?? name), |
| 431 | previous?.bucketName, |
| 432 | ), |
| 433 | }; |
| 434 | }; |
| 435 | |
| 436 | const stringRecordOf = (value: unknown): Record<string, string> => |
| 437 | Object.fromEntries( |
| 438 | Object.entries(asRecord(value)).flatMap(([key, item]) => { |
| 439 | const text = asString(item); |
| 440 | return text !== undefined ? [[key, text]] : []; |
| 441 | }), |
| 442 | ); |
| 443 | |
| 444 | const hasAlchemyMetadata = (metadata: unknown): boolean => { |
| 445 | const tags = stringRecordOf(metadata); |
| 446 | const stack = tags["alchemy::stack"] ?? tags["alchemy.stack"]; |
| 447 | return stack !== undefined && stack.length > 0; |
| 448 | }; |
| 449 | |
| 450 | const isOwnedAddOn = (addOn: ObservedAddOn): boolean => |
| 451 | matchesAlchemyPhysicalName(addOn.name ?? undefined) || |
| 452 | hasAlchemyMetadata(addOn.metadata); |
| 453 | |
| 454 | export const listTigrisAddOns = Effect.fn(function* () { |
| 455 | const rows: AddOnsResponseEdgesItemNode[] = []; |
| 456 | let after: string | undefined; |
| 457 | for (let i = 0; i < 8; i++) { |
| 458 | const page = yield* addons.addOns({ |
| 459 | type: TIGRIS, |
| 460 | first: 50, |
| 461 | after, |
| 462 | }); |
| 463 | for (const edge of page.edges ?? []) { |
| 464 | if (edge?.node != null) rows.push(edge.node); |
| 465 | } |
| 466 | if (!page.pageInfo.hasNextPage) break; |
| 467 | after = page.pageInfo.endCursor ?? undefined; |
| 468 | if (after === undefined || after.length === 0) break; |
| 469 | } |
| 470 | return rows; |
| 471 | }); |
| 472 | |
| 473 | const findById = (addOnId: string) => |
| 474 | listTigrisAddOns().pipe( |
| 475 | Effect.map((addOns) => addOns.find((addOn) => addOn.id === addOnId)), |
| 476 | ); |
| 477 | |
| 478 | const findByName = (name: string) => |
| 479 | listTigrisAddOns().pipe( |
| 480 | Effect.map((addOns) => addOns.find((addOn) => addOn.name === name)), |
| 481 | ); |
| 482 | |
| 483 | const failedStatus = (status: string | undefined) => |
| 484 | status === "error" || status === "failed"; |
| 485 | |
| 486 | const pendingStatus = (status: string | undefined) => |
| 487 | status === "creating" || |
| 488 | status === "provisioning" || |
| 489 | status === "pending" || |
| 490 | status === "pending_create"; |
| 491 | |
| 492 | const waitUntilReady = (name: string, addOnId: string) => |
| 493 | findById(addOnId).pipe( |
| 494 | Effect.flatMap( |
| 495 | ( |
| 496 | addOn, |
| 497 | ): Effect.Effect< |
| 498 | AddOnsResponseEdgesItemNode, |
| 499 | BucketPending | BucketProvisionFailed |
| 500 | > => { |
| 501 | if (addOn === undefined) { |
| 502 | return Effect.fail(new BucketPending({ name, status: "missing" })); |
| 503 | } |
| 504 | const status = addOn.status ?? undefined; |
| 505 | if (failedStatus(status)) { |
| 506 | return Effect.fail( |
| 507 | new BucketProvisionFailed({ |
| 508 | name, |
| 509 | status, |
| 510 | errorMessage: addOn.errorMessage ?? undefined, |
| 511 | }), |
| 512 | ); |
| 513 | } |
| 514 | if (pendingStatus(status)) { |
| 515 | return Effect.fail( |
| 516 | new BucketPending({ name, status: status ?? "creating" }), |
| 517 | ); |
| 518 | } |
| 519 | return Effect.succeed(addOn); |
| 520 | }, |
| 521 | ), |
| 522 | Effect.retry({ |
| 523 | while: (e) => e._tag === "Fly.BucketPending", |
| 524 | times: 8, |
| 525 | schedule: backoff, |
| 526 | }), |
| 527 | Effect.catchTag("Fly.BucketPending", () => findById(addOnId)), |
| 528 | ); |
| 529 | |
| 530 | const waitUntilGone = (addOnId: string, name: string) => |
| 531 | Effect.gen(function* () { |
| 532 | // Best-effort: Tigris often answers "The specified bucket does not |
| 533 | // exist" (untyped UnknownFlyIoError) for a delete that already |
| 534 | // succeeded. Never fail the wait on that — only on the add-on still |
| 535 | // showing up in list. |
| 536 | yield* Effect.result( |
| 537 | addons.deleteAddOn({ |
| 538 | input: addOnId.length > 0 ? { addOnId } : { name, provider: TIGRIS }, |
| 539 | }), |
| 540 | ); |
| 541 | const still = |
| 542 | (yield* findById(addOnId)) ?? |
| 543 | (name.length > 0 ? yield* findByName(name) : undefined); |
| 544 | if (still !== undefined) { |
| 545 | return yield* new BucketPending({ |
| 546 | name, |
| 547 | status: still.status ?? "deleting", |
| 548 | }); |
| 549 | } |
| 550 | }).pipe( |
| 551 | Effect.retry({ |
| 552 | while: (e) => e._tag === "Fly.BucketPending", |
| 553 | schedule: Schedule.spaced("2 seconds"), |
| 554 | times: 20, |
| 555 | }), |
| 556 | ); |
| 557 | |
| 558 | const resolveOrganization = (orgSlug: string) => |
| 559 | addons |
| 560 | .organization({ slug: orgSlug }) |
| 561 | .pipe(Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined))); |
| 562 | |
| 563 | const ensureTos = (orgSlug: string, organizationId: string) => |
| 564 | Effect.gen(function* () { |
| 565 | const agreed = yield* addons.agreedToProviderTos({ |
| 566 | slug: orgSlug, |
| 567 | providerName: TIGRIS, |
| 568 | }); |
| 569 | if (agreed === true) return; |
| 570 | const tos = yield* Effect.result( |
| 571 | addons.createExtensionTosAgreement({ |
| 572 | input: { |
| 573 | addOnProviderName: TIGRIS, |
| 574 | organizationId, |
| 575 | }, |
| 576 | }), |
| 577 | ); |
| 578 | if (Result.isFailure(tos)) return; |
| 579 | }); |
| 580 | |
| 581 | const optionsEqual = ( |
| 582 | observed: Record<string, unknown>, |
| 583 | desired: Record<string, unknown>, |
| 584 | ): boolean => { |
| 585 | const observedPublic = observed.public === true; |
| 586 | const desiredPublic = desired.public === true; |
| 587 | const observedAccelerate = observed.accelerate === true; |
| 588 | const desiredAccelerate = desired.accelerate === true; |
| 589 | const observedDomain = websiteDomain(observed) ?? ""; |
| 590 | const desiredDomain = websiteDomain(desired) ?? ""; |
| 591 | if ( |
| 592 | observedPublic !== desiredPublic || |
| 593 | observedAccelerate !== desiredAccelerate || |
| 594 | observedDomain !== desiredDomain |
| 595 | ) { |
| 596 | return false; |
| 597 | } |
| 598 | if (desired.shadow_bucket !== undefined) { |
| 599 | return deepEqual(asRecord(observed.shadow_bucket), desired.shadow_bucket); |
| 600 | } |
| 601 | return true; |
| 602 | }; |
| 603 | |
| 604 | export const BucketProvider = () => |
| 605 | Provider.succeed(Bucket, { |
| 606 | stables: ["addOnId", "name", "orgSlug"], |
| 607 | |
| 608 | diff: Effect.fn(function* ({ news, output }) { |
| 609 | if (news === undefined || !isResolved(news)) return undefined; |
| 610 | if (output === undefined) return undefined; |
| 611 | const desiredName = |
| 612 | news.name !== undefined |
| 613 | ? sanitizeFlyBucketName(news.name) |
| 614 | : output.name; |
| 615 | const nameChanged = desiredName !== output.name; |
| 616 | const orgChanged = |
| 617 | news.orgSlug !== undefined && news.orgSlug !== output.orgSlug; |
| 618 | if (nameChanged || orgChanged) { |
| 619 | return { action: "replace" as const }; |
| 620 | } |
| 621 | return undefined; |
| 622 | }), |
| 623 | |
| 624 | read: Effect.fn(function* ({ id, olds, output }) { |
| 625 | const name = yield* resolveBucketName(id, olds?.name, output?.name); |
| 626 | const found = |
| 627 | (output?.addOnId !== undefined && output.addOnId.length > 0 |
| 628 | ? yield* findById(output.addOnId) |
| 629 | : undefined) ?? (yield* findByName(name)); |
| 630 | if (found === undefined) return undefined; |
| 631 | const attrs = toAttrs(found, output, { |
| 632 | name, |
| 633 | orgSlug: output?.orgSlug ?? olds?.orgSlug, |
| 634 | }); |
| 635 | if (output !== undefined) return attrs; |
| 636 | return isOwnedAddOn(found) ? attrs : Unowned(attrs); |
| 637 | }), |
| 638 | |
| 639 | list: Effect.fn(function* () { |
| 640 | const addOns = yield* listTigrisAddOns(); |
| 641 | return addOns.flatMap((addOn) => |
| 642 | isOwnedAddOn(addOn) ? [toAttrs(addOn)] : [], |
| 643 | ); |
| 644 | }), |
| 645 | |
| 646 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 647 | const props = news ?? ({} as BucketProps); |
| 648 | const name = yield* resolveBucketName(id, props.name, output?.name); |
| 649 | const orgSlug = |
| 650 | props.orgSlug ?? output?.orgSlug ?? (yield* resolveOrgSlug()); |
| 651 | let previous = output; |
| 652 | |
| 653 | // Observe by cached id, then the desired name. |
| 654 | let current: ObservedAddOn | undefined = |
| 655 | output?.addOnId !== undefined && output.addOnId.length > 0 |
| 656 | ? yield* findById(output.addOnId) |
| 657 | : undefined; |
| 658 | if (current === undefined) { |
| 659 | current = yield* findByName(name); |
| 660 | } |
| 661 | |
| 662 | const org = yield* resolveOrganization(orgSlug); |
| 663 | if (org === undefined) { |
| 664 | return yield* new BucketOrgMissing({ orgSlug }); |
| 665 | } |
| 666 | |
| 667 | if (current === undefined) { |
| 668 | yield* ensureTos(orgSlug, org.id); |
| 669 | const created = yield* Effect.result( |
| 670 | addons.createAddOn({ |
| 671 | input: { |
| 672 | type: TIGRIS, |
| 673 | name, |
| 674 | organizationId: org.id, |
| 675 | options: desiredOptions(props), |
| 676 | }, |
| 677 | }), |
| 678 | ); |
| 679 | if (Result.isSuccess(created)) { |
| 680 | current = created.success.addOn; |
| 681 | } else { |
| 682 | current = yield* findByName(name); |
| 683 | if (current === undefined) { |
| 684 | return yield* Effect.fail(created.failure); |
| 685 | } |
| 686 | } |
| 687 | const createdSecrets = current; |
| 688 | if (current.id.length > 0) { |
| 689 | current = (yield* waitUntilReady(name, current.id)) ?? current; |
| 690 | } |
| 691 | // List/get omit `environment` after create. Keep the create |
| 692 | // payload as the secrets source for keepSecret below. |
| 693 | previous = toAttrs(createdSecrets, previous, { name, orgSlug }); |
| 694 | } |
| 695 | |
| 696 | if (current === undefined || current.id.length === 0) { |
| 697 | return yield* new BucketNotCreated({ name }); |
| 698 | } |
| 699 | |
| 700 | const status = current.status ?? undefined; |
| 701 | if (failedStatus(status)) { |
| 702 | return yield* new BucketProvisionFailed({ |
| 703 | name, |
| 704 | status, |
| 705 | errorMessage: current.errorMessage ?? undefined, |
| 706 | }); |
| 707 | } |
| 708 | |
| 709 | const observedOptions = asRecord(current.options); |
| 710 | const desired = desiredOptions(props); |
| 711 | const internal = yield* createInternalTags(id); |
| 712 | const observedMeta = stringRecordOf(current.metadata); |
| 713 | const nextMeta = { ...observedMeta, ...internal }; |
| 714 | const optionsChanged = !optionsEqual(observedOptions, desired); |
| 715 | const metaChanged = !deepEqual(observedMeta, nextMeta); |
| 716 | if (optionsChanged || metaChanged) { |
| 717 | const nextOptions = { |
| 718 | ...observedOptions, |
| 719 | ...desired, |
| 720 | }; |
| 721 | const updated = yield* Effect.result( |
| 722 | addons.updateAddOn({ |
| 723 | input: { |
| 724 | addOnId: current.id, |
| 725 | options: optionsChanged ? nextOptions : undefined, |
| 726 | metadata: metaChanged ? nextMeta : undefined, |
| 727 | }, |
| 728 | }), |
| 729 | ); |
| 730 | if (Result.isSuccess(updated)) { |
| 731 | current = { |
| 732 | ...current, |
| 733 | ...updated.success.addOn, |
| 734 | }; |
| 735 | } else { |
| 736 | current = (yield* findById(current.id)) ?? current; |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | const latest = (yield* findById(current.id)) ?? current; |
| 741 | // GraphQL list/get omit `environment` after create. Keep credentials |
| 742 | // from the create payload (or last persisted attrs) via keepSecret. |
| 743 | const attrs = toAttrs( |
| 744 | latest, |
| 745 | toAttrs(current, previous, { name, orgSlug }), |
| 746 | { |
| 747 | name, |
| 748 | orgSlug, |
| 749 | }, |
| 750 | ); |
| 751 | const observedPublic = asRecord(latest.options).public; |
| 752 | return { |
| 753 | ...attrs, |
| 754 | public: |
| 755 | typeof observedPublic === "boolean" |
| 756 | ? observedPublic |
| 757 | : (props.public ?? attrs.public), |
| 758 | domainName: |
| 759 | websiteDomain(asRecord(latest.options)) ?? |
| 760 | props.domainName ?? |
| 761 | attrs.domainName, |
| 762 | accelerate: |
| 763 | typeof asRecord(latest.options).accelerate === "boolean" |
| 764 | ? (asRecord(latest.options).accelerate as boolean) |
| 765 | : (props.accelerate ?? attrs.accelerate), |
| 766 | }; |
| 767 | }), |
| 768 | |
| 769 | delete: Effect.fn(function* ({ output }) { |
| 770 | const addOnId = output.addOnId; |
| 771 | const name = output.name; |
| 772 | if (addOnId.length === 0 && name.length === 0) return; |
| 773 | // Always wait for the add-on to leave the list. A failed |
| 774 | // deleteAddOn plus a single list miss is not gone — Tigris |
| 775 | // list lags, and a bucket still attached to an App is a |
| 776 | // transient error. waitUntilGone retries delete and only |
| 777 | // succeeds once list (id and name) is empty. |
| 778 | yield* waitUntilGone(addOnId, name); |
| 779 | }), |
| 780 | }); |
| 781 | |
| 782 | class BucketSecretVersionMissing extends Data.TaggedError( |
| 783 | "Fly.BucketSecretVersionMissing", |
| 784 | )<{ appName: string }> {} |
| 785 | |
| 786 | const bucketSecretVersion = ( |
| 787 | appName: string, |
| 788 | response: machines.AppSecretsUpdateResp | machines.SetAppSecretResponse, |
| 789 | ) => { |
| 790 | const version = response.version ?? response.Version; |
| 791 | return version !== undefined && Number.isSafeInteger(version) && version >= 0 |
| 792 | ? Effect.succeed(version) |
| 793 | : Effect.fail(new BucketSecretVersionMissing({ appName })); |
| 794 | }; |
| 795 | |
| 796 | const requireBucketSecrets = Effect.fn(function* ( |
| 797 | appName: string, |
| 798 | values: Record<string, string>, |
| 799 | ) { |
| 800 | const required = [ |
| 801 | "BUCKET_NAME", |
| 802 | "AWS_ACCESS_KEY_ID", |
| 803 | "AWS_SECRET_ACCESS_KEY", |
| 804 | "AWS_ENDPOINT_URL_S3", |
| 805 | ]; |
| 806 | if (required.some((key) => !values[key]?.trim())) { |
| 807 | return yield* new TigrisCredentialsMissing({ |
| 808 | name: values.BUCKET_NAME || appName, |
| 809 | }); |
| 810 | } |
| 811 | }); |
| 812 | |
| 813 | const putSecretValues = (appName: string, values: Record<string, string>) => |
| 814 | Effect.gen(function* () { |
| 815 | yield* requireBucketSecrets(appName, values); |
| 816 | return yield* machines |
| 817 | .updateSecrets({ |
| 818 | app_name: appName, |
| 819 | values, |
| 820 | }) |
| 821 | .pipe( |
| 822 | Effect.flatMap((response) => bucketSecretVersion(appName, response)), |
| 823 | Effect.catchTag("NotFound", () => |
| 824 | Effect.gen(function* () { |
| 825 | const versions: number[] = []; |
| 826 | for (const [secretName, value] of Object.entries(values)) { |
| 827 | const version = yield* machines |
| 828 | .createSecret({ |
| 829 | app_name: appName, |
| 830 | secret_name: secretName, |
| 831 | value, |
| 832 | }) |
| 833 | .pipe( |
| 834 | Effect.flatMap((response) => |
| 835 | bucketSecretVersion(appName, response), |
| 836 | ), |
| 837 | Effect.catchTag("Conflict", () => |
| 838 | machines |
| 839 | .updateSecrets({ |
| 840 | app_name: appName, |
| 841 | values: { [secretName]: value }, |
| 842 | }) |
| 843 | .pipe( |
| 844 | Effect.flatMap((response) => |
| 845 | bucketSecretVersion(appName, response), |
| 846 | ), |
| 847 | ), |
| 848 | ), |
| 849 | ); |
| 850 | versions.push(version); |
| 851 | } |
| 852 | return Math.max(...versions); |
| 853 | }), |
| 854 | ), |
| 855 | ); |
| 856 | }); |
| 857 | |
| 858 | const envValuesOf = (addOn: ObservedAddOn): Record<string, string> => { |
| 859 | const env = asRecord(addOn.environment); |
| 860 | const values: Record<string, string> = {}; |
| 861 | for (const key of BUCKET_SECRET_NAMES) { |
| 862 | const value = envString(env, key); |
| 863 | if (value !== undefined) values[key] = value; |
| 864 | } |
| 865 | if (values.BUCKET_NAME === undefined && addOn.name != null) { |
| 866 | values.BUCKET_NAME = addOn.name; |
| 867 | } |
| 868 | if ( |
| 869 | values.AWS_ENDPOINT_URL === undefined && |
| 870 | values.AWS_ENDPOINT_URL_S3 !== undefined |
| 871 | ) { |
| 872 | values.AWS_ENDPOINT_URL = values.AWS_ENDPOINT_URL_S3; |
| 873 | } |
| 874 | return values; |
| 875 | }; |
| 876 | |
| 877 | const findAttachedBucket = (input: { id?: string; name: string }) => |
| 878 | Effect.gen(function* () { |
| 879 | const listed = |
| 880 | input.id !== undefined && input.id.length > 0 |
| 881 | ? ((yield* findById(input.id)) ?? |
| 882 | (input.name.length > 0 ? yield* findByName(input.name) : undefined)) |
| 883 | : input.name.length > 0 |
| 884 | ? yield* findByName(input.name) |
| 885 | : undefined; |
| 886 | if (listed === undefined) { |
| 887 | return yield* new BucketPending({ |
| 888 | name: input.name || input.id || "", |
| 889 | status: "missing", |
| 890 | }); |
| 891 | } |
| 892 | const detail = yield* addons |
| 893 | .addOn({ |
| 894 | id: listed.id, |
| 895 | name: listed.name ?? input.name, |
| 896 | provider: TIGRIS, |
| 897 | }) |
| 898 | .pipe( |
| 899 | Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined)), |
| 900 | ); |
| 901 | return detail ?? listed; |
| 902 | }); |
| 903 | |
| 904 | const secretsFromBoundEnv = ( |
| 905 | env: Record<string, string>, |
| 906 | ): Record<string, string> => { |
| 907 | const values: Record<string, string> = {}; |
| 908 | for (const key of BUCKET_SECRET_NAMES) { |
| 909 | const value = env[key]; |
| 910 | if (value !== undefined && value.length > 0) values[key] = value; |
| 911 | } |
| 912 | if ( |
| 913 | values.AWS_ENDPOINT_URL === undefined && |
| 914 | values.AWS_ENDPOINT_URL_S3 !== undefined |
| 915 | ) { |
| 916 | values.AWS_ENDPOINT_URL = values.AWS_ENDPOINT_URL_S3; |
| 917 | } |
| 918 | return values; |
| 919 | }; |
| 920 | |
| 921 | /** |
| 922 | * Write Tigris `AWS_*` / `BUCKET_NAME` secrets onto an App. Called from |
| 923 | * {@link Service} reconcile so the secrets exist before Machines boot. |
| 924 | * |
| 925 | * Looks up the add-on by id/name (retrying while Tigris is still |
| 926 | * provisioning) and also copies any already-evaluated `AWS_*` / |
| 927 | * `BUCKET_NAME` values from binding env — GraphQL `environment` is often |
| 928 | * empty after create. A missing add-on may use bound values only when the |
| 929 | * bucket name, credential pair, and S3 endpoint are complete. Partial values |
| 930 | * are never written. Region is optional, as in the Tigris bindings. |
| 931 | * Without an attachment, only an explicit `BUCKET_NAME` requests this mode; |
| 932 | * ordinary AWS region or credential settings do not imply a Bucket. |
| 933 | * Returns the highest accepted secret-version floor, or `undefined` when |
| 934 | * no Bucket was requested; this is not a vault snapshot. |
| 935 | */ |
| 936 | export const attachBucketSecrets = Effect.fn(function* ( |
| 937 | appName: string, |
| 938 | attached: readonly { |
| 939 | name: string; |
| 940 | id?: string; |
| 941 | /** Credentials from the bound Bucket's attributes. */ |
| 942 | values?: Record<string, string>; |
| 943 | }[], |
| 944 | boundEnv: Record<string, string> = {}, |
| 945 | ) { |
| 946 | if (appName.length === 0) return undefined; |
| 947 | if (attached.length === 0 && boundEnv.BUCKET_NAME === undefined) { |
| 948 | return undefined; |
| 949 | } |
| 950 | const fromEnv = secretsFromBoundEnv(boundEnv); |
| 951 | if (attached.length === 0) { |
| 952 | return yield* putSecretValues(appName, fromEnv); |
| 953 | } |
| 954 | const versions: number[] = []; |
| 955 | for (const item of attached) { |
| 956 | const name = item.name; |
| 957 | const id = item.id; |
| 958 | const fallbackValues: Record<string, string> = { |
| 959 | ...fromEnv, |
| 960 | ...item.values, |
| 961 | }; |
| 962 | if ( |
| 963 | fallbackValues.AWS_ENDPOINT_URL === undefined && |
| 964 | fallbackValues.AWS_ENDPOINT_URL_S3 !== undefined |
| 965 | ) { |
| 966 | fallbackValues.AWS_ENDPOINT_URL = fallbackValues.AWS_ENDPOINT_URL_S3; |
| 967 | } |
| 968 | if (fallbackValues.BUCKET_NAME === undefined && name.length > 0) { |
| 969 | fallbackValues.BUCKET_NAME = name; |
| 970 | } |
| 971 | const row = yield* findAttachedBucket({ id, name }).pipe( |
| 972 | Effect.retry({ |
| 973 | while: (error) => error._tag === "Fly.BucketPending", |
| 974 | times: 8, |
| 975 | schedule: backoff, |
| 976 | }), |
| 977 | Effect.catchTag("Fly.BucketPending", () => |
| 978 | requireBucketSecrets(appName, fallbackValues).pipe( |
| 979 | Effect.as(undefined), |
| 980 | ), |
| 981 | ), |
| 982 | ); |
| 983 | const values = { |
| 984 | ...fallbackValues, |
| 985 | ...(row === undefined ? {} : envValuesOf(row)), |
| 986 | }; |
| 987 | versions.push(yield* putSecretValues(appName, values)); |
| 988 | } |
| 989 | return versions.length > 0 ? Math.max(...versions) : undefined; |
| 990 | }); |