1import * as osconfig from "@distilled.cloud/gcp/osconfig_v2";
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_ACTION,
19 DEFAULT_STATE,
20 OsconfigNotResolved,
21 collectOrchestrators,
22 defaultOrchestratedResource,
23 fieldMask,
24 fingerprint,
25 folderParent,
26 globalParent,
27 hasAlchemyLabelMap,
28 orchestratedPayload,
29 parseName,
30 replaceOnIdentity,
31 resolveFolder,
32 resourceName,
33 toPhysicalId,
34 tryResolveFolder,
35 userLabels,
36 waitForOperation,
37 waitUntilExists,
38 waitUntilGone,
39 type OrchestratedResource,
40 type OrchestrationScope,
41} from "./internal.ts";
42
43export type FoldersLocationsGlobalPolicyOrchestratorProps = {
44 /**
45 * Folder (`folders/{folder}` or the numeric id). When omitted, the
46 * stack project's parent folder is used. Immutable — changing it
47 * replaces the orchestrator.
48 */
49 folderId?: string;
50 /**
51 * Orchestrator id (the `{policyOrchestrator}` segment of
52 * `folders/{folder}/locations/global/policyOrchestrators/{policyOrchestrator}`).
53 * If omitted, a unique RFC1035 name is generated. Immutable — changing
54 * it replaces the orchestrator.
55 */
56 policyOrchestratorId?: string;
57 /**
58 * Action applied to matching OS policy assignments.
59 * `UPSERT` creates or updates; `DELETE` removes them.
60 * @default "UPSERT"
61 */
62 action?: string;
63 /**
64 * Orchestrator state. `ACTIVE` applies changes; `STOPPED` is idle.
65 * Defaults to `STOPPED` so create does not roll out assignments.
66 * @default "STOPPED"
67 */
68 state?: string;
69 /**
70 * Free-form description of the orchestrator.
71 */
72 description?: string;
73 /**
74 * User labels. Alchemy ownership labels are merged in automatically.
75 */
76 labels?: Record<string, string>;
77 /**
78 * Resource the orchestrator creates, updates, or deletes. When omitted,
79 * a no-op VALIDATION OS policy assignment is used.
80 */
81 orchestratedResource?: OrchestratedResource;
82 /**
83 * Scope of projects, folders, and zones the orchestrator targets.
84 * When omitted, the parent folder is the entire scope.
85 */
86 orchestrationScope?: OrchestrationScope;
87};
88
89export type FoldersLocationsGlobalPolicyOrchestrator = Resource<
90 "GCP.OSConfig.FoldersLocationsGlobalPolicyOrchestrator",
91 FoldersLocationsGlobalPolicyOrchestratorProps,
92 {
93 /** Full resource name. */
94 name: string;
95 /** Orchestrator id (last path segment). */
96 policyOrchestratorId: string;
97 /** Parent `folders/{folder}/locations/global`. */
98 parent: string;
99 /** Folder id. */
100 folderId: string;
101 /** Project id of the stack (used for quota). */
102 project: string;
103 /** Orchestration action (`UPSERT` or `DELETE`). */
104 action: string | undefined;
105 /** Orchestrator state (`ACTIVE` or `STOPPED`). */
106 state: string | undefined;
107 /** User description. */
108 description: string | undefined;
109 /** User labels (Alchemy ownership labels stripped). */
110 labels: Record<string, string>;
111 /** Orchestrated OS policy assignment payload. */
112 orchestratedResource: OrchestratedResource | undefined;
113 /** Orchestration scope selectors. */
114 orchestrationScope: OrchestrationScope | undefined;
115 /** Whether the orchestrator is currently applying changes. */
116 reconciling: boolean | undefined;
117 /** Server checksum used on update and delete. */
118 etag: string | undefined;
119 /** RFC3339 creation timestamp. */
120 createTime: string | undefined;
121 /** RFC3339 last-update timestamp. */
122 updateTime: string | undefined;
123 },
124 never,
125 Providers
126>;
127
128/**
129 * A folder-scoped OS Config policy orchestrator.
130 *
131 * Policy orchestrators upsert or delete OS policy assignments across
132 * descendant projects and zones. `folderId` and `policyOrchestratorId`
133 * are identity. Action, state, description, labels, the orchestrated
134 * payload, and scope update in place.
135 *
136 * ### Creating a Policy Orchestrator
137 * **Example:** Generated name on the project's parent folder
138 * ```typescript
139 * const orch = yield* GCP.OSConfig.FoldersLocationsGlobalPolicyOrchestrator(
140 * "Debian",
141 * { labels: { env: "test" } },
142 * );
143 * ```
144 *
145 * **Example:** Explicit folder and id
146 * ```typescript
147 * const orch = yield* GCP.OSConfig.FoldersLocationsGlobalPolicyOrchestrator(
148 * "Debian",
149 * {
150 * folderId: "123456",
151 * policyOrchestratorId: "debian-noop",
152 * state: "STOPPED",
153 * },
154 * );
155 * ```
156 *
157 * @resource
158 * @category OSConfig
159 */
160export const FoldersLocationsGlobalPolicyOrchestrator =
161 Resource<FoldersLocationsGlobalPolicyOrchestrator>(
162 "GCP.OSConfig.FoldersLocationsGlobalPolicyOrchestrator",
163 );
164
165const toAttrs = (
166 item: osconfig.GoogleCloudOsconfigV2__PolicyOrchestrator,
167 project: string,
168) => {
169 const name = item.name ?? "";
170 const parsed = parseName(name);
171 return {
172 name,
173 policyOrchestratorId: parsed.id,
174 parent: parsed.parent,
175 folderId: parsed.folder,
176 project,
177 action: item.action,
178 state: item.state,
179 description: item.description,
180 labels: userLabels(item.labels),
181 orchestratedResource: item.orchestratedResource,
182 orchestrationScope: item.orchestrationScope,
183 reconciling: item.reconciling,
184 etag: item.etag,
185 createTime: item.createTime,
186 updateTime: item.updateTime,
187 };
188};
189
190const getByName = (name: string) =>
191 name.length === 0
192 ? Effect.succeed(undefined)
193 : osconfig
194 .getFoldersLocationsGlobalPolicyOrchestrators({ name })
195 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
196
197const desiredBody = (
198 news: FoldersLocationsGlobalPolicyOrchestratorProps,
199 policyOrchestratorId: string,
200 desiredLabels: Record<string, string>,
201): osconfig.GoogleCloudOsconfigV2__PolicyOrchestrator => ({
202 action: news.action ?? DEFAULT_ACTION,
203 state: news.state ?? DEFAULT_STATE,
204 description: news.description,
205 labels: desiredLabels,
206 orchestratedResource: orchestratedPayload(
207 news.orchestratedResource ??
208 defaultOrchestratedResource(policyOrchestratorId.slice(0, 63)),
209 ),
210 orchestrationScope: news.orchestrationScope,
211});
212
213export const FoldersLocationsGlobalPolicyOrchestratorProvider = () =>
214 Provider.succeed(FoldersLocationsGlobalPolicyOrchestrator, {
215 stables: [
216 "name",
217 "policyOrchestratorId",
218 "parent",
219 "folderId",
220 "project",
221 "createTime",
222 ],
223
224 diff: Effect.fn(function* ({ news, olds, output }) {
225 if (!isResolved(news)) return undefined;
226 const previousFolder =
227 olds?.folderId !== undefined
228 ? folderParent(olds.folderId)
229 : output?.folderId
230 ? folderParent(output.folderId)
231 : "";
232 const nextFolder =
233 news.folderId !== undefined
234 ? folderParent(news.folderId)
235 : previousFolder;
236 return replaceOnIdentity({
237 previousId: olds?.policyOrchestratorId ?? output?.policyOrchestratorId,
238 nextId:
239 news.policyOrchestratorId ??
240 olds?.policyOrchestratorId ??
241 output?.policyOrchestratorId,
242 previousParent: previousFolder,
243 nextParent: nextFolder,
244 });
245 }),
246
247 read: Effect.fn(function* ({ id, olds, output }) {
248 const env = yield* GcpEnvironment.current;
249 const policyOrchestratorId = yield* toPhysicalId(
250 id,
251 olds?.policyOrchestratorId,
252 output?.policyOrchestratorId,
253 );
254 let name = output?.name ?? "";
255 if (name.length === 0) {
256 const folder =
257 olds?.folderId !== undefined
258 ? folderParent(olds.folderId)
259 : output?.folderId
260 ? folderParent(output.folderId)
261 : yield* tryResolveFolder();
262 if (folder === undefined) return undefined;
263 name = resourceName(globalParent(folder), policyOrchestratorId);
264 }
265 const existing = yield* getByName(name);
266 if (existing === undefined) return undefined;
267 const attrs = toAttrs(existing, env.project);
268 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
269 ? attrs
270 : Unowned(attrs);
271 }),
272
273 list: () =>
274 Effect.gen(function* () {
275 const env = yield* GcpEnvironment.current;
276 const folder = yield* tryResolveFolder();
277 if (folder === undefined) return [];
278 const items = yield* collectOrchestrators(
279 osconfig.listFoldersLocationsGlobalPolicyOrchestrators.pages({
280 parent: globalParent(folder),
281 pageSize: 1000,
282 }),
283 );
284 return items
285 .filter((item) => hasAlchemyLabelMap(item.labels))
286 .map((item) => toAttrs(item, env.project));
287 }),
288
289 reconcile: Effect.fn(function* ({ id, news, output }) {
290 const env = yield* GcpEnvironment.current;
291 const policyOrchestratorId = yield* toPhysicalId(
292 id,
293 news.policyOrchestratorId,
294 output?.policyOrchestratorId,
295 );
296 const folder = yield* resolveFolder(news.folderId, output?.folderId);
297 const parent = globalParent(folder);
298 const name = resourceName(parent, policyOrchestratorId);
299 const desiredLabels = {
300 ...toLabels(news.labels),
301 ...(yield* createInternalLabels(id)),
302 };
303 const desired = desiredBody(news, policyOrchestratorId, desiredLabels);
304
305 let current = yield* getByName(output?.name ?? name);
306
307 if (current === undefined) {
308 const created = yield* osconfig
309 .createFoldersLocationsGlobalPolicyOrchestrators({
310 parent,
311 policyOrchestratorId,
312 body: desired,
313 })
314 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
315 if (created !== undefined) {
316 yield* waitForOperation(created);
317 }
318 current = yield* waitUntilExists(getByName(name), name);
319 }
320
321 if (current === undefined) {
322 return yield* new OsconfigNotResolved({ name });
323 }
324
325 const observedLabels = tagRecord(current.labels);
326 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
327 const labelsChanged = upsert.length > 0 || removed.length > 0;
328 const actionChanged =
329 (current.action ?? DEFAULT_ACTION) !==
330 (desired.action ?? DEFAULT_ACTION);
331 const stateChanged =
332 (current.state ?? DEFAULT_STATE) !== (desired.state ?? DEFAULT_STATE);
333 const descriptionChanged =
334 (current.description ?? "") !== (desired.description ?? "");
335 const resourceChanged =
336 fingerprint(orchestratedPayload(current.orchestratedResource)) !==
337 fingerprint(desired.orchestratedResource);
338 const scopeChanged =
339 desired.orchestrationScope !== undefined &&
340 fingerprint(current.orchestrationScope) !==
341 fingerprint(desired.orchestrationScope);
342 const mask = fieldMask([
343 labelsChanged && "labels",
344 actionChanged && "action",
345 stateChanged && "state",
346 descriptionChanged && "description",
347 resourceChanged && "orchestratedResource",
348 scopeChanged && "orchestrationScope",
349 ]);
350
351 if (mask.length > 0) {
352 const operation =
353 yield* osconfig.patchFoldersLocationsGlobalPolicyOrchestrators({
354 name: current.name ?? name,
355 updateMask: mask,
356 body: {
357 ...desired,
358 name: current.name ?? name,
359 etag: current.etag,
360 },
361 });
362 yield* waitForOperation(operation);
363 current = yield* waitUntilExists(
364 getByName(current.name ?? name),
365 current.name ?? name,
366 );
367 }
368
369 return toAttrs(current, env.project);
370 }),
371
372 delete: Effect.fn(function* ({ output }) {
373 if (!output.name) return;
374 const operation = yield* osconfig
375 .deleteFoldersLocationsGlobalPolicyOrchestrators({
376 name: output.name,
377 etag: output.etag,
378 })
379 .pipe(
380 Effect.retry({
381 while: (error) => error._tag === "Conflict",
382 times: 8,
383 schedule: Schedule.spaced("2 seconds"),
384 }),
385 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
386 );
387 if (operation !== undefined) {
388 yield* waitForOperation(operation, { notFoundOk: true });
389 }
390 yield* waitUntilGone(getByName(output.name), output.name);
391 }),
392 });