1import * as vm from "@distilled.cloud/gcp/vmmigration_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 collectPages,
19 fieldMask,
20 fingerprint,
21 forEachSource,
22 hasAlchemyLabelMap,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sourceOf,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33} from "./internal.ts";
34
35export type SchedulePolicy = vm.SchedulePolicy;
36export type ComputeEngineTargetDefaults = vm.ComputeEngineTargetDefaults;
37export type ComputeEngineDisksTargetDefaults =
38 vm.ComputeEngineDisksTargetDefaults;
39export type MigratingVmState = vm.MigratingVmStateEnum | (string & {});
40
41export type SourcesMigratingVmProps = {
42 /**
43 * Parent source. Full name
44 * `projects/{project}/locations/{location}/sources/{source}` or the
45 * source id (combined with `location`). Immutable — changing it
46 * replaces the migrating VM.
47 */
48 source: string;
49 /**
50 * Region used when `source` is a bare id.
51 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
52 */
53 location?: string;
54 /**
55 * Migrating VM id. If omitted, a unique RFC1035 name is generated.
56 * Immutable — changing it replaces the migrating VM.
57 */
58 migratingVmId?: string;
59 /**
60 * Unique VM id in the source (vSphere moRef `vm-…`, AWS instance id,
61 * Azure VM id). Immutable — changing it replaces the migrating VM.
62 */
63 sourceVmId: string;
64 /**
65 * User-friendly display name.
66 */
67 displayName?: string;
68 /**
69 * Free-text description.
70 */
71 description?: string;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 */
75 labels?: Record<string, string>;
76 /**
77 * Replication schedule policy.
78 */
79 policy?: SchedulePolicy;
80 /**
81 * Target VM defaults in Compute Engine. Mutually exclusive with
82 * `computeEngineDisksTargetDefaults`.
83 */
84 computeEngineTargetDefaults?: ComputeEngineTargetDefaults;
85 /**
86 * Target persistent-disk defaults. Mutually exclusive with
87 * `computeEngineTargetDefaults`.
88 */
89 computeEngineDisksTargetDefaults?: ComputeEngineDisksTargetDefaults;
90};
91
92export type SourcesMigratingVm = Resource<
93 "GCP.VMMigration.SourcesMigratingVm",
94 SourcesMigratingVmProps,
95 {
96 /** Full resource name. */
97 name: string;
98 /** Migrating VM id (last path segment). */
99 migratingVmId: string;
100 /** Parent source resource name. */
101 source: string;
102 /** Project id. */
103 project: string;
104 /** Location id. */
105 location: string;
106 /** Unique VM id in the source. */
107 sourceVmId: string | undefined;
108 /** User-friendly display name. */
109 displayName: string | undefined;
110 /** Free-text description. */
111 description: string | undefined;
112 /** User labels (Alchemy ownership labels stripped). */
113 labels: Record<string, string>;
114 /** Replication schedule policy. */
115 policy: SchedulePolicy | undefined;
116 /** Compute Engine VM target defaults. */
117 computeEngineTargetDefaults: ComputeEngineTargetDefaults | undefined;
118 /** Compute Engine disk target defaults. */
119 computeEngineDisksTargetDefaults:
120 | ComputeEngineDisksTargetDefaults
121 | undefined;
122 /** Group this migrating VM belongs to. */
123 group: string | undefined;
124 /** Replication state. */
125 state: string | undefined;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A VM Migration migrating VM: a source VM plus the replication and
137 * target defaults used to clone or cut over into Compute Engine.
138 *
139 * Source, migrating VM id, and `sourceVmId` are immutable. Display
140 * name, description, labels, policy, and target defaults update in
141 * place.
142 *
143 * ### Creating a Migrating VM
144 * **Example:** Compute Engine VM target
145 * ```typescript
146 * const vm = yield* GCP.VMMigration.SourcesMigratingVm("Web", {
147 * source: source.name,
148 * sourceVmId: "vm-123",
149 * displayName: "web-1",
150 * computeEngineTargetDefaults: {
151 * vmName: "web-1",
152 * machineType: "n2-standard-4",
153 * zone: "us-central1-a",
154 * targetProject: target.name,
155 * },
156 * });
157 * ```
158 *
159 * @resource
160 * @category VMMigration
161 */
162export const SourcesMigratingVm = Resource<SourcesMigratingVm>(
163 "GCP.VMMigration.SourcesMigratingVm",
164);
165
166const resourceName = (source: string, migratingVmId: string) =>
167 `${source}/migratingVms/${migratingVmId}`;
168
169const userVmTarget = (target: ComputeEngineTargetDefaults | undefined) => {
170 if (target === undefined) return undefined;
171 const { appliedLicense: _applied, bootOption: _boot, ...rest } = target;
172 return rest;
173};
174
175const userDisksTarget = (
176 target: ComputeEngineDisksTargetDefaults | undefined,
177) => {
178 if (target === undefined) return undefined;
179 return {
180 targetProject: target.targetProject,
181 zone: target.zone,
182 disks: target.disks,
183 vmTargetDefaults: target.vmTargetDefaults,
184 };
185};
186
187const toAttrs = (
188 migrating: vm.MigratingVm,
189 project: string,
190 region: string,
191) => {
192 const name = migrating.name ?? "";
193 const parsed = parseName(name, "migratingVms", region);
194 return {
195 name,
196 migratingVmId: parsed.id,
197 source: parsed.parent,
198 project: parsed.project || project,
199 location: parsed.location,
200 sourceVmId: migrating.sourceVmId,
201 displayName: migrating.displayName,
202 description: migrating.description,
203 labels: userLabels(migrating.labels),
204 policy: migrating.policy,
205 computeEngineTargetDefaults: migrating.computeEngineTargetDefaults,
206 computeEngineDisksTargetDefaults:
207 migrating.computeEngineDisksTargetDefaults,
208 group: migrating.group,
209 state: migrating.state,
210 createTime: migrating.createTime,
211 updateTime: migrating.updateTime,
212 };
213};
214
215const getByName = (name: string) =>
216 name.length === 0
217 ? Effect.succeed(undefined)
218 : vm
219 .getProjectsLocationsSourcesMigratingVms({ name })
220 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
221
222const listChildren = (parent: string) =>
223 collectPages(
224 vm.listProjectsLocationsSourcesMigratingVms.pages({
225 parent,
226 pageSize: 1000,
227 view: "MIGRATING_VM_VIEW_BASIC",
228 }),
229 (page) => page.migratingVms,
230 ).pipe(
231 Effect.catchTag("NotFound", () => Effect.succeed([] as vm.MigratingVm[])),
232 );
233
234export const SourcesMigratingVmProvider = () =>
235 Provider.succeed(SourcesMigratingVm, {
236 stables: [
237 "name",
238 "migratingVmId",
239 "source",
240 "project",
241 "location",
242 "createTime",
243 ],
244
245 diff: Effect.fn(function* ({ news, olds, output }) {
246 if (!isResolved(news)) return undefined;
247 const env = yield* GcpEnvironment.current;
248 const previousSource = olds?.source ?? output?.source;
249 const previousVm = olds?.sourceVmId ?? output?.sourceVmId;
250 const nextVm = news.sourceVmId ?? previousVm;
251 return replaceOnIdentity({
252 previousId: olds?.migratingVmId ?? output?.migratingVmId,
253 nextId:
254 news.migratingVmId ?? olds?.migratingVmId ?? output?.migratingVmId,
255 previousLocation: normalizeLocation(
256 olds?.location ?? output?.location,
257 env.region,
258 ),
259 nextLocation: normalizeLocation(
260 news.location ?? olds?.location ?? output?.location,
261 env.region,
262 ),
263 previousParent: previousSource,
264 nextParent: news.source ?? previousSource,
265 extra: previousVm !== nextVm,
266 });
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const location = normalizeLocation(
272 olds?.location ?? output?.location,
273 env.region,
274 );
275 const migratingVmId = yield* toPhysicalId(
276 id,
277 olds?.migratingVmId,
278 output?.migratingVmId,
279 "migratingvm",
280 );
281 const source =
282 olds?.source !== undefined
283 ? sourceOf(olds.source, env.project, location)
284 : (output?.source ?? "");
285 const name =
286 output?.name ??
287 (source.length > 0 ? resourceName(source, migratingVmId) : "");
288 const existing = yield* getByName(name);
289 if (existing === undefined) return undefined;
290 const attrs = toAttrs(existing, env.project, env.region);
291 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
292 ? attrs
293 : Unowned(attrs);
294 }),
295
296 list: () =>
297 Effect.gen(function* () {
298 const env = yield* GcpEnvironment.current;
299 const items = yield* forEachSource(
300 env.project,
301 listChildren,
302 env.region,
303 );
304 return items
305 .filter((item) => hasAlchemyLabelMap(item.labels))
306 .map((item) => toAttrs(item, env.project, env.region));
307 }),
308
309 reconcile: Effect.fn(function* ({ id, news, output }) {
310 const env = yield* GcpEnvironment.current;
311 const location = normalizeLocation(
312 news.location ?? output?.location,
313 env.region,
314 );
315 const source = sourceOf(news.source, env.project, location);
316 const migratingVmId = yield* toPhysicalId(
317 id,
318 news.migratingVmId,
319 output?.migratingVmId,
320 "migratingvm",
321 );
322 const name = resourceName(source, migratingVmId);
323 const desiredLabels = {
324 ...toLabels(news.labels),
325 ...(yield* createInternalLabels(id)),
326 };
327 const displayName = news.displayName ?? migratingVmId;
328
329 let current = yield* getByName(output?.name ?? name);
330
331 if (current === undefined) {
332 const created = yield* vm
333 .createProjectsLocationsSourcesMigratingVms({
334 parent: source,
335 migratingVmId,
336 body: {
337 sourceVmId: news.sourceVmId,
338 displayName,
339 description: news.description,
340 labels: desiredLabels,
341 policy: news.policy,
342 computeEngineTargetDefaults: news.computeEngineTargetDefaults,
343 computeEngineDisksTargetDefaults:
344 news.computeEngineDisksTargetDefaults,
345 },
346 })
347 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
348 if (created !== undefined) {
349 // ALREADY_EXISTS (6): a concurrent create won the race.
350 yield* waitForOperation(created).pipe(
351 Effect.catchTag("GCP.OperationFailed", (error) =>
352 error.code === 6 ? Effect.void : Effect.fail(error),
353 ),
354 );
355 }
356 current = yield* waitUntilExists(getByName(name), name);
357 }
358
359 if (current === undefined) {
360 return yield* new ResourceNotResolved({ name });
361 }
362
363 const observedLabels = tagRecord(current.labels);
364 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
365 const labelsChanged = upsert.length > 0 || removed.length > 0;
366 const displayNameChanged = (current.displayName ?? "") !== displayName;
367 const descriptionChanged =
368 (current.description ?? "") !== (news.description ?? "");
369 const policyChanged =
370 fingerprint(current.policy) !== fingerprint(news.policy);
371 const vmTargetChanged =
372 fingerprint(userVmTarget(current.computeEngineTargetDefaults)) !==
373 fingerprint(userVmTarget(news.computeEngineTargetDefaults));
374 const disksTargetChanged =
375 fingerprint(
376 userDisksTarget(current.computeEngineDisksTargetDefaults),
377 ) !==
378 fingerprint(userDisksTarget(news.computeEngineDisksTargetDefaults));
379 const mask = fieldMask([
380 labelsChanged && "labels",
381 displayNameChanged && "displayName",
382 descriptionChanged && "description",
383 policyChanged && "policy",
384 vmTargetChanged && "computeEngineTargetDefaults",
385 disksTargetChanged && "computeEngineDisksTargetDefaults",
386 ]);
387
388 if (mask.length > 0) {
389 const operation = yield* vm.patchProjectsLocationsSourcesMigratingVms({
390 name: current.name ?? name,
391 updateMask: mask,
392 body: {
393 name: current.name ?? name,
394 displayName,
395 description: news.description,
396 labels: desiredLabels,
397 policy: news.policy,
398 computeEngineTargetDefaults: news.computeEngineTargetDefaults,
399 computeEngineDisksTargetDefaults:
400 news.computeEngineDisksTargetDefaults,
401 },
402 });
403 yield* waitForOperation(operation);
404 current = yield* waitUntilExists(
405 getByName(current.name ?? name),
406 current.name ?? name,
407 );
408 }
409
410 return toAttrs(current, env.project, env.region);
411 }),
412
413 delete: Effect.fn(function* ({ output }) {
414 const operation = yield* vm
415 .deleteProjectsLocationsSourcesMigratingVms({ name: output.name })
416 .pipe(
417 Effect.retry({
418 while: (error) => error._tag === "Conflict",
419 times: 8,
420 schedule: Schedule.spaced("2 seconds"),
421 }),
422 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
423 );
424 if (operation !== undefined) {
425 // NOT_FOUND (5): already gone.
426 yield* waitForOperation(operation).pipe(
427 Effect.catchTag("GCP.OperationFailed", (error) =>
428 error.code === 5 ? Effect.void : Effect.fail(error),
429 ),
430 );
431 }
432 yield* waitUntilGone(getByName(output.name), output.name);
433 }),
434 });