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 InspectConfig = dlp.GooglePrivacyDlpV2InspectConfig;
28
29export type LocationsInspectTemplateProps = {
30 /**
31 * Template id (the `{inspectTemplate}` segment of
32 * `projects/{project}/locations/{location}/inspectTemplates/{inspectTemplate}`).
33 * If omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+` and
34 * is at most 100 characters. Immutable — changing it replaces the
35 * template.
36 */
37 templateId?: string;
38 /**
39 * Processing location (`us-central1`, `global`, …). Immutable —
40 * changing it replaces the template.
41 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
42 */
43 location?: string;
44 /**
45 * Display name (max 256 characters).
46 */
47 displayName?: string;
48 /**
49 * Human-readable description (max 256 characters). Inspect templates
50 * have no labels field, so Alchemy ownership is stored in a
51 * `[alchemy …]` prefix and stripped from attributes.
52 */
53 description?: string;
54 /**
55 * Inspection configuration (info types, likelihood, quotes, limits).
56 */
57 inspectConfig?: InspectConfig;
58 /**
59 * Allow limited-availability built-in info types in `inspectConfig`.
60 * @default false
61 */
62 allowLimitedAvailabilityInfoTypes?: boolean;
63};
64
65export type LocationsInspectTemplate = Resource<
66 "GCP.DLP.LocationsInspectTemplate",
67 LocationsInspectTemplateProps,
68 {
69 /** Full resource name `projects/{project}/locations/{location}/inspectTemplates/{id}`. */
70 name: string;
71 /** Template id (last path segment). */
72 templateId: string;
73 /** Location id. */
74 location: string;
75 /** Project id. */
76 project: string;
77 /** Display name. */
78 displayName: string | undefined;
79 /** User description with the Alchemy ownership prefix stripped. */
80 description: string | undefined;
81 /** Inspection configuration. */
82 inspectConfig: InspectConfig | undefined;
83 /** Whether limited-availability info types are allowed. */
84 allowLimitedAvailabilityInfoTypes: boolean;
85 /** RFC3339 creation timestamp. */
86 createTime: string | undefined;
87 /** RFC3339 last-update timestamp. */
88 updateTime: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * A regional Cloud DLP inspect template
96 * (`projects.locations.inspectTemplates`).
97 *
98 * Inspect templates have no labels field, so Alchemy stamps ownership
99 * into the description for `list` / nuke. Location and id are identity —
100 * changing them replaces the template. Display name, description, and
101 * inspect config update in place.
102 *
103 * ### Creating an Inspect Template
104 * **Example:** Email detector
105 * ```typescript
106 * const template = yield* GCP.DLP.LocationsInspectTemplate("Email", {
107 * displayName: "email",
108 * description: "detect email addresses",
109 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
110 * });
111 * ```
112 *
113 * ### Updating an Inspect Template
114 * **Example:** Raise the likelihood threshold
115 * ```typescript
116 * const template = yield* GCP.DLP.LocationsInspectTemplate("Email", {
117 * displayName: "email",
118 * description: "detect email addresses v2",
119 * inspectConfig: {
120 * infoTypes: [{ name: "EMAIL_ADDRESS" }],
121 * minLikelihood: "LIKELY",
122 * includeQuote: true,
123 * },
124 * });
125 * ```
126 *
127 * @resource
128 * @category DLP
129 */
130export const LocationsInspectTemplate = Resource<LocationsInspectTemplate>(
131 "GCP.DLP.LocationsInspectTemplate",
132);
133
134export class LocationsInspectTemplateNotResolved extends Data.TaggedError(
135 "GCP.DLP.LocationsInspectTemplateNotResolved",
136)<{
137 name: string;
138}> {}
139
140const resourceName = (project: string, location: string, templateId: string) =>
141 `${locationParent(project, location)}/inspectTemplates/${templateId}`;
142
143const toAttrs = (
144 template: dlp.GooglePrivacyDlpV2InspectTemplate,
145 project: string,
146 region: string,
147) => {
148 const name = template.name ?? "";
149 const parsed = parseOwnership(template.description);
150 return {
151 name,
152 templateId: lastSegment(name),
153 location: locationOf(name, region),
154 project,
155 displayName: template.displayName,
156 description: parsed.text,
157 inspectConfig: template.inspectConfig,
158 allowLimitedAvailabilityInfoTypes:
159 template.allowLimitedAvailabilityInfoTypes === true,
160 createTime: template.createTime,
161 updateTime: template.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 name.length === 0
167 ? Effect.succeed(undefined)
168 : dlp
169 .getProjectsLocationsInspectTemplates({ name })
170 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
171
172export const LocationsInspectTemplateProvider = () =>
173 Provider.succeed(LocationsInspectTemplate, {
174 stables: ["name", "templateId", "location", "project", "createTime"],
175
176 diff: Effect.fn(function* ({ news, olds, output }) {
177 if (!isResolved(news)) return undefined;
178 const env = yield* GcpEnvironment.current;
179 const previousId = olds?.templateId ?? output?.templateId;
180 const idChanged =
181 previousId !== undefined &&
182 news.templateId !== undefined &&
183 news.templateId !== previousId;
184 const previousLocation = olds?.location ?? output?.location;
185 const locationChanged =
186 previousLocation !== undefined &&
187 normalizeLocation(news.location ?? previousLocation, env.region) !==
188 normalizeLocation(previousLocation, env.region);
189 return replaceOnIdentity(idChanged || locationChanged);
190 }),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const env = yield* GcpEnvironment.current;
194 const location = normalizeLocation(
195 olds?.location ?? output?.location,
196 env.region,
197 );
198 const templateId = yield* toResourceId(
199 id,
200 olds?.templateId,
201 output?.templateId,
202 );
203 const name =
204 output?.name ?? resourceName(env.project, location, templateId);
205 const existing = yield* getByName(name);
206 if (existing === undefined) return undefined;
207 const attrs = toAttrs(existing, env.project, env.region);
208 return (yield* ownedByAlchemy(id, existing.description))
209 ? attrs
210 : Unowned(attrs);
211 }),
212
213 list: () =>
214 Effect.gen(function* () {
215 const env = yield* GcpEnvironment.current;
216 const items = yield* collectPages(
217 dlp.listProjectsLocationsInspectTemplates.pages({
218 parent: locationParent(env.project, env.region),
219 pageSize: 100,
220 }),
221 (page) => page.inspectTemplates,
222 ).pipe(
223 Effect.catchTag("NotFound", () =>
224 Effect.succeed([] as dlp.GooglePrivacyDlpV2InspectTemplate[]),
225 ),
226 );
227 return items
228 .filter((template) => hasOwnershipMarker(template.description))
229 .map((template) => toAttrs(template, env.project, env.region));
230 }),
231
232 reconcile: Effect.fn(function* ({ id, news, output }) {
233 const env = yield* GcpEnvironment.current;
234 const location = normalizeLocation(
235 news.location ?? output?.location,
236 env.region,
237 );
238 const templateId = yield* toResourceId(
239 id,
240 news.templateId,
241 output?.templateId,
242 );
243 const name = resourceName(env.project, location, templateId);
244 const ownership = yield* createInternalLabels(id);
245 const description = encodeOwnership(ownership, news.description);
246 const allowLimitedAvailabilityInfoTypes =
247 news.allowLimitedAvailabilityInfoTypes === true;
248
249 let current = yield* getByName(output?.name ?? name);
250
251 if (current === undefined) {
252 const created = yield* dlp
253 .createProjectsLocationsInspectTemplates({
254 parent: locationParent(env.project, location),
255 body: {
256 templateId,
257 inspectTemplate: {
258 displayName: news.displayName,
259 description,
260 inspectConfig: news.inspectConfig,
261 allowLimitedAvailabilityInfoTypes,
262 },
263 },
264 })
265 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
266 current = created ?? undefined;
267 }
268
269 if (current === undefined) {
270 return yield* new LocationsInspectTemplateNotResolved({ name });
271 }
272
273 const displayChanged = !jsonEqual(current.displayName, news.displayName);
274 const descriptionChanged = (current.description ?? "") !== description;
275 const configChanged = !jsonEqual(
276 current.inspectConfig,
277 news.inspectConfig,
278 );
279 const limitedChanged =
280 (current.allowLimitedAvailabilityInfoTypes === true) !==
281 allowLimitedAvailabilityInfoTypes;
282
283 if (
284 displayChanged ||
285 descriptionChanged ||
286 configChanged ||
287 limitedChanged
288 ) {
289 current = yield* dlp
290 .patchProjectsLocationsInspectTemplates({
291 name: current.name ?? name,
292 body: {
293 updateMask: updateMaskOf(
294 displayChanged ? "displayName" : undefined,
295 descriptionChanged ? "description" : undefined,
296 configChanged ? "inspectConfig" : undefined,
297 limitedChanged
298 ? "allowLimitedAvailabilityInfoTypes"
299 : undefined,
300 ),
301 inspectTemplate: {
302 displayName: news.displayName,
303 description,
304 inspectConfig: news.inspectConfig,
305 allowLimitedAvailabilityInfoTypes,
306 },
307 },
308 })
309 .pipe();
310 }
311
312 return toAttrs(current, env.project, env.region);
313 }),
314
315 delete: Effect.fn(function* ({ output }) {
316 yield* dlp
317 .deleteProjectsLocationsInspectTemplates({ name: output.name })
318 .pipe(Effect.catchTag("NotFound", () => Effect.void));
319 }),
320 });