1import * as acm from "@distilled.cloud/gcp/accesscontextmanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 encodeOwnership,
13 encodeOwnershipLine,
14 jsonEqual,
15 listOwnedPolicies,
16 MAX_DESCRIPTION_LENGTH,
17 MAX_TITLE_LENGTH,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 policyNameOf,
22 replaceOnIdentity,
23 resourceNameOf,
24 toAcmId,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type ServicePerimeterConfig = acm.ServicePerimeterConfig;
31export type ServicePerimeterType = acm.ServicePerimeterPerimeterTypeEnum;
32
33export type AccessPoliciesServicePerimeterProps = {
34 /**
35 * Parent access policy (`accessPolicies/{policy}` or the policy id).
36 * Immutable — changing it replaces the perimeter.
37 */
38 policy: string;
39 /**
40 * Service perimeter id (the `{service_perimeter}` segment). If omitted,
41 * a unique name is generated from the stack, stage, and logical id.
42 * Must begin with a letter, then alphanumeric or `_`. Immutable —
43 * changing it replaces the perimeter.
44 */
45 servicePerimeterId?: string;
46 /**
47 * Human-readable title. Must be unique within the policy.
48 */
49 title?: string;
50 /**
51 * Description of the perimeter. Service perimeters have no labels, so
52 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
53 * from attributes.
54 */
55 description?: string;
56 /**
57 * Perimeter type. Immutable — changing it replaces the perimeter.
58 * @default "PERIMETER_TYPE_REGULAR"
59 */
60 perimeterType?: ServicePerimeterType | (string & {});
61 /**
62 * When true, `spec` is an explicit dry-run configuration instead of an
63 * implicit copy of `status`.
64 * @default false
65 */
66 useExplicitDryRunSpec?: boolean;
67 /**
68 * Enforced perimeter configuration (resources, restricted services,
69 * access levels, ingress/egress policies).
70 */
71 status?: ServicePerimeterConfig;
72 /**
73 * Proposed (dry-run) configuration. Only applied when
74 * `useExplicitDryRunSpec` is true.
75 */
76 spec?: ServicePerimeterConfig;
77};
78
79export type AccessPoliciesServicePerimeter = Resource<
80 "GCP.AccessContextManager.AccessPoliciesServicePerimeter",
81 AccessPoliciesServicePerimeterProps,
82 {
83 /** Resource name `accessPolicies/{policy}/servicePerimeters/{perimeter}`. */
84 name: string;
85 /** Service perimeter id (last path segment). */
86 servicePerimeterId: string;
87 /** Parent policy name `accessPolicies/{policy}`. */
88 policy: string;
89 /** User title with the Alchemy ownership prefix stripped. */
90 title: string | undefined;
91 /** User description with the Alchemy ownership prefix stripped. */
92 description: string | undefined;
93 /** Perimeter type. */
94 perimeterType: string | undefined;
95 /** Whether an explicit dry-run spec is in use. */
96 useExplicitDryRunSpec: boolean;
97 /** Enforced configuration. */
98 status: ServicePerimeterConfig | undefined;
99 /** Dry-run configuration. */
100 spec: ServicePerimeterConfig | undefined;
101 /** Server etag for optimistic concurrency. */
102 etag: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * An Access Context Manager service perimeter.
110 *
111 * A service perimeter groups Google Cloud resources that can freely
112 * exchange data with each other but not with the outside. Regular
113 * perimeters cannot overlap; bridges can share projects.
114 *
115 * Service perimeters have no labels. Alchemy stamps ownership into
116 * `description` so `list` / `pnpm nuke:gcp` can find them. `policy`,
117 * `servicePerimeterId`, and `perimeterType` are immutable. Title,
118 * description, `status`, `spec`, and `useExplicitDryRunSpec` update in
119 * place.
120 *
121 * ### Creating a Service Perimeter
122 * **Example:** Regular perimeter around Cloud Storage
123 * ```typescript
124 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Corp", {
125 * scopes: ["projects/123456789"],
126 * });
127 * const perimeter = yield* GCP.AccessContextManager.AccessPoliciesServicePerimeter(
128 * "Storage",
129 * {
130 * policy: policy.name,
131 * title: "storage perimeter",
132 * status: {
133 * restrictedServices: ["storage.googleapis.com"],
134 * },
135 * },
136 * );
137 * ```
138 *
139 * ### Updating a Service Perimeter
140 * **Example:** Restrict BigQuery as well
141 * ```typescript
142 * const perimeter = yield* GCP.AccessContextManager.AccessPoliciesServicePerimeter(
143 * "Storage",
144 * {
145 * policy: policy.name,
146 * title: "data perimeter",
147 * status: {
148 * restrictedServices: [
149 * "storage.googleapis.com",
150 * "bigquery.googleapis.com",
151 * ],
152 * },
153 * },
154 * );
155 * ```
156 *
157 * @resource
158 * @category AccessContextManager
159 */
160export const AccessPoliciesServicePerimeter =
161 Resource<AccessPoliciesServicePerimeter>(
162 "GCP.AccessContextManager.AccessPoliciesServicePerimeter",
163 );
164
165export class AccessPoliciesServicePerimeterNotResolved extends Data.TaggedError(
166 "GCP.AccessContextManager.AccessPoliciesServicePerimeterNotResolved",
167)<{
168 name: string;
169}> {}
170
171const DEFAULT_TYPE = "PERIMETER_TYPE_REGULAR";
172
173const typeOf = (value: string | undefined) => value ?? DEFAULT_TYPE;
174
175const toAttrs = (
176 perimeter: acm.ServicePerimeter,
177): AccessPoliciesServicePerimeter["Attributes"] => {
178 const name = perimeter.name ?? "";
179 const parsed = parseName(name, "servicePerimeters");
180 const title = parseOwnership(perimeter.title);
181 const description = parseOwnership(perimeter.description);
182 return {
183 name,
184 servicePerimeterId: parsed.id,
185 policy: parsed.parent,
186 title: title.text,
187 description: description.text,
188 perimeterType: perimeter.perimeterType,
189 useExplicitDryRunSpec: perimeter.useExplicitDryRunSpec === true,
190 status: perimeter.status,
191 spec: perimeter.spec,
192 etag: perimeter.etag,
193 };
194};
195
196const getByName = (name: string) =>
197 acm
198 .getAccessPoliciesServicePerimeters({ name })
199 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
200
201const listPerimeters = (policy: string) =>
202 collectPages(
203 acm.listAccessPoliciesServicePerimeters.pages({
204 parent: policyNameOf(policy),
205 pageSize: 100,
206 }),
207 (page) => page.servicePerimeters,
208 ).pipe(
209 Effect.catchTag("NotFound", () =>
210 Effect.succeed([] as acm.ServicePerimeter[]),
211 ),
212 );
213
214export const AccessPoliciesServicePerimeterProvider = () =>
215 Provider.succeed(AccessPoliciesServicePerimeter, {
216 stables: ["name", "servicePerimeterId", "policy", "perimeterType"],
217
218 diff: Effect.fn(function* ({ news, olds, output }) {
219 if (!isResolved(news)) return undefined;
220 const previousId = olds?.servicePerimeterId ?? output?.servicePerimeterId;
221 const idChanged =
222 previousId !== undefined &&
223 news.servicePerimeterId !== undefined &&
224 news.servicePerimeterId !== previousId;
225 const previousPolicy = olds?.policy ?? output?.policy;
226 const policyChanged =
227 previousPolicy !== undefined &&
228 policyNameOf(news.policy) !== policyNameOf(previousPolicy);
229 const previousType = typeOf(olds?.perimeterType ?? output?.perimeterType);
230 const nextType = typeOf(
231 news.perimeterType ?? olds?.perimeterType ?? output?.perimeterType,
232 );
233 const typeChanged = previousType !== nextType;
234 return replaceOnIdentity(idChanged || policyChanged || typeChanged);
235 }),
236
237 read: Effect.fn(function* ({ id, olds, output }) {
238 const servicePerimeterId = yield* toAcmId(
239 id,
240 olds?.servicePerimeterId,
241 output?.servicePerimeterId,
242 );
243 const policy = olds?.policy ?? output?.policy;
244 if (policy === undefined) return undefined;
245 const name =
246 output?.name ??
247 resourceNameOf(policy, "servicePerimeters", servicePerimeterId);
248 const existing = yield* getByName(name);
249 if (existing === undefined) return undefined;
250 const attrs = toAttrs(existing);
251 return (yield* ownedByAlchemy(id, existing.description ?? existing.title))
252 ? attrs
253 : Unowned(attrs);
254 }),
255
256 list: () =>
257 Effect.gen(function* () {
258 const policies = yield* listOwnedPolicies();
259 const perimeters = yield* Effect.forEach(
260 policies,
261 (policy) =>
262 policy.name
263 ? listPerimeters(policy.name)
264 : Effect.succeed([] as acm.ServicePerimeter[]),
265 { concurrency: 4 },
266 );
267 return perimeters
268 .flat()
269 .filter(
270 (perimeter) =>
271 parseOwnership(perimeter.description ?? perimeter.title).labels[
272 "alchemy-id"
273 ],
274 )
275 .map(toAttrs);
276 }),
277
278 reconcile: Effect.fn(function* ({ id, news, output }) {
279 const policy = policyNameOf(news.policy);
280 const servicePerimeterId = yield* toAcmId(
281 id,
282 news.servicePerimeterId,
283 output?.servicePerimeterId,
284 );
285 const name = resourceNameOf(
286 policy,
287 "servicePerimeters",
288 servicePerimeterId,
289 );
290 const ownership = yield* createInternalLabels(id);
291 const desiredTitle = encodeOwnershipLine(
292 ownership,
293 news.title ?? servicePerimeterId,
294 MAX_TITLE_LENGTH,
295 );
296 const desiredDescription = encodeOwnership(
297 ownership,
298 news.description,
299 MAX_DESCRIPTION_LENGTH,
300 );
301 const desiredType = typeOf(news.perimeterType);
302 const desiredDryRun = news.useExplicitDryRunSpec === true;
303
304 let current = yield* getByName(output?.name ?? name);
305
306 if (current === undefined) {
307 const created = yield* acm
308 .createAccessPoliciesServicePerimeters({
309 parent: policy,
310 body: {
311 name,
312 title: desiredTitle,
313 description: desiredDescription,
314 perimeterType: desiredType,
315 useExplicitDryRunSpec: desiredDryRun ? true : undefined,
316 status: news.status,
317 spec: news.spec,
318 },
319 })
320 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
321 if (created !== undefined) {
322 // ALREADY_EXISTS (6): a concurrent create won the race.
323 yield* waitForOperation(created).pipe(
324 Effect.catchTag("GCP.OperationFailed", (error) =>
325 error.code === 6 ? Effect.void : Effect.fail(error),
326 ),
327 );
328 }
329 current = yield* waitUntilExists(getByName(name), name);
330 }
331
332 if (current === undefined) {
333 return yield* new AccessPoliciesServicePerimeterNotResolved({ name });
334 }
335
336 const titleChanged = (current.title ?? "") !== desiredTitle;
337 const descriptionChanged =
338 (current.description ?? "") !== desiredDescription;
339 const dryRunChanged =
340 (current.useExplicitDryRunSpec === true) !== desiredDryRun;
341 const statusChanged =
342 news.status !== undefined && !jsonEqual(current.status, news.status);
343 const specChanged =
344 news.spec !== undefined && !jsonEqual(current.spec, news.spec);
345
346 const updateMask = [
347 titleChanged ? "title" : undefined,
348 descriptionChanged ? "description" : undefined,
349 dryRunChanged ? "use_explicit_dry_run_spec" : undefined,
350 statusChanged ? "status" : undefined,
351 specChanged ? "spec" : undefined,
352 ].filter((field): field is string => field !== undefined);
353
354 if (updateMask.length > 0) {
355 const operation = yield* acm.patchAccessPoliciesServicePerimeters({
356 name: current.name ?? name,
357 updateMask: updateMask.join(","),
358 body: {
359 name: current.name ?? name,
360 title: desiredTitle,
361 description: desiredDescription,
362 useExplicitDryRunSpec: desiredDryRun,
363 status: news.status,
364 spec: news.spec,
365 etag: current.etag,
366 },
367 });
368 yield* waitForOperation(operation);
369 current = yield* waitUntilExists(
370 getByName(current.name ?? name),
371 current.name ?? name,
372 );
373 }
374
375 if (current === undefined) {
376 return yield* new AccessPoliciesServicePerimeterNotResolved({ name });
377 }
378
379 return toAttrs(current);
380 }),
381
382 delete: Effect.fn(function* ({ output }) {
383 const operation = yield* acm
384 .deleteAccessPoliciesServicePerimeters({ name: output.name })
385 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
386 if (operation !== undefined) {
387 // NOT_FOUND (5): already gone.
388 yield* waitForOperation(operation).pipe(
389 Effect.catchTag("GCP.OperationFailed", (error) =>
390 error.code === 5 ? Effect.void : Effect.fail(error),
391 ),
392 );
393 }
394 yield* waitUntilGone(getByName(output.name), output.name);
395 }),
396 });