1import * as Hetzner from "@/Hetzner";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as networks from "@distilled.cloud/hetzner/networks";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Hetzner.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
18
19const waitUntilGone = (id: number) =>
20 networks.getNetwork({ id }).pipe(
21 Effect.as("found" as const),
22 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
23 Effect.repeat({
24 schedule: Schedule.spaced("1 second"),
25 until: (status) => status === "gone",
26 times: 10,
27 }),
28 );
29
30test.provider.skipIf(!hasHetznerCreds)(
31 "create, update, and delete a network",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35
36 const created = yield* stack.deploy(
37 Effect.gen(function* () {
38 return yield* Hetzner.Network("Vpc", {
39 ipRange: "10.0.0.0/16",
40 subnets: [
41 {
42 type: "cloud",
43 ipRange: "10.0.1.0/24",
44 networkZone: "eu-central",
45 },
46 ],
47 labels: { env: "test" },
48 });
49 }),
50 );
51
52 expect(created.networkId).toEqual(expect.any(Number));
53 expect(created.name).toEqual(expect.any(String));
54 expect(created.ipRange).toEqual("10.0.0.0/16");
55 expect(created.subnets).toEqual(
56 expect.arrayContaining([
57 expect.objectContaining({
58 type: "cloud",
59 ipRange: "10.0.1.0/24",
60 networkZone: "eu-central",
61 }),
62 ]),
63 );
64 expect(created.routes).toEqual([]);
65 expect(created.deleteProtection).toEqual(false);
66 expect(created.exposeRoutesToVswitch).toEqual(false);
67 expect(created.labels).toMatchObject({ env: "test" });
68
69 const fetched = yield* networks.getNetwork({
70 id: created.networkId,
71 });
72 expect(fetched.network?.id).toEqual(created.networkId);
73 expect(fetched.network?.ip_range).toEqual("10.0.0.0/16");
74 expect(fetched.network?.labels.env).toEqual("test");
75 expect(fetched.network?.subnets).toEqual(
76 expect.arrayContaining([
77 expect.objectContaining({
78 type: "cloud",
79 ip_range: "10.0.1.0/24",
80 network_zone: "eu-central",
81 }),
82 ]),
83 );
84
85 const updated = yield* stack.deploy(
86 Effect.gen(function* () {
87 return yield* Hetzner.Network("Vpc", {
88 ipRange: "10.0.0.0/16",
89 subnets: [
90 {
91 type: "cloud",
92 ipRange: "10.0.1.0/24",
93 networkZone: "eu-central",
94 },
95 {
96 type: "cloud",
97 ipRange: "10.0.2.0/24",
98 networkZone: "eu-central",
99 },
100 ],
101 routes: [{ destination: "10.10.0.0/24", gateway: "10.0.1.2" }],
102 exposeRoutesToVswitch: true,
103 deleteProtection: true,
104 labels: { env: "prod", role: "vpc" },
105 });
106 }),
107 );
108
109 expect(updated.networkId).toEqual(created.networkId);
110 expect(updated.ipRange).toEqual("10.0.0.0/16");
111 expect(updated.subnets).toHaveLength(2);
112 expect(updated.routes).toEqual([
113 { destination: "10.10.0.0/24", gateway: "10.0.1.2" },
114 ]);
115 expect(updated.deleteProtection).toEqual(true);
116 expect(updated.exposeRoutesToVswitch).toEqual(true);
117 expect(updated.labels).toMatchObject({ env: "prod", role: "vpc" });
118
119 const refetched = yield* networks.getNetwork({
120 id: updated.networkId,
121 });
122 expect(refetched.network?.protection.delete).toEqual(true);
123 expect(refetched.network?.expose_routes_to_vswitch).toEqual(true);
124 expect(refetched.network?.labels.env).toEqual("prod");
125 expect(refetched.network?.labels.role).toEqual("vpc");
126 expect(refetched.network?.subnets).toHaveLength(2);
127 expect(refetched.network?.routes).toEqual([
128 { destination: "10.10.0.0/24", gateway: "10.0.1.2" },
129 ]);
130
131 yield* stack.destroy();
132
133 const gone = yield* waitUntilGone(created.networkId);
134 expect(gone).toEqual("gone");
135 }).pipe(logLevel),
136 {
137 tags: [
138 "provider:hetzner",
139 "provider:hetzner:network",
140 "provider:hetzner:service",
141 "live",
142 ],
143 timeout: 120_000,
144 },
145);
146
147test.provider.skipIf(!hasHetznerCreds)(
148 "replace when ip range is not a supernet",
149 (stack) =>
150 Effect.gen(function* () {
151 yield* stack.destroy();
152
153 const created = yield* stack.deploy(
154 Effect.gen(function* () {
155 return yield* Hetzner.Network("ReplaceVpc", {
156 ipRange: "10.0.0.0/16",
157 subnets: [
158 {
159 type: "cloud",
160 ipRange: "10.0.1.0/24",
161 networkZone: "eu-central",
162 },
163 ],
164 });
165 }),
166 );
167
168 expect(created.ipRange).toEqual("10.0.0.0/16");
169
170 const replaced = yield* stack.deploy(
171 Effect.gen(function* () {
172 return yield* Hetzner.Network("ReplaceVpc", {
173 ipRange: "172.16.0.0/16",
174 subnets: [
175 {
176 type: "cloud",
177 ipRange: "172.16.1.0/24",
178 networkZone: "eu-central",
179 },
180 ],
181 });
182 }),
183 );
184
185 expect(replaced.networkId).not.toEqual(created.networkId);
186 expect(replaced.ipRange).toEqual("172.16.0.0/16");
187
188 const fetched = yield* networks.getNetwork({
189 id: replaced.networkId,
190 });
191 expect(fetched.network?.ip_range).toEqual("172.16.0.0/16");
192
193 const oldGone = yield* waitUntilGone(created.networkId);
194 expect(oldGone).toEqual("gone");
195
196 yield* stack.destroy();
197
198 const gone = yield* waitUntilGone(replaced.networkId);
199 expect(gone).toEqual("gone");
200 }).pipe(logLevel),
201 {
202 tags: [
203 "provider:hetzner",
204 "provider:hetzner:network",
205 "provider:hetzner:service",
206 "live",
207 ],
208 timeout: 120_000,
209 },
210);
211
212test.provider.skipIf(!hasHetznerCreds)(
213 "list enumerates the deployed network",
214 (stack) =>
215 Effect.gen(function* () {
216 yield* stack.destroy();
217
218 const deployed = yield* stack.deploy(
219 Effect.gen(function* () {
220 return yield* Hetzner.Network("ListVpc", {
221 ipRange: "10.20.0.0/16",
222 subnets: [
223 {
224 type: "cloud",
225 ipRange: "10.20.1.0/24",
226 networkZone: "eu-central",
227 },
228 ],
229 });
230 }),
231 );
232
233 const provider = yield* Provider.findProvider(Hetzner.Network);
234 const all = yield* provider.list();
235 const found = all.find(
236 (network) => network.networkId === deployed.networkId,
237 );
238 expect(found).toBeDefined();
239 expect(found?.ipRange).toEqual("10.20.0.0/16");
240 expect(found?.name).toEqual(deployed.name);
241
242 yield* stack.destroy();
243
244 const gone = yield* waitUntilGone(deployed.networkId);
245 expect(gone).toEqual("gone");
246 }).pipe(logLevel),
247 {
248 tags: [
249 "provider:hetzner",
250 "provider:hetzner:network",
251 "provider:hetzner:service",
252 "live",
253 ],
254 timeout: 120_000,
255 },
256);