1import type {
2 FlyContainerConfig,
3 FlyMachineConfig,
4} from "@distilled.cloud/fly-io/machines";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7
8import type { MachineContainer, MachineProps } from "./Machine.ts";
9
10import { deepEqual } from "../Diff.ts";
11
12export class InvalidMachineContainers extends Data.TaggedError(
13 "Fly.InvalidMachineContainers",
14)<{
15 message: string;
16}> {}
17
18/** Validate resolved inputs before any Machine or Volume is changed. */
19export const validateMachineContainers = (
20 props: Pick<MachineProps, "image" | "containers" | "init">,
21) => {
22 const image = "image" in props ? props.image : undefined;
23 const containers = "containers" in props ? props.containers : undefined;
24 if ((image !== undefined) === (containers !== undefined))
25 return Effect.fail(
26 new InvalidMachineContainers({
27 message: "Fly.Machine requires exactly one of image or containers.",
28 }),
29 );
30 if (image !== undefined)
31 return typeof image === "string" && image.trim().length > 0
32 ? Effect.void
33 : Effect.fail(
34 new InvalidMachineContainers({ message: "image must be nonempty." }),
35 );
36 if (!Array.isArray(containers) || containers.length === 0)
37 return Effect.fail(
38 new InvalidMachineContainers({
39 message: "containers must be a nonempty array.",
40 }),
41 );
42 if (props.init !== undefined)
43 return Effect.fail(
44 new InvalidMachineContainers({
45 message: "init is only supported with image.",
46 }),
47 );
48 const names = new Set<string>();
49 for (const container of containers) {
50 if (
51 container == null ||
52 typeof container.name !== "string" ||
53 !container.name.trim() ||
54 typeof container.image !== "string" ||
55 !container.image.trim()
56 )
57 return Effect.fail(
58 new InvalidMachineContainers({
59 message: "Every container needs a nonempty name and image.",
60 }),
61 );
62 if ("exec" in container && container.exec !== undefined)
63 return Effect.fail(
64 new InvalidMachineContainers({
65 message: `Container ${container.name}: use cmd and entrypoint; native container exec overrides are not supported.`,
66 }),
67 );
68 if (names.has(container.name))
69 return Effect.fail(
70 new InvalidMachineContainers({
71 message: `Duplicate container name: ${container.name}.`,
72 }),
73 );
74 names.add(container.name);
75 }
76 for (const container of containers)
77 for (const dependency of container.dependsOn ?? [])
78 if (
79 dependency == null ||
80 typeof dependency.name !== "string" ||
81 !names.has(dependency.name)
82 )
83 return Effect.fail(
84 new InvalidMachineContainers({
85 message: `Container ${container.name} depends on an undeclared container.`,
86 }),
87 );
88 return Effect.void;
89};
90
91export const toFlyContainers = (
92 containers: MachineContainer[],
93): FlyContainerConfig[] =>
94 containers.map((container) => ({
95 name: container.name,
96 image: container.image,
97 cmd: container.cmd,
98 entrypoint: container.entrypoint,
99 env: container.env,
100 depends_on: container.dependsOn?.map(({ name, condition }) => ({
101 name,
102 condition,
103 })),
104 healthchecks: container.healthChecks?.map((check) => ({
105 name: check.name,
106 kind: check.kind,
107 interval: check.interval,
108 timeout: check.timeout,
109 grace_period: check.gracePeriod,
110 success_threshold: check.successThreshold,
111 failure_threshold: check.failureThreshold,
112 http: check.http && {
113 port: check.http.port,
114 path: check.http.path,
115 method: check.http.method,
116 scheme: check.http.scheme,
117 headers: check.http.headers,
118 tls_server_name: check.http.tlsServerName,
119 tls_skip_verify: check.http.tlsSkipVerify,
120 },
121 tcp: check.tcp,
122 exec: check.exec,
123 })),
124 }));
125
126/** Canonical semantic config shared by drift detection and generation identity. */
127export const canonicalContainers = (
128 containers: FlyContainerConfig[] | undefined,
129) =>
130 (containers ?? [])
131 .map((container) => ({
132 name: container.name,
133 image: container.image,
134 cmd: container.cmd,
135 entrypoint: container.entrypoint,
136 env: Object.fromEntries(
137 Object.entries(container.env ?? {}).filter(
138 ([, value]) => value !== undefined,
139 ),
140 ),
141 depends_on: [...(container.depends_on ?? [])]
142 .map(({ name, condition }) => ({ name, condition }))
143 .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
144 healthchecks: (container.healthchecks ?? []).map((check) => ({
145 name: check.name,
146 kind: check.kind,
147 interval: check.interval,
148 timeout: check.timeout,
149 grace_period: check.grace_period,
150 success_threshold: check.success_threshold,
151 failure_threshold: check.failure_threshold,
152 http: check.http,
153 tcp: check.tcp,
154 exec: check.exec,
155 })),
156 }))
157 .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
158
159export const sameContainers = (
160 observed: FlyContainerConfig[] | undefined,
161 desired: FlyContainerConfig[] | undefined,
162) =>
163 deepEqual(canonicalContainers(observed), canonicalContainers(desired), {
164 stripNullish: true,
165 });
166
167/** Fly may synthesize a top-level image from the first container on readback. */
168export const sameContainerWorkload = (
169 observed: FlyMachineConfig | undefined,
170 desired: FlyContainerConfig[],
171) => sameContainers(observed?.containers, desired);