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