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