1import * as vm from "@distilled.cloud/gcp/vmmigration_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 encodeOwnershipLine,
14 fingerprint,
15 forEachSource,
16 hasOwnershipMarker,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOnIdentity,
22 ResourceNotResolved,
23 sourceOf,
24 toPhysicalId,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type UtilizationReportTimeFrame =
31 | vm.UtilizationReportTimeFrameEnum
32 | (string & {});
33export type UtilizationReportState =
34 | vm.UtilizationReportStateEnum
35 | (string & {});
36export type VmUtilizationInfo = vm.VmUtilizationInfo;
37
38export type SourcesUtilizationReportProps = {
39 /**
40 * Parent source. Full name
41 * `projects/{project}/locations/{location}/sources/{source}` or the
42 * source id (combined with `location`). Immutable — changing it
43 * replaces the report.
44 */
45 source: string;
46 /**
47 * Region used when `source` is a bare id.
48 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
49 */
50 location?: string;
51 /**
52 * Utilization report id. If omitted, a unique RFC1035 name is
53 * generated. Immutable — changing it replaces the report.
54 */
55 utilizationReportId?: string;
56 /**
57 * User-friendly display name. Reports have no labels field, so
58 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
59 * from attributes.
60 */
61 displayName?: string;
62 /**
63 * Time frame of the report.
64 * @default "WEEK"
65 */
66 timeFrame?: UtilizationReportTimeFrame;
67 /**
68 * VMs to include. Only `vmId` is honored on create; other fields are
69 * filled by the service.
70 */
71 vms?: VmUtilizationInfo[];
72};
73
74export type SourcesUtilizationReport = Resource<
75 "GCP.VMMigration.SourcesUtilizationReport",
76 SourcesUtilizationReportProps,
77 {
78 /** Full resource name. */
79 name: string;
80 /** Utilization report id (last path segment). */
81 utilizationReportId: string;
82 /** Parent source resource name. */
83 source: string;
84 /** Project id. */
85 project: string;
86 /** Location id. */
87 location: string;
88 /** Display name with the Alchemy ownership prefix stripped. */
89 displayName: string | undefined;
90 /** Time frame of the report. */
91 timeFrame: string | undefined;
92 /** VMs included in the report. */
93 vms: VmUtilizationInfo[] | undefined;
94 /** Number of VMs included. */
95 vmCount: number | undefined;
96 /** Report state. */
97 state: string | undefined;
98 /** End of the sampled time frame. */
99 frameEndTime: string | undefined;
100 /** RFC3339 creation timestamp. */
101 createTime: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * A VM Migration utilization report for selected VMs in a source
109 * environment (CPU, memory, disk, network).
110 *
111 * Reports have no labels field — Alchemy stamps ownership into the
112 * display name so `list` / nuke can find them. The API has no patch
113 * method; changing time frame, VMs, or display name replaces the
114 * report.
115 *
116 * ### Creating a Utilization Report
117 * **Example:** Weekly report for selected VMs
118 * ```typescript
119 * const report = yield* GCP.VMMigration.SourcesUtilizationReport("Week", {
120 * source: source.name,
121 * timeFrame: "WEEK",
122 * vms: [{ vmId: "vm-123" }, { vmId: "vm-456" }],
123 * });
124 * ```
125 *
126 * @resource
127 * @category VMMigration
128 */
129export const SourcesUtilizationReport = Resource<SourcesUtilizationReport>(
130 "GCP.VMMigration.SourcesUtilizationReport",
131);
132
133const DEFAULT_TIME_FRAME: UtilizationReportTimeFrame = "WEEK";
134
135const resourceName = (source: string, utilizationReportId: string) =>
136 `${source}/utilizationReports/${utilizationReportId}`;
137
138const toAttrs = (
139 report: vm.UtilizationReport,
140 project: string,
141 region: string,
142) => {
143 const name = report.name ?? "";
144 const parsed = parseName(name, "utilizationReports", region);
145 const ownership = parseOwnership(report.displayName);
146 return {
147 name,
148 utilizationReportId: parsed.id,
149 source: parsed.parent,
150 project: parsed.project || project,
151 location: parsed.location,
152 displayName: ownership.text,
153 timeFrame: report.timeFrame,
154 vms: report.vms,
155 vmCount: report.vmCount,
156 state: report.state,
157 frameEndTime: report.frameEndTime,
158 createTime: report.createTime,
159 };
160};
161
162const getByName = (name: string) =>
163 name.length === 0
164 ? Effect.succeed(undefined)
165 : vm
166 .getProjectsLocationsSourcesUtilizationReports({ name })
167 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
168
169const listChildren = (parent: string) =>
170 collectPages(
171 vm.listProjectsLocationsSourcesUtilizationReports.pages({
172 parent,
173 pageSize: 1000,
174 view: "BASIC",
175 }),
176 (page) => page.utilizationReports,
177 ).pipe(
178 Effect.catchTag("NotFound", () =>
179 Effect.succeed([] as vm.UtilizationReport[]),
180 ),
181 );
182
183export const SourcesUtilizationReportProvider = () =>
184 Provider.succeed(SourcesUtilizationReport, {
185 stables: [
186 "name",
187 "utilizationReportId",
188 "source",
189 "project",
190 "location",
191 "createTime",
192 ],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const env = yield* GcpEnvironment.current;
197 const previousSource = olds?.source ?? output?.source;
198 const previousFrame =
199 olds?.timeFrame ?? output?.timeFrame ?? DEFAULT_TIME_FRAME;
200 const nextFrame = news.timeFrame ?? previousFrame;
201 const extra =
202 previousFrame !== nextFrame ||
203 fingerprint(news.vms?.map((vmInfo) => vmInfo.vmId)) !==
204 fingerprint((olds?.vms ?? output?.vms)?.map((vmInfo) => vmInfo.vmId));
205 return replaceOnIdentity({
206 previousId: olds?.utilizationReportId ?? output?.utilizationReportId,
207 nextId:
208 news.utilizationReportId ??
209 olds?.utilizationReportId ??
210 output?.utilizationReportId,
211 previousLocation: normalizeLocation(
212 olds?.location ?? output?.location,
213 env.region,
214 ),
215 nextLocation: normalizeLocation(
216 news.location ?? olds?.location ?? output?.location,
217 env.region,
218 ),
219 previousParent: previousSource,
220 nextParent: news.source ?? previousSource,
221 extra,
222 });
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const env = yield* GcpEnvironment.current;
227 const location = normalizeLocation(
228 olds?.location ?? output?.location,
229 env.region,
230 );
231 const utilizationReportId = yield* toPhysicalId(
232 id,
233 olds?.utilizationReportId,
234 output?.utilizationReportId,
235 "utilreport",
236 );
237 const source =
238 olds?.source !== undefined
239 ? sourceOf(olds.source, env.project, location)
240 : (output?.source ?? "");
241 const name =
242 output?.name ??
243 (source.length > 0 ? resourceName(source, utilizationReportId) : "");
244 const existing = yield* getByName(name);
245 if (existing === undefined) return undefined;
246 const attrs = toAttrs(existing, env.project, env.region);
247 return (yield* ownedByAlchemy(id, existing.displayName))
248 ? attrs
249 : Unowned(attrs);
250 }),
251
252 list: () =>
253 Effect.gen(function* () {
254 const env = yield* GcpEnvironment.current;
255 const items = yield* forEachSource(
256 env.project,
257 listChildren,
258 env.region,
259 );
260 return items
261 .filter((item) => hasOwnershipMarker(item.displayName))
262 .map((item) => toAttrs(item, env.project, env.region));
263 }),
264
265 reconcile: Effect.fn(function* ({ id, news, output }) {
266 const env = yield* GcpEnvironment.current;
267 const location = normalizeLocation(
268 news.location ?? output?.location,
269 env.region,
270 );
271 const source = sourceOf(news.source, env.project, location);
272 const utilizationReportId = yield* toPhysicalId(
273 id,
274 news.utilizationReportId,
275 output?.utilizationReportId,
276 "utilreport",
277 );
278 const name = resourceName(source, utilizationReportId);
279 const ownership = yield* createInternalLabels(id);
280 const displayName = encodeOwnershipLine(
281 ownership,
282 news.displayName ?? utilizationReportId,
283 );
284 const timeFrame = news.timeFrame ?? DEFAULT_TIME_FRAME;
285
286 let current = yield* getByName(output?.name ?? name);
287
288 if (current === undefined) {
289 const created = yield* vm
290 .createProjectsLocationsSourcesUtilizationReports({
291 parent: source,
292 utilizationReportId,
293 body: {
294 displayName,
295 timeFrame,
296 vms: news.vms,
297 },
298 })
299 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
300 if (created !== undefined) {
301 // ALREADY_EXISTS (6): a concurrent create won the race.
302 yield* waitForOperation(created).pipe(
303 Effect.catchTag("GCP.OperationFailed", (error) =>
304 error.code === 6 ? Effect.void : Effect.fail(error),
305 ),
306 );
307 }
308 current = yield* waitUntilExists(getByName(name), name);
309 }
310
311 if (current === undefined) {
312 return yield* new ResourceNotResolved({ name });
313 }
314
315 return toAttrs(current, env.project, env.region);
316 }),
317
318 delete: Effect.fn(function* ({ output }) {
319 const operation = yield* vm
320 .deleteProjectsLocationsSourcesUtilizationReports({
321 name: output.name,
322 })
323 .pipe(
324 Effect.retry({
325 while: (error) => error._tag === "Conflict",
326 times: 8,
327 schedule: Schedule.spaced("2 seconds"),
328 }),
329 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
330 );
331 if (operation !== undefined) {
332 // NOT_FOUND (5): already gone.
333 yield* waitForOperation(operation).pipe(
334 Effect.catchTag("GCP.OperationFailed", (error) =>
335 error.code === 5 ? Effect.void : Effect.fail(error),
336 ),
337 );
338 }
339 yield* waitUntilGone(getByName(output.name), output.name);
340 }),
341 });