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
19test.provider(
20 "create, update, delete vpc service",
21 (stack) =>
22 Effect.gen(function* () {
23 const { accountId } = yield* yield* CloudflareEnvironment;
24
25 yield* stack.destroy();
26
27 const { tunnel, service } = yield* stack.deploy(
28 Effect.gen(function* () {
29 const tunnel = yield* Cloudflare.Tunnel.Tunnel("VpcTunnel", {
30 ingress: [{ service: "http://localhost:8080" }],
31 adopt: true,
32 });
33 const service = yield* Cloudflare.VpcService.VpcService("VpcSvc", {
34 httpPort: 8080,
35 host: {
36 hostname: "localhost",
37 resolverNetwork: { tunnelId: tunnel.tunnelId },
38 },
39 adopt: true,
40 });
41 return { tunnel, service };
42 }),
43 );
44
45 expect(service.serviceId).toBeDefined();
46 expect(service.serviceType).toEqual("http");
47 expect(service.httpPort).toEqual(8080);
48 expect(service.host).toMatchObject({
49 hostname: "localhost",
50 resolverNetwork: { tunnelId: tunnel.tunnelId },
51 });
52
53 const fetched = yield* connectivity.getDirectoryService({
54 accountId,
55 serviceId: service.serviceId,
56 });
57 expect(fetched.serviceId).toEqual(service.serviceId);
58 expect(fetched.httpPort).toEqual(8080);
59
60 const updated = yield* stack.deploy(
61 Effect.gen(function* () {
62 const tunnel = yield* Cloudflare.Tunnel.Tunnel("VpcTunnel", {
63 ingress: [{ service: "http://localhost:8080" }],
64 adopt: true,
65 });
66 return yield* Cloudflare.VpcService.VpcService("VpcSvc", {
67 httpPort: 3000,
68 httpsPort: 3001,
69 host: {
70 hostname: "localhost",
71 resolverNetwork: { tunnelId: tunnel.tunnelId },
72 },
73 adopt: true,
74 });
75 }),
76 );
77
78 expect(updated.serviceId).toEqual(service.serviceId);
79 expect(updated.httpPort).toEqual(3000);
80 expect(updated.httpsPort).toEqual(3001);
81
82 const fetchedUpdated = yield* connectivity.getDirectoryService({
83 accountId,
84 serviceId: service.serviceId,
85 });
86 expect(fetchedUpdated.httpPort).toEqual(3000);
87 expect(fetchedUpdated.httpsPort).toEqual(3001);
88
89 yield* stack.destroy();
90
91 yield* waitForServiceToBeDeleted(service.serviceId, accountId);
92 }).pipe(logLevel),
93 {
94 tags: [
95 "provider:cloudflare",
96 "provider:cloudflare:connectivity",
97 "provider:cloudflare:tunnel",
98 "provider:cloudflare:vpcservice",
99 "live",
100 ],
101 },
102);
103
104test.provider(
105 "create vpc service with ipv4 host",
106 (stack) =>
107 Effect.gen(function* () {
108 const { accountId } = yield* yield* CloudflareEnvironment;
109
110 yield* stack.destroy();
111
112 const service = yield* stack.deploy(
113 Effect.gen(function* () {
114 const tunnel = yield* Cloudflare.Tunnel.Tunnel("Ipv4Tunnel", {
115 ingress: [{ service: "http://localhost:8080" }],
116 adopt: true,
117 });
118 return yield* Cloudflare.VpcService.VpcService("Ipv4Svc", {
119 httpPort: 8080,
120 host: {
121 ipv4: "192.168.1.100",
122 network: { tunnelId: tunnel.tunnelId },
123 },
124 adopt: true,
125 });
126 }),
127 );
128
129 expect(service.host).toMatchObject({
130 ipv4: "192.168.1.100",
131 });
132 expect("ipv6" in service.host).toBe(false);
133
134 const fetched = yield* connectivity.getDirectoryService({
135 accountId,
136 serviceId: service.serviceId,
137 });
138 expect((fetched.host as { ipv4?: string }).ipv4).toEqual("192.168.1.100");
139
140 yield* stack.destroy();
141 yield* waitForServiceToBeDeleted(service.serviceId, accountId);
142 }).pipe(logLevel),
143 {
144 tags: [
145 "provider:cloudflare",
146 "provider:cloudflare:connectivity",
147 "provider:cloudflare:tunnel",
148 "provider:cloudflare:vpcservice",
149 "live",
150 ],
151 },
152);
153
154// TODO: re-enable once distilled ships the union-ordering fix
155// (alchemy-run/distilled#232) — on @distilled.cloud/cloudflare@0.16.3 the
156// dual-stack host variant comes after the ipv4-only variant in the request
157// schema's Schema.Union, so `ipv6` is silently stripped on encode.
158test.provider.skip(
159 "create vpc service with dual-stack host",
160 (stack) =>
161 Effect.gen(function* () {
162 const { accountId } = yield* yield* CloudflareEnvironment;
163
164 yield* stack.destroy();
165
166 const service = yield* stack.deploy(
167 Effect.gen(function* () {
168 const tunnel = yield* Cloudflare.Tunnel.Tunnel("DualStackTunnel", {
169 ingress: [{ service: "http://localhost:8080" }],
170 adopt: true,
171 });
172 return yield* Cloudflare.VpcService.VpcService("DualStackSvc", {
173 httpPort: 8080,
174 host: {
175 ipv4: "192.168.1.101",
176 ipv6: "2001:db8::1",
177 network: { tunnelId: tunnel.tunnelId },
178 },
179 adopt: true,
180 });
181 }),
182 );
183
184 expect(service.host).toMatchObject({
185 ipv4: "192.168.1.101",
186 ipv6: "2001:db8::1",
187 });
188
189 yield* stack.destroy();
190 yield* waitForServiceToBeDeleted(service.serviceId, accountId);
191 }).pipe(logLevel),
192 {
193 tags: [
194 "provider:cloudflare",
195 "provider:cloudflare:connectivity",
196 "provider:cloudflare:tunnel",
197 "provider:cloudflare:vpcservice",
198 "live",
199 ],
200 },
201);
202
203test.provider(
204 "list enumerates the deployed vpc service",
205 (stack) =>
206 Effect.gen(function* () {
207 const { accountId } = yield* yield* CloudflareEnvironment;
208
209 yield* stack.destroy();
210
211 const service = yield* stack.deploy(
212 Effect.gen(function* () {
213 const tunnel = yield* Cloudflare.Tunnel.Tunnel("ListTunnel", {
214 ingress: [{ service: "http://localhost:8080" }],
215 adopt: true,
216 });
217 return yield* Cloudflare.VpcService.VpcService("ListSvc", {
218 httpPort: 8080,
219 host: {
220 hostname: "localhost",
221 resolverNetwork: { tunnelId: tunnel.tunnelId },
222 },
223 adopt: true,
224 });
225 }),
226 );
227
228 const provider = yield* Provider.findProvider(
229 Cloudflare.VpcService.VpcService,
230 );
231 const all = yield* provider.list();
232
233 const found = all.find((s) => s.serviceId === service.serviceId);
234 expect(found).toBeDefined();
235 expect(found?.serviceName).toEqual(service.serviceName);
236 expect(found?.accountId).toEqual(accountId);
237
238 yield* stack.destroy();
239 yield* waitForServiceToBeDeleted(service.serviceId, accountId);
240 }).pipe(logLevel),
241 {
242 tags: [
243 "provider:cloudflare",
244 "provider:cloudflare:connectivity",
245 "provider:cloudflare:tunnel",
246 "provider:cloudflare:vpcservice",
247 "live",
248 ],
249 },
250);
251
252const waitForServiceToBeDeleted = Effect.fn(function* (
253 serviceId: string,
254 accountId: string,
255) {
256 yield* connectivity.getDirectoryService({ accountId, serviceId }).pipe(
257 Effect.flatMap(() => Effect.fail(new VpcServiceStillExists())),
258 Effect.retry({
259 while: (e): e is VpcServiceStillExists =>
260 e instanceof VpcServiceStillExists,
261 schedule: Schedule.exponential(100),
262 }),
263 Effect.catch(() => Effect.void),
264 );
265});
266
267class VpcServiceStillExists extends Data.TaggedError("VpcServiceStillExists") {}