1import type { IPAssignment as FlyIPAssignment } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { isResolved } from "../Diff.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import { listOwnedApps } from "./App.ts";
10import type { App } from "./App.ts";
11import type { Providers } from "./Providers.ts";
12
13/**
14 * A resource-valued prop: the resource itself, or an Effect that produces
15 * it (so `yield* App(...)` and `App(...)` both type-check).
16 */
17type Ref<T> = T | Effect.Effect<T, never, Providers>;
18
19/**
20 * Fly IP family allocated onto an App. Dedicated `v4` is billed and may
21 * be rejected when the org has no IPv4 quota. Prefer `v6` (free) or
22 * `shared_v4` (free) in tests. `private_v6` is a Flycast address: it is
23 * reachable only from the organization's private network.
24 */
25export type IpAssignmentType = "v4" | "v6" | "shared_v4" | "private_v6";
26
27export interface IpAssignmentProps {
28 /**
29 * Parent Fly App. Accepts a `Fly.App` resource or an Effect that
30 * produces one. Changing the App replaces the assignment.
31 */
32 app: Ref<App>;
33 /**
34 * Address family to allocate. `v6` is a dedicated IPv6 (free).
35 * `shared_v4` is a shared Anycast IPv4 (free). `v4` is a dedicated
36 * IPv4 (billed; may 400 when the org is over quota). `private_v6` is
37 * a Flycast IPv6 (free) that Fly's proxy serves only inside the
38 * private network. Changing type replaces the assignment.
39 */
40 type: IpAssignmentType;
41 /**
42 * Region for a dedicated address. Changing it replaces the assignment.
43 */
44 region?: string;
45 /**
46 * Private network a `private_v6` address is reachable from. Defaults
47 * to the organization's network. Create-only; changing it replaces.
48 */
49 network?: string;
50 /**
51 * Organization slug. Defaults to the current token's org. Not a
52 * replacement key.
53 */
54 orgSlug?: string;
55 /**
56 * Fly proxy service this address is bound to. Changing it replaces
57 * the assignment.
58 */
59 serviceName?: string;
60}
61
62export type IpAssignment = Resource<
63 "Fly.IpAssignment",
64 IpAssignmentProps,
65 {
66 /** Physical Fly App name the address is assigned to. */
67 appName: string;
68 /** Allocated address. Identity of the assignment. */
69 ip: string;
70 /** Observed address family. */
71 type: IpAssignmentType;
72 /** Region for a dedicated address, if set. */
73 region: string | undefined;
74 /** Fly proxy service this address is bound to, if set. */
75 serviceName: string | undefined;
76 /** Whether the address is a shared Anycast IPv4. */
77 shared: boolean;
78 /**
79 * Named private network a `private_v6` address is reachable from.
80 * `undefined` for the organization's default network and for public
81 * addresses.
82 */
83 network: string | undefined;
84 /** RFC3339 creation timestamp, if the API returned one. */
85 createdAt: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91const resolveIpAssignmentProps = (
92 props: IpAssignmentProps | Effect.Effect<IpAssignmentProps, never, Providers>,
93): Effect.Effect<IpAssignmentProps, never, Providers> =>
94 Effect.gen(function* () {
95 const resolved = Effect.isEffect(props) ? yield* props : props;
96 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
97 const app = Effect.isEffect(resolved.app)
98 ? yield* resolved.app as Effect.Effect<App, never, Providers>
99 : resolved.app;
100 return { ...resolved, app };
101 });
102
103const IpAssignmentResource = Resource<IpAssignment>("Fly.IpAssignment");
104
105/**
106 * A Fly.IpAssignment is an address on an {@link App}. A {@link Service}
107 * publishes ports. Fly's proxy load-balances `{app}.fly.dev` across
108 * Machines that publish a proxy service.
109 *
110 * Identity is the allocated `ip`. There is no in-place update.
111 *
112 * @see https://fly.io/docs/networking/services/
113 *
114 * ### Shared IPv4
115 * `shared_v4` is a shared Anycast IPv4. It is free. This is what you
116 * want for fly.dev over IPv4. Yield it next to the Service.
117 *
118 * **Example:** Allocate shared_v4
119 * ```typescript
120 * export const PublicIp = Fly.IpAssignment("Shared", {
121 * app: Site,
122 * type: "shared_v4",
123 * });
124 * ```
125 *
126 * :::caution[Changing `type` or `app` replaces the assignment]
127 * A new address is allocated. The old one is released.
128 * :::
129 *
130 * ### Dedicated IPv6
131 * `v6` is a dedicated IPv6. It is free.
132 *
133 * **Example:** Allocate v6
134 * ```typescript
135 * export const V6 = Fly.IpAssignment("V6", {
136 * app: Site,
137 * type: "v6",
138 * });
139 * ```
140 *
141 * ### Dedicated IPv4
142 * `v4` is a billed dedicated IPv4. It may 400 if the org has no
143 * quota. Prefer `shared_v4` or `v6` in tests.
144 *
145 * **Example:** Allocate v4
146 * ```typescript
147 * export const Dedicated = Fly.IpAssignment("Dedicated", {
148 * app: Site,
149 * type: "v4",
150 * });
151 * ```
152 *
153 * ### Flycast (private IPv6)
154 * `private_v6` is a Flycast address. Fly's proxy serves it only inside
155 * the organization's private network, at `{app}.flycast`. It is free.
156 * Use it for a backend that another App calls and the internet must
157 * not reach. Requests still go through the proxy, so blue/green
158 * cordoning, `autostart`, and `autostop` keep working. Allocate no
159 * public address on the same App.
160 *
161 * **Example:** Allocate private_v6
162 * ```typescript
163 * export const Private = Fly.IpAssignment("Private", {
164 * app: Backend,
165 * type: "private_v6",
166 * });
167 * ```
168 *
169 * ### Region
170 * `region` pins a dedicated address. Shared Anycast ignores it. See
171 * [Regions](/fly/compute/regions) for the list of codes.
172 *
173 * **Example:** Dedicated IPv4 in iad
174 * ```typescript
175 * export const Dedicated = Fly.IpAssignment("Dedicated", {
176 * app: Site,
177 * type: "v4",
178 * region: "iad",
179 * });
180 * ```
181 *
182 * :::caution[Changing `region` replaces the assignment]
183 * A new address is allocated in the new region.
184 * :::
185 *
186 * ### Service name
187 * `serviceName` binds the address to a Fly proxy service. Changing it
188 * replaces the assignment.
189 *
190 * **Example:** Bind to a named service
191 * ```typescript
192 * export const PublicIp = Fly.IpAssignment("Shared", {
193 * app: Site,
194 * type: "shared_v4",
195 * serviceName: "http",
196 * });
197 * ```
198 *
199 * :::caution[Changing `serviceName` replaces the assignment]
200 * A new address is allocated bound to the new service.
201 * :::
202 *
203 * ### Isolated network
204 * `network` names the private network a `private_v6` address is
205 * reachable from. Create-only.
206 *
207 * **Example:** Custom network
208 * ```typescript
209 * export const Private = Fly.IpAssignment("Private", {
210 * app: Backend,
211 * type: "private_v6",
212 * network: "tenant-a",
213 * });
214 * ```
215 *
216 * :::caution[Changing `network` replaces the assignment]
217 * A new address is allocated on the new network.
218 * :::
219 *
220 * ### Organization
221 * `orgSlug` defaults to the current token's org. It is not a
222 * replacement key.
223 *
224 * **Example:** Pin an org
225 * ```typescript
226 * export const PublicIp = Fly.IpAssignment("Shared", {
227 * app: Site,
228 * type: "shared_v4",
229 * orgSlug: "my-org",
230 * });
231 * ```
232 *
233 * ### Yield with a Service
234 * Allocate the address on the same App as the Service. `api.url` is
235 * `https://{appName}.fly.dev`.
236 *
237 * **Example:** Stack outputs
238 * ```typescript
239 * export default Alchemy.Stack(
240 * "MyApp",
241 * { providers: Fly.providers(), state: Alchemy.localState() },
242 * Effect.gen(function* () {
243 * const api = yield* Api;
244 * const ip = yield* PublicIp;
245 * return { url: api.url, ip: ip.ip };
246 * }),
247 * );
248 * ```
249 *
250 * @resource
251 * @product App
252 */
253export const IpAssignment: typeof IpAssignmentResource = Object.assign(
254 (
255 id: string,
256 props:
257 | IpAssignmentProps
258 | Effect.Effect<IpAssignmentProps, never, Providers>,
259 ) => IpAssignmentResource(id, resolveIpAssignmentProps(props)),
260 IpAssignmentResource,
261);
262
263export class IpAssignmentNotCreated extends Data.TaggedError(
264 "Fly.IpAssignmentNotCreated",
265)<{
266 appName: string;
267 type: IpAssignmentType;
268}> {}
269
270export class IpAssignmentAppMissing extends Data.TaggedError(
271 "Fly.IpAssignmentAppMissing",
272)<{
273 type: IpAssignmentType;
274}> {}
275
276const appNameOf = (value: unknown): string | undefined => {
277 if (value === null || typeof value !== "object") return undefined;
278 const rec = value as { appName?: unknown; name?: unknown };
279 if (typeof rec.appName === "string" && rec.appName.length > 0) {
280 return rec.appName;
281 }
282 if (typeof rec.name === "string" && rec.name.length > 0) {
283 return rec.name;
284 }
285 return undefined;
286};
287
288const asType = (value: string | undefined): IpAssignmentType | undefined =>
289 value === "v4" ||
290 value === "v6" ||
291 value === "shared_v4" ||
292 value === "private_v6"
293 ? value
294 : undefined;
295
296/**
297 * Fly omits `type` from IP assignment responses. Only Flycast addresses carry
298 * a `network` (its name is empty for the organization's default network).
299 */
300const isFlycast = (assignment: FlyIPAssignment) =>
301 assignment.network !== undefined && assignment.network !== null;
302
303const networkOf = (assignment: FlyIPAssignment) =>
304 assignment.network?.name || undefined;
305
306const inferType = (
307 assignment: FlyIPAssignment,
308 fallback?: IpAssignmentType,
309): IpAssignmentType => {
310 const wire = asType(assignment.type);
311 if (wire !== undefined) return wire;
312 if (assignment.shared === true) return "shared_v4";
313 if (isFlycast(assignment)) return "private_v6";
314 const ip = assignment.ip ?? "";
315 if (ip.includes(":")) return "v6";
316 if (fallback !== undefined) return fallback;
317 return ip.length > 0 ? "v4" : (fallback ?? "v4");
318};
319
320const toAttrs = (
321 appName: string,
322 assignment: FlyIPAssignment,
323 fallbackType?: IpAssignmentType,
324): IpAssignment["Attributes"] => {
325 const type = inferType(assignment, fallbackType);
326 return {
327 appName,
328 ip: assignment.ip ?? "",
329 type,
330 region: assignment.region,
331 serviceName: assignment.service_name,
332 shared: type === "shared_v4" || assignment.shared === true,
333 network: networkOf(assignment),
334 createdAt: assignment.created_at,
335 };
336};
337
338const matchesDesired = (
339 assignment: FlyIPAssignment,
340 news: Pick<IpAssignmentProps, "type" | "region" | "serviceName" | "network">,
341): boolean => {
342 if (inferType(assignment, news.type) !== news.type) return false;
343 if (news.type === "private_v6" && networkOf(assignment) !== news.network)
344 return false;
345 if (news.region !== undefined && assignment.region !== news.region) {
346 return false;
347 }
348 if (
349 news.serviceName !== undefined &&
350 assignment.service_name !== news.serviceName
351 ) {
352 return false;
353 }
354 return true;
355};
356
357const listAssignments = (appName: string) =>
358 machines.listAppIPAssignments({ app_name: appName }).pipe(
359 Effect.map((res) => res.ips ?? []),
360 Effect.catchTag(["NotFound", "Forbidden"], () =>
361 Effect.succeed([] as FlyIPAssignment[]),
362 ),
363 );
364
365const findByIp = (appName: string, ip: string) =>
366 listAssignments(appName).pipe(
367 Effect.map((ips) => ips.find((item) => item.ip === ip)),
368 );
369
370const findMatching = (
371 appName: string,
372 news: Pick<IpAssignmentProps, "type" | "region" | "serviceName" | "network">,
373) =>
374 listAssignments(appName).pipe(
375 Effect.map((ips) => ips.find((item) => matchesDesired(item, news))),
376 );
377
378const waitUntilGone = (appName: string, ip: string) =>
379 findByIp(appName, ip).pipe(
380 Effect.map((found) => found === undefined),
381 Effect.repeat({
382 schedule: Schedule.spaced("1 second"),
383 until: (gone) => gone,
384 times: 8,
385 }),
386 );
387
388/** Whether the App has any address reachable from the internet. */
389export const hasPublicAddress = (appName: string) =>
390 listAssignments(appName).pipe(
391 Effect.map((ips) => ips.some((item) => inferType(item) !== "private_v6")),
392 );
393
394/**
395 * Converge the addresses of an App a {@link Service} owns. Every owned App
396 * gets a Flycast address on its private network (the organization's
397 * default network when `network` is omitted). A public App also gets a
398 * shared IPv4 and an IPv6; a private one has every public address
399 * released. All three types are free.
400 */
401export const syncOwnedAppAddresses = Effect.fn(function* (
402 appName: string,
403 isPublic: boolean,
404 network?: string,
405) {
406 const observed = yield* listAssignments(appName);
407 const isDesiredFlycast = (item: FlyIPAssignment) =>
408 inferType(item) === "private_v6" && networkOf(item) === network;
409 if (!observed.some(isDesiredFlycast)) {
410 yield* machines
411 .createAppIPAssignment({ app_name: appName, type: "private_v6", network })
412 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
413 }
414 if (isPublic) {
415 for (const type of ["shared_v4", "v6"] as const) {
416 if (observed.some((item) => inferType(item) === type)) continue;
417 yield* machines
418 .createAppIPAssignment({ app_name: appName, type })
419 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
420 }
421 }
422 // Release public addresses of a private Service and Flycast addresses
423 // on any other network.
424 for (const item of observed) {
425 if (item.ip === undefined || isDesiredFlycast(item)) continue;
426 if (isPublic && inferType(item) !== "private_v6") continue;
427 yield* machines
428 .deleteAppIPAssignment({ app_name: appName, ip: item.ip })
429 .pipe(Effect.catchTag("NotFound", () => Effect.void));
430 yield* waitUntilGone(appName, item.ip);
431 }
432});
433
434/**
435 * Add a Flycast address on `network` to an App a {@link Service} shares,
436 * so bound callers can reach it. Only ever adds; the address is removed
437 * with the App.
438 */
439export const ensureFlycastAddress = Effect.fn(function* (
440 appName: string,
441 network?: string,
442) {
443 const observed = yield* listAssignments(appName);
444 if (
445 observed.some(
446 (item) => inferType(item) === "private_v6" && networkOf(item) === network,
447 )
448 )
449 return;
450 yield* machines
451 .createAppIPAssignment({ app_name: appName, type: "private_v6", network })
452 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
453});
454
455export const IpAssignmentProvider = () =>
456 Provider.succeed(IpAssignment, {
457 stables: ["ip", "appName", "type", "region"],
458 nuke: { dependsOn: ["Fly.App"] },
459
460 diff: Effect.fn(function* ({ news, olds, output }) {
461 if (news === undefined || !isResolved(news)) return undefined;
462 if (output === undefined) return undefined;
463 const appName = appNameOf(news.app);
464 const appChanged = appName !== undefined && appName !== output.appName;
465 const typeChanged = news.type !== output.type;
466 const regionChanged =
467 news.region !== undefined && news.region !== output.region;
468 const serviceChanged =
469 news.serviceName !== undefined &&
470 news.serviceName !== output.serviceName;
471 const networkChanged =
472 news.network !== (olds !== undefined ? olds.network : output.network);
473 if (
474 appChanged ||
475 typeChanged ||
476 regionChanged ||
477 serviceChanged ||
478 networkChanged
479 ) {
480 return { action: "replace" as const };
481 }
482 return undefined;
483 }),
484
485 read: Effect.fn(function* ({ olds, output }) {
486 const appName = output?.appName ?? appNameOf(olds?.app);
487 if (appName === undefined) return undefined;
488 const found =
489 output?.ip !== undefined && output.ip.length > 0
490 ? yield* findByIp(appName, output.ip)
491 : olds !== undefined
492 ? yield* findMatching(appName, olds)
493 : undefined;
494 if (found === undefined || found.ip === undefined) return undefined;
495 return toAttrs(appName, found, output?.type ?? olds?.type);
496 }),
497
498 list: Effect.fn(function* () {
499 const apps = yield* listOwnedApps();
500 const rows = yield* Effect.forEach(
501 apps,
502 (app) =>
503 listAssignments(app.appName).pipe(
504 Effect.map((ips) =>
505 ips
506 .filter((item) => item.ip !== undefined && item.ip.length > 0)
507 .map((item) => toAttrs(app.appName, item)),
508 ),
509 ),
510 { concurrency: 8 },
511 );
512 return rows.flat();
513 }),
514
515 reconcile: Effect.fn(function* ({ news, output }) {
516 const props = news ?? ({} as IpAssignmentProps);
517 const appName = appNameOf(props.app) ?? output?.appName;
518 if (appName === undefined) {
519 return yield* new IpAssignmentAppMissing({
520 type: props.type,
521 });
522 }
523
524 // Observe by cached ip, then by desired type/region/service.
525 let current =
526 output?.ip !== undefined && output.ip.length > 0
527 ? yield* findByIp(appName, output.ip)
528 : undefined;
529 if (current === undefined) {
530 current = yield* findMatching(appName, props);
531 }
532
533 if (current === undefined) {
534 const created = yield* machines
535 .createAppIPAssignment({
536 app_name: appName,
537 type: props.type,
538 region: props.region,
539 network: props.network,
540 org_slug: props.orgSlug,
541 service_name: props.serviceName,
542 })
543 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
544 if (created !== undefined && created.ip !== undefined) {
545 current = created;
546 } else {
547 current = yield* findMatching(appName, props);
548 }
549 }
550
551 if (current === undefined || current.ip === undefined) {
552 return yield* new IpAssignmentNotCreated({
553 appName,
554 type: props.type,
555 });
556 }
557
558 return toAttrs(appName, current, props.type);
559 }),
560
561 delete: Effect.fn(function* ({ output }) {
562 const appName = output.appName;
563 const ip = output.ip;
564 if (appName.length === 0 || ip.length === 0) return;
565 yield* machines
566 .deleteAppIPAssignment({
567 app_name: appName,
568 ip,
569 })
570 .pipe(Effect.catchTag("NotFound", () => Effect.void));
571 yield* waitUntilGone(appName, ip);
572 }),
573 });