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