1import * as vm from "@distilled.cloud/gcp/vmmigration_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 GLOBAL_LOCATION,
16 globalParent,
17 hasOwnershipMarker,
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 TargetProps = {
30 /**
31 * Target project id (the `{targetProject}` segment of
32 * `projects/{project}/locations/global/targetProjects/{targetProject}`).
33 * If omitted, a unique RFC1035 name is generated. Immutable — changing
34 * it replaces the target.
35 */
36 targetProjectId?: string;
37 /**
38 * Compute Engine project id (or number) this target points at.
39 * Defaults to the current Alchemy GCP project.
40 */
41 project?: string;
42 /**
43 * Free-text description. Target projects have no labels field, so
44 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
45 * from attributes.
46 */
47 description?: string;
48};
49
50export type Target = Resource<
51 "GCP.VMMigration.Target",
52 TargetProps,
53 {
54 /** Full resource name. */
55 name: string;
56 /** Target project id (last path segment). */
57 targetProjectId: string;
58 /** Host project id of the VM Migration resource. */
59 hostProject: string;
60 /** Compute Engine project this target points at. */
61 project: string;
62 /** Location id — always `global`. */
63 location: string;
64 /** User description with the Alchemy ownership prefix stripped. */
65 description: string | undefined;
66 /** RFC3339 creation timestamp. */
67 createTime: string | undefined;
68 /** RFC3339 last-update timestamp. */
69 updateTime: string | undefined;
70 },
71 never,
72 Providers
73>;
74
75/**
76 * A VM Migration target project: the Compute Engine project clones and
77 * cutovers land in.
78 *
79 * Target projects are global. They have no labels field — Alchemy stamps
80 * ownership into the description so `list` / nuke can find them. The
81 * resource id is immutable; `project` and `description` update in place.
82 *
83 * ### Creating a Target
84 * **Example:** Point at the current project
85 * ```typescript
86 * const target = yield* GCP.VMMigration.Target("Default", {
87 * description: "this project",
88 * });
89 * ```
90 *
91 * **Example:** Explicit Compute Engine project
92 * ```typescript
93 * const target = yield* GCP.VMMigration.Target("Prod", {
94 * project: "prod-compute",
95 * description: "production landing zone",
96 * });
97 * ```
98 *
99 * @resource
100 * @category VMMigration
101 */
102export const Target = Resource<Target>("GCP.VMMigration.Target");
103
104const resourceName = (hostProject: string, targetProjectId: string) =>
105 `${globalParent(hostProject)}/targetProjects/${targetProjectId}`;
106
107const toAttrs = (target: vm.TargetProject, hostProject: string) => {
108 const name = target.name ?? "";
109 const parsed = parseName(name, "targetProjects", "global");
110 const ownership = parseOwnership(target.description);
111 return {
112 name,
113 targetProjectId: parsed.id,
114 hostProject: parsed.project || hostProject,
115 project: target.project ?? parsed.project ?? hostProject,
116 location: GLOBAL_LOCATION,
117 description: ownership.text,
118 createTime: target.createTime,
119 updateTime: target.updateTime,
120 };
121};
122
123const getByName = (name: string) =>
124 name.length === 0
125 ? Effect.succeed(undefined)
126 : vm
127 .getProjectsLocationsTargetProjects({ name })
128 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
129
130const listOwned = (project: string) =>
131 vm.listProjectsLocationsTargetProjects
132 .pages({
133 parent: globalParent(project),
134 pageSize: 1000,
135 })
136 .pipe(
137 Stream.flatMap((page) => Stream.fromIterable(page.targetProjects ?? [])),
138 Stream.filter((item) => hasOwnershipMarker(item.description)),
139 Stream.runCollect,
140 Effect.map((chunk) => Array.from(chunk)),
141 Effect.catchTag("NotFound", () =>
142 Effect.succeed([] as vm.TargetProject[]),
143 ),
144 );
145
146export const TargetProvider = () =>
147 Provider.succeed(Target, {
148 stables: [
149 "name",
150 "targetProjectId",
151 "hostProject",
152 "location",
153 "createTime",
154 ],
155
156 diff: Effect.fn(function* ({ news, olds, output }) {
157 if (!isResolved(news)) return undefined;
158 return replaceOnIdentity({
159 previousId: olds?.targetProjectId ?? output?.targetProjectId,
160 nextId:
161 news?.targetProjectId ??
162 olds?.targetProjectId ??
163 output?.targetProjectId,
164 previousLocation: GLOBAL_LOCATION,
165 nextLocation: GLOBAL_LOCATION,
166 });
167 }),
168
169 read: Effect.fn(function* ({ id, olds, output }) {
170 const env = yield* GcpEnvironment.current;
171 const targetProjectId = yield* toPhysicalId(
172 id,
173 olds?.targetProjectId,
174 output?.targetProjectId,
175 "target",
176 );
177 const name = output?.name ?? resourceName(env.project, targetProjectId);
178 const existing = yield* getByName(name);
179 if (existing === undefined) return undefined;
180 const attrs = toAttrs(existing, env.project);
181 return (yield* ownedByAlchemy(id, existing.description))
182 ? attrs
183 : Unowned(attrs);
184 }),
185
186 list: () =>
187 Effect.gen(function* () {
188 const env = yield* GcpEnvironment.current;
189 const items = yield* listOwned(env.project);
190 return items.map((item) => toAttrs(item, env.project));
191 }),
192
193 reconcile: Effect.fn(function* ({ id, news, output }) {
194 const env = yield* GcpEnvironment.current;
195 const targetProjectId = yield* toPhysicalId(
196 id,
197 news?.targetProjectId,
198 output?.targetProjectId,
199 "target",
200 );
201 const name = resourceName(env.project, targetProjectId);
202 const ownership = yield* createInternalLabels(id);
203 const description = encodeOwnership(ownership, news?.description);
204 const project = news?.project ?? env.project;
205
206 let current = yield* getByName(output?.name ?? name);
207
208 if (current === undefined) {
209 const created = yield* vm
210 .createProjectsLocationsTargetProjects({
211 parent: globalParent(env.project),
212 targetProjectId,
213 body: {
214 project,
215 description,
216 },
217 })
218 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
219 if (created !== undefined) {
220 // ALREADY_EXISTS (6): a concurrent create won the race.
221 yield* waitForOperation(created).pipe(
222 Effect.catchTag("GCP.OperationFailed", (error) =>
223 error.code === 6 ? Effect.void : Effect.fail(error),
224 ),
225 );
226 }
227 current = yield* waitUntilExists(getByName(name), name);
228 }
229
230 if (current === undefined) {
231 return yield* new ResourceNotResolved({ name });
232 }
233
234 const descriptionChanged = (current.description ?? "") !== description;
235 const projectChanged = (current.project ?? "") !== project;
236 const mask = fieldMask([
237 descriptionChanged && "description",
238 projectChanged && "project",
239 ]);
240
241 if (mask.length > 0) {
242 const operation = yield* vm.patchProjectsLocationsTargetProjects({
243 name: current.name ?? name,
244 updateMask: mask,
245 body: {
246 name: current.name ?? name,
247 project,
248 description,
249 },
250 });
251 yield* waitForOperation(operation);
252 current = yield* waitUntilExists(
253 getByName(current.name ?? name),
254 current.name ?? name,
255 );
256 }
257
258 return toAttrs(current, env.project);
259 }),
260
261 delete: Effect.fn(function* ({ output }) {
262 const operation = yield* vm
263 .deleteProjectsLocationsTargetProjects({ name: output.name })
264 .pipe(
265 Effect.retry({
266 while: (error) => error._tag === "Conflict",
267 times: 8,
268 schedule: Schedule.spaced("2 seconds"),
269 }),
270 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
271 );
272 if (operation !== undefined) {
273 // NOT_FOUND (5): already gone.
274 yield* waitForOperation(operation).pipe(
275 Effect.catchTag("GCP.OperationFailed", (error) =>
276 error.code === 5 ? Effect.void : Effect.fail(error),
277 ),
278 );
279 }
280 yield* waitUntilGone(getByName(output.name), output.name);
281 }),
282 });