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 forEachSource,
15 hasOwnershipMarker,
16 normalizeLocation,
17 ownedByAlchemy,
18 parseName,
19 parseOwnership,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 sourceOf,
23 toPhysicalId,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type DatacenterConnectorState =
30 | vm.DatacenterConnectorStateEnum
31 | (string & {});
32
33export type SourcesDatacenterConnectorProps = {
34 /**
35 * Parent source. Full name
36 * `projects/{project}/locations/{location}/sources/{source}` or the
37 * source id (combined with `location`). Immutable — changing it
38 * replaces the connector.
39 */
40 source: string;
41 /**
42 * Region used when `source` is a bare id.
43 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
44 */
45 location?: string;
46 /**
47 * Datacenter connector id. If omitted, a unique RFC1035 name is
48 * generated. Immutable — changing it replaces the connector.
49 */
50 datacenterConnectorId?: string;
51 /**
52 * Unique registration key supplied by the OVA connector. Immutable —
53 * changing it replaces the connector. Defaults to the connector id.
54 */
55 registrationId?: string;
56 /**
57 * Appliance version. Connectors have no labels or description field,
58 * so Alchemy ownership is stored in a `[alchemy …]` prefix and
59 * stripped from attributes.
60 */
61 version?: string;
62 /**
63 * Service account the connector uses when talking to Google Cloud.
64 */
65 serviceAccount?: string;
66};
67
68export type SourcesDatacenterConnector = Resource<
69 "GCP.VMMigration.SourcesDatacenterConnector",
70 SourcesDatacenterConnectorProps,
71 {
72 /** Full resource name. */
73 name: string;
74 /** Datacenter connector id (last path segment). */
75 datacenterConnectorId: string;
76 /** Parent source resource name. */
77 source: string;
78 /** Project id. */
79 project: string;
80 /** Location id. */
81 location: string;
82 /** Registration key. */
83 registrationId: string | undefined;
84 /** Appliance version with the Alchemy ownership prefix stripped. */
85 version: string | undefined;
86 /** Service account used by the connector. */
87 serviceAccount: string | undefined;
88 /** Communication bucket. */
89 bucket: string | undefined;
90 /** Health 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 VM Migration datacenter connector registered against a VMware
103 * source. The on-prem OVA appliance uses the registration id to bind to
104 * this resource.
105 *
106 * Connectors have no labels or description — Alchemy stamps ownership
107 * into `version` so `list` / nuke can find them. The API has no patch
108 * method; changing identity or registration replaces the connector.
109 *
110 * ### Creating a Datacenter Connector
111 * **Example:** Register against a source
112 * ```typescript
113 * const source = yield* GCP.VMMigration.Source("Vcenter", {
114 * vmware: { vcenterIp: "10.0.0.4", username: "admin" },
115 * });
116 * const connector = yield* GCP.VMMigration.SourcesDatacenterConnector(
117 * "Appliance",
118 * {
119 * source: source.name,
120 * version: "1.0.0",
121 * },
122 * );
123 * ```
124 *
125 * @resource
126 * @category VMMigration
127 */
128export const SourcesDatacenterConnector = Resource<SourcesDatacenterConnector>(
129 "GCP.VMMigration.SourcesDatacenterConnector",
130);
131
132const resourceName = (source: string, datacenterConnectorId: string) =>
133 `${source}/datacenterConnectors/${datacenterConnectorId}`;
134
135const toAttrs = (
136 connector: vm.DatacenterConnector,
137 project: string,
138 region: string,
139) => {
140 const name = connector.name ?? "";
141 const parsed = parseName(name, "datacenterConnectors", region);
142 const ownership = parseOwnership(connector.version);
143 return {
144 name,
145 datacenterConnectorId: parsed.id,
146 source: parsed.parent,
147 project: parsed.project || project,
148 location: parsed.location,
149 registrationId: connector.registrationId,
150 version: ownership.text,
151 serviceAccount: connector.serviceAccount,
152 bucket: connector.bucket,
153 state: connector.state,
154 createTime: connector.createTime,
155 updateTime: connector.updateTime,
156 };
157};
158
159const getByName = (name: string) =>
160 name.length === 0
161 ? Effect.succeed(undefined)
162 : vm
163 .getProjectsLocationsSourcesDatacenterConnectors({ name })
164 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
165
166const listChildren = (parent: string) =>
167 collectPages(
168 vm.listProjectsLocationsSourcesDatacenterConnectors.pages({
169 parent,
170 pageSize: 1000,
171 }),
172 (page) => page.datacenterConnectors,
173 ).pipe(
174 Effect.catchTag("NotFound", () =>
175 Effect.succeed([] as vm.DatacenterConnector[]),
176 ),
177 );
178
179export const SourcesDatacenterConnectorProvider = () =>
180 Provider.succeed(SourcesDatacenterConnector, {
181 stables: [
182 "name",
183 "datacenterConnectorId",
184 "source",
185 "project",
186 "location",
187 "createTime",
188 ],
189
190 diff: Effect.fn(function* ({ news, olds, output }) {
191 if (!isResolved(news)) return undefined;
192 const env = yield* GcpEnvironment.current;
193 const previousSource = olds?.source ?? output?.source;
194 const extra =
195 (news.registrationId !== undefined &&
196 (olds?.registrationId ?? output?.registrationId) !== undefined &&
197 news.registrationId !==
198 (olds?.registrationId ?? output?.registrationId)) ||
199 (news.serviceAccount !== undefined &&
200 (olds?.serviceAccount ?? output?.serviceAccount) !== undefined &&
201 news.serviceAccount !==
202 (olds?.serviceAccount ?? output?.serviceAccount));
203 return replaceOnIdentity({
204 previousId:
205 olds?.datacenterConnectorId ?? output?.datacenterConnectorId,
206 nextId:
207 news.datacenterConnectorId ??
208 olds?.datacenterConnectorId ??
209 output?.datacenterConnectorId,
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 previousParent: previousSource,
219 nextParent: news.source ?? previousSource,
220 extra,
221 });
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const env = yield* GcpEnvironment.current;
226 const location = normalizeLocation(
227 olds?.location ?? output?.location,
228 env.region,
229 );
230 const datacenterConnectorId = yield* toPhysicalId(
231 id,
232 olds?.datacenterConnectorId,
233 output?.datacenterConnectorId,
234 "connector",
235 );
236 const source =
237 olds?.source !== undefined
238 ? sourceOf(olds.source, env.project, location)
239 : (output?.source ?? "");
240 const name =
241 output?.name ??
242 (source.length > 0 ? resourceName(source, datacenterConnectorId) : "");
243 const existing = yield* getByName(name);
244 if (existing === undefined) return undefined;
245 const attrs = toAttrs(existing, env.project, env.region);
246 return (yield* ownedByAlchemy(id, existing.version))
247 ? attrs
248 : Unowned(attrs);
249 }),
250
251 list: () =>
252 Effect.gen(function* () {
253 const env = yield* GcpEnvironment.current;
254 const items = yield* forEachSource(
255 env.project,
256 listChildren,
257 env.region,
258 );
259 return items
260 .filter((item) => hasOwnershipMarker(item.version))
261 .map((item) => toAttrs(item, env.project, env.region));
262 }),
263
264 reconcile: Effect.fn(function* ({ id, news, output }) {
265 const env = yield* GcpEnvironment.current;
266 const location = normalizeLocation(
267 news.location ?? output?.location,
268 env.region,
269 );
270 const source = sourceOf(news.source, env.project, location);
271 const datacenterConnectorId = yield* toPhysicalId(
272 id,
273 news.datacenterConnectorId,
274 output?.datacenterConnectorId,
275 "connector",
276 );
277 const name = resourceName(source, datacenterConnectorId);
278 const ownership = yield* createInternalLabels(id);
279 const version = encodeOwnershipLine(ownership, news.version, 128);
280 const registrationId = news.registrationId ?? datacenterConnectorId;
281
282 let current = yield* getByName(output?.name ?? name);
283
284 if (current === undefined) {
285 const created = yield* vm
286 .createProjectsLocationsSourcesDatacenterConnectors({
287 parent: source,
288 datacenterConnectorId,
289 body: {
290 registrationId,
291 version,
292 serviceAccount: news.serviceAccount,
293 },
294 })
295 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
296 if (created !== undefined) {
297 // ALREADY_EXISTS (6): a concurrent create won the race.
298 yield* waitForOperation(created).pipe(
299 Effect.catchTag("GCP.OperationFailed", (error) =>
300 error.code === 6 ? Effect.void : Effect.fail(error),
301 ),
302 );
303 }
304 current = yield* waitUntilExists(getByName(name), name);
305 }
306
307 if (current === undefined) {
308 return yield* new ResourceNotResolved({ name });
309 }
310
311 return toAttrs(current, env.project, env.region);
312 }),
313
314 delete: Effect.fn(function* ({ output }) {
315 const operation = yield* vm
316 .deleteProjectsLocationsSourcesDatacenterConnectors({
317 name: output.name,
318 })
319 .pipe(
320 Effect.retry({
321 while: (error) => error._tag === "Conflict",
322 times: 8,
323 schedule: Schedule.spaced("2 seconds"),
324 }),
325 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
326 );
327 if (operation !== undefined) {
328 // NOT_FOUND (5): already gone.
329 yield* waitForOperation(operation).pipe(
330 Effect.catchTag("GCP.OperationFailed", (error) =>
331 error.code === 5 ? Effect.void : Effect.fail(error),
332 ),
333 );
334 }
335 yield* waitUntilGone(getByName(output.name), output.name);
336 }),
337 });