1import * as dlp from "@distilled.cloud/gcp/dlp_v2";
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 { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 encodeOwnership,
14 hasOwnershipMarker,
15 jsonEqual,
16 lastSegment,
17 locationOf,
18 locationParent,
19 normalizeLocation,
20 ownedByAlchemy,
21 parseOwnership,
22 replaceOnIdentity,
23 toResourceId,
24 updateMaskOf,
25} from "./internal.ts";
26
27type InspectJobConfig = dlp.GooglePrivacyDlpV2InspectJobConfig;
28type JobTriggerStatus = dlp.GooglePrivacyDlpV2JobTriggerStatusEnum;
29type JobTriggerTrigger = dlp.GooglePrivacyDlpV2Trigger;
30
31const DEFAULT_STATUS: JobTriggerStatus = "PAUSED";
32const DEFAULT_TRIGGERS: JobTriggerTrigger[] = [{ manual: {} }];
33const DEFAULT_INSPECT_JOB: InspectJobConfig = {
34 inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
35 storageConfig: { hybridOptions: {} },
36};
37
38export type LocationsJobTriggerProps = {
39 /**
40 * Trigger id (the `{jobTrigger}` segment of
41 * `projects/{project}/locations/{location}/jobTriggers/{jobTrigger}`).
42 * If omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+` and
43 * is at most 100 characters. Immutable — changing it replaces the
44 * trigger.
45 */
46 triggerId?: string;
47 /**
48 * Processing location (`us-central1`, `global`, …). Immutable —
49 * changing it replaces the trigger.
50 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Display name (max 100 characters).
55 */
56 displayName?: string;
57 /**
58 * Human-readable description (max 256 characters). Job triggers have no
59 * labels field, so Alchemy ownership is stored in a `[alchemy …]`
60 * prefix and stripped from attributes.
61 */
62 description?: string;
63 /**
64 * Trigger status. `PAUSED` stores the trigger without starting jobs.
65 * @default "PAUSED"
66 */
67 status?: JobTriggerStatus | (string & {});
68 /**
69 * Inspect job configuration run when the trigger fires. Defaults to a
70 * hybrid EMAIL_ADDRESS inspect job.
71 */
72 inspectJob?: InspectJobConfig;
73 /**
74 * Triggers OR'ed together. Defaults to a manual (hybrid) trigger.
75 */
76 triggers?: JobTriggerTrigger[];
77};
78
79export type LocationsJobTrigger = Resource<
80 "GCP.DLP.LocationsJobTrigger",
81 LocationsJobTriggerProps,
82 {
83 /** Full resource name `projects/{project}/locations/{location}/jobTriggers/{id}`. */
84 name: string;
85 /** Trigger id (last path segment). */
86 triggerId: string;
87 /** Location id. */
88 location: string;
89 /** Project id. */
90 project: string;
91 /** Display name. */
92 displayName: string | undefined;
93 /** User description with the Alchemy ownership prefix stripped. */
94 description: string | undefined;
95 /** Trigger status. */
96 status: string | undefined;
97 /** Inspect job configuration. */
98 inspectJob: InspectJobConfig | undefined;
99 /** Trigger conditions. */
100 triggers: JobTriggerTrigger[];
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 /** RFC3339 last-run timestamp. */
106 lastRunTime: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A regional Cloud DLP job trigger (`projects.locations.jobTriggers`).
114 *
115 * Job triggers have no labels field, so Alchemy stamps ownership into the
116 * description for `list` / nuke. Location and id are identity — changing
117 * them replaces the trigger. Display name, description, status, inspect
118 * job, and triggers update in place.
119 *
120 * ### Creating a Job Trigger
121 * **Example:** Paused hybrid inspect trigger
122 * ```typescript
123 * const trigger = yield* GCP.DLP.LocationsJobTrigger("Inbox", {
124 * displayName: "inbox scan",
125 * description: "paused hybrid inspect",
126 * status: "PAUSED",
127 * inspectJob: {
128 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
129 * storageConfig: { hybridOptions: {} },
130 * },
131 * triggers: [{ manual: {} }],
132 * });
133 * ```
134 *
135 * ### Updating a Job Trigger
136 * **Example:** Change the description
137 * ```typescript
138 * const trigger = yield* GCP.DLP.LocationsJobTrigger("Inbox", {
139 * displayName: "inbox scan",
140 * description: "paused hybrid inspect v2",
141 * status: "PAUSED",
142 * inspectJob: {
143 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
144 * storageConfig: { hybridOptions: {} },
145 * },
146 * triggers: [{ manual: {} }],
147 * });
148 * ```
149 *
150 * @resource
151 * @category DLP
152 */
153export const LocationsJobTrigger = Resource<LocationsJobTrigger>(
154 "GCP.DLP.LocationsJobTrigger",
155);
156
157export class LocationsJobTriggerNotResolved extends Data.TaggedError(
158 "GCP.DLP.LocationsJobTriggerNotResolved",
159)<{
160 name: string;
161}> {}
162
163const resourceName = (project: string, location: string, triggerId: string) =>
164 `${locationParent(project, location)}/jobTriggers/${triggerId}`;
165
166const toAttrs = (
167 trigger: dlp.GooglePrivacyDlpV2JobTrigger,
168 project: string,
169 region: string,
170) => {
171 const name = trigger.name ?? "";
172 const parsed = parseOwnership(trigger.description);
173 return {
174 name,
175 triggerId: lastSegment(name),
176 location: locationOf(name, region),
177 project,
178 displayName: trigger.displayName,
179 description: parsed.text,
180 status: trigger.status,
181 inspectJob: trigger.inspectJob,
182 triggers: trigger.triggers ?? [],
183 createTime: trigger.createTime,
184 updateTime: trigger.updateTime,
185 lastRunTime: trigger.lastRunTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : dlp
193 .getProjectsLocationsJobTriggers({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196export const LocationsJobTriggerProvider = () =>
197 Provider.succeed(LocationsJobTrigger, {
198 stables: ["name", "triggerId", "location", "project", "createTime"],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const env = yield* GcpEnvironment.current;
203 const previousId = olds?.triggerId ?? output?.triggerId;
204 const idChanged =
205 previousId !== undefined &&
206 news.triggerId !== undefined &&
207 news.triggerId !== previousId;
208 const previousLocation = olds?.location ?? output?.location;
209 const locationChanged =
210 previousLocation !== undefined &&
211 normalizeLocation(news.location ?? previousLocation, env.region) !==
212 normalizeLocation(previousLocation, env.region);
213 return replaceOnIdentity(idChanged || locationChanged);
214 }),
215
216 read: Effect.fn(function* ({ id, olds, output }) {
217 const env = yield* GcpEnvironment.current;
218 const location = normalizeLocation(
219 olds?.location ?? output?.location,
220 env.region,
221 );
222 const triggerId = yield* toResourceId(
223 id,
224 olds?.triggerId,
225 output?.triggerId,
226 );
227 const name =
228 output?.name ?? resourceName(env.project, location, triggerId);
229 const existing = yield* getByName(name);
230 if (existing === undefined) return undefined;
231 const attrs = toAttrs(existing, env.project, env.region);
232 return (yield* ownedByAlchemy(id, existing.description))
233 ? attrs
234 : Unowned(attrs);
235 }),
236
237 list: () =>
238 Effect.gen(function* () {
239 const env = yield* GcpEnvironment.current;
240 const items = yield* collectPages(
241 dlp.listProjectsLocationsJobTriggers.pages({
242 parent: locationParent(env.project, env.region),
243 pageSize: 100,
244 }),
245 (page) => page.jobTriggers,
246 ).pipe(
247 Effect.catchTag("NotFound", () =>
248 Effect.succeed([] as dlp.GooglePrivacyDlpV2JobTrigger[]),
249 ),
250 );
251 return items
252 .filter((trigger) => hasOwnershipMarker(trigger.description))
253 .map((trigger) => toAttrs(trigger, env.project, env.region));
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output }) {
257 const env = yield* GcpEnvironment.current;
258 const location = normalizeLocation(
259 news.location ?? output?.location,
260 env.region,
261 );
262 const triggerId = yield* toResourceId(
263 id,
264 news.triggerId,
265 output?.triggerId,
266 );
267 const name = resourceName(env.project, location, triggerId);
268 const ownership = yield* createInternalLabels(id);
269 const description = encodeOwnership(ownership, news.description);
270 const status = news.status ?? DEFAULT_STATUS;
271 const inspectJob = news.inspectJob ?? DEFAULT_INSPECT_JOB;
272 const triggers = news.triggers ?? DEFAULT_TRIGGERS;
273 const jobTrigger: dlp.GooglePrivacyDlpV2JobTrigger = {
274 displayName: news.displayName,
275 description,
276 status,
277 inspectJob,
278 triggers,
279 };
280
281 let current = yield* getByName(output?.name ?? name);
282
283 if (current === undefined) {
284 const created = yield* dlp
285 .createProjectsLocationsJobTriggers({
286 parent: locationParent(env.project, location),
287 body: {
288 triggerId,
289 jobTrigger,
290 },
291 })
292 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
293 current = created ?? undefined;
294 }
295
296 if (current === undefined) {
297 return yield* new LocationsJobTriggerNotResolved({ name });
298 }
299
300 const displayChanged = !jsonEqual(current.displayName, news.displayName);
301 const descriptionChanged = (current.description ?? "") !== description;
302 const statusChanged = (current.status ?? "") !== status;
303 const inspectChanged = !jsonEqual(current.inspectJob, inspectJob);
304 const triggersChanged = !jsonEqual(current.triggers ?? [], triggers);
305
306 if (
307 displayChanged ||
308 descriptionChanged ||
309 statusChanged ||
310 inspectChanged ||
311 triggersChanged
312 ) {
313 current = yield* dlp.patchProjectsLocationsJobTriggers({
314 name: current.name ?? name,
315 body: {
316 updateMask: updateMaskOf(
317 displayChanged ? "displayName" : undefined,
318 descriptionChanged ? "description" : undefined,
319 statusChanged ? "status" : undefined,
320 inspectChanged ? "inspectJob" : undefined,
321 triggersChanged ? "triggers" : undefined,
322 ),
323 jobTrigger,
324 },
325 });
326 }
327
328 return toAttrs(current, env.project, env.region);
329 }),
330
331 delete: Effect.fn(function* ({ output }) {
332 yield* dlp
333 .deleteProjectsLocationsJobTriggers({ name: output.name })
334 .pipe(Effect.catchTag("NotFound", () => Effect.void));
335 }),
336 });