1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Docker from "@/Docker";
3import { inMemoryState } from "@/State";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Cause from "effect/Cause";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({
11 providers: Docker.providers(),
12 state: inMemoryState(),
13});
14
15// Docker.Swarm mutates engine-level state (swarm membership), so these tests
16// never touch the host engine. Each test boots a disposable docker:dind
17// engine, points a Docker.Context at it, and provisions the swarm there —
18// the exact remote-VPS flow, on a throwaway daemon.
19const startDind = Effect.fn(function* (name: string, port: number) {
20 const docker = yield* Docker.Docker;
21 const host = `tcp://127.0.0.1:${port}`;
22
23 yield* Effect.addFinalizer(() =>
24 docker.run(["rm", "-f", name]).pipe(Effect.ignore),
25 );
26 yield* docker.run(["rm", "-f", name]).pipe(Effect.ignore);
27 yield* docker.run([
28 "run",
29 "-d",
30 "--rm",
31 "--privileged",
32 "--name",
33 name,
34 "-e",
35 "DOCKER_TLS_CERTDIR=",
36 "-p",
37 `127.0.0.1:${port}:2375`,
38 "docker:dind",
39 ]);
40
41 // The inner daemon takes a few seconds to boot before it serves the API.
42 yield* docker
43 .run(["-H", host, "info", "--format", "{{.ServerVersion}}"])
44 .pipe(Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }));
45
46 return { host };
47});
48
49describe(
50 "Docker.Swarm",
51 {
52 tags: ["provider:docker", "provider:docker:swarm", "local"],
53 concurrent: false,
54 },
55 () => {
56 test.provider.skipIf(!!process.env.FAST)(
57 "initializes a swarm on a fresh engine and deploys a service into it",
58 (stack) =>
59 Effect.gen(function* () {
60 const docker = yield* Docker.Docker;
61 const { host } = yield* startDind("alchemy-test-dind-swarm", 23750);
62
63 const program = Effect.gen(function* () {
64 const context = yield* Docker.Context("dind-context", {
65 name: "alchemy-test-dind-swarm-ctx",
66 docker: `host=${host}`,
67 });
68 const swarm = yield* Docker.Swarm("swarm", {
69 context,
70 advertiseAddr: "127.0.0.1",
71 });
72 const service = yield* Docker.Service("web", {
73 context: swarm,
74 image: "nginx:alpine",
75 replicas: 1,
76 });
77 return { swarm, service };
78 });
79
80 const { swarm, service } = yield* stack.deploy(program);
81
82 expect(swarm.id.length).toBeGreaterThan(0);
83 expect(swarm.nodeId.length).toBeGreaterThan(0);
84 expect(swarm.context).toBe("alchemy-test-dind-swarm-ctx");
85 expect(swarm.managers).toBe(1);
86 expect(swarm.nodes).toBe(1);
87 // The service inherited the swarm's context and landed on the dind
88 // engine, ordered after the swarm init.
89 expect(service.context).toBe("alchemy-test-dind-swarm-ctx");
90 expect(service.replicas).toBe(1);
91
92 // Idempotent: a second deploy converges on the same cluster.
93 const again = yield* stack.deploy(program);
94 expect(again.swarm.id).toBe(swarm.id);
95 expect(again.service.id).toBe(service.id);
96
97 yield* stack.destroy();
98
99 // Destroy dissolved the swarm (service first, then leave) and
100 // removed the context.
101 const state = yield* docker.run([
102 "-H",
103 host,
104 "info",
105 "--format",
106 "{{.Swarm.LocalNodeState}}",
107 ]);
108 expect(state.stdout).toBe("inactive");
109 const contextGone = yield* docker.context
110 .inspect("alchemy-test-dind-swarm-ctx")
111 .pipe(
112 Effect.map(() => false),
113 Effect.catchReason("PlatformError", "NotFound", () =>
114 Effect.succeed(true),
115 ),
116 );
117 expect(contextGone).toBe(true);
118 }),
119 {
120 tags: ["provider:docker:context", "provider:docker:service"],
121 timeout: 300_000,
122 },
123 );
124
125 test.provider.skipIf(!!process.env.FAST)(
126 "refuses an already-initialized engine unless explicitly adopted",
127 (stack) =>
128 Effect.gen(function* () {
129 const docker = yield* Docker.Docker;
130 const { host } = yield* startDind("alchemy-test-dind-adopt", 23751);
131
132 // The engine is already a swarm manager — initialized out of band,
133 // reachable through an out-of-band context. The Swarm resource's
134 // props must be fully resolved (a plain context name, not a
135 // same-plan Context resource) for the engine's adoption probe to
136 // run at plan time.
137 yield* docker.run([
138 "-H",
139 host,
140 "swarm",
141 "init",
142 "--advertise-addr",
143 "127.0.0.1",
144 ]);
145 const contextName = "alchemy-test-dind-adopt-ctx";
146 yield* Effect.addFinalizer(() =>
147 docker.context.remove(contextName, true).pipe(Effect.ignore),
148 );
149 yield* docker.context.remove(contextName, true).pipe(Effect.ignore);
150 yield* docker.context.create({
151 name: contextName,
152 docker: `host=${host}`,
153 });
154
155 const makeSwarm = (adopted: boolean) =>
156 Effect.gen(function* () {
157 const swarm = Docker.Swarm("existing-swarm", {
158 context: contextName,
159 });
160 return yield* adopted ? swarm.pipe(adopt(true)) : swarm;
161 });
162
163 const error = yield* stack.deploy(makeSwarm(false)).pipe(
164 Effect.as(undefined),
165 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
166 );
167 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
168
169 const adopted = yield* stack.deploy(makeSwarm(true));
170 expect(adopted.id.length).toBeGreaterThan(0);
171 expect(adopted.nodeId.length).toBeGreaterThan(0);
172
173 yield* stack.destroy();
174 }),
175 { timeout: 300_000 },
176 );
177 },
178);
179
180const findOwnedError = (
181 cause: Cause.Cause<unknown>,
182): OwnedBySomeoneElse | undefined =>
183 cause.reasons
184 .map((reason) =>
185 Cause.isFailReason(reason)
186 ? reason.error
187 : Cause.isDieReason(reason)
188 ? reason.defect
189 : undefined,
190 )
191 .find(
192 (value): value is OwnedBySomeoneElse =>
193 value instanceof OwnedBySomeoneElse,
194 );