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 DiskMigrationJobTargetDetails = vm.DiskMigrationJobTargetDetails;
36export type AwsSourceDiskDetails = vm.AwsSourceDiskDetails;
37export type DiskMigrationJobState =
38 | vm.DiskMigrationJobStateEnum
39 | (string & {});
40
41export type SourcesDiskMigrationJobProps = {
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 job.
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 * Disk migration job id. If omitted, a unique RFC1035 name is
56 * generated. Immutable — changing it replaces the job.
57 */
58 diskMigrationJobId?: string;
59 /**
60 * Target disk in Compute Engine. Alchemy ownership labels are merged
61 * into `targetDetails.labels` (the job itself has no labels field).
62 */
63 targetDetails: DiskMigrationJobTargetDetails;
64 /**
65 * Unattached AWS source disk. Immutable volume id — changing it
66 * replaces the job.
67 */
68 awsSourceDiskDetails?: AwsSourceDiskDetails;
69};
70
71export type SourcesDiskMigrationJob = Resource<
72 "GCP.VMMigration.SourcesDiskMigrationJob",
73 SourcesDiskMigrationJobProps,
74 {
75 /** Full resource name. */
76 name: string;
77 /** Disk migration job id (last path segment). */
78 diskMigrationJobId: string;
79 /** Parent source resource name. */
80 source: string;
81 /** Project id. */
82 project: string;
83 /** Location id. */
84 location: string;
85 /** Target disk details with Alchemy labels stripped. */
86 targetDetails: DiskMigrationJobTargetDetails | undefined;
87 /** AWS source disk details. */
88 awsSourceDiskDetails: AwsSourceDiskDetails | undefined;
89 /** Job state. */
90 state: string | undefined;
91 /** Progress steps. */
92 steps: vm.DiskMigrationStepList | undefined;
93 /** Errors that led to a failed state. */
94 errors: vm.StatusList | undefined;
95 /** RFC3339 creation timestamp. */
96 createTime: string | undefined;
97 /** RFC3339 last-update timestamp. */
98 updateTime: string | undefined;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * A VM Migration disk migration job that copies an unattached source
106 * disk into a Compute Engine persistent disk.
107 *
108 * The job has no labels field — Alchemy stamps ownership into
109 * `targetDetails.labels` so `list` / nuke can find them. Source, job
110 * id, and AWS volume id are immutable. Target details update in place.
111 *
112 * ### Creating a Disk Migration Job
113 * **Example:** AWS volume to Compute Engine
114 * ```typescript
115 * const job = yield* GCP.VMMigration.SourcesDiskMigrationJob("Boot", {
116 * source: source.name,
117 * awsSourceDiskDetails: { volumeId: "vol-123" },
118 * targetDetails: {
119 * targetProject: target.name,
120 * targetDisk: {
121 * zone: "projects/my-project/locations/us-central1-a",
122 * diskType: "COMPUTE_ENGINE_DISK_TYPE_STANDARD",
123 * diskId: "imported-boot",
124 * },
125 * },
126 * });
127 * ```
128 *
129 * @resource
130 * @category VMMigration
131 */
132export const SourcesDiskMigrationJob = Resource<SourcesDiskMigrationJob>(
133 "GCP.VMMigration.SourcesDiskMigrationJob",
134);
135
136const resourceName = (source: string, diskMigrationJobId: string) =>
137 `${source}/diskMigrationJobs/${diskMigrationJobId}`;
138
139const stampTarget = (
140 target: DiskMigrationJobTargetDetails,
141 ownership: Record<string, string>,
142): DiskMigrationJobTargetDetails => ({
143 ...target,
144 labels: {
145 ...toLabels(tagRecord(target.labels)),
146 ...ownership,
147 },
148});
149
150const stripTarget = (
151 target: DiskMigrationJobTargetDetails | undefined,
152): DiskMigrationJobTargetDetails | undefined => {
153 if (target === undefined) return undefined;
154 return {
155 ...target,
156 labels: userLabels(target.labels),
157 };
158};
159
160const toAttrs = (job: vm.DiskMigrationJob, project: string, region: string) => {
161 const name = job.name ?? "";
162 const parsed = parseName(name, "diskMigrationJobs", region);
163 return {
164 name,
165 diskMigrationJobId: parsed.id,
166 source: parsed.parent,
167 project: parsed.project || project,
168 location: parsed.location,
169 targetDetails: stripTarget(job.targetDetails),
170 awsSourceDiskDetails: job.awsSourceDiskDetails,
171 state: job.state,
172 steps: job.steps,
173 errors: job.errors,
174 createTime: job.createTime,
175 updateTime: job.updateTime,
176 };
177};
178
179const getByName = (name: string) =>
180 name.length === 0
181 ? Effect.succeed(undefined)
182 : vm
183 .getProjectsLocationsSourcesDiskMigrationJobs({ name })
184 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
185
186const listChildren = (parent: string) =>
187 collectPages(
188 vm.listProjectsLocationsSourcesDiskMigrationJobs.pages({
189 parent,
190 pageSize: 1000,
191 }),
192 (page) => page.diskMigrationJobs,
193 ).pipe(
194 Effect.catchTag("NotFound", () =>
195 Effect.succeed([] as vm.DiskMigrationJob[]),
196 ),
197 );
198
199export const SourcesDiskMigrationJobProvider = () =>
200 Provider.succeed(SourcesDiskMigrationJob, {
201 stables: [
202 "name",
203 "diskMigrationJobId",
204 "source",
205 "project",
206 "location",
207 "createTime",
208 ],
209
210 diff: Effect.fn(function* ({ news, olds, output }) {
211 if (!isResolved(news)) return undefined;
212 const env = yield* GcpEnvironment.current;
213 const previousVolume =
214 olds?.awsSourceDiskDetails?.volumeId ??
215 output?.awsSourceDiskDetails?.volumeId;
216 const nextVolume = news.awsSourceDiskDetails?.volumeId ?? previousVolume;
217 const previousSource = olds?.source ?? output?.source;
218 return replaceOnIdentity({
219 previousId: olds?.diskMigrationJobId ?? output?.diskMigrationJobId,
220 nextId:
221 news.diskMigrationJobId ??
222 olds?.diskMigrationJobId ??
223 output?.diskMigrationJobId,
224 previousLocation: normalizeLocation(
225 olds?.location ?? output?.location,
226 env.region,
227 ),
228 nextLocation: normalizeLocation(
229 news.location ?? olds?.location ?? output?.location,
230 env.region,
231 ),
232 previousParent: previousSource,
233 nextParent: news.source ?? previousSource,
234 extra: previousVolume !== nextVolume,
235 });
236 }),
237
238 read: Effect.fn(function* ({ id, olds, output }) {
239 const env = yield* GcpEnvironment.current;
240 const location = normalizeLocation(
241 olds?.location ?? output?.location,
242 env.region,
243 );
244 const diskMigrationJobId = yield* toPhysicalId(
245 id,
246 olds?.diskMigrationJobId,
247 output?.diskMigrationJobId,
248 "diskmigration",
249 );
250 const source =
251 olds?.source !== undefined
252 ? sourceOf(olds.source, env.project, location)
253 : (output?.source ?? "");
254 const name =
255 output?.name ??
256 (source.length > 0 ? resourceName(source, diskMigrationJobId) : "");
257 const existing = yield* getByName(name);
258 if (existing === undefined) return undefined;
259 const attrs = toAttrs(existing, env.project, env.region);
260 return (yield* hasAlchemyLabels(
261 id,
262 tagRecord(existing.targetDetails?.labels),
263 ))
264 ? attrs
265 : Unowned(attrs);
266 }),
267
268 list: () =>
269 Effect.gen(function* () {
270 const env = yield* GcpEnvironment.current;
271 const items = yield* forEachSource(
272 env.project,
273 listChildren,
274 env.region,
275 );
276 return items
277 .filter((item) => hasAlchemyLabelMap(item.targetDetails?.labels))
278 .map((item) => toAttrs(item, env.project, env.region));
279 }),
280
281 reconcile: Effect.fn(function* ({ id, news, output }) {
282 const env = yield* GcpEnvironment.current;
283 const location = normalizeLocation(
284 news.location ?? output?.location,
285 env.region,
286 );
287 const source = sourceOf(news.source, env.project, location);
288 const diskMigrationJobId = yield* toPhysicalId(
289 id,
290 news.diskMigrationJobId,
291 output?.diskMigrationJobId,
292 "diskmigration",
293 );
294 const name = resourceName(source, diskMigrationJobId);
295 const ownership = yield* createInternalLabels(id);
296 const targetDetails = stampTarget(news.targetDetails, ownership);
297
298 let current = yield* getByName(output?.name ?? name);
299
300 if (current === undefined) {
301 const created = yield* vm
302 .createProjectsLocationsSourcesDiskMigrationJobs({
303 parent: source,
304 diskMigrationJobId,
305 body: {
306 targetDetails,
307 awsSourceDiskDetails: news.awsSourceDiskDetails,
308 },
309 })
310 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
311 if (created !== undefined) {
312 // ALREADY_EXISTS (6): a concurrent create won the race.
313 yield* waitForOperation(created).pipe(
314 Effect.catchTag("GCP.OperationFailed", (error) =>
315 error.code === 6 ? Effect.void : Effect.fail(error),
316 ),
317 );
318 }
319 current = yield* waitUntilExists(getByName(name), name);
320 }
321
322 if (current === undefined) {
323 return yield* new ResourceNotResolved({ name });
324 }
325
326 const observedLabels = tagRecord(current.targetDetails?.labels);
327 const desiredLabels = tagRecord(targetDetails.labels);
328 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
329 const labelsChanged = upsert.length > 0 || removed.length > 0;
330 const targetChanged =
331 fingerprint({
332 ...current.targetDetails,
333 labels: undefined,
334 }) !==
335 fingerprint({
336 ...targetDetails,
337 labels: undefined,
338 });
339 const mask = fieldMask([
340 (labelsChanged || targetChanged) && "targetDetails",
341 ]);
342
343 if (mask.length > 0) {
344 const operation =
345 yield* vm.patchProjectsLocationsSourcesDiskMigrationJobs({
346 name: current.name ?? name,
347 updateMask: mask,
348 body: {
349 name: current.name ?? name,
350 targetDetails,
351 },
352 });
353 yield* waitForOperation(operation);
354 current = yield* waitUntilExists(
355 getByName(current.name ?? name),
356 current.name ?? name,
357 );
358 }
359
360 return toAttrs(current, env.project, env.region);
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 const operation = yield* vm
365 .deleteProjectsLocationsSourcesDiskMigrationJobs({
366 name: output.name,
367 })
368 .pipe(
369 Effect.retry({
370 while: (error) => error._tag === "Conflict",
371 times: 8,
372 schedule: Schedule.spaced("2 seconds"),
373 }),
374 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
375 );
376 if (operation !== undefined) {
377 // NOT_FOUND (5): already gone.
378 yield* waitForOperation(operation).pipe(
379 Effect.catchTag("GCP.OperationFailed", (error) =>
380 error.code === 5 ? Effect.void : Effect.fail(error),
381 ),
382 );
383 }
384 yield* waitUntilGone(getByName(output.name), output.name);
385 }),
386 });