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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 deployedConfig,
14 encodeDescription,
15 environmentIdOf,
16 environmentNameOf,
17 hasOwnershipMarker,
18 lastSegment,
19 listProjectEnvironments,
20 missingToUndefined,
21 namesFromConfig,
22 organizationIdOf,
23 ownedById,
24 parseDescription,
25 parseOrgEnv,
26 sameText,
27 toResourceId,
28} from "./common.ts";
29
30const MAX_NAME_LENGTH = 255;
31
32export type TargetServerTlsCommonName = {
33 /** TLS common name to match. */
34 value?: string;
35 /** Match the cert as a wildcard. */
36 wildcardMatch?: boolean;
37};
38
39export type TargetServerTlsInfo = {
40 /** Enable TLS. */
41 enabled?: boolean;
42 /** Ignore TLS certificate errors. */
43 ignoreValidationErrors?: boolean;
44 /** Enable two-way TLS. */
45 clientAuthEnabled?: boolean;
46 /** Truststore resource id. */
47 trustStore?: string;
48 /** Key alias when client auth is enabled. */
49 keyAlias?: string;
50 /** Keystore resource id when client auth is enabled. */
51 keyStore?: string;
52 /** TLS common name. */
53 commonName?: TargetServerTlsCommonName;
54 /** TLS protocol versions. */
55 protocols?: string[];
56 /** Cipher suites. */
57 ciphers?: string[];
58 /** Strictly enforce TLS. */
59 enforce?: boolean;
60};
61
62export type EnvironmentsTargetserverProps = {
63 /**
64 * Apigee organization id or `organizations/{org}`. Defaults to the
65 * current GCP project id. Immutable — changing it replaces the server.
66 */
67 organization?: string;
68 /**
69 * Environment id or `organizations/{org}/environments/{env}`. Immutable —
70 * changing it replaces the server.
71 */
72 environment: string;
73 /**
74 * Target server id. If omitted, a unique name is generated from the
75 * stack, stage, and logical id. Immutable — changing it replaces the
76 * server.
77 */
78 targetserverId?: string;
79 /**
80 * Hostname this target connects to (RFC-1123).
81 */
82 host: string;
83 /**
84 * Port on `host`. Must be between 1 and 65535.
85 */
86 port: number;
87 /**
88 * Protocol. Immutable — changing it replaces the server.
89 */
90 protocol?: apigee.GoogleCloudApigeeV1TargetServerProtocolEnum | (string & {});
91 /**
92 * Human-readable description. Alchemy stamps ownership into a
93 * `[alchemy …]` prefix because target servers have no labels field.
94 */
95 description?: string;
96 /**
97 * Whether the target is in rotation.
98 * @default true
99 */
100 isEnabled?: boolean;
101 /**
102 * TLS configuration (`sSLInfo` on the wire).
103 */
104 sSLInfo?: TargetServerTlsInfo;
105};
106
107export type EnvironmentsTargetserver = Resource<
108 "GCP.Apigee.EnvironmentsTargetserver",
109 EnvironmentsTargetserverProps,
110 {
111 /** Full resource name `organizations/{org}/environments/{env}/targetservers/{id}`. */
112 name: string;
113 /** Target server id. */
114 targetserverId: string;
115 /** Apigee organization id. */
116 organizationId: string;
117 /** Environment id. */
118 environmentId: string;
119 /** Hostname. */
120 host: string;
121 /** Port. */
122 port: number;
123 /** Protocol. */
124 protocol: string | undefined;
125 /** User description with the Alchemy ownership prefix stripped. */
126 description: string | undefined;
127 /** Whether the target is enabled. */
128 isEnabled: boolean;
129 /** TLS configuration. */
130 sSLInfo: TargetServerTlsInfo | undefined;
131 },
132 never,
133 Providers
134>;
135
136/**
137 * An Apigee environment TargetServer — a named backend host:port used by
138 * proxy TargetEndpoints.
139 *
140 * Target servers have no labels, so Alchemy stamps ownership into
141 * `description` for `list` / nuke. Name and protocol are identity; host,
142 * port, enabled flag, description, and TLS settings update in place.
143 *
144 * ### Creating a Target Server
145 * **Example:** HTTP backend
146 * ```typescript
147 * const backend = yield* GCP.Apigee.EnvironmentsTargetserver("Api", {
148 * environment: "eval",
149 * host: "backend.example.com",
150 * port: 443,
151 * protocol: "HTTP",
152 * sSLInfo: { enabled: true },
153 * });
154 * ```
155 *
156 * @resource
157 * @category Apigee
158 */
159export const EnvironmentsTargetserver = Resource<EnvironmentsTargetserver>(
160 "GCP.Apigee.EnvironmentsTargetserver",
161);
162
163export class EnvironmentsTargetserverNotResolved extends Data.TaggedError(
164 "GCP.Apigee.EnvironmentsTargetserverNotResolved",
165)<{
166 name: string;
167}> {}
168
169const resourceName = (
170 organizationId: string,
171 environmentId: string,
172 targetserverId: string,
173) =>
174 `${environmentNameOf(organizationId, environmentId)}/targetservers/${targetserverId}`;
175
176const tlsOf = (
177 info: apigee.GoogleCloudApigeeV1TlsInfo | undefined,
178): TargetServerTlsInfo | undefined => {
179 if (info === undefined) return undefined;
180 return {
181 enabled: info.enabled,
182 ignoreValidationErrors: info.ignoreValidationErrors,
183 clientAuthEnabled: info.clientAuthEnabled,
184 trustStore: info.trustStore,
185 keyAlias: info.keyAlias,
186 keyStore: info.keyStore,
187 commonName: info.commonName
188 ? {
189 value: info.commonName.value,
190 wildcardMatch: info.commonName.wildcardMatch,
191 }
192 : undefined,
193 protocols: info.protocols,
194 ciphers: info.ciphers,
195 enforce: info.enforce,
196 };
197};
198
199const toAttrs = (
200 server: apigee.GoogleCloudApigeeV1TargetServer,
201 organizationId: string,
202 environmentId: string,
203) => {
204 const raw = server.name ?? "";
205 const parsed = parseOrgEnv(raw);
206 const targetserverId = lastSegment(raw);
207 const description = parseDescription(server.description);
208 return {
209 name: raw.includes("/")
210 ? raw
211 : resourceName(organizationId, environmentId, targetserverId || raw),
212 targetserverId: targetserverId || raw,
213 organizationId: parsed.organizationId || organizationId,
214 environmentId: parsed.environmentId || environmentId,
215 host: server.host ?? "",
216 port: server.port ?? 0,
217 protocol: server.protocol,
218 description: description.description,
219 isEnabled: server.isEnabled !== false,
220 sSLInfo: tlsOf(server.sSLInfo),
221 };
222};
223
224const getByName = (name: string) =>
225 missingToUndefined(
226 apigee.getOrganizationsEnvironmentsTargetservers({ name }),
227 );
228
229const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
230
231export const EnvironmentsTargetserverProvider = () =>
232 Provider.succeed(EnvironmentsTargetserver, {
233 stables: ["name", "targetserverId", "organizationId", "environmentId"],
234
235 diff: Effect.fn(function* ({ news, olds, output }) {
236 if (!isResolved(news)) return undefined;
237 const previousId = olds?.targetserverId ?? output?.targetserverId;
238 const previousOrg = olds?.organization ?? output?.organizationId;
239 const previousEnv = olds?.environment ?? output?.environmentId;
240 const previousProtocol = olds?.protocol ?? output?.protocol;
241 const idChanged =
242 previousId !== undefined &&
243 news.targetserverId !== undefined &&
244 news.targetserverId !== previousId;
245 const orgChanged =
246 previousOrg !== undefined &&
247 news.organization !== undefined &&
248 organizationIdOf(news.organization, "") !==
249 organizationIdOf(previousOrg, "");
250 const envChanged =
251 previousEnv !== undefined &&
252 environmentIdOf(news.environment) !== environmentIdOf(previousEnv);
253 const protocolChanged =
254 previousProtocol !== undefined &&
255 news.protocol !== undefined &&
256 news.protocol !== previousProtocol;
257 if (idChanged || orgChanged || envChanged || protocolChanged) {
258 return { action: "replace" as const, deleteFirst: false };
259 }
260 return undefined;
261 }),
262
263 read: Effect.fn(function* ({ id, olds, output }) {
264 const { project } = yield* GcpEnvironment.current;
265 const organizationId = organizationIdOf(
266 olds?.organization ?? output?.organizationId,
267 project,
268 );
269 const environmentId = environmentIdOf(
270 olds?.environment ?? output?.environmentId ?? "",
271 );
272 const targetserverId = yield* toResourceId(
273 id,
274 olds?.targetserverId,
275 output?.targetserverId,
276 { maxLength: MAX_NAME_LENGTH },
277 );
278 const name =
279 output?.name ??
280 resourceName(organizationId, environmentId, targetserverId);
281 const existing = yield* getByName(name);
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, organizationId, environmentId);
284 const { labels } = parseDescription(existing.description);
285 return (yield* ownedById(id, tagRecord(labels))) ? attrs : Unowned(attrs);
286 }),
287
288 list: () =>
289 Effect.gen(function* () {
290 const environments = yield* listProjectEnvironments();
291 const found: EnvironmentsTargetserver["Attributes"][] = [];
292 for (const item of environments) {
293 const ids = namesFromConfig(
294 (yield* deployedConfig(item.parent))?.targets,
295 );
296 for (const raw of ids) {
297 const name = raw.includes("/")
298 ? raw
299 : resourceName(item.organizationId, item.environmentId, raw);
300 const server = yield* getByName(name);
301 if (server === undefined) continue;
302 if (!hasOwnershipMarker(server.description)) continue;
303 found.push(
304 toAttrs(server, item.organizationId, item.environmentId),
305 );
306 }
307 }
308 return found;
309 }),
310
311 reconcile: Effect.fn(function* ({ id, news, output }) {
312 const { project } = yield* GcpEnvironment.current;
313 const organizationId = organizationIdOf(news.organization, project);
314 const environmentId = environmentIdOf(news.environment);
315 const targetserverId = yield* toResourceId(
316 id,
317 news.targetserverId,
318 output?.targetserverId,
319 { maxLength: MAX_NAME_LENGTH },
320 );
321 const parent = environmentNameOf(organizationId, environmentId);
322 const name = resourceName(organizationId, environmentId, targetserverId);
323 const ownership = yield* createInternalLabels(id);
324 const desiredDescription = encodeDescription(ownership, news.description);
325 const desiredEnabled = news.isEnabled !== false;
326 const body: apigee.GoogleCloudApigeeV1TargetServer = {
327 name: targetserverId,
328 host: news.host,
329 port: news.port,
330 protocol: news.protocol,
331 description: desiredDescription,
332 isEnabled: desiredEnabled,
333 sSLInfo: news.sSLInfo,
334 };
335
336 let current = yield* getByName(output?.name ?? name);
337
338 if (current === undefined) {
339 const created = yield* apigee
340 .createOrganizationsEnvironmentsTargetservers({
341 parent,
342 name: targetserverId,
343 body,
344 })
345 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
346 current = created ?? undefined;
347 }
348
349 if (current === undefined) {
350 return yield* new EnvironmentsTargetserverNotResolved({ name });
351 }
352
353 const hostChanged = !sameText(current.host, news.host);
354 const portChanged = (current.port ?? 0) !== news.port;
355 const descriptionChanged = !sameText(
356 current.description,
357 desiredDescription,
358 );
359 const enabledChanged = (current.isEnabled !== false) !== desiredEnabled;
360 const tlsChanged =
361 jsonOf(tlsOf(current.sSLInfo)) !== jsonOf(news.sSLInfo);
362
363 if (
364 hostChanged ||
365 portChanged ||
366 descriptionChanged ||
367 enabledChanged ||
368 tlsChanged
369 ) {
370 current = yield* apigee.updateOrganizationsEnvironmentsTargetservers({
371 name,
372 body,
373 });
374 }
375
376 return toAttrs(current, organizationId, environmentId);
377 }),
378
379 delete: Effect.fn(function* ({ output }) {
380 yield* apigee
381 .deleteOrganizationsEnvironmentsTargetservers({ name: output.name })
382 .pipe(
383 Effect.catchTag(
384 ["NotFound", "ApigeeResourceNotFound"],
385 () => Effect.void,
386 ),
387 );
388 }),
389 });