| 1 | import * as Effect from "effect/Effect"; |
| 2 | import { Unowned } from "../AdoptPolicy.ts"; |
| 3 | import { isResolved } from "../Diff.ts"; |
| 4 | import * as Provider from "../Provider.ts"; |
| 5 | import { Resource } from "../Resource.ts"; |
| 6 | import { Docker, dockerContextName } from "./Docker.ts"; |
| 7 | import type { Providers } from "./Providers.ts"; |
| 8 | |
| 9 | export 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 | |
| 37 | export 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 | */ |
| 128 | export const Swarm = Resource<Swarm>("Docker.Swarm"); |
| 129 | |
| 130 | export 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 | |
| 219 | const 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 | }); |