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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DataplexNotResolved,
12 collectPages,
13 lastSegment,
14 parseName,
15 replaceOnIdentity,
16 retryQuota,
17 toPhysicalId,
18 waitForOperation,
19 waitUntilExists,
20 waitUntilGone,
21} from "./internal.ts";
22import { listAlchemyDataDomains } from "./DataDomain.ts";
23
24export type DataDomainsBindingProps = {
25 /**
26 * Parent DataDomain resource name
27 * (`projects/{project}/locations/{location}/dataDomains/{dataDomain}`).
28 * Immutable — changing it replaces the binding.
29 */
30 parent: string;
31 /**
32 * Binding id. If omitted, a unique name is generated. Must contain
33 * only lowercase letters, numbers and hyphens, start with a letter,
34 * end with a letter or number, and be 1-63 characters. Immutable —
35 * changing it replaces the binding.
36 */
37 dataDomainBindingId?: string;
38 /**
39 * Immutable IAM full resource name to include in the DataDomain, e.g.
40 * `//cloudresourcemanager.googleapis.com/projects/{project}` or
41 * `//bigquery.googleapis.com/projects/{project}/datasets/{dataset}`.
42 * Changing it replaces the binding.
43 */
44 resource: string;
45};
46
47export type DataDomainsBinding = Resource<
48 "GCP.Dataplex.DataDomainsBinding",
49 DataDomainsBindingProps,
50 {
51 /** Full resource name. */
52 name: string;
53 /** Binding id (last path segment). */
54 dataDomainBindingId: string;
55 /** Parent DataDomain resource name. */
56 parent: string;
57 /** Project id. */
58 project: string;
59 /** Location id. */
60 location: string;
61 /** Bound Google Cloud resource. */
62 resource: string;
63 /** System uid. */
64 uid: string | undefined;
65 /** RFC3339 creation timestamp. */
66 createTime: string | undefined;
67 },
68 never,
69 Providers
70>;
71
72/**
73 * A Dataplex DataDomainBinding that includes a Google Cloud resource
74 * (and its contents) in a DataDomain.
75 *
76 * Bindings have no labels or description. `list` walks alchemy-labeled
77 * DataDomains and returns their bindings so nuke can find them. Parent,
78 * binding id, and resource are identity — there is no in-place update
79 * API.
80 *
81 * ### Creating a DataDomainBinding
82 * **Example:** Bind a BigQuery dataset
83 * ```typescript
84 * const binding = yield* GCP.Dataplex.DataDomainsBinding("Analytics", {
85 * parent: domain.name,
86 * resource:
87 * "//bigquery.googleapis.com/projects/my-project/datasets/analytics",
88 * });
89 * ```
90 *
91 * @resource
92 * @category Dataplex
93 */
94export const DataDomainsBinding = Resource<DataDomainsBinding>(
95 "GCP.Dataplex.DataDomainsBinding",
96);
97
98const resourceNameOf = (parent: string, dataDomainBindingId: string) =>
99 `${parent}/bindings/${dataDomainBindingId}`;
100
101const toAttrs = (
102 binding: dataplex.GoogleCloudDataplexV1DataDomainBinding,
103 project: string,
104) => {
105 const name = binding.name ?? "";
106 const parsed = parseName(name, "bindings");
107 return {
108 name,
109 dataDomainBindingId: parsed.id,
110 parent: parsed.parent,
111 project: parsed.project || project,
112 location: parsed.location,
113 resource: binding.resource ?? "",
114 uid: binding.uid,
115 createTime: binding.createTime,
116 };
117};
118
119const getByName = (name: string) =>
120 name.length === 0
121 ? Effect.succeed(undefined)
122 : retryQuota(
123 dataplex.getProjectsLocationsDataDomainsBindings({ name }),
124 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
125
126const listAtParent = (parent: string) =>
127 collectPages(
128 dataplex.listProjectsLocationsDataDomainsBindings.pages({
129 parent,
130 pageSize: 100,
131 }),
132 (page) => page.dataDomainBindings,
133 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed([])));
134
135export const DataDomainsBindingProvider = () =>
136 Provider.succeed(DataDomainsBinding, {
137 stables: [
138 "name",
139 "dataDomainBindingId",
140 "parent",
141 "project",
142 "location",
143 "resource",
144 "uid",
145 "createTime",
146 ],
147
148 diff: Effect.fn(function* ({ news, olds, output }) {
149 if (!isResolved(news)) return undefined;
150 return replaceOnIdentity({
151 previousId: olds?.dataDomainBindingId ?? output?.dataDomainBindingId,
152 nextId:
153 news.dataDomainBindingId ??
154 olds?.dataDomainBindingId ??
155 output?.dataDomainBindingId,
156 previousLocation: lastSegment(olds?.parent ?? output?.parent ?? ""),
157 nextLocation: lastSegment(news.parent),
158 previousParent: olds?.parent ?? output?.parent,
159 nextParent: news.parent,
160 extra:
161 (olds?.resource ?? output?.resource) !== undefined &&
162 news.resource !== (olds?.resource ?? output?.resource),
163 });
164 }),
165
166 read: Effect.fn(function* ({ id, olds, output }) {
167 const env = yield* GcpEnvironment.current;
168 const dataDomainBindingId = yield* toPhysicalId(
169 id,
170 olds?.dataDomainBindingId,
171 output?.dataDomainBindingId,
172 "ddbinding",
173 );
174 const name =
175 output?.name ??
176 (olds?.parent ? resourceNameOf(olds.parent, dataDomainBindingId) : "");
177 const existing = yield* getByName(name);
178 if (existing === undefined) return undefined;
179 const attrs = toAttrs(existing, env.project);
180 return lastSegment(existing.name ?? "") === dataDomainBindingId
181 ? attrs
182 : Unowned(attrs);
183 }),
184
185 list: () =>
186 Effect.gen(function* () {
187 const env = yield* GcpEnvironment.current;
188 const domains = yield* listAlchemyDataDomains(env.project, env.region);
189 const pages = yield* Effect.forEach(
190 domains,
191 (domain) =>
192 domain.name
193 ? listAtParent(domain.name)
194 : Effect.succeed(
195 [] as dataplex.GoogleCloudDataplexV1DataDomainBinding[],
196 ),
197 { concurrency: 4 },
198 );
199 return pages.flat().map((item) => toAttrs(item, env.project));
200 }),
201
202 reconcile: Effect.fn(function* ({ id, news, output }) {
203 const env = yield* GcpEnvironment.current;
204 const dataDomainBindingId = yield* toPhysicalId(
205 id,
206 news.dataDomainBindingId,
207 output?.dataDomainBindingId,
208 "ddbinding",
209 );
210 const name = resourceNameOf(news.parent, dataDomainBindingId);
211
212 let current = yield* getByName(output?.name ?? name);
213
214 if (current === undefined) {
215 const created = yield* retryQuota(
216 dataplex.createProjectsLocationsDataDomainsBindings({
217 parent: news.parent,
218 dataDomainBindingId,
219 body: { resource: news.resource },
220 }),
221 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
222 if (created !== undefined) {
223 yield* waitForOperation(created);
224 }
225 current = yield* waitUntilExists(getByName(name), name);
226 }
227
228 if (current === undefined) {
229 return yield* new DataplexNotResolved({ name });
230 }
231
232 return toAttrs(current, env.project);
233 }),
234
235 delete: Effect.fn(function* ({ output }) {
236 const operation = yield* dataplex
237 .deleteProjectsLocationsDataDomainsBindings({ name: output.name })
238 .pipe(
239 Effect.retry({
240 while: (error) => error._tag === "Conflict",
241 times: 8,
242 schedule: Schedule.spaced("2 seconds"),
243 }),
244 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
245 );
246 if (operation !== undefined) {
247 yield* waitForOperation(operation, { notFoundOk: true });
248 }
249 yield* waitUntilGone(getByName(output.name), output.name);
250 }),
251 });