1import * as networkservices from "@distilled.cloud/gcp/networkservices_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 DEFAULT_GLOBAL,
19 canonicalizeLink,
20 changedFields,
21 collectPages,
22 hasAlchemyLabelKeys,
23 linkKey,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 resourceName,
28 rfc1035,
29 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilGone,
33 waitUntilPresent,
34} from "./internal.ts";
35
36const COLLECTION = "serviceBindings";
37
38export type ServiceBindingProps = {
39 /**
40 * Binding id (the `{serviceBinding}` segment of
41 * `projects/{project}/locations/{location}/serviceBindings/{serviceBinding}`).
42 * If omitted, a unique name is generated from the stack, stage, and
43 * logical id. Immutable — changing it replaces the binding.
44 */
45 serviceBindingId?: string;
46 /**
47 * Location (`global`, `us-central1`, …). Immutable — changing it
48 * replaces the binding. `US-CENTRAL1` is accepted and normalized to
49 * `us-central1`.
50 * @default "global"
51 */
52 location?: string;
53 /**
54 * Human-readable description. Max 1024 characters.
55 */
56 description?: string;
57 /**
58 * Service Directory service resource name
59 * `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}`.
60 * This field is for the deprecated Service Directory integration.
61 * Immutable — changing it replaces the binding.
62 */
63 service?: string;
64 /**
65 * User labels. Alchemy ownership labels are merged in automatically.
66 */
67 labels?: Record<string, string>;
68};
69
70export type ServiceBinding = Resource<
71 "GCP.NetworkServices.ServiceBinding",
72 ServiceBindingProps,
73 {
74 /** Full resource name `projects/{project}/locations/{location}/serviceBindings/{serviceBinding}`. */
75 name: string;
76 /** Binding id (last path segment). */
77 serviceBindingId: string;
78 /** Project id. */
79 project: string;
80 /** Location id (`global`, `us-central1`, …). */
81 location: string;
82 /** User-provided description. */
83 description: string | undefined;
84 /** Bound Service Directory service name, if set. */
85 service: string | undefined;
86 /** UUID of the validated Service Directory service, if populated. */
87 serviceId: string | undefined;
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** RFC3339 creation timestamp. */
91 createTime: string | undefined;
92 /** RFC3339 last-update timestamp. */
93 updateTime: string | undefined;
94 },
95 never,
96 Providers
97>;
98
99/**
100 * A ServiceBinding attaches a producer service (Service Directory, PSC,
101 * or Cloud Run) so Cloud Service Mesh or an Application Load Balancer
102 * can send traffic to it.
103 *
104 * Changing `serviceBindingId`, `location`, or `service` replaces the
105 * binding. Description and labels update in place.
106 *
107 * ### Creating a ServiceBinding
108 * **Example:** Named binding
109 * ```typescript
110 * const binding = yield* GCP.NetworkServices.ServiceBinding("Api", {
111 * location: "global",
112 * description: "prod api",
113 * labels: { env: "prod" },
114 * });
115 * ```
116 *
117 * @resource
118 * @category NetworkServices
119 */
120export const ServiceBinding = Resource<ServiceBinding>(
121 "GCP.NetworkServices.ServiceBinding",
122);
123
124const toService = (service: string | undefined) => {
125 const trimmed = canonicalizeLink(service);
126 return trimmed.length > 0 ? trimmed : undefined;
127};
128
129const toAttrs = (binding: networkservices.ServiceBinding, project: string) => {
130 const name = binding.name ?? "";
131 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
132 return {
133 name,
134 serviceBindingId: parsed.id,
135 project: parsed.project || project,
136 location: parsed.location || DEFAULT_GLOBAL,
137 description: binding.description,
138 service: toService(binding.service),
139 serviceId: binding.serviceId,
140 labels: userLabels(binding.labels),
141 createTime: binding.createTime,
142 updateTime: binding.updateTime,
143 };
144};
145
146const getByName = (name: string) =>
147 networkservices
148 .getProjectsLocationsServiceBindings({ name })
149 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
150
151export const ServiceBindingProvider = () =>
152 Provider.succeed(ServiceBinding, {
153 stables: [
154 "name",
155 "serviceBindingId",
156 "project",
157 "location",
158 "service",
159 "serviceId",
160 "createTime",
161 ],
162
163 diff: Effect.fn(function* ({ news, olds, output }) {
164 if (!isResolved(news)) return undefined;
165 const previousId = olds?.serviceBindingId ?? output?.serviceBindingId;
166 const nextId = news.serviceBindingId
167 ? rfc1035(news.serviceBindingId, "service-binding")
168 : previousId;
169 const previousLocation = normalizeLocation(
170 olds?.location ?? output?.location,
171 DEFAULT_GLOBAL,
172 );
173 const nextLocation = normalizeLocation(
174 news.location ?? olds?.location ?? output?.location,
175 DEFAULT_GLOBAL,
176 );
177 const previousService = linkKey(olds?.service ?? output?.service);
178 const nextService = linkKey(news.service);
179 if (
180 (previousId !== undefined &&
181 nextId !== undefined &&
182 nextId !== previousId) ||
183 previousLocation !== nextLocation ||
184 (previousService.length > 0 &&
185 nextService.length > 0 &&
186 previousService !== nextService)
187 ) {
188 return { action: "replace" as const };
189 }
190 return undefined;
191 }),
192
193 read: Effect.fn(function* ({ id, olds, output }) {
194 const env = yield* GcpEnvironment.current;
195 const serviceBindingId = yield* toPhysicalId(
196 id,
197 olds?.serviceBindingId,
198 output?.serviceBindingId,
199 "service-binding",
200 );
201 const location = normalizeLocation(
202 olds?.location ?? output?.location,
203 DEFAULT_GLOBAL,
204 );
205 const name =
206 output?.name ??
207 resourceName(env.project, location, COLLECTION, serviceBindingId);
208 const existing = yield* getByName(name);
209 if (existing === undefined) return undefined;
210 const attrs = toAttrs(existing, env.project);
211 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
212 ? attrs
213 : Unowned(attrs);
214 }),
215
216 list: () =>
217 Effect.gen(function* () {
218 const env = yield* GcpEnvironment.current;
219 const items = yield* collectPages(
220 networkservices.listProjectsLocationsServiceBindings.pages({
221 parent: parentOf(env.project, "-"),
222 pageSize: 1000,
223 }),
224 (page) => page.serviceBindings,
225 );
226 return items
227 .filter((item) => hasAlchemyLabelKeys(item.labels))
228 .map((item) => toAttrs(item, env.project));
229 }),
230
231 reconcile: Effect.fn(function* ({ id, news, output }) {
232 const env = yield* GcpEnvironment.current;
233 const serviceBindingId = yield* toPhysicalId(
234 id,
235 news.serviceBindingId,
236 output?.serviceBindingId,
237 "service-binding",
238 );
239 const location = normalizeLocation(
240 news.location ?? output?.location,
241 DEFAULT_GLOBAL,
242 );
243 const name = resourceName(
244 env.project,
245 location,
246 COLLECTION,
247 serviceBindingId,
248 );
249 const desiredLabels = {
250 ...toLabels(news.labels),
251 ...(yield* createInternalLabels(id)),
252 };
253 const service = toService(news.service);
254
255 let current = yield* getByName(name);
256
257 if (current === undefined) {
258 const created = yield* networkservices
259 .createProjectsLocationsServiceBindings({
260 parent: parentOf(env.project, location),
261 serviceBindingId,
262 body: {
263 description: news.description,
264 labels: desiredLabels,
265 service,
266 },
267 })
268 .pipe(
269 Effect.retry({
270 while: (error) => error._tag === "Conflict",
271 times: 5,
272 schedule: Schedule.spaced("2 seconds"),
273 }),
274 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
275 );
276 if (created !== undefined) {
277 yield* waitForOperation(created);
278 }
279 current = yield* waitUntilPresent(getByName(name), name);
280 }
281
282 const observedLabels = tagRecord(current.labels);
283 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
284 const labelsChanged = upsert.length > 0 || removed.length > 0;
285 const updateMask = changedFields([
286 ["labels", labelsChanged],
287 [
288 "description",
289 (current.description ?? "") !== (news.description ?? ""),
290 ],
291 ]);
292
293 if (updateMask.length > 0) {
294 const operation =
295 yield* networkservices.patchProjectsLocationsServiceBindings({
296 name: current.name ?? name,
297 updateMask: updateMask.join(","),
298 body: {
299 name: current.name ?? name,
300 labels: desiredLabels,
301 description: news.description,
302 },
303 });
304 yield* waitForOperation(operation);
305 current = yield* waitUntilPresent(
306 getByName(current.name ?? name),
307 current.name ?? name,
308 );
309 }
310
311 return toAttrs(current, env.project);
312 }),
313
314 delete: Effect.fn(function* ({ output }) {
315 const operation = yield* networkservices
316 .deleteProjectsLocationsServiceBindings({ name: output.name })
317 .pipe(
318 Effect.retry({
319 while: (error) => error._tag === "Conflict",
320 times: 8,
321 schedule: Schedule.spaced("2 seconds"),
322 }),
323 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
324 );
325 if (operation !== undefined) {
326 yield* waitForOperation(operation, { notFoundOk: true });
327 }
328 yield* waitUntilGone(getByName(output.name), output.name);
329 }),
330 });