1import * as vmwareengine from "@distilled.cloud/gcp/vmwareengine_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 type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_ZONE,
12 VmwareengineNotResolved,
13 changedFields,
14 collectPages,
15 createInternalLabels,
16 encodeOwnership,
17 expandName,
18 hasAlchemyLabels,
19 hasOwnershipMarker,
20 listAcrossLocations,
21 locationFromName,
22 normalizeLocation,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 rfc1035,
27 toPhysicalId,
28 waitForOperation,
29 waitUntilGone,
30 waitUntilPresent,
31 waitUntilReady,
32} from "./internal.ts";
33
34const COLLECTION = "externalAddresses";
35const PARENT_COLLECTION = "privateClouds";
36
37export type PrivateCloudsExternalAddressProps = {
38 /**
39 * Parent PrivateCloud resource name
40 * (`projects/{project}/locations/{location}/privateClouds/{privateCloud}`)
41 * or the cloud id. Immutable — changing it replaces the address.
42 */
43 privateCloud: string;
44 /**
45 * External address id (the `{externalAddress}` segment of
46 * `.../privateClouds/{privateCloud}/externalAddresses/{externalAddress}`).
47 * If omitted, a unique RFC1035 name is generated. Immutable.
48 */
49 externalAddressId?: string;
50 /**
51 * Location of the parent private cloud. Inferred from `privateCloud`
52 * when that value is a full resource name. Immutable.
53 * @default "us-central1-a"
54 */
55 location?: string;
56 /**
57 * Internal IP address of the workload VM this mapping points at.
58 */
59 internalIp: string;
60 /**
61 * Human-readable description. External addresses have no labels field,
62 * so Alchemy stamps ownership into a `[alchemy …]` prefix and strips
63 * it from attributes.
64 */
65 description?: string;
66};
67
68export type PrivateCloudsExternalAddress = Resource<
69 "GCP.VMwareEngine.PrivateCloudsExternalAddress",
70 PrivateCloudsExternalAddressProps,
71 {
72 /** Full resource name. */
73 name: string;
74 /** External address id (last path segment). */
75 externalAddressId: string;
76 /** Parent PrivateCloud resource name. */
77 privateCloud: string;
78 /** Project id. */
79 project: string;
80 /** Location id. */
81 location: string;
82 /** Internal IP of the workload VM. */
83 internalIp: string | undefined;
84 /** Allocated external IP. */
85 externalIp: string | undefined;
86 /** User description with the Alchemy ownership prefix stripped. */
87 description: string | undefined;
88 /** Server-reported state. */
89 state: string | undefined;
90 /** System-generated unique identifier. */
91 uid: 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 * An allocated external IP address mapped to a workload VM inside a
103 * VMware Engine private cloud. The corresponding network policy must have
104 * `externalIp` enabled.
105 *
106 * External addresses have no labels field, so Alchemy stamps ownership
107 * into the description for `list` / nuke. Changing the parent cloud,
108 * address id, or location replaces the address. Internal IP and
109 * description update in place.
110 *
111 * ### Creating a PrivateCloudsExternalAddress
112 * **Example:** Map a workload VM
113 * ```typescript
114 * const address = yield* GCP.VMwareEngine.PrivateCloudsExternalAddress(
115 * "Web",
116 * {
117 * privateCloud: cloud.name,
118 * internalIp: "192.168.1.10",
119 * description: "web vip",
120 * },
121 * );
122 * ```
123 *
124 * @resource
125 * @category VMwareEngine
126 */
127export const PrivateCloudsExternalAddress =
128 Resource<PrivateCloudsExternalAddress>(
129 "GCP.VMwareEngine.PrivateCloudsExternalAddress",
130 );
131
132const parentCloudName = (
133 project: string,
134 location: string,
135 privateCloud: string,
136) => expandName(privateCloud, project, location, PARENT_COLLECTION);
137
138const resourceNameOf = (parent: string, externalAddressId: string) =>
139 `${parent}/${COLLECTION}/${externalAddressId}`;
140
141const toAttrs = (item: vmwareengine.ExternalAddress, project: string) => {
142 const name = item.name ?? "";
143 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
144 const ownership = parseOwnership(item.description);
145 return {
146 name,
147 externalAddressId: parsed.id,
148 privateCloud: parsed.parent,
149 project: parsed.project || project,
150 location: parsed.location,
151 internalIp: item.internalIp,
152 externalIp: item.externalIp,
153 description: ownership.text,
154 state: item.state,
155 uid: item.uid,
156 createTime: item.createTime,
157 updateTime: item.updateTime,
158 };
159};
160
161const getByName = (name: string) =>
162 vmwareengine
163 .getProjectsLocationsPrivateCloudsExternalAddresses({ name })
164 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
165
166export const PrivateCloudsExternalAddressProvider = () =>
167 Provider.succeed(PrivateCloudsExternalAddress, {
168 stables: [
169 "name",
170 "externalAddressId",
171 "privateCloud",
172 "project",
173 "location",
174 "uid",
175 "createTime",
176 ],
177
178 diff: Effect.fn(function* ({ news, olds, output }) {
179 if (!isResolved(news)) return undefined;
180 const previousLocation = normalizeLocation(
181 olds?.location ?? output?.location,
182 DEFAULT_ZONE,
183 );
184 return replaceOnIdentity({
185 previousId: olds?.externalAddressId ?? output?.externalAddressId,
186 nextId: news.externalAddressId
187 ? rfc1035(news.externalAddressId, "address")
188 : (olds?.externalAddressId ?? output?.externalAddressId),
189 previousLocation,
190 nextLocation: normalizeLocation(
191 news.location ??
192 locationFromName(news.privateCloud, previousLocation),
193 DEFAULT_ZONE,
194 ),
195 previousParent: olds?.privateCloud ?? output?.privateCloud,
196 nextParent: news.privateCloud,
197 });
198 }),
199
200 read: Effect.fn(function* ({ id, olds, output }) {
201 const env = yield* GcpEnvironment.current;
202 const location = normalizeLocation(
203 olds?.location ??
204 output?.location ??
205 (olds?.privateCloud
206 ? locationFromName(olds.privateCloud, DEFAULT_ZONE)
207 : undefined),
208 DEFAULT_ZONE,
209 );
210 const parent = parentCloudName(
211 env.project,
212 location,
213 olds?.privateCloud ?? output?.privateCloud ?? "",
214 );
215 const externalAddressId = yield* toPhysicalId(
216 id,
217 olds?.externalAddressId,
218 output?.externalAddressId,
219 "address",
220 );
221 const name = output?.name ?? resourceNameOf(parent, externalAddressId);
222 const existing = yield* getByName(name);
223 if (existing === undefined) return undefined;
224 const attrs = toAttrs(existing, env.project);
225 const { labels } = parseOwnership(existing.description);
226 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
227 }),
228
229 list: () =>
230 Effect.gen(function* () {
231 const env = yield* GcpEnvironment.current;
232 const clouds = yield* listAcrossLocations(
233 env.project,
234 env.region,
235 (parent) =>
236 collectPages(
237 vmwareengine.listProjectsLocationsPrivateClouds.pages({
238 parent,
239 pageSize: 1000,
240 }),
241 (page) => page.privateClouds,
242 ),
243 );
244 const nested = yield* Effect.forEach(
245 clouds.filter((cloud) => (cloud.name ?? "").length > 0),
246 (cloud) =>
247 collectPages(
248 vmwareengine.listProjectsLocationsPrivateCloudsExternalAddresses.pages(
249 {
250 parent: cloud.name ?? "",
251 pageSize: 1000,
252 },
253 ),
254 (page) => page.externalAddresses,
255 ),
256 { concurrency: 4 },
257 );
258 return nested
259 .flat()
260 .filter((item) => hasOwnershipMarker(item.description))
261 .map((item) => toAttrs(item, env.project));
262 }),
263
264 reconcile: Effect.fn(function* ({ id, news, output }) {
265 const env = yield* GcpEnvironment.current;
266 const location = normalizeLocation(
267 news.location ??
268 output?.location ??
269 locationFromName(news.privateCloud, DEFAULT_ZONE),
270 DEFAULT_ZONE,
271 );
272 const parent = parentCloudName(env.project, location, news.privateCloud);
273 const externalAddressId = yield* toPhysicalId(
274 id,
275 news.externalAddressId,
276 output?.externalAddressId,
277 "address",
278 );
279 const name = resourceNameOf(parent, externalAddressId);
280 const ownership = yield* createInternalLabels(id);
281 const desiredDescription = encodeOwnership(ownership, news.description);
282
283 let current = yield* getByName(output?.name ?? name);
284
285 if (current === undefined) {
286 const created = yield* vmwareengine
287 .createProjectsLocationsPrivateCloudsExternalAddresses({
288 parent,
289 externalAddressId,
290 body: {
291 internalIp: news.internalIp,
292 description: desiredDescription,
293 },
294 })
295 .pipe(
296 Effect.retry({
297 while: (error) => error._tag === "Conflict",
298 times: 5,
299 schedule: Schedule.spaced("2 seconds"),
300 }),
301 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
302 );
303 if (created !== undefined) {
304 yield* waitForOperation(created);
305 }
306 current = yield* waitUntilPresent(getByName(name), name);
307 }
308
309 if (current === undefined) {
310 return yield* new VmwareengineNotResolved({ name });
311 }
312
313 current = yield* waitUntilReady(
314 getByName(current.name ?? name),
315 current.name ?? name,
316 (item) => item.state,
317 );
318
319 const descriptionChanged =
320 (current.description ?? "") !== desiredDescription;
321 const ipChanged = (current.internalIp ?? "") !== news.internalIp;
322 const updateMask = changedFields([
323 ["description", descriptionChanged],
324 ["internalIp", ipChanged],
325 ]);
326
327 if (updateMask.length > 0) {
328 const operation =
329 yield* vmwareengine.patchProjectsLocationsPrivateCloudsExternalAddresses(
330 {
331 name: current.name ?? name,
332 updateMask: updateMask.join(","),
333 body: {
334 name: current.name ?? name,
335 description: desiredDescription,
336 internalIp: news.internalIp,
337 },
338 },
339 );
340 yield* waitForOperation(operation);
341 current = yield* waitUntilReady(
342 getByName(current.name ?? name),
343 current.name ?? name,
344 (item) => item.state,
345 );
346 }
347
348 return toAttrs(current, env.project);
349 }),
350
351 delete: Effect.fn(function* ({ output }) {
352 const operation = yield* vmwareengine
353 .deleteProjectsLocationsPrivateCloudsExternalAddresses({
354 name: output.name,
355 })
356 .pipe(
357 Effect.retry({
358 while: (error) => error._tag === "Conflict",
359 times: 8,
360 schedule: Schedule.spaced("2 seconds"),
361 }),
362 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
363 );
364 if (operation !== undefined) {
365 yield* waitForOperation(operation, { notFoundOk: true });
366 }
367 yield* waitUntilGone(getByName(output.name), output.name);
368 }),
369 });