| 1 | import type { |
| 2 | FlyMachineConfig, |
| 3 | FlyMachineGuest, |
| 4 | FlyMachineInit, |
| 5 | FlyMachineMount, |
| 6 | FlyMachineRestart, |
| 7 | FlyMachineService, |
| 8 | Machine as FlyMachine, |
| 9 | } from "@distilled.cloud/fly-io/machines"; |
| 10 | import * as Data from "effect/Data"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | |
| 13 | import type { Input } from "../Input.ts"; |
| 14 | import type { DiskSpec, MountedDisk, ServiceBinding } from "./MountVolume.ts"; |
| 15 | import type { Providers } from "./Providers.ts"; |
| 16 | |
| 17 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 18 | import * as Provider from "../Provider.ts"; |
| 19 | import { Resource, type ResourceBinding } from "../Resource.ts"; |
| 20 | import { App } from "./App.ts"; |
| 21 | import { |
| 22 | deploymentPolicy, |
| 23 | validateDeployment, |
| 24 | type MachineDeploy, |
| 25 | type MachineShutdown, |
| 26 | type MachineCheck, |
| 27 | } from "./Deployment.ts"; |
| 28 | import { toEnvRecord } from "./hosted.ts"; |
| 29 | import { |
| 30 | sameContainerWorkload, |
| 31 | toFlyContainers, |
| 32 | validateMachineContainers, |
| 33 | } from "./MachineContainers.ts"; |
| 34 | import { |
| 35 | createFlyResourceName, |
| 36 | diffMachineMetadata, |
| 37 | sanitizeFlyAppName, |
| 38 | } from "./Metadata.ts"; |
| 39 | import { |
| 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 | |
| 52 | export type { Replica }; |
| 53 | |
| 54 | const DEFAULT_REGION = "iad"; |
| 55 | const DEFAULT_CPU_KIND = "shared"; |
| 56 | const DEFAULT_CPUS = 1; |
| 57 | const 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 | */ |
| 63 | type Ref<T> = T | Effect.Effect<T, never, Providers>; |
| 64 | |
| 65 | export 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 | |
| 90 | export 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 | |
| 103 | export 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 | |
| 112 | export 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. */ |
| 126 | export 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 | |
| 159 | export 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 | |
| 180 | export type MachineMount = DiskSpec; |
| 181 | |
| 182 | /** Startup dependency on another named container in the same Machine. */ |
| 183 | export 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. */ |
| 191 | export 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. */ |
| 230 | export 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 | |
| 247 | export 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 | |
| 330 | export 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 | |
| 345 | export type MachineImageRef = { |
| 346 | registry?: string; |
| 347 | repository?: string; |
| 348 | tag?: string; |
| 349 | digest?: string; |
| 350 | }; |
| 351 | |
| 352 | export 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 | */ |
| 850 | export const Machine = Resource<Machine>("Fly.Machine"); |
| 851 | |
| 852 | export class MachineNotCreated extends Data.TaggedError( |
| 853 | "Fly.MachineNotCreated", |
| 854 | )<{ |
| 855 | name: string; |
| 856 | appName: string; |
| 857 | }> {} |
| 858 | |
| 859 | export class MachineAppNotResolved extends Data.TaggedError( |
| 860 | "Fly.MachineAppNotResolved", |
| 861 | )<{ |
| 862 | message: string; |
| 863 | }> {} |
| 864 | |
| 865 | type MachineBinding = Machine["Binding"]; |
| 866 | |
| 867 | const 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 | |
| 873 | const 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 | |
| 882 | const toEnv = toEnvRecord; |
| 883 | |
| 884 | const 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 | |
| 895 | const 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 | |
| 907 | const 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 | |
| 918 | const 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 | |
| 929 | const 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 | |
| 937 | const toFlyRestart = (restart: MachineRestart): FlyMachineRestart => ({ |
| 938 | policy: restart.policy, |
| 939 | max_retries: restart.maxRetries, |
| 940 | }); |
| 941 | |
| 942 | const desiredEnv = ( |
| 943 | props: MachineProps, |
| 944 | bindingEnv: Record<string, any>, |
| 945 | ): Record<string, string> => ({ ...toEnv(props.env), ...toEnv(bindingEnv) }); |
| 946 | |
| 947 | const desiredMetadata = ( |
| 948 | props: MachineProps, |
| 949 | alchemy: Record<string, string>, |
| 950 | ): Record<string, string> => ({ ...(props.metadata ?? {}), ...alchemy }); |
| 951 | |
| 952 | const 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 | |
| 979 | const 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 | |
| 993 | const 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 | |
| 1003 | const sameEnv = ( |
| 1004 | observed: Record<string, string | undefined> | undefined, |
| 1005 | desired: Record<string, string>, |
| 1006 | ) => deepEqual(compactRecord(observed), desired); |
| 1007 | |
| 1008 | const 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 | |
| 1019 | const 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 | |
| 1035 | const sameInit = ( |
| 1036 | observed: FlyMachineInit | undefined, |
| 1037 | desired: FlyMachineInit | undefined, |
| 1038 | ) => deepEqual(observed ?? {}, desired ?? {}, { stripNullish: true }); |
| 1039 | |
| 1040 | const 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 | |
| 1055 | const 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 | |
| 1088 | const 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 | |
| 1106 | const machineIdsOf = (output: Machine["Attributes"] | undefined) => |
| 1107 | output?.machineIds ?? |
| 1108 | (output?.machineId !== undefined && output.machineId.length > 0 |
| 1109 | ? [output.machineId] |
| 1110 | : []); |
| 1111 | |
| 1112 | export 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 | }); |