| 1 | import type { |
| 2 | FlyMachineConfig, |
| 3 | FlyStopConfigSignal, |
| 4 | } from "@distilled.cloud/fly-io/machines"; |
| 5 | import * as Data from "effect/Data"; |
| 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import type { MachineServiceCheck } from "./Machine.ts"; |
| 9 | import { pinsFromConfig } from "./DeploymentImages.ts"; |
| 10 | |
| 11 | /** Replacement/readiness policy shared by Machines, Services, and websites; not an App-wide deployment lock. */ |
| 12 | export interface MachineDeploy { |
| 13 | /** Sequential in-place updates, or a checked replacement set. @default "rolling" */ |
| 14 | strategy: "rolling" | "bluegreen"; |
| 15 | /** Readiness deadline after startup, positive and at most 300 seconds. A deadline-only change does not replace Machines. @default "60 seconds" */ |
| 16 | healthTimeout?: Duration.Input; |
| 17 | } |
| 18 | |
| 19 | /** Process shutdown policy. Retirement uses the predecessor's own policy; raw images and external website servers own signal handling and drain. */ |
| 20 | export interface MachineShutdown { |
| 21 | /** Signal sent before termination. Managed Services support SIGTERM and SIGINT. @default "SIGTERM" */ |
| 22 | signal?: Exclude<FlyStopConfigSignal, "SIGKILL">; |
| 23 | /** Grace period, greater than zero and at most 300 seconds. @default "30 seconds" */ |
| 24 | timeout?: Duration.Input; |
| 25 | } |
| 26 | |
| 27 | /** Named readiness check for a worker without public proxy ports. */ |
| 28 | export interface MachineCheck extends MachineServiceCheck { |
| 29 | /** Port inside the Machine to check. */ |
| 30 | port: number; |
| 31 | } |
| 32 | |
| 33 | export class InvalidDeployment extends Data.TaggedError( |
| 34 | "Fly.InvalidDeployment", |
| 35 | )<{ |
| 36 | message: string; |
| 37 | }> {} |
| 38 | |
| 39 | export interface DeploymentPolicy { |
| 40 | bluegreen: boolean; |
| 41 | healthTimeoutMs: number; |
| 42 | shutdown: |
| 43 | | { |
| 44 | signal: Exclude<FlyStopConfigSignal, "SIGKILL">; |
| 45 | timeout: string; |
| 46 | timeoutMs: number; |
| 47 | } |
| 48 | | undefined; |
| 49 | } |
| 50 | |
| 51 | const duration = (value: Duration.Input, name: string, maximum: number) => |
| 52 | Effect.try({ |
| 53 | try: () => { |
| 54 | const milliseconds = Duration.toMillis(value); |
| 55 | if ( |
| 56 | !Number.isSafeInteger(milliseconds) || |
| 57 | milliseconds <= 0 || |
| 58 | milliseconds > maximum |
| 59 | ) { |
| 60 | throw new Error("invalid duration"); |
| 61 | } |
| 62 | return milliseconds; |
| 63 | }, |
| 64 | catch: () => |
| 65 | new InvalidDeployment({ |
| 66 | message: `${name} must be a finite positive duration no greater than ${maximum / 1000} seconds.`, |
| 67 | }), |
| 68 | }); |
| 69 | |
| 70 | export const deploymentPolicy = Effect.fn(function* ( |
| 71 | deploy: MachineDeploy | undefined, |
| 72 | shutdown: MachineShutdown | undefined, |
| 73 | managed = false, |
| 74 | ) { |
| 75 | const bluegreen = deploy?.strategy === "bluegreen"; |
| 76 | const healthTimeoutMs = yield* duration( |
| 77 | deploy?.healthTimeout ?? "60 seconds", |
| 78 | "deploy.healthTimeout", |
| 79 | 300_000, |
| 80 | ); |
| 81 | const enabled = bluegreen || shutdown !== undefined; |
| 82 | const signal = shutdown?.signal ?? "SIGTERM"; |
| 83 | if ( |
| 84 | enabled && |
| 85 | !["SIGHUP", "SIGINT", "SIGQUIT", "SIGUSR1", "SIGUSR2", "SIGTERM"].includes( |
| 86 | signal, |
| 87 | ) |
| 88 | ) { |
| 89 | return yield* new InvalidDeployment({ |
| 90 | message: |
| 91 | "shutdown.signal must be a supported graceful Fly signal, not SIGKILL.", |
| 92 | }); |
| 93 | } |
| 94 | if (managed && enabled && signal !== "SIGTERM" && signal !== "SIGINT") { |
| 95 | return yield* new InvalidDeployment({ |
| 96 | message: |
| 97 | "Managed Fly Services support SIGTERM and SIGINT shutdown signals.", |
| 98 | }); |
| 99 | } |
| 100 | const timeoutMs = enabled |
| 101 | ? yield* duration( |
| 102 | shutdown?.timeout ?? "30 seconds", |
| 103 | "shutdown.timeout", |
| 104 | 300_000, |
| 105 | ) |
| 106 | : undefined; |
| 107 | return { |
| 108 | bluegreen, |
| 109 | healthTimeoutMs, |
| 110 | shutdown: |
| 111 | timeoutMs === undefined |
| 112 | ? undefined |
| 113 | : { signal, timeout: `${timeoutMs}ms`, timeoutMs }, |
| 114 | } satisfies DeploymentPolicy; |
| 115 | }); |
| 116 | |
| 117 | export const validateDeployment = ( |
| 118 | policy: DeploymentPolicy, |
| 119 | config: FlyMachineConfig, |
| 120 | mounted: boolean, |
| 121 | skipLaunch = false, |
| 122 | ) => { |
| 123 | if (!policy.bluegreen) return Effect.void; |
| 124 | let message: string | undefined; |
| 125 | if (mounted) |
| 126 | message = |
| 127 | "Blue/green deployments cannot attach volumes, including MountVolume bindings."; |
| 128 | else if ( |
| 129 | config.containers !== undefined && |
| 130 | pinsFromConfig(config) === undefined |
| 131 | ) |
| 132 | message = |
| 133 | "Blue/green multi-container Machines require unique names and immutable repository@sha256 image references for every container."; |
| 134 | else if (skipLaunch || config.auto_destroy || config.restart?.policy === "no") |
| 135 | message = "Blue/green deployments require a persistent, launched Machine."; |
| 136 | else if ( |
| 137 | (config.services ?? []).some( |
| 138 | (service) => (service.ports?.length ?? 0) > 0 && !service.checks?.length, |
| 139 | ) |
| 140 | ) |
| 141 | message = |
| 142 | "Every published service needs a service health check for blue/green deployment."; |
| 143 | else if ( |
| 144 | !(config.services ?? []).some((service) => service.checks?.length) && |
| 145 | !Object.keys(config.checks ?? {}).length |
| 146 | ) |
| 147 | message = "Blue/green deployments require readiness checks."; |
| 148 | return message |
| 149 | ? Effect.fail(new InvalidDeployment({ message })) |
| 150 | : Effect.void; |
| 151 | }; |