1import type {
2 FlyMachineConfig,
3 FlyStopConfigSignal,
4} from "@distilled.cloud/fly-io/machines";
5import * as Data from "effect/Data";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import type { MachineServiceCheck } from "./Machine.ts";
9import { pinsFromConfig } from "./DeploymentImages.ts";
10
11/** Replacement/readiness policy shared by Machines, Services, and websites; not an App-wide deployment lock. */
12export 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. */
20export 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. */
28export interface MachineCheck extends MachineServiceCheck {
29 /** Port inside the Machine to check. */
30 port: number;
31}
32
33export class InvalidDeployment extends Data.TaggedError(
34 "Fly.InvalidDeployment",
35)<{
36 message: string;
37}> {}
38
39export 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
51const 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
70export 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
117export 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};