1import * as datacatalog from "@distilled.cloud/gcp/datacatalog_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 DatacatalogNotResolved,
13 collectPages,
14 emptyOnMissing,
15 encodeOwnership,
16 findOwned,
17 hasOwnershipMarker,
18 ignoreGone,
19 listAtLocation,
20 locationParent,
21 missingGet,
22 normalizeLocation,
23 ownedByAlchemy,
24 parseName,
25 parseOwnership,
26 replaceOnIdentity,
27 retryTransient,
28 sameStringList,
29 sameText,
30 toDisplayName,
31 updateMaskOf,
32 type TaxonomyActivatedPolicyType,
33} from "./internal.ts";
34import { isTransientGcpError } from "../Errors.ts";
35
36export type { TaxonomyActivatedPolicyType };
37
38export type TaxonomyProps = {
39 /**
40 * Region (`us-central1`, `us`, …). Immutable — changing it replaces
41 * the taxonomy. Policy Tag Manager assigns the taxonomy id; it is not
42 * chosen by the caller.
43 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
44 */
45 location?: string;
46 /**
47 * User-defined display name. Unique within an organization. Letters,
48 * numbers, underscores, dashes, and spaces; max 200 bytes. If omitted,
49 * a unique name is generated from the stack, stage, and logical id.
50 */
51 displayName?: string;
52 /**
53 * Human-readable description (max 2000 bytes). Taxonomies have no
54 * labels field, so Alchemy ownership is stored in a `[alchemy …]`
55 * prefix and stripped from attributes.
56 */
57 description?: string;
58 /**
59 * Policy types activated on this taxonomy
60 * (`FINE_GRAINED_ACCESS_CONTROL`).
61 */
62 activatedPolicyTypes?: TaxonomyActivatedPolicyType[];
63};
64
65export type Taxonomy = Resource<
66 "GCP.DataCatalog.Taxonomy",
67 TaxonomyProps,
68 {
69 /** Full resource name. */
70 name: string;
71 /** Server-assigned taxonomy id (last path segment). */
72 taxonomyId: string;
73 /** Project id. */
74 project: string;
75 /** Location id. */
76 location: string;
77 /** Display name. */
78 displayName: string | undefined;
79 /** User description (Alchemy ownership marker stripped). */
80 description: string | undefined;
81 /** Activated policy types. */
82 activatedPolicyTypes: string[];
83 /** Number of policy tags in this taxonomy. */
84 policyTagCount: number | undefined;
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 Data Catalog taxonomy — a hierarchy of policy tags used to classify
96 * data (for example PII vs financials).
97 *
98 * Taxonomy ids are assigned by Policy Tag Manager. Location is
99 * immutable. Display name, description, and activated policy types
100 * update in place. Taxonomies have no labels; Alchemy stamps
101 * `alchemy-stack` / `alchemy-stage` / `alchemy-id` into the description
102 * so `list` and `pnpm nuke:gcp` can identify owned taxonomies.
103 *
104 * ### Creating a Taxonomy
105 * **Example:** Generated display name
106 * ```typescript
107 * const taxonomy = yield* GCP.DataCatalog.Taxonomy("Pii", {
108 * description: "sensitivity classes",
109 * });
110 * ```
111 *
112 * **Example:** Fine-grained access control
113 * ```typescript
114 * const taxonomy = yield* GCP.DataCatalog.Taxonomy("Pii", {
115 * displayName: "PII",
116 * activatedPolicyTypes: ["FINE_GRAINED_ACCESS_CONTROL"],
117 * });
118 * ```
119 *
120 * ### Updating a Taxonomy
121 * **Example:** Description
122 * ```typescript
123 * // Same logical id, changed props: the engine updates it in place.
124 * const taxonomy = yield* GCP.DataCatalog.Taxonomy("Pii", {
125 * displayName: "PII",
126 * description: "sensitivity classes v2",
127 * activatedPolicyTypes: ["FINE_GRAINED_ACCESS_CONTROL"],
128 * });
129 * ```
130 *
131 * @resource
132 * @category DataCatalog
133 */
134export const Taxonomy = Resource<Taxonomy>("GCP.DataCatalog.Taxonomy");
135
136const toAttrs = (
137 taxonomy: datacatalog.GoogleCloudDatacatalogV1Taxonomy,
138 project: string,
139 region: string,
140) => {
141 const name = taxonomy.name ?? "";
142 const parsed = parseName(name, "taxonomies", region);
143 const ownership = parseOwnership(taxonomy.description);
144 return {
145 name,
146 taxonomyId: parsed.id,
147 project: parsed.project || project,
148 location: parsed.location,
149 displayName: taxonomy.displayName,
150 description: ownership.text,
151 activatedPolicyTypes: [...(taxonomy.activatedPolicyTypes ?? [])],
152 policyTagCount: taxonomy.policyTagCount,
153 createTime: taxonomy.taxonomyTimestamps?.createTime,
154 updateTime: taxonomy.taxonomyTimestamps?.updateTime,
155 };
156};
157
158const getByName = missingGet(datacatalog.getProjectsLocationsTaxonomies);
159
160const listTaxonomiesAt = (
161 parent: string,
162): Effect.Effect<
163 datacatalog.GoogleCloudDatacatalogV1Taxonomy[],
164 datacatalog.ListProjectsLocationsTaxonomiesError,
165 datacatalog.GcpOpContext
166> =>
167 emptyOnMissing(
168 collectPages(
169 datacatalog.listProjectsLocationsTaxonomies.pages({
170 parent,
171 pageSize: 1000,
172 }),
173 (page) => page.taxonomies,
174 ),
175 );
176
177const listOwned = (project: string, region: string) =>
178 listAtLocation(project, region, listTaxonomiesAt).pipe(
179 Effect.map((items) =>
180 items.filter((item) => hasOwnershipMarker(item.description)),
181 ),
182 );
183
184const observe = (
185 id: string,
186 name: string | undefined,
187 project: string,
188 location: string,
189) =>
190 Effect.gen(function* () {
191 if (name !== undefined && name.length > 0) {
192 const existing = yield* getByName(name);
193 if (existing !== undefined) return existing;
194 }
195 const candidates = yield* listTaxonomiesAt(
196 locationParent(project, location),
197 );
198 return yield* findOwned(candidates, id, (item) => item.description);
199 });
200
201export const TaxonomyProvider = () =>
202 Provider.succeed(Taxonomy, {
203 stables: ["name", "taxonomyId", "project", "location", "createTime"],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const env = yield* GcpEnvironment.current;
208 return replaceOnIdentity({
209 previousLocation: normalizeLocation(
210 olds?.location ?? output?.location,
211 env.region,
212 ),
213 nextLocation: normalizeLocation(
214 news.location ?? olds?.location ?? output?.location,
215 env.region,
216 ),
217 });
218 }),
219
220 read: Effect.fn(function* ({ id, olds, output }) {
221 const env = yield* GcpEnvironment.current;
222 const location = normalizeLocation(
223 olds?.location ?? output?.location,
224 env.region,
225 );
226 const existing = yield* observe(id, output?.name, env.project, location);
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing, env.project, env.region);
229 return (yield* ownedByAlchemy(id, existing.description))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const env = yield* GcpEnvironment.current;
237 const items = yield* listOwned(env.project, env.region);
238 return items.map((item) => toAttrs(item, env.project, env.region));
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const env = yield* GcpEnvironment.current;
243 const location = normalizeLocation(
244 news.location ?? output?.location,
245 env.region,
246 );
247 const parent = locationParent(env.project, location);
248 const ownership = yield* createInternalLabels(id);
249 const description = encodeOwnership(ownership, news.description);
250 const displayName = yield* toDisplayName(
251 id,
252 news.displayName,
253 output?.displayName,
254 );
255 const activatedPolicyTypes = news.activatedPolicyTypes;
256
257 let current = yield* observe(id, output?.name, env.project, location);
258
259 if (current === undefined) {
260 const created = yield* retryTransient(
261 datacatalog.createProjectsLocationsTaxonomies({
262 parent,
263 body: {
264 displayName,
265 description,
266 activatedPolicyTypes,
267 },
268 }),
269 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
270 current =
271 created ?? (yield* observe(id, undefined, env.project, location));
272 }
273
274 if (current === undefined) {
275 return yield* new DatacatalogNotResolved({
276 name: output?.name ?? `${parent}/taxonomies`,
277 });
278 }
279
280 const currentName = current.name ?? output?.name ?? "";
281 const displayChanged = !sameText(current.displayName, displayName);
282 const descriptionChanged = (current.description ?? "") !== description;
283 const policyChanged =
284 activatedPolicyTypes !== undefined &&
285 !sameStringList(current.activatedPolicyTypes, activatedPolicyTypes);
286
287 if (displayChanged || descriptionChanged || policyChanged) {
288 current = yield* retryTransient(
289 datacatalog.patchProjectsLocationsTaxonomies({
290 name: currentName,
291 updateMask: updateMaskOf(
292 displayChanged ? "display_name" : undefined,
293 descriptionChanged ? "description" : undefined,
294 policyChanged ? "activated_policy_types" : undefined,
295 ),
296 body: {
297 name: currentName,
298 displayName,
299 description,
300 activatedPolicyTypes,
301 },
302 }),
303 );
304 }
305
306 return toAttrs(current, env.project, env.region);
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* ignoreGone(
311 datacatalog
312 .deleteProjectsLocationsTaxonomies({ name: output.name })
313 .pipe(
314 Effect.retry({
315 while: (error) =>
316 error._tag === "Conflict" || isTransientGcpError(error),
317 times: 8,
318 schedule: Schedule.exponential("500 millis"),
319 }),
320 ),
321 );
322 }),
323 });