1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import { attachBucketSecrets } from "@/Fly/Bucket";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as Redacted from "effect/Redacted";
11import * as Result from "effect/Result";
12import * as Schedule from "effect/Schedule";
13import * as HttpClient from "effect/http/HttpClient";
14
15const requests: Array<{ method: string; path: string }> = [];
16const providers = Layer.effect(
17 HttpClient.HttpClient,
18 Effect.gen(function* () {
19 const client = yield* HttpClient.HttpClient;
20 return HttpClient.mapRequestEffect(client, (request) =>
21 Effect.sync(() => {
22 if (request.url.startsWith("https://api.machines.dev/")) {
23 requests.push({
24 method: request.method,
25 path: new URL(request.url).pathname,
26 });
27 }
28 return request;
29 }),
30 );
31 }),
32).pipe(Layer.provideMerge(Fly.providers()));
33
34const { test } = Test.make({ providers });
35
36const assertAppGone = (appName: string) =>
37 machines.getApp({ app_name: appName }).pipe(
38 Effect.as(false),
39 Effect.catchTag("NotFound", () => Effect.succeed(true)),
40 Effect.repeat({
41 schedule: Schedule.spaced("1 second"),
42 until: (gone) => gone,
43 times: 10,
44 }),
45 Effect.tap((gone) => Effect.sync(() => expect(gone).toBe(true))),
46 );
47
48const secretMetadata = (appName: string) =>
49 machines
50 .listSecrets({ app_name: appName, show_secrets: false })
51 .pipe(
52 Effect.map((response) =>
53 (response.secrets ?? [])
54 .map((secret) => ({ name: secret.name, digest: secret.digest }))
55 .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
56 ),
57 );
58
59const scenarios: Array<{
60 name: string;
61 attachment: boolean;
62 env: Record<string, string>;
63}> = [
64 { name: "missing add-on without credentials", attachment: true, env: {} },
65 {
66 name: "missing add-on with only an access key and endpoint",
67 attachment: true,
68 env: {
69 AWS_ACCESS_KEY_ID: "unused-negative-test-key",
70 AWS_ENDPOINT_URL_S3: "https://fly.storage.tigris.dev",
71 },
72 },
73 {
74 name: "missing add-on with credentials but no endpoint",
75 attachment: true,
76 env: {
77 AWS_ACCESS_KEY_ID: "unused-negative-test-key",
78 AWS_SECRET_ACCESS_KEY: "unused-negative-test-secret",
79 },
80 },
81 {
82 name: "bound-env-only credentials without an endpoint",
83 attachment: false,
84 env: {
85 AWS_ACCESS_KEY_ID: "unused-negative-test-key",
86 AWS_SECRET_ACCESS_KEY: "unused-negative-test-secret",
87 },
88 },
89];
90
91for (const scenario of scenarios) {
92 test.provider(
93 `F12 ${scenario.name} fails before secret writes or candidate creation`,
94 (stack) =>
95 Effect.gen(function* () {
96 yield* stack.destroy();
97 const app = yield* stack.deploy(Fly.App("Site"));
98 yield* Effect.gen(function* () {
99 const missingName = `${app.appName}-absent-bucket`;
100 const before = yield* secretMetadata(app.appName);
101 const offset = yield* Effect.sync(() => requests.length);
102 const result = yield* stack
103 .deploy(
104 Effect.gen(function* () {
105 const site = yield* Fly.App("Site");
106 const service = yield* Fly.Service("Consumer", {
107 app: site,
108 // Attachment validation must fail before image resolution.
109 main: import.meta.url,
110 isExternal: true,
111 services: [],
112 deploy: { strategy: "bluegreen" },
113 checks: { ready: { type: "tcp", port: 3000 } },
114 });
115 yield* service.bind("BucketAttachment", {
116 bucket: scenario.attachment
117 ? { name: missingName }
118 : undefined,
119 env: { BUCKET_NAME: missingName, ...scenario.env },
120 });
121 return service;
122 }),
123 )
124 .pipe(Effect.result);
125 expect(Result.isFailure(result)).toBe(true);
126 if (Result.isFailure(result)) {
127 expect(result.failure).toMatchObject({
128 _tag: "Fly.TigrisCredentialsMissing",
129 });
130 }
131 const mutations = yield* Effect.sync(() => {
132 const prefix = `/v1/apps/${app.appName}/`;
133 return requests
134 .slice(offset)
135 .filter(
136 (request) =>
137 request.method !== "GET" &&
138 (request.path.startsWith(`${prefix}machines`) ||
139 request.path.startsWith(`${prefix}secrets`)),
140 );
141 });
142 expect(mutations).toEqual([]);
143 expect(
144 yield* machines.listMachines({ app_name: app.appName }),
145 ).toEqual([]);
146 expect(
147 yield* Effect.sync(() =>
148 requests
149 .slice(offset)
150 .some(
151 (request) =>
152 request.method === "GET" &&
153 request.path === `/v1/apps/${app.appName}/machines`,
154 ),
155 ),
156 ).toBe(true);
157 expect(yield* secretMetadata(app.appName)).toEqual(before);
158 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)));
159 yield* assertAppGone(app.appName);
160 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie))),
161 {
162 tags: [
163 "provider:fly",
164 "provider:fly:app",
165 "provider:fly:bucket",
166 "provider:fly:machine",
167 "provider:fly:service",
168 "live",
169 ],
170 timeout: 300_000,
171 },
172 );
173}
174
175const credential = (
176 value: Redacted.Redacted<string> | undefined,
177 field: string,
178) =>
179 value === undefined
180 ? Effect.fail(new Error(`Real Tigris bucket did not return ${field}`))
181 : Effect.sync(() => Redacted.value(value));
182
183test.provider(
184 "F12 complete real bound credentials survive a missing add-on reference",
185 (stack) =>
186 Effect.gen(function* () {
187 yield* stack.destroy();
188 const created = yield* stack.deploy(
189 Effect.gen(function* () {
190 const app = yield* Fly.App("Site");
191 const bucket = yield* Fly.Bucket("Data");
192 return { app, bucket };
193 }),
194 );
195 yield* Effect.gen(function* () {
196 const env = {
197 BUCKET_NAME: created.bucket.name,
198 AWS_ACCESS_KEY_ID: yield* credential(
199 created.bucket.accessKeyId,
200 "access key",
201 ),
202 AWS_SECRET_ACCESS_KEY: yield* credential(
203 created.bucket.secretAccessKey,
204 "secret key",
205 ),
206 AWS_ENDPOINT_URL_S3: yield* credential(
207 created.bucket.endpoint,
208 "endpoint",
209 ),
210 };
211 const floor = yield* attachBucketSecrets(
212 created.app.appName,
213 [{ name: "", id: `${created.bucket.addOnId}-absent` }],
214 env,
215 );
216 expect(floor).toEqual(expect.any(Number));
217 const fromEnvFloor = yield* attachBucketSecrets(
218 created.app.appName,
219 [],
220 env,
221 );
222 expect(fromEnvFloor).toEqual(expect.any(Number));
223 if (floor !== undefined && fromEnvFloor !== undefined) {
224 expect(fromEnvFloor).toBeGreaterThanOrEqual(floor);
225 }
226 const names = (yield* secretMetadata(created.app.appName)).map(
227 (secret) => secret.name,
228 );
229 for (const name of [...Object.keys(env), "AWS_ENDPOINT_URL"]) {
230 expect(names).toContain(name);
231 }
232 const appRequestCount = Effect.sync(
233 () =>
234 requests.filter((request) =>
235 request.path.startsWith(`/v1/apps/${created.app.appName}/`),
236 ).length,
237 );
238 const beforeNoop = yield* appRequestCount;
239 expect(
240 yield* attachBucketSecrets(created.app.appName, [], {}),
241 ).toBeUndefined();
242 expect(yield* appRequestCount).toBe(beforeNoop);
243 expect(
244 yield* machines.listMachines({ app_name: created.app.appName }),
245 ).toEqual([]);
246 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)));
247 yield* assertAppGone(created.app.appName);
248 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie))),
249 {
250 tags: [
251 "provider:fly",
252 "provider:fly:app",
253 "provider:fly:bucket",
254 "provider:fly:machine",
255 "live",
256 ],
257 timeout: 300_000,
258 },
259);
260
261test.provider(
262 "ordinary AWS environment without a Bucket deploys a Service without attachment writes",
263 (stack) =>
264 Effect.gen(function* () {
265 yield* stack.destroy();
266 const fs = yield* FileSystem.FileSystem;
267 const path = yield* Path.Path;
268 const directory = yield* fs.makeTempDirectoryScoped({
269 prefix: "alchemy-fly-ordinary-aws-env-",
270 });
271 const main = path.join(directory, "main.mjs");
272 yield* fs.writeFileString(
273 main,
274 'if (!process.env.AWS_REGION) throw new Error("missing AWS region");\nsetInterval(() => {}, 1000);\n',
275 );
276 const app = yield* stack.deploy(Fly.App("Site"));
277 const before = yield* secretMetadata(app.appName);
278 const environments: Array<Record<string, string>> = [
279 { AWS_REGION: "us-east-1" },
280 {
281 AWS_REGION: "us-east-1",
282 AWS_ACCESS_KEY_ID: "ordinary-aws-test-key",
283 AWS_SECRET_ACCESS_KEY: "ordinary-aws-test-secret",
284 },
285 ];
286 for (const env of environments) {
287 const offset = yield* Effect.sync(() => requests.length);
288 expect(
289 yield* attachBucketSecrets(app.appName, [], env),
290 ).toBeUndefined();
291 const service = yield* stack.deploy(
292 Effect.gen(function* () {
293 const site = yield* Fly.App("Site");
294 return yield* Fly.Service("Consumer", {
295 app: site,
296 main,
297 isExternal: true,
298 services: [],
299 env,
300 });
301 }),
302 );
303 const current = yield* machines.getMachine({
304 app_name: app.appName,
305 machine_id: service.machineId,
306 });
307 expect(current.state).toBe("started");
308 expect(current.config?.env).toMatchObject(env);
309 expect(yield* secretMetadata(app.appName)).toEqual(before);
310 const observed = yield* Effect.sync(() =>
311 requests
312 .slice(offset)
313 .filter((request) =>
314 request.path.startsWith(`/v1/apps/${app.appName}/`),
315 ),
316 );
317 expect(
318 observed.some(
319 (request) =>
320 request.method === "GET" &&
321 request.path ===
322 `/v1/apps/${app.appName}/machines/${service.machineId}`,
323 ),
324 ).toBe(true);
325 expect(
326 observed.filter(
327 (request) =>
328 request.method !== "GET" &&
329 request.path.startsWith(`/v1/apps/${app.appName}/secrets`),
330 ),
331 ).toEqual([]);
332 }
333 yield* stack.destroy();
334 yield* assertAppGone(app.appName);
335 }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)), Effect.scoped),
336 {
337 tags: [
338 "provider:fly",
339 "provider:fly:app",
340 "provider:fly:bucket",
341 "provider:fly:machine",
342 "provider:fly:service",
343 "live",
344 ],
345 timeout: 600_000,
346 },
347);