1import * as GCP from "@/GCP";
2import { ObjectRequestFailed } from "@/GCP/Storage/ObjectMedia.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import { serveProbes } from "../../bindingHost.ts";
6
7/** Object key and content seeded into the read-side buckets. */
8export const SEED_KEY = "seed/hello.txt";
9export const SEED_TEXT = "hello from the seed object";
10
11/**
12 * One bucket per binding, so each bucket's IAM policy shows exactly the
13 * role that binding grants.
14 */
15const bucket = (id: string) =>
16 GCP.Storage.Bucket(id, { location: "US-CENTRAL1", forceDestroy: true });
17
18const seeded = (id: string) =>
19 Effect.gen(function* () {
20 const target = yield* bucket(id);
21 yield* GCP.Storage.Object(`${id}Seed`, {
22 bucketName: target.bucketName,
23 key: SEED_KEY,
24 content: SEED_TEXT,
25 contentType: "text/plain",
26 });
27 return target;
28 });
29
30export const ReadAssets = seeded("ReadAssets");
31export const WriteAssets = bucket("WriteAssets");
32export const ReadWriteAssets = bucket("ReadWriteAssets");
33export const GetAssets = seeded("GetAssets");
34export const PutAssets = bucket("PutAssets");
35export const DeleteAssets = seeded("DeleteAssets");
36export const SignAssets = seeded("SignAssets");
37
38/**
39 * A 403 from the media endpoints: the fresh bucket grant has not
40 * propagated yet (`callProbe` retries on this tag).
41 */
42class Forbidden extends Data.TaggedError("Forbidden")<{ message: string }> {}
43
44const forbiddenAsTag = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
45 effect.pipe(
46 Effect.mapError((error) =>
47 error instanceof ObjectRequestFailed && error.status === 403
48 ? new Forbidden({ message: error.message })
49 : error,
50 ),
51 );
52
53const text = (body: Uint8Array) => new TextDecoder().decode(body);
54
55/**
56 * Effect-native Cloud Run service exercising every Cloud Storage binding
57 * as its own runtime service account. Deployed from
58 * {@link ../Bindings.test.ts}.
59 */
60export default class StorageBindingsHost extends GCP.Function<StorageBindingsHost>()(
61 "StorageBindingsHost",
62 { main: import.meta.url, invokerIamDisabled: true },
63 Effect.gen(function* () {
64 const readBucket = yield* GCP.Storage.ReadBucket(ReadAssets);
65 const writeBucket = yield* GCP.Storage.WriteBucket(WriteAssets);
66 const readWriteBucket = yield* GCP.Storage.ReadWriteBucket(ReadWriteAssets);
67 const getObject = yield* GCP.Storage.GetObject(GetAssets);
68 const putObject = yield* GCP.Storage.PutObject(PutAssets);
69 const deleteObject = yield* GCP.Storage.DeleteObject(DeleteAssets);
70 const signGetObjectUrl = yield* GCP.Storage.SignGetObjectUrl(SignAssets);
71
72 return {
73 fetch: serveProbes({
74 readBucket: forbiddenAsTag(
75 Effect.gen(function* () {
76 const head = yield* readBucket.head(SEED_KEY);
77 const object = yield* readBucket.get(SEED_KEY);
78 const listed = yield* readBucket.list({ prefix: "seed/" });
79 const missingHead = yield* readBucket.head("missing.txt");
80 const missingGet = yield* readBucket.get("missing.txt");
81 return {
82 headName: head?.name,
83 headSize: head?.size,
84 text: object && text(object.body),
85 contentType: object?.contentType,
86 listed: listed.objects.map((item) => item.name),
87 missingHead: missingHead === undefined,
88 missingGet: missingGet === undefined,
89 };
90 }),
91 ),
92 writeBucketPut: forbiddenAsTag(
93 writeBucket
94 .put("written.txt", "hello from WriteBucket", {
95 contentType: "text/plain",
96 metadata: { source: "write-bucket" },
97 })
98 .pipe(Effect.map((object) => ({ name: object.name }))),
99 ),
100 writeBucketDelete: Effect.gen(function* () {
101 yield* writeBucket.delete("written.txt");
102 // Deleting a missing object succeeds.
103 yield* writeBucket.delete("missing.txt");
104 return { deleted: "written.txt" };
105 }),
106 readWriteBucket: forbiddenAsTag(
107 Effect.gen(function* () {
108 const put = yield* readWriteBucket.put(
109 "round-trip.txt",
110 "hello from ReadWriteBucket",
111 );
112 const object = yield* readWriteBucket.get("round-trip.txt");
113 const listed = yield* readWriteBucket.list();
114 return {
115 putName: put.name,
116 text: object && text(object.body),
117 listed: listed.objects.map((item) => item.name),
118 };
119 }),
120 ),
121 readWriteBucketDelete: Effect.gen(function* () {
122 yield* readWriteBucket.delete("round-trip.txt");
123 const after = yield* readWriteBucket.head("round-trip.txt");
124 return { gone: after === undefined };
125 }),
126 getObject: forbiddenAsTag(
127 Effect.gen(function* () {
128 const object = yield* getObject({ object: SEED_KEY });
129 return { text: text(object.body), contentType: object.contentType };
130 }),
131 ),
132 getObjectMissing: forbiddenAsTag(getObject({ object: "missing.txt" })),
133 putObject: forbiddenAsTag(
134 putObject({
135 name: "put.bin",
136 body: new TextEncoder().encode("hello from PutObject"),
137 contentType: "application/octet-stream",
138 metadata: { source: "put-object" },
139 }).pipe(
140 Effect.map((object) => ({
141 name: object.name,
142 metadata: object.metadata,
143 })),
144 ),
145 ),
146 deleteObject: deleteObject({ object: SEED_KEY }).pipe(
147 Effect.as({ deleted: SEED_KEY }),
148 ),
149 deleteObjectMissing: deleteObject({ object: "missing.txt" }),
150 signGetObjectUrl: signGetObjectUrl({
151 object: SEED_KEY,
152 expiresIn: 600,
153 contentType: "text/plain",
154 }).pipe(Effect.map((url) => ({ url }))),
155 }),
156 };
157 }).pipe(
158 Effect.provide(GCP.Storage.ReadBucketHttp),
159 Effect.provide(GCP.Storage.WriteBucketHttp),
160 Effect.provide(GCP.Storage.ReadWriteBucketHttp),
161 Effect.provide(GCP.Storage.GetObjectHttp),
162 Effect.provide(GCP.Storage.PutObjectHttp),
163 Effect.provide(GCP.Storage.DeleteObjectHttp),
164 Effect.provide(GCP.Storage.SignGetObjectUrlHttp),
165 ),
166) {}