1import * as Effect from "effect/Effect";
2import { Unowned } from "../AdoptPolicy.ts";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import { Docker, dockerContextName } from "./Docker.ts";
7import type { Providers } from "./Providers.ts";
8
9export interface SwarmProps {
10 /** Docker context name or context resource of the engine to initialize. */
11 context?: Docker.ContextRef;
12 /**
13 * Address advertised to other swarm members, e.g. `"10.0.0.1"` or
14 * `"eth0"`. Required by Docker when the host has several network
15 * interfaces. Applied only when the swarm is first initialized.
16 */
17 advertiseAddr?: string;
18 /**
19 * Listen address for swarm traffic, e.g. `"0.0.0.0:2377"`. Applied only
20 * when the swarm is first initialized.
21 */
22 listenAddr?: string;
23 /**
24 * Address pools for global-scope (overlay) networks, e.g.
25 * `["10.20.0.0/16"]`. Applied only when the swarm is first initialized.
26 */
27 defaultAddrPool?: string[];
28 /**
29 * CIDR mask length for each subnet carved from `defaultAddrPool`.
30 * Applied only when the swarm is first initialized.
31 *
32 * @default 24
33 */
34 subnetSize?: number;
35}
36
37export interface Swarm extends Resource<
38 "Docker.Swarm",
39 SwarmProps,
40 {
41 /** Swarm cluster id. */
42 id: string;
43 /** This node's id in the swarm (a manager). */
44 nodeId: string;
45 /** Docker context the swarm was initialized on. */
46 context?: string;
47 /** Number of manager nodes. */
48 managers: number;
49 /** Number of nodes in the swarm. */
50 nodes: number;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * Swarm mode on a Docker engine — an idempotent `docker swarm init`.
58 *
59 * Turns the engine behind the given `context` (or the local engine) into a
60 * single-node swarm: the node becomes a manager that can run `Docker.Service`
61 * workloads. Regular (non-swarm) docker usage of the engine is unaffected.
62 *
63 * Pass the swarm as the `context` of a `Docker.Service` or overlay
64 * `Docker.Network`: the workload then deploys after the swarm exists and
65 * inherits its Docker context.
66 *
67 * An engine that is already in swarm mode is treated as foreign — adopt it
68 * with `adopt(true)` (or `--adopt`) to manage it. Destroying the resource
69 * dissolves the node's swarm membership (`docker swarm leave --force`),
70 * which stops every service running on it — services managed by the same
71 * stack are destroyed first via their dependency edges.
72 *
73 * Growing the cluster beyond one node is host-level setup: run
74 * `docker swarm join --token <token> <manager-ip>:2377` on each additional
75 * machine (`docker swarm join-token worker` on the manager prints the
76 * command). Alchemy manages the swarm's workloads through the manager.
77 * ### Creating a Swarm
78 * **Example:** Local single-node swarm
79 * ```typescript
80 * const swarm = yield* Docker.Swarm("swarm");
81 * ```
82 *
83 * **Example:** Remote engine over SSH
84 * ```typescript
85 * const vps = yield* Docker.Context("vps", {
86 * docker: "host=ssh://deploy@example.com",
87 * });
88 * const swarm = yield* Docker.Swarm("swarm", {
89 * context: vps,
90 * advertiseAddr: "10.0.0.1",
91 * });
92 * ```
93 *
94 * ### Using an Existing Swarm
95 * **Example:** Reference without owning
96 * ```typescript
97 * // Services don't require a Swarm resource — point them at an engine that
98 * // is already a manager and the swarm's lifecycle stays external: destroy
99 * // removes the services, never the swarm.
100 * const web = yield* Docker.Service("web", {
101 * context: vps,
102 * image: "nginx:alpine",
103 * });
104 * ```
105 *
106 * **Example:** Adopt an already-initialized engine
107 * ```typescript
108 * // Adoption makes the swarm part of the stack — destroy then dissolves
109 * // the node's membership.
110 * const swarm = yield* Docker.Swarm("swarm").pipe(adopt(true));
111 * ```
112 *
113 * ### Deploying into the Swarm
114 * **Example:** Service ordered after the swarm
115 * ```typescript
116 * const swarm = yield* Docker.Swarm("swarm");
117 * const web = yield* Docker.Service("web", {
118 * context: swarm,
119 * image: "nginx:alpine",
120 * replicas: 2,
121 * ports: [{ external: 8080, internal: 80 }],
122 * });
123 * ```
124 *
125 * @resource
126 * @product Swarm
127 */
128export const Swarm = Resource<Swarm>("Docker.Swarm");
129
130export const SwarmProvider = () =>
131 Provider.effect(
132 Swarm,
133 Effect.gen(function* () {
134 const docker = yield* Docker;
135
136 /** Active-manager info, or undefined when the engine isn't one. */
137 const observe = Effect.fn(function* (context: string | undefined) {
138 const info = yield* docker.swarm.info(context);
139 if (
140 info.LocalNodeState !== "active" ||
141 !info.ControlAvailable ||
142 !info.Cluster?.ID
143 ) {
144 return undefined;
145 }
146 return info;
147 });
148
149 return Swarm.Provider.of({
150 list: () => Effect.succeed([]),
151 read: Effect.fn(function* ({ olds, output }) {
152 const context = dockerContextName(olds?.context);
153 const info = yield* observe(context);
154 if (!info) return undefined;
155 const attrs = toSwarmAttributes(info, context);
156 if (output) return attrs;
157 // A swarm carries no labels to brand, so an already-initialized
158 // engine can never be proven ours — adoption is gated behind
159 // `--adopt` / `adopt(true)`.
160 return Unowned(attrs);
161 }),
162 diff: Effect.fn(function* ({ news, olds }) {
163 if (!isResolved(news)) return undefined;
164 if (
165 dockerContextName(olds?.context) !==
166 dockerContextName(news?.context)
167 ) {
168 return { action: "replace" as const, deleteFirst: true };
169 }
170 // The remaining props only apply at `swarm init`; changing them on
171 // a live swarm is a no-op rather than a destructive re-init.
172 return { action: "noop" as const };
173 }),
174 reconcile: Effect.fn(function* ({ news }) {
175 const context = dockerContextName(news?.context);
176 const existing = yield* observe(context);
177 if (existing) return toSwarmAttributes(existing, context);
178
179 yield* docker.swarm
180 .init({
181 context,
182 "advertise-addr": news?.advertiseAddr,
183 "listen-addr": news?.listenAddr,
184 "default-addr-pool": news?.defaultAddrPool,
185 "default-addr-pool-mask-length": news?.subnetSize,
186 })
187 .pipe(
188 // A concurrent init (or an interrupted prior reconcile) already
189 // made this node a swarm member — converge on the observation.
190 Effect.catchIf(
191 (error) => /already part of a swarm/i.test(String(error)),
192 () => Effect.void,
193 ),
194 );
195
196 const info = yield* observe(context);
197 if (!info) {
198 return yield* Effect.die(
199 new Error(
200 "docker swarm init succeeded but the engine did not become an active swarm manager",
201 ),
202 );
203 }
204 return toSwarmAttributes(info, context);
205 }),
206 delete: Effect.fn(({ output }) =>
207 docker.swarm.leave(true, output.context).pipe(
208 Effect.catchIf(
209 (error) => /not part of a swarm/i.test(String(error)),
210 () => Effect.void,
211 ),
212 Effect.asVoid,
213 ),
214 ),
215 });
216 }),
217 );
218
219const toSwarmAttributes = (
220 info: Docker.SwarmInfo,
221 context: string | undefined,
222): Swarm["Attributes"] => ({
223 id: info.Cluster!.ID,
224 nodeId: info.NodeID,
225 context,
226 managers: info.Managers ?? 1,
227 nodes: info.Nodes ?? 1,
228});