1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 defaultOrgName,
14 lastSegment,
15 listOrgNames,
16 orgIdOf,
17 orgNameOf,
18 waitForOperation,
19} from "./operations.ts";
20
21const MAX_ID_LENGTH = 32;
22
23export type EndpointAttachmentProps = {
24 /**
25 * Apigee organization id or `organizations/{org}`. Defaults to the
26 * current GCP project id. Immutable — changing it replaces the
27 * attachment.
28 */
29 organization?: string;
30 /**
31 * Endpoint attachment id (the `{endpoint_attachment}` segment of
32 * `organizations/{org}/endpointAttachments/{endpoint_attachment}`).
33 * Must start with a lowercase letter, be 2-32 characters of lowercase
34 * letters, numbers, or hyphens, and must not end with a hyphen. If
35 * omitted, a unique `alc-` prefixed name is generated so `list` / nuke
36 * can find it (the API has no labels or description). Immutable —
37 * changing it replaces the attachment.
38 */
39 endpointAttachmentId?: string;
40 /**
41 * Location of the endpoint attachment. Immutable — changing it replaces
42 * the attachment. `US-CENTRAL1` is accepted and normalized to
43 * `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Service attachment in
49 * `projects/{project}/regions/{region}/serviceAttachments/{attachment}`.
50 * Immutable — changing it replaces the attachment.
51 */
52 serviceAttachment: string;
53};
54
55export type EndpointAttachment = Resource<
56 "GCP.Apigee.EndpointAttachment",
57 EndpointAttachmentProps,
58 {
59 /** Full resource name `organizations/{org}/endpointAttachments/{id}`. */
60 name: string;
61 /** Endpoint attachment id (last path segment). */
62 endpointAttachmentId: string;
63 /** Apigee organization id. */
64 organization: string;
65 /** Location. */
66 location: string;
67 /** Service attachment resource name. */
68 serviceAttachment: string | undefined;
69 /** Host that can be used as an HTTP target. */
70 host: string | undefined;
71 /** Server-reported state (`CREATING`, `ACTIVE`, …). */
72 state: string | undefined;
73 /** Connection state to the service attachment. */
74 connectionState: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * An Apigee endpoint attachment for southbound Private Service Connect.
82 *
83 * The API has no labels or description, so Alchemy prefixes generated ids
84 * with `alc-` so `list` / nuke can find them. All properties are identity —
85 * changing any of them replaces the attachment.
86 *
87 * ### Creating an Endpoint Attachment
88 * **Example:** Attach a service attachment in us-central1
89 * ```typescript
90 * const attachment = yield* GCP.Apigee.EndpointAttachment("Backend", {
91 * location: "us-central1",
92 * serviceAttachment:
93 * "projects/my-project/regions/us-central1/serviceAttachments/backend",
94 * });
95 * ```
96 *
97 * @resource
98 * @category Apigee
99 */
100export const EndpointAttachment = Resource<EndpointAttachment>(
101 "GCP.Apigee.EndpointAttachment",
102);
103
104export class EndpointAttachmentNotResolved extends Data.TaggedError(
105 "GCP.Apigee.EndpointAttachmentNotResolved",
106)<{
107 name: string;
108}> {}
109
110const normalizeLocation = (
111 location: string | undefined,
112 defaultLocation: string,
113) => (location ?? defaultLocation).toLowerCase();
114
115const toId = (id: string, explicit: string | undefined, existing?: string) =>
116 Effect.gen(function* () {
117 if (explicit !== undefined) return explicit;
118 if (existing !== undefined) return existing;
119 const generated = yield* createPhysicalName({
120 id,
121 maxLength: MAX_ID_LENGTH - 4,
122 lowercase: true,
123 });
124 const body = generated.replace(/-+$/g, "");
125 const prefixed = body.startsWith("alc") ? body : `alc-${body}`;
126 return prefixed.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "") || "alc-a";
127 });
128
129const resourceName = (organization: string, endpointAttachmentId: string) =>
130 `${orgNameOf(organization)}/endpointAttachments/${endpointAttachmentId}`;
131
132const toAttrs = (
133 attachment: apigee.GoogleCloudApigeeV1EndpointAttachment,
134 organization: string,
135 region: string,
136) => {
137 const name = attachment.name ?? "";
138 return {
139 name,
140 endpointAttachmentId: lastSegment(name),
141 organization: orgIdOf(organization),
142 location: attachment.location ?? region,
143 serviceAttachment: attachment.serviceAttachment,
144 host: attachment.host,
145 state: attachment.state,
146 connectionState: attachment.connectionState,
147 };
148};
149
150const getByName = (name: string) =>
151 apigee
152 .getOrganizationsEndpointAttachments({ name })
153 .pipe(
154 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
155 Effect.succeed(undefined),
156 ),
157 );
158
159const isAlchemyId = (endpointAttachmentId: string) =>
160 endpointAttachmentId.startsWith("alc");
161
162export const EndpointAttachmentProvider = () =>
163 Provider.succeed(EndpointAttachment, {
164 stables: [
165 "name",
166 "endpointAttachmentId",
167 "organization",
168 "location",
169 "serviceAttachment",
170 ],
171
172 diff: Effect.fn(function* ({ news, olds, output }) {
173 if (!isResolved(news)) return undefined;
174 const env = yield* GcpEnvironment.current;
175 const previousId =
176 olds?.endpointAttachmentId ?? output?.endpointAttachmentId;
177 const previousOrg = olds?.organization ?? output?.organization;
178 const previousLocation = olds?.location ?? output?.location;
179 const previousSa = olds?.serviceAttachment ?? output?.serviceAttachment;
180 const idChanged =
181 previousId !== undefined &&
182 news.endpointAttachmentId !== undefined &&
183 news.endpointAttachmentId !== previousId;
184 const orgChanged =
185 previousOrg !== undefined &&
186 news.organization !== undefined &&
187 orgIdOf(news.organization) !== orgIdOf(previousOrg);
188 const locationChanged =
189 previousLocation !== undefined &&
190 normalizeLocation(news.location ?? previousLocation, env.region) !==
191 normalizeLocation(previousLocation, env.region);
192 const saChanged =
193 previousSa !== undefined && news.serviceAttachment !== previousSa;
194 if (idChanged || orgChanged || locationChanged || saChanged) {
195 return {
196 action: "replace" as const,
197 deleteFirst: idChanged && !orgChanged,
198 };
199 }
200 return undefined;
201 }),
202
203 read: Effect.fn(function* ({ id, olds, output }) {
204 const env = yield* GcpEnvironment.current;
205 const organization = defaultOrgName(
206 env.project,
207 olds?.organization ?? output?.organization,
208 );
209 const endpointAttachmentId = yield* toId(
210 id,
211 olds?.endpointAttachmentId,
212 output?.endpointAttachmentId,
213 );
214 const name =
215 output?.name ?? resourceName(organization, endpointAttachmentId);
216 const existing = yield* getByName(name);
217 if (existing === undefined) return undefined;
218 const attrs = toAttrs(existing, organization, env.region);
219 return isAlchemyId(attrs.endpointAttachmentId) ? attrs : Unowned(attrs);
220 }),
221
222 list: () =>
223 Effect.gen(function* () {
224 const env = yield* GcpEnvironment.current;
225 const orgs = yield* listOrgNames();
226 const rows: EndpointAttachment["Attributes"][] = [];
227 for (const organization of orgs) {
228 const attachments = yield* collectPages(
229 apigee.listOrganizationsEndpointAttachments.pages({
230 parent: organization,
231 pageSize: 1000,
232 }),
233 (page) => page.endpointAttachments,
234 ).pipe(
235 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
236 Effect.succeed(
237 [] as apigee.GoogleCloudApigeeV1EndpointAttachment[],
238 ),
239 ),
240 );
241 for (const attachment of attachments) {
242 const attrs = toAttrs(attachment, organization, env.region);
243 if (isAlchemyId(attrs.endpointAttachmentId)) {
244 rows.push(attrs);
245 }
246 }
247 }
248 return rows;
249 }),
250
251 reconcile: Effect.fn(function* ({ id, news, output }) {
252 const env = yield* GcpEnvironment.current;
253 const organization = defaultOrgName(env.project, news.organization);
254 const endpointAttachmentId = yield* toId(
255 id,
256 news.endpointAttachmentId,
257 output?.endpointAttachmentId,
258 );
259 const name = resourceName(organization, endpointAttachmentId);
260 const location = normalizeLocation(
261 news.location ?? output?.location,
262 env.region,
263 );
264
265 let current = yield* getByName(output?.name ?? name);
266
267 if (current === undefined) {
268 const operation = yield* apigee
269 .createOrganizationsEndpointAttachments({
270 parent: organization,
271 endpointAttachmentId,
272 body: {
273 location,
274 serviceAttachment: news.serviceAttachment,
275 },
276 })
277 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
278 if (operation !== undefined) {
279 yield* waitForOperation(operation);
280 }
281 current = yield* getByName(name);
282 }
283
284 if (current === undefined) {
285 return yield* new EndpointAttachmentNotResolved({ name });
286 }
287
288 return toAttrs(current, organization, env.region);
289 }),
290
291 delete: Effect.fn(function* ({ output }) {
292 const operation = yield* apigee
293 .deleteOrganizationsEndpointAttachments({ name: output.name })
294 .pipe(
295 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
296 Effect.succeed(undefined),
297 ),
298 );
299 if (operation !== undefined) {
300 yield* waitForOperation(operation, { notFoundOk: true });
301 }
302 }),
303 });