1import * as Hetzner from "@/Hetzner";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as firewalls from "@distilled.cloud/hetzner/firewalls";
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 sshRule: Hetzner.FirewallRule = {
20 direction: "in",
21 protocol: "tcp",
22 port: "22",
23 sourceIps: ["0.0.0.0/0", "::/0"],
24 description: "ssh",
25};
26
27const httpRule: Hetzner.FirewallRule = {
28 direction: "in",
29 protocol: "tcp",
30 port: "80",
31 sourceIps: ["0.0.0.0/0", "::/0"],
32 description: "http",
33};
34
35const waitUntilGone = (id: number) =>
36 firewalls.getFirewall({ id }).pipe(
37 Effect.as("found" as const),
38 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
39 Effect.repeat({
40 schedule: Schedule.spaced("1 second"),
41 until: (status) => status === "gone",
42 times: 10,
43 }),
44 );
45
46test.provider.skipIf(!hasHetznerCreds)(
47 "create, update rules and labels, and delete a firewall",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const created = yield* stack.deploy(
53 Effect.gen(function* () {
54 return yield* Hetzner.Firewall("Web", {
55 rules: [sshRule],
56 labels: { env: "test" },
57 });
58 }),
59 );
60
61 expect(created.id).toEqual(expect.any(Number));
62 expect(created.name).toEqual(expect.any(String));
63 expect(created.created).toEqual(expect.any(String));
64 expect(created.appliedTo).toEqual([]);
65 expect(created.labels).toMatchObject({ env: "test" });
66 expect(created.rules).toEqual(
67 expect.arrayContaining([
68 expect.objectContaining({
69 direction: "in",
70 protocol: "tcp",
71 port: "22",
72 description: "ssh",
73 }),
74 ]),
75 );
76
77 const fetched = yield* firewalls.getFirewall({
78 id: created.id,
79 });
80 expect(fetched.firewall.id).toEqual(created.id);
81 expect(fetched.firewall.name).toEqual(created.name);
82 expect(fetched.firewall.applied_to).toEqual([]);
83 expect(fetched.firewall.labels?.env).toEqual("test");
84 expect(fetched.firewall.rules).toEqual(
85 expect.arrayContaining([
86 expect.objectContaining({
87 direction: "in",
88 protocol: "tcp",
89 port: "22",
90 description: "ssh",
91 }),
92 ]),
93 );
94
95 const updated = yield* stack.deploy(
96 Effect.gen(function* () {
97 return yield* Hetzner.Firewall("Web", {
98 rules: [sshRule, httpRule],
99 labels: { env: "prod", role: "edge" },
100 });
101 }),
102 );
103
104 expect(updated.id).toEqual(created.id);
105 expect(updated.name).toEqual(created.name);
106 expect(updated.appliedTo).toEqual([]);
107 expect(updated.labels).toMatchObject({ env: "prod", role: "edge" });
108 expect(updated.rules).toHaveLength(2);
109 expect(updated.rules).toEqual(
110 expect.arrayContaining([
111 expect.objectContaining({ port: "22", description: "ssh" }),
112 expect.objectContaining({ port: "80", description: "http" }),
113 ]),
114 );
115
116 const refetched = yield* firewalls.getFirewall({
117 id: updated.id,
118 });
119 expect(refetched.firewall.id).toEqual(created.id);
120 expect(refetched.firewall.labels?.env).toEqual("prod");
121 expect(refetched.firewall.labels?.role).toEqual("edge");
122 expect(refetched.firewall.rules).toHaveLength(2);
123
124 yield* stack.destroy();
125
126 const gone = yield* waitUntilGone(created.id);
127 expect(gone).toEqual("gone");
128 }).pipe(logLevel),
129 {
130 tags: [
131 "provider:hetzner",
132 "provider:hetzner:firewall",
133 "provider:hetzner:service",
134 "live",
135 ],
136 timeout: 120_000,
137 },
138);
139
140test.provider.skipIf(!hasHetznerCreds)(
141 "list enumerates the deployed firewall",
142 (stack) =>
143 Effect.gen(function* () {
144 yield* stack.destroy();
145
146 const deployed = yield* stack.deploy(
147 Effect.gen(function* () {
148 return yield* Hetzner.Firewall("ListFw", {
149 rules: [sshRule],
150 });
151 }),
152 );
153
154 const provider = yield* Provider.findProvider(Hetzner.Firewall);
155 const all = yield* provider.list();
156 const found = all.find((item) => item.id === deployed.id);
157 expect(found).toBeDefined();
158 expect(found?.name).toEqual(deployed.name);
159 expect(found?.rules).toEqual(
160 expect.arrayContaining([
161 expect.objectContaining({
162 direction: "in",
163 protocol: "tcp",
164 port: "22",
165 }),
166 ]),
167 );
168
169 yield* stack.destroy();
170
171 const gone = yield* waitUntilGone(deployed.id);
172 expect(gone).toEqual("gone");
173 }).pipe(logLevel),
174 {
175 tags: [
176 "provider:hetzner",
177 "provider:hetzner:firewall",
178 "provider:hetzner:service",
179 "live",
180 ],
181 timeout: 120_000,
182 },
183);