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