1import * as securityposture from "@distilled.cloud/gcp/securityposture_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 DEFAULT_LOCATION,
15 defaultTargetResource,
16 fieldMask,
17 hasAlchemyAnnotationMap,
18 lastSegment,
19 locationParent,
20 normalizeTargetResource,
21 organizationIdOf,
22 organizationParent,
23 parseName,
24 replaceOnIdentity,
25 resolveOrganization,
26 sameText,
27 SecuritypostureNotResolved,
28 toPhysicalId,
29 tryResolveOrganization,
30 userAnnotations,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34} from "./internal.ts";
35
36export type PostureDeploymentState =
37 | securityposture.PostureDeploymentStateEnum
38 | (string & {});
39
40export type PostureDeploymentProps = {
41 /**
42 * Posture deployment id (the `{postureDeployment}` segment of
43 * `organizations/{organization}/locations/global/postureDeployments/{postureDeployment}`).
44 * If omitted, a unique id is generated from the stack, stage, and
45 * logical id. Letters, digits, hyphens, and underscores; max 63
46 * characters. Immutable — changing it replaces the deployment.
47 */
48 postureDeploymentId?: string;
49 /**
50 * Parent organization (`organizations/{organization}` or the numeric
51 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
52 * Manager ancestor. Immutable — changing it replaces the deployment.
53 */
54 organization?: string;
55 /**
56 * Location. Posture deployments live in `global`.
57 * @default "global"
58 */
59 location?: string;
60 /**
61 * Organization, folder, or project the posture is applied to
62 * (`organizations/{number}`, `folders/{number}`, or
63 * `projects/{number}`). Defaults to the stack project. At most one
64 * posture can be deployed to each target. Immutable — changing it
65 * replaces the deployment.
66 */
67 targetResource?: string;
68 /**
69 * Posture used in the deployment, as
70 * `organizations/{organization}/locations/global/postures/{posture}`.
71 */
72 postureId: string;
73 /**
74 * Revision id of the posture used in the deployment.
75 */
76 postureRevisionId: string;
77 /**
78 * Human-readable description.
79 */
80 description?: string;
81 /**
82 * User annotations. Deployments have no labels field — Alchemy
83 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is
84 * stored here so `list` / `pnpm nuke:gcp` can find owned deployments.
85 * Annotations are set at create time.
86 */
87 annotations?: Record<string, string>;
88};
89
90export type PostureDeployment = Resource<
91 "GCP.SecurityPosture.PostureDeployment",
92 PostureDeploymentProps,
93 {
94 /** Full resource name `organizations/{organization}/locations/global/postureDeployments/{postureDeployment}`. */
95 name: string;
96 /** Posture deployment id (last path segment). */
97 postureDeploymentId: string;
98 /** Organization resource name. */
99 organization: string;
100 /** Organization id. */
101 organizationId: string;
102 /** Location id (`global`). */
103 location: string;
104 /** Project id of the deploying stack. */
105 project: string;
106 /** Target organization, folder, or project. */
107 targetResource: string | undefined;
108 /** Deployed posture resource name. */
109 postureId: string | undefined;
110 /** Deployed posture revision id. */
111 postureRevisionId: string | undefined;
112 /** User description. */
113 description: string | undefined;
114 /** User annotations (Alchemy ownership keys stripped). */
115 annotations: Record<string, string>;
116 /** Deployment state (`CREATING`, `ACTIVE`, `CREATE_FAILED`, …). */
117 state: string | undefined;
118 /** Failure message when the deployment is in a failed state. */
119 failureMessage: string | undefined;
120 /** Categories assigned by the Security Posture API. */
121 categories: string[] | undefined;
122 /** Whether the deployment is being updated. */
123 reconciling: boolean | undefined;
124 /** Server checksum used on update and delete. */
125 etag: string | undefined;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A Security Command Center posture deployment.
137 *
138 * Deployments apply a posture revision to one organization, folder, or
139 * project. The parent is always the organization, even when the target
140 * is a folder or project. At most one posture can be deployed to each
141 * target. Deployments have no labels field — Alchemy stamps ownership
142 * into `annotations`. `postureDeploymentId`, `organization`, and
143 * `targetResource` are identity. `postureId` and `postureRevisionId`
144 * update in place.
145 *
146 * The posture must be `ACTIVE`. Creating a deployment requires the
147 * Security Posture API and organization-level Security Command Center
148 * Premium or Enterprise.
149 *
150 * ### Creating a Posture Deployment
151 * **Example:** Deploy a posture to the stack project
152 * ```typescript
153 * const posture = yield* GCP.SecurityPosture.Posture("Baseline", {
154 * state: "ACTIVE",
155 * });
156 * const deployment = yield* GCP.SecurityPosture.PostureDeployment(
157 * "Staging",
158 * {
159 * postureId: posture.name,
160 * postureRevisionId: posture.revisionId,
161 * },
162 * );
163 * ```
164 *
165 * **Example:** Deploy to an explicit project
166 * ```typescript
167 * const deployment = yield* GCP.SecurityPosture.PostureDeployment(
168 * "Staging",
169 * {
170 * organization: "organizations/123456789",
171 * targetResource: "projects/987654321",
172 * postureId:
173 * "organizations/123456789/locations/global/postures/staging-baseline",
174 * postureRevisionId: "abcdefgh",
175 * description: "staging deployment",
176 * },
177 * );
178 * ```
179 *
180 * @resource
181 * @category SecurityPosture
182 */
183export const PostureDeployment = Resource<PostureDeployment>(
184 "GCP.SecurityPosture.PostureDeployment",
185);
186
187const resourceName = (
188 organization: string,
189 location: string,
190 postureDeploymentId: string,
191) =>
192 `${locationParent(organization, location)}/postureDeployments/${postureDeploymentId}`;
193
194const waitDeploymentOperation = (operation: securityposture.Operation) =>
195 waitForOperation(operation);
196
197const waitDeploymentOperationGone = (operation: securityposture.Operation) =>
198 waitForOperation(operation, { notFoundOk: true });
199
200const toAttrs = (
201 deployment: securityposture.PostureDeployment,
202 project: string,
203) => {
204 const name = deployment.name ?? "";
205 const parsed = parseName(name, "postureDeployments");
206 const organization = parsed.organization
207 ? organizationParent(parsed.organization)
208 : "";
209 return {
210 name,
211 postureDeploymentId: parsed.id || lastSegment(name),
212 organization,
213 organizationId: organizationIdOf(organization),
214 location: parsed.location,
215 project,
216 targetResource: deployment.targetResource,
217 postureId: deployment.postureId,
218 postureRevisionId: deployment.postureRevisionId,
219 description: deployment.description,
220 annotations: userAnnotations(deployment.annotations),
221 state: deployment.state,
222 failureMessage: deployment.failureMessage,
223 categories: deployment.categories,
224 reconciling: deployment.reconciling,
225 etag: deployment.etag,
226 createTime: deployment.createTime,
227 updateTime: deployment.updateTime,
228 };
229};
230
231const getByName = (name: string) =>
232 name.length === 0
233 ? Effect.succeed(undefined)
234 : securityposture
235 .getOrganizationsLocationsPostureDeployments({ name })
236 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
237
238const isBusy = (deployment: securityposture.PostureDeployment) =>
239 deployment.reconciling === true ||
240 deployment.state === "CREATING" ||
241 deployment.state === "UPDATING" ||
242 deployment.state === "DELETING";
243
244const waitUntilSettled = (name: string) =>
245 getByName(name).pipe(
246 Effect.flatMap((current) => {
247 if (current === undefined) {
248 return Effect.fail(new SecuritypostureNotResolved({ name }));
249 }
250 if (isBusy(current)) {
251 return Effect.fail(
252 new SecuritypostureNotResolved({ name: `${name}:${current.state}` }),
253 );
254 }
255 return Effect.succeed(current);
256 }),
257 Effect.retry({
258 while: (error) => error._tag === "GCP.SecurityPosture.NotResolved",
259 times: 10,
260 schedule: Schedule.spaced("3 seconds"),
261 }),
262 );
263
264export const PostureDeploymentProvider = () =>
265 Provider.succeed(PostureDeployment, {
266 stables: [
267 "name",
268 "postureDeploymentId",
269 "organization",
270 "organizationId",
271 "location",
272 "project",
273 "targetResource",
274 "createTime",
275 ],
276
277 diff: Effect.fn(function* ({ news, olds, output }) {
278 if (!isResolved(news)) return undefined;
279 const previousId =
280 olds?.postureDeploymentId ?? output?.postureDeploymentId;
281 const nextId = news.postureDeploymentId ?? previousId;
282 const previousOrg = olds?.organization ?? output?.organization;
283 const nextOrg =
284 news.organization !== undefined
285 ? organizationParent(news.organization)
286 : previousOrg;
287 const previousTarget = olds?.targetResource ?? output?.targetResource;
288 const nextTarget = news.targetResource ?? previousTarget;
289 return replaceOnIdentity(
290 (previousId !== undefined &&
291 nextId !== undefined &&
292 nextId !== previousId) ||
293 (previousOrg !== undefined &&
294 nextOrg !== undefined &&
295 organizationParent(previousOrg) !== organizationParent(nextOrg)) ||
296 (previousTarget !== undefined &&
297 nextTarget !== undefined &&
298 previousTarget !== nextTarget),
299 true,
300 );
301 }),
302
303 read: Effect.fn(function* ({ id, olds, output }) {
304 const env = yield* GcpEnvironment.current;
305 const postureDeploymentId = yield* toPhysicalId(
306 id,
307 olds?.postureDeploymentId,
308 output?.postureDeploymentId,
309 "deploy",
310 );
311 const organization = yield* resolveOrganization(
312 olds?.organization ?? output?.organization,
313 output?.organization,
314 ).pipe(
315 Effect.catchTag("GCP.SecurityPosture.OrganizationNotResolved", () =>
316 Effect.succeed(output?.organization ?? ""),
317 ),
318 );
319 const location = lastSegment(
320 olds?.location ?? output?.location ?? DEFAULT_LOCATION,
321 );
322 const name =
323 output?.name ??
324 (organization.length > 0
325 ? resourceName(organization, location, postureDeploymentId)
326 : "");
327 const existing = yield* getByName(name);
328 if (existing === undefined) return undefined;
329 const attrs = toAttrs(existing, env.project);
330 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
331 ? attrs
332 : Unowned(attrs);
333 }),
334
335 list: () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 const organization = yield* tryResolveOrganization();
339 if (organization === undefined) return [];
340 const parent = locationParent(organization, DEFAULT_LOCATION);
341 return yield* securityposture.listOrganizationsLocationsPostureDeployments
342 .pages({ parent, pageSize: 1000 })
343 .pipe(
344 Stream.flatMap((page) =>
345 Stream.fromIterable(page.postureDeployments ?? []),
346 ),
347 Stream.filter((deployment) =>
348 hasAlchemyAnnotationMap(deployment.annotations),
349 ),
350 Stream.map((deployment) => toAttrs(deployment, env.project)),
351 Stream.runCollect,
352 Effect.map((chunk) => Array.from(chunk)),
353 Effect.catchTag("NotFound", () => Effect.succeed([])),
354 );
355 }),
356
357 reconcile: Effect.fn(function* ({ id, news, output }) {
358 const env = yield* GcpEnvironment.current;
359 const postureDeploymentId = yield* toPhysicalId(
360 id,
361 news.postureDeploymentId,
362 output?.postureDeploymentId,
363 "deploy",
364 );
365 const organization = yield* resolveOrganization(
366 news.organization ?? output?.organization,
367 output?.organization,
368 );
369 const location = lastSegment(
370 news.location ?? output?.location ?? DEFAULT_LOCATION,
371 );
372 const parent = locationParent(organization, location);
373 const name = resourceName(organization, location, postureDeploymentId);
374 const targetResource = yield* news.targetResource !== undefined
375 ? normalizeTargetResource(news.targetResource)
376 : output?.targetResource
377 ? Effect.succeed(output.targetResource)
378 : defaultTargetResource();
379 const desiredAnnotations = {
380 ...toLabels(news.annotations),
381 ...(yield* createInternalLabels(id)),
382 };
383
384 let current = yield* getByName(output?.name ?? name);
385
386 if (current === undefined) {
387 const created = yield* securityposture
388 .createOrganizationsLocationsPostureDeployments({
389 parent,
390 postureDeploymentId,
391 body: {
392 targetResource,
393 postureId: news.postureId,
394 postureRevisionId: news.postureRevisionId,
395 description: news.description,
396 annotations: desiredAnnotations,
397 },
398 })
399 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
400 if (created !== undefined) {
401 yield* waitDeploymentOperation(created);
402 }
403 current = yield* waitUntilExists(getByName(name), name);
404 current = yield* waitUntilSettled(current.name ?? name);
405 }
406
407 if (current === undefined) {
408 return yield* new SecuritypostureNotResolved({ name });
409 }
410
411 const postureChanged = !sameText(current.postureId, news.postureId);
412 const revisionChanged = !sameText(
413 current.postureRevisionId,
414 news.postureRevisionId,
415 );
416 const descriptionChanged = !sameText(
417 current.description,
418 news.description,
419 );
420 const mask = fieldMask([
421 postureChanged && "posture_id",
422 revisionChanged && "posture_revision_id",
423 descriptionChanged && "description",
424 ]);
425
426 if (mask.length > 0) {
427 const operation =
428 yield* securityposture.patchOrganizationsLocationsPostureDeployments({
429 name: current.name ?? name,
430 updateMask: mask,
431 body: {
432 name: current.name ?? name,
433 postureId: news.postureId,
434 postureRevisionId: news.postureRevisionId,
435 description: news.description,
436 etag: current.etag,
437 },
438 });
439 yield* waitDeploymentOperation(operation);
440 current = yield* waitUntilSettled(current.name ?? name);
441 }
442
443 return toAttrs(current, env.project);
444 }),
445
446 delete: Effect.fn(function* ({ output }) {
447 if (!output.name) return;
448 const operation = yield* securityposture
449 .deleteOrganizationsLocationsPostureDeployments({
450 name: output.name,
451 etag: output.etag,
452 })
453 .pipe(
454 Effect.retry({
455 while: (error) => error._tag === "Conflict",
456 times: 8,
457 schedule: Schedule.spaced("2 seconds"),
458 }),
459 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
460 );
461 if (operation !== undefined) {
462 yield* waitDeploymentOperationGone(operation);
463 }
464 yield* waitUntilGone(getByName(output.name), output.name);
465 }),
466 });