1import type { AddOnsResponseEdgesItemNode } from "@distilled.cloud/fly-io/addons";
2import * as addons from "@distilled.cloud/fly-io/addons";
3import * as machines from "@distilled.cloud/fly-io/machines";
4import * as Data from "effect/Data";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10import { Unowned } from "../AdoptPolicy.ts";
11import { deepEqual, isResolved } from "../Diff.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import { createInternalTags } from "../Tags.ts";
15import { resolveOrgSlug } from "./Environment.ts";
16import { TigrisCredentialsMissing } from "./Errors.ts";
17import { createFlyAppName, matchesAlchemyPhysicalName } from "./Metadata.ts";
18import type { Providers } from "./Providers.ts";
19
20/** Env-var names Tigris HTTP bindings write onto an App (same as `fly storage create`). */
21export 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
29export 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 */
35export 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
54export 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
92export 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 */
251export const Bucket = Resource<Bucket>("Fly.Bucket");
252
253export class BucketNotCreated extends Data.TaggedError("Fly.BucketNotCreated")<{
254 name: string;
255}> {}
256
257export class BucketOrgMissing extends Data.TaggedError("Fly.BucketOrgMissing")<{
258 orgSlug: string;
259}> {}
260
261export class BucketProvisionFailed extends Data.TaggedError(
262 "Fly.BucketProvisionFailed",
263)<{
264 name: string;
265 status: string | undefined;
266 errorMessage: string | undefined;
267}> {}
268
269class BucketPending extends Data.TaggedError("Fly.BucketPending")<{
270 name: string;
271 status: string;
272}> {}
273
274const TIGRIS = "tigris" as const;
275
276const backoff = Schedule.min([
277 Schedule.exponential(Duration.millis(500), 1.5),
278 Schedule.spaced(Duration.seconds(5)),
279]);
280
281const 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
293const 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
304const 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
321const 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
331const redact = (
332 value: string | undefined,
333): Redacted.Redacted<string> | undefined =>
334 value !== undefined && value.length > 0 ? Redacted.make(value) : undefined;
335
336const keepSecret = (
337 next: Redacted.Redacted<string> | undefined,
338 previous: Redacted.Redacted<string> | undefined,
339): Redacted.Redacted<string> | undefined => next ?? previous;
340
341const envString = (
342 env: Record<string, unknown>,
343 key: BucketSecretName,
344): string | undefined => asString(env[key]);
345
346type 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
361const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
362 Redacted.isRedacted(value) ? Redacted.value(value) : value;
363
364const 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
383const websiteDomain = (
384 options: Record<string, unknown>,
385): string | undefined => {
386 const website = asRecord(options.website);
387 return asString(website.domain_name);
388};
389
390const 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
436const 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
444const 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
450const isOwnedAddOn = (addOn: ObservedAddOn): boolean =>
451 matchesAlchemyPhysicalName(addOn.name ?? undefined) ||
452 hasAlchemyMetadata(addOn.metadata);
453
454export 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
473const findById = (addOnId: string) =>
474 listTigrisAddOns().pipe(
475 Effect.map((addOns) => addOns.find((addOn) => addOn.id === addOnId)),
476 );
477
478const findByName = (name: string) =>
479 listTigrisAddOns().pipe(
480 Effect.map((addOns) => addOns.find((addOn) => addOn.name === name)),
481 );
482
483const failedStatus = (status: string | undefined) =>
484 status === "error" || status === "failed";
485
486const pendingStatus = (status: string | undefined) =>
487 status === "creating" ||
488 status === "provisioning" ||
489 status === "pending" ||
490 status === "pending_create";
491
492const 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
530const 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
558const resolveOrganization = (orgSlug: string) =>
559 addons
560 .organization({ slug: orgSlug })
561 .pipe(Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined)));
562
563const 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
581const 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
604export 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
782class BucketSecretVersionMissing extends Data.TaggedError(
783 "Fly.BucketSecretVersionMissing",
784)<{ appName: string }> {}
785
786const 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
796const 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
813const 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
858const 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
877const 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
904const 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 */
936export 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});