1import * as addons from "@distilled.cloud/fly-io/addons";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Fly from "@/Fly";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import BucketApi, {
12 BucketIp,
13 BucketSite,
14 Data,
15 OBJECT_BODY,
16} from "./fixtures/bucket-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 listTigris = () =>
26 Effect.gen(function* () {
27 const rows: Array<{ id: string; name: string | null; options: unknown }> =
28 [];
29 let after: string | undefined;
30 for (let i = 0; i < 8; i++) {
31 const page = yield* addons.addOns({
32 type: "tigris",
33 first: 50,
34 after,
35 });
36 for (const edge of page.edges ?? []) {
37 if (edge?.node != null) rows.push(edge.node);
38 }
39 if (!page.pageInfo.hasNextPage) break;
40 after = page.pageInfo.endCursor ?? undefined;
41 if (after === undefined || after.length === 0) break;
42 }
43 return rows;
44 });
45
46const findAddOn = (addOnId: string, name: string) =>
47 listTigris().pipe(
48 Effect.map(
49 (addOns) =>
50 addOns.find((addOn) => addOn.id === addOnId) ??
51 addOns.find((addOn) => addOn.name === name),
52 ),
53 );
54
55const waitUntilBucketGone = (addOnId: string, name: string) =>
56 findAddOn(addOnId, name).pipe(
57 Effect.map((addOn) =>
58 addOn === undefined ? ("gone" as const) : ("found" as const),
59 ),
60 Effect.repeat({
61 schedule: Schedule.spaced("2 seconds"),
62 until: (status) => status === "gone",
63 times: 10,
64 }),
65 );
66
67const waitUntilAppGone = (appName: string) =>
68 machines.getApp({ app_name: appName }).pipe(
69 Effect.as("found" as const),
70 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
71 Effect.repeat({
72 schedule: Schedule.spaced("1 second"),
73 until: (status) => status === "gone",
74 times: 10,
75 }),
76 );
77
78const listedSecrets = (appName: string) =>
79 machines
80 .listSecrets({
81 app_name: appName,
82 show_secrets: false,
83 })
84 .pipe(
85 Effect.map(
86 (res) => new Set((res.secrets ?? []).map((secret) => secret.name)),
87 ),
88 Effect.catchTag("NotFound", () => Effect.succeed(new Set<string>())),
89 );
90
91test.provider(
92 "create, update, and destroy a tigris bucket",
93 (stack) =>
94 Effect.gen(function* () {
95 yield* stack.destroy();
96
97 const created = yield* stack.deploy(
98 Effect.gen(function* () {
99 return yield* Fly.Bucket("Data");
100 }),
101 );
102
103 expect(created.addOnId).toEqual(expect.any(String));
104 expect(created.addOnId.length).toBeGreaterThan(0);
105 expect(created.name).toEqual(expect.any(String));
106 expect(created.name.length).toBeGreaterThan(0);
107 expect(created.public).toEqual(false);
108
109 const fetched = yield* findAddOn(created.addOnId, created.name);
110 expect(fetched).toBeDefined();
111 expect(fetched?.id).toEqual(created.addOnId);
112 expect(fetched?.name).toEqual(created.name);
113
114 const updated = yield* stack.deploy(
115 Effect.gen(function* () {
116 return yield* Fly.Bucket("Data", {
117 public: true,
118 });
119 }),
120 );
121
122 expect(updated.addOnId).toEqual(created.addOnId);
123 expect(updated.name).toEqual(created.name);
124
125 const refetched = yield* findAddOn(updated.addOnId, updated.name);
126 expect(refetched).toBeDefined();
127 expect(refetched?.id).toEqual(created.addOnId);
128
129 const provider = yield* Provider.findProvider(Fly.Bucket);
130 const all = yield* provider.list();
131 const listed = all.find((row) => row.addOnId === created.addOnId);
132 expect(listed).toBeDefined();
133 expect(listed?.name).toEqual(created.name);
134
135 yield* stack.destroy();
136
137 const gone = yield* waitUntilBucketGone(created.addOnId, created.name);
138 expect(gone).toEqual("gone");
139 }).pipe(logLevel),
140 { tags: ["provider:fly", "provider:fly:bucket", "live"], timeout: 120_000 },
141);
142
143test.provider(
144 "replace when the name changes",
145 (stack) =>
146 Effect.gen(function* () {
147 yield* stack.destroy();
148
149 const created = yield* stack.deploy(
150 Effect.gen(function* () {
151 return yield* Fly.Bucket("Data");
152 }),
153 );
154
155 const nextName = sanitizeReplaceName(created.name);
156 const replaced = yield* stack.deploy(
157 Effect.gen(function* () {
158 return yield* Fly.Bucket("Data", {
159 name: nextName,
160 });
161 }),
162 );
163
164 expect(replaced.name).toEqual(nextName);
165 expect(replaced.addOnId).not.toEqual(created.addOnId);
166
167 const oldGone = yield* findAddOn(created.addOnId, created.name);
168 expect(oldGone).toBeUndefined();
169 const next = yield* findAddOn(replaced.addOnId, replaced.name);
170 expect(next).toBeDefined();
171 expect(next?.name).toEqual(nextName);
172
173 yield* stack.destroy();
174
175 const gone = yield* waitUntilBucketGone(replaced.addOnId, replaced.name);
176 expect(gone).toEqual("gone");
177 }).pipe(logLevel),
178 { tags: ["provider:fly", "provider:fly:bucket", "live"], timeout: 120_000 },
179);
180
181test.provider(
182 "a Service puts and gets an object on Tigris",
183 (stack) =>
184 Effect.gen(function* () {
185 yield* stack.destroy();
186
187 const out = yield* stack.deploy(
188 Effect.gen(function* () {
189 const app = yield* BucketSite;
190 const bucket = yield* Data;
191 const ip = yield* BucketIp;
192 const api = yield* BucketApi;
193 return { app, bucket, ip, api };
194 }),
195 );
196
197 expect(out.api.url).toEqual(`https://${out.app.appName}.fly.dev`);
198
199 const names = yield* listedSecrets(out.app.appName).pipe(
200 Effect.flatMap((set) =>
201 set.has("BUCKET_NAME") && set.has("AWS_ACCESS_KEY_ID")
202 ? Effect.succeed(set)
203 : Effect.fail(
204 new Error(
205 `missing Tigris secrets: ${[...set].join(",") || "(none)"}`,
206 ),
207 ),
208 ),
209 Effect.retry({
210 schedule: Schedule.spaced("2 seconds"),
211 times: 8,
212 }),
213 );
214 expect(names.has("BUCKET_NAME")).toEqual(true);
215 expect(names.has("AWS_ACCESS_KEY_ID")).toEqual(true);
216
217 const untilOk = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
218 effect.pipe(
219 Effect.retry({
220 schedule: Schedule.spaced("4 seconds"),
221 times: 10,
222 }),
223 );
224
225 const put = yield* untilOk(
226 HttpClient.get(`${out.api.url}/put`).pipe(
227 Effect.flatMap((res) =>
228 res.status === 200
229 ? res.json
230 : Effect.fail(new Error(`api returned ${res.status}`)),
231 ),
232 Effect.map((value) => value as { ok: boolean }),
233 ),
234 );
235 expect(put.ok).toEqual(true);
236
237 const got = yield* untilOk(
238 HttpClient.get(`${out.api.url}/get`).pipe(
239 Effect.flatMap((res) =>
240 res.status === 200
241 ? res.json
242 : Effect.fail(new Error(`api returned ${res.status}`)),
243 ),
244 Effect.map((value) => value as { ok: boolean; text: string }),
245 ),
246 );
247 expect(got.ok).toEqual(true);
248 expect(got.text).toEqual(OBJECT_BODY);
249
250 yield* stack.destroy();
251
252 const bucketGone = yield* waitUntilBucketGone(
253 out.bucket.addOnId,
254 out.bucket.name,
255 );
256 expect(bucketGone).toEqual("gone");
257 const appGone = yield* waitUntilAppGone(out.app.appName);
258 expect(appGone).toEqual("gone");
259 }).pipe(logLevel),
260 {
261 tags: [
262 "provider:fly",
263 "provider:fly:app",
264 "provider:fly:bucket",
265 "provider:fly:ipassignment",
266 "provider:fly:machine",
267 "provider:fly:service",
268 "live",
269 ],
270 timeout: 120_000,
271 },
272);
273
274const sanitizeReplaceName = (name: string): string => {
275 const clipped = name.length >= 30 ? name.slice(0, 29) : name;
276 return `${clipped}x`.replace(/[^a-z0-9-]/g, "-").slice(0, 63);
277};