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 expandName,
17 hasOwnershipMarker,
18 listAcrossLocations,
19 locationFromName,
20 normalizeLocation,
21 parseName,
22 replaceOnIdentity,
23 rfc1035,
24 toPhysicalId,
25 waitForOperation,
26 waitUntilGone,
27 waitUntilPresent,
28} from "./internal.ts";
29
30const COLLECTION = "loggingServers";
31const PARENT_COLLECTION = "privateClouds";
32const DEFAULT_PORT = 514;
33const DEFAULT_PROTOCOL = "UDP";
34const DEFAULT_SOURCE_TYPE = "ESXI";
35
36export type LoggingServerProtocol =
37 | vmwareengine.LoggingServerProtocolEnum
38 | (string & {});
39
40export type LoggingServerSourceType =
41 | vmwareengine.LoggingServerSourceTypeEnum
42 | (string & {});
43
44export type PrivateCloudsLoggingServerProps = {
45 /**
46 * Parent PrivateCloud resource name
47 * (`projects/{project}/locations/{location}/privateClouds/{privateCloud}`)
48 * or the cloud id. Immutable — changing it replaces the logging server.
49 */
50 privateCloud: string;
51 /**
52 * Logging server id (the `{loggingServer}` segment of
53 * `.../privateClouds/{privateCloud}/loggingServers/{loggingServer}`).
54 * If omitted, a unique RFC1035 name is generated. Immutable.
55 */
56 loggingServerId?: string;
57 /**
58 * Location of the parent private cloud. Inferred from `privateCloud`
59 * when that value is a full resource name. Immutable.
60 * @default "us-central1-a"
61 */
62 location?: string;
63 /**
64 * FQDN or IP address of the logging server.
65 */
66 hostname: string;
67 /**
68 * Port the logging server listens on.
69 * @default 514
70 */
71 port?: number;
72 /**
73 * Protocol used to send logs (`UDP`, `TCP`, `TLS`, `SSL`, `RELP`).
74 * @default "UDP"
75 */
76 protocol?: LoggingServerProtocol;
77 /**
78 * Component that produces logs (`ESXI` or `VCSA`).
79 * @default "ESXI"
80 */
81 sourceType?: LoggingServerSourceType;
82};
83
84export type PrivateCloudsLoggingServer = Resource<
85 "GCP.VMwareEngine.PrivateCloudsLoggingServer",
86 PrivateCloudsLoggingServerProps,
87 {
88 /** Full resource name. */
89 name: string;
90 /** Logging server id (last path segment). */
91 loggingServerId: string;
92 /** Parent PrivateCloud resource name. */
93 privateCloud: string;
94 /** Project id. */
95 project: string;
96 /** Location id. */
97 location: string;
98 /** FQDN or IP address. */
99 hostname: string | undefined;
100 /** Listen port. */
101 port: number | undefined;
102 /** Log transport protocol. */
103 protocol: string | undefined;
104 /** Log source type. */
105 sourceType: string | undefined;
106 /** System-generated unique identifier. */
107 uid: string | undefined;
108 /** RFC3339 creation timestamp. */
109 createTime: string | undefined;
110 /** RFC3339 last-update timestamp. */
111 updateTime: string | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A syslog destination that receives vCenter or ESXi logs from a VMware
119 * Engine private cloud.
120 *
121 * Logging servers have no labels or description field, so Alchemy treats
122 * servers whose parent private cloud carries the `[alchemy …]` ownership
123 * marker as owned for `list` / nuke. Changing the parent cloud, server
124 * id, or location replaces the server. Hostname, port, protocol, and
125 * source type update in place.
126 *
127 * ### Creating a PrivateCloudsLoggingServer
128 * **Example:** Forward ESXi logs over UDP
129 * ```typescript
130 * const syslog = yield* GCP.VMwareEngine.PrivateCloudsLoggingServer(
131 * "Esxi",
132 * {
133 * privateCloud: cloud.name,
134 * hostname: "logs.example.com",
135 * port: 514,
136 * protocol: "UDP",
137 * sourceType: "ESXI",
138 * },
139 * );
140 * ```
141 *
142 * @resource
143 * @category VMwareEngine
144 */
145export const PrivateCloudsLoggingServer = Resource<PrivateCloudsLoggingServer>(
146 "GCP.VMwareEngine.PrivateCloudsLoggingServer",
147);
148
149const parentCloudName = (
150 project: string,
151 location: string,
152 privateCloud: string,
153) => expandName(privateCloud, project, location, PARENT_COLLECTION);
154
155const resourceNameOf = (parent: string, loggingServerId: string) =>
156 `${parent}/${COLLECTION}/${loggingServerId}`;
157
158const toAttrs = (item: vmwareengine.LoggingServer, project: string) => {
159 const name = item.name ?? "";
160 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
161 return {
162 name,
163 loggingServerId: parsed.id,
164 privateCloud: parsed.parent,
165 project: parsed.project || project,
166 location: parsed.location,
167 hostname: item.hostname,
168 port: item.port,
169 protocol: item.protocol,
170 sourceType: item.sourceType,
171 uid: item.uid,
172 createTime: item.createTime,
173 updateTime: item.updateTime,
174 };
175};
176
177const getByName = (name: string) =>
178 vmwareengine
179 .getProjectsLocationsPrivateCloudsLoggingServers({ name })
180 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
181
182const getParentCloud = (name: string) =>
183 vmwareengine
184 .getProjectsLocationsPrivateClouds({ name })
185 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
186
187export const PrivateCloudsLoggingServerProvider = () =>
188 Provider.succeed(PrivateCloudsLoggingServer, {
189 stables: [
190 "name",
191 "loggingServerId",
192 "privateCloud",
193 "project",
194 "location",
195 "uid",
196 "createTime",
197 ],
198
199 diff: Effect.fn(function* ({ news, olds, output }) {
200 if (!isResolved(news)) return undefined;
201 const previousLocation = normalizeLocation(
202 olds?.location ?? output?.location,
203 DEFAULT_ZONE,
204 );
205 return replaceOnIdentity({
206 previousId: olds?.loggingServerId ?? output?.loggingServerId,
207 nextId: news.loggingServerId
208 ? rfc1035(news.loggingServerId, "loggingserver")
209 : (olds?.loggingServerId ?? output?.loggingServerId),
210 previousLocation,
211 nextLocation: normalizeLocation(
212 news.location ??
213 locationFromName(news.privateCloud, previousLocation),
214 DEFAULT_ZONE,
215 ),
216 previousParent: olds?.privateCloud ?? output?.privateCloud,
217 nextParent: news.privateCloud,
218 });
219 }),
220
221 read: Effect.fn(function* ({ id, olds, output }) {
222 const env = yield* GcpEnvironment.current;
223 const location = normalizeLocation(
224 olds?.location ??
225 output?.location ??
226 (olds?.privateCloud
227 ? locationFromName(olds.privateCloud, DEFAULT_ZONE)
228 : undefined),
229 DEFAULT_ZONE,
230 );
231 const parent = parentCloudName(
232 env.project,
233 location,
234 olds?.privateCloud ?? output?.privateCloud ?? "",
235 );
236 const loggingServerId = yield* toPhysicalId(
237 id,
238 olds?.loggingServerId,
239 output?.loggingServerId,
240 "loggingserver",
241 );
242 const name = output?.name ?? resourceNameOf(parent, loggingServerId);
243 const existing = yield* getByName(name);
244 if (existing === undefined) return undefined;
245 const attrs = toAttrs(existing, env.project);
246 if (output?.name === (existing.name ?? name)) return attrs;
247 const parentCloud = yield* getParentCloud(attrs.privateCloud);
248 return hasOwnershipMarker(parentCloud?.description)
249 ? attrs
250 : Unowned(attrs);
251 }),
252
253 list: () =>
254 Effect.gen(function* () {
255 const env = yield* GcpEnvironment.current;
256 const clouds = yield* listAcrossLocations(
257 env.project,
258 env.region,
259 (parent) =>
260 collectPages(
261 vmwareengine.listProjectsLocationsPrivateClouds.pages({
262 parent,
263 pageSize: 1000,
264 }),
265 (page) => page.privateClouds,
266 ),
267 );
268 const ownedClouds = clouds.filter((cloud) =>
269 hasOwnershipMarker(cloud.description),
270 );
271 const nested = yield* Effect.forEach(
272 ownedClouds.filter((cloud) => (cloud.name ?? "").length > 0),
273 (cloud) =>
274 collectPages(
275 vmwareengine.listProjectsLocationsPrivateCloudsLoggingServers.pages(
276 {
277 parent: cloud.name ?? "",
278 pageSize: 1000,
279 },
280 ),
281 (page) => page.loggingServers,
282 ),
283 { concurrency: 4 },
284 );
285 return nested.flat().map((item) => toAttrs(item, env.project));
286 }),
287
288 reconcile: Effect.fn(function* ({ id, news, output }) {
289 const env = yield* GcpEnvironment.current;
290 const location = normalizeLocation(
291 news.location ??
292 output?.location ??
293 locationFromName(news.privateCloud, DEFAULT_ZONE),
294 DEFAULT_ZONE,
295 );
296 const parent = parentCloudName(env.project, location, news.privateCloud);
297 const loggingServerId = yield* toPhysicalId(
298 id,
299 news.loggingServerId,
300 output?.loggingServerId,
301 "loggingserver",
302 );
303 const name = resourceNameOf(parent, loggingServerId);
304 yield* createInternalLabels(id);
305 const port = news.port ?? DEFAULT_PORT;
306 const protocol = news.protocol ?? DEFAULT_PROTOCOL;
307 const sourceType = news.sourceType ?? DEFAULT_SOURCE_TYPE;
308
309 let current = yield* getByName(output?.name ?? name);
310
311 if (current === undefined) {
312 const created = yield* vmwareengine
313 .createProjectsLocationsPrivateCloudsLoggingServers({
314 parent,
315 loggingServerId,
316 body: {
317 hostname: news.hostname,
318 port,
319 protocol,
320 sourceType,
321 },
322 })
323 .pipe(
324 Effect.retry({
325 while: (error) => error._tag === "Conflict",
326 times: 5,
327 schedule: Schedule.spaced("2 seconds"),
328 }),
329 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
330 );
331 if (created !== undefined) {
332 yield* waitForOperation(created);
333 }
334 current = yield* waitUntilPresent(getByName(name), name);
335 }
336
337 if (current === undefined) {
338 return yield* new VmwareengineNotResolved({ name });
339 }
340
341 const hostnameChanged = (current.hostname ?? "") !== news.hostname;
342 const portChanged = (current.port ?? 0) !== port;
343 const protocolChanged = (current.protocol ?? "") !== protocol;
344 const sourceChanged = (current.sourceType ?? "") !== sourceType;
345 const updateMask = changedFields([
346 ["hostname", hostnameChanged],
347 ["port", portChanged],
348 ["protocol", protocolChanged],
349 ["sourceType", sourceChanged],
350 ]);
351
352 if (updateMask.length > 0) {
353 const operation =
354 yield* vmwareengine.patchProjectsLocationsPrivateCloudsLoggingServers(
355 {
356 name: current.name ?? name,
357 updateMask: updateMask.join(","),
358 body: {
359 name: current.name ?? name,
360 hostname: news.hostname,
361 port,
362 protocol,
363 sourceType,
364 },
365 },
366 );
367 yield* waitForOperation(operation);
368 current = yield* waitUntilPresent(
369 getByName(current.name ?? name),
370 current.name ?? name,
371 );
372 }
373
374 return toAttrs(current, env.project);
375 }),
376
377 delete: Effect.fn(function* ({ output }) {
378 const operation = yield* vmwareengine
379 .deleteProjectsLocationsPrivateCloudsLoggingServers({
380 name: output.name,
381 })
382 .pipe(
383 Effect.retry({
384 while: (error) => error._tag === "Conflict",
385 times: 8,
386 schedule: Schedule.spaced("2 seconds"),
387 }),
388 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
389 );
390 if (operation !== undefined) {
391 yield* waitForOperation(operation, { notFoundOk: true });
392 }
393 yield* waitUntilGone(getByName(output.name), output.name);
394 }),
395 });