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 { ensureDockerSwarm } from "./Runtime.ts";
9
10const { test } = Test.make({
11 providers: Docker.providers(),
12 state: inMemoryState(),
13});
14
15// Every test deploys real swarm services through the engine (plan → apply →
16// destroy) — no provider-method unit tests. The suite self-provisions a
17// single-node swarm on the local engine (`ensureDockerSwarm`), so it runs
18// anywhere Docker runs; deactivate afterwards with `docker swarm leave
19// --force` if you don't want the node to stay a swarm manager.
20describe(
21 "Docker.Service",
22 {
23 tags: ["provider:docker", "provider:docker:service", "local"],
24 concurrent: false,
25 },
26 () => {
27 test.provider(
28 "creates a replicated service with labels",
29 (stack) =>
30 Effect.gen(function* () {
31 yield* ensureDockerSwarm;
32 const serviceName = "alchemy-test-service-create";
33 const service = yield* stack.deploy(
34 Docker.Service("created-service", {
35 name: serviceName,
36 image: "nginx:alpine",
37 replicas: 1,
38 labels: { "com.alchemy.test": "true" },
39 }),
40 );
41
42 expect(service.name).toBe(serviceName);
43 expect(service.id.length).toBeGreaterThan(0);
44 expect(service.image).toContain("nginx:alpine");
45 expect(service.replicas).toBe(1);
46 expect(service.endpointMode).toBe("vip");
47 expect(service.labels["com.alchemy.test"]).toBe("true");
48
49 yield* stack.destroy();
50
51 const docker = yield* Docker.Docker;
52 const gone = yield* docker.service.inspect(service.id).pipe(
53 Effect.map(() => false),
54 Effect.catchReason("PlatformError", "NotFound", () =>
55 Effect.succeed(true),
56 ),
57 );
58 expect(gone).toBe(true);
59 }),
60 { timeout: 240_000 },
61 );
62
63 test.provider(
64 "replaces a service when replicas change",
65 (stack) =>
66 Effect.gen(function* () {
67 yield* ensureDockerSwarm;
68 const serviceName = "alchemy-test-service-replicas";
69
70 const first = yield* stack.deploy(
71 Docker.Service("scaled-service", {
72 name: serviceName,
73 image: "nginx:alpine",
74 replicas: 1,
75 }),
76 );
77 expect(first.replicas).toBe(1);
78
79 const second = yield* stack.deploy(
80 Docker.Service("scaled-service", {
81 name: serviceName,
82 image: "nginx:alpine",
83 replicas: 2,
84 }),
85 );
86
87 expect(second.id).not.toBe(first.id);
88 expect(second.replicas).toBe(2);
89
90 yield* stack.destroy();
91 }),
92 { timeout: 240_000 },
93 );
94
95 test.provider(
96 "replaces a service when its Docker context changes",
97 (stack) =>
98 Effect.gen(function* () {
99 yield* ensureDockerSwarm;
100 const serviceName = "alchemy-test-service-context";
101
102 const first = yield* stack.deploy(
103 Docker.Service("context-service", {
104 name: serviceName,
105 image: "nginx:alpine",
106 }),
107 );
108 expect(first.context).toBeUndefined();
109
110 // `default` is the engine's built-in context — same daemon, but a
111 // different context ref, so the replace path (delete-first, recreate
112 // under `--context`) runs against a real named context.
113 const second = yield* stack.deploy(
114 Docker.Service("context-service", {
115 name: serviceName,
116 image: "nginx:alpine",
117 context: "default",
118 }),
119 );
120
121 expect(second.id).not.toBe(first.id);
122 expect(second.context).toBe("default");
123
124 yield* stack.destroy();
125 }),
126 { timeout: 240_000 },
127 );
128
129 test.provider(
130 "refuses a pre-existing service unless explicitly adopted",
131 (stack) =>
132 Effect.gen(function* () {
133 yield* ensureDockerSwarm;
134 const docker = yield* Docker.Docker;
135 const serviceName = "alchemy-test-service-adopt-existing";
136
137 yield* Effect.addFinalizer(() =>
138 docker.service.remove(serviceName).pipe(Effect.ignore),
139 );
140
141 yield* docker.service
142 .remove(serviceName)
143 .pipe(
144 Effect.catchReason(
145 "PlatformError",
146 "NotFound",
147 () => Effect.void,
148 ),
149 );
150
151 yield* docker.service.create({
152 name: serviceName,
153 image: "nginx:alpine",
154 replicas: 1,
155 });
156
157 const error = yield* stack
158 .deploy(
159 Docker.Service("existing-service", {
160 name: serviceName,
161 image: "nginx:alpine",
162 replicas: 1,
163 }),
164 )
165 .pipe(
166 Effect.as(undefined),
167 Effect.catchCause((cause) =>
168 Effect.succeed(findOwnedError(cause)),
169 ),
170 );
171
172 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
173
174 const adopted = yield* stack.deploy(
175 Docker.Service("existing-service", {
176 name: serviceName,
177 image: "nginx:alpine",
178 replicas: 1,
179 }).pipe(adopt(true)),
180 );
181
182 expect(adopted.name).toBe(serviceName);
183 expect(adopted.id.length).toBeGreaterThan(0);
184
185 yield* stack.destroy();
186 }),
187 { timeout: 240_000 },
188 );
189
190 test.provider(
191 "replaces a service when labels change",
192 (stack) =>
193 Effect.gen(function* () {
194 yield* ensureDockerSwarm;
195 const serviceName = "alchemy-test-service-replace";
196
197 const first = yield* stack.deploy(
198 Docker.Service("replaceable-service", {
199 name: serviceName,
200 image: "nginx:alpine",
201 labels: { generation: "1" },
202 }),
203 );
204
205 const second = yield* stack.deploy(
206 Docker.Service("replaceable-service", {
207 name: serviceName,
208 image: "nginx:alpine",
209 labels: { generation: "2" },
210 }),
211 );
212
213 expect(second.id).not.toBe(first.id);
214 expect(second.labels.generation).toBe("2");
215
216 yield* stack.destroy();
217 }),
218 { timeout: 240_000 },
219 );
220 },
221);
222
223const findOwnedError = (
224 cause: Cause.Cause<unknown>,
225): OwnedBySomeoneElse | undefined =>
226 cause.reasons
227 .map((reason) =>
228 Cause.isFailReason(reason)
229 ? reason.error
230 : Cause.isDieReason(reason)
231 ? reason.defect
232 : undefined,
233 )
234 .find(
235 (value): value is OwnedBySomeoneElse =>
236 value instanceof OwnedBySomeoneElse,
237 );