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