1import * as networkservices from "@distilled.cloud/gcp/networkservices_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 DEFAULT_GLOBAL,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 sameJson,
28 sameStringList,
29 toBackendServiceResource,
30 toNamedResource,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilGone,
35 waitUntilPresent,
36} from "./internal.ts";
37
38const COLLECTION = "tcpRoutes";
39
40export type TcpRouteRouteMatch = {
41 /**
42 * CIDR range to match (IPv4). Prefix length defaults to 32.
43 * Examples: `10.0.0.1`, `10.0.0.0/8`, `0.0.0.0/0`.
44 */
45 address?: string;
46 /** Destination port to match. */
47 port?: string;
48};
49
50export type TcpRouteRouteDestination = {
51 /**
52 * BackendService resource name to route to, e.g.
53 * `projects/{project}/locations/global/backendServices/{service}`.
54 */
55 serviceName?: string;
56 /**
57 * Relative weight of this destination. If any destination has a
58 * weight, all destinations must.
59 */
60 weight?: number;
61};
62
63export type TcpRouteRouteAction = {
64 /**
65 * Destination services. At least one is required unless
66 * `originalDestination` is true. Mutually exclusive with
67 * `originalDestination`.
68 */
69 destinations?: TcpRouteRouteDestination[];
70 /**
71 * Use the original connection destination IP and port. Mutually
72 * exclusive with `destinations`.
73 * @default false
74 */
75 originalDestination?: boolean;
76 /**
77 * Idle timeout (e.g. `"30s"`). Default 30s; `"0s"` disables it.
78 */
79 idleTimeout?: string;
80};
81
82export type TcpRouteRouteRule = {
83 /**
84 * Match predicates, OR-ed. An empty list matches all traffic.
85 */
86 matches?: TcpRouteRouteMatch[];
87 /** Routing action applied when a match succeeds. */
88 action?: TcpRouteRouteAction;
89};
90
91export type TcpRouteProps = {
92 /**
93 * Route id (the `{tcpRoute}` segment of
94 * `projects/{project}/locations/{location}/tcpRoutes/{tcpRoute}`). If
95 * omitted, a unique name is generated from the stack, stage, and
96 * logical id. Immutable — changing it replaces the route.
97 */
98 tcpRouteId?: string;
99 /**
100 * Location (`global`, `us-central1`, …). Immutable — changing it
101 * replaces the route. `US-CENTRAL1` is accepted and normalized to
102 * `us-central1`.
103 * @default "global"
104 */
105 location?: string;
106 /**
107 * Human-readable description. Max 1024 characters.
108 */
109 description?: string;
110 /**
111 * Routing rules evaluated in order. At least one rule is required.
112 */
113 rules: TcpRouteRouteRule[];
114 /**
115 * Mesh resource names this route attaches to
116 * (`projects/{project}/locations/{location}/meshes/{mesh}`). Attached
117 * meshes must be sidecar type.
118 */
119 meshes?: string[];
120 /**
121 * Gateway resource names this route attaches to
122 * (`projects/{project}/locations/{location}/gateways/{gateway}`).
123 */
124 gateways?: string[];
125 /**
126 * User labels. Alchemy ownership labels are merged in automatically.
127 */
128 labels?: Record<string, string>;
129};
130
131export type TcpRoute = Resource<
132 "GCP.NetworkServices.TcpRoute",
133 TcpRouteProps,
134 {
135 /** Full resource name `projects/{project}/locations/{location}/tcpRoutes/{tcpRoute}`. */
136 name: string;
137 /** Route id (last path segment). */
138 tcpRouteId: string;
139 /** Project id. */
140 project: string;
141 /** Location id (`global`, `us-central1`, …). */
142 location: string;
143 /** User-provided description. */
144 description: string | undefined;
145 /** Configured routing rules. */
146 rules: TcpRouteRouteRule[];
147 /** Attached mesh resource names. */
148 meshes: string[];
149 /** Attached gateway resource names. */
150 gateways: string[];
151 /** User labels (Alchemy ownership labels stripped). */
152 labels: Record<string, string>;
153 /** Server-defined resource URL. */
154 selfLink: string | undefined;
155 /** RFC3339 creation timestamp. */
156 createTime: string | undefined;
157 /** RFC3339 last-update timestamp. */
158 updateTime: string | undefined;
159 },
160 never,
161 Providers
162>;
163
164/**
165 * A TcpRoute describes how a Mesh or Gateway routes TCP traffic.
166 *
167 * Changing `tcpRouteId` or `location` replaces the route. Description,
168 * labels, rules, meshes, and gateways update in place.
169 *
170 * ### Creating a TcpRoute
171 * **Example:** Original-destination sidecar route
172 * ```typescript
173 * const route = yield* GCP.NetworkServices.TcpRoute("Passthrough", {
174 * meshes: [mesh.name],
175 * rules: [
176 * {
177 * matches: [{ address: "0.0.0.0/0", port: "443" }],
178 * action: { originalDestination: true },
179 * },
180 * ],
181 * });
182 * ```
183 *
184 * **Example:** Weighted backend destinations
185 * ```typescript
186 * const route = yield* GCP.NetworkServices.TcpRoute("Split", {
187 * meshes: [mesh.name],
188 * rules: [
189 * {
190 * action: {
191 * destinations: [
192 * { serviceName: backend.name, weight: 80 },
193 * { serviceName: canary.name, weight: 20 },
194 * ],
195 * },
196 * },
197 * ],
198 * });
199 * ```
200 *
201 * @resource
202 * @category NetworkServices
203 */
204export const TcpRoute = Resource<TcpRoute>("GCP.NetworkServices.TcpRoute");
205
206const toDestination = (
207 project: string,
208 destination:
209 | TcpRouteRouteDestination
210 | networkservices.TcpRouteRouteDestination,
211): TcpRouteRouteDestination => ({
212 serviceName: destination.serviceName
213 ? toBackendServiceResource(project, destination.serviceName)
214 : undefined,
215 weight: destination.weight,
216});
217
218const toAction = (
219 project: string,
220 action: TcpRouteRouteAction | networkservices.TcpRouteRouteAction | undefined,
221): TcpRouteRouteAction | undefined => {
222 if (action === undefined) return undefined;
223 return {
224 destinations: (action.destinations ?? []).map((destination) =>
225 toDestination(project, destination),
226 ),
227 originalDestination: action.originalDestination,
228 idleTimeout: action.idleTimeout,
229 };
230};
231
232const toRule = (
233 project: string,
234 rule: TcpRouteRouteRule | networkservices.TcpRouteRouteRule,
235): TcpRouteRouteRule => ({
236 matches: (rule.matches ?? []).map((match) => ({
237 address: match.address,
238 port: match.port,
239 })),
240 action: toAction(project, rule.action),
241});
242
243const toMeshes = (
244 project: string,
245 location: string,
246 meshes: readonly string[] | undefined,
247) =>
248 (meshes ?? []).map((mesh) =>
249 toNamedResource(project, location, "meshes", mesh),
250 );
251
252const toGateways = (
253 project: string,
254 location: string,
255 gateways: readonly string[] | undefined,
256) =>
257 (gateways ?? []).map((gateway) =>
258 toNamedResource(project, location, "gateways", gateway),
259 );
260
261const toAttrs = (route: networkservices.TcpRoute, project: string) => {
262 const name = route.name ?? "";
263 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
264 const location = parsed.location || DEFAULT_GLOBAL;
265 const proj = parsed.project || project;
266 return {
267 name,
268 tcpRouteId: parsed.id,
269 project: proj,
270 location,
271 description: route.description,
272 rules: (route.rules ?? []).map((rule) => toRule(proj, rule)),
273 meshes: toMeshes(proj, location, route.meshes),
274 gateways: toGateways(proj, location, route.gateways),
275 labels: userLabels(route.labels),
276 selfLink: route.selfLink,
277 createTime: route.createTime,
278 updateTime: route.updateTime,
279 };
280};
281
282const getByName = (name: string) =>
283 networkservices
284 .getProjectsLocationsTcpRoutes({ name })
285 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
286
287export const TcpRouteProvider = () =>
288 Provider.succeed(TcpRoute, {
289 stables: [
290 "name",
291 "tcpRouteId",
292 "project",
293 "location",
294 "createTime",
295 "selfLink",
296 ],
297
298 diff: Effect.fn(function* ({ news, olds, output }) {
299 if (!isResolved(news)) return undefined;
300 const previousId = olds?.tcpRouteId ?? output?.tcpRouteId;
301 const nextId = news.tcpRouteId
302 ? rfc1035(news.tcpRouteId, "tcp-route")
303 : previousId;
304 const previousLocation = normalizeLocation(
305 olds?.location ?? output?.location,
306 DEFAULT_GLOBAL,
307 );
308 const nextLocation = normalizeLocation(
309 news.location ?? olds?.location ?? output?.location,
310 DEFAULT_GLOBAL,
311 );
312 if (
313 (previousId !== undefined &&
314 nextId !== undefined &&
315 nextId !== previousId) ||
316 previousLocation !== nextLocation
317 ) {
318 return { action: "replace" as const };
319 }
320 return undefined;
321 }),
322
323 read: Effect.fn(function* ({ id, olds, output }) {
324 const env = yield* GcpEnvironment.current;
325 const tcpRouteId = yield* toPhysicalId(
326 id,
327 olds?.tcpRouteId,
328 output?.tcpRouteId,
329 "tcp-route",
330 );
331 const location = normalizeLocation(
332 olds?.location ?? output?.location,
333 DEFAULT_GLOBAL,
334 );
335 const name =
336 output?.name ??
337 resourceName(env.project, location, COLLECTION, tcpRouteId);
338 const existing = yield* getByName(name);
339 if (existing === undefined) return undefined;
340 const attrs = toAttrs(existing, env.project);
341 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
342 ? attrs
343 : Unowned(attrs);
344 }),
345
346 list: () =>
347 Effect.gen(function* () {
348 const env = yield* GcpEnvironment.current;
349 const items = yield* collectPages(
350 networkservices.listProjectsLocationsTcpRoutes.pages({
351 parent: parentOf(env.project, "-"),
352 pageSize: 1000,
353 returnPartialSuccess: true,
354 }),
355 (page) => page.tcpRoutes,
356 );
357 return items
358 .filter((item) => hasAlchemyLabelKeys(item.labels))
359 .map((item) => toAttrs(item, env.project));
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output }) {
363 const env = yield* GcpEnvironment.current;
364 const tcpRouteId = yield* toPhysicalId(
365 id,
366 news.tcpRouteId,
367 output?.tcpRouteId,
368 "tcp-route",
369 );
370 const location = normalizeLocation(
371 news.location ?? output?.location,
372 DEFAULT_GLOBAL,
373 );
374 const name = resourceName(env.project, location, COLLECTION, tcpRouteId);
375 const desiredLabels = {
376 ...toLabels(news.labels),
377 ...(yield* createInternalLabels(id)),
378 };
379 const desiredRules = news.rules.map((rule) => toRule(env.project, rule));
380 const desiredMeshes = toMeshes(env.project, location, news.meshes);
381 const desiredGateways = toGateways(env.project, location, news.gateways);
382
383 let current = yield* getByName(name);
384
385 if (current === undefined) {
386 const created = yield* networkservices
387 .createProjectsLocationsTcpRoutes({
388 parent: parentOf(env.project, location),
389 tcpRouteId,
390 body: {
391 description: news.description,
392 labels: desiredLabels,
393 rules: desiredRules,
394 meshes: desiredMeshes,
395 gateways: desiredGateways,
396 },
397 })
398 .pipe(
399 Effect.retry({
400 while: (error) => error._tag === "Conflict",
401 times: 5,
402 schedule: Schedule.spaced("2 seconds"),
403 }),
404 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
405 );
406 if (created !== undefined) {
407 yield* waitForOperation(created);
408 }
409 current = yield* waitUntilPresent(getByName(name), name);
410 }
411
412 const observed = toAttrs(current, env.project);
413 const observedLabels = tagRecord(current.labels);
414 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
415 const labelsChanged = upsert.length > 0 || removed.length > 0;
416 const updateMask = changedFields([
417 ["labels", labelsChanged],
418 [
419 "description",
420 (current.description ?? "") !== (news.description ?? ""),
421 ],
422 ["rules", !sameJson(observed.rules, desiredRules)],
423 ["meshes", !sameStringList(observed.meshes, desiredMeshes)],
424 ["gateways", !sameStringList(observed.gateways, desiredGateways)],
425 ]);
426
427 if (updateMask.length > 0) {
428 const operation =
429 yield* networkservices.patchProjectsLocationsTcpRoutes({
430 name: current.name ?? name,
431 updateMask: updateMask.join(","),
432 body: {
433 name: current.name ?? name,
434 labels: desiredLabels,
435 description: news.description,
436 rules: desiredRules,
437 meshes: desiredMeshes,
438 gateways: desiredGateways,
439 },
440 });
441 yield* waitForOperation(operation);
442 current = yield* waitUntilPresent(
443 getByName(current.name ?? name),
444 current.name ?? name,
445 );
446 }
447
448 return toAttrs(current, env.project);
449 }),
450
451 delete: Effect.fn(function* ({ output }) {
452 const operation = yield* networkservices
453 .deleteProjectsLocationsTcpRoutes({ name: output.name })
454 .pipe(
455 Effect.retry({
456 while: (error) => error._tag === "Conflict",
457 times: 8,
458 schedule: Schedule.spaced("2 seconds"),
459 }),
460 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
461 );
462 if (operation !== undefined) {
463 yield* waitForOperation(operation, { notFoundOk: true });
464 }
465 yield* waitUntilGone(getByName(output.name), output.name);
466 }),
467 });