1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as connectivity from "@distilled.cloud/cloudflare/connectivity";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// The scoped API token the test harness mints propagates eventually-
20// consistently across Cloudflare's edge — ride out 403 blips
21// (`Forbidden`, declared in the distilled error union) on the test's
22// own out-of-band verification calls.
23const getService = (accountId: string, serviceId: string) =>
24 connectivity.getDirectoryService({ accountId, serviceId }).pipe(
25 Effect.retry({
26 while: (e) => e._tag === "Forbidden",
27 schedule: Schedule.exponential("500 millis"),
28 times: 8,
29 }),
30 );
31
32class ServiceStillExists extends Data.TaggedError("ServiceStillExists") {}
33
34const expectGone = (accountId: string, serviceId: string) =>
35 getService(accountId, serviceId).pipe(
36 Effect.flatMap(() => Effect.fail(new ServiceStillExists())),
37 // A missing service surfaces as the typed `VpcServiceNotFound`
38 // (Cloudflare error code 5104) — that's the success condition here.
39 Effect.catchTag("VpcServiceNotFound", () => Effect.void),
40 Effect.retry({
41 while: (e) => e._tag === "ServiceStillExists",
42 schedule: Schedule.exponential("500 millis"),
43 times: 10,
44 }),
45 );
46
47test.provider(
48 "tcp service lifecycle: create, update, host switch",
49 (stack) =>
50 Effect.gen(function* () {
51 const { accountId } = yield* yield* CloudflareEnvironment;
52
53 yield* stack.destroy();
54
55 // Create — tcp database service on a tunnel-backed IPv4 host.
56 const { tunnel, service } = yield* stack.deploy(
57 Effect.gen(function* () {
58 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcTunnel", {
59 ingress: [{ service: "http://localhost:8080" }],
60 adopt: true,
61 });
62 const service = yield* Cloudflare.Connectivity.DirectoryService(
63 "PgService",
64 {
65 name: "alchemy-connectivity-dirsvc-tcp",
66 type: "tcp",
67 tcpPort: 5432,
68 appProtocol: "postgresql",
69 host: {
70 ipv4: "10.10.0.21",
71 network: { tunnelId: tunnel.tunnelId },
72 },
73 },
74 );
75 return { tunnel, service };
76 }),
77 );
78
79 expect(service.serviceId).toBeDefined();
80 expect(service.accountId).toEqual(accountId);
81 expect(service.name).toEqual("alchemy-connectivity-dirsvc-tcp");
82 expect(service.type).toEqual("tcp");
83 expect(service.tcpPort).toEqual(5432);
84 expect(service.appProtocol).toEqual("postgresql");
85 expect(service.host).toMatchObject({
86 ipv4: "10.10.0.21",
87 network: { tunnelId: tunnel.tunnelId },
88 });
89
90 // Out-of-band verify against the live API.
91 const live = yield* getService(accountId, service.serviceId);
92 expect(live.serviceId).toEqual(service.serviceId);
93 expect(live.name).toEqual("alchemy-connectivity-dirsvc-tcp");
94
95 // Update in place — new name and port, same serviceId.
96 const updated = yield* stack.deploy(
97 Effect.gen(function* () {
98 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcTunnel", {
99 ingress: [{ service: "http://localhost:8080" }],
100 adopt: true,
101 });
102 return yield* Cloudflare.Connectivity.DirectoryService("PgService", {
103 name: "alchemy-connectivity-dirsvc-tcp-v2",
104 type: "tcp",
105 tcpPort: 5433,
106 appProtocol: "postgresql",
107 host: {
108 ipv4: "10.10.0.21",
109 network: { tunnelId: tunnel.tunnelId },
110 },
111 });
112 }),
113 );
114
115 expect(updated.serviceId).toEqual(service.serviceId);
116 expect(updated.name).toEqual("alchemy-connectivity-dirsvc-tcp-v2");
117 expect(updated.tcpPort).toEqual(5433);
118
119 const liveUpdated = yield* getService(accountId, service.serviceId);
120 expect(liveUpdated.name).toEqual("alchemy-connectivity-dirsvc-tcp-v2");
121
122 // Switch the host variant (ipv4 -> hostname) — still an in-place
123 // update, same serviceId. Keep the tunnel deployed across steps.
124 const rehosted = yield* stack.deploy(
125 Effect.gen(function* () {
126 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcTunnel", {
127 ingress: [{ service: "http://localhost:8080" }],
128 adopt: true,
129 });
130 return yield* Cloudflare.Connectivity.DirectoryService("PgService", {
131 name: "alchemy-connectivity-dirsvc-tcp-v2",
132 type: "tcp",
133 tcpPort: 5433,
134 appProtocol: "postgresql",
135 host: {
136 hostname: "db.internal",
137 resolverNetwork: { tunnelId: tunnel.tunnelId },
138 },
139 });
140 }),
141 );
142
143 expect(rehosted.serviceId).toEqual(service.serviceId);
144 expect(rehosted.host).toMatchObject({
145 hostname: "db.internal",
146 resolverNetwork: { tunnelId: tunnel.tunnelId },
147 });
148
149 // Redeploying identical props is a no-op (same serviceId, no drift).
150 const noop = yield* stack.deploy(
151 Effect.gen(function* () {
152 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcTunnel", {
153 ingress: [{ service: "http://localhost:8080" }],
154 adopt: true,
155 });
156 return yield* Cloudflare.Connectivity.DirectoryService("PgService", {
157 name: "alchemy-connectivity-dirsvc-tcp-v2",
158 type: "tcp",
159 tcpPort: 5433,
160 appProtocol: "postgresql",
161 host: {
162 hostname: "db.internal",
163 resolverNetwork: { tunnelId: tunnel.tunnelId },
164 },
165 });
166 }),
167 );
168 expect(noop.serviceId).toEqual(service.serviceId);
169
170 yield* stack.destroy();
171 yield* expectGone(accountId, service.serviceId);
172
173 // Destroy is idempotent — a second destroy of an empty stack is a no-op.
174 yield* stack.destroy();
175 }).pipe(logLevel),
176 {
177 tags: [
178 "provider:cloudflare",
179 "provider:cloudflare:connectivity",
180 "provider:cloudflare:tunnel",
181 "live",
182 ],
183 },
184);
185
186test.provider(
187 "http service with explicit ports and default name",
188 (stack) =>
189 Effect.gen(function* () {
190 const { accountId } = yield* yield* CloudflareEnvironment;
191
192 yield* stack.destroy();
193
194 const service = yield* stack.deploy(
195 Effect.gen(function* () {
196 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcHttpTunnel", {
197 ingress: [{ service: "http://localhost:8080" }],
198 adopt: true,
199 });
200 return yield* Cloudflare.Connectivity.DirectoryService(
201 "HttpService",
202 {
203 type: "http",
204 httpPort: 8080,
205 httpsPort: 8443,
206 host: {
207 hostname: "api.internal",
208 resolverNetwork: {
209 tunnelId: tunnel.tunnelId,
210 resolverIps: ["10.0.0.53"],
211 },
212 },
213 },
214 );
215 }),
216 );
217
218 expect(service.serviceId).toBeDefined();
219 // Name was omitted — the provider generates one from app/stage/id.
220 expect(service.name).toBeTruthy();
221 expect(service.type).toEqual("http");
222 expect(service.httpPort).toEqual(8080);
223 expect(service.httpsPort).toEqual(8443);
224 expect(service.host).toMatchObject({
225 hostname: "api.internal",
226 resolverNetwork: { resolverIps: ["10.0.0.53"] },
227 });
228
229 const live = yield* getService(accountId, service.serviceId);
230 expect(live.serviceId).toEqual(service.serviceId);
231
232 // Update ports in place.
233 const updated = yield* stack.deploy(
234 Effect.gen(function* () {
235 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcHttpTunnel", {
236 ingress: [{ service: "http://localhost:8080" }],
237 adopt: true,
238 });
239 return yield* Cloudflare.Connectivity.DirectoryService(
240 "HttpService",
241 {
242 type: "http",
243 httpPort: 3000,
244 httpsPort: 3001,
245 host: {
246 hostname: "api.internal",
247 resolverNetwork: {
248 tunnelId: tunnel.tunnelId,
249 resolverIps: ["10.0.0.53"],
250 },
251 },
252 },
253 );
254 }),
255 );
256
257 expect(updated.serviceId).toEqual(service.serviceId);
258 expect(updated.httpPort).toEqual(3000);
259 expect(updated.httpsPort).toEqual(3001);
260
261 yield* stack.destroy();
262 yield* expectGone(accountId, service.serviceId);
263 }).pipe(logLevel),
264 {
265 tags: [
266 "provider:cloudflare",
267 "provider:cloudflare:connectivity",
268 "provider:cloudflare:tunnel",
269 "live",
270 ],
271 },
272);
273
274test.provider(
275 "list enumerates the deployed directory service",
276 (stack) =>
277 Effect.gen(function* () {
278 yield* stack.destroy();
279
280 const service = yield* stack.deploy(
281 Effect.gen(function* () {
282 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcListTunnel", {
283 ingress: [{ service: "http://localhost:8080" }],
284 adopt: true,
285 });
286 return yield* Cloudflare.Connectivity.DirectoryService(
287 "ListService",
288 {
289 name: "alchemy-connectivity-dirsvc-list",
290 type: "tcp",
291 tcpPort: 5432,
292 host: {
293 ipv4: "10.20.0.21",
294 network: { tunnelId: tunnel.tunnelId },
295 },
296 },
297 );
298 }),
299 );
300
301 const provider = yield* Provider.findProvider(
302 Cloudflare.Connectivity.DirectoryService,
303 );
304 const all = yield* provider.list();
305
306 expect(all.some((s) => s.serviceId === service.serviceId)).toBe(true);
307 const found = all.find((s) => s.serviceId === service.serviceId)!;
308 expect(found.name).toEqual("alchemy-connectivity-dirsvc-list");
309 expect(found.type).toEqual("tcp");
310
311 yield* stack.destroy();
312 yield* expectGone(service.accountId, service.serviceId);
313 }).pipe(logLevel),
314 {
315 tags: [
316 "provider:cloudflare",
317 "provider:cloudflare:connectivity",
318 "provider:cloudflare:tunnel",
319 "live",
320 ],
321 },
322);
323
324test.provider(
325 "recreates after out-of-band delete",
326 (stack) =>
327 Effect.gen(function* () {
328 const { accountId } = yield* yield* CloudflareEnvironment;
329
330 yield* stack.destroy();
331
332 const service = yield* stack.deploy(
333 Effect.gen(function* () {
334 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcHealTunnel", {
335 ingress: [{ service: "http://localhost:8080" }],
336 adopt: true,
337 });
338 return yield* Cloudflare.Connectivity.DirectoryService(
339 "HealService",
340 {
341 name: "alchemy-connectivity-dirsvc-heal",
342 type: "http",
343 httpPort: 8080,
344 host: {
345 hostname: "heal.internal",
346 resolverNetwork: { tunnelId: tunnel.tunnelId },
347 },
348 },
349 );
350 }),
351 );
352
353 // Delete the service out-of-band; a redeploy with changed props must
354 // observe it as missing and recreate instead of failing on a 404.
355 yield* connectivity
356 .deleteDirectoryService({ accountId, serviceId: service.serviceId })
357 .pipe(
358 Effect.retry({
359 while: (e) => e._tag === "Forbidden",
360 schedule: Schedule.exponential("500 millis"),
361 times: 8,
362 }),
363 );
364
365 const healed = yield* stack.deploy(
366 Effect.gen(function* () {
367 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DirSvcHealTunnel", {
368 ingress: [{ service: "http://localhost:8080" }],
369 adopt: true,
370 });
371 return yield* Cloudflare.Connectivity.DirectoryService(
372 "HealService",
373 {
374 name: "alchemy-connectivity-dirsvc-heal",
375 type: "http",
376 httpPort: 9090,
377 host: {
378 hostname: "heal.internal",
379 resolverNetwork: { tunnelId: tunnel.tunnelId },
380 },
381 },
382 );
383 }),
384 );
385
386 expect(healed.serviceId).not.toEqual(service.serviceId);
387 expect(healed.httpPort).toEqual(9090);
388 const live = yield* getService(accountId, healed.serviceId);
389 expect(live.name).toEqual("alchemy-connectivity-dirsvc-heal");
390
391 yield* stack.destroy();
392 yield* expectGone(accountId, healed.serviceId);
393 }).pipe(logLevel),
394 {
395 tags: [
396 "provider:cloudflare",
397 "provider:cloudflare:connectivity",
398 "provider:cloudflare:tunnel",
399 "live",
400 ],
401 },
402);