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 DEFAULT_REGIONAL_LOCATION,
13 MAX_DISPLAY_NAME_LENGTH,
14 collectPages,
15 encodeOwnershipLine,
16 hasOwnershipMarker,
17 jsonEqual,
18 lastSegment,
19 locationOf,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseOwnership,
24 projectOf,
25 replaceOnIdentity,
26 toResourceId,
27 updateMaskOf,
28} from "./internal.ts";
29
30type DiscoveryTarget = dlp.GooglePrivacyDlpV2DiscoveryTarget;
31type DiscoveryConfigStatus = dlp.GooglePrivacyDlpV2DiscoveryConfigStatusEnum;
32type DataProfileAction = dlp.GooglePrivacyDlpV2DataProfileAction;
33type ProcessingLocation = dlp.GooglePrivacyDlpV2ProcessingLocation;
34
35export type DiscoveryConfigProps = {
36 /**
37 * Config id (the `{discoveryConfig}` segment of
38 * `projects/{project}/locations/{location}/discoveryConfigs/{id}`). If
39 * omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+` and
40 * is at most 100 characters. Immutable — changing it replaces the
41 * config.
42 */
43 configId?: string;
44 /**
45 * Processing location (`us`, `us-central1`, …). Immutable — changing it
46 * replaces the config.
47 * @default "us"
48 */
49 location?: string;
50 /**
51 * Display name (max 100 characters). Discovery configs have no labels
52 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
53 * stripped from attributes.
54 */
55 displayName?: string;
56 /**
57 * Config status. `PAUSED` stores the config without profiling.
58 * @default "PAUSED"
59 */
60 status?: DiscoveryConfigStatus | (string & {});
61 /**
62 * Targets describing what to scan.
63 */
64 targets?: DiscoveryTarget[];
65 /**
66 * Inspect templates used for profile generation.
67 */
68 inspectTemplates?: string[];
69 /**
70 * Actions to run after scanning.
71 */
72 actions?: DataProfileAction[];
73 /**
74 * Processing location configuration.
75 */
76 processingLocation?: ProcessingLocation;
77};
78
79export type DiscoveryConfig = Resource<
80 "GCP.DLP.DiscoveryConfig",
81 DiscoveryConfigProps,
82 {
83 /** Full resource name. */
84 name: string;
85 /** Config id (last path segment). */
86 configId: string;
87 /** Location id. */
88 location: string;
89 /** Project id. */
90 project: string;
91 /** User display name with the Alchemy ownership prefix stripped. */
92 displayName: string | undefined;
93 /** Config status. */
94 status: string | undefined;
95 /** Discovery targets. */
96 targets: DiscoveryTarget[];
97 /** Inspect templates. */
98 inspectTemplates: string[];
99 /** Actions. */
100 actions: DataProfileAction[];
101 /** Processing location. */
102 processingLocation: ProcessingLocation | undefined;
103 /** RFC3339 last-run timestamp. */
104 lastRunTime: string | undefined;
105 /** RFC3339 creation timestamp. */
106 createTime: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A location-scoped Cloud DLP discovery config.
116 *
117 * Discovery configs have no labels field, so Alchemy stamps ownership
118 * into the display name for `list` / nuke. Location and id are identity
119 * — changing them replaces the config. Display name, status, targets,
120 * and templates update in place.
121 *
122 * ### Creating a Discovery Config
123 * **Example:** Paused Cloud Storage target
124 * ```typescript
125 * const template = yield* GCP.DLP.LocationsInspectTemplate("Emails", {
126 * location: "us",
127 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
128 * });
129 * const config = yield* GCP.DLP.DiscoveryConfig("Profiles", {
130 * location: "us",
131 * displayName: "paused storage",
132 * status: "PAUSED",
133 * inspectTemplates: [template.name],
134 * targets: [
135 * {
136 * cloudStorageTarget: {
137 * filter: { others: {} },
138 * disabled: {},
139 * },
140 * },
141 * ],
142 * });
143 * ```
144 *
145 * @resource
146 * @category DLP
147 */
148export const DiscoveryConfig = Resource<DiscoveryConfig>(
149 "GCP.DLP.DiscoveryConfig",
150);
151
152export class DiscoveryConfigNotResolved extends Data.TaggedError(
153 "GCP.DLP.DiscoveryConfigNotResolved",
154)<{
155 name: string;
156}> {}
157
158const DEFAULT_STATUS: DiscoveryConfigStatus = "PAUSED";
159
160const resourceName = (project: string, location: string, configId: string) =>
161 `${locationParent(project, location)}/discoveryConfigs/${configId}`;
162
163const toAttrs = (
164 config: dlp.GooglePrivacyDlpV2DiscoveryConfig,
165 project: string,
166) => {
167 const name = config.name ?? "";
168 const parsed = parseOwnership(config.displayName);
169 return {
170 name,
171 configId: lastSegment(name),
172 location: locationOf(name, DEFAULT_REGIONAL_LOCATION),
173 project: projectOf(name) || project,
174 displayName: parsed.text,
175 status: config.status,
176 targets: config.targets ?? [],
177 inspectTemplates: config.inspectTemplates ?? [],
178 actions: config.actions ?? [],
179 processingLocation: config.processingLocation,
180 lastRunTime: config.lastRunTime,
181 createTime: config.createTime,
182 updateTime: config.updateTime,
183 };
184};
185
186const getByName = (name: string) =>
187 name.length === 0
188 ? Effect.succeed(undefined)
189 : dlp
190 .getProjectsLocationsDiscoveryConfigs({ name })
191 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
192
193export const DiscoveryConfigProvider = () =>
194 Provider.succeed(DiscoveryConfig, {
195 stables: ["name", "configId", "location", "project", "createTime"],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const previousId = olds?.configId ?? output?.configId;
200 const idChanged =
201 previousId !== undefined &&
202 news.configId !== undefined &&
203 news.configId !== previousId;
204 const previousLocation = olds?.location ?? output?.location;
205 const locationChanged =
206 previousLocation !== undefined &&
207 news.location !== undefined &&
208 normalizeLocation(news.location, DEFAULT_REGIONAL_LOCATION) !==
209 normalizeLocation(previousLocation, DEFAULT_REGIONAL_LOCATION);
210 return replaceOnIdentity(idChanged || locationChanged);
211 }),
212
213 read: Effect.fn(function* ({ id, olds, output }) {
214 const env = yield* GcpEnvironment.current;
215 const configId = yield* toResourceId(
216 id,
217 olds?.configId,
218 output?.configId,
219 );
220 const location = normalizeLocation(
221 olds?.location ?? output?.location,
222 DEFAULT_REGIONAL_LOCATION,
223 );
224 const name =
225 output?.name ?? resourceName(env.project, location, configId);
226 const existing = yield* getByName(name);
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing, env.project);
229 return (yield* ownedByAlchemy(id, existing.displayName))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const env = yield* GcpEnvironment.current;
237 const items = yield* collectPages(
238 dlp.listProjectsLocationsDiscoveryConfigs.pages({
239 parent: locationParent(env.project, DEFAULT_REGIONAL_LOCATION),
240 pageSize: 100,
241 }),
242 (page) => page.discoveryConfigs,
243 ).pipe(
244 Effect.catchTag("NotFound", () =>
245 Effect.succeed([] as dlp.GooglePrivacyDlpV2DiscoveryConfig[]),
246 ),
247 );
248 return items
249 .filter((config) => hasOwnershipMarker(config.displayName))
250 .map((config) => toAttrs(config, env.project));
251 }),
252
253 reconcile: Effect.fn(function* ({ id, news, output }) {
254 const env = yield* GcpEnvironment.current;
255 const location = normalizeLocation(
256 news.location ?? output?.location ?? DEFAULT_REGIONAL_LOCATION,
257 DEFAULT_REGIONAL_LOCATION,
258 );
259 const configId = yield* toResourceId(id, news.configId, output?.configId);
260 const name = resourceName(env.project, location, configId);
261 const ownership = yield* createInternalLabels(id);
262 const displayName = encodeOwnershipLine(
263 ownership,
264 news.displayName,
265 MAX_DISPLAY_NAME_LENGTH,
266 );
267 const status = news.status ?? DEFAULT_STATUS;
268 const body: dlp.GooglePrivacyDlpV2DiscoveryConfig = {
269 displayName,
270 status,
271 targets: news.targets,
272 inspectTemplates: news.inspectTemplates,
273 actions: news.actions,
274 processingLocation: news.processingLocation,
275 };
276
277 let current = yield* getByName(output?.name ?? name);
278
279 if (current === undefined) {
280 const created = yield* dlp
281 .createProjectsLocationsDiscoveryConfigs({
282 parent: locationParent(env.project, location),
283 body: {
284 configId,
285 discoveryConfig: body,
286 },
287 })
288 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
289 current = created ?? undefined;
290 }
291
292 if (current === undefined) {
293 return yield* new DiscoveryConfigNotResolved({ name });
294 }
295
296 const displayChanged = (current.displayName ?? "") !== displayName;
297 const statusChanged = (current.status ?? "") !== status;
298 const targetsChanged = !jsonEqual(current.targets, news.targets);
299 const templatesChanged = !jsonEqual(
300 current.inspectTemplates,
301 news.inspectTemplates,
302 );
303 const actionsChanged = !jsonEqual(current.actions, news.actions);
304 const processingChanged = !jsonEqual(
305 current.processingLocation,
306 news.processingLocation,
307 );
308
309 if (
310 displayChanged ||
311 statusChanged ||
312 targetsChanged ||
313 templatesChanged ||
314 actionsChanged ||
315 processingChanged
316 ) {
317 current = yield* dlp.patchProjectsLocationsDiscoveryConfigs({
318 name: current.name ?? name,
319 body: {
320 discoveryConfig: body,
321 updateMask: updateMaskOf(
322 displayChanged ? "displayName" : undefined,
323 statusChanged ? "status" : undefined,
324 targetsChanged ? "targets" : undefined,
325 templatesChanged ? "inspectTemplates" : undefined,
326 actionsChanged ? "actions" : undefined,
327 processingChanged ? "processingLocation" : undefined,
328 ),
329 },
330 });
331 }
332
333 return toAttrs(current, env.project);
334 }),
335
336 delete: Effect.fn(function* ({ output }) {
337 yield* dlp
338 .deleteProjectsLocationsDiscoveryConfigs({ name: output.name })
339 .pipe(Effect.catchTag("NotFound", () => Effect.void));
340 }),
341 });