1import type {
2 FlyMachineConfig,
3 FlyMachineGuest,
4 FlyMachineInit,
5 FlyMachineMount,
6 FlyMachineRestart,
7 FlyMachineService,
8 Machine as FlyMachine,
9} from "@distilled.cloud/fly-io/machines";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12
13import type { Input } from "../Input.ts";
14import type { DiskSpec, MountedDisk, ServiceBinding } from "./MountVolume.ts";
15import type { Providers } from "./Providers.ts";
16
17import { deepEqual, isResolved } from "../Diff.ts";
18import * as Provider from "../Provider.ts";
19import { Resource, type ResourceBinding } from "../Resource.ts";
20import { App } from "./App.ts";
21import {
22 deploymentPolicy,
23 validateDeployment,
24 type MachineDeploy,
25 type MachineShutdown,
26 type MachineCheck,
27} from "./Deployment.ts";
28import { toEnvRecord } from "./hosted.ts";
29import {
30 sameContainerWorkload,
31 toFlyContainers,
32 validateMachineContainers,
33} from "./MachineContainers.ts";
34import {
35 createFlyResourceName,
36 diffMachineMetadata,
37 sanitizeFlyAppName,
38} from "./Metadata.ts";
39import {
40 deleteReplicaSet,
41 listReplicaSets,
42 observeReplicaSet,
43 reconcileReplicas,
44 resolveCount,
45 sameServices,
46 toFlyService,
47 volumeIdsOf,
48 type Replica,
49 type ReplicaSet,
50} from "./replicas.ts";
51
52export type { Replica };
53
54const DEFAULT_REGION = "iad";
55const DEFAULT_CPU_KIND = "shared";
56const DEFAULT_CPUS = 1;
57const DEFAULT_MEMORY_MB = 256;
58
59/**
60 * A resource-valued prop: the resource itself, or an Effect that produces
61 * it (so `yield* App(...)` and `App(...)` both type-check).
62 */
63type Ref<T> = T | Effect.Effect<T, never, Providers>;
64
65export interface MachineGuest {
66 /**
67 * CPU kind (`shared`, `performance`, `shared-cpu-1x`, …).
68 *
69 * @default "shared"
70 */
71 cpuKind?: string;
72 /**
73 * Number of CPUs.
74 *
75 * @default 1
76 */
77 cpus?: number;
78 /**
79 * Memory in MB.
80 *
81 * @default 256
82 */
83 memoryMb?: number;
84 /** GPU kind, if this Machine should have a GPU. */
85 gpuKind?: string;
86 /** Number of GPUs. */
87 gpus?: number;
88}
89
90export interface MachineInit {
91 /** Process command. */
92 cmd?: string[];
93 /** Container entrypoint. */
94 entrypoint?: string[];
95 /** Exec form override. */
96 exec?: string[];
97 /** Swap size in MB. */
98 swapSizeMb?: number;
99 /** Allocate a TTY. */
100 tty?: boolean;
101}
102
103export interface MachineRestart {
104 /**
105 * Restart policy (`no`, `always`, `on-failure`, `spot-price`).
106 */
107 policy?: "no" | "always" | "on-failure" | "spot-price";
108 /** Max restarts when `policy` is `on-failure`. */
109 maxRetries?: number;
110}
111
112export interface MachinePort {
113 /** Published proxy port. */
114 port?: number;
115 /** Fly handlers (`http`, `tls`, `pg_tls`, …). */
116 handlers?: string[];
117 /** Redirect HTTP to HTTPS on this port. */
118 forceHttps?: boolean;
119 /** Inclusive start of a published port range. */
120 startPort?: number;
121 /** Inclusive end of a published port range. */
122 endPort?: number;
123}
124
125/** A health check attached to a Fly service. */
126export interface MachineServiceCheck {
127 /** Check type. HTTP checks send a request; TCP checks open a connection. */
128 type: "http" | "tcp";
129 /** Port to check. Usually the service's {@link MachineService.internalPort}. */
130 port?: number;
131 /**
132 * Time between checks, such as `"15s"`.
133 * Fly caps service checks at `"60s"`; this cap does not apply to named Machine checks.
134 */
135 interval?: string;
136 /** Maximum time for a check, such as `"2s"`. */
137 timeout?: string;
138 /** Delay after the Machine starts before checks begin, such as `"30s"`. */
139 gracePeriod?: string;
140 /** HTTP method for an `http` check. */
141 method?: string;
142 /** Request path for an `http` check. */
143 path?: string;
144 /** Request protocol for an `http` check. */
145 protocol?: "http" | "https";
146 /** Headers sent by an `http` check. */
147 headers?: Array<{
148 /** Header name. */
149 name: string;
150 /** Header values. */
151 values: string[];
152 }>;
153 /** Hostname used to validate the certificate for an HTTPS check. */
154 tlsServerName?: string;
155 /** Skip certificate verification for an HTTPS check. */
156 tlsSkipVerify?: boolean;
157}
158
159export interface MachineService {
160 /**
161 * Proxy protocol (`tcp` or `udp`).
162 */
163 protocol?: string;
164 /** Port the process listens on inside the Machine. */
165 internalPort?: number;
166 /** Published Fly proxy ports. */
167 ports?: MachinePort[];
168 /** Start this Machine when a request arrives. */
169 autostart?: boolean;
170 /**
171 * Stop or suspend this Machine when idle.
172 */
173 autostop?: "off" | "stop" | "suspend" | boolean;
174 /** Minimum Machines to keep running for this service. */
175 minMachinesRunning?: number;
176 /** Health checks for this service. */
177 checks?: MachineServiceCheck[];
178}
179
180export type MachineMount = DiskSpec;
181
182/** Startup dependency on another named container in the same Machine. */
183export interface MachineContainerDependency {
184 /** Name of another container in this Machine. */
185 name: string;
186 /** Startup condition Fly waits for. */
187 condition?: "started" | "healthy" | "exited_successfully";
188}
189
190/** A native Pilot health check; timing fields are numeric seconds. */
191export interface MachineContainerHealthCheck {
192 /** Optional check name, unique within this container. */
193 name?: string;
194 /** Whether the check gates readiness or liveness. */
195 kind?: "readiness" | "liveness";
196 /** Seconds between checks. */
197 interval?: number;
198 /** Seconds before a check times out. */
199 timeout?: number;
200 /** Seconds after startup before checks begin. */
201 gracePeriod?: number;
202 /** Consecutive successes required to become healthy. */
203 successThreshold?: number;
204 /** Consecutive failures required to become unhealthy. */
205 failureThreshold?: number;
206 /** HTTP probe. Configure exactly one of HTTP, TCP, or exec. */
207 http?: {
208 /** Container-local port. */
209 port: number;
210 /** Request path. */
211 path?: string;
212 /** Request method. */
213 method?: string;
214 /** Request scheme. */
215 scheme?: "http" | "https";
216 /** Additional request headers. */
217 headers?: Array<{ name: string; values: string[] }>;
218 /** Hostname for TLS certificate verification. */
219 tlsServerName?: string;
220 /** Skip TLS certificate verification. */
221 tlsSkipVerify?: boolean;
222 };
223 /** TCP connection probe. */
224 tcp?: { port: number };
225 /** Process execution probe. */
226 exec?: { command: string[] };
227}
228
229/** One named container in a Machine replica. */
230export interface MachineContainer {
231 /** Stable name identifying this container within the group. */
232 name: string;
233 /** Docker image reference. Rolling deployments accept tags or digests. */
234 image: string;
235 /** Command override. */
236 cmd?: string[];
237 /** Entrypoint override. */
238 entrypoint?: string[];
239 /** Per-container environment variables; Fly applies these over Machine env. */
240 env?: Record<string, string>;
241 /** Other named containers that must reach a startup condition first. */
242 dependsOn?: MachineContainerDependency[];
243 /** Native Pilot checks for this container. Deployment readiness is configured separately. */
244 healthChecks?: MachineContainerHealthCheck[];
245}
246
247export interface MachinePropsBase {
248 /** Deployment strategy and readiness deadline. Defaults to in-place rolling updates. */
249 deploy?: MachineDeploy;
250 /** Graceful process shutdown. Defaults to SIGTERM / 30 seconds when blue/green is enabled. */
251 shutdown?: MachineShutdown;
252 /** Named readiness checks for workers without public services. */
253 checks?: Record<string, MachineCheck>;
254 /**
255 * Parent Fly App. Changing it replaces the Machine.
256 */
257 app: Ref<App>;
258 /**
259 * Machine name. Unique per App. If omitted, a unique name is generated
260 * from the stack, stage and logical ID. Changing it replaces the Machine.
261 * In blue/green mode this is a base for generation-qualified physical names.
262 */
263 name?: string;
264 /**
265 * Region to start the Machine in (`iad`, `ewr`, `ord`, …). Changing it
266 * replaces the Machine.
267 *
268 * @default "iad"
269 */
270 region?: string;
271 /**
272 * Number of Machines to provision, including stopped/suspended idle capacity.
273 * Fly's proxy load-balances published `services` across available replicas.
274 * Blue/green checks a representative and the required running floor while
275 * preserving idle nonrepresentatives. Each replica gets its own Volume
276 * from every {@link mounts} group; attached volumes require rolling updates.
277 *
278 * @default 1
279 */
280 count?: number;
281 /**
282 * Guest size. Defaults to shared-cpu-1x 256 MB.
283 */
284 guest?: MachineGuest;
285 /**
286 * Environment variables. Merged with binding `env`.
287 */
288 env?: Record<string, string>;
289 /**
290 * Fly proxy services (HTTP/TCP ports).
291 */
292 services?: MachineService[];
293 /**
294 * Disks to attach. Each entry is a Fly volume group: `count`
295 * independent Volumes, one mounted on each replica. Also collected
296 * from `MountVolume` bindings.
297 */
298 mounts?: MachineMount[];
299 /**
300 * User metadata. Alchemy ownership keys (`alchemy.stack` /
301 * `alchemy.stage` / `alchemy.id` / `alchemy.type` /
302 * `alchemy.replica`) are always merged.
303 */
304 metadata?: Record<string, string>;
305 /**
306 * Destroy the Machine when its main process exits.
307 *
308 * @default false
309 */
310 autoDestroy?: boolean;
311 /**
312 * Restart policy after the main process exits.
313 */
314 restart?: MachineRestart;
315 /**
316 * Create or update without launching the Machine.
317 *
318 * @default false
319 */
320 skipLaunch?: boolean;
321 /**
322 * Minimum App-secrets version required by this Machine, not an immutable
323 * snapshot. Other writers can advance the shared vault. Secret changes are
324 * not automatically watched; change an explicit deployment input or floor
325 * when an out-of-band rotation requires reconciliation.
326 */
327 minSecretsVersion?: number;
328}
329
330export type MachineProps =
331 | (MachinePropsBase & {
332 /** Docker image reference. Rolling updates change the existing Machines. */
333 image: string;
334 containers?: never;
335 /** Process init overrides for a single-image Machine. */
336 init?: MachineInit;
337 })
338 | (MachinePropsBase & {
339 /** Named containers run together in every Machine replica. */
340 containers: MachineContainer[];
341 image?: never;
342 init?: never;
343 });
344
345export type MachineImageRef = {
346 registry?: string;
347 repository?: string;
348 tag?: string;
349 digest?: string;
350};
351
352export type Machine = Resource<
353 "Fly.Machine",
354 MachineProps,
355 {
356 /** Whether recovery must finish an interrupted deployment. */
357 rolloutPending?: boolean;
358 /** Parent Fly App name. */
359 appName: string;
360 /** Fly Machine id of replica 0. */
361 machineId: string;
362 /** Fly Machine ids of every replica. */
363 machineIds: string[];
364 /** Logical base for generation-qualified Machine names. */
365 baseName?: string;
366 /** Machine name of replica 0 (unique per App). Changes during blue/green deployment. */
367 name: string;
368 /** Region the Machines are running in. */
369 region: string;
370 /** Observed state of replica 0 (`created`, `started`, `stopped`, …). */
371 state: string;
372 /** Fly instance / version id of replica 0, if the API returned one. */
373 instanceId: string | undefined;
374 /** Internal 6PN address of replica 0. */
375 privateIp: string | undefined;
376 /** Parsed image reference from Fly. */
377 imageRef: MachineImageRef | undefined;
378 /** Observed guest size. */
379 guest: MachineGuest | undefined;
380 /** Number of Machines in the replica set. */
381 count: number;
382 /** Disks mounted on replica 0. */
383 mounts: MountedDisk[];
384 /** Every replica in the set. */
385 replicas: Replica[];
386 },
387 ServiceBinding,
388 Providers
389>;
390
391/**
392 * A Fly.Machine is a Firecracker VM running one image or a named container group.
393 *
394 * Prefer a {@link Service} when the program is Effect. A Service is effectful, supports bindings,
395 * and scales with `count`. Alchemy builds and pushes the image. Use `Fly.Machine` when you already
396 * have an image.
397 *
398 * @see https://fly.io/docs/machines/api/machines-resource/
399 *
400 * ### Prefer a Service
401 * Declare a {@link Service} when you own the program. Alchemy bundles
402 * `main`, builds `linux/amd64`, and pushes to `registry.fly.io`.
403 *
404 * **Example:** Effect HTTP service
405 * ```typescript
406 * export default class Api extends Fly.Service<Api>()(
407 * "Api",
408 * { app: Site, main: import.meta.url, region: "iad", count: 3, port: 3000 },
409 * Effect.gen(function* () {
410 * return {
411 * fetch: Effect.succeed(HttpServerResponse.text("hello")),
412 * };
413 * }),
414 * ) {}
415 * ```
416 *
417 * ### Launch a Machine
418 * The parent is an {@link App}. Pin a region and an image. Guest
419 * defaults to shared-cpu 1× / 256 MB. Rolling updates `image` in place;
420 * blue/green prepares replacement Machines.
421 *
422 * **Example:** Nginx
423 * ```typescript
424 * const web = yield* Fly.Machine("Web", {
425 * app: Site,
426 * region: "iad",
427 * image: "nginx:alpine",
428 * });
429 * ```
430 *
431 * :::caution[Changing `app` replaces the Machine]
432 * The new App gets a new Machine. The old one is deleted.
433 * :::
434 *
435 * ### Run named containers
436 * Each replica contains the entire group. Container checks and dependencies
437 * control Pilot startup; configure Machine or service checks for deployment
438 * readiness. Rolling updates can restart the entire group when one image
439 * changes. Blue/green requires every named image to use an immutable
440 * `repository@sha256:` digest and cannot attach volumes. Alchemy replaces
441 * the entire group and applies readiness policy before retiring predecessors.
442 *
443 * **Example:** API and worker
444 * ```typescript
445 * const preview = yield* Fly.Machine("Preview", {
446 * app: Site,
447 * containers: [
448 * { name: "api", image: apiImage, healthChecks: [{ http: { port: 3000, path: "/health" } }] },
449 * { name: "worker", image: workerImage, dependsOn: [{ name: "api", condition: "healthy" }] },
450 * ],
451 * });
452 * ```
453 *
454 * ### A stable name
455 * Machine names are unique per App. Omit `name` and Alchemy generates
456 * one from the stack, stage, and logical ID.
457 *
458 * **Example:** Explicit name
459 * ```typescript
460 * const web = yield* Fly.Machine("Web", {
461 * app: Site,
462 * name: "web",
463 * region: "iad",
464 * image: "nginx:alpine",
465 * });
466 * ```
467 *
468 * :::caution[Changing `name` replaces the Machine]
469 * Fly cannot rename a Machine. Alchemy creates the new name, then
470 * deletes the old one.
471 * :::
472 *
473 * ### Region
474 * Fly Machines live in a region. Default is `iad`. See
475 * [Regions](/fly/compute/regions) for the list of codes.
476 *
477 * **Example:** Pin a region
478 * ```typescript
479 * const web = yield* Fly.Machine("Web", {
480 * app: Site,
481 * region: "ewr",
482 * image: "nginx:alpine",
483 * });
484 * ```
485 *
486 * :::caution[Changing `region` replaces the Machine]
487 * The Machine is created in the new region. The old one is deleted.
488 * :::
489 *
490 * ### Guest size
491 * `guest` is CPU kind, CPU count, and memory. Default is shared-cpu,
492 * 1 CPU, 256 MB. Rolling updates guest sizing in place; blue/green replaces
493 * the Machines.
494 *
495 * **Example:** Shared CPU
496 * ```typescript
497 * const web = yield* Fly.Machine("Web", {
498 * app: Site,
499 * region: "iad",
500 * image: "nginx:alpine",
501 * guest: { cpuKind: "shared", cpus: 1, memoryMb: 256 },
502 * });
503 * ```
504 *
505 * ### GPU
506 * Set `gpuKind` and `gpus` on `guest` when the Machine should have a
507 * GPU.
508 *
509 * **Example:** GPU guest
510 * ```typescript
511 * const worker = yield* Fly.Machine("Worker", {
512 * app: Site,
513 * region: "iad",
514 * image: "my-gpu-image:tag",
515 * guest: {
516 * cpuKind: "performance",
517 * cpus: 2,
518 * memoryMb: 4096,
519 * gpuKind: "a10",
520 * gpus: 1,
521 * },
522 * });
523 * ```
524 *
525 * ### Environment variables
526 * `env` is merged onto the Machine. Fly also injects App
527 * {@link Secret} values as env vars unless the Machine skips secrets.
528 *
529 * **Example:** Set env
530 * ```typescript
531 * const worker = yield* Fly.Machine("Worker", {
532 * app: Site,
533 * region: "iad",
534 * image: "my-image:tag",
535 * env: { LOG_LEVEL: "info" },
536 * });
537 * ```
538 *
539 * ### Publish a proxy service
540 * `services` publishes ports on Fly's proxy. The App needs an
541 * {@link IpAssignment} before `{app}.fly.dev` answers. For a public
542 * endpoint without managing Apps and addresses, use a {@link Service}.
543 *
544 * Handlers are `http`, `tls`, `pg_tls`, and similar. Set `forceHttps`
545 * to redirect HTTP to HTTPS. Use `startPort` / `endPort` for a
546 * published range.
547 *
548 * Omit `services` (or pass `[]`) for a process that should not be
549 * reachable from the internet.
550 *
551 * **Example:** HTTP on port 80
552 * ```typescript
553 * const web = yield* Fly.Machine("Web", {
554 * app: Site,
555 * region: "iad",
556 * image: "nginx:alpine",
557 * services: [
558 * {
559 * protocol: "tcp",
560 * internalPort: 80,
561 * ports: [
562 * { port: 80, handlers: ["http"], forceHttps: true },
563 * { port: 443, handlers: ["tls", "http"] },
564 * ],
565 * },
566 * ],
567 * });
568 * ```
569 *
570 * ### Service health checks
571 * Add HTTP or TCP `checks` to a service. Fly uses their results to
572 * determine whether the service is ready to receive traffic. With rolling
573 * updates, reconcile waits for each started replica's checks before updating
574 * the next replica. Missing or non-passing results are polled within
575 * `deploy.healthTimeout` (60 seconds by default), then fail deployment with
576 * `Fly.ReplicaChecksNotPassing`. Later replicas remain unchanged; earlier
577 * updates are not rolled back. A single rolling replica can be unavailable.
578 * Blue/green checks replacements before retiring the old set, with
579 * representative/floor readiness for idle capacity.
580 *
581 * **Example:** HTTP readiness check
582 * ```typescript
583 * const web = yield* Fly.Machine("Web", {
584 * app: Site,
585 * region: "iad",
586 * image: "nginx:alpine",
587 * services: [
588 * {
589 * protocol: "tcp",
590 * internalPort: 80,
591 * ports: [{ port: 80, handlers: ["http"] }],
592 * checks: [
593 * {
594 * type: "http",
595 * port: 80,
596 * method: "GET",
597 * path: "/",
598 * protocol: "http",
599 * interval: "15s",
600 * timeout: "2s",
601 * gracePeriod: "30s",
602 * headers: [{ name: "X-Health-Check", values: ["alchemy"] }],
603 * },
604 * ],
605 * },
606 * ],
607 * });
608 * ```
609 *
610 * ### Autostart and autostop
611 * `autostart` starts the Machine when a request arrives. `autostop` is
612 * `"off"`, `"stop"`, `"suspend"`, or a boolean. `minMachinesRunning`
613 * keeps that many Machines up for the service.
614 *
615 * Autostop only affects Machines that already exist. Set `count` or declare
616 * more resources to size the pool. Stop boots a new process on autostart;
617 * suspend may resume memory or fall back to a cold start. Suspension is not
618 * SIGTERM shutdown and does not run ordinary shutdown finalizers.
619 *
620 * Blue/green supports both policies: it checks a representative and the
621 * required running floor while preserving idle nonrepresentatives. It
622 * restores the requested idle policy before retiring predecessors and
623 * checks any new instance created by restoration. A replacement does not
624 * inherit its suspended predecessor's process memory.
625 *
626 * **Example:** Stop when idle
627 * ```typescript
628 * const web = yield* Fly.Machine("Web", {
629 * app: Site,
630 * region: "iad",
631 * image: "nginx:alpine",
632 * services: [
633 * {
634 * protocol: "tcp",
635 * internalPort: 80,
636 * autostart: true,
637 * autostop: "stop",
638 * minMachinesRunning: 0,
639 * ports: [{ port: 80, handlers: ["http"] }],
640 * },
641 * ],
642 * });
643 * ```
644 *
645 * ### Scale up
646 * Each Machine resource runs one VM by default. Set `count` to manage
647 * several replicas together, or declare separate resources for Machines
648 * with different configuration. Fly's proxy load-balances published
649 * `services` across them.
650 *
651 * Both Machine and {@link Service} support `count`. Default rolling updates
652 * replicas sequentially; blue/green prepares replacements before retirement.
653 * Separate resources do not share an update order or App-wide lease lock.
654 *
655 * **Example:** Two Machines
656 * ```typescript
657 * const web1 = yield* Fly.Machine("Web1", {
658 * app: Site,
659 * region: "iad",
660 * image: "nginx:alpine",
661 * services: [
662 * {
663 * protocol: "tcp",
664 * internalPort: 80,
665 * ports: [{ port: 80, handlers: ["http"] }],
666 * },
667 * ],
668 * });
669 *
670 * const web2 = yield* Fly.Machine("Web2", {
671 * app: Site,
672 * region: "iad",
673 * image: "nginx:alpine",
674 * services: [
675 * {
676 * protocol: "tcp",
677 * internalPort: 80,
678 * ports: [{ port: 80, handlers: ["http"] }],
679 * },
680 * ],
681 * });
682 * ```
683 *
684 * ### Scale down
685 * Remove a Machine from the stack. The next deploy deletes it.
686 *
687 * **Example:** Drop Web2
688 * ```diff
689 * const web1 = yield* Fly.Machine("Web1", {
690 * app: Site,
691 * region: "iad",
692 * image: "nginx:alpine",
693 * });
694 * -
695 * - const web2 = yield* Fly.Machine("Web2", {
696 * - app: Site,
697 * - region: "iad",
698 * - image: "nginx:alpine",
699 * - });
700 * ```
701 *
702 * ### Attach a disk
703 * Pass disks as `mounts`. Alchemy creates a Volume in the Machine's
704 * app and region. A Volume attaches to one Machine. There is no
705 * standalone Volume resource.
706 *
707 * `sizeGb` can grow in place. Shrinking is not supported. Encryption,
708 * filesystem type, `snapshotId`, and `sourceVolumeId` are create-only.
709 * See {@link MountVolume} for the full disk spec. From a Service,
710 * prefer `MountVolume` so the path is part of the binding graph.
711 *
712 * **Example:** Mount `/data`
713 * ```typescript
714 * const box = yield* Fly.Machine("Box", {
715 * app: Site,
716 * region: "iad",
717 * image: "postgres:16",
718 * mounts: [{ path: "/data", sizeGb: 10 }],
719 * });
720 * ```
721 *
722 * ### Init
723 * `init` overrides `cmd`, `entrypoint`, `exec`, swap, and TTY. Updates
724 * in place.
725 *
726 * **Example:** Custom command
727 * ```typescript
728 * const box = yield* Fly.Machine("Box", {
729 * app: Site,
730 * region: "iad",
731 * image: "postgres:16",
732 * init: { cmd: ["postgres", "-c", "shared_buffers=256MB"] },
733 * });
734 * ```
735 *
736 * ### Restart policy
737 * `restart.policy` is `"no"`, `"always"`, `"on-failure"`, or
738 * `"spot-price"`. `maxRetries` applies when the policy is
739 * `"on-failure"`. Updates in place.
740 *
741 * **Example:** Always restart
742 * ```typescript
743 * const worker = yield* Fly.Machine("Worker", {
744 * app: Site,
745 * region: "iad",
746 * image: "my-image:tag",
747 * restart: { policy: "always" },
748 * });
749 * ```
750 *
751 * ### Destroy on exit
752 * `autoDestroy: true` tears the Machine down when its main process
753 * exits. Default is `false`.
754 *
755 * **Example:** One-shot Machine
756 * ```typescript
757 * const job = yield* Fly.Machine("Job", {
758 * app: Site,
759 * region: "iad",
760 * image: "my-job:tag",
761 * autoDestroy: true,
762 * restart: { policy: "no" },
763 * });
764 * ```
765 *
766 * ### Skip launch
767 * `skipLaunch: true` creates or updates the config without starting
768 * the Machine. Default is `false`. Reconcile otherwise waits until
769 * the Machine is `started`, and until service checks are passing when
770 * the Machine has them.
771 *
772 * **Example:** Config only
773 * ```typescript
774 * const web = yield* Fly.Machine("Web", {
775 * app: Site,
776 * region: "iad",
777 * image: "nginx:alpine",
778 * skipLaunch: true,
779 * });
780 * ```
781 *
782 * ### Metadata
783 * User keys on `metadata` merge with Alchemy ownership keys
784 * (`alchemy.stack`, `alchemy.stage`, `alchemy.id`, `alchemy.type`,
785 * `alchemy.replica`). Those ownership keys are always written so
786 * `list()` can find owned Machines. Fly Apps have no labels.
787 *
788 * **Example:** User metadata
789 * ```typescript
790 * const web = yield* Fly.Machine("Web", {
791 * app: Site,
792 * region: "iad",
793 * image: "nginx:alpine",
794 * metadata: { role: "edge" },
795 * });
796 * ```
797 *
798 * ### Secrets version
799 * `minSecretsVersion` requires at least that App-secrets version, not an
800 * immutable snapshot. Machine leases do not serialize vault writers.
801 * After rotating a {@link Secret}, declare the required floor or another
802 * explicit rollout input; an out-of-band change alone does not watch/redeploy.
803 *
804 * **Example:** Wait for secrets
805 * ```typescript
806 * const web = yield* Fly.Machine("Web", {
807 * app: Site,
808 * region: "iad",
809 * image: "nginx:alpine",
810 * minSecretsVersion: 2,
811 * });
812 * ```
813 *
814 * ### Blue/green deployments
815 * Prepare a healthy replacement set before retiring the current Machines.
816 * Physical IDs and names change; the App URL remains stable. Volumes,
817 * auto-destroy, and skipLaunch are incompatible. Autostop preserves idle
818 * nonrepresentatives while a representative passes checks. Raw images must
819 * handle their own shutdown signal and stop accepting background work.
820 *
821 * Retirement honors each predecessor's own signal and timeout. Native
822 * target leases do not exclude every simultaneous first deployment or
823 * snapshot; serialize CI for the same resource. See the
824 * [deployment guide](/fly/compute/deployments) for idle capacity, secret
825 * floors, recovery, and the limits of live-tested parity.
826 *
827 * **Example:** Private worker readiness
828 * ```typescript
829 * const worker = yield* Fly.Machine("Worker", {
830 * app: Site,
831 * image: "registry.example.com/worker:v2",
832 * deploy: { strategy: "bluegreen", healthTimeout: "60 seconds" },
833 * shutdown: { signal: "SIGTERM", timeout: "30 seconds" },
834 * checks: {
835 * ready: { type: "http", port: 3000, path: "/healthz", interval: "5s", timeout: "2s" },
836 * },
837 * });
838 * ```
839 *
840 * Every published service needs its own service check. Before promotion, a
841 * failed candidate leaves the old generation serving. After possible promotion,
842 * potentially serving replacements are preserved rather than blindly deleted.
843 * Retry an interrupted deploy to finish promotion or retirement; destroy
844 * discovers unfinished owned generations.
845 * See the [deployment guide](/fly/compute/deployments) for recovery and limits.
846 *
847 * @resource
848 * @product Machine
849 */
850export const Machine = Resource<Machine>("Fly.Machine");
851
852export class MachineNotCreated extends Data.TaggedError(
853 "Fly.MachineNotCreated",
854)<{
855 name: string;
856 appName: string;
857}> {}
858
859export class MachineAppNotResolved extends Data.TaggedError(
860 "Fly.MachineAppNotResolved",
861)<{
862 message: string;
863}> {}
864
865type MachineBinding = Machine["Binding"];
866
867const appNameOf = (value: unknown): string | undefined => {
868 if (value == null || typeof value !== "object") return undefined;
869 const name = (value as { appName?: unknown }).appName;
870 return typeof name === "string" && name.length > 0 ? name : undefined;
871};
872
873const compactRecord = (
874 record: Record<string, string | undefined> | null | undefined,
875): Record<string, string> =>
876 Object.fromEntries(
877 Object.entries(record ?? {}).flatMap(([key, value]) =>
878 value === undefined ? [] : [[key, value]],
879 ),
880 );
881
882const toEnv = toEnvRecord;
883
884const resolveMachineName = (
885 id: string,
886 name: string | undefined,
887 existing?: string,
888) =>
889 Effect.gen(function* () {
890 if (name !== undefined) return sanitizeFlyAppName(name);
891 if (existing !== undefined) return existing;
892 return yield* createFlyResourceName(id);
893 });
894
895const mergeBindings = (
896 bindings: readonly ResourceBinding<MachineBinding>[],
897) => {
898 const env: Record<string, any> = {};
899 const mounts: DiskSpec[] = [];
900 for (const binding of bindings) {
901 Object.assign(env, binding.data?.env);
902 if (binding.data?.mounts) mounts.push(...binding.data.mounts);
903 }
904 return { env, mounts };
905};
906
907const mergeDisks = (
908 props: DiskSpec[] | undefined,
909 bindingMounts: DiskSpec[],
910): DiskSpec[] => {
911 const byPath = new Map<string, DiskSpec>();
912 for (const disk of [...(props ?? []), ...bindingMounts]) {
913 byPath.set(disk.path, disk);
914 }
915 return [...byPath.values()];
916};
917
918const toFlyGuest = (guest: MachineGuest | undefined): FlyMachineGuest => {
919 const fly: FlyMachineGuest = {
920 cpu_kind: guest?.cpuKind ?? DEFAULT_CPU_KIND,
921 cpus: guest?.cpus ?? DEFAULT_CPUS,
922 memory_mb: guest?.memoryMb ?? DEFAULT_MEMORY_MB,
923 };
924 if (guest?.gpuKind !== undefined) fly.gpu_kind = guest.gpuKind;
925 if (guest?.gpus !== undefined) fly.gpus = guest.gpus;
926 return fly;
927};
928
929const toFlyInit = (init: MachineInit): FlyMachineInit => ({
930 cmd: init.cmd,
931 entrypoint: init.entrypoint,
932 exec: init.exec,
933 swap_size_mb: init.swapSizeMb,
934 tty: init.tty,
935});
936
937const toFlyRestart = (restart: MachineRestart): FlyMachineRestart => ({
938 policy: restart.policy,
939 max_retries: restart.maxRetries,
940});
941
942const desiredEnv = (
943 props: MachineProps,
944 bindingEnv: Record<string, any>,
945): Record<string, string> => ({ ...toEnv(props.env), ...toEnv(bindingEnv) });
946
947const desiredMetadata = (
948 props: MachineProps,
949 alchemy: Record<string, string>,
950): Record<string, string> => ({ ...(props.metadata ?? {}), ...alchemy });
951
952const buildConfig = (input: {
953 image: string | undefined;
954 containers: FlyMachineConfig["containers"];
955 guest: FlyMachineGuest;
956 env: Record<string, string>;
957 services: FlyMachineService[] | undefined;
958 mounts: FlyMachineMount[];
959 metadata: Record<string, string>;
960 restart: FlyMachineRestart | undefined;
961 autoDestroy: boolean | undefined;
962 init: FlyMachineInit | undefined;
963}): FlyMachineConfig => ({
964 image: input.image,
965 containers: input.containers,
966 guest: input.guest,
967 env: Object.keys(input.env).length > 0 ? input.env : undefined,
968 services:
969 input.services !== undefined && input.services.length > 0
970 ? input.services
971 : undefined,
972 mounts: input.mounts.length > 0 ? input.mounts : undefined,
973 metadata: input.metadata,
974 restart: input.restart,
975 auto_destroy: input.autoDestroy,
976 init: input.init,
977});
978
979const sameImage = (machine: FlyMachine, image: string) => {
980 const configImage = machine.config?.image;
981 if (configImage === image) return true;
982 const ref = machine.image_ref;
983 const colon = image.lastIndexOf(":");
984 const slash = image.lastIndexOf("/");
985 const split = colon > slash ? colon : -1;
986 const repo = split === -1 ? image : image.slice(0, split);
987 const tag = split === -1 ? "latest" : image.slice(split + 1);
988 const observedRepo = ref?.repository;
989 if (observedRepo === undefined || ref?.tag !== tag) return false;
990 return observedRepo === repo || observedRepo.endsWith(`/${repo}`);
991};
992
993const sameGuest = (
994 observed: FlyMachineGuest | undefined,
995 desired: FlyMachineGuest,
996) =>
997 (observed?.cpu_kind ?? DEFAULT_CPU_KIND) === desired.cpu_kind &&
998 (observed?.cpus ?? DEFAULT_CPUS) === desired.cpus &&
999 (observed?.memory_mb ?? DEFAULT_MEMORY_MB) === desired.memory_mb &&
1000 observed?.gpu_kind === desired.gpu_kind &&
1001 observed?.gpus === desired.gpus;
1002
1003const sameEnv = (
1004 observed: Record<string, string | undefined> | undefined,
1005 desired: Record<string, string>,
1006) => deepEqual(compactRecord(observed), desired);
1007
1008const sameMounts = (
1009 observed: FlyMachineMount[] | undefined,
1010 desired: FlyMachineMount[],
1011) => {
1012 const key = (mount: FlyMachineMount) =>
1013 `${mount.volume ?? ""}:${mount.path ?? ""}`;
1014 const left = [...(observed ?? [])].map(key).sort();
1015 const right = desired.map(key).sort();
1016 return deepEqual(left, right);
1017};
1018
1019const sameRestart = (
1020 observed: FlyMachineRestart | undefined,
1021 desired: FlyMachineRestart | undefined,
1022) =>
1023 deepEqual(
1024 {
1025 policy: observed?.policy ?? "on-failure",
1026 max_retries: observed?.max_retries ?? 10,
1027 },
1028 {
1029 policy: desired?.policy ?? "on-failure",
1030 max_retries: desired?.max_retries ?? 10,
1031 },
1032 { stripNullish: true },
1033 );
1034
1035const sameInit = (
1036 observed: FlyMachineInit | undefined,
1037 desired: FlyMachineInit | undefined,
1038) => deepEqual(observed ?? {}, desired ?? {}, { stripNullish: true });
1039
1040const metadataChanged = (
1041 observed: Record<string, string | undefined> | undefined,
1042 desired: Record<string, string>,
1043) => {
1044 const { removed, added, updated } = diffMachineMetadata(
1045 compactRecord(observed),
1046 desired,
1047 );
1048 return (
1049 removed.length > 0 ||
1050 Object.keys(added).length > 0 ||
1051 Object.keys(updated).length > 0
1052 );
1053};
1054
1055const configDrifted = (
1056 machine: FlyMachine,
1057 desired: {
1058 image: string | undefined;
1059 containers: FlyMachineConfig["containers"];
1060 guest: FlyMachineGuest;
1061 env: Record<string, string>;
1062 services: FlyMachineService[] | undefined;
1063 mounts: FlyMachineMount[];
1064 metadata: Record<string, string>;
1065 restart: FlyMachineRestart | undefined;
1066 autoDestroy: boolean | undefined;
1067 init: FlyMachineInit | undefined;
1068 },
1069) => {
1070 const config = machine.config;
1071 return (
1072 (desired.containers !== undefined
1073 ? !sameContainerWorkload(config, desired.containers)
1074 : desired.image === undefined ||
1075 !sameImage(machine, desired.image) ||
1076 (config?.containers?.length ?? 0) > 0) ||
1077 !sameGuest(config?.guest, desired.guest) ||
1078 !sameEnv(config?.env, desired.env) ||
1079 !sameServices(config?.services, desired.services) ||
1080 !sameMounts(config?.mounts, desired.mounts) ||
1081 metadataChanged(config?.metadata, desired.metadata) ||
1082 !sameRestart(config?.restart, desired.restart) ||
1083 (desired.autoDestroy ?? false) !== (config?.auto_destroy ?? false) ||
1084 !sameInit(config?.init, desired.init)
1085 );
1086};
1087
1088const toAttrs = (set: ReplicaSet): Machine["Attributes"] => ({
1089 appName: set.appName,
1090 rolloutPending: set.rolloutPending,
1091 machineId: set.machineId,
1092 machineIds: set.machineIds,
1093 name: set.name,
1094 baseName: set.baseName,
1095 region: set.region,
1096 state: set.state,
1097 instanceId: set.instanceId,
1098 privateIp: set.privateIp,
1099 imageRef: set.imageRef,
1100 guest: set.guest,
1101 count: set.count,
1102 mounts: set.mounts,
1103 replicas: set.replicas,
1104});
1105
1106const machineIdsOf = (output: Machine["Attributes"] | undefined) =>
1107 output?.machineIds ??
1108 (output?.machineId !== undefined && output.machineId.length > 0
1109 ? [output.machineId]
1110 : []);
1111
1112export const MachineProvider = () =>
1113 Provider.succeed(Machine, {
1114 stables: ["region", "appName"],
1115 nuke: { dependsOn: ["Fly.App"] },
1116
1117 diff: Effect.fn(function* ({ news, output }) {
1118 if (news === undefined) return;
1119 if ("app" in news) {
1120 const imageMode = {
1121 image: news.image,
1122 containers: news.containers,
1123 init: news.init,
1124 };
1125 const imageModeResolved =
1126 isResolved<Pick<MachineProps, "image" | "containers" | "init">>(
1127 imageMode,
1128 );
1129 if (imageModeResolved) yield* validateMachineContainers(imageMode);
1130 const settings: Input<
1131 Pick<
1132 MachineProps,
1133 | "deploy"
1134 | "shutdown"
1135 | "checks"
1136 | "services"
1137 | "mounts"
1138 | "skipLaunch"
1139 | "autoDestroy"
1140 | "restart"
1141 | "containers"
1142 >
1143 > = {
1144 deploy: news.deploy,
1145 shutdown: news.shutdown,
1146 checks: news.checks,
1147 services: news.services,
1148 mounts: news.mounts,
1149 skipLaunch: news.skipLaunch,
1150 autoDestroy: news.autoDestroy,
1151 restart: news.restart,
1152 containers: news.containers,
1153 };
1154 if (
1155 imageModeResolved &&
1156 isResolved<
1157 Pick<
1158 MachineProps,
1159 | "deploy"
1160 | "shutdown"
1161 | "checks"
1162 | "services"
1163 | "mounts"
1164 | "skipLaunch"
1165 | "autoDestroy"
1166 | "restart"
1167 | "containers"
1168 >
1169 >(settings)
1170 ) {
1171 yield* validateDeployment(
1172 yield* deploymentPolicy(settings.deploy, settings.shutdown),
1173 {
1174 services: settings.services?.map(toFlyService),
1175 checks: settings.checks,
1176 auto_destroy: settings.autoDestroy,
1177 restart: settings.restart,
1178 containers:
1179 settings.containers === undefined
1180 ? undefined
1181 : toFlyContainers(settings.containers),
1182 },
1183 (settings.mounts?.length ?? 0) > 0,
1184 settings.skipLaunch,
1185 );
1186 }
1187 }
1188 if (!isResolved(news))
1189 return output?.rolloutPending
1190 ? { action: "update" as const }
1191 : undefined;
1192 yield* validateMachineContainers(news);
1193 if (output === undefined) return undefined;
1194 const desiredAppName = appNameOf(news.app);
1195 const appChanged =
1196 desiredAppName !== undefined && desiredAppName !== output.appName;
1197 const desiredName =
1198 news.name !== undefined
1199 ? sanitizeFlyAppName(news.name)
1200 : (output.baseName ?? output.name);
1201 const nameChanged = desiredName !== (output.baseName ?? output.name);
1202 const desiredRegion = news.region ?? DEFAULT_REGION;
1203 const regionChanged = desiredRegion !== output.region;
1204 if (appChanged || nameChanged || regionChanged) {
1205 return {
1206 action: "replace" as const,
1207 // Name is unique per App — same name in a new region cannot coexist.
1208 deleteFirst: nameChanged === false && appChanged === false,
1209 };
1210 }
1211 return output.rolloutPending ? { action: "update" as const } : undefined;
1212 }),
1213
1214 read: Effect.fn(function* ({ id, fqn, instanceId, olds, output }) {
1215 const appName = appNameOf(olds?.app) ?? output?.appName;
1216 const name = yield* resolveMachineName(
1217 id,
1218 olds?.name,
1219 output?.baseName ?? output?.name,
1220 );
1221 const found = yield* observeReplicaSet({
1222 appName,
1223 id,
1224 type: "Fly.Machine",
1225 fqn,
1226 resourceInstanceId: instanceId,
1227 machineIds: machineIdsOf(output),
1228 baseName: name,
1229 });
1230 if (found === undefined) return undefined;
1231 return toAttrs(found);
1232 }),
1233
1234 list: Effect.fn(function* () {
1235 const sets = yield* listReplicaSets("Fly.Machine");
1236 return sets.map(toAttrs);
1237 }),
1238
1239 reconcile: Effect.fn(function* ({
1240 id,
1241 fqn,
1242 instanceId,
1243 news,
1244 output,
1245 bindings,
1246 }) {
1247 const props = news;
1248 yield* validateMachineContainers(props);
1249 const policy = yield* deploymentPolicy(props.deploy, props.shutdown);
1250 const appName = appNameOf(props.app) ?? output?.appName;
1251 if (appName === undefined) {
1252 return yield* new MachineAppNotResolved({
1253 message: "Fly.Machine requires a resolved App with appName.",
1254 });
1255 }
1256 const name = yield* resolveMachineName(
1257 id,
1258 props.name,
1259 output?.baseName ?? output?.name,
1260 );
1261 const region = props.region ?? output?.region ?? DEFAULT_REGION;
1262 const count = resolveCount(props.count);
1263 const skipLaunch = props.skipLaunch === true;
1264 const bound = mergeBindings(bindings ?? []);
1265 const disks = mergeDisks(props.mounts, bound.mounts);
1266 const env = desiredEnv(props, bound.env);
1267 const guest = toFlyGuest(props.guest);
1268 const services = props.services?.map(toFlyService);
1269 const restart = props.restart ? toFlyRestart(props.restart) : undefined;
1270 const init = props.init ? toFlyInit(props.init) : undefined;
1271 const containers =
1272 props.containers === undefined
1273 ? undefined
1274 : toFlyContainers(props.containers);
1275
1276 const set = yield* reconcileReplicas({
1277 id,
1278 type: "Fly.Machine",
1279 fqn,
1280 resourceInstanceId: instanceId,
1281 policy,
1282 checks: props.checks,
1283 appName,
1284 baseName: name,
1285 regions: [region],
1286 count,
1287 disks,
1288 skipLaunch,
1289 minSecretsVersion: props.minSecretsVersion,
1290 outputMachineIds: machineIdsOf(output),
1291 preferVolumeIds: (output?.replicas ?? []).map((replica) =>
1292 replica.mounts.map((mount) => mount.volumeId),
1293 ),
1294 configDrifted: (machine, desired) =>
1295 configDrifted(machine, {
1296 image: props.image,
1297 containers,
1298 guest,
1299 env,
1300 services,
1301 mounts: desired.mounts,
1302 metadata: desiredMetadata(props, desired.metadata),
1303 restart,
1304 autoDestroy: props.autoDestroy,
1305 init,
1306 }),
1307 buildConfig: ({ mounts, metadata }) =>
1308 buildConfig({
1309 image: props.image,
1310 containers,
1311 guest,
1312 env,
1313 services,
1314 mounts,
1315 metadata: desiredMetadata(props, metadata),
1316 restart,
1317 autoDestroy: props.autoDestroy,
1318 init,
1319 }),
1320 }).pipe(
1321 Effect.catchTag("Fly.ReplicaNotCreated", (error) =>
1322 Effect.fail(
1323 new MachineNotCreated({ name: error.name, appName: error.appName }),
1324 ),
1325 ),
1326 );
1327 return toAttrs(set);
1328 }),
1329
1330 delete: Effect.fn(function* ({ id, fqn, instanceId, olds, output, force }) {
1331 const appName = output.appName ?? appNameOf(olds.app);
1332 if (appName === undefined) return;
1333 yield* deleteReplicaSet({
1334 appName,
1335 id,
1336 type: "Fly.Machine",
1337 fqn,
1338 resourceInstanceId: instanceId,
1339 machineIds: machineIdsOf(output),
1340 volumeIds: volumeIdsOf(output),
1341 force,
1342 });
1343 }),
1344 });