1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 lastSegment,
21 normalizeLocation,
22 parentOf,
23 parseResourceName,
24 resourceName,
25 toId,
26 toResourcePath,
27 userLabels,
28} from "./names.ts";
29import { waitForOperation } from "./operations.ts";
30
31const COLLECTION = "sacAttachments";
32
33export type SacAttachmentProps = {
34 /**
35 * Attachment id (the `{sacAttachment}` segment of
36 * `projects/{project}/locations/{location}/sacAttachments/{sacAttachment}`).
37 * If omitted, a unique RFC1035 name is generated from the stack, stage,
38 * and logical id. Immutable — changing it replaces the attachment.
39 */
40 sacAttachmentId?: string;
41 /**
42 * Region of the attachment (e.g. `us-central1`). Immutable — changing
43 * it replaces the attachment. `US-CENTRAL1` is accepted and
44 * normalized to `us-central1`.
45 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
46 */
47 location?: string;
48 /**
49 * SAC realm that owns the attachment, as an id or full resource name.
50 * The realm must already be paired with its SSE partner's realm;
51 * otherwise create fails with `SacRealmNotPaired`. Immutable — changing
52 * it replaces the attachment.
53 */
54 sacRealm: string;
55 /**
56 * NCC Gateway spoke associated with the attachment, as an id or full
57 * resource name. Immutable — changing it replaces the attachment.
58 */
59 nccGateway: string;
60 /**
61 * User labels. Alchemy ownership labels are merged in automatically.
62 * The SAC attachment API has no patch, so changing labels replaces
63 * the attachment.
64 */
65 labels?: Record<string, string>;
66};
67
68export type SacAttachment = Resource<
69 "GCP.NetworkSecurity.SacAttachment",
70 SacAttachmentProps,
71 {
72 /** Full resource name. */
73 name: string;
74 /** Attachment id (last path segment). */
75 sacAttachmentId: string;
76 /** Project id. */
77 project: string;
78 /** Region id. */
79 location: string;
80 /** Owning SAC realm resource name. */
81 sacRealm: string | undefined;
82 /** Associated NCC Gateway spoke resource name. */
83 nccGateway: string | undefined;
84 /** User labels (Alchemy ownership labels stripped). */
85 labels: Record<string, string>;
86 /** Server-reported lifecycle state. */
87 state: string | undefined;
88 /** RFC3339 creation timestamp. */
89 createTime: string | undefined;
90 /** RFC3339 last-update timestamp. */
91 updateTime: string | undefined;
92 },
93 never,
94 Providers
95>;
96
97/**
98 * A Secure Access Connect (SAC) attachment — binds an NCC Gateway to a
99 * SAC realm so SSE traffic can be processed.
100 *
101 * The realm must first be paired with the SSE partner's realm (Palo Alto
102 * Prisma Access / Symantec) on the partner side; an unpaired realm is
103 * rejected with `SacRealmNotPaired`.
104 *
105 * The create API has no patch; changing `sacAttachmentId`, `location`,
106 * `sacRealm`, `nccGateway`, or labels replaces the attachment.
107 *
108 * ### Creating an Attachment
109 * **Example:** Generated name
110 * ```typescript
111 * const attachment = yield* GCP.NetworkSecurity.SacAttachment("PrismaLink", {
112 * sacRealm: realm.name,
113 * nccGateway: gateway.name,
114 * });
115 * ```
116 *
117 * **Example:** Named attachment with labels
118 * ```typescript
119 * const attachment = yield* GCP.NetworkSecurity.SacAttachment("PrismaLink", {
120 * sacAttachmentId: "app-prisma-link",
121 * location: "us-central1",
122 * sacRealm: realm.name,
123 * nccGateway: gateway.name,
124 * labels: { env: "prod" },
125 * });
126 * ```
127 *
128 * @resource
129 * @category NetworkSecurity
130 */
131export const SacAttachment = Resource<SacAttachment>(
132 "GCP.NetworkSecurity.SacAttachment",
133);
134
135export class SacAttachmentNotResolved extends Data.TaggedError(
136 "GCP.NetworkSecurity.SacAttachmentNotResolved",
137)<{
138 name: string;
139}> {}
140
141export class SacAttachmentStillExists extends Data.TaggedError(
142 "GCP.NetworkSecurity.SacAttachmentStillExists",
143)<{
144 name: string;
145}> {}
146
147const toAttrs = (
148 attachment: networksecurity.SACAttachment,
149 project: string,
150 region: string,
151) => {
152 const name = attachment.name ?? "";
153 const parsed = parseResourceName(name, COLLECTION);
154 return {
155 name,
156 sacAttachmentId: parsed.id,
157 project: parsed.project || project,
158 location: parsed.location || region,
159 sacRealm: attachment.sacRealm,
160 nccGateway: attachment.nccGateway,
161 labels: userLabels(attachment.labels),
162 state: attachment.state,
163 createTime: attachment.createTime,
164 updateTime: attachment.updateTime,
165 };
166};
167
168const getByName = (name: string) =>
169 networksecurity
170 .getProjectsLocationsSacAttachments({ name })
171 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
172
173const waitUntilReady = (name: string) =>
174 getByName(name).pipe(
175 Effect.filterOrFail(
176 (attachment): attachment is networksecurity.SACAttachment =>
177 attachment !== undefined,
178 () => new SacAttachmentNotResolved({ name }),
179 ),
180 Effect.retry({
181 while: (error) =>
182 error._tag === "GCP.NetworkSecurity.SacAttachmentNotResolved",
183 times: 10,
184 schedule: Schedule.spaced("2 seconds"),
185 }),
186 );
187
188const waitUntilGone = (name: string) =>
189 getByName(name).pipe(
190 Effect.flatMap((attachment) =>
191 attachment === undefined
192 ? Effect.void
193 : Effect.fail(new SacAttachmentStillExists({ name })),
194 ),
195 Effect.retry({
196 while: (error) =>
197 error._tag === "GCP.NetworkSecurity.SacAttachmentStillExists",
198 times: 10,
199 schedule: Schedule.spaced("2 seconds"),
200 }),
201 );
202
203const listOwned = (project: string, region: string) =>
204 networksecurity.listProjectsLocationsSacAttachments
205 .pages({
206 parent: parentOf(project, "-"),
207 pageSize: 1000,
208 })
209 .pipe(
210 Stream.flatMap((page) => Stream.fromIterable(page.sacAttachments ?? [])),
211 Stream.filter((attachment) =>
212 Object.keys(attachment.labels ?? {}).some((key) =>
213 key.startsWith("alchemy-"),
214 ),
215 ),
216 Stream.map((attachment) => toAttrs(attachment, project, region)),
217 Stream.runCollect,
218 Effect.map((chunk) => Array.from(chunk)),
219 Effect.catchTag("NotFound", () => Effect.succeed([])),
220 );
221
222export const SacAttachmentProvider = () =>
223 Provider.succeed(SacAttachment, {
224 stables: [
225 "name",
226 "sacAttachmentId",
227 "project",
228 "location",
229 "sacRealm",
230 "nccGateway",
231 "createTime",
232 ],
233
234 diff: Effect.fn(function* ({ news, olds, output }) {
235 if (!isResolved(news)) return undefined;
236 const env = yield* GcpEnvironment.current;
237 const previousId = olds?.sacAttachmentId ?? output?.sacAttachmentId;
238 const nextId = news.sacAttachmentId ?? previousId;
239 const previousLocation = normalizeLocation(
240 olds?.location ?? output?.location,
241 env.region,
242 );
243 const nextLocation = normalizeLocation(
244 news.location ?? olds?.location ?? output?.location,
245 env.region,
246 );
247 const previousRealm = lastSegment(
248 olds?.sacRealm ?? output?.sacRealm ?? "",
249 );
250 const nextRealm = lastSegment(news.sacRealm);
251 const previousGateway = lastSegment(
252 olds?.nccGateway ?? output?.nccGateway ?? "",
253 );
254 const nextGateway = lastSegment(news.nccGateway);
255 const previousLabels = { ...toLabels(olds?.labels) };
256 const nextLabels = { ...toLabels(news.labels) };
257 const { upsert, removed } = diffLabels(previousLabels, nextLabels);
258 const labelsChanged = upsert.length > 0 || removed.length > 0;
259 const replace =
260 (previousId !== undefined &&
261 nextId !== undefined &&
262 nextId !== previousId) ||
263 previousLocation !== nextLocation ||
264 (previousRealm.length > 0 && previousRealm !== nextRealm) ||
265 (previousGateway.length > 0 && previousGateway !== nextGateway) ||
266 labelsChanged;
267 if (!replace) return undefined;
268 return { action: "replace" as const };
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const env = yield* GcpEnvironment.current;
273 const sacAttachmentId = yield* toId(
274 id,
275 olds?.sacAttachmentId,
276 output?.sacAttachmentId,
277 "saca",
278 );
279 const location = normalizeLocation(
280 olds?.location ?? output?.location,
281 env.region,
282 );
283 const name =
284 output?.name ??
285 resourceName(env.project, location, COLLECTION, sacAttachmentId);
286 const existing = yield* getByName(name);
287 if (existing === undefined) return undefined;
288 const attrs = toAttrs(existing, env.project, env.region);
289 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
290 ? attrs
291 : Unowned(attrs);
292 }),
293
294 list: () =>
295 Effect.gen(function* () {
296 const env = yield* GcpEnvironment.current;
297 return yield* listOwned(env.project, env.region);
298 }),
299
300 reconcile: Effect.fn(function* ({ id, news, output }) {
301 const env = yield* GcpEnvironment.current;
302 const sacAttachmentId = yield* toId(
303 id,
304 news.sacAttachmentId,
305 output?.sacAttachmentId,
306 "saca",
307 );
308 const location = normalizeLocation(
309 news.location ?? output?.location,
310 env.region,
311 );
312 const name = resourceName(
313 env.project,
314 location,
315 COLLECTION,
316 sacAttachmentId,
317 );
318 const sacRealm = toResourcePath(news.sacRealm);
319 const nccGateway = toResourcePath(news.nccGateway);
320 const desiredLabels = {
321 ...toLabels(news.labels),
322 ...(yield* createInternalLabels(id)),
323 };
324
325 let current = yield* getByName(name);
326
327 if (current === undefined) {
328 const created = yield* networksecurity
329 .createProjectsLocationsSacAttachments({
330 parent: parentOf(env.project, location),
331 sacAttachmentId,
332 body: {
333 sacRealm,
334 nccGateway,
335 labels: desiredLabels,
336 },
337 })
338 .pipe(
339 Effect.retry({
340 while: (error) => error._tag === "Conflict",
341 times: 5,
342 schedule: Schedule.spaced("2 seconds"),
343 }),
344 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
345 );
346 if (created !== undefined) {
347 yield* waitForOperation(created);
348 }
349 current = yield* waitUntilReady(name);
350 }
351
352 if (current === undefined) {
353 return yield* new SacAttachmentNotResolved({ name });
354 }
355
356 return toAttrs(current, env.project, env.region);
357 }),
358
359 delete: Effect.fn(function* ({ output }) {
360 const operation = yield* networksecurity
361 .deleteProjectsLocationsSacAttachments({ name: output.name })
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(output.name);
374 }),
375 });