1import * as ds from "@distilled.cloud/gcp/datastream_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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 collectPages,
14 fingerprint,
15 hasAlchemyLabelMap,
16 normalizeLocation,
17 parseName,
18 privateConnectionOf,
19 replaceOnIdentity,
20 ResourceNotResolved,
21 settleOperation,
22 toPhysicalId,
23 userLabels,
24 waitUntilExists,
25 waitUntilGone,
26} from "./internal.ts";
27import { listPrivateConnections } from "./PrivateConnection.ts";
28
29export type PrivateConnectionsRouteProps = {
30 /**
31 * Parent private connection. Full name
32 * `projects/{project}/locations/{location}/privateConnections/{privateConnection}`
33 * or the connection id (combined with `location`). Immutable —
34 * changing it replaces the route.
35 */
36 privateConnection: string;
37 /**
38 * Region used when `privateConnection` is a bare id.
39 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
40 */
41 location?: string;
42 /**
43 * Route id (the `{route}` segment of
44 * `{privateConnection}/routes/{route}`). If omitted, a unique RFC1035
45 * name is generated. Immutable — changing it replaces the route.
46 */
47 routeId?: string;
48 /**
49 * User-friendly display name. The API has no patch method — changing
50 * display name replaces the route.
51 */
52 displayName?: string;
53 /**
54 * User labels. Alchemy ownership labels are merged in automatically.
55 * The API has no patch method — changing labels replaces the route.
56 */
57 labels?: Record<string, string>;
58 /**
59 * Destination address for the route (hostname or IP). Immutable —
60 * changing it replaces the route.
61 */
62 destinationAddress: string;
63 /**
64 * Destination port for the route. Immutable — changing it replaces
65 * the route.
66 */
67 destinationPort?: number;
68};
69
70export type PrivateConnectionsRoute = Resource<
71 "GCP.Datastream.PrivateConnectionsRoute",
72 PrivateConnectionsRouteProps,
73 {
74 /** Full resource name. */
75 name: string;
76 /** Route id (last path segment). */
77 routeId: string;
78 /** Parent private connection resource name. */
79 privateConnection: string;
80 /** Project id. */
81 project: string;
82 /** Location id. */
83 location: string;
84 /** User-friendly display name. */
85 displayName: string | undefined;
86 /** User labels (Alchemy ownership labels stripped). */
87 labels: Record<string, string>;
88 /** Destination address. */
89 destinationAddress: string | undefined;
90 /** Destination port. */
91 destinationPort: number | 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 Datastream route attached to a private connection. Routes advertise
103 * a destination address (and optional port) through the private
104 * connection's VPC peering or PSC interface.
105 *
106 * The API has no patch method. `routeId`, parent private connection,
107 * destination address/port, display name, and labels are replacement
108 * triggers.
109 *
110 * ### Creating a Route
111 * **Example:** Advertise a database host
112 * ```typescript
113 * const peering = yield* GCP.Datastream.PrivateConnection("DsPeer", {
114 * vpcPeeringConfig: {
115 * vpc: network.networkName,
116 * subnet: "10.9.0.0/29",
117 * },
118 * });
119 * const route = yield* GCP.Datastream.PrivateConnectionsRoute("DbHost", {
120 * privateConnection: peering.name,
121 * destinationAddress: "10.0.0.8",
122 * destinationPort: 3306,
123 * labels: { env: "test" },
124 * });
125 * ```
126 *
127 * @resource
128 * @category Datastream
129 */
130export const PrivateConnectionsRoute = Resource<PrivateConnectionsRoute>(
131 "GCP.Datastream.PrivateConnectionsRoute",
132);
133
134const resourceName = (privateConnection: string, routeId: string) =>
135 `${privateConnection}/routes/${routeId}`;
136
137const toAttrs = (item: ds.Route, project: string, region: string) => {
138 const name = item.name ?? "";
139 const parsed = parseName(name, "routes", region);
140 return {
141 name,
142 routeId: parsed.id,
143 privateConnection: parsed.parent,
144 project: parsed.project || project,
145 location: parsed.location,
146 displayName: item.displayName,
147 labels: userLabels(item.labels),
148 destinationAddress: item.destinationAddress,
149 destinationPort: item.destinationPort,
150 createTime: item.createTime,
151 updateTime: item.updateTime,
152 };
153};
154
155const getByName = (name: string) =>
156 name.length === 0
157 ? Effect.succeed(undefined)
158 : ds
159 .getProjectsLocationsPrivateConnectionsRoutes({ name })
160 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
161
162const listRoutes = (parent: string) =>
163 collectPages(
164 ds.listProjectsLocationsPrivateConnectionsRoutes.pages({
165 parent,
166 pageSize: 1000,
167 }),
168 (page) => page.routes,
169 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed([] as ds.Route[])));
170
171const listOwned = (project: string, region: string) =>
172 Effect.gen(function* () {
173 const connections = yield* listPrivateConnections(project, region);
174 const pages = yield* Effect.forEach(
175 connections.filter((item) => (item.name ?? "").length > 0),
176 (item) => listRoutes(item.name!),
177 { concurrency: 4 },
178 );
179 return pages.flat().filter((route) => hasAlchemyLabelMap(route.labels));
180 });
181
182export const PrivateConnectionsRouteProvider = () =>
183 Provider.succeed(PrivateConnectionsRoute, {
184 stables: ["name", "routeId", "privateConnection", "project", "location"],
185
186 diff: Effect.fn(function* ({ news, olds, output }) {
187 if (!isResolved(news)) return undefined;
188 const env = yield* GcpEnvironment.current;
189 const previousAddress =
190 olds?.destinationAddress ?? output?.destinationAddress;
191 const nextAddress = news.destinationAddress ?? previousAddress;
192 const previousPort = olds?.destinationPort ?? output?.destinationPort;
193 const nextPort = news.destinationPort ?? previousPort;
194 const previousDisplay = olds?.displayName ?? output?.displayName;
195 const nextDisplay = news.displayName ?? previousDisplay;
196 const previousLabels = fingerprint(olds?.labels ?? output?.labels);
197 const nextLabels = fingerprint(
198 news.labels ?? olds?.labels ?? output?.labels,
199 );
200 const previousParent =
201 olds?.privateConnection ?? output?.privateConnection;
202 const nextParent = news.privateConnection ?? previousParent;
203 return replaceOnIdentity({
204 previousId: olds?.routeId ?? output?.routeId,
205 nextId: news.routeId ?? olds?.routeId ?? output?.routeId,
206 previousLocation: normalizeLocation(
207 olds?.location ?? output?.location,
208 env.region,
209 ),
210 nextLocation: normalizeLocation(
211 news.location ?? olds?.location ?? output?.location,
212 env.region,
213 ),
214 previousParent,
215 nextParent,
216 extra:
217 (olds !== undefined || output !== undefined) &&
218 (previousAddress !== nextAddress ||
219 previousPort !== nextPort ||
220 previousDisplay !== nextDisplay ||
221 previousLabels !== nextLabels),
222 });
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const env = yield* GcpEnvironment.current;
227 const location = normalizeLocation(
228 olds?.location ?? output?.location,
229 env.region,
230 );
231 const parentId = olds?.privateConnection ?? output?.privateConnection;
232 // Recovering an interrupted create whose parent never resolved: no
233 // route can exist without its private connection.
234 if (typeof parentId !== "string" || parentId.length === 0) {
235 return undefined;
236 }
237 const parent = privateConnectionOf(parentId, env.project, location);
238 const routeId = yield* toPhysicalId(
239 id,
240 olds?.routeId,
241 output?.routeId,
242 "route",
243 );
244 const name = output?.name ?? resourceName(parent, routeId);
245 const existing = yield* getByName(name);
246 if (existing === undefined) return undefined;
247 const attrs = toAttrs(existing, env.project, env.region);
248 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
249 ? attrs
250 : Unowned(attrs);
251 }),
252
253 list: () =>
254 Effect.gen(function* () {
255 const env = yield* GcpEnvironment.current;
256 const items = yield* listOwned(env.project, env.region);
257 return items.map((item) => toAttrs(item, env.project, env.region));
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const env = yield* GcpEnvironment.current;
262 const location = normalizeLocation(
263 news.location ?? output?.location,
264 env.region,
265 );
266 const parent = privateConnectionOf(
267 news.privateConnection,
268 env.project,
269 location,
270 );
271 const routeId = yield* toPhysicalId(
272 id,
273 news.routeId,
274 output?.routeId,
275 "route",
276 );
277 const name = resourceName(parent, routeId);
278 const desiredLabels = {
279 ...toLabels(news.labels),
280 ...(yield* createInternalLabels(id)),
281 };
282 const displayName = news.displayName ?? routeId;
283
284 let current = yield* getByName(output?.name ?? name);
285
286 if (current === undefined) {
287 const created = yield* ds
288 .createProjectsLocationsPrivateConnectionsRoutes({
289 parent,
290 routeId,
291 body: {
292 displayName,
293 labels: desiredLabels,
294 destinationAddress: news.destinationAddress,
295 destinationPort: news.destinationPort,
296 },
297 })
298 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
299 yield* settleOperation(created);
300 current = yield* waitUntilExists(getByName(name), name);
301 }
302
303 if (current === undefined) {
304 return yield* new ResourceNotResolved({ name });
305 }
306
307 return toAttrs(current, env.project, env.region);
308 }),
309
310 delete: Effect.fn(function* ({ output }) {
311 const operation = yield* ds
312 .deleteProjectsLocationsPrivateConnectionsRoutes({
313 name: output.name,
314 })
315 .pipe(
316 Effect.retry({
317 while: (error) => error._tag === "Conflict",
318 times: 8,
319 schedule: Schedule.spaced("2 seconds"),
320 }),
321 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
322 );
323 yield* settleOperation(operation, { notFoundOk: true });
324 yield* waitUntilGone(getByName(output.name), output.name);
325 }),
326 });