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 DEFAULT_LOCATION,
21 normalizeLocation,
22 parentOf,
23 parseResourceName,
24 resourceName,
25 toId,
26 userLabels,
27} from "./names.ts";
28import { waitForOperation } from "./operations.ts";
29
30const COLLECTION = "sacRealms";
31const DEFAULT_SECURITY_SERVICE =
32 "PALO_ALTO_PRISMA_ACCESS" satisfies networksecurity.SACRealmSecurityServiceEnum;
33
34export type SacRealmSecurityService =
35 | networksecurity.SACRealmSecurityServiceEnum
36 | (string & {});
37
38export type SacRealmPairingKey = {
39 /** Pairing key value shared with the SSE partner. */
40 key: string | undefined;
41 /** RFC3339 expiry of the pairing key. */
42 expireTime: string | undefined;
43};
44
45export type SacRealmProps = {
46 /**
47 * Realm id (the `{sacRealm}` segment of
48 * `projects/{project}/locations/global/sacRealms/{sacRealm}`). If
49 * omitted, a unique RFC1035 name is generated from the stack, stage,
50 * and logical id. Immutable — changing it replaces the realm.
51 */
52 sacRealmId?: string;
53 /**
54 * Location of the realm. Realms are global — always `"global"`.
55 * Immutable — changing it replaces the realm. `GLOBAL` is accepted
56 * and normalized to `global`.
57 * @default "global"
58 */
59 location?: string;
60 /**
61 * SSE service provider associated with the realm. Immutable —
62 * changing it replaces the realm.
63 * @default "PALO_ALTO_PRISMA_ACCESS"
64 */
65 securityService?: SacRealmSecurityService;
66 /**
67 * User labels. Alchemy ownership labels are merged in automatically.
68 * The SAC realm API has no patch, so changing labels replaces the
69 * realm.
70 */
71 labels?: Record<string, string>;
72};
73
74export type SacRealm = Resource<
75 "GCP.NetworkSecurity.SacRealm",
76 SacRealmProps,
77 {
78 /** Full resource name. */
79 name: string;
80 /** Realm id (last path segment). */
81 sacRealmId: string;
82 /** Project id. */
83 project: string;
84 /** Location id. Always `"global"`. */
85 location: string;
86 /** SSE service provider. */
87 securityService: string | undefined;
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** Server-reported lifecycle state. */
91 state: string | undefined;
92 /** Pairing key used during partner handshake. */
93 pairingKey: SacRealmPairingKey | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Secure Access Connect (SAC) realm — the handshake between a Google
105 * Cloud project and an SSE partner such as Palo Alto Prisma Access.
106 *
107 * The create API has no patch; changing `sacRealmId`, `location`,
108 * `securityService`, or labels replaces the realm.
109 *
110 * ### Creating a Realm
111 * **Example:** Generated name
112 * ```typescript
113 * const realm = yield* GCP.NetworkSecurity.SacRealm("Prisma", {});
114 * ```
115 *
116 * **Example:** Named realm with labels
117 * ```typescript
118 * const realm = yield* GCP.NetworkSecurity.SacRealm("Prisma", {
119 * sacRealmId: "app-prisma",
120 * securityService: "PALO_ALTO_PRISMA_ACCESS",
121 * labels: { env: "prod" },
122 * });
123 * ```
124 *
125 * @resource
126 * @category NetworkSecurity
127 */
128export const SacRealm = Resource<SacRealm>("GCP.NetworkSecurity.SacRealm");
129
130export class SacRealmNotResolved extends Data.TaggedError(
131 "GCP.NetworkSecurity.SacRealmNotResolved",
132)<{
133 name: string;
134}> {}
135
136export class SacRealmFailed extends Data.TaggedError(
137 "GCP.NetworkSecurity.SacRealmFailed",
138)<{
139 name: string;
140 state: string | undefined;
141}> {}
142
143export class SacRealmStillExists extends Data.TaggedError(
144 "GCP.NetworkSecurity.SacRealmStillExists",
145)<{
146 name: string;
147}> {}
148
149const isPendingState = (state: string | undefined) =>
150 state === "STATE_UNSPECIFIED";
151
152const toPairingKey = (
153 key: networksecurity.SACRealmPairingKey | undefined,
154): SacRealmPairingKey | undefined =>
155 key === undefined
156 ? undefined
157 : {
158 key: key.key,
159 expireTime: key.expireTime,
160 };
161
162const toAttrs = (realm: networksecurity.SACRealm, project: string) => {
163 const name = realm.name ?? "";
164 const parsed = parseResourceName(name, COLLECTION);
165 return {
166 name,
167 sacRealmId: parsed.id,
168 project: parsed.project || project,
169 location: parsed.location || DEFAULT_LOCATION,
170 securityService: realm.securityService,
171 labels: userLabels(realm.labels),
172 state: realm.state,
173 pairingKey: toPairingKey(realm.pairingKey),
174 createTime: realm.createTime,
175 updateTime: realm.updateTime,
176 };
177};
178
179const getByName = (name: string) =>
180 networksecurity
181 .getProjectsLocationsSacRealms({ name })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184const waitUntilReady = (name: string) =>
185 getByName(name).pipe(
186 Effect.filterOrFail(
187 (realm): realm is networksecurity.SACRealm => realm !== undefined,
188 () => new SacRealmNotResolved({ name }),
189 ),
190 Effect.filterOrFail(
191 (realm) => realm.state !== "KEY_EXPIRED",
192 (realm) => new SacRealmFailed({ name, state: realm.state }),
193 ),
194 Effect.filterOrFail(
195 (realm) => !isPendingState(realm.state),
196 () => new SacRealmNotResolved({ name }),
197 ),
198 Effect.retry({
199 while: (error) =>
200 error._tag === "GCP.NetworkSecurity.SacRealmNotResolved",
201 times: 10,
202 schedule: Schedule.spaced("2 seconds"),
203 }),
204 );
205
206const waitUntilGone = (name: string) =>
207 getByName(name).pipe(
208 Effect.flatMap((realm) =>
209 realm === undefined
210 ? Effect.void
211 : Effect.fail(new SacRealmStillExists({ name })),
212 ),
213 Effect.retry({
214 while: (error) =>
215 error._tag === "GCP.NetworkSecurity.SacRealmStillExists",
216 times: 10,
217 schedule: Schedule.spaced("2 seconds"),
218 }),
219 );
220
221const listOwned = (project: string) =>
222 networksecurity.listProjectsLocationsSacRealms
223 .pages({
224 parent: parentOf(project, DEFAULT_LOCATION),
225 pageSize: 1000,
226 })
227 .pipe(
228 Stream.flatMap((page) => Stream.fromIterable(page.sacRealms ?? [])),
229 Stream.filter((realm) =>
230 Object.keys(realm.labels ?? {}).some((key) =>
231 key.startsWith("alchemy-"),
232 ),
233 ),
234 Stream.map((realm) => toAttrs(realm, project)),
235 Stream.runCollect,
236 Effect.map((chunk) => Array.from(chunk)),
237 Effect.catchTag("NotFound", () => Effect.succeed([])),
238 );
239
240export const SacRealmProvider = () =>
241 Provider.succeed(SacRealm, {
242 stables: ["name", "sacRealmId", "project", "location", "createTime"],
243
244 diff: Effect.fn(function* ({ news, olds, output }) {
245 if (!isResolved(news)) return undefined;
246 const previousId = olds?.sacRealmId ?? output?.sacRealmId;
247 const nextId = news.sacRealmId ?? previousId;
248 const previousLocation = normalizeLocation(
249 olds?.location ?? output?.location,
250 );
251 const nextLocation = normalizeLocation(
252 news.location ?? olds?.location ?? output?.location,
253 );
254 const previousService = (
255 olds?.securityService ??
256 output?.securityService ??
257 DEFAULT_SECURITY_SERVICE
258 ).toUpperCase();
259 const nextService = (
260 news.securityService ?? previousService
261 ).toUpperCase();
262 const previousLabels = {
263 ...toLabels(olds?.labels),
264 };
265 const nextLabels = {
266 ...toLabels(news.labels),
267 };
268 const { upsert, removed } = diffLabels(previousLabels, nextLabels);
269 const labelsChanged = upsert.length > 0 || removed.length > 0;
270 const replace =
271 (previousId !== undefined &&
272 nextId !== undefined &&
273 nextId !== previousId) ||
274 previousLocation !== nextLocation ||
275 previousService !== nextService ||
276 labelsChanged;
277 if (!replace) return undefined;
278 return { action: "replace" as const };
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const sacRealmId = yield* toId(
284 id,
285 olds?.sacRealmId,
286 output?.sacRealmId,
287 "sacr",
288 );
289 const location = normalizeLocation(olds?.location ?? output?.location);
290 const name =
291 output?.name ??
292 resourceName(env.project, location, COLLECTION, sacRealmId);
293 const existing = yield* getByName(name);
294 if (existing === undefined) return undefined;
295 const attrs = toAttrs(existing, env.project);
296 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
297 ? attrs
298 : Unowned(attrs);
299 }),
300
301 list: () =>
302 Effect.gen(function* () {
303 const env = yield* GcpEnvironment.current;
304 return yield* listOwned(env.project);
305 }),
306
307 reconcile: Effect.fn(function* ({ id, news, output }) {
308 const env = yield* GcpEnvironment.current;
309 const sacRealmId = yield* toId(
310 id,
311 news.sacRealmId,
312 output?.sacRealmId,
313 "sacr",
314 );
315 const location = normalizeLocation(news.location ?? output?.location);
316 const name = resourceName(env.project, location, COLLECTION, sacRealmId);
317 const securityService = news.securityService ?? DEFAULT_SECURITY_SERVICE;
318 const desiredLabels = {
319 ...toLabels(news.labels),
320 ...(yield* createInternalLabels(id)),
321 };
322
323 let current = yield* getByName(name);
324
325 if (current === undefined) {
326 const created = yield* networksecurity
327 .createProjectsLocationsSacRealms({
328 parent: parentOf(env.project, location),
329 sacRealmId,
330 body: {
331 securityService,
332 labels: desiredLabels,
333 },
334 })
335 .pipe(
336 Effect.retry({
337 while: (error) => error._tag === "Conflict",
338 times: 5,
339 schedule: Schedule.spaced("2 seconds"),
340 }),
341 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
342 );
343 if (created !== undefined) {
344 yield* waitForOperation(created);
345 }
346 current = yield* waitUntilReady(name);
347 }
348
349 if (current === undefined) {
350 return yield* new SacRealmNotResolved({ name });
351 }
352
353 return toAttrs(current, env.project);
354 }),
355
356 delete: Effect.fn(function* ({ output }) {
357 const operation = yield* networksecurity
358 .deleteProjectsLocationsSacRealms({ name: output.name })
359 .pipe(
360 Effect.retry({
361 while: (error) => error._tag === "Conflict",
362 times: 8,
363 schedule: Schedule.spaced("2 seconds"),
364 }),
365 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
366 );
367 if (operation !== undefined) {
368 yield* waitForOperation(operation, { notFoundOk: true });
369 }
370 yield* waitUntilGone(output.name);
371 }),
372 });