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 {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 fieldMask,
20 fingerprint,
21 hasAlchemyLabelMap,
22 locationParent,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type AwsSourceDetails = vm.AwsSourceDetails;
35export type AzureSourceDetails = vm.AzureSourceDetails;
36export type VmwareSourceDetails = vm.VmwareSourceDetails;
37export type Encryption = vm.Encryption;
38
39export type SourceProps = {
40 /**
41 * Source id (the `{source}` segment of
42 * `projects/{project}/locations/{location}/sources/{source}`). If
43 * omitted, a unique RFC1035 name is generated. Immutable — changing it
44 * replaces the source.
45 */
46 sourceId?: string;
47 /**
48 * Region (`us-central1`, …). Immutable — changing it replaces the
49 * source. `US-CENTRAL1` is accepted and normalized to `us-central1`.
50 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
51 */
52 location?: string;
53 /**
54 * User-provided description of the source.
55 */
56 description?: string;
57 /**
58 * User labels. Alchemy ownership labels are merged in automatically.
59 */
60 labels?: Record<string, string>;
61 /**
62 * Encryption of source data stored by the service. Immutable —
63 * changing it replaces the source.
64 */
65 encryption?: Encryption;
66 /**
67 * AWS source environment. Mutually exclusive with `azure` and
68 * `vmware`. Switching source kinds replaces the source.
69 */
70 aws?: AwsSourceDetails;
71 /**
72 * Azure source environment. Mutually exclusive with `aws` and
73 * `vmware`. Switching source kinds replaces the source.
74 */
75 azure?: AzureSourceDetails;
76 /**
77 * VMware vCenter source environment. Mutually exclusive with `aws`
78 * and `azure`. Switching source kinds replaces the source.
79 */
80 vmware?: VmwareSourceDetails;
81};
82
83export type Source = Resource<
84 "GCP.VMMigration.Source",
85 SourceProps,
86 {
87 /** Full resource name. */
88 name: string;
89 /** Source id (last path segment). */
90 sourceId: string;
91 /** Project id. */
92 project: string;
93 /** Location id. */
94 location: string;
95 /** User-provided description. */
96 description: string | undefined;
97 /** User labels (Alchemy ownership labels stripped). */
98 labels: Record<string, string>;
99 /** Encryption of stored source data. */
100 encryption: Encryption | undefined;
101 /** AWS source details as reported by the API. */
102 aws: AwsSourceDetails | undefined;
103 /** Azure source details as reported by the API. */
104 azure: AzureSourceDetails | undefined;
105 /** VMware source details as reported by the API. */
106 vmware: VmwareSourceDetails | undefined;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A VM Migration source environment (AWS, Azure, or VMware vCenter)
118 * that inventories VMs and disks for migration.
119 *
120 * `sourceId`, `location`, `encryption`, and the source kind
121 * (`aws` / `azure` / `vmware`) plus immutable region/subscription
122 * fields are replacement triggers. Description, labels, and credential
123 * fields update in place.
124 *
125 * ### Creating a Source
126 * **Example:** AWS source
127 * ```typescript
128 * const source = yield* GCP.VMMigration.Source("Aws", {
129 * aws: {
130 * awsRegion: "us-east-1",
131 * accessKeyCreds: {
132 * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
133 * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
134 * },
135 * },
136 * labels: { env: "test" },
137 * });
138 * ```
139 *
140 * **Example:** VMware source
141 * ```typescript
142 * const source = yield* GCP.VMMigration.Source("Vcenter", {
143 * vmware: {
144 * vcenterIp: "10.0.0.4",
145 * username: "admin",
146 * password: process.env.VCENTER_PASSWORD,
147 * thumbprint: process.env.VCENTER_THUMBPRINT,
148 * },
149 * });
150 * ```
151 *
152 * @resource
153 * @category VMMigration
154 */
155export const Source = Resource<Source>("GCP.VMMigration.Source");
156
157const resourceName = (project: string, location: string, sourceId: string) =>
158 `${locationParent(project, location)}/sources/${sourceId}`;
159
160const kindOf = (value: { aws?: unknown; azure?: unknown; vmware?: unknown }) =>
161 value.aws ? "aws" : value.azure ? "azure" : value.vmware ? "vmware" : "";
162
163const toAttrs = (source: vm.Source, project: string, region: string) => {
164 const name = source.name ?? "";
165 const parsed = parseName(name, "sources", region);
166 return {
167 name,
168 sourceId: parsed.id,
169 project: parsed.project || project,
170 location: parsed.location,
171 description: source.description,
172 labels: userLabels(source.labels),
173 encryption: source.encryption,
174 aws: source.aws,
175 azure: source.azure,
176 vmware: source.vmware,
177 createTime: source.createTime,
178 updateTime: source.updateTime,
179 };
180};
181
182const getByName = (name: string) =>
183 name.length === 0
184 ? Effect.succeed(undefined)
185 : vm
186 .getProjectsLocationsSources({ name })
187 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
188
189const listOwned = (project: string, region: string) =>
190 vm.listProjectsLocationsSources
191 .pages({
192 parent: `projects/${project}/locations/-`,
193 pageSize: 1000,
194 })
195 .pipe(
196 Stream.flatMap((page) => Stream.fromIterable(page.sources ?? [])),
197 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
198 Stream.runCollect,
199 Effect.map((chunk) => Array.from(chunk)),
200 Effect.catchTag("NotFound", () =>
201 vm.listProjectsLocationsSources
202 .pages({
203 parent: locationParent(project, region),
204 pageSize: 1000,
205 })
206 .pipe(
207 Stream.flatMap((page) => Stream.fromIterable(page.sources ?? [])),
208 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
209 Stream.runCollect,
210 Effect.map((chunk) => Array.from(chunk)),
211 Effect.catchTag("NotFound", () =>
212 Effect.succeed([] as vm.Source[]),
213 ),
214 ),
215 ),
216 );
217
218const publicAws = (aws: AwsSourceDetails | undefined) =>
219 aws === undefined
220 ? undefined
221 : {
222 awsRegion: aws.awsRegion,
223 inventorySecurityGroupNames: aws.inventorySecurityGroupNames,
224 inventoryTagList: aws.inventoryTagList,
225 migrationResourcesUserTags: aws.migrationResourcesUserTags,
226 accessKeyCreds: aws.accessKeyCreds
227 ? { accessKeyId: aws.accessKeyCreds.accessKeyId }
228 : undefined,
229 };
230
231const publicAzure = (azure: AzureSourceDetails | undefined) =>
232 azure === undefined
233 ? undefined
234 : {
235 azureLocation: azure.azureLocation,
236 subscriptionId: azure.subscriptionId,
237 migrationResourcesUserTags: azure.migrationResourcesUserTags,
238 clientSecretCreds: azure.clientSecretCreds
239 ? {
240 tenantId: azure.clientSecretCreds.tenantId,
241 clientId: azure.clientSecretCreds.clientId,
242 }
243 : undefined,
244 };
245
246const publicVmware = (vmware: VmwareSourceDetails | undefined) =>
247 vmware === undefined
248 ? undefined
249 : {
250 vcenterIp: vmware.vcenterIp,
251 username: vmware.username,
252 thumbprint: vmware.thumbprint,
253 resolvedVcenterHost: vmware.resolvedVcenterHost,
254 };
255
256export const SourceProvider = () =>
257 Provider.succeed(Source, {
258 stables: ["name", "sourceId", "project", "location", "createTime"],
259
260 diff: Effect.fn(function* ({ news, olds, output }) {
261 if (!isResolved(news)) return undefined;
262 const env = yield* GcpEnvironment.current;
263 const previousKind = kindOf({
264 aws: olds?.aws ?? output?.aws,
265 azure: olds?.azure ?? output?.azure,
266 vmware: olds?.vmware ?? output?.vmware,
267 });
268 const nextKind = kindOf(news) || previousKind;
269 const previousEncryption =
270 olds?.encryption?.kmsKey ?? output?.encryption?.kmsKey;
271 const nextEncryption = news.encryption?.kmsKey ?? previousEncryption;
272 const previousAwsRegion = olds?.aws?.awsRegion ?? output?.aws?.awsRegion;
273 const nextAwsRegion = news.aws?.awsRegion ?? previousAwsRegion;
274 const previousAzureLocation =
275 olds?.azure?.azureLocation ?? output?.azure?.azureLocation;
276 const nextAzureLocation =
277 news.azure?.azureLocation ?? previousAzureLocation;
278 const previousSubscription =
279 olds?.azure?.subscriptionId ?? output?.azure?.subscriptionId;
280 const nextSubscription =
281 news.azure?.subscriptionId ?? previousSubscription;
282 return replaceOnIdentity({
283 previousId: olds?.sourceId ?? output?.sourceId,
284 nextId: news.sourceId ?? olds?.sourceId ?? output?.sourceId,
285 previousLocation: normalizeLocation(
286 olds?.location ?? output?.location,
287 env.region,
288 ),
289 nextLocation: normalizeLocation(
290 news.location ?? olds?.location ?? output?.location,
291 env.region,
292 ),
293 extra:
294 previousKind !== nextKind ||
295 previousEncryption !== nextEncryption ||
296 previousAwsRegion !== nextAwsRegion ||
297 previousAzureLocation !== nextAzureLocation ||
298 previousSubscription !== nextSubscription,
299 });
300 }),
301
302 read: Effect.fn(function* ({ id, olds, output }) {
303 const env = yield* GcpEnvironment.current;
304 const sourceId = yield* toPhysicalId(
305 id,
306 olds?.sourceId,
307 output?.sourceId,
308 "source",
309 );
310 const location = normalizeLocation(
311 olds?.location ?? output?.location,
312 env.region,
313 );
314 const name =
315 output?.name ?? resourceName(env.project, location, sourceId);
316 const existing = yield* getByName(name);
317 if (existing === undefined) return undefined;
318 const attrs = toAttrs(existing, env.project, env.region);
319 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
320 ? attrs
321 : Unowned(attrs);
322 }),
323
324 list: () =>
325 Effect.gen(function* () {
326 const env = yield* GcpEnvironment.current;
327 const items = yield* listOwned(env.project, env.region);
328 return items.map((item) => toAttrs(item, env.project, env.region));
329 }),
330
331 reconcile: Effect.fn(function* ({ id, news, output }) {
332 const env = yield* GcpEnvironment.current;
333 const sourceId = yield* toPhysicalId(
334 id,
335 news.sourceId,
336 output?.sourceId,
337 "source",
338 );
339 const location = normalizeLocation(
340 news.location ?? output?.location,
341 env.region,
342 );
343 const name = resourceName(env.project, location, sourceId);
344 const desiredLabels = {
345 ...toLabels(news.labels),
346 ...(yield* createInternalLabels(id)),
347 };
348
349 let current = yield* getByName(output?.name ?? name);
350
351 if (current === undefined) {
352 const created = yield* vm
353 .createProjectsLocationsSources({
354 parent: locationParent(env.project, location),
355 sourceId,
356 body: {
357 description: news.description,
358 labels: desiredLabels,
359 encryption: news.encryption,
360 aws: news.aws,
361 azure: news.azure,
362 vmware: news.vmware,
363 },
364 })
365 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
366 if (created !== undefined) {
367 // ALREADY_EXISTS (6): a concurrent create won the race.
368 yield* waitForOperation(created).pipe(
369 Effect.catchTag("GCP.OperationFailed", (error) =>
370 error.code === 6 ? Effect.void : Effect.fail(error),
371 ),
372 );
373 }
374 current = yield* waitUntilExists(getByName(name), name);
375 }
376
377 if (current === undefined) {
378 return yield* new ResourceNotResolved({ name });
379 }
380
381 const observedLabels = tagRecord(current.labels);
382 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
383 const labelsChanged = upsert.length > 0 || removed.length > 0;
384 const descriptionChanged =
385 (current.description ?? "") !== (news.description ?? "");
386 const awsChanged =
387 news.aws !== undefined &&
388 (fingerprint(publicAws(current.aws)) !==
389 fingerprint(publicAws(news.aws)) ||
390 news.aws.accessKeyCreds?.secretAccessKey !== undefined);
391 const azureChanged =
392 news.azure !== undefined &&
393 (fingerprint(publicAzure(current.azure)) !==
394 fingerprint(publicAzure(news.azure)) ||
395 news.azure.clientSecretCreds?.clientSecret !== undefined);
396 const vmwareChanged =
397 news.vmware !== undefined &&
398 (fingerprint(publicVmware(current.vmware)) !==
399 fingerprint(publicVmware(news.vmware)) ||
400 news.vmware.password !== undefined);
401 const mask = fieldMask([
402 labelsChanged && "labels",
403 descriptionChanged && "description",
404 awsChanged && "aws",
405 azureChanged && "azure",
406 vmwareChanged && "vmware",
407 ]);
408
409 if (mask.length > 0) {
410 const operation = yield* vm.patchProjectsLocationsSources({
411 name: current.name ?? name,
412 updateMask: mask,
413 body: {
414 name: current.name ?? name,
415 description: news.description,
416 labels: desiredLabels,
417 aws: news.aws,
418 azure: news.azure,
419 vmware: news.vmware,
420 },
421 });
422 yield* waitForOperation(operation);
423 current = yield* waitUntilExists(
424 getByName(current.name ?? name),
425 current.name ?? name,
426 );
427 }
428
429 return toAttrs(current, env.project, env.region);
430 }),
431
432 delete: Effect.fn(function* ({ output }) {
433 const operation = yield* vm
434 .deleteProjectsLocationsSources({ name: output.name })
435 .pipe(
436 Effect.retry({
437 while: (error) => error._tag === "Conflict",
438 times: 8,
439 schedule: Schedule.spaced("2 seconds"),
440 }),
441 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
442 );
443 if (operation !== undefined) {
444 // NOT_FOUND (5): already gone.
445 yield* waitForOperation(operation).pipe(
446 Effect.catchTag("GCP.OperationFailed", (error) =>
447 error.code === 5 ? Effect.void : Effect.fail(error),
448 ),
449 );
450 }
451 yield* waitUntilGone(getByName(output.name), output.name);
452 }),
453 });