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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 fingerprint,
15 hasAlchemyLabelMap,
16 locationParent,
17 normalizeLocation,
18 parseName,
19 replaceOnIdentity,
20 ResourceNotResolved,
21 toPhysicalId,
22 userLabels,
23 waitForOperation,
24 waitUntilExists,
25 waitUntilGone,
26} from "./internal.ts";
27
28export type AssetsExportJobFileFormat =
29 | mc.SignedUriDestinationFileFormatEnum
30 | (string & {});
31
32export type AssetsExportJobProps = {
33 /**
34 * Assets export job id (the `{assetsExportJob}` segment of
35 * `projects/{project}/locations/{location}/assetsExportJobs/{assetsExportJob}`).
36 * If omitted, a unique RFC1035 name is generated. Immutable — changing
37 * it replaces the job.
38 */
39 assetsExportJobId?: string;
40 /**
41 * Region (`us-central1`, …). Immutable — changing it replaces the job.
42 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, else the profile
44 * region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * File format for signed-URI downloads. The API has no update method,
49 * so changing it replaces the job.
50 * @default "CSV"
51 */
52 fileFormat?: AssetsExportJobFileFormat;
53 /**
54 * Optional filter selecting which assets to export.
55 */
56 filter?: string;
57 /**
58 * When true, hidden assets are included in the export.
59 * @default false
60 */
61 showHidden?: boolean;
62 /**
63 * When true, export asset inventory details.
64 * @default true
65 */
66 inventory?: boolean;
67 /**
68 * When true, export network-dependency data.
69 * @default false
70 */
71 networkDependencies?: boolean;
72 /**
73 * Performance-data window in days (1-420). Omit to skip performance
74 * export.
75 */
76 performanceDataMaxDays?: number;
77 /**
78 * User labels. Alchemy ownership labels are merged in automatically.
79 * The API has no update method, so changing labels replaces the job.
80 */
81 labels?: Record<string, string>;
82};
83
84export type AssetsExportJob = Resource<
85 "GCP.MigrationCenter.AssetsExportJob",
86 AssetsExportJobProps,
87 {
88 /** Full resource name. */
89 name: string;
90 /** Assets export job id (last path segment). */
91 assetsExportJobId: string;
92 /** Project id. */
93 project: string;
94 /** Location id. */
95 location: string;
96 /** Signed-URI destination file format. */
97 fileFormat: string | undefined;
98 /** Asset filter. */
99 filter: string | undefined;
100 /** Whether hidden assets are included. */
101 showHidden: boolean | undefined;
102 /** Whether inventory details are exported. */
103 inventory: boolean;
104 /** Whether network-dependency data is exported. */
105 networkDependencies: boolean;
106 /** Performance-data window in days. */
107 performanceDataMaxDays: number | undefined;
108 /** User labels (Alchemy ownership labels stripped). */
109 labels: Record<string, string>;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Migration Center assets export job that writes inventory (and
121 * optional performance / network-dependency) data to signed-URI files.
122 *
123 * The API has no patch method — changing format, filter, inventory flags,
124 * or labels replaces the job. Id and location are also immutable.
125 *
126 * ### Creating an Assets Export Job
127 * **Example:** CSV inventory export
128 * ```typescript
129 * const job = yield* GCP.MigrationCenter.AssetsExportJob("Inventory", {
130 * fileFormat: "CSV",
131 * labels: { env: "test" },
132 * });
133 * ```
134 *
135 * **Example:** XLSX with performance data
136 * ```typescript
137 * const job = yield* GCP.MigrationCenter.AssetsExportJob("Inventory", {
138 * fileFormat: "XLSX",
139 * performanceDataMaxDays: 40,
140 * });
141 * ```
142 *
143 * @resource
144 * @category MigrationCenter
145 */
146export const AssetsExportJob = Resource<AssetsExportJob>(
147 "GCP.MigrationCenter.AssetsExportJob",
148);
149
150const DEFAULT_FORMAT: AssetsExportJobFileFormat = "CSV";
151
152const resourceName = (
153 project: string,
154 location: string,
155 assetsExportJobId: string,
156) =>
157 `${locationParent(project, location)}/assetsExportJobs/${assetsExportJobId}`;
158
159const toAttrs = (job: mc.AssetsExportJob, project: string, region: string) => {
160 const name = job.name ?? "";
161 const parsed = parseName(name, "assetsExportJobs", region);
162 return {
163 name,
164 assetsExportJobId: parsed.id,
165 project: parsed.project || project,
166 location: parsed.location,
167 fileFormat: job.signedUriDestination?.fileFormat,
168 filter: job.condition?.filter,
169 showHidden: job.showHidden,
170 inventory: job.inventory !== undefined,
171 networkDependencies: job.networkDependencies !== undefined,
172 performanceDataMaxDays: job.performanceData?.maxDays,
173 labels: userLabels(job.labels),
174 createTime: job.createTime,
175 updateTime: job.updateTime,
176 };
177};
178
179const desiredBody = (
180 news: AssetsExportJobProps,
181 labels: Record<string, string>,
182): mc.AssetsExportJob => ({
183 labels,
184 showHidden: news.showHidden === true,
185 condition:
186 news.filter !== undefined && news.filter.length > 0
187 ? { filter: news.filter }
188 : undefined,
189 signedUriDestination: {
190 fileFormat: news.fileFormat ?? DEFAULT_FORMAT,
191 },
192 inventory: news.inventory === false ? undefined : {},
193 networkDependencies: news.networkDependencies === true ? {} : undefined,
194 performanceData:
195 news.performanceDataMaxDays !== undefined
196 ? { maxDays: news.performanceDataMaxDays }
197 : undefined,
198});
199
200const getByName = (name: string) =>
201 name.length === 0
202 ? Effect.succeed(undefined)
203 : mc
204 .getProjectsLocationsAssetsExportJobs({ name })
205 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
206
207const listOwned = (project: string, region: string) =>
208 mc.listProjectsLocationsAssetsExportJobs
209 .pages({
210 parent: `projects/${project}/locations/-`,
211 pageSize: 1000,
212 })
213 .pipe(
214 Stream.flatMap((page) =>
215 Stream.fromIterable(page.assetsExportJobs ?? []),
216 ),
217 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
218 Stream.runCollect,
219 Effect.map((chunk) => Array.from(chunk)),
220 Effect.catchTag("NotFound", () =>
221 mc.listProjectsLocationsAssetsExportJobs
222 .pages({
223 parent: locationParent(project, region),
224 pageSize: 1000,
225 })
226 .pipe(
227 Stream.flatMap((page) =>
228 Stream.fromIterable(page.assetsExportJobs ?? []),
229 ),
230 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
231 Stream.runCollect,
232 Effect.map((chunk) => Array.from(chunk)),
233 Effect.catchTag("NotFound", () =>
234 Effect.succeed([] as mc.AssetsExportJob[]),
235 ),
236 ),
237 ),
238 );
239
240export const AssetsExportJobProvider = () =>
241 Provider.succeed(AssetsExportJob, {
242 stables: ["name", "assetsExportJobId", "project", "location", "createTime"],
243
244 diff: Effect.fn(function* ({ news, olds, output }) {
245 if (!isResolved(news)) return undefined;
246 const env = yield* GcpEnvironment.current;
247 const payloadChanged =
248 fingerprint({
249 fileFormat: news.fileFormat ?? DEFAULT_FORMAT,
250 filter: news.filter,
251 showHidden: news.showHidden === true,
252 inventory: news.inventory !== false,
253 networkDependencies: news.networkDependencies === true,
254 performanceDataMaxDays: news.performanceDataMaxDays,
255 labels: news.labels ?? {},
256 }) !==
257 fingerprint({
258 fileFormat: olds?.fileFormat ?? output?.fileFormat ?? DEFAULT_FORMAT,
259 filter: olds?.filter ?? output?.filter,
260 showHidden: (olds?.showHidden ?? output?.showHidden) === true,
261 inventory: (olds?.inventory ?? output?.inventory) !== false,
262 networkDependencies:
263 (olds?.networkDependencies ?? output?.networkDependencies) === true,
264 performanceDataMaxDays:
265 olds?.performanceDataMaxDays ?? output?.performanceDataMaxDays,
266 labels: olds?.labels ?? {},
267 });
268 return replaceOnIdentity({
269 previousId: olds?.assetsExportJobId ?? output?.assetsExportJobId,
270 nextId:
271 news.assetsExportJobId ??
272 olds?.assetsExportJobId ??
273 output?.assetsExportJobId,
274 previousLocation: normalizeLocation(
275 olds?.location ?? output?.location,
276 env.region,
277 ),
278 nextLocation: normalizeLocation(
279 news.location ?? olds?.location ?? output?.location,
280 env.region,
281 ),
282 extra: payloadChanged,
283 });
284 }),
285
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const env = yield* GcpEnvironment.current;
288 const assetsExportJobId = yield* toPhysicalId(
289 id,
290 olds?.assetsExportJobId,
291 output?.assetsExportJobId,
292 "exportjob",
293 );
294 const location = normalizeLocation(
295 olds?.location ?? output?.location,
296 env.region,
297 );
298 const name =
299 output?.name ?? resourceName(env.project, location, assetsExportJobId);
300 const existing = yield* getByName(name);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project, env.region);
303 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
304 ? attrs
305 : Unowned(attrs);
306 }),
307
308 list: () =>
309 Effect.gen(function* () {
310 const env = yield* GcpEnvironment.current;
311 const items = yield* listOwned(env.project, env.region);
312 return items.map((item) => toAttrs(item, env.project, env.region));
313 }),
314
315 reconcile: Effect.fn(function* ({ id, news, output }) {
316 const env = yield* GcpEnvironment.current;
317 const assetsExportJobId = yield* toPhysicalId(
318 id,
319 news.assetsExportJobId,
320 output?.assetsExportJobId,
321 "exportjob",
322 );
323 const location = normalizeLocation(
324 news.location ?? output?.location,
325 env.region,
326 );
327 const name = resourceName(env.project, location, assetsExportJobId);
328 const desiredLabels = {
329 ...toLabels(news.labels),
330 ...(yield* createInternalLabels(id)),
331 };
332
333 let current = yield* getByName(output?.name ?? name);
334
335 if (current === undefined) {
336 const created = yield* mc
337 .createProjectsLocationsAssetsExportJobs({
338 parent: locationParent(env.project, location),
339 assetsExportJobId,
340 body: desiredBody(news, desiredLabels),
341 })
342 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
343 if (created !== undefined) {
344 yield* waitForOperation(created);
345 }
346 current = yield* waitUntilExists(getByName(name), name);
347 }
348
349 if (current === undefined) {
350 return yield* new ResourceNotResolved({ name });
351 }
352
353 return toAttrs(current, env.project, env.region);
354 }),
355
356 delete: Effect.fn(function* ({ output }) {
357 const operation = yield* mc
358 .deleteProjectsLocationsAssetsExportJobs({ name: output.name })
359 .pipe(
360 Effect.retry({
361 while: (error) => error._tag === "Conflict",
362 times: 8,
363 schedule: Schedule.spaced("2 seconds"),
364 }),
365 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
366 );
367 if (operation !== undefined) {
368 yield* waitForOperation(operation, { notFoundOk: true });
369 }
370 yield* waitUntilGone(getByName(output.name), output.name);
371 }),
372 });