1import * as loadBalancers from "@distilled.cloud/hetzner/load_balancers";
2import * as zoneRrsets from "@distilled.cloud/hetzner/zone_rrsets";
3import * as Hetzner from "@/Hetzner";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import { CredentialsFromEnv } from "@distilled.cloud/hetzner";
7import * as firewalls from "@distilled.cloud/hetzner/firewalls";
8import * as servers from "@distilled.cloud/hetzner/servers";
9import * as volumes from "@distilled.cloud/hetzner/volumes";
10import { expect } from "alchemy-test";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import { MinimumLogLevel } from "effect/References";
15import * as Schedule from "effect/Schedule";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import Api from "./fixtures/app/api.ts";
19import {
20 API_PORT,
21 AppRecord,
22 Box,
23 Data as Volume,
24 DnsZone,
25 Edge,
26 MARKER,
27 VOLUME_PATH,
28 Wall,
29} from "./fixtures/app/shared.ts";
30import Worker from "./fixtures/app/worker.ts";
31
32const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
33 providers: Hetzner.providers(),
34});
35
36const logLevel = Effect.provideService(
37 MinimumLogLevel,
38 process.env.DEBUG ? "Debug" : "Info",
39);
40
41const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
42
43const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
44 effect.pipe(
45 Effect.provide(Layer.mergeAll(CredentialsFromEnv, FetchHttpClient.layer)),
46 );
47
48class ApiNotReady extends Data.TaggedError("ApiNotReady")<{
49 status: number;
50}> {}
51
52const Stack = Alchemy.Stack(
53 "HetznerAppFixture",
54 {
55 providers: Hetzner.providers(),
56 state: Alchemy.localState(),
57 },
58 Effect.gen(function* () {
59 const server = yield* Box;
60 const volume = yield* Volume;
61 const firewall = yield* Wall;
62 const lb = yield* Edge;
63 const zone = yield* DnsZone;
64 const records = yield* AppRecord;
65 const worker = yield* Worker;
66 const api = yield* Api;
67 return {
68 serverId: server.serverId,
69 serverIpv4: server.ipv4,
70 volumeId: volume.id,
71 firewallId: firewall.id,
72 lbId: lb.id,
73 lbIpv4: lb.ipv4,
74 zoneId: zone.zoneId,
75 zoneName: zone.name,
76 recordName: records.name,
77 recordType: records.type,
78 apiUrl: api.url,
79 apiPort: api.port,
80 apiUnit: api.unitName,
81 workerUnit: worker.unitName,
82 };
83 }),
84);
85
86const stack = hasHetznerCreds
87 ? beforeAll(deploy(Stack), { timeout: 180_000, exclusive: true })
88 : null;
89
90afterAll.skipIf(!hasHetznerCreds || !!process.env.NO_DESTROY)(destroy(Stack), {
91 timeout: 120_000,
92 exclusive: true,
93});
94
95test.skipIf(!hasHetznerCreds)(
96 "deploys an effectful app and serves the mounted volume over HTTP",
97 Effect.gen(function* () {
98 const out = yield* stack!;
99
100 expect(out.serverId).toEqual(expect.any(Number));
101 expect(out.serverIpv4).toEqual(expect.any(String));
102 expect(out.volumeId).toEqual(expect.any(Number));
103 expect(out.firewallId).toEqual(expect.any(Number));
104 expect(out.lbId).toEqual(expect.any(Number));
105 expect(out.lbIpv4).toEqual(expect.any(String));
106 expect(out.zoneId).toEqual(expect.any(Number));
107 expect(out.recordName).toEqual("app");
108 expect(out.recordType).toEqual("A");
109 expect(out.apiPort).toEqual(API_PORT);
110 expect(out.apiUnit).not.toEqual(out.workerUnit);
111 expect(out.apiUrl).toContain(out.serverIpv4);
112
113 const liveServer = yield* distilled(
114 servers.getServer({ id: out.serverId }),
115 );
116 expect(liveServer.server?.id).toEqual(out.serverId);
117 expect(liveServer.server?.public_net.ipv4?.ip).toEqual(out.serverIpv4);
118
119 const liveVolume = yield* distilled(
120 volumes.getVolume({ id: out.volumeId }),
121 );
122 expect(liveVolume.volume.server).toEqual(out.serverId);
123 expect(liveVolume.volume.linux_device).toMatch(/^\/dev\//);
124
125 const liveFirewall = yield* distilled(
126 firewalls.getFirewall({ id: out.firewallId }),
127 );
128 expect(liveFirewall.firewall.applied_to).toEqual(
129 expect.arrayContaining([
130 expect.objectContaining({
131 type: "server",
132 server: expect.objectContaining({ id: out.serverId }),
133 }),
134 ]),
135 );
136 expect(liveFirewall.firewall.rules).toEqual(
137 expect.arrayContaining([
138 expect.objectContaining({
139 direction: "in",
140 protocol: "tcp",
141 port: "22",
142 }),
143 expect.objectContaining({
144 direction: "in",
145 protocol: "tcp",
146 port: String(API_PORT),
147 }),
148 ]),
149 );
150
151 const liveLb = yield* distilled(
152 loadBalancers.getLoadBalancer({ id: out.lbId }),
153 );
154 expect(liveLb.load_balancer.public_net.ipv4.ip).toEqual(out.lbIpv4);
155 expect(liveLb.load_balancer.targets).toEqual(
156 expect.arrayContaining([
157 expect.objectContaining({
158 type: "server",
159 server: expect.objectContaining({ id: out.serverId }),
160 }),
161 ]),
162 );
163
164 const liveRecord = yield* distilled(
165 zoneRrsets.getZoneRrset({
166 id_or_name: String(out.zoneId),
167 rr_name: out.recordName,
168 rr_type: out.recordType,
169 }),
170 );
171 expect(liveRecord.rrset.records).toEqual(
172 expect.arrayContaining([expect.objectContaining({ value: out.lbIpv4 })]),
173 );
174
175 const client = yield* HttpClient.HttpClient;
176 const url = out.apiUrl ?? `http://${out.serverIpv4}:${out.apiPort}`;
177
178 const body = yield* client.get(url).pipe(
179 Effect.flatMap((res) =>
180 res.status === 200
181 ? res.json.pipe(
182 Effect.mapError(() => new ApiNotReady({ status: res.status })),
183 )
184 : Effect.fail(new ApiNotReady({ status: res.status })),
185 ),
186 Effect.retry({
187 while: (e) => e._tag === "ApiNotReady",
188 schedule: Schedule.exponential("500 millis"),
189 times: 10,
190 }),
191 Effect.map((value) => value as { text: string; path: string }),
192 );
193 expect(body.path).toEqual(VOLUME_PATH);
194 expect(body.text).toEqual(MARKER);
195 }).pipe(logLevel),
196 {
197 tags: [
198 "provider:hetzner",
199 "provider:hetzner:firewall",
200 "provider:hetzner:loadbalancer",
201 "provider:hetzner:mountvolume",
202 "provider:hetzner:recordset",
203 "provider:hetzner:server",
204 "provider:hetzner:service",
205 "provider:hetzner:volume",
206 "provider:hetzner:zone",
207 "live",
208 ],
209 timeout: 180_000,
210 exclusive: true,
211 },
212);