1import * as dataplex from "@distilled.cloud/gcp/dataplex_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DataplexNotResolved,
19 collectPages,
20 expandParent,
21 fingerprint,
22 hasAlchemyLabelMap,
23 lastSegment,
24 listAtLocation,
25 normalizeLocation,
26 parseName,
27 replaceOnIdentity,
28 retryQuota,
29 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34} from "./internal.ts";
35
36export type ResourceAccessSpec = {
37 /** Principals granted reader on the resource (`user:{email}`, …). */
38 readers?: string[];
39 /** Principals granted writer on the resource. */
40 writers?: string[];
41 /** Principals granted owner on the resource. */
42 owners?: string[];
43};
44
45export type DataAccessSpec = {
46 /** Principals granted reader on data stored in the resource. */
47 readers?: string[];
48};
49
50export type DataTaxonomiesAttributeProps = {
51 /**
52 * Parent Data Taxonomy. Full name
53 * `projects/{project}/locations/{location}/dataTaxonomies/{dataTaxonomy}`
54 * or the taxonomy id (combined with `location`). Immutable — changing
55 * it replaces the attribute.
56 */
57 dataTaxonomy: string;
58 /**
59 * Data attribute id (the `{dataAttribute}` segment of
60 * `.../dataTaxonomies/{dataTaxonomy}/attributes/{dataAttribute}`).
61 * If omitted, a unique name is generated from the stack, stage, and
62 * logical id. Must be 1-63 characters, start with a letter, and match
63 * `[a-z]([a-z0-9-]*[a-z0-9])?`. Immutable — changing it replaces the
64 * attribute.
65 */
66 dataAttributeId?: string;
67 /**
68 * Region used when `dataTaxonomy` is a bare id. Immutable — changing
69 * it replaces the attribute.
70 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
71 */
72 location?: string;
73 /**
74 * Human-readable description.
75 */
76 description?: string;
77 /**
78 * User-friendly display name.
79 */
80 displayName?: string;
81 /**
82 * User labels. Alchemy ownership labels are merged in automatically.
83 */
84 labels?: Record<string, string>;
85 /**
86 * Parent DataAttribute id in the same taxonomy. Maximum hierarchy
87 * depth is 4.
88 */
89 parentId?: string;
90 /**
91 * Access control applied to the resource (bucket, dataset, table).
92 */
93 resourceAccessSpec?: ResourceAccessSpec;
94 /**
95 * Access control applied to data stored in the resource.
96 */
97 dataAccessSpec?: DataAccessSpec;
98};
99
100export type DataTaxonomiesAttribute = Resource<
101 "GCP.Dataplex.DataTaxonomiesAttribute",
102 DataTaxonomiesAttributeProps,
103 {
104 /** Full resource name. */
105 name: string;
106 /** Data attribute id (last path segment). */
107 dataAttributeId: string;
108 /** Parent taxonomy resource name. */
109 dataTaxonomy: string;
110 /** Parent taxonomy id. */
111 dataTaxonomyId: string;
112 /** Project id. */
113 project: string;
114 /** Location id. */
115 location: string;
116 /** User-provided description. */
117 description: string | undefined;
118 /** User-friendly display name. */
119 displayName: string | undefined;
120 /** User labels (Alchemy ownership labels stripped). */
121 labels: Record<string, string>;
122 /** Parent attribute id, if nested. */
123 parentId: string | undefined;
124 /** Child attribute count. */
125 attributeCount: number | undefined;
126 /** Resource access spec. */
127 resourceAccessSpec: ResourceAccessSpec | undefined;
128 /** Data access spec. */
129 dataAccessSpec: DataAccessSpec | undefined;
130 /** Server etag. */
131 etag: string | undefined;
132 /** System-generated uid. */
133 uid: string | undefined;
134 /** RFC3339 creation timestamp. */
135 createTime: string | undefined;
136 /** RFC3339 last-update timestamp. */
137 updateTime: string | undefined;
138 },
139 never,
140 Providers
141>;
142
143/**
144 * A Dataplex Data Attribute in a Data Taxonomy (for example `PII`).
145 *
146 * Changing `dataAttributeId`, `dataTaxonomy`, or `location` replaces
147 * the attribute. Description, display name, labels, parent, and access
148 * specs update in place.
149 *
150 * ### Creating a Data Attribute
151 * **Example:** Attribute under a taxonomy
152 * ```typescript
153 * const attr = yield* GCP.Dataplex.DataTaxonomiesAttribute("Pii", {
154 * dataTaxonomy: taxonomy.name,
155 * displayName: "PII",
156 * labels: { env: "prod" },
157 * });
158 * ```
159 *
160 * ### Updating a Data Attribute
161 * **Example:** Description and labels
162 * ```typescript
163 * // Same logical id as before; only the changed props differ.
164 * const attr = yield* GCP.Dataplex.DataTaxonomiesAttribute("Pii", {
165 * dataTaxonomy: taxonomy.name,
166 * description: "personally identifiable",
167 * labels: { env: "prod", class: "restricted" },
168 * });
169 * ```
170 *
171 * @resource
172 * @category Dataplex
173 */
174export const DataTaxonomiesAttribute = Resource<DataTaxonomiesAttribute>(
175 "GCP.Dataplex.DataTaxonomiesAttribute",
176);
177
178const resolveParent = (
179 project: string,
180 dataTaxonomy: string,
181 location: string | undefined,
182 defaultLocation: string,
183) => {
184 const parent = expandParent(
185 dataTaxonomy,
186 project,
187 normalizeLocation(location, defaultLocation),
188 "dataTaxonomies",
189 );
190 const parsed = parseName(`${parent}/attributes/_`, "attributes");
191 return {
192 parent: parsed.parent,
193 location: parsed.location,
194 project: parsed.project || project,
195 dataTaxonomyId: lastSegment(parsed.parent),
196 };
197};
198
199const resourceName = (parent: string, dataAttributeId: string) =>
200 `${parent}/attributes/${dataAttributeId}`;
201
202const toAttrs = (
203 attribute: dataplex.GoogleCloudDataplexV1DataAttribute,
204 project: string,
205) => {
206 const name = attribute.name ?? "";
207 const parsed = parseName(name, "attributes");
208 const taxonomy = parseName(parsed.parent, "dataTaxonomies");
209 return {
210 name,
211 dataAttributeId: parsed.id,
212 dataTaxonomy: parsed.parent,
213 dataTaxonomyId: taxonomy.id,
214 project: parsed.project || project,
215 location: parsed.location,
216 description: attribute.description,
217 displayName: attribute.displayName,
218 labels: userLabels(attribute.labels),
219 parentId: attribute.parentId,
220 attributeCount: attribute.attributeCount,
221 resourceAccessSpec: attribute.resourceAccessSpec,
222 dataAccessSpec: attribute.dataAccessSpec,
223 etag: attribute.etag,
224 uid: attribute.uid,
225 createTime: attribute.createTime,
226 updateTime: attribute.updateTime,
227 };
228};
229
230const getByName = (name: string) =>
231 retryQuota(
232 dataplex.getProjectsLocationsDataTaxonomiesAttributes({ name }),
233 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
234
235const listAttributesUnder = (parent: string, project: string) =>
236 collectPages(
237 dataplex.listProjectsLocationsDataTaxonomiesAttributes.pages({
238 parent,
239 pageSize: 1000,
240 }),
241 (page) => page.dataAttributes,
242 ).pipe(
243 Effect.map((items) =>
244 items
245 .filter((item) => hasAlchemyLabelMap(item.labels))
246 .map((item) => toAttrs(item, project)),
247 ),
248 Effect.catchTag("NotFound", () => Effect.succeed([])),
249 );
250
251export const DataTaxonomiesAttributeProvider = () =>
252 Provider.succeed(DataTaxonomiesAttribute, {
253 stables: [
254 "name",
255 "dataAttributeId",
256 "dataTaxonomy",
257 "dataTaxonomyId",
258 "project",
259 "location",
260 "createTime",
261 ],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 const env = yield* GcpEnvironment.current;
266 return replaceOnIdentity({
267 previousId: olds?.dataAttributeId ?? output?.dataAttributeId,
268 nextId:
269 news.dataAttributeId ??
270 olds?.dataAttributeId ??
271 output?.dataAttributeId,
272 previousLocation: normalizeLocation(
273 olds?.location ?? output?.location,
274 env.region,
275 ),
276 nextLocation: normalizeLocation(
277 news.location ?? olds?.location ?? output?.location,
278 env.region,
279 ),
280 previousParent: olds?.dataTaxonomy ?? output?.dataTaxonomy,
281 nextParent:
282 news.dataTaxonomy ?? olds?.dataTaxonomy ?? output?.dataTaxonomy,
283 });
284 }),
285
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const env = yield* GcpEnvironment.current;
288 const resolved = resolveParent(
289 env.project,
290 olds?.dataTaxonomy ?? output?.dataTaxonomy ?? "",
291 olds?.location ?? output?.location,
292 env.region,
293 );
294 const dataAttributeId = yield* toPhysicalId(
295 id,
296 olds?.dataAttributeId,
297 output?.dataAttributeId,
298 "attribute",
299 );
300 const name =
301 output?.name ?? resourceName(resolved.parent, dataAttributeId);
302 const existing = yield* getByName(name);
303 if (existing === undefined) return undefined;
304 const attrs = toAttrs(existing, env.project);
305 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
306 ? attrs
307 : Unowned(attrs);
308 }),
309
310 list: () =>
311 Effect.gen(function* () {
312 const env = yield* GcpEnvironment.current;
313 const taxonomies = yield* listAtLocation(
314 env.project,
315 env.region,
316 (parent) =>
317 collectPages(
318 dataplex.listProjectsLocationsDataTaxonomies.pages({
319 parent,
320 pageSize: 1000,
321 }),
322 (page) => page.dataTaxonomies,
323 ).pipe(
324 Effect.map((items) =>
325 items.filter((item) => hasAlchemyLabelMap(item.labels)),
326 ),
327 ),
328 );
329 const nested = yield* Effect.forEach(
330 taxonomies,
331 (taxonomy) =>
332 taxonomy.name
333 ? listAttributesUnder(taxonomy.name, env.project)
334 : Effect.succeed([]),
335 { concurrency: 4 },
336 );
337 return nested.flat();
338 }),
339
340 reconcile: Effect.fn(function* ({ id, news, output }) {
341 const env = yield* GcpEnvironment.current;
342 const resolved = resolveParent(
343 env.project,
344 news.dataTaxonomy,
345 news.location ?? output?.location,
346 env.region,
347 );
348 const dataAttributeId = yield* toPhysicalId(
349 id,
350 news.dataAttributeId,
351 output?.dataAttributeId,
352 "attribute",
353 );
354 const name = resourceName(resolved.parent, dataAttributeId);
355 const desiredLabels = {
356 ...toLabels(news.labels),
357 ...(yield* createInternalLabels(id)),
358 };
359
360 let current = yield* getByName(output?.name ?? name);
361
362 if (current === undefined) {
363 const created = yield* dataplex
364 .createProjectsLocationsDataTaxonomiesAttributes({
365 parent: resolved.parent,
366 dataAttributeId,
367 body: {
368 description: news.description,
369 displayName: news.displayName,
370 labels: desiredLabels,
371 parentId: news.parentId,
372 resourceAccessSpec: news.resourceAccessSpec,
373 dataAccessSpec: news.dataAccessSpec,
374 },
375 })
376 .pipe(
377 retryQuota,
378 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
379 );
380 if (created !== undefined) {
381 yield* waitForOperation(created);
382 }
383 current = yield* waitUntilExists(getByName(name), name);
384 }
385
386 if (current === undefined) {
387 return yield* new DataplexNotResolved({ name });
388 }
389
390 const observedLabels = tagRecord(current.labels);
391 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
392 const labelsChanged = upsert.length > 0 || removed.length > 0;
393 const descriptionChanged =
394 (current.description ?? "") !== (news.description ?? "");
395 const displayNameChanged =
396 (current.displayName ?? "") !== (news.displayName ?? "");
397 const parentChanged =
398 news.parentId !== undefined &&
399 (current.parentId ?? "") !== news.parentId;
400 const resourceAccessChanged =
401 news.resourceAccessSpec !== undefined &&
402 fingerprint(news.resourceAccessSpec) !==
403 fingerprint(current.resourceAccessSpec);
404 const dataAccessChanged =
405 news.dataAccessSpec !== undefined &&
406 fingerprint(news.dataAccessSpec) !==
407 fingerprint(current.dataAccessSpec);
408
409 if (
410 labelsChanged ||
411 descriptionChanged ||
412 displayNameChanged ||
413 parentChanged ||
414 resourceAccessChanged ||
415 dataAccessChanged
416 ) {
417 const operation = yield* retryQuota(
418 dataplex.patchProjectsLocationsDataTaxonomiesAttributes({
419 name: current.name ?? name,
420 updateMask: [
421 labelsChanged ? "labels" : undefined,
422 descriptionChanged ? "description" : undefined,
423 displayNameChanged ? "displayName" : undefined,
424 parentChanged ? "parentId" : undefined,
425 resourceAccessChanged ? "resourceAccessSpec" : undefined,
426 dataAccessChanged ? "dataAccessSpec" : undefined,
427 ]
428 .filter((field): field is string => field !== undefined)
429 .join(","),
430 body: {
431 name: current.name ?? name,
432 etag: current.etag,
433 labels: desiredLabels,
434 description: news.description,
435 displayName: news.displayName,
436 parentId: news.parentId ?? current.parentId,
437 resourceAccessSpec:
438 news.resourceAccessSpec ?? current.resourceAccessSpec,
439 dataAccessSpec: news.dataAccessSpec ?? current.dataAccessSpec,
440 },
441 }),
442 );
443 yield* waitForOperation(operation);
444 current = yield* waitUntilExists(
445 getByName(current.name ?? name),
446 current.name ?? name,
447 );
448 }
449
450 return toAttrs(current, env.project);
451 }),
452
453 delete: Effect.fn(function* ({ output }) {
454 const operation = yield* dataplex
455 .deleteProjectsLocationsDataTaxonomiesAttributes({
456 name: output.name,
457 })
458 .pipe(
459 Effect.retry({
460 while: (error) =>
461 error._tag === "Conflict" || error._tag === "TooManyRequests",
462 times: 8,
463 schedule: Schedule.spaced("2 seconds"),
464 }),
465 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
466 );
467 if (operation !== undefined) {
468 yield* waitForOperation(operation, { notFoundOk: true });
469 }
470 yield* waitUntilGone(getByName(output.name), output.name);
471 }),
472 });