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 { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 encodeOwnership,
15 fingerprint,
16 hasAlchemyLabelMap,
17 hasOwnershipMarker,
18 locationParent,
19 normalizeLocation,
20 ownedByAlchemy,
21 parseName,
22 parseOwnership,
23 replaceOnIdentity,
24 ResourceNotResolved,
25 toPhysicalId,
26 userLabels,
27 waitForOperation,
28 waitUntilExists,
29 waitUntilGone,
30} from "./internal.ts";
31
32export type DiskImageTargetDetails = vm.DiskImageTargetDetails;
33export type MachineImageTargetDetails = vm.MachineImageTargetDetails;
34
35export type ImageImportProps = {
36 /**
37 * Image import id (the `{imageImport}` segment of
38 * `projects/{project}/locations/{location}/imageImports/{imageImport}`).
39 * If omitted, a unique RFC1035 name is generated. Immutable — changing
40 * it replaces the import.
41 */
42 imageImportId?: string;
43 /**
44 * Region (`us-central1`, …). Immutable — changing it replaces the
45 * import. `US-CENTRAL1` is accepted and normalized to `us-central1`.
46 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
47 */
48 location?: string;
49 /**
50 * Cloud Storage URI of the image to import. Immutable — changing it
51 * replaces the import.
52 */
53 cloudStorageUri: string;
54 /**
55 * Target details for a disk image. Mutually exclusive with
56 * `machineImageTargetDefaults`. Alchemy ownership labels are merged
57 * into `labels` (the import itself has no labels field). Immutable.
58 */
59 diskImageTargetDefaults?: DiskImageTargetDetails;
60 /**
61 * Target details for a machine image. Mutually exclusive with
62 * `diskImageTargetDefaults`. Alchemy ownership labels are merged into
63 * `labels`. Immutable.
64 */
65 machineImageTargetDefaults?: MachineImageTargetDetails;
66 /**
67 * Encryption used during image adaptation. Immutable — changing it
68 * replaces the import.
69 */
70 encryption?: vm.Encryption;
71};
72
73export type ImageImport = Resource<
74 "GCP.VMMigration.ImageImport",
75 ImageImportProps,
76 {
77 /** Full resource name. */
78 name: string;
79 /** Image import id (last path segment). */
80 imageImportId: string;
81 /** Project id. */
82 project: string;
83 /** Location id. */
84 location: string;
85 /** Cloud Storage URI of the imported image. */
86 cloudStorageUri: string | undefined;
87 /** Disk image target details with Alchemy labels stripped. */
88 diskImageTargetDefaults: DiskImageTargetDetails | undefined;
89 /** Machine image target details with Alchemy labels stripped. */
90 machineImageTargetDefaults: MachineImageTargetDetails | undefined;
91 /** Encryption used during adaptation. */
92 encryption: vm.Encryption | undefined;
93 /** Most recent import jobs. */
94 recentImageImportJobs: vm.ImageImportJobList | undefined;
95 /** RFC3339 creation timestamp. */
96 createTime: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A VM Migration image import from a Cloud Storage file into a Compute
104 * Engine disk or machine image.
105 *
106 * Image imports are immutable — every field is a replacement trigger.
107 * The resource has no labels field; Alchemy stamps ownership into the
108 * target details' `labels` and `description` so `list` / nuke can find
109 * them.
110 *
111 * ### Creating an Image Import
112 * **Example:** Disk image
113 * ```typescript
114 * const target = yield* GCP.VMMigration.Target("Landing");
115 * const image = yield* GCP.VMMigration.ImageImport("Disk", {
116 * cloudStorageUri: "gs://bucket/disk.vmdk",
117 * diskImageTargetDefaults: {
118 * imageName: "imported-disk",
119 * targetProject: target.name,
120 * },
121 * });
122 * ```
123 *
124 * **Example:** Machine image
125 * ```typescript
126 * const image = yield* GCP.VMMigration.ImageImport("Machine", {
127 * cloudStorageUri: "gs://bucket/machine.ova",
128 * machineImageTargetDefaults: {
129 * machineImageName: "imported-machine",
130 * targetProject: target.name,
131 * },
132 * });
133 * ```
134 *
135 * @resource
136 * @category VMMigration
137 */
138export const ImageImport = Resource<ImageImport>("GCP.VMMigration.ImageImport");
139
140const resourceName = (
141 project: string,
142 location: string,
143 imageImportId: string,
144) => `${locationParent(project, location)}/imageImports/${imageImportId}`;
145
146const stripTarget = <T extends { labels?: vm.StringMap; description?: string }>(
147 target: T | undefined,
148): T | undefined => {
149 if (target === undefined) return undefined;
150 return {
151 ...target,
152 labels: userLabels(target.labels),
153 description: parseOwnership(target.description).text,
154 };
155};
156
157const toAttrs = (image: vm.ImageImport, project: string, region: string) => {
158 const name = image.name ?? "";
159 const parsed = parseName(name, "imageImports", region);
160 return {
161 name,
162 imageImportId: parsed.id,
163 project: parsed.project || project,
164 location: parsed.location,
165 cloudStorageUri: image.cloudStorageUri,
166 diskImageTargetDefaults: stripTarget(image.diskImageTargetDefaults),
167 machineImageTargetDefaults: stripTarget(image.machineImageTargetDefaults),
168 encryption: image.encryption,
169 recentImageImportJobs: image.recentImageImportJobs,
170 createTime: image.createTime,
171 };
172};
173
174const isOwned = (image: vm.ImageImport) =>
175 hasAlchemyLabelMap(image.diskImageTargetDefaults?.labels) ||
176 hasAlchemyLabelMap(image.machineImageTargetDefaults?.labels) ||
177 hasOwnershipMarker(image.diskImageTargetDefaults?.description) ||
178 hasOwnershipMarker(image.machineImageTargetDefaults?.description);
179
180const ownedById = (id: string, image: vm.ImageImport) =>
181 Effect.gen(function* () {
182 const disk = image.diskImageTargetDefaults;
183 const machine = image.machineImageTargetDefaults;
184 if (disk && (yield* hasAlchemyLabels(id, tagRecord(disk.labels)))) {
185 return true;
186 }
187 if (machine && (yield* hasAlchemyLabels(id, tagRecord(machine.labels)))) {
188 return true;
189 }
190 if (yield* ownedByAlchemy(id, disk?.description)) return true;
191 if (yield* ownedByAlchemy(id, machine?.description)) return true;
192 return false;
193 });
194
195const stampTarget = <T extends { labels?: vm.StringMap; description?: string }>(
196 target: T | undefined,
197 ownership: Record<string, string>,
198): T | undefined => {
199 if (target === undefined) return undefined;
200 return {
201 ...target,
202 labels: { ...toLabels(tagRecord(target.labels)), ...ownership },
203 description: encodeOwnership(ownership, target.description),
204 };
205};
206
207const getByName = (name: string) =>
208 name.length === 0
209 ? Effect.succeed(undefined)
210 : vm
211 .getProjectsLocationsImageImports({ name })
212 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
213
214const listOwned = (project: string, region: string) =>
215 vm.listProjectsLocationsImageImports
216 .pages({
217 parent: `projects/${project}/locations/-`,
218 pageSize: 1000,
219 })
220 .pipe(
221 Stream.flatMap((page) => Stream.fromIterable(page.imageImports ?? [])),
222 Stream.filter(isOwned),
223 Stream.runCollect,
224 Effect.map((chunk) => Array.from(chunk)),
225 Effect.catchTag("NotFound", () =>
226 vm.listProjectsLocationsImageImports
227 .pages({
228 parent: locationParent(project, region),
229 pageSize: 1000,
230 })
231 .pipe(
232 Stream.flatMap((page) =>
233 Stream.fromIterable(page.imageImports ?? []),
234 ),
235 Stream.filter(isOwned),
236 Stream.runCollect,
237 Effect.map((chunk) => Array.from(chunk)),
238 Effect.catchTag("NotFound", () =>
239 Effect.succeed([] as vm.ImageImport[]),
240 ),
241 ),
242 ),
243 );
244
245export const ImageImportProvider = () =>
246 Provider.succeed(ImageImport, {
247 stables: ["name", "imageImportId", "project", "location", "createTime"],
248
249 diff: Effect.fn(function* ({ news, olds, output }) {
250 if (!isResolved(news)) return undefined;
251 const env = yield* GcpEnvironment.current;
252 const extra =
253 (news.cloudStorageUri !== undefined &&
254 (olds?.cloudStorageUri ?? output?.cloudStorageUri) !== undefined &&
255 news.cloudStorageUri !==
256 (olds?.cloudStorageUri ?? output?.cloudStorageUri)) ||
257 fingerprint(news.encryption) !==
258 fingerprint(olds?.encryption ?? output?.encryption) ||
259 fingerprint(news.diskImageTargetDefaults) !==
260 fingerprint(
261 olds?.diskImageTargetDefaults ?? output?.diskImageTargetDefaults,
262 ) ||
263 fingerprint(news.machineImageTargetDefaults) !==
264 fingerprint(
265 olds?.machineImageTargetDefaults ??
266 output?.machineImageTargetDefaults,
267 );
268 return replaceOnIdentity({
269 previousId: olds?.imageImportId ?? output?.imageImportId,
270 nextId:
271 news.imageImportId ?? olds?.imageImportId ?? output?.imageImportId,
272 previousLocation: normalizeLocation(
273 olds?.location ?? output?.location,
274 env.region,
275 ),
276 nextLocation: normalizeLocation(
277 news.location ?? olds?.location ?? output?.location,
278 env.region,
279 ),
280 extra,
281 });
282 }),
283
284 read: Effect.fn(function* ({ id, olds, output }) {
285 const env = yield* GcpEnvironment.current;
286 const imageImportId = yield* toPhysicalId(
287 id,
288 olds?.imageImportId,
289 output?.imageImportId,
290 "imageimport",
291 );
292 const location = normalizeLocation(
293 olds?.location ?? output?.location,
294 env.region,
295 );
296 const name =
297 output?.name ?? resourceName(env.project, location, imageImportId);
298 const existing = yield* getByName(name);
299 if (existing === undefined) return undefined;
300 const attrs = toAttrs(existing, env.project, env.region);
301 return (yield* ownedById(id, existing)) ? attrs : Unowned(attrs);
302 }),
303
304 list: () =>
305 Effect.gen(function* () {
306 const env = yield* GcpEnvironment.current;
307 const items = yield* listOwned(env.project, env.region);
308 return items.map((item) => toAttrs(item, env.project, env.region));
309 }),
310
311 reconcile: Effect.fn(function* ({ id, news, output }) {
312 const env = yield* GcpEnvironment.current;
313 const imageImportId = yield* toPhysicalId(
314 id,
315 news.imageImportId,
316 output?.imageImportId,
317 "imageimport",
318 );
319 const location = normalizeLocation(
320 news.location ?? output?.location,
321 env.region,
322 );
323 const name = resourceName(env.project, location, imageImportId);
324 const ownership = yield* createInternalLabels(id);
325 const diskImageTargetDefaults = stampTarget(
326 news.diskImageTargetDefaults,
327 ownership,
328 );
329 const machineImageTargetDefaults = stampTarget(
330 news.machineImageTargetDefaults,
331 ownership,
332 );
333
334 let current = yield* getByName(output?.name ?? name);
335
336 if (current === undefined) {
337 const created = yield* vm
338 .createProjectsLocationsImageImports({
339 parent: locationParent(env.project, location),
340 imageImportId,
341 body: {
342 cloudStorageUri: news.cloudStorageUri,
343 diskImageTargetDefaults,
344 machineImageTargetDefaults,
345 encryption: news.encryption,
346 },
347 })
348 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
349 if (created !== undefined) {
350 // ALREADY_EXISTS (6): a concurrent create won the race.
351 yield* waitForOperation(created).pipe(
352 Effect.catchTag("GCP.OperationFailed", (error) =>
353 error.code === 6 ? Effect.void : Effect.fail(error),
354 ),
355 );
356 }
357 current = yield* waitUntilExists(getByName(name), name);
358 }
359
360 if (current === undefined) {
361 return yield* new ResourceNotResolved({ name });
362 }
363
364 return toAttrs(current, env.project, env.region);
365 }),
366
367 delete: Effect.fn(function* ({ output }) {
368 const operation = yield* vm
369 .deleteProjectsLocationsImageImports({ name: output.name })
370 .pipe(
371 Effect.retry({
372 while: (error) => error._tag === "Conflict",
373 times: 8,
374 schedule: Schedule.spaced("2 seconds"),
375 }),
376 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
377 );
378 if (operation !== undefined) {
379 // NOT_FOUND (5): already gone.
380 yield* waitForOperation(operation).pipe(
381 Effect.catchTag("GCP.OperationFailed", (error) =>
382 error.code === 5 ? Effect.void : Effect.fail(error),
383 ),
384 );
385 }
386 yield* waitUntilGone(getByName(output.name), output.name);
387 }),
388 });