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