1import type { App as FlyApp } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Retry from "@distilled.cloud/fly-io/Retry";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Schedule from "effect/Schedule";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import { Unowned } from "../AdoptPolicy.ts";
12import { isResolved } from "../Diff.ts";
13import * as Provider from "../Provider.ts";
14import { Resource, type ResourceBinding } from "../Resource.ts";
15import { resolveOrgSlug } from "./Environment.ts";
16import {
17 createFlyAppName,
18 isAlchemyOwnedMetadata,
19 matchesAlchemyPhysicalName,
20 sanitizeFlyAppName,
21} from "./Metadata.ts";
22import type { MachineService } from "./Machine.ts";
23import { findPortConflict, portsOfProps, withBindingPort } from "./ports.ts";
24import { DEFAULT_BINDING_PORT } from "./rpc.ts";
25import type { Providers } from "./Providers.ts";
26
27export class AppDeletionAmbiguous extends Data.TaggedError(
28 "Fly.AppDeletionAmbiguous",
29)<{
30 appName: string;
31 evidence: string;
32}> {
33 get message() {
34 return `Deletion of ${this.appName} is uncertain (${this.evidence}); reconcile the App before retrying or recreating its name. Machine leases cannot fence App deletion.`;
35 }
36}
37
38export interface AppProps {
39 /**
40 * Fly App name. Globally unique, DNS-compatible (lowercase letters,
41 * digits, hyphens), must start with a letter, max 30 characters. If
42 * omitted, a unique name is generated from the stack, stage and logical
43 * ID. Changing it replaces the App.
44 */
45 name?: string;
46 /**
47 * Organization slug. Defaults to the current token's org
48 * (`getCurrentToken`). Changing it replaces the App.
49 */
50 orgSlug?: string;
51 /**
52 * Isolated network name. Immutable after create — changing it replaces
53 * the App.
54 */
55 network?: string;
56 /**
57 * Enable `*.{name}.fly.dev` subdomains. Create-only; ignored on update
58 * (Fly has no App update API).
59 */
60 enableSubdomains?: boolean;
61}
62
63export type App = Resource<
64 "Fly.App",
65 AppProps,
66 {
67 /** Fly App id. */
68 appId: string;
69 /** Physical Fly App name. */
70 appName: string;
71 /** Fly internal numeric id, if the API returned one. */
72 internalNumericId: number | undefined;
73 /** Isolated network name, if set. */
74 network: string | undefined;
75 /** Observed status (e.g. `deployed`, `pending`). */
76 status: string | undefined;
77 /** Organization slug. */
78 orgSlug: string | undefined;
79 /** Observed machine count. */
80 machineCount: number | undefined;
81 /** Observed volume count. */
82 volumeCount: number | undefined;
83 /** Public `https://{appName}.fly.dev` URL. */
84 url: string;
85 },
86 AppBinding,
87 Providers
88>;
89
90/**
91 * Binding contract accepted by {@link App}. Each {@link Service} placed in
92 * the App (`app` prop) reports the ports it publishes, so the App can
93 * reject two Services on the same port before either one is deployed.
94 */
95export interface AppBinding {
96 /** Logical id of the Service. */
97 service: string;
98 /** The Service's `services` prop; `undefined` for the default ports. */
99 services: MachineService[] | undefined;
100 /** The Service's `bindingPort` prop. */
101 bindingPort: number | undefined;
102}
103
104/**
105 * A Fly.App is a global namespace in your account. It contains Machines,
106 * Secrets, IPs, and certificates. A {@link Service} creates its own App,
107 * so declare an App yourself for {@link Machine}s, or to group Services
108 * that share Secrets or a custom domain (the Service `app` prop).
109 *
110 * @see https://fly.io/docs/machines/api/apps-resource/
111 *
112 * ### Create an App
113 * Alchemy generates a unique name unless you pass one. `url` is
114 * `https://{appName}.fly.dev`. Nothing answers there until a
115 * {@link Machine} (or a Service with `app`) publishes a proxy service and
116 * the App has an {@link IpAssignment}.
117 *
118 * **Example:** Generated name
119 * ```typescript
120 * const site = yield* Fly.App("Site");
121 * ```
122 *
123 * :::note
124 * Prefer omitting `name` in tests and CI so names stay unique and
125 * reclaimable.
126 * :::
127 *
128 * ### A stable hostname
129 * Pass `name` when you need a stable `fly.dev` hostname.
130 *
131 * **Example:** Explicit name
132 * ```typescript
133 * const site = yield* Fly.App("Site", {
134 * name: "my-site",
135 * });
136 * ```
137 *
138 * :::caution[Changing `name` replaces the App]
139 * Fly cannot have two Apps with the same name. Alchemy deletes the
140 * old App first, then creates the new one.
141 * :::
142 *
143 * ### Organization
144 * Org defaults to the current token. Pass `orgSlug` to pin it.
145 *
146 * **Example:** Pin an org
147 * ```typescript
148 * const site = yield* Fly.App("Site", {
149 * name: "my-site",
150 * orgSlug: "my-org",
151 * });
152 * ```
153 *
154 * :::caution[Changing `orgSlug` replaces the App]
155 * The App is created in the new org. The old App is deleted.
156 * :::
157 *
158 * ### Subdomains
159 * `enableSubdomains: true` turns on `*.{appName}.fly.dev`.
160 *
161 * **Example:** Enable subdomains
162 * ```typescript
163 * const site = yield* Fly.App("Site", {
164 * name: "my-site",
165 * enableSubdomains: true,
166 * });
167 * ```
168 *
169 * :::note[Create-only]
170 * Flipping `enableSubdomains` later is ignored.
171 * :::
172 *
173 * ### Isolated network
174 * `network` is an optional 6PN name.
175 *
176 * **Example:** Custom network
177 * ```typescript
178 * const site = yield* Fly.App("Site", {
179 * name: "my-site",
180 * network: "private",
181 * });
182 * ```
183 *
184 * :::caution[Changing `network` replaces the App]
185 * The App is recreated on the new network.
186 * :::
187 *
188 * ### Module-scope declarations
189 * Declare the App once. Pass it into every child. Resource-valued props
190 * accept the resource or an Effect producing it. Do not unwrap it just
191 * to pass it along.
192 *
193 * **Example:** Module-scope App
194 * ```typescript
195 * // src/app.ts
196 * import * as Fly from "alchemy/Fly";
197 *
198 * export const Site = Fly.App("Site");
199 * ```
200 *
201 * @resource
202 * @product App
203 */
204export const App = Resource<App>("Fly.App");
205
206export class AppNotCreated extends Data.TaggedError("Fly.AppNotCreated")<{
207 name: string;
208 /** Fly's rejection of the create request, when there was one. */
209 reason?: string;
210}> {
211 get message() {
212 return `Fly App ${this.name} was not created${this.reason ? `: ${this.reason}` : ""}`;
213 }
214}
215
216const toAttrs = (app: FlyApp, fallbackName?: string): App["Attributes"] => {
217 const appName = app.name ?? fallbackName ?? "";
218 return {
219 appId: app.id ?? appName,
220 appName,
221 internalNumericId: app.internal_numeric_id,
222 network: app.network,
223 status: app.status,
224 orgSlug: app.organization?.slug,
225 machineCount: app.machine_count,
226 volumeCount: app.volume_count,
227 url: `https://${appName}.fly.dev`,
228 };
229};
230
231const resolveAppName = (
232 id: string,
233 name: string | undefined,
234 existing?: string,
235) =>
236 Effect.gen(function* () {
237 if (name !== undefined) return sanitizeFlyAppName(name);
238 if (existing !== undefined) return existing;
239 return yield* createFlyAppName(id);
240 });
241
242const getByName = (appName: string) =>
243 machines
244 .getApp({ app_name: appName })
245 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
246
247const hasAlchemyMachines = (appName: string) =>
248 machines.listMachines({ app_name: appName }).pipe(
249 Effect.map((machines) =>
250 machines.some((machine) =>
251 isAlchemyOwnedMetadata(machine.config?.metadata),
252 ),
253 ),
254 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed(false)),
255 );
256
257const hasAlchemyNamed = (names: Array<string | undefined>) =>
258 names.some((name) => matchesAlchemyPhysicalName(name));
259
260const hasAlchemyVolumes = (appName: string) =>
261 machines.listVolumes({ app_name: appName }).pipe(
262 Effect.map((volumes) =>
263 hasAlchemyNamed(volumes.map((volume) => volume.name)),
264 ),
265 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed(false)),
266 );
267
268const hasAlchemySecrets = (appName: string) =>
269 machines.listSecrets({ app_name: appName }).pipe(
270 Effect.map((res) =>
271 hasAlchemyNamed((res.secrets ?? []).map((secret) => secret.name)),
272 ),
273 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed(false)),
274 );
275
276const hasAlchemySecretKeys = (appName: string) =>
277 machines.listSecretKeys({ app_name: appName }).pipe(
278 Effect.map((res) =>
279 hasAlchemyNamed((res.secret_keys ?? []).map((key) => key.name)),
280 ),
281 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed(false)),
282 );
283
284const isOwnedApp = (app: FlyApp) =>
285 Effect.gen(function* () {
286 if (matchesAlchemyPhysicalName(app.name)) return true;
287 const appName = app.name;
288 if (appName === undefined || appName.length === 0) return false;
289 const flags = yield* Effect.all(
290 [
291 hasAlchemyMachines(appName),
292 hasAlchemyVolumes(appName),
293 hasAlchemySecrets(appName),
294 hasAlchemySecretKeys(appName),
295 ],
296 { concurrency: 4 },
297 );
298 return flags.some(Boolean);
299 });
300
301/**
302 * Apps in the current token's org that Alchemy owns. Used by {@link App}
303 * `list()` and by child resources (Machine / Volume / Secret) so nuke
304 * never enumerates the whole org unfiltered.
305 */
306export const listOwnedApps = Effect.fn(function* () {
307 const orgSlug = yield* resolveOrgSlug();
308 const { apps } = yield* machines.listApps({
309 org_slug: orgSlug,
310 });
311 const flagged = yield* Effect.forEach(
312 apps ?? [],
313 (app) =>
314 isOwnedApp(app).pipe(
315 Effect.map((owned) => (owned ? toAttrs(app) : undefined)),
316 ),
317 { concurrency: 8 },
318 );
319 return flagged.filter((attrs) => attrs !== undefined);
320});
321
322/**
323 * Observe an App by name and create it when missing. Shared by
324 * {@link App} and by Services that own their App.
325 */
326export const ensureApp = Effect.fn(function* (input: {
327 name: string;
328 orgSlug?: string;
329 network?: string;
330 enableSubdomains?: boolean;
331 /** Physical name from a previous reconcile, observed first. */
332 previousName?: string;
333}) {
334 let current =
335 input.previousName !== undefined
336 ? yield* getByName(input.previousName)
337 : undefined;
338 if (current === undefined && input.previousName !== input.name) {
339 current = yield* getByName(input.name);
340 }
341 if (current === undefined) {
342 const orgSlug = input.orgSlug ?? (yield* resolveOrgSlug());
343 // A name race surfaces as Conflict or UnprocessableEntity; the lookup
344 // decides whether the App now exists. Apps created at the same time on
345 // a network that does not exist yet race to create it, and the losers
346 // are rejected with "uniqueness constraint violated", so retry them.
347 const create = machines
348 .createApp({
349 name: input.name,
350 org_slug: orgSlug,
351 network: input.network,
352 enable_subdomains: input.enableSubdomains,
353 })
354 .pipe(
355 Effect.as(undefined),
356 Effect.catchTag(["Conflict", "UnprocessableEntity"], (error) =>
357 Effect.succeed(error),
358 ),
359 );
360 // A new App can take a moment to become readable.
361 const observe = getByName(input.name).pipe(
362 Effect.repeat({
363 schedule: Schedule.spaced("1 second"),
364 until: (app) => app !== undefined,
365 times: 5,
366 }),
367 );
368 let rejection: string | undefined;
369 for (let attempt = 0; attempt < 5 && current === undefined; attempt++) {
370 if (attempt > 0) yield* Effect.sleep(`${attempt * 2} seconds`);
371 rejection = (yield* create)?.message;
372 current = yield* observe;
373 }
374 if (current === undefined) {
375 return yield* new AppNotCreated({ name: input.name, reason: rejection });
376 }
377 }
378 if (current === undefined) {
379 return yield* new AppNotCreated({ name: input.name });
380 }
381 return current;
382});
383
384/**
385 * Delete an App and the Alchemy-owned Volumes in it. Idempotent: a missing
386 * App is not an error. Fly deletes the App's Machines, addresses, secrets,
387 * and certificates with it.
388 */
389export const deleteApp = Effect.fn(function* (appName: string) {
390 if (appName.length === 0) return;
391 const volumes = yield* machines
392 .listVolumes({ app_name: appName })
393 .pipe(Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed([])));
394 yield* Effect.forEach(
395 volumes,
396 (volume) => {
397 const volumeId = volume.id;
398 if (
399 volumeId === undefined ||
400 volumeId.length === 0 ||
401 !matchesAlchemyPhysicalName(volume.name)
402 ) {
403 return Effect.void;
404 }
405 return machines
406 .deleteVolume({ app_name: appName, volume_id: volumeId })
407 .pipe(
408 Effect.asVoid,
409 Effect.catchTag(["NotFound", "Conflict"], () => Effect.void),
410 );
411 },
412 { concurrency: 4 },
413 );
414 const http = yield* HttpClient.HttpClient;
415 const fetchOptions = yield* Effect.serviceOption(FetchHttpClient.RequestInit);
416 yield* machines.deleteApp({ app_name: appName }).pipe(
417 Retry.none,
418 // Bun can replay DELETE on a reused socket below the SDK retry policy.
419 Effect.provideService(
420 HttpClient.HttpClient,
421 HttpClient.mapRequest(
422 http,
423 HttpClientRequest.setHeader("connection", "close"),
424 ),
425 ),
426 Effect.provideService(FetchHttpClient.RequestInit, {
427 ...Option.getOrUndefined(fetchOptions),
428 keepalive: false,
429 redirect: "error",
430 }),
431 Effect.timeout("30 seconds"),
432 Effect.catchTag("NotFound", () => Effect.void),
433 Effect.catchTag(
434 [
435 "HttpClientError",
436 "TimeoutError",
437 "InternalServerError",
438 "BadGateway",
439 "ServiceUnavailable",
440 "GatewayTimeout",
441 ],
442 (error) =>
443 Effect.fail(
444 new AppDeletionAmbiguous({ appName, evidence: error._tag }),
445 ),
446 ),
447 );
448 const gone = yield* getByName(appName).pipe(
449 Effect.map((app) => app === undefined),
450 Effect.repeat({
451 schedule: Schedule.spaced("1 second"),
452 until: (gone) => gone,
453 times: 8,
454 }),
455 Effect.timeout("30 seconds"),
456 Effect.mapError(
457 (error) =>
458 new AppDeletionAmbiguous({
459 appName,
460 evidence: `delete accepted but absence verification failed: ${error._tag}`,
461 }),
462 ),
463 );
464 if (!gone)
465 return yield* new AppDeletionAmbiguous({
466 appName,
467 evidence: "delete accepted but absence not observed",
468 });
469});
470
471/** Fail when two Services placed in the App publish the same port. */
472const validateServicePorts = (
473 appName: string,
474 bindings: ReadonlyArray<ResourceBinding<AppBinding> & { action?: string }>,
475) => {
476 const conflict = findPortConflict(
477 appName,
478 bindings
479 .filter((binding) => binding.action !== "delete")
480 .map((binding) => ({
481 id: binding.data.service,
482 // A Service in a shared App is public: default ports are 80 and 443.
483 ports: withBindingPort(
484 portsOfProps(binding.data.services, true),
485 binding.data.bindingPort ?? DEFAULT_BINDING_PORT,
486 ),
487 })),
488 );
489 return conflict === undefined ? Effect.void : Effect.fail(conflict);
490};
491
492export const AppProvider = () =>
493 Provider.succeed(App, {
494 stables: ["appId", "appName", "orgSlug", "network", "internalNumericId"],
495
496 diff: Effect.fn(function* ({ id, news, output, newBindings }) {
497 // At plan time, including for an App created in the same deploy.
498 if (isResolved(newBindings)) {
499 yield* validateServicePorts(
500 output?.appName ?? (isResolved(news) ? news?.name : undefined) ?? id,
501 newBindings,
502 );
503 }
504 if (news === undefined || !isResolved(news)) return undefined;
505 if (output === undefined) return undefined;
506 const desiredName =
507 news.name !== undefined
508 ? sanitizeFlyAppName(news.name)
509 : output.appName;
510 const nameChanged = desiredName !== output.appName;
511 const orgChanged =
512 news.orgSlug !== undefined && news.orgSlug !== output.orgSlug;
513 const networkChanged =
514 news.network !== undefined && news.network !== output.network;
515 if (nameChanged || orgChanged || networkChanged) {
516 return {
517 action: "replace" as const,
518 // Same physical name cannot exist twice — delete the old App first.
519 deleteFirst: !nameChanged,
520 };
521 }
522 return undefined;
523 }),
524
525 read: Effect.fn(function* ({ id, olds, output }) {
526 const name = yield* resolveAppName(id, olds?.name, output?.appName);
527 const found =
528 (output?.appName !== undefined
529 ? yield* getByName(output.appName)
530 : undefined) ?? (yield* getByName(name));
531 if (found === undefined) return undefined;
532 const attrs = toAttrs(found, name);
533 if (output !== undefined) return attrs;
534 return (yield* isOwnedApp(found)) ? attrs : Unowned(attrs);
535 }),
536
537 list: listOwnedApps,
538
539 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
540 const props = news ?? {};
541 const name = yield* resolveAppName(id, props.name, output?.appName);
542 // Before creating anything, so a conflict leaves the App untouched.
543 yield* validateServicePorts(name, bindings);
544 const current = yield* ensureApp({
545 name,
546 orgSlug: props.orgSlug,
547 network: props.network,
548 enableSubdomains: props.enableSubdomains,
549 previousName: output?.appName,
550 });
551 // No update API. enableSubdomains is create-only.
552 return toAttrs(current, name);
553 }),
554
555 delete: Effect.fn(function* ({ output }) {
556 yield* deleteApp(output.appName);
557 }),
558 });