1import * as mc from "@distilled.cloud/gcp/migrationcenter_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 fieldMask,
20 hasAlchemyLabelMap,
21 locationParent,
22 normalizeLocation,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilExists,
30 waitUntilGone,
31} from "./internal.ts";
32
33export type GroupProps = {
34 /**
35 * Group id (the `{group}` segment of
36 * `projects/{project}/locations/{location}/groups/{group}`). If omitted,
37 * a unique RFC1035 name is generated. Immutable — changing it replaces
38 * the group.
39 */
40 groupId?: string;
41 /**
42 * Region (`us-central1`, …). Immutable — changing it replaces the
43 * group. `US-CENTRAL1` is accepted and normalized to `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, else the profile
45 * region, else `us-central1`)
46 */
47 location?: string;
48 /**
49 * User-friendly display name.
50 */
51 displayName?: string;
52 /**
53 * Free-text description.
54 */
55 description?: string;
56 /**
57 * User labels. Alchemy ownership labels are merged in automatically.
58 */
59 labels?: Record<string, string>;
60};
61
62export type Group = Resource<
63 "GCP.MigrationCenter.Group",
64 GroupProps,
65 {
66 /** Full resource name. */
67 name: string;
68 /** Group id (last path segment). */
69 groupId: string;
70 /** Project id. */
71 project: string;
72 /** Location id. */
73 location: string;
74 /** User-friendly display name. */
75 displayName: string | undefined;
76 /** Free-text description. */
77 description: string | undefined;
78 /** User labels (Alchemy ownership labels stripped). */
79 labels: Record<string, string>;
80 /** RFC3339 creation timestamp. */
81 createTime: string | undefined;
82 /** RFC3339 last-update timestamp. */
83 updateTime: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A Migration Center asset group used to bundle related assets and
91 * generate reports.
92 *
93 * Changing `groupId` or `location` replaces the group. Display name,
94 * description, and labels update in place.
95 *
96 * ### Creating a Group
97 * **Example:** Generated name
98 * ```typescript
99 * const group = yield* GCP.MigrationCenter.Group("Workloads", {
100 * displayName: "workloads",
101 * labels: { env: "test" },
102 * });
103 * ```
104 *
105 * **Example:** Explicit id
106 * ```typescript
107 * const group = yield* GCP.MigrationCenter.Group("Workloads", {
108 * groupId: "app-workloads",
109 * description: "production vms",
110 * });
111 * ```
112 *
113 * @resource
114 * @category MigrationCenter
115 */
116export const Group = Resource<Group>("GCP.MigrationCenter.Group");
117
118const resourceName = (project: string, location: string, groupId: string) =>
119 `${locationParent(project, location)}/groups/${groupId}`;
120
121const toAttrs = (group: mc.Group, project: string, region: string) => {
122 const name = group.name ?? "";
123 const parsed = parseName(name, "groups", region);
124 return {
125 name,
126 groupId: parsed.id,
127 project: parsed.project || project,
128 location: parsed.location,
129 displayName: group.displayName,
130 description: group.description,
131 labels: userLabels(group.labels),
132 createTime: group.createTime,
133 updateTime: group.updateTime,
134 };
135};
136
137const getByName = (name: string) =>
138 name.length === 0
139 ? Effect.succeed(undefined)
140 : mc
141 .getProjectsLocationsGroups({ name })
142 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
143
144const listOwned = (project: string, region: string) =>
145 mc.listProjectsLocationsGroups
146 .pages({
147 parent: `projects/${project}/locations/-`,
148 pageSize: 1000,
149 })
150 .pipe(
151 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
152 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
153 Stream.runCollect,
154 Effect.map((chunk) => Array.from(chunk)),
155 Effect.catchTag("NotFound", () =>
156 mc.listProjectsLocationsGroups
157 .pages({
158 parent: locationParent(project, region),
159 pageSize: 1000,
160 })
161 .pipe(
162 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
163 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
164 Stream.runCollect,
165 Effect.map((chunk) => Array.from(chunk)),
166 Effect.catchTag("NotFound", () => Effect.succeed([] as mc.Group[])),
167 ),
168 ),
169 );
170
171export const GroupProvider = () =>
172 Provider.succeed(Group, {
173 stables: ["name", "groupId", "project", "location", "createTime"],
174
175 diff: Effect.fn(function* ({ news, olds, output }) {
176 if (!isResolved(news)) return undefined;
177 const env = yield* GcpEnvironment.current;
178 return replaceOnIdentity({
179 previousId: olds?.groupId ?? output?.groupId,
180 nextId: news.groupId ?? olds?.groupId ?? output?.groupId,
181 previousLocation: normalizeLocation(
182 olds?.location ?? output?.location,
183 env.region,
184 ),
185 nextLocation: normalizeLocation(
186 news.location ?? olds?.location ?? output?.location,
187 env.region,
188 ),
189 });
190 }),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const env = yield* GcpEnvironment.current;
194 const groupId = yield* toPhysicalId(
195 id,
196 olds?.groupId,
197 output?.groupId,
198 "group",
199 );
200 const location = normalizeLocation(
201 olds?.location ?? output?.location,
202 env.region,
203 );
204 const name = output?.name ?? resourceName(env.project, location, groupId);
205 const existing = yield* getByName(name);
206 if (existing === undefined) return undefined;
207 const attrs = toAttrs(existing, env.project, env.region);
208 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
209 ? attrs
210 : Unowned(attrs);
211 }),
212
213 list: () =>
214 Effect.gen(function* () {
215 const env = yield* GcpEnvironment.current;
216 const items = yield* listOwned(env.project, env.region);
217 return items.map((item) => toAttrs(item, env.project, env.region));
218 }),
219
220 reconcile: Effect.fn(function* ({ id, news, output }) {
221 const env = yield* GcpEnvironment.current;
222 const groupId = yield* toPhysicalId(
223 id,
224 news.groupId,
225 output?.groupId,
226 "group",
227 );
228 const location = normalizeLocation(
229 news.location ?? output?.location,
230 env.region,
231 );
232 const name = resourceName(env.project, location, groupId);
233 const desiredLabels = {
234 ...toLabels(news.labels),
235 ...(yield* createInternalLabels(id)),
236 };
237 const displayName = news.displayName ?? groupId;
238
239 let current = yield* getByName(output?.name ?? name);
240
241 if (current === undefined) {
242 const created = yield* mc
243 .createProjectsLocationsGroups({
244 parent: locationParent(env.project, location),
245 groupId,
246 body: {
247 displayName,
248 description: news.description,
249 labels: desiredLabels,
250 },
251 })
252 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
253 if (created !== undefined) {
254 yield* waitForOperation(created);
255 }
256 current = yield* waitUntilExists(getByName(name), name);
257 }
258
259 if (current === undefined) {
260 return yield* new ResourceNotResolved({ name });
261 }
262
263 const observedLabels = tagRecord(current.labels);
264 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
265 const labelsChanged = upsert.length > 0 || removed.length > 0;
266 const displayNameChanged = (current.displayName ?? "") !== displayName;
267 const descriptionChanged =
268 (current.description ?? "") !== (news.description ?? "");
269 const mask = fieldMask([
270 labelsChanged && "labels",
271 displayNameChanged && "displayName",
272 descriptionChanged && "description",
273 ]);
274
275 if (mask.length > 0) {
276 const operation = yield* mc.patchProjectsLocationsGroups({
277 name: current.name ?? name,
278 updateMask: mask,
279 body: {
280 name: current.name ?? name,
281 displayName,
282 description: news.description,
283 labels: desiredLabels,
284 },
285 });
286 yield* waitForOperation(operation);
287 current = yield* waitUntilExists(
288 getByName(current.name ?? name),
289 current.name ?? name,
290 );
291 }
292
293 return toAttrs(current, env.project, env.region);
294 }),
295
296 delete: Effect.fn(function* ({ output }) {
297 const operation = yield* mc
298 .deleteProjectsLocationsGroups({ name: output.name })
299 .pipe(
300 Effect.retry({
301 while: (error) => error._tag === "Conflict",
302 times: 8,
303 schedule: Schedule.spaced("2 seconds"),
304 }),
305 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
306 );
307 if (operation !== undefined) {
308 yield* waitForOperation(operation, { notFoundOk: true });
309 }
310 yield* waitUntilGone(getByName(output.name), output.name);
311 }),
312 });