1import * as dlp from "@distilled.cloud/gcp/dlp_v2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_LOCATION,
13 DlpNotResolved,
14 encodeDescription,
15 fingerprint,
16 hasOwnershipMarker,
17 lastSegment,
18 locationParentsOf,
19 organizationLocationParent,
20 normalizeLocation,
21 organizationIdOf,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOn,
26 resolveOrganization,
27 sameText,
28 toPhysicalId,
29 tryResolveOrganization,
30 updateMaskOf,
31} from "./internal.ts";
32
33export type InspectJobConfig = dlp.GooglePrivacyDlpV2InspectJobConfig;
34export type JobTriggerTrigger = dlp.GooglePrivacyDlpV2Trigger;
35export type JobTriggerStatus =
36 | dlp.GooglePrivacyDlpV2JobTriggerStatusEnum
37 | (string & {});
38
39export type OrganizationsLocationsJobTriggerProps = {
40 /**
41 * Trigger id (the `{jobTrigger}` segment of
42 * `organizations/{organization}/locations/{location}/jobTriggers/{jobTrigger}`).
43 * If omitted, a unique id is generated. Letters, digits, hyphens, and
44 * underscores; max 100 characters. Immutable — changing it replaces
45 * the trigger.
46 */
47 triggerId?: string;
48 /**
49 * Parent organization (`organizations/{organization}` or the numeric
50 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
51 * Manager ancestor. Immutable — changing it replaces the trigger.
52 */
53 organization?: string;
54 /**
55 * Processing location (`us-central1`, `global`, `us`, …). Immutable —
56 * changing it replaces the trigger.
57 * @default "global"
58 */
59 location?: string;
60 /**
61 * Human-readable display name (max 100 characters).
62 */
63 displayName?: string;
64 /**
65 * Human-readable description (max 256 characters). Job triggers have no
66 * labels field, so Alchemy ownership is stored in a `[alchemy …]` prefix
67 * and stripped from attributes.
68 */
69 description?: string;
70 /**
71 * Trigger status. Prefer `PAUSED` unless jobs should run.
72 * @default "PAUSED"
73 */
74 status?: JobTriggerStatus;
75 /**
76 * Events that start a job. At least one trigger is required.
77 */
78 triggers: JobTriggerTrigger[];
79 /**
80 * Inspect job configuration (what to scan and which detectors to run).
81 */
82 inspectJob: InspectJobConfig;
83};
84
85export type OrganizationsLocationsJobTrigger = Resource<
86 "GCP.DLP.OrganizationsLocationsJobTrigger",
87 OrganizationsLocationsJobTriggerProps,
88 {
89 /** Full resource name. */
90 name: string;
91 /** Trigger id (last path segment). */
92 triggerId: string;
93 /** Organization resource name. */
94 organization: string;
95 /** Organization id. */
96 organizationId: string;
97 /** Location id. */
98 location: string;
99 /** Project id of the deploying stack. */
100 project: string;
101 /** User display name. */
102 displayName: string | undefined;
103 /** User description with the Alchemy ownership prefix stripped. */
104 description: string | undefined;
105 /** Trigger status. */
106 status: string;
107 /** Triggers that start a job. */
108 triggers: JobTriggerTrigger[];
109 /** Inspect job configuration. */
110 inspectJob: InspectJobConfig | undefined;
111 /** RFC3339 creation timestamp. */
112 createTime: string | undefined;
113 /** RFC3339 last-update timestamp. */
114 updateTime: string | undefined;
115 /** RFC3339 last-run timestamp. */
116 lastRunTime: string | undefined;
117 },
118 never,
119 Providers
120>;
121
122/**
123 * An organization-scoped Sensitive Data Protection job trigger that
124 * inspects storage on a schedule.
125 *
126 * Job triggers have no labels field — Alchemy stamps ownership into the
127 * description so `list` / nuke can find them. Trigger id, organization,
128 * and location are identity. Display name, description, status, triggers,
129 * and inspect job update in place. Keep `status` as `PAUSED` unless jobs
130 * should run.
131 *
132 * ### Creating a Job Trigger
133 * **Example:** Daily paused Cloud Storage inspect
134 * ```typescript
135 * const trigger = yield* GCP.DLP.OrganizationsLocationsJobTrigger(
136 * "ScanBucket",
137 * {
138 * status: "PAUSED",
139 * triggers: [
140 * { schedule: { recurrencePeriodDuration: "86400s" } },
141 * ],
142 * inspectJob: {
143 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
144 * storageConfig: {
145 * cloudStorageOptions: {
146 * fileSet: { url: "gs://my-bucket/" },
147 * },
148 * },
149 * },
150 * },
151 * );
152 * ```
153 *
154 * @resource
155 * @category DLP
156 */
157export const OrganizationsLocationsJobTrigger =
158 Resource<OrganizationsLocationsJobTrigger>(
159 "GCP.DLP.OrganizationsLocationsJobTrigger",
160 );
161
162const DEFAULT_STATUS = "PAUSED" satisfies JobTriggerStatus;
163
164const resourceName = (
165 organization: string,
166 location: string,
167 triggerId: string,
168) =>
169 `${organizationLocationParent(organization, location)}/jobTriggers/${triggerId}`;
170
171const toAttrs = (
172 trigger: dlp.GooglePrivacyDlpV2JobTrigger,
173 organization: string,
174 project: string,
175) => {
176 const name = trigger.name ?? "";
177 const parsed = parseName(name, "jobTriggers");
178 const ownership = parseOwnership(trigger.description);
179 return {
180 name,
181 triggerId: parsed.id || lastSegment(name),
182 organization,
183 organizationId: organizationIdOf(organization),
184 location: parsed.location || DEFAULT_LOCATION,
185 project,
186 displayName: trigger.displayName,
187 description: ownership.text,
188 status: trigger.status ?? DEFAULT_STATUS,
189 triggers: trigger.triggers ?? [],
190 inspectJob: trigger.inspectJob,
191 createTime: trigger.createTime,
192 updateTime: trigger.updateTime,
193 lastRunTime: trigger.lastRunTime,
194 };
195};
196
197const getByName = (name: string) =>
198 name.length === 0
199 ? Effect.succeed(undefined)
200 : dlp
201 .getOrganizationsLocationsJobTriggers({ name })
202 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
203
204const listAt = (parent: string, organization: string, project: string) =>
205 dlp.listOrganizationsLocationsJobTriggers
206 .pages({ parent, pageSize: 100, type: "INSPECT_JOB" })
207 .pipe(
208 Stream.flatMap((page) => Stream.fromIterable(page.jobTriggers ?? [])),
209 Stream.filter((trigger) => hasOwnershipMarker(trigger.description)),
210 Stream.map((trigger) => toAttrs(trigger, organization, project)),
211 Stream.runCollect,
212 Effect.map((chunk) => Array.from(chunk)),
213 Effect.catchTag("NotFound", () => Effect.succeed([])),
214 );
215
216const statusOf = (value: string | undefined) => value ?? DEFAULT_STATUS;
217
218export const OrganizationsLocationsJobTriggerProvider = () =>
219 Provider.succeed(OrganizationsLocationsJobTrigger, {
220 stables: [
221 "name",
222 "triggerId",
223 "organization",
224 "organizationId",
225 "location",
226 "project",
227 "createTime",
228 ],
229
230 diff: Effect.fn(function* ({ news, olds, output }) {
231 if (!isResolved(news)) return undefined;
232 const previousLocation = normalizeLocation(
233 olds?.location ?? output?.location,
234 );
235 const nextLocation = normalizeLocation(
236 news.location ?? olds?.location ?? output?.location,
237 );
238 return (
239 replaceOn(olds?.triggerId ?? output?.triggerId, news.triggerId) ??
240 replaceOn(
241 olds?.organization ?? output?.organization,
242 news.organization,
243 ) ??
244 replaceOn(previousLocation, nextLocation)
245 );
246 }),
247
248 read: Effect.fn(function* ({ id, olds, output }) {
249 const env = yield* GcpEnvironment.current;
250 const organization = yield* resolveOrganization(
251 olds?.organization ?? output?.organization,
252 output?.organization,
253 );
254 const location = normalizeLocation(olds?.location ?? output?.location);
255 const triggerId = yield* toPhysicalId(
256 id,
257 olds?.triggerId,
258 output?.triggerId,
259 );
260 const name =
261 output?.name ?? resourceName(organization, location, triggerId);
262 const existing = yield* getByName(name);
263 if (existing === undefined) return undefined;
264 const attrs = toAttrs(existing, organization, env.project);
265 return (yield* ownedByAlchemy(id, existing.description))
266 ? attrs
267 : Unowned(attrs);
268 }),
269
270 list: () =>
271 Effect.gen(function* () {
272 const env = yield* GcpEnvironment.current;
273 const organization = yield* tryResolveOrganization();
274 if (organization === undefined) return [];
275 const pages = yield* Effect.forEach(
276 locationParentsOf(organization, env.region),
277 (parent) => listAt(parent, organization, env.project),
278 { concurrency: 3 },
279 );
280 return pages.flat();
281 }),
282
283 reconcile: Effect.fn(function* ({ id, news, output }) {
284 const env = yield* GcpEnvironment.current;
285 const organization = yield* resolveOrganization(
286 news.organization,
287 output?.organization,
288 );
289 const location = normalizeLocation(news.location ?? output?.location);
290 const triggerId = yield* toPhysicalId(
291 id,
292 news.triggerId,
293 output?.triggerId,
294 );
295 const parent = organizationLocationParent(organization, location);
296 const name = resourceName(organization, location, triggerId);
297 const ownership = yield* createInternalLabels(id);
298 const description = encodeDescription(ownership, news.description);
299 const displayName = news.displayName;
300 const status = statusOf(news.status);
301 const triggers = news.triggers;
302 const inspectJob = news.inspectJob;
303
304 let current = yield* getByName(output?.name ?? name);
305
306 if (current === undefined) {
307 const created = yield* dlp
308 .createOrganizationsLocationsJobTriggers({
309 parent,
310 body: {
311 triggerId,
312 jobTrigger: {
313 displayName,
314 description,
315 status,
316 triggers,
317 inspectJob,
318 },
319 },
320 })
321 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
322 current = created ?? undefined;
323 }
324
325 if (current === undefined) {
326 return yield* new DlpNotResolved({ name });
327 }
328
329 const currentName = current.name ?? name;
330 const displayChanged = !sameText(current.displayName, displayName);
331 const descriptionChanged = !sameText(current.description, description);
332 const statusChanged = !sameText(current.status, status);
333 const triggersChanged =
334 fingerprint(current.triggers) !== fingerprint(triggers);
335 const jobChanged =
336 fingerprint(current.inspectJob) !== fingerprint(inspectJob);
337 const updateMask = updateMaskOf(
338 displayChanged ? "displayName" : undefined,
339 descriptionChanged ? "description" : undefined,
340 statusChanged ? "status" : undefined,
341 triggersChanged ? "triggers" : undefined,
342 jobChanged ? "inspectJob" : undefined,
343 );
344
345 if (updateMask.length > 0) {
346 current = yield* dlp.patchOrganizationsLocationsJobTriggers({
347 name: currentName,
348 body: {
349 updateMask,
350 jobTrigger: {
351 displayName,
352 description,
353 status,
354 triggers,
355 inspectJob,
356 },
357 },
358 });
359 }
360
361 return toAttrs(current, organization, env.project);
362 }),
363
364 delete: Effect.fn(function* ({ output }) {
365 yield* dlp
366 .deleteOrganizationsLocationsJobTriggers({ name: output.name })
367 .pipe(Effect.catchTag("NotFound", () => Effect.void));
368 }),
369 });