1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
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 ApihubNotResolved,
12 DEFAULT_DEPLOYMENT_TYPE,
13 encodeOwnership,
14 hasOwnershipMarker,
15 locationParent,
16 MAX_LONG_ID_LENGTH,
17 normalizeLocation,
18 ownedByAlchemy,
19 ownershipLabels,
20 parseName,
21 parseOwnership,
22 replaceOnIdentity,
23 sameJson,
24 sameStringList,
25 sameText,
26 toPhysicalId,
27 updateMaskOf,
28} from "./internal.ts";
29
30type AttributeValues = apihub.GoogleCloudApihubV1AttributeValues;
31type AttributeValuesMap = apihub.GoogleCloudApihubV1AttributeValuesMap;
32type Documentation = apihub.GoogleCloudApihubV1Documentation;
33
34export type DeploymentProps = {
35 /**
36 * Deployment id (the `{deployment}` segment of
37 * `projects/{project}/locations/{location}/deployments/{deployment}`).
38 * If omitted, a unique id is generated. Immutable — changing it replaces
39 * the deployment.
40 */
41 deploymentId?: string;
42 /**
43 * Location of the API Hub instance (`us-central1`, …). Immutable —
44 * changing it replaces the deployment. `US-CENTRAL1` is accepted and
45 * normalized to `us-central1`.
46 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
47 */
48 location?: string;
49 /**
50 * Human-readable display name.
51 */
52 displayName?: string;
53 /**
54 * Human-readable description. API Hub deployments have no labels
55 * field, so Alchemy stamps ownership into a `[alchemy …]` prefix and
56 * strips it from attributes.
57 */
58 description?: string;
59 /**
60 * Documentation URI for the deployment.
61 */
62 documentation?: Documentation;
63 /**
64 * Deployment type (`system-deployment-type` enum). Defaults to the
65 * `others` allowed value.
66 */
67 deploymentType?: AttributeValues;
68 /**
69 * Resource URI that identifies the deployment in its gateway.
70 */
71 resourceUri: string;
72 /**
73 * Endpoints at which this deployment listens (URIs, hostnames, or IPs).
74 */
75 endpoints: string[];
76 /**
77 * SLO attribute values.
78 */
79 slo?: AttributeValues;
80 /**
81 * Environment attribute values.
82 */
83 environment?: AttributeValues;
84 /**
85 * User-defined attributes keyed by attribute resource name.
86 */
87 attributes?: AttributeValuesMap;
88 /**
89 * Source project or org identifier.
90 */
91 sourceProject?: string;
92 /**
93 * Source environment (`prod`, `dev`, `staging`, …).
94 */
95 sourceEnvironment?: string;
96 /**
97 * Management URL attribute values.
98 */
99 managementUrl?: AttributeValues;
100 /**
101 * Source URI attribute values.
102 */
103 sourceUri?: AttributeValues;
104};
105
106export type Deployment = Resource<
107 "GCP.ApiHub.Deployment",
108 DeploymentProps,
109 {
110 /** Full resource name. */
111 name: string;
112 /** Deployment id (last path segment). */
113 deploymentId: string;
114 /** Project id. */
115 project: string;
116 /** Location id. */
117 location: string;
118 /** Display name. */
119 displayName: string | undefined;
120 /** User description with the Alchemy ownership prefix stripped. */
121 description: string | undefined;
122 /** Documentation. */
123 documentation: Documentation | undefined;
124 /** Deployment type. */
125 deploymentType: AttributeValues | undefined;
126 /** Gateway resource URI. */
127 resourceUri: string | undefined;
128 /** Listening endpoints. */
129 endpoints: string[];
130 /** Linked API versions. */
131 apiVersions: string[];
132 /** SLO attribute. */
133 slo: AttributeValues | undefined;
134 /** Environment attribute. */
135 environment: AttributeValues | undefined;
136 /** User-defined attributes. */
137 attributes: AttributeValuesMap | undefined;
138 /** Source metadata. */
139 sourceMetadata: apihub.GoogleCloudApihubV1SourceMetadataList | undefined;
140 /** Source project. */
141 sourceProject: string | undefined;
142 /** Source environment. */
143 sourceEnvironment: string | undefined;
144 /** Management URL. */
145 managementUrl: AttributeValues | undefined;
146 /** Source URI. */
147 sourceUri: AttributeValues | undefined;
148 /** RFC3339 creation timestamp. */
149 createTime: string | undefined;
150 /** RFC3339 last-update timestamp. */
151 updateTime: string | undefined;
152 },
153 never,
154 Providers
155>;
156
157/**
158 * An API Hub deployment — a gateway, proxy, or other runtime that hosts
159 * APIs. Deployments are root-level entities and exist independently of
160 * any API.
161 *
162 * API Hub deployments have no labels, so Alchemy stamps ownership into
163 * the description for `list` / nuke. Location and deployment id are
164 * identity — changing them replaces the deployment. Display name,
165 * description, type, URI, endpoints, and related attributes update in
166 * place.
167 *
168 * ### Creating a Deployment
169 * **Example:** Generated id
170 * ```typescript
171 * const deployment = yield* GCP.ApiHub.Deployment("Orders", {
172 * resourceUri: "organizations/cymbal/environments/staging/apis/orders",
173 * endpoints: ["https://orders.example.com"],
174 * });
175 * ```
176 *
177 * **Example:** Named deployment with type and description
178 * ```typescript
179 * const deployment = yield* GCP.ApiHub.Deployment("Orders", {
180 * deploymentId: "orders-staging",
181 * displayName: "orders staging",
182 * description: "checkout proxy",
183 * resourceUri: "organizations/cymbal/environments/staging/apis/orders",
184 * endpoints: ["https://orders.example.com"],
185 * deploymentType: { enumValues: { values: [{ id: "apigee" }] } },
186 * });
187 * ```
188 *
189 * ### Updating a Deployment
190 * **Example:** Change description and endpoints
191 * ```typescript
192 * const deployment = yield* GCP.ApiHub.Deployment("Orders", {
193 * resourceUri: "organizations/cymbal/environments/staging/apis/orders",
194 * endpoints: ["https://orders.example.com", "https://orders-alt.example.com"],
195 * description: "checkout proxy (updated)",
196 * });
197 * ```
198 *
199 * @resource
200 * @category ApiHub
201 */
202export const Deployment = Resource<Deployment>("GCP.ApiHub.Deployment");
203
204const resourceName = (
205 project: string,
206 location: string,
207 deploymentId: string,
208) => `${locationParent(project, location)}/deployments/${deploymentId}`;
209
210const toAttrs = (
211 deployment: apihub.GoogleCloudApihubV1Deployment,
212 project: string,
213 region: string,
214) => {
215 const name = deployment.name ?? "";
216 const parsed = parseName(name, "deployments", region);
217 const { text } = parseOwnership(deployment.description);
218 return {
219 name,
220 deploymentId: parsed.id,
221 project: parsed.project || project,
222 location: parsed.location,
223 displayName: deployment.displayName,
224 description: text,
225 documentation: deployment.documentation,
226 deploymentType: deployment.deploymentType,
227 resourceUri: deployment.resourceUri,
228 endpoints: [...(deployment.endpoints ?? [])],
229 apiVersions: [...(deployment.apiVersions ?? [])],
230 slo: deployment.slo,
231 environment: deployment.environment,
232 attributes: deployment.attributes,
233 sourceMetadata: deployment.sourceMetadata,
234 sourceProject: deployment.sourceProject,
235 sourceEnvironment: deployment.sourceEnvironment,
236 managementUrl: deployment.managementUrl,
237 sourceUri: deployment.sourceUri,
238 createTime: deployment.createTime,
239 updateTime: deployment.updateTime,
240 };
241};
242
243const getByName = (name: string) =>
244 name.length === 0
245 ? Effect.succeed(undefined)
246 : apihub
247 .getProjectsLocationsDeployments({ name })
248 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
249
250const listAt = (parent: string, project: string, region: string) =>
251 apihub.listProjectsLocationsDeployments
252 .pages({ parent, pageSize: 1000 })
253 .pipe(
254 Stream.flatMap((page) => Stream.fromIterable(page.deployments ?? [])),
255 Stream.filter((item) => hasOwnershipMarker(item.description)),
256 Stream.map((item) => toAttrs(item, project, region)),
257 Stream.runCollect,
258 Effect.map((chunk) => Array.from(chunk)),
259 Effect.catchTag("NotFound", () => Effect.succeed([])),
260 );
261
262const toBody = (
263 news: DeploymentProps,
264 description: string,
265 displayName: string,
266): apihub.GoogleCloudApihubV1Deployment => ({
267 displayName,
268 description,
269 documentation: news.documentation,
270 deploymentType: news.deploymentType ?? DEFAULT_DEPLOYMENT_TYPE,
271 resourceUri: news.resourceUri,
272 endpoints: news.endpoints,
273 slo: news.slo,
274 environment: news.environment,
275 attributes: news.attributes,
276 sourceProject: news.sourceProject,
277 sourceEnvironment: news.sourceEnvironment,
278 managementUrl: news.managementUrl,
279 sourceUri: news.sourceUri,
280});
281
282export const DeploymentProvider = () =>
283 Provider.succeed(Deployment, {
284 stables: ["name", "deploymentId", "project", "location", "createTime"],
285
286 diff: Effect.fn(function* ({ news, olds, output }) {
287 if (!isResolved(news)) return undefined;
288 const env = yield* GcpEnvironment.current;
289 return replaceOnIdentity({
290 previousId: olds?.deploymentId ?? output?.deploymentId,
291 nextId: news.deploymentId ?? olds?.deploymentId ?? output?.deploymentId,
292 previousLocation: normalizeLocation(
293 olds?.location ?? output?.location,
294 env.region,
295 ),
296 nextLocation: normalizeLocation(
297 news.location ?? olds?.location ?? output?.location,
298 env.region,
299 ),
300 });
301 }),
302
303 read: Effect.fn(function* ({ id, olds, output }) {
304 const env = yield* GcpEnvironment.current;
305 const deploymentId = yield* toPhysicalId(
306 id,
307 olds?.deploymentId,
308 output?.deploymentId,
309 MAX_LONG_ID_LENGTH,
310 );
311 const location = normalizeLocation(
312 olds?.location ?? output?.location,
313 env.region,
314 );
315 const name =
316 output?.name ?? resourceName(env.project, location, deploymentId);
317 const existing = yield* getByName(name);
318 if (existing === undefined) return undefined;
319 const attrs = toAttrs(existing, env.project, env.region);
320 return (yield* ownedByAlchemy(id, existing.description))
321 ? attrs
322 : Unowned(attrs);
323 }),
324
325 list: () =>
326 Effect.gen(function* () {
327 const env = yield* GcpEnvironment.current;
328 return yield* listAt(
329 locationParent(env.project, env.region),
330 env.project,
331 env.region,
332 );
333 }),
334
335 reconcile: Effect.fn(function* ({ id, news, output }) {
336 const env = yield* GcpEnvironment.current;
337 const location = normalizeLocation(
338 news.location ?? output?.location,
339 env.region,
340 );
341 const deploymentId = yield* toPhysicalId(
342 id,
343 news.deploymentId,
344 output?.deploymentId,
345 MAX_LONG_ID_LENGTH,
346 );
347 const name = resourceName(env.project, location, deploymentId);
348 const ownership = yield* ownershipLabels(id);
349 const description = encodeOwnership(ownership, news.description);
350 const displayName = news.displayName ?? deploymentId;
351 const parent = locationParent(env.project, location);
352
353 let current = yield* getByName(output?.name ?? name);
354
355 if (current === undefined) {
356 const created = yield* apihub
357 .createProjectsLocationsDeployments({
358 parent,
359 deploymentId,
360 body: toBody(news, description, displayName),
361 })
362 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
363 current = created ?? (yield* getByName(name));
364 }
365
366 if (current === undefined) {
367 return yield* new ApihubNotResolved({ name });
368 }
369
370 const currentName = current.name ?? name;
371 const observed = parseOwnership(current.description).text;
372 const displayChanged = !sameText(current.displayName, displayName);
373 const descriptionChanged = !sameText(observed, news.description);
374 const documentationChanged = !sameJson(
375 current.documentation,
376 news.documentation,
377 );
378 const typeChanged = !sameJson(
379 current.deploymentType,
380 news.deploymentType ?? DEFAULT_DEPLOYMENT_TYPE,
381 );
382 const uriChanged = !sameText(current.resourceUri, news.resourceUri);
383 const endpointsChanged = !sameStringList(
384 current.endpoints,
385 news.endpoints,
386 );
387 const sloChanged = !sameJson(current.slo, news.slo);
388 const environmentChanged = !sameJson(
389 current.environment,
390 news.environment,
391 );
392 const attributesChanged = !sameJson(current.attributes, news.attributes);
393 const sourceProjectChanged = !sameText(
394 current.sourceProject,
395 news.sourceProject,
396 );
397 const sourceEnvironmentChanged = !sameText(
398 current.sourceEnvironment,
399 news.sourceEnvironment,
400 );
401 const managementUrlChanged = !sameJson(
402 current.managementUrl,
403 news.managementUrl,
404 );
405 const sourceUriChanged = !sameJson(current.sourceUri, news.sourceUri);
406
407 const updateMask = updateMaskOf(
408 displayChanged ? "display_name" : undefined,
409 descriptionChanged ? "description" : undefined,
410 documentationChanged ? "documentation" : undefined,
411 typeChanged ? "deployment_type" : undefined,
412 uriChanged ? "resource_uri" : undefined,
413 endpointsChanged ? "endpoints" : undefined,
414 sloChanged ? "slo" : undefined,
415 environmentChanged ? "environment" : undefined,
416 attributesChanged ? "attributes" : undefined,
417 sourceProjectChanged ? "source_project" : undefined,
418 sourceEnvironmentChanged ? "source_environment" : undefined,
419 managementUrlChanged ? "management_url" : undefined,
420 sourceUriChanged ? "source_uri" : undefined,
421 );
422
423 if (updateMask.length > 0) {
424 current = yield* apihub.patchProjectsLocationsDeployments({
425 name: currentName,
426 updateMask,
427 body: {
428 name: currentName,
429 ...toBody(news, description, displayName),
430 },
431 });
432 }
433
434 return toAttrs(current, env.project, env.region);
435 }),
436
437 delete: Effect.fn(function* ({ output }) {
438 yield* apihub
439 .deleteProjectsLocationsDeployments({ name: output.name })
440 .pipe(Effect.catchTag("NotFound", () => Effect.void));
441 }),
442 });