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 = "tlsRoutes";
39
40export type TlsRouteRouteMatch = {
41 /**
42 * SNI hosts to match, including wildcards (`*.example.com`). At least
43 * one of `sniHost` or `alpn` is required. Up to 100 SNI hosts across
44 * all matches.
45 */
46 sniHost?: string[];
47 /**
48 * ALPN values to match (`http/1.1`, `h2`). At least one of `sniHost`
49 * or `alpn` is required. Up to 5 ALPNs across all matches.
50 */
51 alpn?: string[];
52};
53
54export type TlsRouteRouteDestination = {
55 /**
56 * BackendService resource name to route to, e.g.
57 * `projects/{project}/locations/global/backendServices/{service}`.
58 */
59 serviceName?: string;
60 /**
61 * Relative weight of this destination. Weights do not need to sum to
62 * 100.
63 */
64 weight?: number;
65};
66
67export type TlsRouteRouteAction = {
68 /** Destination services. At least one is required. */
69 destinations?: TlsRouteRouteDestination[];
70 /**
71 * Idle timeout (e.g. `"3600s"`). Default 1 hour; `"0s"` disables it.
72 */
73 idleTimeout?: string;
74};
75
76export type TlsRouteRouteRule = {
77 /** Match predicates, OR-ed. At least one match is required. */
78 matches?: TlsRouteRouteMatch[];
79 /** Routing action applied when a match succeeds. */
80 action?: TlsRouteRouteAction;
81};
82
83export type TlsRouteProps = {
84 /**
85 * Route id (the `{tlsRoute}` segment of
86 * `projects/{project}/locations/{location}/tlsRoutes/{tlsRoute}`). If
87 * omitted, a unique name is generated from the stack, stage, and
88 * logical id. Immutable — changing it replaces the route.
89 */
90 tlsRouteId?: string;
91 /**
92 * Location (`global`, `us-central1`, …). Immutable — changing it
93 * replaces the route. `US-CENTRAL1` is accepted and normalized to
94 * `us-central1`.
95 * @default "global"
96 */
97 location?: string;
98 /**
99 * Human-readable description. Max 1024 characters.
100 */
101 description?: string;
102 /**
103 * Routing rules evaluated in order. At least one rule is required.
104 */
105 rules: TlsRouteRouteRule[];
106 /**
107 * Mesh resource names this route attaches to
108 * (`projects/{project}/locations/{location}/meshes/{mesh}`). Attached
109 * meshes must be sidecar type.
110 */
111 meshes?: string[];
112 /**
113 * Gateway resource names this route attaches to
114 * (`projects/{project}/locations/{location}/gateways/{gateway}`).
115 */
116 gateways?: string[];
117 /**
118 * TargetTcpProxy resource names this route attaches to
119 * (`projects/{project}/locations/{location}/targetTcpProxies/{proxy}`).
120 */
121 targetProxies?: string[];
122 /**
123 * User labels. Alchemy ownership labels are merged in automatically.
124 */
125 labels?: Record<string, string>;
126};
127
128export type TlsRoute = Resource<
129 "GCP.NetworkServices.TlsRoute",
130 TlsRouteProps,
131 {
132 /** Full resource name `projects/{project}/locations/{location}/tlsRoutes/{tlsRoute}`. */
133 name: string;
134 /** Route id (last path segment). */
135 tlsRouteId: string;
136 /** Project id. */
137 project: string;
138 /** Location id (`global`, `us-central1`, …). */
139 location: string;
140 /** User-provided description. */
141 description: string | undefined;
142 /** Configured routing rules. */
143 rules: TlsRouteRouteRule[];
144 /** Attached mesh resource names. */
145 meshes: string[];
146 /** Attached gateway resource names. */
147 gateways: string[];
148 /** Attached TargetTcpProxy resource names. */
149 targetProxies: string[];
150 /** User labels (Alchemy ownership labels stripped). */
151 labels: Record<string, string>;
152 /** Server-defined resource URL. */
153 selfLink: string | undefined;
154 /** RFC3339 creation timestamp. */
155 createTime: string | undefined;
156 /** RFC3339 last-update timestamp. */
157 updateTime: string | undefined;
158 },
159 never,
160 Providers
161>;
162
163/**
164 * A TlsRoute routes TLS traffic based on SNI and ALPN for a Mesh,
165 * Gateway, or TargetTcpProxy.
166 *
167 * Changing `tlsRouteId` or `location` replaces the route. Description,
168 * labels, rules, meshes, gateways, and target proxies update in place.
169 *
170 * ### Creating a TlsRoute
171 * **Example:** SNI-based sidecar route
172 * ```typescript
173 * const route = yield* GCP.NetworkServices.TlsRoute("Secure", {
174 * meshes: [mesh.name],
175 * rules: [
176 * {
177 * matches: [{ sniHost: ["api.example.com"], alpn: ["h2"] }],
178 * action: {
179 * destinations: [{ serviceName: backend.name, weight: 1 }],
180 * },
181 * },
182 * ],
183 * });
184 * ```
185 *
186 * @resource
187 * @category NetworkServices
188 */
189export const TlsRoute = Resource<TlsRoute>("GCP.NetworkServices.TlsRoute");
190
191const toDestination = (
192 project: string,
193 destination:
194 | TlsRouteRouteDestination
195 | networkservices.TlsRouteRouteDestination,
196): TlsRouteRouteDestination => ({
197 serviceName: destination.serviceName
198 ? toBackendServiceResource(project, destination.serviceName)
199 : undefined,
200 weight: destination.weight,
201});
202
203const toAction = (
204 project: string,
205 action: TlsRouteRouteAction | networkservices.TlsRouteRouteAction | undefined,
206): TlsRouteRouteAction | undefined => {
207 if (action === undefined) return undefined;
208 return {
209 destinations: (action.destinations ?? []).map((destination) =>
210 toDestination(project, destination),
211 ),
212 idleTimeout: action.idleTimeout,
213 };
214};
215
216const toRule = (
217 project: string,
218 rule: TlsRouteRouteRule | networkservices.TlsRouteRouteRule,
219): TlsRouteRouteRule => ({
220 matches: (rule.matches ?? []).map((match) => ({
221 sniHost: [...(match.sniHost ?? [])],
222 alpn: [...(match.alpn ?? [])],
223 })),
224 action: toAction(project, rule.action),
225});
226
227const toMeshes = (
228 project: string,
229 location: string,
230 meshes: readonly string[] | undefined,
231) =>
232 (meshes ?? []).map((mesh) =>
233 toNamedResource(project, location, "meshes", mesh),
234 );
235
236const toGateways = (
237 project: string,
238 location: string,
239 gateways: readonly string[] | undefined,
240) =>
241 (gateways ?? []).map((gateway) =>
242 toNamedResource(project, location, "gateways", gateway),
243 );
244
245const toTargetProxies = (
246 project: string,
247 location: string,
248 proxies: readonly string[] | undefined,
249) =>
250 (proxies ?? []).map((proxy) =>
251 toNamedResource(project, location, "targetTcpProxies", proxy),
252 );
253
254const toAttrs = (route: networkservices.TlsRoute, project: string) => {
255 const name = route.name ?? "";
256 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
257 const location = parsed.location || DEFAULT_GLOBAL;
258 const proj = parsed.project || project;
259 return {
260 name,
261 tlsRouteId: parsed.id,
262 project: proj,
263 location,
264 description: route.description,
265 rules: (route.rules ?? []).map((rule) => toRule(proj, rule)),
266 meshes: toMeshes(proj, location, route.meshes),
267 gateways: toGateways(proj, location, route.gateways),
268 targetProxies: toTargetProxies(proj, location, route.targetProxies),
269 labels: userLabels(route.labels),
270 selfLink: route.selfLink,
271 createTime: route.createTime,
272 updateTime: route.updateTime,
273 };
274};
275
276const getByName = (name: string) =>
277 networkservices
278 .getProjectsLocationsTlsRoutes({ name })
279 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
280
281export const TlsRouteProvider = () =>
282 Provider.succeed(TlsRoute, {
283 stables: [
284 "name",
285 "tlsRouteId",
286 "project",
287 "location",
288 "createTime",
289 "selfLink",
290 ],
291
292 diff: Effect.fn(function* ({ news, olds, output }) {
293 if (!isResolved(news)) return undefined;
294 const previousId = olds?.tlsRouteId ?? output?.tlsRouteId;
295 const nextId = news.tlsRouteId
296 ? rfc1035(news.tlsRouteId, "tls-route")
297 : previousId;
298 const previousLocation = normalizeLocation(
299 olds?.location ?? output?.location,
300 DEFAULT_GLOBAL,
301 );
302 const nextLocation = normalizeLocation(
303 news.location ?? olds?.location ?? output?.location,
304 DEFAULT_GLOBAL,
305 );
306 if (
307 (previousId !== undefined &&
308 nextId !== undefined &&
309 nextId !== previousId) ||
310 previousLocation !== nextLocation
311 ) {
312 return { action: "replace" as const };
313 }
314 return undefined;
315 }),
316
317 read: Effect.fn(function* ({ id, olds, output }) {
318 const env = yield* GcpEnvironment.current;
319 const tlsRouteId = yield* toPhysicalId(
320 id,
321 olds?.tlsRouteId,
322 output?.tlsRouteId,
323 "tls-route",
324 );
325 const location = normalizeLocation(
326 olds?.location ?? output?.location,
327 DEFAULT_GLOBAL,
328 );
329 const name =
330 output?.name ??
331 resourceName(env.project, location, COLLECTION, tlsRouteId);
332 const existing = yield* getByName(name);
333 if (existing === undefined) return undefined;
334 const attrs = toAttrs(existing, env.project);
335 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
336 ? attrs
337 : Unowned(attrs);
338 }),
339
340 list: () =>
341 Effect.gen(function* () {
342 const env = yield* GcpEnvironment.current;
343 const items = yield* collectPages(
344 networkservices.listProjectsLocationsTlsRoutes.pages({
345 parent: parentOf(env.project, "-"),
346 pageSize: 1000,
347 returnPartialSuccess: true,
348 }),
349 (page) => page.tlsRoutes,
350 );
351 return items
352 .filter((item) => hasAlchemyLabelKeys(item.labels))
353 .map((item) => toAttrs(item, env.project));
354 }),
355
356 reconcile: Effect.fn(function* ({ id, news, output }) {
357 const env = yield* GcpEnvironment.current;
358 const tlsRouteId = yield* toPhysicalId(
359 id,
360 news.tlsRouteId,
361 output?.tlsRouteId,
362 "tls-route",
363 );
364 const location = normalizeLocation(
365 news.location ?? output?.location,
366 DEFAULT_GLOBAL,
367 );
368 const name = resourceName(env.project, location, COLLECTION, tlsRouteId);
369 const desiredLabels = {
370 ...toLabels(news.labels),
371 ...(yield* createInternalLabels(id)),
372 };
373 const desiredRules = news.rules.map((rule) => toRule(env.project, rule));
374 const desiredMeshes = toMeshes(env.project, location, news.meshes);
375 const desiredGateways = toGateways(env.project, location, news.gateways);
376 const desiredProxies = toTargetProxies(
377 env.project,
378 location,
379 news.targetProxies,
380 );
381
382 let current = yield* getByName(name);
383
384 if (current === undefined) {
385 const created = yield* networkservices
386 .createProjectsLocationsTlsRoutes({
387 parent: parentOf(env.project, location),
388 tlsRouteId,
389 body: {
390 description: news.description,
391 labels: desiredLabels,
392 rules: desiredRules,
393 meshes: desiredMeshes,
394 gateways: desiredGateways,
395 targetProxies: desiredProxies,
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 "targetProxies",
427 !sameStringList(observed.targetProxies, desiredProxies),
428 ],
429 ]);
430
431 if (updateMask.length > 0) {
432 const operation =
433 yield* networkservices.patchProjectsLocationsTlsRoutes({
434 name: current.name ?? name,
435 updateMask: updateMask.join(","),
436 body: {
437 name: current.name ?? name,
438 labels: desiredLabels,
439 description: news.description,
440 rules: desiredRules,
441 meshes: desiredMeshes,
442 gateways: desiredGateways,
443 targetProxies: desiredProxies,
444 },
445 });
446 yield* waitForOperation(operation);
447 current = yield* waitUntilPresent(
448 getByName(current.name ?? name),
449 current.name ?? name,
450 );
451 }
452
453 return toAttrs(current, env.project);
454 }),
455
456 delete: Effect.fn(function* ({ output }) {
457 const operation = yield* networkservices
458 .deleteProjectsLocationsTlsRoutes({ name: output.name })
459 .pipe(
460 Effect.retry({
461 while: (error) => error._tag === "Conflict",
462 times: 8,
463 schedule: Schedule.spaced("2 seconds"),
464 }),
465 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
466 );
467 if (operation !== undefined) {
468 yield* waitForOperation(operation, { notFoundOk: true });
469 }
470 yield* waitUntilGone(getByName(output.name), output.name);
471 }),
472 });