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 encodeOwnershipLine,
14 expandParent,
15 hasOwnershipMarker,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOnIdentity,
22 ResourceNotResolved,
23 toPhysicalId,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type ImportDataFileFormat = mc.ImportDataFileFormatEnum | (string & {});
30export type ImportDataFileState = mc.ImportDataFileStateEnum | (string & {});
31
32export type ImportJobsImportDataFileProps = {
33 /**
34 * Parent import job. Full name
35 * `projects/{project}/locations/{location}/importJobs/{importJob}` or
36 * the import job id (combined with `location`). Immutable — changing it
37 * replaces the file.
38 */
39 importJob: string;
40 /**
41 * Region used when `importJob` is a bare id.
42 * @default the stack's GCP region (`GCP.Region`, else the profile
43 * region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Import data file id (the `{importDataFile}` segment). If omitted, a
48 * unique RFC1035 name is generated. Immutable — changing it replaces
49 * the file.
50 */
51 importDataFileId?: string;
52 /**
53 * Payload format. Immutable — changing it replaces the file.
54 * @default "IMPORT_JOB_FORMAT_RVTOOLS_CSV"
55 */
56 format?: ImportDataFileFormat;
57 /**
58 * User-friendly display name. Maximum length is 63 characters including
59 * Alchemy's ownership marker. Import data files have no labels field,
60 * so ownership is stored in a `[alchemy …]` prefix and stripped from
61 * attributes.
62 */
63 displayName?: string;
64};
65
66export type ImportJobsImportDataFile = Resource<
67 "GCP.MigrationCenter.ImportJobsImportDataFile",
68 ImportJobsImportDataFileProps,
69 {
70 /** Full resource name. */
71 name: string;
72 /** Import data file id (last path segment). */
73 importDataFileId: string;
74 /** Parent import job resource name. */
75 importJob: string;
76 /** Project id. */
77 project: string;
78 /** Location id. */
79 location: string;
80 /** Payload format. */
81 format: string | undefined;
82 /** User display name with the Alchemy ownership prefix stripped. */
83 displayName: string | undefined;
84 /** Server-reported state. */
85 state: string | undefined;
86 /** Signed upload URI. */
87 signedUri: string | undefined;
88 /** Upload URI expiration. */
89 uriExpirationTime: string | undefined;
90 /** RFC3339 creation timestamp. */
91 createTime: string | undefined;
92 },
93 never,
94 Providers
95>;
96
97/**
98 * A payload file attached to a Migration Center import job. Creating the
99 * file returns a signed URI that the client uploads inventory CSV/XLSX
100 * into.
101 *
102 * Import data files have no labels field — Alchemy stamps ownership into
103 * the display name so `list` / nuke can find them. The API has no patch
104 * method; changing format or display name replaces the file.
105 *
106 * ### Creating an Import Data File
107 * **Example:** RVTools CSV payload
108 * ```typescript
109 * const job = yield* GCP.MigrationCenter.ImportJob("Rvtools", {
110 * assetSource: source.name,
111 * });
112 * const file = yield* GCP.MigrationCenter.ImportJobsImportDataFile("Payload", {
113 * importJob: job.name,
114 * format: "IMPORT_JOB_FORMAT_RVTOOLS_CSV",
115 * displayName: "inventory",
116 * });
117 * ```
118 *
119 * @resource
120 * @category MigrationCenter
121 */
122export const ImportJobsImportDataFile = Resource<ImportJobsImportDataFile>(
123 "GCP.MigrationCenter.ImportJobsImportDataFile",
124);
125
126const DEFAULT_FORMAT: ImportDataFileFormat = "IMPORT_JOB_FORMAT_RVTOOLS_CSV";
127
128const jobNameOf = (value: string, project: string, location: string) =>
129 expandParent(value, project, location, "importJobs");
130
131const resourceName = (importJob: string, importDataFileId: string) =>
132 `${importJob}/importDataFiles/${importDataFileId}`;
133
134const toAttrs = (item: mc.ImportDataFile, project: string, region: string) => {
135 const name = item.name ?? "";
136 const parsed = parseName(name, "importDataFiles", region);
137 const ownership = parseOwnership(item.displayName);
138 return {
139 name,
140 importDataFileId: parsed.id,
141 importJob: parsed.parent,
142 project: parsed.project || project,
143 location: parsed.location,
144 format: item.format,
145 displayName: ownership.text,
146 state: item.state,
147 signedUri: item.uploadFileInfo?.signedUri,
148 uriExpirationTime: item.uploadFileInfo?.uriExpirationTime,
149 createTime: item.createTime,
150 };
151};
152
153const getByName = (name: string) =>
154 name.length === 0
155 ? Effect.succeed(undefined)
156 : mc
157 .getProjectsLocationsImportJobsImportDataFiles({ name })
158 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
159
160const listFiles = (parent: string) =>
161 mc.listProjectsLocationsImportJobsImportDataFiles
162 .pages({ parent, pageSize: 1000 })
163 .pipe(
164 Stream.flatMap((page) => Stream.fromIterable(page.importDataFiles ?? [])),
165 Stream.runCollect,
166 Effect.map((chunk) => Array.from(chunk)),
167 Effect.catchTag("NotFound", () =>
168 Effect.succeed([] as mc.ImportDataFile[]),
169 ),
170 );
171
172const listJobs = (project: string, region: string) =>
173 mc.listProjectsLocationsImportJobs
174 .pages({
175 parent: `projects/${project}/locations/-`,
176 pageSize: 1000,
177 view: "IMPORT_JOB_VIEW_BASIC",
178 })
179 .pipe(
180 Stream.flatMap((page) => Stream.fromIterable(page.importJobs ?? [])),
181 Stream.runCollect,
182 Effect.map((chunk) => Array.from(chunk)),
183 Effect.catchTag("NotFound", () =>
184 mc.listProjectsLocationsImportJobs
185 .pages({
186 parent: locationParent(project, region),
187 pageSize: 1000,
188 view: "IMPORT_JOB_VIEW_BASIC",
189 })
190 .pipe(
191 Stream.flatMap((page) =>
192 Stream.fromIterable(page.importJobs ?? []),
193 ),
194 Stream.runCollect,
195 Effect.map((chunk) => Array.from(chunk)),
196 Effect.catchTag("NotFound", () =>
197 Effect.succeed([] as mc.ImportJob[]),
198 ),
199 ),
200 ),
201 );
202
203const listOwned = (project: string, region: string) =>
204 Effect.gen(function* () {
205 const jobs = yield* listJobs(project, region);
206 const files: mc.ImportDataFile[] = [];
207 for (const job of jobs) {
208 if (job.name === undefined) continue;
209 const nested = yield* listFiles(job.name);
210 for (const file of nested) {
211 if (hasOwnershipMarker(file.displayName)) files.push(file);
212 }
213 }
214 return files;
215 });
216
217export const ImportJobsImportDataFileProvider = () =>
218 Provider.succeed(ImportJobsImportDataFile, {
219 stables: [
220 "name",
221 "importDataFileId",
222 "importJob",
223 "project",
224 "location",
225 "createTime",
226 ],
227
228 diff: Effect.fn(function* ({ news, olds, output }) {
229 if (!isResolved(news)) return undefined;
230 const env = yield* GcpEnvironment.current;
231 const previousFormat = olds?.format ?? output?.format ?? DEFAULT_FORMAT;
232 const nextFormat = news.format ?? previousFormat;
233 const previousJob = olds?.importJob ?? output?.importJob;
234 const nextJob = news.importJob;
235 return replaceOnIdentity({
236 previousId: olds?.importDataFileId ?? output?.importDataFileId,
237 nextId:
238 news.importDataFileId ??
239 olds?.importDataFileId ??
240 output?.importDataFileId,
241 previousLocation: normalizeLocation(
242 olds?.location ?? output?.location,
243 env.region,
244 ),
245 nextLocation: normalizeLocation(
246 news.location ?? olds?.location ?? output?.location,
247 env.region,
248 ),
249 previousParent: previousJob,
250 nextParent: nextJob,
251 extra: previousFormat !== nextFormat,
252 });
253 }),
254
255 read: Effect.fn(function* ({ id, olds, output }) {
256 const env = yield* GcpEnvironment.current;
257 const location = normalizeLocation(
258 olds?.location ?? output?.location,
259 env.region,
260 );
261 const importJob = jobNameOf(
262 olds?.importJob ?? output?.importJob ?? "",
263 env.project,
264 location,
265 );
266 const importDataFileId = yield* toPhysicalId(
267 id,
268 olds?.importDataFileId,
269 output?.importDataFileId,
270 "importdata",
271 );
272 const name =
273 output?.name ??
274 (importJob.length > 0 ? resourceName(importJob, importDataFileId) : "");
275 const existing = yield* getByName(name);
276 if (existing === undefined) return undefined;
277 const attrs = toAttrs(existing, env.project, env.region);
278 return (yield* ownedByAlchemy(id, existing.displayName))
279 ? attrs
280 : Unowned(attrs);
281 }),
282
283 list: () =>
284 Effect.gen(function* () {
285 const env = yield* GcpEnvironment.current;
286 const items = yield* listOwned(env.project, env.region);
287 return items.map((item) => toAttrs(item, env.project, env.region));
288 }),
289
290 reconcile: Effect.fn(function* ({ id, news, output }) {
291 const env = yield* GcpEnvironment.current;
292 const location = normalizeLocation(
293 news.location ?? output?.location,
294 env.region,
295 );
296 const importJob = jobNameOf(news.importJob, env.project, location);
297 const importDataFileId = yield* toPhysicalId(
298 id,
299 news.importDataFileId,
300 output?.importDataFileId,
301 "importdata",
302 );
303 const name = resourceName(importJob, importDataFileId);
304 const ownership = yield* createInternalLabels(id);
305 const displayName = encodeOwnershipLine(ownership, news.displayName);
306 const format = news.format ?? DEFAULT_FORMAT;
307
308 let current = yield* getByName(output?.name ?? name);
309
310 if (current === undefined) {
311 const created = yield* mc
312 .createProjectsLocationsImportJobsImportDataFiles({
313 parent: importJob,
314 importDataFileId,
315 body: {
316 displayName,
317 format,
318 },
319 })
320 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
321 if (created !== undefined) {
322 yield* waitForOperation(created);
323 }
324 current = yield* waitUntilExists(getByName(name), name);
325 }
326
327 if (current === undefined) {
328 return yield* new ResourceNotResolved({ name });
329 }
330
331 return toAttrs(current, env.project, env.region);
332 }),
333
334 delete: Effect.fn(function* ({ output }) {
335 const operation = yield* mc
336 .deleteProjectsLocationsImportJobsImportDataFiles({
337 name: output.name,
338 })
339 .pipe(
340 Effect.retry({
341 while: (error) => error._tag === "Conflict",
342 times: 8,
343 schedule: Schedule.spaced("2 seconds"),
344 }),
345 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
346 );
347 if (operation !== undefined) {
348 yield* waitForOperation(operation, { notFoundOk: true });
349 }
350 yield* waitUntilGone(getByName(output.name), output.name);
351 }),
352 });