1import * as mc from "@distilled.cloud/gcp/migrationcenter_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 expandParent,
20 fieldMask,
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 DiscoveryClientState = mc.DiscoveryClientStateEnum | (string & {});
35
36export type DiscoveryClientProps = {
37 /**
38 * Discovery client id (the `{discoveryClient}` segment of
39 * `projects/{project}/locations/{location}/discoveryClients/{discoveryClient}`).
40 * If omitted, a unique RFC1035 name is generated. Immutable — changing
41 * it replaces the client.
42 */
43 discoveryClientId?: string;
44 /**
45 * Region (`us-central1`, …). Immutable — changing it replaces the
46 * client. `US-CENTRAL1` is accepted and normalized to `us-central1`.
47 * @default the stack's GCP region (`GCP.Region`, else the profile
48 * region, else `us-central1`)
49 */
50 location?: string;
51 /**
52 * Full name of the associated source, or the source id (combined with
53 * `location`). Immutable — changing it replaces the client.
54 */
55 source: string;
56 /**
57 * Service account used by the discovery client
58 * (`user@project.iam.gserviceaccount.com` or
59 * `projects/{project}/serviceAccounts/{email}`).
60 */
61 serviceAccount: string;
62 /**
63 * User-friendly display name. Maximum length is 63 characters.
64 */
65 displayName?: string;
66 /**
67 * Free-text description. Maximum length is 1000 characters.
68 */
69 description?: string;
70 /**
71 * Client time-to-live (input only), e.g. `"2592000s"`.
72 */
73 ttl?: string;
74 /**
75 * Client expiration time in UTC. The backend stops accepting frames
76 * after this time.
77 */
78 expireTime?: string;
79 /**
80 * User labels. Alchemy ownership labels are merged in automatically.
81 */
82 labels?: Record<string, string>;
83};
84
85export type DiscoveryClient = Resource<
86 "GCP.MigrationCenter.DiscoveryClient",
87 DiscoveryClientProps,
88 {
89 /** Full resource name. */
90 name: string;
91 /** Discovery client id (last path segment). */
92 discoveryClientId: string;
93 /** Project id. */
94 project: string;
95 /** Location id. */
96 location: string;
97 /** Associated source resource name. */
98 source: string | undefined;
99 /** Service account used by the client. */
100 serviceAccount: string | undefined;
101 /** User-friendly display name. */
102 displayName: string | undefined;
103 /** Free-text description. */
104 description: string | undefined;
105 /** Expiration timestamp. */
106 expireTime: string | undefined;
107 /** User labels (Alchemy ownership labels stripped). */
108 labels: Record<string, string>;
109 /** Server-reported state. */
110 state: string | undefined;
111 /** Client version from the last heartbeat. */
112 version: string | undefined;
113 /** Signals endpoint. */
114 signalsEndpoint: string | undefined;
115 /** Last heartbeat timestamp. */
116 heartbeatTime: string | undefined;
117 /** RFC3339 creation timestamp. */
118 createTime: string | undefined;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * A Migration Center discovery client that reports on-prem asset frames
128 * for a source.
129 *
130 * `discoveryClientId`, `location`, and `source` are immutable. Display
131 * name, description, service account, expiration, and labels update in
132 * place.
133 *
134 * ### Creating a Discovery Client
135 * **Example:** Bind a scanner to a source
136 * ```typescript
137 * const source = yield* GCP.MigrationCenter.Source("Scanner", {
138 * type: "SOURCE_TYPE_DISCOVERY_CLIENT",
139 * });
140 * const client = yield* GCP.MigrationCenter.DiscoveryClient("Agent", {
141 * source: source.name,
142 * serviceAccount: "scanner@my-project.iam.gserviceaccount.com",
143 * displayName: "on-prem-agent",
144 * });
145 * ```
146 *
147 * @resource
148 * @category MigrationCenter
149 */
150export const DiscoveryClient = Resource<DiscoveryClient>(
151 "GCP.MigrationCenter.DiscoveryClient",
152);
153
154const resourceName = (
155 project: string,
156 location: string,
157 discoveryClientId: string,
158) =>
159 `${locationParent(project, location)}/discoveryClients/${discoveryClientId}`;
160
161const sourceOf = (value: string, project: string, location: string) =>
162 expandParent(value, project, location, "sources");
163
164const toAttrs = (item: mc.DiscoveryClient, project: string, region: string) => {
165 const name = item.name ?? "";
166 const parsed = parseName(name, "discoveryClients", region);
167 return {
168 name,
169 discoveryClientId: parsed.id,
170 project: parsed.project || project,
171 location: parsed.location,
172 source: item.source,
173 serviceAccount: item.serviceAccount,
174 displayName: item.displayName,
175 description: item.description,
176 expireTime: item.expireTime,
177 labels: userLabels(item.labels),
178 state: item.state,
179 version: item.version,
180 signalsEndpoint: item.signalsEndpoint,
181 heartbeatTime: item.heartbeatTime,
182 createTime: item.createTime,
183 updateTime: item.updateTime,
184 };
185};
186
187const getByName = (name: string) =>
188 name.length === 0
189 ? Effect.succeed(undefined)
190 : mc
191 .getProjectsLocationsDiscoveryClients({ name })
192 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
193
194const listOwned = (project: string, region: string) =>
195 mc.listProjectsLocationsDiscoveryClients
196 .pages({
197 parent: `projects/${project}/locations/-`,
198 pageSize: 1000,
199 })
200 .pipe(
201 Stream.flatMap((page) =>
202 Stream.fromIterable(page.discoveryClients ?? []),
203 ),
204 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
205 Stream.runCollect,
206 Effect.map((chunk) => Array.from(chunk)),
207 Effect.catchTag("NotFound", () =>
208 mc.listProjectsLocationsDiscoveryClients
209 .pages({
210 parent: locationParent(project, region),
211 pageSize: 1000,
212 })
213 .pipe(
214 Stream.flatMap((page) =>
215 Stream.fromIterable(page.discoveryClients ?? []),
216 ),
217 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
218 Stream.runCollect,
219 Effect.map((chunk) => Array.from(chunk)),
220 Effect.catchTag("NotFound", () =>
221 Effect.succeed([] as mc.DiscoveryClient[]),
222 ),
223 ),
224 ),
225 );
226
227export const DiscoveryClientProvider = () =>
228 Provider.succeed(DiscoveryClient, {
229 stables: ["name", "discoveryClientId", "project", "location", "createTime"],
230
231 diff: Effect.fn(function* ({ news, olds, output }) {
232 if (!isResolved(news)) return undefined;
233 const env = yield* GcpEnvironment.current;
234 const previousSource = olds?.source ?? output?.source;
235 const nextSource = news.source;
236 const sourceChanged =
237 previousSource !== undefined &&
238 nextSource !== undefined &&
239 previousSource !== nextSource &&
240 !previousSource.endsWith(`/${nextSource}`) &&
241 !nextSource.endsWith(`/${previousSource}`);
242 return replaceOnIdentity({
243 previousId: olds?.discoveryClientId ?? output?.discoveryClientId,
244 nextId:
245 news.discoveryClientId ??
246 olds?.discoveryClientId ??
247 output?.discoveryClientId,
248 previousLocation: normalizeLocation(
249 olds?.location ?? output?.location,
250 env.region,
251 ),
252 nextLocation: normalizeLocation(
253 news.location ?? olds?.location ?? output?.location,
254 env.region,
255 ),
256 extra: sourceChanged,
257 });
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const discoveryClientId = yield* toPhysicalId(
263 id,
264 olds?.discoveryClientId,
265 output?.discoveryClientId,
266 "discclient",
267 );
268 const location = normalizeLocation(
269 olds?.location ?? output?.location,
270 env.region,
271 );
272 const name =
273 output?.name ?? resourceName(env.project, location, discoveryClientId);
274 const existing = yield* getByName(name);
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, env.project, env.region);
277 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
278 ? attrs
279 : Unowned(attrs);
280 }),
281
282 list: () =>
283 Effect.gen(function* () {
284 const env = yield* GcpEnvironment.current;
285 const items = yield* listOwned(env.project, env.region);
286 return items.map((item) => toAttrs(item, env.project, env.region));
287 }),
288
289 reconcile: Effect.fn(function* ({ id, news, output }) {
290 const env = yield* GcpEnvironment.current;
291 const discoveryClientId = yield* toPhysicalId(
292 id,
293 news.discoveryClientId,
294 output?.discoveryClientId,
295 "discclient",
296 );
297 const location = normalizeLocation(
298 news.location ?? output?.location,
299 env.region,
300 );
301 const name = resourceName(env.project, location, discoveryClientId);
302 const source = sourceOf(news.source, env.project, location);
303 const desiredLabels = {
304 ...toLabels(news.labels),
305 ...(yield* createInternalLabels(id)),
306 };
307 const displayName = news.displayName ?? discoveryClientId;
308
309 let current = yield* getByName(output?.name ?? name);
310
311 if (current === undefined) {
312 const created = yield* mc
313 .createProjectsLocationsDiscoveryClients({
314 parent: locationParent(env.project, location),
315 discoveryClientId,
316 body: {
317 source,
318 serviceAccount: news.serviceAccount,
319 displayName,
320 description: news.description,
321 ttl: news.ttl,
322 expireTime: news.expireTime,
323 labels: desiredLabels,
324 },
325 })
326 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
327 if (created !== undefined) {
328 yield* waitForOperation(created);
329 }
330 current = yield* waitUntilExists(getByName(name), name);
331 }
332
333 if (current === undefined) {
334 return yield* new ResourceNotResolved({ name });
335 }
336
337 const observedLabels = tagRecord(current.labels);
338 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
339 const labelsChanged = upsert.length > 0 || removed.length > 0;
340 const displayNameChanged = (current.displayName ?? "") !== displayName;
341 const descriptionChanged =
342 (current.description ?? "") !== (news.description ?? "");
343 const serviceAccountChanged =
344 (current.serviceAccount ?? "") !== news.serviceAccount;
345 const expireTimeChanged =
346 (current.expireTime ?? "") !== (news.expireTime ?? "");
347 const mask = fieldMask([
348 labelsChanged && "labels",
349 displayNameChanged && "displayName",
350 descriptionChanged && "description",
351 serviceAccountChanged && "serviceAccount",
352 expireTimeChanged && "expireTime",
353 ]);
354
355 if (mask.length > 0) {
356 const operation = yield* mc.patchProjectsLocationsDiscoveryClients({
357 name: current.name ?? name,
358 updateMask: mask,
359 body: {
360 name: current.name ?? name,
361 displayName,
362 description: news.description,
363 serviceAccount: news.serviceAccount,
364 expireTime: news.expireTime,
365 labels: desiredLabels,
366 },
367 });
368 yield* waitForOperation(operation);
369 current = yield* waitUntilExists(
370 getByName(current.name ?? name),
371 current.name ?? name,
372 );
373 }
374
375 return toAttrs(current, env.project, env.region);
376 }),
377
378 delete: Effect.fn(function* ({ output }) {
379 const operation = yield* mc
380 .deleteProjectsLocationsDiscoveryClients({ name: output.name })
381 .pipe(
382 Effect.retry({
383 while: (error) => error._tag === "Conflict",
384 times: 8,
385 schedule: Schedule.spaced("2 seconds"),
386 }),
387 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
388 );
389 if (operation !== undefined) {
390 yield* waitForOperation(operation, { notFoundOk: true });
391 }
392 yield* waitUntilGone(getByName(output.name), output.name);
393 }),
394 });