1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_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_ZONE,
19 canonicalizeLink,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 linkKey,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 rfc1035,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilReady,
33} from "./internal.ts";
34
35const COLLECTION = "interceptDeployments";
36
37export type InterceptDeploymentState =
38 | networksecurity.InterceptDeploymentStateEnum
39 | (string & {});
40
41export type InterceptDeploymentProps = {
42 /**
43 * Deployment id (the `{interceptDeployment}` segment of
44 * `projects/{project}/locations/{location}/interceptDeployments/{interceptDeployment}`).
45 * If omitted, a unique name is generated from the stack, stage, and
46 * logical id. Immutable — changing it replaces the deployment.
47 */
48 interceptDeploymentId?: string;
49 /**
50 * Zone of the deployment (`us-central1-a`, …). Immutable — changing
51 * it replaces the deployment. `US-CENTRAL1-A` is accepted and
52 * normalized to `us-central1-a`.
53 * @default "us-central1-a"
54 */
55 location?: string;
56 /**
57 * Parent InterceptDeploymentGroup resource name
58 * (`projects/{project}/locations/global/interceptDeploymentGroups/{interceptDeploymentGroup}`).
59 * Immutable — changing it replaces the deployment.
60 */
61 interceptDeploymentGroup: string;
62 /**
63 * Regional forwarding rule that fronts interceptors, e.g.
64 * `projects/{project}/regions/{region}/forwardingRules/{forwardingRule}`.
65 * Immutable — changing it replaces the deployment.
66 */
67 forwardingRule: string;
68 /**
69 * Human-readable description.
70 */
71 description?: string;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 */
75 labels?: Record<string, string>;
76};
77
78export type InterceptDeployment = Resource<
79 "GCP.NetworkSecurity.InterceptDeployment",
80 InterceptDeploymentProps,
81 {
82 /** Full resource name `projects/{project}/locations/{location}/interceptDeployments/{interceptDeployment}`. */
83 name: string;
84 /** Deployment id (last path segment). */
85 interceptDeploymentId: string;
86 /** Project id. */
87 project: string;
88 /** Zone id (`us-central1-a`). */
89 location: string;
90 /** Parent InterceptDeploymentGroup resource name. */
91 interceptDeploymentGroup: string | undefined;
92 /** Forwarding rule resource name. */
93 forwardingRule: string | undefined;
94 /** User-provided description. */
95 description: string | undefined;
96 /** User labels (Alchemy ownership labels stripped). */
97 labels: Record<string, string>;
98 /** Server-reported lifecycle state (`ACTIVE`, `CREATING`, …). */
99 state: string | undefined;
100 /** Whether reconciling is in progress. */
101 reconciling: boolean;
102 /** RFC3339 creation timestamp. */
103 createTime: string | undefined;
104 /** RFC3339 last-update timestamp. */
105 updateTime: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * A zonal intercept deployment — a GENEVE backend (typically an
113 * internal passthrough load balancer) that belongs to a global
114 * InterceptDeploymentGroup.
115 *
116 * Changing `interceptDeploymentId`, `location`, `interceptDeploymentGroup`,
117 * or `forwardingRule` replaces the deployment. Description and labels
118 * update in place.
119 *
120 * ### Creating an InterceptDeployment
121 * **Example:** Attach a forwarding rule to a group
122 * ```typescript
123 * const deployment = yield* GCP.NetworkSecurity.InterceptDeployment(
124 * "ZoneA",
125 * {
126 * location: "us-central1-a",
127 * interceptDeploymentGroup: group.name,
128 * forwardingRule: rule.selfLink,
129 * description: "us-central1-a interceptors",
130 * },
131 * );
132 * ```
133 *
134 * @resource
135 * @category NetworkSecurity
136 */
137export const InterceptDeployment = Resource<InterceptDeployment>(
138 "GCP.NetworkSecurity.InterceptDeployment",
139);
140
141const resourceName = (
142 project: string,
143 location: string,
144 interceptDeploymentId: string,
145) =>
146 `projects/${project}/locations/${location}/interceptDeployments/${interceptDeploymentId}`;
147
148const toAttrs = (
149 deployment: networksecurity.InterceptDeployment,
150 project: string,
151) => {
152 const name = deployment.name ?? "";
153 const parsed = parseName(name, COLLECTION, DEFAULT_ZONE);
154 return {
155 name,
156 interceptDeploymentId: parsed.id,
157 project: parsed.project || project,
158 location: parsed.location || DEFAULT_ZONE,
159 interceptDeploymentGroup: deployment.interceptDeploymentGroup
160 ? canonicalizeLink(deployment.interceptDeploymentGroup)
161 : undefined,
162 forwardingRule: deployment.forwardingRule
163 ? canonicalizeLink(deployment.forwardingRule)
164 : undefined,
165 description: deployment.description,
166 labels: userLabels(deployment.labels),
167 state: deployment.state,
168 reconciling: deployment.reconciling === true,
169 createTime: deployment.createTime,
170 updateTime: deployment.updateTime,
171 };
172};
173
174const getByName = (name: string) =>
175 networksecurity
176 .getProjectsLocationsInterceptDeployments({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179export const InterceptDeploymentProvider = () =>
180 Provider.succeed(InterceptDeployment, {
181 stables: [
182 "name",
183 "interceptDeploymentId",
184 "project",
185 "location",
186 "interceptDeploymentGroup",
187 "forwardingRule",
188 "createTime",
189 ],
190
191 diff: Effect.fn(function* ({ news, olds, output }) {
192 if (!isResolved(news)) return undefined;
193 const previousId =
194 olds?.interceptDeploymentId ?? output?.interceptDeploymentId;
195 const nextId = news.interceptDeploymentId
196 ? rfc1035(news.interceptDeploymentId, "intercept-deployment")
197 : previousId;
198 const previousLocation = normalizeLocation(
199 olds?.location ?? output?.location,
200 DEFAULT_ZONE,
201 );
202 const nextLocation = normalizeLocation(
203 news.location ?? olds?.location ?? output?.location,
204 DEFAULT_ZONE,
205 );
206 const previousGroup = linkKey(
207 olds?.interceptDeploymentGroup ?? output?.interceptDeploymentGroup,
208 );
209 const nextGroup = linkKey(news.interceptDeploymentGroup);
210 const previousRule = linkKey(
211 olds?.forwardingRule ?? output?.forwardingRule,
212 );
213 const nextRule = linkKey(news.forwardingRule);
214 if (
215 (previousId !== undefined &&
216 nextId !== undefined &&
217 nextId !== previousId) ||
218 previousLocation !== nextLocation ||
219 (previousGroup.length > 0 && previousGroup !== nextGroup) ||
220 (previousRule.length > 0 && previousRule !== nextRule)
221 ) {
222 return { action: "replace" as const };
223 }
224 return undefined;
225 }),
226
227 read: Effect.fn(function* ({ id, olds, output }) {
228 const env = yield* GcpEnvironment.current;
229 const interceptDeploymentId = yield* toPhysicalId(
230 id,
231 olds?.interceptDeploymentId,
232 output?.interceptDeploymentId,
233 "intercept-deployment",
234 );
235 const location = normalizeLocation(
236 olds?.location ?? output?.location,
237 DEFAULT_ZONE,
238 );
239 const name =
240 output?.name ??
241 resourceName(env.project, location, interceptDeploymentId);
242 const existing = yield* getByName(name);
243 if (existing === undefined) return undefined;
244 const attrs = toAttrs(existing, env.project);
245 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
246 ? attrs
247 : Unowned(attrs);
248 }),
249
250 list: () =>
251 Effect.gen(function* () {
252 const env = yield* GcpEnvironment.current;
253 const items = yield* collectPages(
254 networksecurity.listProjectsLocationsInterceptDeployments.pages({
255 parent: parentOf(env.project, "-"),
256 pageSize: 1000,
257 }),
258 (page) => page.interceptDeployments,
259 );
260 return items
261 .filter((item) => hasAlchemyLabelKeys(item.labels))
262 .map((item) => toAttrs(item, env.project));
263 }),
264
265 reconcile: Effect.fn(function* ({ id, news, output }) {
266 const env = yield* GcpEnvironment.current;
267 const interceptDeploymentId = yield* toPhysicalId(
268 id,
269 news.interceptDeploymentId,
270 output?.interceptDeploymentId,
271 "intercept-deployment",
272 );
273 const location = normalizeLocation(
274 news.location ?? output?.location,
275 DEFAULT_ZONE,
276 );
277 const name = resourceName(env.project, location, interceptDeploymentId);
278 const desiredLabels = {
279 ...toLabels(news.labels),
280 ...(yield* createInternalLabels(id)),
281 };
282 const interceptDeploymentGroup = canonicalizeLink(
283 news.interceptDeploymentGroup,
284 );
285 const forwardingRule = canonicalizeLink(news.forwardingRule);
286
287 let current = yield* getByName(name);
288
289 if (current === undefined) {
290 const created = yield* networksecurity
291 .createProjectsLocationsInterceptDeployments({
292 parent: parentOf(env.project, location),
293 interceptDeploymentId,
294 body: {
295 interceptDeploymentGroup,
296 forwardingRule,
297 description: news.description,
298 labels: desiredLabels,
299 },
300 })
301 .pipe(
302 Effect.retry({
303 while: (error) => error._tag === "Conflict",
304 times: 5,
305 schedule: Schedule.spaced("2 seconds"),
306 }),
307 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
308 );
309 if (created !== undefined) {
310 yield* waitForOperation(created);
311 }
312 current = yield* waitUntilReady(getByName(name), name);
313 }
314
315 const observedLabels = tagRecord(current.labels);
316 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
317 const labelsChanged = upsert.length > 0 || removed.length > 0;
318 const descriptionChanged =
319 (current.description ?? "") !== (news.description ?? "");
320 const updateMask = changedFields([
321 ["labels", labelsChanged],
322 ["description", descriptionChanged],
323 ]);
324
325 if (updateMask.length > 0) {
326 const operation =
327 yield* networksecurity.patchProjectsLocationsInterceptDeployments({
328 name: current.name ?? name,
329 updateMask: updateMask.join(","),
330 body: {
331 name: current.name ?? name,
332 labels: desiredLabels,
333 description: news.description,
334 },
335 });
336 yield* waitForOperation(operation);
337 current = yield* waitUntilReady(
338 getByName(current.name ?? name),
339 current.name ?? name,
340 );
341 }
342
343 return toAttrs(current, env.project);
344 }),
345
346 delete: Effect.fn(function* ({ output }) {
347 const operation = yield* networksecurity
348 .deleteProjectsLocationsInterceptDeployments({ name: output.name })
349 .pipe(
350 Effect.retry({
351 while: (error) => error._tag === "Conflict",
352 times: 8,
353 schedule: Schedule.spaced("2 seconds"),
354 }),
355 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
356 );
357 if (operation !== undefined) {
358 yield* waitForOperation(operation, { notFoundOk: true });
359 }
360 yield* waitUntilGone(getByName(output.name), output.name);
361 }),
362 });