1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 NetworkConnectivityNotResolved,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 lastSegment,
23 parentOfName,
24 parseName,
25 rfc1035,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilGone,
30 waitUntilPresent,
31} from "./internal.ts";
32
33const COLLECTION = "destinations";
34
35export type DestinationEndpoint = {
36 /** ASN of the remote IP prefix. */
37 asn?: string;
38 /** Cloud service provider of the remote IP prefix. */
39 csp?: string;
40 /** Server-reported endpoint state (`VALID`, `INVALID`). */
41 state?: string;
42 /** RFC3339 last-update timestamp. */
43 updateTime?: string;
44};
45
46export type DestinationStateTimeline = {
47 /** Ordered state/activation entries. */
48 states?: Array<{
49 state?: string;
50 effectiveTime?: string;
51 }>;
52};
53
54export type MulticloudDataTransferConfigsDestinationProps = {
55 /**
56 * Parent `MulticloudDataTransferConfig` resource name
57 * `projects/{project}/locations/{location}/multicloudDataTransferConfigs/{config}`.
58 * Immutable — changing it replaces the destination.
59 */
60 parent: string;
61 /**
62 * Destination id (the `{destination}` segment). If omitted, a unique
63 * name is generated from the stack, stage, and logical id. Immutable —
64 * changing it replaces the destination.
65 */
66 destinationId?: string;
67 /**
68 * IP prefix that represents the remote workload. Immutable — changing
69 * it replaces the destination.
70 */
71 ipPrefix: string;
72 /**
73 * Endpoints (ASN + CSP) configured for the prefix.
74 */
75 endpoints: DestinationEndpoint[];
76 /**
77 * Human-readable description.
78 */
79 description?: string;
80 /**
81 * User labels. Alchemy ownership labels are merged in automatically.
82 */
83 labels?: Record<string, string>;
84};
85
86export type MulticloudDataTransferConfigsDestination = Resource<
87 "GCP.NetworkConnectivity.MulticloudDataTransferConfigsDestination",
88 MulticloudDataTransferConfigsDestinationProps,
89 {
90 /** Full resource name `.../multicloudDataTransferConfigs/{config}/destinations/{destination}`. */
91 name: string;
92 /** Destination id (last path segment). */
93 destinationId: string;
94 /** Parent config resource name. */
95 parent: string;
96 /** Project id. */
97 project: string;
98 /** Location id. */
99 location: string;
100 /** Config id. */
101 multicloudDataTransferConfigId: string;
102 /** Remote IP prefix. */
103 ipPrefix: string | undefined;
104 /** Endpoints currently configured. */
105 endpoints: DestinationEndpoint[];
106 /** User-provided description. */
107 description: string | undefined;
108 /** Server-reported state timeline. */
109 stateTimeline: DestinationStateTimeline | undefined;
110 /** User labels (Alchemy ownership labels stripped). */
111 labels: Record<string, string>;
112 /** Google-generated unique id. */
113 uid: string | undefined;
114 /** Server etag. */
115 etag: string | undefined;
116 /** RFC3339 creation timestamp. */
117 createTime: string | undefined;
118 /** RFC3339 last-update timestamp. */
119 updateTime: string | undefined;
120 },
121 never,
122 Providers
123>;
124
125/**
126 * A Data Transfer Essentials `Destination` — an IP prefix plus ASN/CSP
127 * endpoints billed through a `MulticloudDataTransferConfig`.
128 *
129 * Changing `parent`, `destinationId`, or `ipPrefix` replaces the
130 * destination. Description, labels, and `endpoints` update in place.
131 *
132 * ### Creating a Destination
133 * **Example:** Prefix with one endpoint
134 * ```typescript
135 * const config = yield* GCP.NetworkConnectivity.MulticloudDataTransferConfig(
136 * "Dte",
137 * {},
138 * );
139 * const destination =
140 * yield* GCP.NetworkConnectivity.MulticloudDataTransferConfigsDestination(
141 * "OnPrem",
142 * {
143 * parent: config.name,
144 * ipPrefix: "203.0.113.0/24",
145 * endpoints: [{ asn: "64512", csp: "aws" }],
146 * labels: { env: "prod" },
147 * },
148 * );
149 * ```
150 *
151 * ### Updating a Destination
152 * **Example:** Description, labels, and endpoints
153 * ```typescript
154 * const destination =
155 * yield* GCP.NetworkConnectivity.MulticloudDataTransferConfigsDestination(
156 * "OnPrem",
157 * {
158 * parent: config.name,
159 * ipPrefix: "203.0.113.0/24",
160 * endpoints: [
161 * { asn: "64512", csp: "aws" },
162 * { asn: "64513", csp: "azure" },
163 * ],
164 * description: "on-prem v2",
165 * labels: { env: "prod", role: "dte" },
166 * },
167 * );
168 * ```
169 *
170 * @resource
171 * @category NetworkConnectivity
172 */
173export const MulticloudDataTransferConfigsDestination =
174 Resource<MulticloudDataTransferConfigsDestination>(
175 "GCP.NetworkConnectivity.MulticloudDataTransferConfigsDestination",
176 );
177
178const resourceNameOf = (parent: string, destinationId: string) =>
179 `${parent}/destinations/${destinationId}`;
180
181const toEndpoint = (
182 endpoint: DestinationEndpoint | networkconnectivity.DestinationEndpoint,
183): DestinationEndpoint => ({
184 asn: endpoint.asn,
185 csp: endpoint.csp,
186 state: endpoint.state,
187 updateTime: endpoint.updateTime,
188});
189
190const desiredEndpoints = (endpoints: DestinationEndpoint[]) =>
191 endpoints.map((endpoint) => ({
192 asn: endpoint.asn,
193 csp: endpoint.csp,
194 }));
195
196const endpointsKey = (endpoints: readonly DestinationEndpoint[] | undefined) =>
197 JSON.stringify(
198 [...(endpoints ?? [])]
199 .map((endpoint) => ({
200 asn: endpoint.asn ?? "",
201 csp: (endpoint.csp ?? "").toLowerCase(),
202 }))
203 .sort((left, right) =>
204 left.asn === right.asn
205 ? left.csp.localeCompare(right.csp)
206 : left.asn.localeCompare(right.asn),
207 ),
208 );
209
210const configIdOf = (parent: string) => lastSegment(parent);
211
212const toAttrs = (
213 destination: networkconnectivity.Destination,
214 project: string,
215 region: string,
216) => {
217 const name = destination.name ?? "";
218 const parsed = parseName(name, COLLECTION, region);
219 const parent = parentOfName(name, COLLECTION);
220 return {
221 name,
222 destinationId: parsed.id,
223 parent,
224 project: parsed.project || project,
225 location: parsed.location || region,
226 multicloudDataTransferConfigId: configIdOf(parent),
227 ipPrefix: destination.ipPrefix,
228 endpoints: (destination.endpoints ?? []).map(toEndpoint),
229 description: destination.description,
230 stateTimeline: destination.stateTimeline
231 ? {
232 states: (destination.stateTimeline.states ?? []).map((entry) => ({
233 state: entry.state,
234 effectiveTime: entry.effectiveTime,
235 })),
236 }
237 : undefined,
238 labels: userLabels(destination.labels),
239 uid: destination.uid,
240 etag: destination.etag,
241 createTime: destination.createTime,
242 updateTime: destination.updateTime,
243 };
244};
245
246const getByName = (name: string) =>
247 networkconnectivity
248 .getProjectsLocationsMulticloudDataTransferConfigsDestinations({ name })
249 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
250
251export const MulticloudDataTransferConfigsDestinationProvider = () =>
252 Provider.succeed(MulticloudDataTransferConfigsDestination, {
253 stables: [
254 "name",
255 "destinationId",
256 "parent",
257 "project",
258 "location",
259 "multicloudDataTransferConfigId",
260 "ipPrefix",
261 "uid",
262 "createTime",
263 ],
264
265 diff: Effect.fn(function* ({ news, olds, output }) {
266 if (!isResolved(news)) return undefined;
267 const previousId = olds?.destinationId ?? output?.destinationId;
268 const nextId = news.destinationId
269 ? rfc1035(news.destinationId, "destination")
270 : previousId;
271 const previousParent = olds?.parent ?? output?.parent;
272 const previousPrefix = olds?.ipPrefix ?? output?.ipPrefix;
273 if (
274 (previousId !== undefined &&
275 nextId !== undefined &&
276 nextId !== previousId) ||
277 (previousParent !== undefined && previousParent !== news.parent) ||
278 (previousPrefix !== undefined && previousPrefix !== news.ipPrefix)
279 ) {
280 return { action: "replace" as const };
281 }
282 return undefined;
283 }),
284
285 read: Effect.fn(function* ({ id, olds, output }) {
286 const env = yield* GcpEnvironment.current;
287 const destinationId = yield* toPhysicalId(
288 id,
289 olds?.destinationId,
290 output?.destinationId,
291 "destination",
292 );
293 const parent = olds?.parent ?? output?.parent;
294 const name =
295 output?.name ??
296 (parent !== undefined
297 ? resourceNameOf(parent, destinationId)
298 : undefined);
299 if (name === undefined) return undefined;
300 const existing = yield* getByName(name);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project, env.region);
303 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
304 ? attrs
305 : Unowned(attrs);
306 }),
307
308 list: () =>
309 Effect.gen(function* () {
310 const env = yield* GcpEnvironment.current;
311 const configs = yield* collectPages(
312 networkconnectivity.listProjectsLocationsMulticloudDataTransferConfigs.pages(
313 {
314 parent: `projects/${env.project}/locations/-`,
315 pageSize: 1000,
316 returnPartialSuccess: true,
317 },
318 ),
319 (page) => page.multicloudDataTransferConfigs,
320 );
321 const nested = yield* Effect.forEach(
322 configs.filter((config) => config.name),
323 (config) =>
324 collectPages(
325 networkconnectivity.listProjectsLocationsMulticloudDataTransferConfigsDestinations.pages(
326 {
327 parent: config.name!,
328 pageSize: 1000,
329 returnPartialSuccess: true,
330 },
331 ),
332 (page) => page.destinations,
333 ),
334 { concurrency: 4 },
335 );
336 return nested
337 .flat()
338 .filter((item) => hasAlchemyLabelKeys(item.labels))
339 .map((item) => toAttrs(item, env.project, env.region));
340 }),
341
342 reconcile: Effect.fn(function* ({ id, news, output }) {
343 const env = yield* GcpEnvironment.current;
344 const destinationId = yield* toPhysicalId(
345 id,
346 news.destinationId,
347 output?.destinationId,
348 "destination",
349 );
350 const name = resourceNameOf(news.parent, destinationId);
351 const desiredLabels = {
352 ...toLabels(news.labels),
353 ...(yield* createInternalLabels(id)),
354 };
355 const endpoints = desiredEndpoints(news.endpoints);
356
357 let current = yield* getByName(name);
358
359 if (current === undefined) {
360 const created = yield* networkconnectivity
361 .createProjectsLocationsMulticloudDataTransferConfigsDestinations({
362 parent: news.parent,
363 destinationId,
364 body: {
365 ipPrefix: news.ipPrefix,
366 endpoints,
367 description: news.description,
368 labels: desiredLabels,
369 },
370 })
371 .pipe(
372 Effect.retry({
373 while: (error) => error._tag === "Conflict",
374 times: 5,
375 schedule: Schedule.spaced("2 seconds"),
376 }),
377 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
378 );
379 if (created !== undefined) {
380 yield* waitForOperation(created);
381 }
382 current = yield* waitUntilPresent(getByName(name), name);
383 }
384
385 if (current === undefined) {
386 return yield* new NetworkConnectivityNotResolved({ name });
387 }
388
389 const observedLabels = tagRecord(current.labels);
390 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
391 const labelsChanged = upsert.length > 0 || removed.length > 0;
392 const descriptionChanged =
393 (current.description ?? "") !== (news.description ?? "");
394 const endpointsChanged =
395 endpointsKey((current.endpoints ?? []).map(toEndpoint)) !==
396 endpointsKey(news.endpoints);
397 const updateMask = changedFields([
398 ["labels", labelsChanged],
399 ["description", descriptionChanged],
400 ["endpoints", endpointsChanged],
401 ]);
402
403 if (updateMask.length > 0) {
404 const operation =
405 yield* networkconnectivity.patchProjectsLocationsMulticloudDataTransferConfigsDestinations(
406 {
407 name: current.name ?? name,
408 updateMask: updateMask.join(","),
409 body: {
410 name: current.name ?? name,
411 labels: desiredLabels,
412 description: news.description,
413 endpoints,
414 etag: current.etag,
415 },
416 },
417 );
418 yield* waitForOperation(operation);
419 current = yield* waitUntilPresent(
420 getByName(current.name ?? name),
421 current.name ?? name,
422 );
423 }
424
425 return toAttrs(current, env.project, env.region);
426 }),
427
428 delete: Effect.fn(function* ({ output }) {
429 const operation = yield* networkconnectivity
430 .deleteProjectsLocationsMulticloudDataTransferConfigsDestinations({
431 name: output.name,
432 etag: output.etag,
433 })
434 .pipe(
435 Effect.retry({
436 while: (error) => error._tag === "Conflict",
437 times: 8,
438 schedule: Schedule.spaced("2 seconds"),
439 }),
440 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
441 );
442 if (operation !== undefined) {
443 yield* waitForOperation(operation, { notFoundOk: true });
444 }
445 yield* waitUntilGone(getByName(output.name), output.name);
446 }),
447 });