1import * as vm from "@distilled.cloud/gcp/vmmigration_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 { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnership,
14 fieldMask,
15 hasOwnershipMarker,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOnIdentity,
22 ResourceNotResolved,
23 toPhysicalId,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type GroupMigrationTargetType =
30 | vm.GroupMigrationTargetTypeEnum
31 | (string & {});
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`, profile region, `us-central1`)
45 */
46 location?: string;
47 /**
48 * User-friendly display name.
49 */
50 displayName?: string;
51 /**
52 * Free-text description. Groups have no labels field, so Alchemy
53 * ownership is stored in a `[alchemy …]` prefix and stripped from
54 * attributes.
55 */
56 description?: string;
57 /**
58 * Target type of this group. Immutable — changing it replaces the
59 * group.
60 * @default "MIGRATION_TARGET_TYPE_GCE"
61 */
62 migrationTargetType?: GroupMigrationTargetType;
63};
64
65export type Group = Resource<
66 "GCP.VMMigration.Group",
67 GroupProps,
68 {
69 /** Full resource name. */
70 name: string;
71 /** Group id (last path segment). */
72 groupId: string;
73 /** Project id. */
74 project: string;
75 /** Location id. */
76 location: string;
77 /** User-friendly display name. */
78 displayName: string | undefined;
79 /** User description with the Alchemy ownership prefix stripped. */
80 description: string | undefined;
81 /** Target type of this group. */
82 migrationTargetType: string | undefined;
83 /** RFC3339 creation timestamp. */
84 createTime: string | undefined;
85 /** RFC3339 last-update timestamp. */
86 updateTime: string | undefined;
87 },
88 never,
89 Providers
90>;
91
92/**
93 * A VM Migration group that bundles migrating VMs so they can be
94 * managed together.
95 *
96 * Groups have no labels field — Alchemy stamps ownership into the
97 * description so `list` / nuke can find them. `groupId`, `location`, and
98 * `migrationTargetType` are immutable. Display name and description
99 * update in place.
100 *
101 * ### Creating a Group
102 * **Example:** Generated name
103 * ```typescript
104 * const group = yield* GCP.VMMigration.Group("Workloads", {
105 * displayName: "workloads",
106 * description: "production vms",
107 * });
108 * ```
109 *
110 * **Example:** Disk-target group
111 * ```typescript
112 * const group = yield* GCP.VMMigration.Group("Disks", {
113 * groupId: "disk-workloads",
114 * migrationTargetType: "MIGRATION_TARGET_TYPE_DISKS",
115 * });
116 * ```
117 *
118 * @resource
119 * @category VMMigration
120 */
121export const Group = Resource<Group>("GCP.VMMigration.Group");
122
123const DEFAULT_TARGET: GroupMigrationTargetType = "MIGRATION_TARGET_TYPE_GCE";
124
125const resourceName = (project: string, location: string, groupId: string) =>
126 `${locationParent(project, location)}/groups/${groupId}`;
127
128const toAttrs = (group: vm.Group, project: string, region: string) => {
129 const name = group.name ?? "";
130 const parsed = parseName(name, "groups", region);
131 const ownership = parseOwnership(group.description);
132 return {
133 name,
134 groupId: parsed.id,
135 project: parsed.project || project,
136 location: parsed.location,
137 displayName: group.displayName,
138 description: ownership.text,
139 migrationTargetType: group.migrationTargetType,
140 createTime: group.createTime,
141 updateTime: group.updateTime,
142 };
143};
144
145const getByName = (name: string) =>
146 name.length === 0
147 ? Effect.succeed(undefined)
148 : vm
149 .getProjectsLocationsGroups({ name })
150 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
151
152const listOwned = (project: string, region: string) =>
153 vm.listProjectsLocationsGroups
154 .pages({
155 parent: `projects/${project}/locations/-`,
156 pageSize: 1000,
157 })
158 .pipe(
159 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
160 Stream.filter((item) => hasOwnershipMarker(item.description)),
161 Stream.runCollect,
162 Effect.map((chunk) => Array.from(chunk)),
163 Effect.catchTag("NotFound", () =>
164 vm.listProjectsLocationsGroups
165 .pages({
166 parent: locationParent(project, region),
167 pageSize: 1000,
168 })
169 .pipe(
170 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
171 Stream.filter((item) => hasOwnershipMarker(item.description)),
172 Stream.runCollect,
173 Effect.map((chunk) => Array.from(chunk)),
174 Effect.catchTag("NotFound", () => Effect.succeed([] as vm.Group[])),
175 ),
176 ),
177 );
178
179export const GroupProvider = () =>
180 Provider.succeed(Group, {
181 stables: ["name", "groupId", "project", "location", "createTime"],
182
183 diff: Effect.fn(function* ({ news, olds, output }) {
184 if (!isResolved(news)) return undefined;
185 const env = yield* GcpEnvironment.current;
186 const previousType =
187 olds?.migrationTargetType ??
188 output?.migrationTargetType ??
189 DEFAULT_TARGET;
190 const nextType = news.migrationTargetType ?? previousType;
191 return replaceOnIdentity({
192 previousId: olds?.groupId ?? output?.groupId,
193 nextId: news.groupId ?? olds?.groupId ?? output?.groupId,
194 previousLocation: normalizeLocation(
195 olds?.location ?? output?.location,
196 env.region,
197 ),
198 nextLocation: normalizeLocation(
199 news.location ?? olds?.location ?? output?.location,
200 env.region,
201 ),
202 extra: previousType !== nextType,
203 });
204 }),
205
206 read: Effect.fn(function* ({ id, olds, output }) {
207 const env = yield* GcpEnvironment.current;
208 const groupId = yield* toPhysicalId(
209 id,
210 olds?.groupId,
211 output?.groupId,
212 "group",
213 );
214 const location = normalizeLocation(
215 olds?.location ?? output?.location,
216 env.region,
217 );
218 const name = output?.name ?? resourceName(env.project, location, groupId);
219 const existing = yield* getByName(name);
220 if (existing === undefined) return undefined;
221 const attrs = toAttrs(existing, env.project, env.region);
222 return (yield* ownedByAlchemy(id, existing.description))
223 ? attrs
224 : Unowned(attrs);
225 }),
226
227 list: () =>
228 Effect.gen(function* () {
229 const env = yield* GcpEnvironment.current;
230 const items = yield* listOwned(env.project, env.region);
231 return items.map((item) => toAttrs(item, env.project, env.region));
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const groupId = yield* toPhysicalId(
237 id,
238 news.groupId,
239 output?.groupId,
240 "group",
241 );
242 const location = normalizeLocation(
243 news.location ?? output?.location,
244 env.region,
245 );
246 const name = resourceName(env.project, location, groupId);
247 const ownership = yield* createInternalLabels(id);
248 const description = encodeOwnership(ownership, news.description);
249 const displayName = news.displayName ?? groupId;
250 const migrationTargetType = news.migrationTargetType ?? DEFAULT_TARGET;
251
252 let current = yield* getByName(output?.name ?? name);
253
254 if (current === undefined) {
255 const created = yield* vm
256 .createProjectsLocationsGroups({
257 parent: locationParent(env.project, location),
258 groupId,
259 body: {
260 displayName,
261 description,
262 migrationTargetType,
263 },
264 })
265 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
266 if (created !== undefined) {
267 // ALREADY_EXISTS (6): a concurrent create won the race.
268 yield* waitForOperation(created).pipe(
269 Effect.catchTag("GCP.OperationFailed", (error) =>
270 error.code === 6 ? Effect.void : Effect.fail(error),
271 ),
272 );
273 }
274 current = yield* waitUntilExists(getByName(name), name);
275 }
276
277 if (current === undefined) {
278 return yield* new ResourceNotResolved({ name });
279 }
280
281 const descriptionChanged = (current.description ?? "") !== description;
282 const displayNameChanged = (current.displayName ?? "") !== displayName;
283 const mask = fieldMask([
284 descriptionChanged && "description",
285 displayNameChanged && "displayName",
286 ]);
287
288 if (mask.length > 0) {
289 const operation = yield* vm.patchProjectsLocationsGroups({
290 name: current.name ?? name,
291 updateMask: mask,
292 body: {
293 name: current.name ?? name,
294 displayName,
295 description,
296 },
297 });
298 yield* waitForOperation(operation);
299 current = yield* waitUntilExists(
300 getByName(current.name ?? name),
301 current.name ?? name,
302 );
303 }
304
305 return toAttrs(current, env.project, env.region);
306 }),
307
308 delete: Effect.fn(function* ({ output }) {
309 const operation = yield* vm
310 .deleteProjectsLocationsGroups({ name: output.name })
311 .pipe(
312 Effect.retry({
313 while: (error) => error._tag === "Conflict",
314 times: 8,
315 schedule: Schedule.spaced("2 seconds"),
316 }),
317 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
318 );
319 if (operation !== undefined) {
320 // NOT_FOUND (5): already gone.
321 yield* waitForOperation(operation).pipe(
322 Effect.catchTag("GCP.OperationFailed", (error) =>
323 error.code === 5 ? Effect.void : Effect.fail(error),
324 ),
325 );
326 }
327 yield* waitUntilGone(getByName(output.name), output.name);
328 }),
329 });