1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19export type TargetGrpcProxyProps = {
20 /**
21 * TargetGrpcProxy name (RFC1035, 1-63 characters). If omitted, a unique
22 * name is generated from the stack, stage, and logical id. Changing it
23 * replaces the resource.
24 */
25 targetGrpcProxyName?: string;
26 /**
27 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
28 * prefix so `list` / nuke can find resources (Compute TargetGrpcProxy has
29 * no labels field).
30 */
31 description?: string;
32 /**
33 * URL map this proxy routes to. Accepts a UrlMap self-link, a
34 * `projects/{project}/global/urlMaps/{name}` path, or the UrlMap name.
35 * The referenced backend services must use protocol `GRPC`. Changing
36 * this replaces the resource.
37 */
38 urlMap: string;
39 /**
40 * If true, BackendServices on the URL map may be reached by proxyless
41 * gRPC clients using the `xds:///` target URI (Traffic Director
42 * configuration checks apply). If false, clients use a sidecar proxy
43 * and must not use `xds:///`. Changing this replaces the resource.
44 * @default false
45 */
46 validateForProxyless?: boolean;
47};
48
49export type TargetGrpcProxy = Resource<
50 "GCP.Compute.TargetGrpcProxy",
51 TargetGrpcProxyProps,
52 {
53 /** TargetGrpcProxy name. */
54 targetGrpcProxyName: string;
55 /** Project id. */
56 project: string;
57 /** User description with the Alchemy ownership prefix stripped. */
58 description: string | undefined;
59 /** URL of the attached UrlMap. */
60 urlMap: string;
61 /** Whether proxyless gRPC clients are allowed. */
62 validateForProxyless: boolean;
63 /** Server-defined URL. */
64 selfLink: string | undefined;
65 /** Server-defined URL that includes the numeric id. */
66 selfLinkWithId: string | undefined;
67 /** Server-assigned numeric id. */
68 targetGrpcProxyId: string | undefined;
69 /** Optimistic-locking fingerprint. */
70 fingerprint: string | undefined;
71 /** RFC3339 creation timestamp. */
72 creationTimestamp: string | undefined;
73 /** Resource kind. */
74 kind: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A global Compute Engine target gRPC proxy.
82 *
83 * Target gRPC proxies are referenced by global forwarding rules with
84 * load-balancing scheme `INTERNAL_SELF_MANAGED` (Traffic Director) and
85 * point at a URL map whose backend services use protocol `GRPC`. This
86 * resource maps to the global `targetGrpcProxies` collection. Compute
87 * TargetGrpcProxy has no labels field — Alchemy ownership is stored in
88 * the description so nuke can find leaked proxies.
89 *
90 * ### Creating a Target gRPC Proxy
91 * **Example:** Generated name in front of a gRPC URL map
92 * ```typescript
93 * const backend = yield* GCP.Compute.BackendService("grpc", {
94 * protocol: "GRPC",
95 * loadBalancingScheme: "INTERNAL_SELF_MANAGED",
96 * });
97 * const map = yield* GCP.Compute.UrlMap("grpc", {
98 * defaultService: backend.selfLink,
99 * });
100 * const proxy = yield* GCP.Compute.TargetGrpcProxy("grpc", {
101 * urlMap: map.urlMapName,
102 * });
103 * ```
104 *
105 * **Example:** Explicit name, description, and proxyless validation
106 * ```typescript
107 * const proxy = yield* GCP.Compute.TargetGrpcProxy("grpc", {
108 * targetGrpcProxyName: "app-grpc",
109 * description: "traffic director frontend",
110 * urlMap: map.selfLink,
111 * validateForProxyless: true,
112 * });
113 * ```
114 *
115 * @resource
116 * @category Compute
117 */
118export const TargetGrpcProxy = Resource<TargetGrpcProxy>(
119 "GCP.Compute.TargetGrpcProxy",
120);
121
122export class TargetGrpcProxyNotResolved extends Data.TaggedError(
123 "GCP.Compute.TargetGrpcProxyNotResolved",
124)<{
125 targetGrpcProxyName: string;
126}> {}
127
128const toName = (id: string, name: string | undefined, existing?: string) =>
129 Effect.gen(function* () {
130 if (name !== undefined) return name;
131 if (existing !== undefined) return existing;
132 const generated = yield* createPhysicalName({
133 id,
134 maxLength: 63,
135 lowercase: true,
136 });
137 return /^[a-z]/.test(generated) ? generated : `g${generated}`.slice(0, 63);
138 });
139
140const encodeDescription = (
141 labels: Record<string, string>,
142 description: string | undefined,
143): string => {
144 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
145 return description ? `${marker}\n${description}` : marker;
146};
147
148const parseDescription = (
149 description: string | undefined,
150): {
151 labels: Record<string, string>;
152 description: string | undefined;
153} => {
154 if (!description?.startsWith("[alchemy ")) {
155 return { labels: {}, description };
156 }
157 const end = description.indexOf("]");
158 if (end < 0) return { labels: {}, description };
159 const labels: Record<string, string> = {};
160 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
161 const eq = part.indexOf("=");
162 if (eq > 0) {
163 labels[part.slice(0, eq)] = part.slice(eq + 1);
164 }
165 }
166 const rest = description.slice(end + 1).replace(/^\n/, "");
167 return { labels, description: rest.length > 0 ? rest : undefined };
168};
169
170const resourceTail = (value: string | undefined): string => {
171 if (value === undefined || value.length === 0) return "";
172 const parts = value.split("/").filter((part) => part.length > 0);
173 return parts[parts.length - 1] ?? "";
174};
175
176const toUrlMapRef = (project: string, urlMap: string): string => {
177 if (urlMap.includes("/")) return urlMap;
178 return `projects/${project}/global/urlMaps/${urlMap}`;
179};
180
181const toAttrs = (proxy: compute.TargetGrpcProxy, project: string) => {
182 const parsed = parseDescription(proxy.description);
183 return {
184 targetGrpcProxyName: proxy.name ?? proxy.id ?? "",
185 project,
186 description: parsed.description,
187 urlMap: proxy.urlMap ?? "",
188 validateForProxyless: proxy.validateForProxyless === true,
189 selfLink: proxy.selfLink,
190 selfLinkWithId: proxy.selfLinkWithId,
191 targetGrpcProxyId: proxy.id,
192 fingerprint: proxy.fingerprint,
193 creationTimestamp: proxy.creationTimestamp,
194 kind: proxy.kind,
195 };
196};
197
198const getByName = (project: string, targetGrpcProxy: string) =>
199 compute
200 .getTargetGrpcProxies({ project, targetGrpcProxy })
201 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
202
203export const TargetGrpcProxyProvider = () =>
204 Provider.succeed(TargetGrpcProxy, {
205 stables: [
206 "targetGrpcProxyName",
207 "project",
208 "targetGrpcProxyId",
209 "selfLink",
210 "selfLinkWithId",
211 "creationTimestamp",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const previous = olds?.targetGrpcProxyName ?? output?.targetGrpcProxyName;
217 const next = news.targetGrpcProxyName;
218 if (previous !== undefined && next !== undefined && previous !== next) {
219 return { action: "replace" as const, deleteFirst: true };
220 }
221 const previousUrlMap = olds?.urlMap ?? output?.urlMap;
222 if (
223 previousUrlMap !== undefined &&
224 resourceTail(previousUrlMap) !== resourceTail(news.urlMap)
225 ) {
226 return { action: "replace" as const, deleteFirst: true };
227 }
228 const previousProxyless =
229 olds?.validateForProxyless ?? output?.validateForProxyless ?? false;
230 const nextProxyless = news.validateForProxyless ?? false;
231 if (previousProxyless !== nextProxyless) {
232 return { action: "replace" as const, deleteFirst: true };
233 }
234 return undefined;
235 }),
236
237 read: Effect.fn(function* ({ id, olds, output }) {
238 const env = yield* GcpEnvironment.current;
239 const targetGrpcProxyName = yield* toName(
240 id,
241 olds?.targetGrpcProxyName,
242 output?.targetGrpcProxyName,
243 );
244 const existing = yield* getByName(env.project, targetGrpcProxyName);
245 if (existing === undefined) return undefined;
246 const attrs = toAttrs(existing, env.project);
247 const { labels } = parseDescription(existing.description);
248 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
249 }),
250
251 list: () =>
252 Effect.gen(function* () {
253 const env = yield* GcpEnvironment.current;
254 return yield* compute.listTargetGrpcProxies
255 .items({ project: env.project, maxResults: 500 })
256 .pipe(
257 Stream.filter((proxy) => {
258 const { labels } = parseDescription(proxy.description);
259 return Object.keys(labels).some((key) =>
260 key.startsWith("alchemy-"),
261 );
262 }),
263 Stream.map((proxy) => toAttrs(proxy, env.project)),
264 Stream.runCollect,
265 Effect.map((chunk) => Array.from(chunk)),
266 );
267 }),
268
269 reconcile: Effect.fn(function* ({ id, news, output }) {
270 const env = yield* GcpEnvironment.current;
271 const targetGrpcProxyName = yield* toName(
272 id,
273 news.targetGrpcProxyName,
274 output?.targetGrpcProxyName,
275 );
276 const ownership = yield* createInternalLabels(id);
277 const desiredDescription = encodeDescription(ownership, news.description);
278 const desiredUrlMap = toUrlMapRef(env.project, news.urlMap);
279 const desiredProxyless = news.validateForProxyless === true;
280
281 let current = yield* getByName(env.project, targetGrpcProxyName);
282
283 if (current === undefined) {
284 const body: compute.TargetGrpcProxy = {
285 name: targetGrpcProxyName,
286 description: desiredDescription,
287 urlMap: desiredUrlMap,
288 };
289 if (news.validateForProxyless !== undefined) {
290 body.validateForProxyless = news.validateForProxyless;
291 }
292 yield* compute
293 .insertTargetGrpcProxies({
294 project: env.project,
295 body,
296 })
297 .pipe(
298 Effect.flatMap((operation) =>
299 waitGlobalOperation(env.project, operation),
300 ),
301 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
302 );
303 current = yield* getByName(env.project, targetGrpcProxyName);
304 }
305
306 if (current === undefined) {
307 return yield* new TargetGrpcProxyNotResolved({ targetGrpcProxyName });
308 }
309
310 const descriptionChanged =
311 (current.description ?? "") !== desiredDescription;
312 const urlMapChanged =
313 resourceTail(current.urlMap) !== resourceTail(desiredUrlMap);
314 const proxylessChanged =
315 (current.validateForProxyless === true) !== desiredProxyless;
316
317 if (descriptionChanged || urlMapChanged || proxylessChanged) {
318 const body: compute.TargetGrpcProxy = {
319 fingerprint: current.fingerprint,
320 description: desiredDescription,
321 };
322 if (urlMapChanged) {
323 body.urlMap = desiredUrlMap;
324 }
325 if (proxylessChanged) {
326 body.validateForProxyless = desiredProxyless;
327 }
328 yield* compute
329 .patchTargetGrpcProxies({
330 project: env.project,
331 targetGrpcProxy: targetGrpcProxyName,
332 body,
333 })
334 .pipe(
335 Effect.flatMap((operation) =>
336 waitGlobalOperation(env.project, operation),
337 ),
338 );
339 current = yield* getByName(env.project, targetGrpcProxyName);
340 if (current === undefined) {
341 return yield* new TargetGrpcProxyNotResolved({
342 targetGrpcProxyName,
343 });
344 }
345 }
346
347 return toAttrs(current, env.project);
348 }),
349
350 delete: Effect.fn(function* ({ output }) {
351 const env = yield* GcpEnvironment.current;
352 const operation = yield* compute
353 .deleteTargetGrpcProxies({
354 project: env.project,
355 targetGrpcProxy: output.targetGrpcProxyName,
356 })
357 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
358 if (operation !== undefined) {
359 yield* waitGlobalOperation(env.project, operation).pipe(
360 Effect.catchTag("NotFound", () => Effect.void),
361 );
362 }
363 }),
364 });