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 { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnership,
14 expandParent,
15 fieldMask,
16 fingerprint,
17 hasOwnershipMarker,
18 lastSegment,
19 locationParent,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseName,
23 parseOwnership,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 toPhysicalId,
27 waitForOperation,
28 waitUntilExists,
29 waitUntilGone,
30} from "./internal.ts";
31
32export type SourceType = mc.SourceTypeEnum | (string & {});
33export type SourceState = mc.SourceStateEnum | (string & {});
34
35export type SourceProps = {
36 /**
37 * Source id (the `{source}` segment of
38 * `projects/{project}/locations/{location}/sources/{source}`). If
39 * omitted, a unique RFC1035 name is generated. Immutable — changing it
40 * replaces the source.
41 */
42 sourceId?: string;
43 /**
44 * Region (`us-central1`, …). Immutable — changing it replaces the
45 * source. `US-CENTRAL1` is accepted and normalized to `us-central1`.
46 * @default the stack's GCP region (`GCP.Region`, else the profile
47 * region, else `us-central1`)
48 */
49 location?: string;
50 /**
51 * Data source type. Immutable — changing it replaces the source.
52 * @default "SOURCE_TYPE_UPLOAD"
53 */
54 type?: SourceType;
55 /**
56 * User-friendly display name.
57 */
58 displayName?: string;
59 /**
60 * Free-text description. Sources have no labels field, so Alchemy
61 * ownership is stored in a `[alchemy …]` prefix and stripped from
62 * attributes.
63 */
64 description?: string;
65 /**
66 * Information confidence of the source. Higher is more confident.
67 */
68 priority?: number;
69};
70
71export type Source = Resource<
72 "GCP.MigrationCenter.Source",
73 SourceProps,
74 {
75 /** Full resource name. */
76 name: string;
77 /** Source id (last path segment). */
78 sourceId: string;
79 /** Project id. */
80 project: string;
81 /** Location id. */
82 location: string;
83 /** Data source type. */
84 type: string | undefined;
85 /** User-friendly display name. */
86 displayName: string | undefined;
87 /** User description with the Alchemy ownership prefix stripped. */
88 description: string | undefined;
89 /** Information confidence. */
90 priority: number | undefined;
91 /** Whether another service manages this source. */
92 managed: boolean | undefined;
93 /** Server-reported state. */
94 state: string | undefined;
95 /** Frames still being processed. */
96 pendingFrameCount: number | undefined;
97 /** Frames that contained errors. */
98 errorFrameCount: number | undefined;
99 /** RFC3339 creation timestamp. */
100 createTime: string | undefined;
101 /** RFC3339 last-update timestamp. */
102 updateTime: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * A Migration Center source that streams asset frames into a project.
110 *
111 * Sources have no labels field — Alchemy stamps ownership into the
112 * description so `list` / nuke can find them. `sourceId`, `location`, and
113 * `type` are immutable. Display name, description, and priority update
114 * in place.
115 *
116 * ### Creating a Source
117 * **Example:** Upload source
118 * ```typescript
119 * const source = yield* GCP.MigrationCenter.Source("Inventory", {
120 * type: "SOURCE_TYPE_UPLOAD",
121 * displayName: "rvtools",
122 * });
123 * ```
124 *
125 * **Example:** Discovery-client source
126 * ```typescript
127 * const source = yield* GCP.MigrationCenter.Source("Scanner", {
128 * type: "SOURCE_TYPE_DISCOVERY_CLIENT",
129 * description: "on-prem scanner",
130 * });
131 * ```
132 *
133 * @resource
134 * @category MigrationCenter
135 */
136export const Source = Resource<Source>("GCP.MigrationCenter.Source");
137
138const DEFAULT_TYPE: SourceType = "SOURCE_TYPE_UPLOAD";
139
140const resourceName = (project: string, location: string, sourceId: string) =>
141 `${locationParent(project, location)}/sources/${sourceId}`;
142
143const toAttrs = (source: mc.Source, project: string, region: string) => {
144 const name = source.name ?? "";
145 const parsed = parseName(name, "sources", region);
146 const ownership = parseOwnership(source.description);
147 return {
148 name,
149 sourceId: parsed.id,
150 project: parsed.project || project,
151 location: parsed.location,
152 type: source.type,
153 displayName: source.displayName,
154 description: ownership.text,
155 priority: source.priority,
156 managed: source.managed,
157 state: source.state,
158 pendingFrameCount: source.pendingFrameCount,
159 errorFrameCount: source.errorFrameCount,
160 createTime: source.createTime,
161 updateTime: source.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 name.length === 0
167 ? Effect.succeed(undefined)
168 : mc
169 .getProjectsLocationsSources({ name })
170 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
171
172const listOwned = (project: string, region: string) =>
173 mc.listProjectsLocationsSources
174 .pages({
175 parent: `projects/${project}/locations/-`,
176 pageSize: 1000,
177 })
178 .pipe(
179 Stream.flatMap((page) => Stream.fromIterable(page.sources ?? [])),
180 Stream.filter((item) => hasOwnershipMarker(item.description)),
181 Stream.runCollect,
182 Effect.map((chunk) => Array.from(chunk)),
183 Effect.catchTag("NotFound", () =>
184 mc.listProjectsLocationsSources
185 .pages({
186 parent: locationParent(project, region),
187 pageSize: 1000,
188 })
189 .pipe(
190 Stream.flatMap((page) => Stream.fromIterable(page.sources ?? [])),
191 Stream.filter((item) => hasOwnershipMarker(item.description)),
192 Stream.runCollect,
193 Effect.map((chunk) => Array.from(chunk)),
194 Effect.catchTag("NotFound", () =>
195 Effect.succeed([] as mc.Source[]),
196 ),
197 ),
198 ),
199 );
200
201export const SourceProvider = () =>
202 Provider.succeed(Source, {
203 stables: ["name", "sourceId", "project", "location", "createTime"],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const env = yield* GcpEnvironment.current;
208 const previousType = olds?.type ?? output?.type ?? DEFAULT_TYPE;
209 const nextType = news.type ?? previousType;
210 return replaceOnIdentity({
211 previousId: olds?.sourceId ?? output?.sourceId,
212 nextId: news.sourceId ?? olds?.sourceId ?? output?.sourceId,
213 previousLocation: normalizeLocation(
214 olds?.location ?? output?.location,
215 env.region,
216 ),
217 nextLocation: normalizeLocation(
218 news.location ?? olds?.location ?? output?.location,
219 env.region,
220 ),
221 extra: previousType !== nextType,
222 });
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const env = yield* GcpEnvironment.current;
227 const sourceId = yield* toPhysicalId(
228 id,
229 olds?.sourceId,
230 output?.sourceId,
231 "source",
232 );
233 const location = normalizeLocation(
234 olds?.location ?? output?.location,
235 env.region,
236 );
237 const name =
238 output?.name ?? resourceName(env.project, location, sourceId);
239 const existing = yield* getByName(name);
240 if (existing === undefined) return undefined;
241 const attrs = toAttrs(existing, env.project, env.region);
242 return (yield* ownedByAlchemy(id, existing.description))
243 ? attrs
244 : Unowned(attrs);
245 }),
246
247 list: () =>
248 Effect.gen(function* () {
249 const env = yield* GcpEnvironment.current;
250 const items = yield* listOwned(env.project, env.region);
251 return items.map((item) => toAttrs(item, env.project, env.region));
252 }),
253
254 reconcile: Effect.fn(function* ({ id, news, output }) {
255 const env = yield* GcpEnvironment.current;
256 const sourceId = yield* toPhysicalId(
257 id,
258 news.sourceId,
259 output?.sourceId,
260 "source",
261 );
262 const location = normalizeLocation(
263 news.location ?? output?.location,
264 env.region,
265 );
266 const name = resourceName(env.project, location, sourceId);
267 const ownership = yield* createInternalLabels(id);
268 const description = encodeOwnership(ownership, news.description);
269 const type = news.type ?? DEFAULT_TYPE;
270 const displayName = news.displayName ?? sourceId;
271
272 let current = yield* getByName(output?.name ?? name);
273
274 if (current === undefined) {
275 const created = yield* mc
276 .createProjectsLocationsSources({
277 parent: locationParent(env.project, location),
278 sourceId,
279 body: {
280 type,
281 displayName,
282 description,
283 priority: news.priority,
284 },
285 })
286 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
287 if (created !== undefined) {
288 yield* waitForOperation(created);
289 }
290 current = yield* waitUntilExists(getByName(name), name);
291 }
292
293 if (current === undefined) {
294 return yield* new ResourceNotResolved({ name });
295 }
296
297 const descriptionChanged = (current.description ?? "") !== description;
298 const displayNameChanged = (current.displayName ?? "") !== displayName;
299 const priorityChanged =
300 fingerprint(current.priority) !== fingerprint(news.priority);
301 const mask = fieldMask([
302 descriptionChanged && "description",
303 displayNameChanged && "displayName",
304 priorityChanged && "priority",
305 ]);
306
307 if (mask.length > 0) {
308 const operation = yield* mc.patchProjectsLocationsSources({
309 name: current.name ?? name,
310 updateMask: mask,
311 body: {
312 name: current.name ?? name,
313 displayName,
314 description,
315 priority: news.priority,
316 },
317 });
318 yield* waitForOperation(operation);
319 current = yield* waitUntilExists(
320 getByName(current.name ?? name),
321 current.name ?? name,
322 );
323 }
324
325 return toAttrs(current, env.project, env.region);
326 }),
327
328 delete: Effect.fn(function* ({ output }) {
329 const operation = yield* mc
330 .deleteProjectsLocationsSources({ name: output.name })
331 .pipe(
332 Effect.retry({
333 while: (error) => error._tag === "Conflict",
334 times: 10,
335 schedule: Schedule.spaced("3 seconds"),
336 }),
337 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
338 );
339 if (operation !== undefined) {
340 yield* waitForOperation(operation, { notFoundOk: true });
341 }
342 yield* waitUntilGone(getByName(output.name), output.name);
343 }),
344 });
345
346export const sourceNameOf = (
347 value: string,
348 project: string,
349 location: string,
350) => expandParent(value, project, location, "sources");
351
352export const sourceIdOf = (name: string) => lastSegment(name);