1import * as storage from "@distilled.cloud/gcp/storage_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { DeleteNotConfirmed } from "../Errors.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 listAlchemyBuckets,
13 toFolderName,
14 withTrailingSlash,
15} from "./internal.ts";
16
17export type ManagedProps = {
18 /**
19 * Name of the uniform-access bucket that contains this managed folder.
20 * Immutable — changing it replaces the folder.
21 */
22 bucketName: string;
23 /**
24 * Managed folder path. A trailing slash is added if omitted. If
25 * omitted entirely, a unique name is generated from the stack, stage,
26 * and logical id. Immutable — changing it replaces the folder.
27 */
28 managedFolderName?: string;
29};
30
31export type Managed = Resource<
32 "GCP.Storage.Managed",
33 ManagedProps,
34 {
35 /** Parent bucket name. */
36 bucketName: string;
37 /** Managed folder path, including the trailing slash. */
38 managedFolderName: string;
39 /** Server-assigned id (`{bucket}/{folder}`). */
40 id: string | undefined;
41 /** Metadata generation. */
42 metageneration: string | undefined;
43 /** RFC3339 creation timestamp. */
44 createTime: string | undefined;
45 /** RFC3339 last-update timestamp. */
46 updateTime: string | undefined;
47 /** GCS self-link. */
48 selfLink: string | undefined;
49 },
50 never,
51 Providers
52>;
53
54/**
55 * A Cloud Storage managed folder.
56 *
57 * Managed folders provide IAM on an object-name prefix in a bucket with
58 * uniform bucket-level access. Identity is `(bucket, folder name)`.
59 * Managed folders have no mutable fields and no labels, so `list` /
60 * `pnpm nuke:gcp` discover them by enumerating alchemy-labeled buckets.
61 *
62 * ### Creating a Managed Folder
63 * **Example:** Generated name
64 * ```typescript
65 * const bucket = yield* GCP.Storage.Bucket("assets", {
66 * uniformBucketLevelAccess: true,
67 * forceDestroy: true,
68 * });
69 * const folder = yield* GCP.Storage.Managed("team", {
70 * bucketName: bucket.bucketName,
71 * });
72 * ```
73 *
74 * **Example:** Explicit path
75 * ```typescript
76 * const folder = yield* GCP.Storage.Managed("team", {
77 * bucketName: bucket.bucketName,
78 * managedFolderName: "teams/payments/",
79 * });
80 * ```
81 *
82 * @resource
83 * @category Storage
84 */
85export const Managed = Resource<Managed>("GCP.Storage.Managed");
86
87export class ManagedNotResolved extends Data.TaggedError(
88 "GCP.Storage.ManagedNotResolved",
89)<{
90 bucketName: string;
91 managedFolderName: string;
92}> {}
93
94const toAttrs = (folder: storage.ManagedFolder, bucketName: string) => ({
95 bucketName: folder.bucket ?? bucketName,
96 managedFolderName: withTrailingSlash(folder.name ?? ""),
97 id: folder.id,
98 metageneration: folder.metageneration,
99 createTime: folder.createTime,
100 updateTime: folder.updateTime,
101 selfLink: folder.selfLink,
102});
103
104const getByName = (bucketName: string, managedFolderName: string) =>
105 storage
106 .getManagedFolders({
107 bucket: bucketName,
108 managedFolder: managedFolderName,
109 })
110 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
111
112const listOnBucket = (bucketName: string) =>
113 storage.listManagedFolders.items({ bucket: bucketName, pageSize: 1000 }).pipe(
114 Stream.runCollect,
115 Effect.map((chunk) => Array.from(chunk)),
116 Effect.catchTag("NotFound", () =>
117 Effect.succeed([] as storage.ManagedFolder[]),
118 ),
119 );
120
121const waitUntilGone = (bucketName: string, managedFolderName: string) =>
122 getByName(bucketName, managedFolderName).pipe(
123 Effect.map((existing) =>
124 existing === undefined ? ("gone" as const) : ("found" as const),
125 ),
126 Effect.repeat({
127 schedule: Schedule.spaced("1 second"),
128 until: (status) => status === "gone",
129 times: 30,
130 }),
131 Effect.flatMap((status): Effect.Effect<void, DeleteNotConfirmed> =>
132 status === "gone"
133 ? Effect.void
134 : Effect.fail(
135 new DeleteNotConfirmed({
136 resource: `${bucketName}/managedFolders/${managedFolderName}`,
137 }),
138 ),
139 ),
140 );
141
142export const ManagedProvider = () =>
143 Provider.succeed(Managed, {
144 stables: [
145 "bucketName",
146 "managedFolderName",
147 "id",
148 "selfLink",
149 "createTime",
150 ],
151
152 diff: Effect.fn(function* ({ news, olds, output }) {
153 if (!isResolved(news)) return undefined;
154 const previousBucket = olds?.bucketName ?? output?.bucketName;
155 if (previousBucket !== undefined && news.bucketName !== previousBucket) {
156 return { action: "replace" as const, deleteFirst: false };
157 }
158 const previousName = olds?.managedFolderName ?? output?.managedFolderName;
159 if (
160 news.managedFolderName !== undefined &&
161 previousName !== undefined &&
162 withTrailingSlash(news.managedFolderName) !==
163 withTrailingSlash(previousName)
164 ) {
165 return { action: "replace" as const, deleteFirst: false };
166 }
167 return undefined;
168 }),
169
170 read: Effect.fn(function* ({ id, olds, output }) {
171 const bucketName = olds?.bucketName ?? output?.bucketName;
172 if (!bucketName) return undefined;
173 const managedFolderName = yield* toFolderName(
174 id,
175 olds?.managedFolderName,
176 output?.managedFolderName,
177 );
178 const existing = yield* getByName(bucketName, managedFolderName);
179 if (existing === undefined) return undefined;
180 return toAttrs(existing, bucketName);
181 }),
182
183 list: () =>
184 Effect.gen(function* () {
185 const buckets = yield* listAlchemyBuckets();
186 const pages = yield* Effect.forEach(
187 buckets,
188 (bucket) => {
189 const bucketName = bucket.name;
190 if (
191 !bucketName ||
192 bucket.iamConfiguration?.uniformBucketLevelAccess?.enabled !==
193 true
194 ) {
195 return Effect.succeed([] as Array<Managed["Attributes"]>);
196 }
197 return listOnBucket(bucketName).pipe(
198 Effect.map((items) =>
199 items.map((item) => toAttrs(item, bucketName)),
200 ),
201 );
202 },
203 { concurrency: 8 },
204 );
205 return pages.flat();
206 }),
207
208 reconcile: Effect.fn(function* ({ id, news, output }) {
209 const bucketName = news.bucketName;
210 const managedFolderName = yield* toFolderName(
211 id,
212 news.managedFolderName,
213 output?.managedFolderName,
214 );
215
216 let current = yield* getByName(bucketName, managedFolderName);
217
218 if (current === undefined) {
219 const created = yield* storage
220 .insertManagedFolders({
221 bucket: bucketName,
222 body: { name: managedFolderName },
223 })
224 .pipe(
225 Effect.catchTag("Conflict", () =>
226 getByName(bucketName, managedFolderName),
227 ),
228 );
229 current = created ?? undefined;
230 }
231
232 if (current === undefined || !(current.name ?? current.id)) {
233 return yield* new ManagedNotResolved({
234 bucketName,
235 managedFolderName,
236 });
237 }
238
239 return toAttrs(current, bucketName);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 const bucketName = output.bucketName;
244 const managedFolderName = output.managedFolderName;
245 if (!bucketName || !managedFolderName) return;
246 yield* storage
247 .deleteManagedFolders({
248 bucket: bucketName,
249 managedFolder: managedFolderName,
250 allowNonEmpty: true,
251 })
252 .pipe(Effect.catchTag("NotFound", () => Effect.void));
253 yield* waitUntilGone(bucketName, managedFolderName);
254 }),
255 });