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 * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 createOwnership,
12 defaultOrgName,
13 desiredAttributes,
14 encodeDescription,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 letterPrefixedId,
19 listOrgNames,
20 orgIdOf,
21 orgNameOf,
22 ownedBy,
23 parseDescription,
24 propertiesToRecord,
25 recordToProperties,
26 sameRecord,
27 userProperties,
28 waitForOperation,
29} from "./operations.ts";
30
31export type EnvironmentType =
32 | apigee.GoogleCloudApigeeV1EnvironmentTypeEnum
33 | (string & {});
34export type EnvironmentApiProxyType =
35 | apigee.GoogleCloudApigeeV1EnvironmentApiProxyTypeEnum
36 | (string & {});
37export type EnvironmentDeploymentType =
38 | apigee.GoogleCloudApigeeV1EnvironmentDeploymentTypeEnum
39 | (string & {});
40
41export type EnvironmentClientIPResolutionConfig =
42 apigee.GoogleCloudApigeeV1EnvironmentClientIPResolutionConfig;
43export type EnvironmentNodeConfig = apigee.GoogleCloudApigeeV1NodeConfig;
44
45export type EnvironmentProps = {
46 /**
47 * Apigee organization id or `organizations/{org}`. Defaults to the
48 * current GCP project id. Immutable — changing it replaces the
49 * environment.
50 */
51 organization?: string;
52 /**
53 * Environment id (the `{env}` segment of
54 * `organizations/{org}/environments/{env}`). Must match
55 * `^[.\\p{Alnum}-_]{1,255}$`. If omitted, a unique name is generated.
56 * Immutable — changing it replaces the environment.
57 */
58 environmentId?: string;
59 /**
60 * Display name.
61 */
62 displayName?: string;
63 /**
64 * Human-readable description. Alchemy ownership is stored in a
65 * `[alchemy …]` prefix and stripped from attributes.
66 */
67 description?: string;
68 /**
69 * Environment type. Immutable — changing it replaces the environment.
70 */
71 type?: EnvironmentType;
72 /**
73 * API proxy type. Immutable — changing it replaces the environment.
74 */
75 apiProxyType?: EnvironmentApiProxyType;
76 /**
77 * Deployment type. Immutable — changing it replaces the environment.
78 */
79 deploymentType?: EnvironmentDeploymentType;
80 /**
81 * Forward proxy URI (`http://{hostname}:{port}`). Empty string removes
82 * the setting.
83 */
84 forwardProxyUri?: string;
85 /**
86 * Client IP resolution algorithm used by analytics and API security.
87 */
88 clientIpResolutionConfig?: EnvironmentClientIPResolutionConfig;
89 /**
90 * Custom properties (name/value pairs). Alchemy ownership properties
91 * are merged in automatically.
92 */
93 properties?: Record<string, string>;
94 /**
95 * Gateway node reservation.
96 */
97 nodeConfig?: EnvironmentNodeConfig;
98};
99
100export type Environment = Resource<
101 "GCP.Apigee.Environment",
102 EnvironmentProps,
103 {
104 /** Full resource name `organizations/{org}/environments/{env}`. */
105 name: string;
106 /** Environment id (last path segment). */
107 environmentId: string;
108 /** Apigee organization id. */
109 organization: string;
110 /** Display name. */
111 displayName: string | undefined;
112 /** User description with the Alchemy ownership prefix stripped. */
113 description: string | undefined;
114 /** Environment type. */
115 type: string | undefined;
116 /** API proxy type. */
117 apiProxyType: string | undefined;
118 /** Deployment type. */
119 deploymentType: string | undefined;
120 /** Forward proxy URI. */
121 forwardProxyUri: string | undefined;
122 /** Client IP resolution config. */
123 clientIpResolutionConfig: EnvironmentClientIPResolutionConfig | undefined;
124 /** User properties (Alchemy ownership properties stripped). */
125 properties: Record<string, string>;
126 /** Gateway node reservation. */
127 nodeConfig: EnvironmentNodeConfig | undefined;
128 /** Whether flow hooks are attached. */
129 hasAttachedFlowHooks: boolean | undefined;
130 /** Server-reported state (`CREATING`, `ACTIVE`, …). */
131 state: string | undefined;
132 /** Creation time in milliseconds since epoch. */
133 createdAt: string | undefined;
134 /** Last modification time in milliseconds since epoch. */
135 lastModifiedAt: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * An Apigee environment that hosts API proxies and shared flows.
143 *
144 * Apigee environments have no labels field, so Alchemy stamps ownership
145 * into the description (and properties) for `list` / nuke. Name, type,
146 * API proxy type, and deployment type are identity — changing them
147 * replaces the environment. Display name, description, forward proxy,
148 * IP resolution, properties, and node config update in place.
149 *
150 * ### Creating an Environment
151 * **Example:** Generated name
152 * ```typescript
153 * const environment = yield* GCP.Apigee.Environment("Runtime", {
154 * displayName: "runtime",
155 * });
156 * ```
157 *
158 * **Example:** Named environment with description
159 * ```typescript
160 * const environment = yield* GCP.Apigee.Environment("Runtime", {
161 * environmentId: "prod",
162 * displayName: "Production",
163 * description: "production API runtime",
164 * apiProxyType: "PROGRAMMABLE",
165 * });
166 * ```
167 *
168 * @resource
169 * @category Apigee
170 */
171export const Environment = Resource<Environment>("GCP.Apigee.Environment");
172
173export class EnvironmentNotResolved extends Data.TaggedError(
174 "GCP.Apigee.EnvironmentNotResolved",
175)<{
176 name: string;
177}> {}
178
179const resourceName = (organization: string, environmentId: string) =>
180 `${orgNameOf(organization)}/environments/${environmentId}`;
181
182const toAttrs = (
183 environment: apigee.GoogleCloudApigeeV1Environment,
184 organization: string,
185) => {
186 const name = environment.name ?? "";
187 const parsed = parseDescription(environment.description);
188 return {
189 name,
190 environmentId: lastSegment(name),
191 organization: orgIdOf(organization),
192 displayName: environment.displayName,
193 description: parsed.description,
194 type: environment.type,
195 apiProxyType: environment.apiProxyType,
196 deploymentType: environment.deploymentType,
197 forwardProxyUri: environment.forwardProxyUri,
198 clientIpResolutionConfig: environment.clientIpResolutionConfig,
199 properties: userProperties(environment.properties),
200 nodeConfig: environment.nodeConfig,
201 hasAttachedFlowHooks: environment.hasAttachedFlowHooks,
202 state: environment.state,
203 createdAt: environment.createdAt,
204 lastModifiedAt: environment.lastModifiedAt,
205 };
206};
207
208const getByName = (name: string) =>
209 apigee
210 .getOrganizationsEnvironments({ name })
211 .pipe(
212 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
213 Effect.succeed(undefined),
214 ),
215 );
216
217export const EnvironmentProvider = () =>
218 Provider.succeed(Environment, {
219 stables: [
220 "name",
221 "environmentId",
222 "organization",
223 "type",
224 "apiProxyType",
225 "deploymentType",
226 "createdAt",
227 ],
228
229 diff: Effect.fn(function* ({ news, olds, output }) {
230 if (!isResolved(news)) return undefined;
231 const previousId = olds?.environmentId ?? output?.environmentId;
232 const previousOrg = olds?.organization ?? output?.organization;
233 const previousType = olds?.type ?? output?.type;
234 const previousProxy = olds?.apiProxyType ?? output?.apiProxyType;
235 const previousDeploy = olds?.deploymentType ?? output?.deploymentType;
236 const idChanged =
237 previousId !== undefined &&
238 news.environmentId !== undefined &&
239 news.environmentId !== previousId;
240 const orgChanged =
241 previousOrg !== undefined &&
242 news.organization !== undefined &&
243 orgIdOf(news.organization) !== orgIdOf(previousOrg);
244 const typeChanged =
245 previousType !== undefined &&
246 news.type !== undefined &&
247 news.type !== previousType;
248 const proxyChanged =
249 previousProxy !== undefined &&
250 news.apiProxyType !== undefined &&
251 news.apiProxyType !== previousProxy;
252 const deployChanged =
253 previousDeploy !== undefined &&
254 news.deploymentType !== undefined &&
255 news.deploymentType !== previousDeploy;
256 if (
257 idChanged ||
258 orgChanged ||
259 typeChanged ||
260 proxyChanged ||
261 deployChanged
262 ) {
263 return {
264 action: "replace" as const,
265 deleteFirst: idChanged && !orgChanged,
266 };
267 }
268 return undefined;
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const env = yield* GcpEnvironment.current;
273 const organization = defaultOrgName(
274 env.project,
275 olds?.organization ?? output?.organization,
276 );
277 const environmentId = yield* letterPrefixedId(
278 id,
279 olds?.environmentId,
280 output?.environmentId,
281 255,
282 );
283 const name = output?.name ?? resourceName(organization, environmentId);
284 const existing = yield* getByName(name);
285 if (existing === undefined) return undefined;
286 const attrs = toAttrs(existing, organization);
287 const { labels } = parseDescription(existing.description);
288 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
289 }),
290
291 list: () =>
292 Effect.gen(function* () {
293 const orgs = yield* listOrgNames();
294 const rows: Environment["Attributes"][] = [];
295 for (const organization of orgs) {
296 const org = yield* apigee
297 .getOrganizations({ name: organization })
298 .pipe(
299 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
300 Effect.succeed(undefined),
301 ),
302 );
303 for (const environmentId of org?.environments ?? []) {
304 const existing = yield* getByName(
305 `${organization}/environments/${environmentId}`,
306 );
307 if (
308 existing !== undefined &&
309 hasOwnershipMarker(existing.description)
310 ) {
311 rows.push(toAttrs(existing, organization));
312 }
313 }
314 }
315 return rows;
316 }),
317
318 reconcile: Effect.fn(function* ({ id, news, output }) {
319 const env = yield* GcpEnvironment.current;
320 const organization = defaultOrgName(env.project, news.organization);
321 const environmentId = yield* letterPrefixedId(
322 id,
323 news.environmentId,
324 output?.environmentId,
325 255,
326 );
327 const name = resourceName(organization, environmentId);
328 const ownership = yield* createOwnership(id);
329 const desiredDescription = encodeDescription(ownership, news.description);
330 const properties = desiredAttributes(news.properties, ownership);
331
332 let current = yield* getByName(output?.name ?? name);
333
334 if (current === undefined) {
335 const operation = yield* apigee
336 .createOrganizationsEnvironments({
337 parent: organization,
338 name: environmentId,
339 body: {
340 name: environmentId,
341 displayName: news.displayName,
342 description: desiredDescription,
343 type: news.type,
344 apiProxyType: news.apiProxyType,
345 deploymentType: news.deploymentType,
346 forwardProxyUri: news.forwardProxyUri,
347 clientIpResolutionConfig: news.clientIpResolutionConfig,
348 properties: recordToProperties(properties),
349 nodeConfig: news.nodeConfig,
350 },
351 })
352 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
353 if (operation !== undefined) {
354 yield* waitForOperation(operation);
355 }
356 current = yield* getByName(name);
357 }
358
359 if (current === undefined) {
360 return yield* new EnvironmentNotResolved({ name });
361 }
362
363 const observedProperties = propertiesToRecord(current.properties);
364 const displayChanged =
365 (current.displayName ?? "") !== (news.displayName ?? "");
366 const descriptionChanged =
367 (current.description ?? "") !== desiredDescription;
368 const proxyUriChanged =
369 (current.forwardProxyUri ?? "") !== (news.forwardProxyUri ?? "");
370 const ipChanged = !jsonEqual(
371 current.clientIpResolutionConfig,
372 news.clientIpResolutionConfig,
373 );
374 const propertiesChanged = !sameRecord(observedProperties, properties);
375 const nodeChanged = !jsonEqual(current.nodeConfig, news.nodeConfig);
376
377 if (
378 displayChanged ||
379 descriptionChanged ||
380 proxyUriChanged ||
381 ipChanged ||
382 propertiesChanged ||
383 nodeChanged
384 ) {
385 current = yield* apigee.updateOrganizationsEnvironments({
386 name,
387 body: {
388 name: environmentId,
389 displayName: news.displayName,
390 description: desiredDescription,
391 type: current.type,
392 apiProxyType: current.apiProxyType,
393 deploymentType: current.deploymentType,
394 forwardProxyUri: news.forwardProxyUri,
395 clientIpResolutionConfig: news.clientIpResolutionConfig,
396 properties: recordToProperties(properties),
397 nodeConfig: news.nodeConfig ?? current.nodeConfig,
398 },
399 });
400 }
401
402 return toAttrs(current, organization);
403 }),
404
405 delete: Effect.fn(function* ({ output }) {
406 const operation = yield* apigee
407 .deleteOrganizationsEnvironments({ name: output.name })
408 .pipe(
409 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
410 Effect.succeed(undefined),
411 ),
412 );
413 if (operation !== undefined) {
414 yield* waitForOperation(operation, { notFoundOk: true });
415 }
416 }),
417 });