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 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilGone,
33 waitUntilPresent,
34} from "./internal.ts";
35
36const COLLECTION = "grpcRoutes";
37
38export type GrpcRouteMethodMatch = {
39 /** How to match the method name. Defaults to `EXACT`. */
40 type?: string;
41 /** gRPC service name. Omitted matches every service. */
42 grpcService?: string;
43 /** gRPC method name. Omitted matches every method. */
44 grpcMethod?: string;
45 /**
46 * Case-sensitive matching. Defaults to true. Must not be set with
47 * `REGULAR_EXPRESSION`.
48 */
49 caseSensitive?: boolean;
50};
51
52export type GrpcRouteHeaderMatch = {
53 /** How to match the header value. Defaults to `EXACT`. */
54 type?: string;
55 /** Header key. */
56 key?: string;
57 /** Header value. */
58 value?: string;
59};
60
61export type GrpcRouteRouteMatch = {
62 /** gRPC method matcher. Omitted matches every method. */
63 method?: GrpcRouteMethodMatch;
64 /** Header matchers. */
65 headers?: GrpcRouteHeaderMatch[];
66};
67
68export type GrpcRouteDestination = {
69 /** BackendService or Service Directory service URL. */
70 serviceName?: string;
71 /** Traffic weight relative to other destinations. */
72 weight?: number;
73};
74
75export type GrpcRouteFaultInjectionDelay = {
76 /** Fixed delay before forwarding (duration string). */
77 fixedDelay?: string;
78 /** Percentage of traffic delayed, 0-100. */
79 percentage?: number;
80};
81
82export type GrpcRouteFaultInjectionAbort = {
83 /** HTTP status used to abort, 200-599. */
84 httpStatus?: number;
85 /** Percentage of traffic aborted, 0-100. */
86 percentage?: number;
87};
88
89export type GrpcRouteFaultInjectionPolicy = {
90 /** Delay injection. */
91 delay?: GrpcRouteFaultInjectionDelay;
92 /** Abort injection. */
93 abort?: GrpcRouteFaultInjectionAbort;
94};
95
96export type GrpcRouteRetryPolicy = {
97 /**
98 * Conditions that trigger a retry (`connect-failure`, `unavailable`,
99 * `cancelled`, …).
100 */
101 retryConditions?: string[];
102 /** Allowed retry count. Defaults to 1. */
103 numRetries?: number;
104};
105
106export type GrpcRouteStatefulSessionAffinityPolicy = {
107 /** Cookie TTL for the `GSSA` session cookie (`0s` is a session cookie). */
108 cookieTtl?: string;
109};
110
111export type GrpcRouteRouteAction = {
112 /** Destinations that receive matched traffic. */
113 destinations?: GrpcRouteDestination[];
114 /** Fault injection. */
115 faultInjectionPolicy?: GrpcRouteFaultInjectionPolicy;
116 /** End-to-end timeout including retries. */
117 timeout?: string;
118 /** Retry policy. */
119 retryPolicy?: GrpcRouteRetryPolicy;
120 /** Cookie-based session affinity. */
121 statefulSessionAffinity?: GrpcRouteStatefulSessionAffinityPolicy;
122 /** Idle timeout. `0s` disables it. */
123 idleTimeout?: string;
124};
125
126export type GrpcRouteRouteRule = {
127 /**
128 * Matchers. The rule matches if any matcher matches. Omitted matches
129 * every request.
130 */
131 matches?: GrpcRouteRouteMatch[];
132 /** How to route matched traffic. */
133 action?: GrpcRouteRouteAction;
134};
135
136export type GrpcRouteProps = {
137 /**
138 * GrpcRoute id (the `{grpcRoute}` segment of
139 * `projects/{project}/locations/{location}/grpcRoutes/{grpcRoute}`).
140 * If omitted, a unique name is generated from the stack, stage, and
141 * logical id. Must be 1-63 characters and not start with a number.
142 * Immutable — changing it replaces the route.
143 */
144 grpcRouteId?: string;
145 /**
146 * Location (`global`, `us-central1`, …). Immutable — changing it
147 * replaces the route. `US-CENTRAL1` is accepted and normalized to
148 * `us-central1`.
149 * @default "global"
150 */
151 location?: string;
152 /**
153 * Service hostnames (with optional port) this route matches, e.g.
154 * `foo.example.com` or `*.example.com`.
155 */
156 hostnames: string[];
157 /**
158 * Routing rules. The first matching rule is executed. At least one
159 * rule is required.
160 */
161 rules: GrpcRouteRouteRule[];
162 /**
163 * Mesh resource names this route attaches to
164 * (`projects/{project}/locations/{location}/meshes/{mesh}`).
165 */
166 meshes?: string[];
167 /**
168 * Gateway resource names this route attaches to
169 * (`projects/{project}/locations/{location}/gateways/{gateway}`).
170 */
171 gateways?: string[];
172 /**
173 * Human-readable description. Max length 1024 characters.
174 */
175 description?: string;
176 /**
177 * User labels. Alchemy ownership labels are merged in automatically.
178 */
179 labels?: Record<string, string>;
180};
181
182export type GrpcRoute = Resource<
183 "GCP.NetworkServices.GrpcRoute",
184 GrpcRouteProps,
185 {
186 /** Full resource name `projects/{project}/locations/{location}/grpcRoutes/{grpcRoute}`. */
187 name: string;
188 /** GrpcRoute id (last path segment). */
189 grpcRouteId: string;
190 /** Project id. */
191 project: string;
192 /** Location id (`global`, `us-central1`, …). */
193 location: string;
194 /** Server-defined resource URL. */
195 selfLink: string | undefined;
196 /** Hostnames this route matches. */
197 hostnames: string[];
198 /** Routing rules. */
199 rules: GrpcRouteRouteRule[];
200 /** Attached Mesh resource names. */
201 meshes: string[];
202 /** Attached Gateway resource names. */
203 gateways: string[];
204 /** User-provided description. */
205 description: string | undefined;
206 /** User labels (Alchemy ownership labels stripped). */
207 labels: Record<string, string>;
208 /** RFC3339 creation timestamp. */
209 createTime: string | undefined;
210 /** RFC3339 last-update timestamp. */
211 updateTime: string | undefined;
212 },
213 never,
214 Providers
215>;
216
217/**
218 * A Network Services GrpcRoute — how a Mesh or Gateway routes gRPC
219 * traffic for a set of hostnames.
220 *
221 * Changing `grpcRouteId` or `location` replaces the route. Hostnames,
222 * rules, mesh/gateway attachments, description, and labels update in
223 * place.
224 *
225 * ### Creating a GrpcRoute
226 * **Example:** Hostname with a retry policy
227 * ```typescript
228 * const route = yield* GCP.NetworkServices.GrpcRoute("Api", {
229 * hostnames: ["api.example.com"],
230 * rules: [
231 * {
232 * action: {
233 * retryPolicy: { retryConditions: ["unavailable"], numRetries: 2 },
234 * },
235 * },
236 * ],
237 * });
238 * ```
239 *
240 * **Example:** Attach to a gateway
241 * ```typescript
242 * const route = yield* GCP.NetworkServices.GrpcRoute("Api", {
243 * grpcRouteId: "app-grpc",
244 * hostnames: ["api.example.com"],
245 * gateways: [gateway.name],
246 * labels: { env: "prod" },
247 * rules: [
248 * {
249 * matches: [{ method: { grpcService: "api.v1.Orders", grpcMethod: "Get" } }],
250 * action: { destinations: [{ serviceName: backend.selfLink }] },
251 * },
252 * ],
253 * });
254 * ```
255 *
256 * @resource
257 * @category NetworkServices
258 */
259export const GrpcRoute = Resource<GrpcRoute>("GCP.NetworkServices.GrpcRoute");
260
261const toMethod = (
262 value:
263 | GrpcRouteMethodMatch
264 | networkservices.GrpcRouteMethodMatch
265 | undefined,
266): GrpcRouteMethodMatch | undefined => {
267 if (value === undefined) return undefined;
268 return {
269 type: value.type,
270 grpcService: value.grpcService,
271 grpcMethod: value.grpcMethod,
272 caseSensitive: value.caseSensitive,
273 };
274};
275
276const toHeader = (
277 value: GrpcRouteHeaderMatch | networkservices.GrpcRouteHeaderMatch,
278): GrpcRouteHeaderMatch => ({
279 type: value.type,
280 key: value.key,
281 value: value.value,
282});
283
284const toMatch = (
285 value: GrpcRouteRouteMatch | networkservices.GrpcRouteRouteMatch,
286): GrpcRouteRouteMatch => ({
287 method: toMethod(value.method),
288 headers: (value.headers ?? []).map(toHeader),
289});
290
291const toDestination = (
292 value: GrpcRouteDestination | networkservices.GrpcRouteDestination,
293): GrpcRouteDestination => ({
294 serviceName: value.serviceName,
295 weight: value.weight,
296});
297
298const toFault = (
299 value:
300 | GrpcRouteFaultInjectionPolicy
301 | networkservices.GrpcRouteFaultInjectionPolicy
302 | undefined,
303): GrpcRouteFaultInjectionPolicy | undefined => {
304 if (value === undefined) return undefined;
305 return {
306 delay: value.delay
307 ? {
308 fixedDelay: value.delay.fixedDelay,
309 percentage: value.delay.percentage,
310 }
311 : undefined,
312 abort: value.abort
313 ? {
314 httpStatus: value.abort.httpStatus,
315 percentage: value.abort.percentage,
316 }
317 : undefined,
318 };
319};
320
321const toRetry = (
322 value:
323 | GrpcRouteRetryPolicy
324 | networkservices.GrpcRouteRetryPolicy
325 | undefined,
326): GrpcRouteRetryPolicy | undefined => {
327 if (value === undefined) return undefined;
328 return {
329 retryConditions: value.retryConditions ?? [],
330 numRetries: value.numRetries,
331 };
332};
333
334const toAffinity = (
335 value:
336 | GrpcRouteStatefulSessionAffinityPolicy
337 | networkservices.GrpcRouteStatefulSessionAffinityPolicy
338 | undefined,
339): GrpcRouteStatefulSessionAffinityPolicy | undefined => {
340 if (value === undefined) return undefined;
341 return { cookieTtl: value.cookieTtl };
342};
343
344const toAction = (
345 value:
346 | GrpcRouteRouteAction
347 | networkservices.GrpcRouteRouteAction
348 | undefined,
349): GrpcRouteRouteAction | undefined => {
350 if (value === undefined) return undefined;
351 return {
352 destinations: (value.destinations ?? []).map(toDestination),
353 faultInjectionPolicy: toFault(value.faultInjectionPolicy),
354 timeout: value.timeout,
355 retryPolicy: toRetry(value.retryPolicy),
356 statefulSessionAffinity: toAffinity(value.statefulSessionAffinity),
357 idleTimeout: value.idleTimeout,
358 };
359};
360
361const toRule = (
362 value: GrpcRouteRouteRule | networkservices.GrpcRouteRouteRule,
363): GrpcRouteRouteRule => ({
364 matches: (value.matches ?? []).map(toMatch),
365 action: toAction(value.action),
366});
367
368const toAttrs = (route: networkservices.GrpcRoute, project: string) => {
369 const name = route.name ?? "";
370 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
371 return {
372 name,
373 grpcRouteId: parsed.id,
374 project: parsed.project || project,
375 location: parsed.location || DEFAULT_GLOBAL,
376 selfLink: route.selfLink,
377 hostnames: route.hostnames ?? [],
378 rules: (route.rules ?? []).map(toRule),
379 meshes: route.meshes ?? [],
380 gateways: route.gateways ?? [],
381 description: route.description,
382 labels: userLabels(route.labels),
383 createTime: route.createTime,
384 updateTime: route.updateTime,
385 };
386};
387
388const getByName = (name: string) =>
389 networkservices
390 .getProjectsLocationsGrpcRoutes({ name })
391 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
392
393export const GrpcRouteProvider = () =>
394 Provider.succeed(GrpcRoute, {
395 stables: [
396 "name",
397 "grpcRouteId",
398 "project",
399 "location",
400 "selfLink",
401 "createTime",
402 ],
403
404 diff: Effect.fn(function* ({ news, olds, output }) {
405 if (!isResolved(news)) return undefined;
406 const previousId = olds?.grpcRouteId ?? output?.grpcRouteId;
407 const nextId = news.grpcRouteId
408 ? rfc1035(news.grpcRouteId, "grpc-route")
409 : previousId;
410 const previousLocation = normalizeLocation(
411 olds?.location ?? output?.location,
412 DEFAULT_GLOBAL,
413 );
414 const nextLocation = normalizeLocation(
415 news.location ?? olds?.location ?? output?.location,
416 DEFAULT_GLOBAL,
417 );
418 if (
419 (previousId !== undefined &&
420 nextId !== undefined &&
421 nextId !== previousId) ||
422 previousLocation !== nextLocation
423 ) {
424 return { action: "replace" as const };
425 }
426 return undefined;
427 }),
428
429 read: Effect.fn(function* ({ id, olds, output }) {
430 const env = yield* GcpEnvironment.current;
431 const grpcRouteId = yield* toPhysicalId(
432 id,
433 olds?.grpcRouteId,
434 output?.grpcRouteId,
435 "grpc-route",
436 );
437 const location = normalizeLocation(
438 olds?.location ?? output?.location,
439 DEFAULT_GLOBAL,
440 );
441 const name =
442 output?.name ??
443 resourceName(env.project, location, COLLECTION, grpcRouteId);
444 const existing = yield* getByName(name);
445 if (existing === undefined) return undefined;
446 const attrs = toAttrs(existing, env.project);
447 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
448 ? attrs
449 : Unowned(attrs);
450 }),
451
452 list: () =>
453 Effect.gen(function* () {
454 const env = yield* GcpEnvironment.current;
455 const items = yield* collectPages(
456 networkservices.listProjectsLocationsGrpcRoutes.pages({
457 parent: parentOf(env.project, "-"),
458 pageSize: 1000,
459 returnPartialSuccess: true,
460 }),
461 (page) => page.grpcRoutes,
462 );
463 return items
464 .filter((item) => hasAlchemyLabelKeys(item.labels))
465 .map((item) => toAttrs(item, env.project));
466 }),
467
468 reconcile: Effect.fn(function* ({ id, news, output }) {
469 const env = yield* GcpEnvironment.current;
470 const grpcRouteId = yield* toPhysicalId(
471 id,
472 news.grpcRouteId,
473 output?.grpcRouteId,
474 "grpc-route",
475 );
476 const location = normalizeLocation(
477 news.location ?? output?.location,
478 DEFAULT_GLOBAL,
479 );
480 const name = resourceName(env.project, location, COLLECTION, grpcRouteId);
481 const desiredLabels = {
482 ...toLabels(news.labels),
483 ...(yield* createInternalLabels(id)),
484 };
485 const desiredRules = news.rules.map(toRule);
486 const desiredHostnames = news.hostnames;
487 const desiredMeshes = news.meshes ?? [];
488 const desiredGateways = news.gateways ?? [];
489
490 let current = yield* getByName(name);
491
492 if (current === undefined) {
493 const created = yield* networkservices
494 .createProjectsLocationsGrpcRoutes({
495 parent: parentOf(env.project, location),
496 grpcRouteId,
497 body: {
498 labels: desiredLabels,
499 description: news.description,
500 hostnames: desiredHostnames,
501 rules: desiredRules,
502 meshes: desiredMeshes,
503 gateways: desiredGateways,
504 },
505 })
506 .pipe(
507 Effect.retry({
508 while: (error) => error._tag === "Conflict",
509 times: 5,
510 schedule: Schedule.spaced("2 seconds"),
511 }),
512 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
513 );
514 if (created !== undefined) {
515 yield* waitForOperation(created);
516 }
517 current = yield* waitUntilPresent(getByName(name), name);
518 }
519
520 const observedLabels = tagRecord(current.labels);
521 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
522 const labelsChanged = upsert.length > 0 || removed.length > 0;
523 const descriptionChanged =
524 (current.description ?? "") !== (news.description ?? "");
525 const hostnamesChanged = !sameStringList(
526 current.hostnames,
527 desiredHostnames,
528 );
529 const rulesChanged = !sameJson(
530 (current.rules ?? []).map(toRule),
531 desiredRules,
532 );
533 const meshesChanged = !sameStringList(current.meshes, desiredMeshes);
534 const gatewaysChanged = !sameStringList(
535 current.gateways,
536 desiredGateways,
537 );
538
539 const updateMask = changedFields([
540 ["labels", labelsChanged],
541 ["description", descriptionChanged],
542 ["hostnames", hostnamesChanged],
543 ["rules", rulesChanged],
544 ["meshes", meshesChanged],
545 ["gateways", gatewaysChanged],
546 ]);
547
548 if (updateMask.length > 0) {
549 const operation =
550 yield* networkservices.patchProjectsLocationsGrpcRoutes({
551 name: current.name ?? name,
552 updateMask: updateMask.join(","),
553 body: {
554 name: current.name ?? name,
555 labels: desiredLabels,
556 description: news.description,
557 hostnames: desiredHostnames,
558 rules: desiredRules,
559 meshes: desiredMeshes,
560 gateways: desiredGateways,
561 },
562 });
563 yield* waitForOperation(operation);
564 current = yield* waitUntilPresent(
565 getByName(current.name ?? name),
566 current.name ?? name,
567 );
568 }
569
570 return toAttrs(current, env.project);
571 }),
572
573 delete: Effect.fn(function* ({ output }) {
574 const operation = yield* networkservices
575 .deleteProjectsLocationsGrpcRoutes({ name: output.name })
576 .pipe(
577 Effect.retry({
578 while: (error) => error._tag === "Conflict",
579 times: 8,
580 schedule: Schedule.spaced("2 seconds"),
581 }),
582 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
583 );
584 if (operation !== undefined) {
585 yield* waitForOperation(operation, { notFoundOk: true });
586 }
587 yield* waitUntilGone(getByName(output.name), output.name);
588 }),
589 });