1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import RedisApi, {
12 Cache,
13 REDIS_VALUE,
14 RedisIp,
15 RedisSite,
16} from "./fixtures/redis-api.ts";
17
18const { test } = Test.make({ providers: Fly.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25const waitUntilRedisGone = (redisId: string, name: string) =>
26 Fly.findRedisAddOn({ id: redisId, name }).pipe(
27 Effect.map((row) =>
28 row === undefined ? ("gone" as const) : ("found" as const),
29 ),
30 Effect.repeat({
31 schedule: Schedule.spaced("2 seconds"),
32 until: (status) => status === "gone",
33 times: 10,
34 }),
35 );
36
37const waitUntilAppGone = (appName: string) =>
38 machines.getApp({ app_name: appName }).pipe(
39 Effect.as("found" as const),
40 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
41 Effect.repeat({
42 schedule: Schedule.spaced("1 second"),
43 until: (status) => status === "gone",
44 times: 10,
45 }),
46 );
47
48test.provider(
49 "lists cheapest redis plan",
50 (stack) =>
51 Effect.gen(function* () {
52 yield* stack.destroy();
53 const plans = yield* Fly.listRedisPlans();
54 expect(plans.length).toBeGreaterThan(0);
55 const cheapest = plans
56 .slice()
57 .sort(
58 (left, right) =>
59 (left.pricePerMonth ?? 0) - (right.pricePerMonth ?? 0),
60 )[0];
61 expect(cheapest?.id).toEqual(expect.any(String));
62 yield* stack.destroy();
63 }).pipe(logLevel),
64 { tags: ["provider:fly", "provider:fly:redis", "live"], timeout: 90_000 },
65);
66
67test.provider(
68 "create, update eviction, and delete redis",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 const created = yield* stack.deploy(
74 Effect.gen(function* () {
75 return yield* Fly.Redis("Cache");
76 }),
77 );
78
79 expect(created.redisId).toEqual(expect.any(String));
80 expect(created.redisId.length).toBeGreaterThan(0);
81 expect(created.name).toEqual(expect.any(String));
82 expect(created.name.length).toBeGreaterThan(0);
83 expect(created.primaryRegion).toEqual("iad");
84 expect(created.planName).toEqual(expect.any(String));
85 expect(Redacted.isRedacted(created.url)).toEqual(true);
86
87 const fetched = yield* Fly.findRedisAddOn({
88 id: created.redisId,
89 name: created.name,
90 });
91 expect(fetched?.id).toEqual(created.redisId);
92 expect(fetched?.name).toEqual(created.name);
93 expect(fetched?.primaryRegion).toEqual("iad");
94
95 const provider = yield* Provider.findProvider(Fly.Redis);
96 const listed = yield* provider.list();
97 const found = listed.find((row) => row.redisId === created.redisId);
98 expect(found).toBeDefined();
99 expect(found?.name).toEqual(created.name);
100
101 const updated = yield* stack.deploy(
102 Effect.gen(function* () {
103 return yield* Fly.Redis("Cache", {
104 eviction: true,
105 });
106 }),
107 );
108
109 expect(updated.redisId).toEqual(created.redisId);
110 expect(updated.name).toEqual(created.name);
111 expect(updated.eviction).toEqual(true);
112 expect(Redacted.isRedacted(updated.url)).toEqual(true);
113
114 const refetched = yield* Fly.findRedisAddOn({
115 id: updated.redisId,
116 name: updated.name,
117 });
118 expect(refetched?.id).toEqual(updated.redisId);
119 const options = refetched?.options as { eviction?: boolean } | null;
120 expect(options?.eviction).toEqual(true);
121
122 yield* stack.destroy();
123
124 const gone = yield* waitUntilRedisGone(created.redisId, created.name);
125 expect(gone).toEqual("gone");
126 }).pipe(logLevel),
127 { tags: ["provider:fly", "provider:fly:redis", "live"], timeout: 120_000 },
128);
129
130test.provider(
131 "replace when name changes",
132 (stack) =>
133 Effect.gen(function* () {
134 yield* stack.destroy();
135
136 const created = yield* stack.deploy(
137 Effect.gen(function* () {
138 return yield* Fly.Redis("ReplaceName");
139 }),
140 );
141
142 const nextName =
143 created.name.slice(0, -1) + (created.name.endsWith("z") ? "y" : "z");
144
145 const replaced = yield* stack.deploy(
146 Effect.gen(function* () {
147 return yield* Fly.Redis("ReplaceName", {
148 name: nextName,
149 });
150 }),
151 );
152
153 expect(replaced.name).toEqual(nextName);
154 expect(replaced.redisId).not.toEqual(created.redisId);
155
156 const oldGone = yield* waitUntilRedisGone(created.redisId, created.name);
157 expect(oldGone).toEqual("gone");
158
159 const fetched = yield* Fly.findRedisAddOn({
160 id: replaced.redisId,
161 name: replaced.name,
162 });
163 expect(fetched?.name).toEqual(nextName);
164
165 yield* stack.destroy();
166
167 const gone = yield* waitUntilRedisGone(replaced.redisId, replaced.name);
168 expect(gone).toEqual("gone");
169 }).pipe(logLevel),
170 { tags: ["provider:fly", "provider:fly:redis", "live"], timeout: 120_000 },
171);
172
173test.provider(
174 "replace when primaryRegion changes",
175 (stack) =>
176 Effect.gen(function* () {
177 yield* stack.destroy();
178
179 const created = yield* stack.deploy(
180 Effect.gen(function* () {
181 return yield* Fly.Redis("ReplaceRegion", {
182 primaryRegion: "iad",
183 });
184 }),
185 );
186
187 expect(created.primaryRegion).toEqual("iad");
188
189 const replaced = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* Fly.Redis("ReplaceRegion", {
192 primaryRegion: "ewr",
193 });
194 }),
195 );
196
197 expect(replaced.primaryRegion).toEqual("ewr");
198 expect(replaced.redisId).not.toEqual(created.redisId);
199
200 const fetched = yield* Fly.findRedisAddOn({
201 id: replaced.redisId,
202 name: replaced.name,
203 });
204 expect(fetched?.primaryRegion).toEqual("ewr");
205
206 yield* stack.destroy();
207
208 const gone = yield* waitUntilRedisGone(replaced.redisId, replaced.name);
209 expect(gone).toEqual("gone");
210 }).pipe(logLevel),
211 { tags: ["provider:fly", "provider:fly:redis", "live"], timeout: 120_000 },
212);
213
214test.provider(
215 "a Service pings Redis and round-trips a key",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* stack.destroy();
219
220 const deployed = yield* stack.deploy(
221 Effect.gen(function* () {
222 const app = yield* RedisSite;
223 const cache = yield* Cache;
224 const ip = yield* RedisIp;
225 const api = yield* RedisApi;
226 return { app, cache, ip, api };
227 }),
228 );
229
230 expect(deployed.cache.redisId).toEqual(expect.any(String));
231 expect(deployed.api.url).toEqual(
232 `https://${deployed.app.appName}.fly.dev`,
233 );
234
235 const secrets = yield* machines.listSecrets({
236 app_name: deployed.app.appName,
237 show_secrets: false,
238 });
239 const redisUrl = (secrets.secrets ?? []).find(
240 (secret) => secret.name === Fly.REDIS_URL_ENV,
241 );
242 expect(redisUrl).toBeDefined();
243 expect(redisUrl?.digest).toEqual(expect.any(String));
244
245 const untilOk = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
246 effect.pipe(
247 Effect.retry({
248 schedule: Schedule.spaced("4 seconds"),
249 times: 10,
250 }),
251 );
252
253 const ping = yield* untilOk(
254 HttpClient.get(`${deployed.api.url}/`).pipe(
255 Effect.flatMap((res) =>
256 res.status === 200
257 ? res.json
258 : Effect.fail(new Error(`api returned ${res.status}`)),
259 ),
260 Effect.map(
261 (value) => value as { pong: boolean; hasOutputUrl: boolean },
262 ),
263 ),
264 );
265 expect(ping.pong).toEqual(true);
266 expect(ping.hasOutputUrl).toEqual(true);
267
268 const written = yield* untilOk(
269 HttpClient.get(`${deployed.api.url}/set`).pipe(
270 Effect.flatMap((res) =>
271 res.status === 200
272 ? res.json
273 : Effect.fail(new Error(`api returned ${res.status}`)),
274 ),
275 Effect.map((value) => value as { ok: boolean }),
276 ),
277 );
278 expect(written.ok).toEqual(true);
279
280 const read = yield* untilOk(
281 HttpClient.get(`${deployed.api.url}/get`).pipe(
282 Effect.flatMap((res) =>
283 res.status === 200
284 ? res.json
285 : Effect.fail(new Error(`api returned ${res.status}`)),
286 ),
287 Effect.map((value) => value as { ok: boolean; value: string }),
288 ),
289 );
290 expect(read.ok).toEqual(true);
291 expect(read.value).toEqual(REDIS_VALUE);
292
293 yield* stack.destroy();
294
295 const redisGone = yield* waitUntilRedisGone(
296 deployed.cache.redisId,
297 deployed.cache.name,
298 );
299 expect(redisGone).toEqual("gone");
300 const appGone = yield* waitUntilAppGone(deployed.app.appName);
301 expect(appGone).toEqual("gone");
302 }).pipe(logLevel),
303 {
304 tags: [
305 "provider:fly",
306 "provider:fly:app",
307 "provider:fly:ipassignment",
308 "provider:fly:machine",
309 "provider:fly:redis",
310 "provider:fly:service",
311 "live",
312 ],
313 timeout: 180_000,
314 },
315);
316
317test.provider(
318 "create redis on a fixed plan",
319 (stack) =>
320 Effect.gen(function* () {
321 yield* stack.destroy();
322
323 const plans = yield* Fly.listRedisPlans();
324 const fixed = plans.find((plan) => Fly.isFixedRedisPlan(plan));
325 expect(fixed).toBeDefined();
326
327 const created = yield* stack.deploy(
328 Effect.gen(function* () {
329 return yield* Fly.Redis("FixedCache", {
330 plan: fixed!.id,
331 });
332 }),
333 );
334
335 expect(created.planId).toEqual(fixed!.id);
336
337 yield* stack.destroy();
338
339 const gone = yield* waitUntilRedisGone(created.redisId, created.name);
340 expect(gone).toEqual("gone");
341 }).pipe(logLevel),
342 { tags: ["provider:fly", "provider:fly:redis", "live"], timeout: 120_000 },
343);