1import { fromCredentials } from "@distilled.cloud/aws/Credentials";
2import * as AwsEndpoint from "@distilled.cloud/aws/Endpoint";
3import type { RegionName } from "@distilled.cloud/aws/Region";
4import * as S3 from "@distilled.cloud/aws/s3";
5import { Query } from "@distilled.cloud/core/query";
6import { Railway as RailwayApi } from "@distilled.cloud/railway";
7import * as Provider from "@/Provider";
8import * as Railway from "@/Railway";
9import { projectBuckets } from "@/Railway/GraphQL.ts";
10import { suitePartition } from "./suiteProject.ts";
11import * as Test from "@/Test/Alchemy";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { MinimumLogLevel } from "effect/References";
16import * as Schedule from "effect/Schedule";
17import * as Stream from "effect/Stream";
18
19const { test } = Test.make({ providers: Railway.providers() });
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const OBJECT_KEY = "alchemy-marker.txt";
27const OBJECT_BODY = "hello-from-railway";
28
29const readBucketCredentials = Query.fn(
30 (bucketId: string, environmentId: string, projectId: string) =>
31 RailwayApi.bucketS3Credentials({ bucketId, environmentId, projectId }).pipe(
32 Query.map((creds) => ({
33 bucketName: creds.bucketName,
34 endpoint: creds.endpoint,
35 accessKeyId: creds.accessKeyId,
36 secretAccessKey: creds.secretAccessKey,
37 region: creds.region,
38 })),
39 ),
40);
41
42const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({
43 config: RailwayApi.environment({ id, projectId }).config,
44}));
45
46const listProjectBuckets = (projectId: string) =>
47 projectBuckets(projectId, (bucket) => ({
48 id: bucket.id,
49 name: bucket.name,
50 projectId: bucket.projectId,
51 })).pipe(Effect.catchTag("RailwayNotFound", () => Effect.succeed([])));
52
53const findBucket = (projectId: string, bucketId: string, name: string) =>
54 listProjectBuckets(projectId).pipe(
55 Effect.map(
56 (buckets) =>
57 buckets.find((bucket) => bucket.id === bucketId) ??
58 buckets.find((bucket) => bucket.name === name),
59 ),
60 );
61
62const firstCredentials = (
63 bucketId: string,
64 environmentId: string,
65 projectId: string,
66) =>
67 readBucketCredentials(bucketId, environmentId, projectId).pipe(
68 Effect.flatMap((items) => {
69 const first = items[0];
70 return first !== undefined
71 ? Effect.succeed(first)
72 : Effect.fail(new Error("missing bucket credentials"));
73 }),
74 Effect.retry({
75 schedule: Schedule.spaced("2 seconds"),
76 times: 8,
77 }),
78 );
79
80const waitUntilBucketGone = (
81 environmentId: string,
82 projectId: string,
83 bucketId: string,
84) =>
85 readEnvironmentConfig(environmentId, projectId).pipe(
86 Effect.map((env) => {
87 const buckets =
88 env.config !== null &&
89 typeof env.config === "object" &&
90 !Array.isArray(env.config)
91 ? (
92 env.config as {
93 buckets?: Record<string, { isDeleted?: boolean | null } | null>;
94 }
95 ).buckets
96 : undefined;
97 const row = buckets?.[bucketId];
98 return row == null || row.isDeleted === true
99 ? ("gone" as const)
100 : ("found" as const);
101 }),
102 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
103 Effect.repeat({
104 schedule: Schedule.spaced("2 seconds"),
105 until: (status) => status === "gone",
106 times: 10,
107 }),
108 );
109
110const withBucketS3 = <A, E, R>(
111 creds: {
112 accessKeyId: string;
113 secretAccessKey: string;
114 endpoint: string;
115 region: string;
116 },
117 operation: Effect.Effect<A, E, R>,
118) =>
119 operation.pipe(
120 Effect.provide(
121 Layer.mergeAll(
122 fromCredentials(
123 {
124 accessKeyId: creds.accessKeyId,
125 secretAccessKey: creds.secretAccessKey,
126 },
127 creds.region as RegionName,
128 ),
129 AwsEndpoint.of(creds.endpoint),
130 ),
131 ),
132 );
133
134test.provider(
135 "create, update, list, put/get/delete objects, and delete a bucket",
136 (stack) =>
137 Effect.gen(function* () {
138 yield* stack.destroy();
139
140 const created = yield* stack.deploy(
141 Effect.gen(function* () {
142 const { project, environment } = yield* suitePartition;
143 const bucket = yield* Railway.Bucket("Data", {
144 project,
145 environment,
146 });
147 return { project, environment, bucket };
148 }),
149 );
150
151 expect(created.bucket.bucketId).toEqual(expect.any(String));
152 expect(created.bucket.bucketId.length).toBeGreaterThan(0);
153 expect(created.bucket.projectId).toEqual(created.project.projectId);
154 expect(created.bucket.environmentId).toEqual(
155 created.environment.environmentId,
156 );
157 expect(created.bucket.name).toEqual(expect.any(String));
158 expect(created.bucket.name.length).toBeGreaterThan(0);
159 expect(created.bucket.name.length).toBeLessThanOrEqual(32);
160 expect(created.bucket.name).toMatch(/^[a-z][a-z0-9-]*$/);
161 expect(created.bucket.region).toEqual("sjc");
162 expect(created.bucket.s3BucketName).toEqual(expect.any(String));
163 expect((created.bucket.s3BucketName ?? "").length).toBeGreaterThan(0);
164 expect(created.bucket.endpoint).toEqual(expect.any(String));
165 expect(created.bucket.createdAt).toEqual(expect.any(String));
166
167 const fetched = yield* findBucket(
168 created.project.projectId,
169 created.bucket.bucketId,
170 created.bucket.name,
171 );
172 expect(fetched).toBeDefined();
173 expect(fetched?.id).toEqual(created.bucket.bucketId);
174 expect(fetched?.name).toEqual(created.bucket.name);
175 expect(fetched?.projectId).toEqual(created.project.projectId);
176
177 const provider = yield* Provider.findProvider(Railway.Bucket);
178 const listed = yield* provider.list();
179 const found = listed.find(
180 (bucket) => bucket.bucketId === created.bucket.bucketId,
181 );
182 expect(found).toBeDefined();
183 expect(found?.name).toEqual(created.bucket.name);
184 expect(found?.projectId).toEqual(created.project.projectId);
185 expect(found?.environmentId).toEqual(created.bucket.environmentId);
186
187 const creds = yield* firstCredentials(
188 created.bucket.bucketId,
189 created.bucket.environmentId,
190 created.project.projectId,
191 );
192 expect(creds.bucketName.length).toBeGreaterThan(0);
193 expect(creds.endpoint.length).toBeGreaterThan(0);
194 expect(creds.accessKeyId.length).toBeGreaterThan(0);
195 expect(creds.secretAccessKey.length).toBeGreaterThan(0);
196
197 yield* withBucketS3(
198 creds,
199 S3.putObject({
200 Bucket: creds.bucketName,
201 Key: OBJECT_KEY,
202 Body: OBJECT_BODY,
203 ContentType: "text/plain",
204 }),
205 );
206
207 const got = yield* withBucketS3(
208 creds,
209 S3.getObject({
210 Bucket: creds.bucketName,
211 Key: OBJECT_KEY,
212 }),
213 );
214 const text =
215 got.Body === undefined
216 ? ""
217 : yield* Stream.mkString(Stream.decodeText(got.Body));
218 expect(text).toEqual(OBJECT_BODY);
219
220 const listedObjects = yield* withBucketS3(
221 creds,
222 S3.listObjectsV2({
223 Bucket: creds.bucketName,
224 Prefix: OBJECT_KEY,
225 }),
226 );
227 expect(
228 (listedObjects.Contents ?? []).some((item) => item.Key === OBJECT_KEY),
229 ).toEqual(true);
230
231 yield* withBucketS3(
232 creds,
233 S3.deleteObject({
234 Bucket: creds.bucketName,
235 Key: OBJECT_KEY,
236 }),
237 );
238
239 const nextName =
240 created.bucket.name.slice(0, -1) +
241 (created.bucket.name.endsWith("z") ? "y" : "z");
242
243 const updated = yield* stack.deploy(
244 Effect.gen(function* () {
245 const { project, environment } = yield* suitePartition;
246 const bucket = yield* Railway.Bucket("Data", {
247 project,
248 environment,
249 name: nextName,
250 });
251 return { project, environment, bucket };
252 }),
253 );
254
255 expect(updated.bucket.bucketId).toEqual(created.bucket.bucketId);
256 expect(updated.bucket.name).toEqual(nextName);
257 expect(updated.bucket.projectId).toEqual(created.project.projectId);
258 expect(updated.bucket.environmentId).toEqual(
259 created.bucket.environmentId,
260 );
261
262 const fetchedUpdate = yield* findBucket(
263 updated.project.projectId,
264 updated.bucket.bucketId,
265 updated.bucket.name,
266 );
267 expect(fetchedUpdate).toBeDefined();
268 expect(fetchedUpdate?.id).toEqual(created.bucket.bucketId);
269 expect(fetchedUpdate?.name).toEqual(nextName);
270
271 yield* stack.destroy();
272
273 const gone = yield* waitUntilBucketGone(
274 created.bucket.environmentId,
275 created.project.projectId,
276 created.bucket.bucketId,
277 );
278 expect(gone).toEqual("gone");
279 }).pipe(logLevel),
280 {
281 tags: [
282 "provider:aws",
283 "provider:aws:s3",
284 "provider:railway",
285 "provider:railway:bucket",
286 "provider:railway:project",
287 "provider:railway:projectenvironment",
288 "live",
289 ],
290 timeout: 120_000,
291 },
292);