1import * as rma from "@distilled.cloud/gcp/rapidmigrationassessment_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 expectedAssetCountOf,
19 fieldMask,
20 isGoneState,
21 isPausedState,
22 isRegisteredState,
23 listOwnedCollectors,
24 locationParent,
25 normalizeLocation,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 sameNumber,
30 sameText,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36 waitUntilReady,
37} from "./internal.ts";
38
39export type CollectorState = rma.CollectorStateEnum | (string & {});
40
41export type CollectorProps = {
42 /**
43 * Collector id (the `{collector}` segment of
44 * `projects/{project}/locations/{location}/collectors/{collector}`).
45 * If omitted, a unique RFC1035 name is generated. Immutable —
46 * changing it replaces the collector.
47 */
48 collectorId?: string;
49 /**
50 * Region (`us-central1`, …). Immutable — changing it replaces the
51 * collector. `US-CENTRAL1` is accepted and normalized to `us-central1`.
52 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
53 */
54 location?: string;
55 /**
56 * User-facing display name. Alchemy falls back to the generated
57 * collector id.
58 */
59 displayName?: string;
60 /**
61 * User-specified description of the collector.
62 */
63 description?: string;
64 /**
65 * How many days to collect data.
66 */
67 collectionDays?: number;
68 /**
69 * Service account email used to ingest data to this collector.
70 */
71 serviceAccount?: string;
72 /**
73 * User-specified expected asset count.
74 */
75 expectedAssetCount?: number | string;
76 /**
77 * URI for the End User License Agreement accepted by the customer.
78 */
79 eulaUri?: string;
80 /**
81 * When true, pause an active collector. When false, resume a paused
82 * collector. Omitted leaves pause state unchanged.
83 */
84 paused?: boolean;
85 /**
86 * When true, register a collector that is ready to use. Registration
87 * is one-way; omitted or false leaves the collector unregistered.
88 */
89 registered?: boolean;
90 /**
91 * User labels. Alchemy ownership labels are merged in automatically.
92 */
93 labels?: Record<string, string>;
94};
95
96export type Collector = Resource<
97 "GCP.RapidMigrationAssessment.Collector",
98 CollectorProps,
99 {
100 /** Full resource name. */
101 name: string;
102 /** Collector id (last path segment). */
103 collectorId: string;
104 /** Project id. */
105 project: string;
106 /** Location id. */
107 location: string;
108 /** User-facing display name. */
109 displayName: string | undefined;
110 /** User-specified description. */
111 description: string | undefined;
112 /** How many days to collect data. */
113 collectionDays: number | undefined;
114 /** Service account email used to ingest data. */
115 serviceAccount: string | undefined;
116 /** Expected asset count. */
117 expectedAssetCount: string | undefined;
118 /** EULA URI. */
119 eulaUri: string | undefined;
120 /** User labels (Alchemy ownership labels stripped). */
121 labels: Record<string, string>;
122 /** Server-reported collector state. */
123 state: string | undefined;
124 /** Whether the collector is paused. */
125 paused: boolean;
126 /** Whether the collector has been registered. */
127 registered: boolean;
128 /** Client version reported by the appliance. */
129 clientVersion: string | undefined;
130 /** Cloud Storage bucket created with this collector. */
131 bucket: string | undefined;
132 /** Migration Center guest-OS scan reference. */
133 guestOsScan: rma.GuestOsScan | undefined;
134 /** Migration Center vSphere scan reference. */
135 vsphereScan: rma.VSphereScan | undefined;
136 /** RFC3339 creation timestamp. */
137 createTime: string | undefined;
138 /** RFC3339 last-update timestamp. */
139 updateTime: string | undefined;
140 },
141 never,
142 Providers
143>;
144
145/**
146 * A Rapid Migration Assessment collector that manages the on-prem
147 * appliance gathering customer asset information.
148 *
149 * `collectorId` and `location` are immutable. Display name, description,
150 * collection days, service account, expected asset count, EULA URI, and
151 * labels update in place. Set `paused` to pause or resume, and
152 * `registered` to register a collector that is ready to use.
153 *
154 * ### Creating a Collector
155 * **Example:** Generated name
156 * ```typescript
157 * const collector = yield* GCP.RapidMigrationAssessment.Collector("OnPrem", {
158 * collectionDays: 30,
159 * expectedAssetCount: 100,
160 * labels: { env: "test" },
161 * });
162 * ```
163 *
164 * **Example:** Explicit id and service account
165 * ```typescript
166 * const collector = yield* GCP.RapidMigrationAssessment.Collector("OnPrem", {
167 * collectorId: "dc-east",
168 * location: "us-central1",
169 * displayName: "east datacenter",
170 * serviceAccount: "rma@my-project.iam.gserviceaccount.com",
171 * collectionDays: 14,
172 * });
173 * ```
174 *
175 * ### Updating a Collector
176 * **Example:** Description, labels, and pause
177 * ```typescript
178 * const collector = yield* GCP.RapidMigrationAssessment.Collector("OnPrem", {
179 * displayName: "east datacenter v2",
180 * description: "paused for maintenance",
181 * paused: true,
182 * labels: { env: "prod", team: "migration" },
183 * });
184 * ```
185 *
186 * @resource
187 * @category RapidMigrationAssessment
188 */
189export const Collector = Resource<Collector>(
190 "GCP.RapidMigrationAssessment.Collector",
191);
192
193const resourceName = (project: string, location: string, collectorId: string) =>
194 `${locationParent(project, location)}/collectors/${collectorId}`;
195
196const toAttrs = (item: rma.Collector, project: string) => {
197 const name = item.name ?? "";
198 const parsed = parseName(name);
199 return {
200 name,
201 collectorId: parsed.id,
202 project: parsed.project || project,
203 location: parsed.location,
204 displayName: item.displayName,
205 description: item.description,
206 collectionDays: item.collectionDays,
207 serviceAccount: item.serviceAccount,
208 expectedAssetCount: item.expectedAssetCount,
209 eulaUri: item.eulaUri,
210 labels: userLabels(item.labels),
211 state: item.state,
212 paused: isPausedState(item.state),
213 registered: isRegisteredState(item.state),
214 clientVersion: item.clientVersion,
215 bucket: item.bucket,
216 guestOsScan: item.guestOsScan,
217 vsphereScan: item.vsphereScan,
218 createTime: item.createTime,
219 updateTime: item.updateTime,
220 };
221};
222
223const getByName = (name: string) =>
224 name.length === 0
225 ? Effect.succeed(undefined)
226 : rma.getProjectsLocationsCollectors({ name }).pipe(
227 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
228 Effect.map((item) =>
229 item === undefined || isGoneState(item.state) ? undefined : item,
230 ),
231 );
232
233const ignoreConflict = <A, R>(
234 effect: Effect.Effect<
235 A,
236 | rma.PauseProjectsLocationsCollectorsError
237 | rma.RegisterProjectsLocationsCollectorsError
238 | rma.ResumeProjectsLocationsCollectorsError,
239 R
240 >,
241) =>
242 effect.pipe(
243 Effect.catchTag(["Conflict", "BadRequest"], () =>
244 Effect.succeed<A | undefined>(undefined),
245 ),
246 );
247
248const applyDesiredState = (
249 name: string,
250 current: rma.Collector,
251 news: CollectorProps,
252) =>
253 Effect.gen(function* () {
254 let next = current;
255 const state = next.state;
256
257 if (news.registered === true && !isRegisteredState(state)) {
258 const operation = yield* ignoreConflict(
259 rma.registerProjectsLocationsCollectors({ name }),
260 );
261 if (operation !== undefined) {
262 yield* waitForOperation(operation);
263 next = yield* waitUntilExists(getByName(name), name);
264 }
265 }
266
267 if (news.paused === true && !isPausedState(next.state)) {
268 const operation = yield* ignoreConflict(
269 rma.pauseProjectsLocationsCollectors({ name }),
270 );
271 if (operation !== undefined) {
272 yield* waitForOperation(operation);
273 next = yield* waitUntilExists(getByName(name), name);
274 }
275 }
276
277 if (news.paused === false && isPausedState(next.state)) {
278 const operation = yield* ignoreConflict(
279 rma.resumeProjectsLocationsCollectors({ name }),
280 );
281 if (operation !== undefined) {
282 yield* waitForOperation(operation);
283 next = yield* waitUntilExists(getByName(name), name);
284 }
285 }
286
287 return next;
288 });
289
290export const CollectorProvider = () =>
291 Provider.succeed(Collector, {
292 stables: ["name", "collectorId", "project", "location", "createTime"],
293
294 diff: Effect.fn(function* ({ news, olds, output }) {
295 if (!isResolved(news)) return undefined;
296 const env = yield* GcpEnvironment.current;
297 return replaceOnIdentity({
298 previousId: olds?.collectorId ?? output?.collectorId,
299 nextId: news.collectorId ?? olds?.collectorId ?? output?.collectorId,
300 previousLocation: normalizeLocation(
301 olds?.location ?? output?.location,
302 env.region,
303 ),
304 nextLocation: normalizeLocation(
305 news.location ?? olds?.location ?? output?.location,
306 env.region,
307 ),
308 });
309 }),
310
311 read: Effect.fn(function* ({ id, olds, output }) {
312 const env = yield* GcpEnvironment.current;
313 const collectorId = yield* toPhysicalId(
314 id,
315 olds?.collectorId,
316 output?.collectorId,
317 );
318 const location = normalizeLocation(
319 olds?.location ?? output?.location,
320 env.region,
321 );
322 const name =
323 output?.name ?? resourceName(env.project, location, collectorId);
324 const existing = yield* getByName(name);
325 if (existing === undefined) return undefined;
326 const attrs = toAttrs(existing, env.project);
327 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
328 ? attrs
329 : Unowned(attrs);
330 }),
331
332 list: () =>
333 Effect.gen(function* () {
334 const env = yield* GcpEnvironment.current;
335 const items = yield* listOwnedCollectors(env.project, env.region);
336 return items
337 .filter((item) => !isGoneState(item.state))
338 .map((item) => toAttrs(item, env.project));
339 }),
340
341 reconcile: Effect.fn(function* ({ id, news, output }) {
342 const env = yield* GcpEnvironment.current;
343 const collectorId = yield* toPhysicalId(
344 id,
345 news.collectorId,
346 output?.collectorId,
347 );
348 const location = normalizeLocation(
349 news.location ?? output?.location,
350 env.region,
351 );
352 const name = resourceName(env.project, location, collectorId);
353 const displayName = news.displayName ?? collectorId;
354 const expectedAssetCount = expectedAssetCountOf(news.expectedAssetCount);
355 const desiredLabels = {
356 ...toLabels(news.labels),
357 ...(yield* createInternalLabels(id)),
358 };
359
360 let observed = yield* getByName(output?.name ?? name);
361
362 if (observed === undefined) {
363 const created = yield* rma
364 .createProjectsLocationsCollectors({
365 parent: locationParent(env.project, location),
366 collectorId,
367 body: {
368 displayName,
369 description: news.description,
370 collectionDays: news.collectionDays,
371 serviceAccount: news.serviceAccount,
372 expectedAssetCount,
373 eulaUri: news.eulaUri,
374 labels: desiredLabels,
375 },
376 })
377 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
378 if (created !== undefined) {
379 yield* waitForOperation(created);
380 }
381 observed = yield* waitUntilExists(getByName(name), name);
382 }
383
384 let current = observed ?? (yield* new ResourceNotResolved({ name }));
385 current = yield* waitUntilReady(
386 getByName(current.name ?? name),
387 current.name ?? name,
388 (item) => item.state,
389 ).pipe(
390 Effect.catchTag("GCP.RapidMigrationAssessment.ResourceNotReady", () =>
391 Effect.succeed(current),
392 ),
393 );
394
395 const observedLabels = tagRecord(current.labels);
396 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
397 const mask = fieldMask([
398 (upsert.length > 0 || removed.length > 0) && "labels",
399 !sameText(current.displayName, displayName) && "displayName",
400 news.description !== undefined &&
401 !sameText(current.description, news.description) &&
402 "description",
403 news.collectionDays !== undefined &&
404 !sameNumber(current.collectionDays, news.collectionDays) &&
405 "collectionDays",
406 news.serviceAccount !== undefined &&
407 !sameText(current.serviceAccount, news.serviceAccount) &&
408 "serviceAccount",
409 expectedAssetCount !== undefined &&
410 !sameText(current.expectedAssetCount, expectedAssetCount) &&
411 "expectedAssetCount",
412 news.eulaUri !== undefined &&
413 !sameText(current.eulaUri, news.eulaUri) &&
414 "eulaUri",
415 ]);
416
417 if (mask.length > 0) {
418 const operation = yield* rma.patchProjectsLocationsCollectors({
419 name: current.name ?? name,
420 updateMask: mask,
421 body: {
422 name: current.name ?? name,
423 displayName,
424 description: news.description,
425 collectionDays: news.collectionDays,
426 serviceAccount: news.serviceAccount,
427 expectedAssetCount,
428 eulaUri: news.eulaUri,
429 labels: desiredLabels,
430 },
431 });
432 yield* waitForOperation(operation);
433 current = yield* waitUntilExists(
434 getByName(current.name ?? name),
435 current.name ?? name,
436 );
437 }
438
439 current = yield* applyDesiredState(current.name ?? name, current, news);
440 return toAttrs(current, env.project);
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 const operation = yield* rma
445 .deleteProjectsLocationsCollectors({ name: output.name })
446 .pipe(
447 Effect.retry({
448 while: (error) => error._tag === "Conflict",
449 times: 8,
450 schedule: Schedule.spaced("2 seconds"),
451 }),
452 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
453 );
454 if (operation !== undefined) {
455 yield* waitForOperation(operation, { notFoundOk: true });
456 }
457 yield* waitUntilGone(getByName(output.name), output.name);
458 }),
459 });