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 changedFields,
19 collectPages,
20 hasAlchemyLabelKeys,
21 normalizeLocation,
22 parentOf,
23 parseName,
24 resourceName,
25 rfc1035,
26 sameJson,
27 sameStringList,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "agentGateways";
36
37export type AgentGatewayGovernedAccessPath =
38 | networkservices.AgentGatewayGoogleManagedGovernedAccessPathEnum
39 | (string & {});
40
41export type AgentGatewayProtocol =
42 | networkservices.AgentGatewayProtocolsItemEnum
43 | (string & {});
44
45export type AgentGatewayGoogleManaged = {
46 /**
47 * Operating mode of a Google-managed Agent Gateway.
48 */
49 governedAccessPath?: AgentGatewayGovernedAccessPath;
50};
51
52export type AgentGatewaySelfManaged = {
53 /**
54 * Existing Application Load Balancer or Secure Web Proxy resource URI
55 * in the same project and location.
56 */
57 resourceUri?: string;
58};
59
60export type AgentGatewayNetworkConfigEgress = {
61 /** Network Attachment URI used for private VPC egress. */
62 networkAttachment?: string;
63};
64
65export type AgentGatewayDnsPeeringConfig = {
66 /** Domain names whose queries are forwarded to the target network. */
67 domains?: string[];
68 /** Project id that owns the target network. */
69 targetProject?: string;
70 /**
71 * Target network in
72 * `projects/{project}/global/networks/{network}` form.
73 */
74 targetNetwork?: string;
75};
76
77export type AgentGatewayNetworkConfig = {
78 /** PSC-interface network attachment for private VPC connectivity. */
79 egress?: AgentGatewayNetworkConfigEgress;
80 /** DNS peering configuration for the user VPC. */
81 dnsPeeringConfig?: AgentGatewayDnsPeeringConfig;
82};
83
84export type AgentGatewayOutputCard = {
85 /** mTLS endpoint associated with this Agent Gateway. */
86 mtlsEndpoint?: string;
87 /** Root certificates agents use to validate this Agent Gateway. */
88 rootCertificates?: string[];
89 /** Service account used by Service Extensions. */
90 serviceExtensionsServiceAccount?: string;
91};
92
93export type AgentGatewayProps = {
94 /**
95 * AgentGateway id (the `{agentGateway}` segment of
96 * `projects/{project}/locations/{location}/agentGateways/{agentGateway}`).
97 * If omitted, a unique name is generated from the stack, stage, and
98 * logical id. Must be 1-63 characters and not start with a number.
99 * Immutable — changing it replaces the gateway.
100 */
101 agentGatewayId?: string;
102 /**
103 * Location (`us-central1`, …). Immutable — changing it replaces the
104 * gateway. `US-CENTRAL1` is accepted and normalized to `us-central1`.
105 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
106 */
107 location?: string;
108 /**
109 * Google-managed deployment. Mutually exclusive with `selfManaged`.
110 */
111 googleManaged?: AgentGatewayGoogleManaged;
112 /**
113 * Attach to an existing Application Load Balancer or Secure Web Proxy.
114 * Mutually exclusive with `googleManaged`.
115 */
116 selfManaged?: AgentGatewaySelfManaged;
117 /**
118 * Agent registries containing agents, MCP servers, and tools. Must be
119 * project-scoped
120 * `//agentregistry.googleapis.com/projects/{project}/locations/{location}/...`
121 * names.
122 */
123 registries?: string[];
124 /**
125 * Deprecated protocol list. Prefer `googleManaged` / `selfManaged`.
126 */
127 protocols?: AgentGatewayProtocol[];
128 /**
129 * Network configuration for private VPC connectivity.
130 */
131 networkConfig?: AgentGatewayNetworkConfig;
132 /**
133 * Human-readable description. Max length 1024 characters.
134 */
135 description?: string;
136 /**
137 * User labels. Alchemy ownership labels are merged in automatically.
138 */
139 labels?: Record<string, string>;
140};
141
142export type AgentGateway = Resource<
143 "GCP.NetworkServices.AgentGateway",
144 AgentGatewayProps,
145 {
146 /** Full resource name `projects/{project}/locations/{location}/agentGateways/{agentGateway}`. */
147 name: string;
148 /** AgentGateway id (last path segment). */
149 agentGatewayId: string;
150 /** Project id. */
151 project: string;
152 /** Location id (`us-central1`, …). */
153 location: string;
154 /** Google-managed deployment, if set. */
155 googleManaged: AgentGatewayGoogleManaged | undefined;
156 /** Self-managed proxy attachment, if set. */
157 selfManaged: AgentGatewaySelfManaged | undefined;
158 /** Agent registry resource names. */
159 registries: string[];
160 /** Deprecated protocol list. */
161 protocols: string[];
162 /** Network configuration, if set. */
163 networkConfig: AgentGatewayNetworkConfig | undefined;
164 /** Output-only Agent Gateway card. */
165 agentGatewayCard: AgentGatewayOutputCard | undefined;
166 /** User-provided description. */
167 description: string | undefined;
168 /** User labels (Alchemy ownership labels stripped). */
169 labels: Record<string, string>;
170 /** Server etag. */
171 etag: string | undefined;
172 /** RFC3339 creation timestamp. */
173 createTime: string | undefined;
174 /** RFC3339 last-update timestamp. */
175 updateTime: string | undefined;
176 },
177 never,
178 Providers
179>;
180
181/**
182 * A Network Services Agent Gateway — the proxy that governs access to
183 * agents, MCP servers, and tools.
184 *
185 * Changing `agentGatewayId` or `location` replaces the gateway.
186 * Description, labels, registries, network config, and deployment mode
187 * update in place.
188 *
189 * ### Creating an AgentGateway
190 * **Example:** Google-managed gateway
191 * ```typescript
192 * const gateway = yield* GCP.NetworkServices.AgentGateway("Agents", {
193 * googleManaged: { governedAccessPath: "AGENT_TO_ANYWHERE" },
194 * });
195 * ```
196 *
197 * **Example:** Named gateway with labels
198 * ```typescript
199 * const gateway = yield* GCP.NetworkServices.AgentGateway("Agents", {
200 * agentGatewayId: "app-agents",
201 * location: "us-central1",
202 * description: "prod agents",
203 * labels: { env: "prod" },
204 * googleManaged: { governedAccessPath: "CLIENT_TO_AGENT" },
205 * });
206 * ```
207 *
208 * @resource
209 * @category NetworkServices
210 */
211export const AgentGateway = Resource<AgentGateway>(
212 "GCP.NetworkServices.AgentGateway",
213);
214
215const toGoogleManaged = (
216 value:
217 | AgentGatewayGoogleManaged
218 | networkservices.AgentGatewayGoogleManaged
219 | undefined,
220): AgentGatewayGoogleManaged | undefined => {
221 if (value === undefined) return undefined;
222 return { governedAccessPath: value.governedAccessPath };
223};
224
225const toSelfManaged = (
226 value:
227 | AgentGatewaySelfManaged
228 | networkservices.AgentGatewaySelfManaged
229 | undefined,
230): AgentGatewaySelfManaged | undefined => {
231 if (value === undefined) return undefined;
232 return { resourceUri: value.resourceUri };
233};
234
235const toNetworkConfig = (
236 value:
237 | AgentGatewayNetworkConfig
238 | networkservices.AgentGatewayNetworkConfig
239 | undefined,
240): AgentGatewayNetworkConfig | undefined => {
241 if (value === undefined) return undefined;
242 return {
243 egress: value.egress
244 ? { networkAttachment: value.egress.networkAttachment }
245 : undefined,
246 dnsPeeringConfig: value.dnsPeeringConfig
247 ? {
248 domains: value.dnsPeeringConfig.domains ?? [],
249 targetProject: value.dnsPeeringConfig.targetProject,
250 targetNetwork: value.dnsPeeringConfig.targetNetwork,
251 }
252 : undefined,
253 };
254};
255
256const toCard = (
257 value: networkservices.AgentGatewayAgentGatewayOutputCard | undefined,
258): AgentGatewayOutputCard | undefined => {
259 if (value === undefined) return undefined;
260 return {
261 mtlsEndpoint: value.mtlsEndpoint,
262 rootCertificates: value.rootCertificates ?? [],
263 serviceExtensionsServiceAccount: value.serviceExtensionsServiceAccount,
264 };
265};
266
267const toAttrs = (
268 gateway: networkservices.AgentGateway,
269 project: string,
270 region: string,
271) => {
272 const name = gateway.name ?? "";
273 const parsed = parseName(name, COLLECTION, region);
274 return {
275 name,
276 agentGatewayId: parsed.id,
277 project: parsed.project || project,
278 location: parsed.location || region,
279 googleManaged: toGoogleManaged(gateway.googleManaged),
280 selfManaged: toSelfManaged(gateway.selfManaged),
281 registries: gateway.registries ?? [],
282 protocols: gateway.protocols ?? [],
283 networkConfig: toNetworkConfig(gateway.networkConfig),
284 agentGatewayCard: toCard(gateway.agentGatewayCard),
285 description: gateway.description,
286 labels: userLabels(gateway.labels),
287 etag: gateway.etag,
288 createTime: gateway.createTime,
289 updateTime: gateway.updateTime,
290 };
291};
292
293const getByName = (name: string) =>
294 networkservices
295 .getProjectsLocationsAgentGateways({ name })
296 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
297
298export const AgentGatewayProvider = () =>
299 Provider.succeed(AgentGateway, {
300 stables: ["name", "agentGatewayId", "project", "location", "createTime"],
301
302 diff: Effect.fn(function* ({ news, olds, output }) {
303 if (!isResolved(news)) return undefined;
304 const env = yield* GcpEnvironment.current;
305 const previousId = olds?.agentGatewayId ?? output?.agentGatewayId;
306 const nextId = news.agentGatewayId
307 ? rfc1035(news.agentGatewayId, "agent-gateway")
308 : previousId;
309 const previousLocation = normalizeLocation(
310 olds?.location ?? output?.location,
311 env.region,
312 );
313 const nextLocation = normalizeLocation(
314 news.location ?? olds?.location ?? output?.location,
315 env.region,
316 );
317 if (
318 (previousId !== undefined &&
319 nextId !== undefined &&
320 nextId !== previousId) ||
321 previousLocation !== nextLocation
322 ) {
323 return { action: "replace" as const };
324 }
325 return undefined;
326 }),
327
328 read: Effect.fn(function* ({ id, olds, output }) {
329 const env = yield* GcpEnvironment.current;
330 const agentGatewayId = yield* toPhysicalId(
331 id,
332 olds?.agentGatewayId,
333 output?.agentGatewayId,
334 "agent-gateway",
335 );
336 const location = normalizeLocation(
337 olds?.location ?? output?.location,
338 env.region,
339 );
340 const name =
341 output?.name ??
342 resourceName(env.project, location, COLLECTION, agentGatewayId);
343 const existing = yield* getByName(name);
344 if (existing === undefined) return undefined;
345 const attrs = toAttrs(existing, env.project, env.region);
346 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
347 ? attrs
348 : Unowned(attrs);
349 }),
350
351 list: () =>
352 Effect.gen(function* () {
353 const env = yield* GcpEnvironment.current;
354 const items = yield* collectPages(
355 networkservices.listProjectsLocationsAgentGateways.pages({
356 parent: parentOf(env.project, "-"),
357 pageSize: 1000,
358 returnPartialSuccess: true,
359 }),
360 (page) => page.agentGateways,
361 );
362 return items
363 .filter((item) => hasAlchemyLabelKeys(item.labels))
364 .map((item) => toAttrs(item, env.project, env.region));
365 }),
366
367 reconcile: Effect.fn(function* ({ id, news, output }) {
368 const env = yield* GcpEnvironment.current;
369 const agentGatewayId = yield* toPhysicalId(
370 id,
371 news.agentGatewayId,
372 output?.agentGatewayId,
373 "agent-gateway",
374 );
375 const location = normalizeLocation(
376 news.location ?? output?.location,
377 env.region,
378 );
379 const name = resourceName(
380 env.project,
381 location,
382 COLLECTION,
383 agentGatewayId,
384 );
385 const desiredLabels = {
386 ...toLabels(news.labels),
387 ...(yield* createInternalLabels(id)),
388 };
389 const desiredGoogle = toGoogleManaged(news.googleManaged);
390 const desiredSelf = toSelfManaged(news.selfManaged);
391 const desiredNetwork = toNetworkConfig(news.networkConfig);
392 const desiredRegistries = news.registries ?? [];
393 const desiredProtocols = news.protocols ?? [];
394
395 let current = yield* getByName(name);
396
397 if (current === undefined) {
398 const created = yield* networkservices
399 .createProjectsLocationsAgentGateways({
400 parent: parentOf(env.project, location),
401 agentGatewayId,
402 body: {
403 labels: desiredLabels,
404 description: news.description,
405 googleManaged: desiredGoogle,
406 selfManaged: desiredSelf,
407 registries: desiredRegistries,
408 protocols: desiredProtocols,
409 networkConfig: desiredNetwork,
410 },
411 })
412 .pipe(
413 Effect.retry({
414 while: (error) => error._tag === "Conflict",
415 times: 5,
416 schedule: Schedule.spaced("2 seconds"),
417 }),
418 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
419 );
420 if (created !== undefined) {
421 yield* waitForOperation(created);
422 }
423 current = yield* waitUntilPresent(getByName(name), name);
424 }
425
426 const observedLabels = tagRecord(current.labels);
427 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
428 const labelsChanged = upsert.length > 0 || removed.length > 0;
429 const descriptionChanged =
430 (current.description ?? "") !== (news.description ?? "");
431 const googleChanged = !sameJson(
432 toGoogleManaged(current.googleManaged),
433 desiredGoogle,
434 );
435 const selfChanged = !sameJson(
436 toSelfManaged(current.selfManaged),
437 desiredSelf,
438 );
439 const registriesChanged = !sameStringList(
440 current.registries,
441 desiredRegistries,
442 );
443 const protocolsChanged = !sameStringList(
444 current.protocols,
445 desiredProtocols,
446 );
447 const networkChanged = !sameJson(
448 toNetworkConfig(current.networkConfig),
449 desiredNetwork,
450 );
451
452 const updateMask = changedFields([
453 ["labels", labelsChanged],
454 ["description", descriptionChanged],
455 ["googleManaged", googleChanged],
456 ["selfManaged", selfChanged],
457 ["registries", registriesChanged],
458 ["protocols", protocolsChanged],
459 ["networkConfig", networkChanged],
460 ]);
461
462 if (updateMask.length > 0) {
463 const operation =
464 yield* networkservices.patchProjectsLocationsAgentGateways({
465 name: current.name ?? name,
466 updateMask: updateMask.join(","),
467 body: {
468 name: current.name ?? name,
469 etag: current.etag,
470 labels: desiredLabels,
471 description: news.description,
472 googleManaged: desiredGoogle,
473 selfManaged: desiredSelf,
474 registries: desiredRegistries,
475 protocols: desiredProtocols,
476 networkConfig: desiredNetwork,
477 },
478 });
479 yield* waitForOperation(operation);
480 current = yield* waitUntilPresent(
481 getByName(current.name ?? name),
482 current.name ?? name,
483 );
484 }
485
486 return toAttrs(current, env.project, env.region);
487 }),
488
489 delete: Effect.fn(function* ({ output }) {
490 const operation = yield* networkservices
491 .deleteProjectsLocationsAgentGateways({
492 name: output.name,
493 etag: output.etag,
494 })
495 .pipe(
496 Effect.retry({
497 while: (error) => error._tag === "Conflict",
498 times: 8,
499 schedule: Schedule.spaced("2 seconds"),
500 }),
501 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
502 );
503 if (operation !== undefined) {
504 yield* waitForOperation(operation, { notFoundOk: true });
505 }
506 yield* waitUntilGone(getByName(output.name), output.name);
507 }),
508 });