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 = "endpointPolicies";
37const DEFAULT_TYPE = "SIDECAR_PROXY";
38
39export type EndpointPolicyType =
40 | networkservices.EndpointPolicyTypeEnum
41 | (string & {});
42
43export type EndpointPolicyMetadataLabel = {
44 /** Label name presented as a key in xDS node metadata. */
45 labelName?: string;
46 /** Label value presented as the matching value. */
47 labelValue?: string;
48};
49
50export type EndpointPolicyMetadataLabelMatcher = {
51 /**
52 * How labels are combined. `MATCH_ANY` matches if any listed label is
53 * present; `MATCH_ALL` requires every listed label.
54 */
55 metadataLabelMatchCriteria?: string;
56 /**
57 * Label pairs that must match xDS node metadata. At most 64 entries.
58 * Empty with `MATCH_ANY` is a wildcard.
59 */
60 metadataLabels?: EndpointPolicyMetadataLabel[];
61};
62
63export type EndpointPolicyEndpointMatcher = {
64 /** Matcher based on xDS client node metadata. */
65 metadataLabelMatcher?: EndpointPolicyMetadataLabelMatcher;
66};
67
68export type EndpointPolicyTrafficPortSelector = {
69 /**
70 * Ports, ranges (`80-90`), named ports, or `*` for all ports. Empty
71 * selects every port.
72 */
73 ports?: string[];
74};
75
76export type EndpointPolicyProps = {
77 /**
78 * EndpointPolicy id (the `{endpointPolicy}` segment of
79 * `projects/{project}/locations/{location}/endpointPolicies/{endpointPolicy}`).
80 * If omitted, a unique name is generated from the stack, stage, and
81 * logical id. Must be 1-63 characters and not start with a number.
82 * Immutable — changing it replaces the policy.
83 */
84 endpointPolicyId?: string;
85 /**
86 * Location (`global`, `us-central1`, …). Immutable — changing it
87 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
88 * `us-central1`.
89 * @default "global"
90 */
91 location?: string;
92 /**
93 * Endpoint policy type. Used to validate configuration.
94 * @default "SIDECAR_PROXY"
95 */
96 type?: EndpointPolicyType;
97 /**
98 * Matcher that selects endpoints this policy applies to.
99 */
100 endpointMatcher?: EndpointPolicyEndpointMatcher;
101 /**
102 * Port selector for matched endpoints. Omitted applies to every port.
103 */
104 trafficPortSelector?: EndpointPolicyTrafficPortSelector;
105 /**
106 * AuthorizationPolicy resource URL applied to inbound traffic. Empty
107 * disables authorization.
108 */
109 authorizationPolicy?: string;
110 /**
111 * ServerTlsPolicy URL used to terminate inbound TLS. Empty leaves the
112 * endpoint open.
113 */
114 serverTlsPolicy?: string;
115 /**
116 * ClientTlsPolicy URL applied to traffic from the sidecar proxy to the
117 * backend. Applicable only when `type` is `SIDECAR_PROXY`.
118 */
119 clientTlsPolicy?: string;
120 /**
121 * Human-readable description. Max length 1024 characters.
122 */
123 description?: string;
124 /**
125 * User labels. Alchemy ownership labels are merged in automatically.
126 */
127 labels?: Record<string, string>;
128};
129
130export type EndpointPolicy = Resource<
131 "GCP.NetworkServices.EndpointPolicy",
132 EndpointPolicyProps,
133 {
134 /** Full resource name `projects/{project}/locations/{location}/endpointPolicies/{endpointPolicy}`. */
135 name: string;
136 /** EndpointPolicy id (last path segment). */
137 endpointPolicyId: string;
138 /** Project id. */
139 project: string;
140 /** Location id (`global`, `us-central1`, …). */
141 location: string;
142 /** Endpoint policy type. */
143 type: string;
144 /** Matcher currently configured. */
145 endpointMatcher: EndpointPolicyEndpointMatcher | undefined;
146 /** Port selector currently configured. */
147 trafficPortSelector: EndpointPolicyTrafficPortSelector | undefined;
148 /** AuthorizationPolicy URL, if set. */
149 authorizationPolicy: string | undefined;
150 /** ServerTlsPolicy URL, if set. */
151 serverTlsPolicy: string | undefined;
152 /** ClientTlsPolicy URL, if set. */
153 clientTlsPolicy: string | undefined;
154 /** User-provided description. */
155 description: string | undefined;
156 /** User labels (Alchemy ownership labels stripped). */
157 labels: Record<string, string>;
158 /** RFC3339 creation timestamp. */
159 createTime: string | undefined;
160 /** RFC3339 last-update timestamp. */
161 updateTime: string | undefined;
162 },
163 never,
164 Providers
165>;
166
167/**
168 * A Network Services EndpointPolicy — applies TLS and authorization
169 * configuration to Traffic Director endpoints that match a metadata
170 * selector.
171 *
172 * Changing `endpointPolicyId`, `location`, or `type` replaces the
173 * policy. Description, labels, matcher, port selector, and policy URLs
174 * update in place.
175 *
176 * ### Creating an EndpointPolicy
177 * **Example:** Sidecar proxy with a metadata matcher
178 * ```typescript
179 * const policy = yield* GCP.NetworkServices.EndpointPolicy("Sidecar", {
180 * type: "SIDECAR_PROXY",
181 * endpointMatcher: {
182 * metadataLabelMatcher: {
183 * metadataLabelMatchCriteria: "MATCH_ANY",
184 * metadataLabels: [{ labelName: "app", labelValue: "web" }],
185 * },
186 * },
187 * });
188 * ```
189 *
190 * **Example:** Named policy with a port selector
191 * ```typescript
192 * const policy = yield* GCP.NetworkServices.EndpointPolicy("Sidecar", {
193 * endpointPolicyId: "app-sidecar",
194 * description: "prod sidecars",
195 * labels: { env: "prod" },
196 * type: "SIDECAR_PROXY",
197 * trafficPortSelector: { ports: ["8080"] },
198 * endpointMatcher: {
199 * metadataLabelMatcher: {
200 * metadataLabelMatchCriteria: "MATCH_ANY",
201 * metadataLabels: [{ labelName: "app", labelValue: "web" }],
202 * },
203 * },
204 * });
205 * ```
206 *
207 * @resource
208 * @category NetworkServices
209 */
210export const EndpointPolicy = Resource<EndpointPolicy>(
211 "GCP.NetworkServices.EndpointPolicy",
212);
213
214const toLabel = (
215 label:
216 | EndpointPolicyMetadataLabel
217 | networkservices.EndpointMatcherMetadataLabelMatcherMetadataLabels,
218): EndpointPolicyMetadataLabel => ({
219 labelName: label.labelName,
220 labelValue: label.labelValue,
221});
222
223const toMatcher = (
224 matcher:
225 | EndpointPolicyEndpointMatcher
226 | networkservices.EndpointMatcher
227 | undefined,
228): EndpointPolicyEndpointMatcher | undefined => {
229 const metadata = matcher?.metadataLabelMatcher;
230 if (metadata === undefined) return undefined;
231 return {
232 metadataLabelMatcher: {
233 metadataLabelMatchCriteria: metadata.metadataLabelMatchCriteria,
234 metadataLabels: (metadata.metadataLabels ?? []).map(toLabel),
235 },
236 };
237};
238
239const toPortSelector = (
240 selector:
241 | EndpointPolicyTrafficPortSelector
242 | networkservices.TrafficPortSelector
243 | undefined,
244): EndpointPolicyTrafficPortSelector | undefined => {
245 if (selector === undefined) return undefined;
246 return { ports: selector.ports ?? [] };
247};
248
249const toAttrs = (policy: networkservices.EndpointPolicy, project: string) => {
250 const name = policy.name ?? "";
251 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
252 return {
253 name,
254 endpointPolicyId: parsed.id,
255 project: parsed.project || project,
256 location: parsed.location || DEFAULT_GLOBAL,
257 type: policy.type ?? DEFAULT_TYPE,
258 endpointMatcher: toMatcher(policy.endpointMatcher),
259 trafficPortSelector: toPortSelector(policy.trafficPortSelector),
260 authorizationPolicy: policy.authorizationPolicy,
261 serverTlsPolicy: policy.serverTlsPolicy,
262 clientTlsPolicy: policy.clientTlsPolicy,
263 description: policy.description,
264 labels: userLabels(policy.labels),
265 createTime: policy.createTime,
266 updateTime: policy.updateTime,
267 };
268};
269
270const getByName = (name: string) =>
271 networkservices
272 .getProjectsLocationsEndpointPolicies({ name })
273 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
274
275export const EndpointPolicyProvider = () =>
276 Provider.succeed(EndpointPolicy, {
277 stables: ["name", "endpointPolicyId", "project", "location", "createTime"],
278
279 diff: Effect.fn(function* ({ news, olds, output }) {
280 if (!isResolved(news)) return undefined;
281 const previousId = olds?.endpointPolicyId ?? output?.endpointPolicyId;
282 const nextId = news.endpointPolicyId
283 ? rfc1035(news.endpointPolicyId, "endpoint-policy")
284 : previousId;
285 const previousLocation = normalizeLocation(
286 olds?.location ?? output?.location,
287 DEFAULT_GLOBAL,
288 );
289 const nextLocation = normalizeLocation(
290 news.location ?? olds?.location ?? output?.location,
291 DEFAULT_GLOBAL,
292 );
293 const previousType = olds?.type ?? output?.type ?? DEFAULT_TYPE;
294 const nextType = news.type ?? previousType;
295 if (
296 (previousId !== undefined &&
297 nextId !== undefined &&
298 nextId !== previousId) ||
299 previousLocation !== nextLocation ||
300 previousType !== nextType
301 ) {
302 return { action: "replace" as const };
303 }
304 return undefined;
305 }),
306
307 read: Effect.fn(function* ({ id, olds, output }) {
308 const env = yield* GcpEnvironment.current;
309 const endpointPolicyId = yield* toPhysicalId(
310 id,
311 olds?.endpointPolicyId,
312 output?.endpointPolicyId,
313 "endpoint-policy",
314 );
315 const location = normalizeLocation(
316 olds?.location ?? output?.location,
317 DEFAULT_GLOBAL,
318 );
319 const name =
320 output?.name ??
321 resourceName(env.project, location, COLLECTION, endpointPolicyId);
322 const existing = yield* getByName(name);
323 if (existing === undefined) return undefined;
324 const attrs = toAttrs(existing, env.project);
325 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
326 ? attrs
327 : Unowned(attrs);
328 }),
329
330 list: () =>
331 Effect.gen(function* () {
332 const env = yield* GcpEnvironment.current;
333 const items = yield* collectPages(
334 networkservices.listProjectsLocationsEndpointPolicies.pages({
335 parent: parentOf(env.project, "-"),
336 pageSize: 1000,
337 returnPartialSuccess: true,
338 }),
339 (page) => page.endpointPolicies,
340 );
341 return items
342 .filter((item) => hasAlchemyLabelKeys(item.labels))
343 .map((item) => toAttrs(item, env.project));
344 }),
345
346 reconcile: Effect.fn(function* ({ id, news, output }) {
347 const env = yield* GcpEnvironment.current;
348 const endpointPolicyId = yield* toPhysicalId(
349 id,
350 news.endpointPolicyId,
351 output?.endpointPolicyId,
352 "endpoint-policy",
353 );
354 const location = normalizeLocation(
355 news.location ?? output?.location,
356 DEFAULT_GLOBAL,
357 );
358 const name = resourceName(
359 env.project,
360 location,
361 COLLECTION,
362 endpointPolicyId,
363 );
364 const desiredLabels = {
365 ...toLabels(news.labels),
366 ...(yield* createInternalLabels(id)),
367 };
368 const type = news.type ?? DEFAULT_TYPE;
369 const desiredMatcher = toMatcher(news.endpointMatcher);
370 const desiredPorts = toPortSelector(news.trafficPortSelector);
371
372 let current = yield* getByName(name);
373
374 if (current === undefined) {
375 const created = yield* networkservices
376 .createProjectsLocationsEndpointPolicies({
377 parent: parentOf(env.project, location),
378 endpointPolicyId,
379 body: {
380 type,
381 labels: desiredLabels,
382 description: news.description,
383 endpointMatcher: desiredMatcher,
384 trafficPortSelector: desiredPorts,
385 authorizationPolicy: news.authorizationPolicy,
386 serverTlsPolicy: news.serverTlsPolicy,
387 clientTlsPolicy: news.clientTlsPolicy,
388 },
389 })
390 .pipe(
391 Effect.retry({
392 while: (error) => error._tag === "Conflict",
393 times: 5,
394 schedule: Schedule.spaced("2 seconds"),
395 }),
396 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
397 );
398 if (created !== undefined) {
399 yield* waitForOperation(created);
400 }
401 current = yield* waitUntilPresent(getByName(name), name);
402 }
403
404 const observedLabels = tagRecord(current.labels);
405 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
406 const labelsChanged = upsert.length > 0 || removed.length > 0;
407 const descriptionChanged =
408 (current.description ?? "") !== (news.description ?? "");
409 const matcherChanged = !sameJson(
410 toMatcher(current.endpointMatcher),
411 desiredMatcher,
412 );
413 const portsChanged = !sameStringList(
414 current.trafficPortSelector?.ports,
415 desiredPorts?.ports,
416 );
417 const authzChanged =
418 (current.authorizationPolicy ?? "") !==
419 (news.authorizationPolicy ?? "");
420 const serverTlsChanged =
421 (current.serverTlsPolicy ?? "") !== (news.serverTlsPolicy ?? "");
422 const clientTlsChanged =
423 (current.clientTlsPolicy ?? "") !== (news.clientTlsPolicy ?? "");
424
425 const updateMask = changedFields([
426 ["labels", labelsChanged],
427 ["description", descriptionChanged],
428 ["endpointMatcher", matcherChanged],
429 ["trafficPortSelector", portsChanged],
430 ["authorizationPolicy", authzChanged],
431 ["serverTlsPolicy", serverTlsChanged],
432 ["clientTlsPolicy", clientTlsChanged],
433 ]);
434
435 if (updateMask.length > 0) {
436 const operation =
437 yield* networkservices.patchProjectsLocationsEndpointPolicies({
438 name: current.name ?? name,
439 updateMask: updateMask.join(","),
440 body: {
441 name: current.name ?? name,
442 type,
443 labels: desiredLabels,
444 description: news.description,
445 endpointMatcher: desiredMatcher,
446 trafficPortSelector: desiredPorts,
447 authorizationPolicy: news.authorizationPolicy,
448 serverTlsPolicy: news.serverTlsPolicy,
449 clientTlsPolicy: news.clientTlsPolicy,
450 },
451 });
452 yield* waitForOperation(operation);
453 current = yield* waitUntilPresent(
454 getByName(current.name ?? name),
455 current.name ?? name,
456 );
457 }
458
459 return toAttrs(current, env.project);
460 }),
461
462 delete: Effect.fn(function* ({ output }) {
463 const operation = yield* networkservices
464 .deleteProjectsLocationsEndpointPolicies({ name: output.name })
465 .pipe(
466 Effect.retry({
467 while: (error) => error._tag === "Conflict",
468 times: 8,
469 schedule: Schedule.spaced("2 seconds"),
470 }),
471 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
472 );
473 if (operation !== undefined) {
474 yield* waitForOperation(operation, { notFoundOk: true });
475 }
476 yield* waitUntilGone(getByName(output.name), output.name);
477 }),
478 });