1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.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 emptyOnMissing,
13 environmentIdOf,
14 environmentNameOf,
15 lastSegment,
16 listProjectEnvironments,
17 missingToUndefined,
18 organizationIdOf,
19 organizationNameOf,
20 sameText,
21 segmentAfter,
22 toResourceId,
23} from "./common.ts";
24
25export type EnvironmentsApisRevisionsDebugsessionProps = {
26 /**
27 * Apigee organization id or `organizations/{org}`. Defaults to the
28 * current GCP project id. Immutable — changing it replaces the session.
29 */
30 organization?: string;
31 /**
32 * Environment id or `organizations/{org}/environments/{env}`. Immutable —
33 * changing it replaces the session.
34 */
35 environment: string;
36 /**
37 * API proxy id. Immutable — changing it replaces the session.
38 */
39 api: string;
40 /**
41 * Deployed revision id. Immutable — changing it replaces the session.
42 */
43 revision: string;
44 /**
45 * Debug session id. If omitted, a unique name is generated. Sessions
46 * have no labels; Alchemy uses this id as the ownership marker for
47 * `list` / nuke. Immutable — changing it replaces the session.
48 */
49 debugsessionId?: string;
50 /**
51 * Seconds after which the session ends. Overrides `timeout` query
52 * when both are set.
53 */
54 timeout?: string;
55 /**
56 * Condition evaluated against the request to decide whether to trace
57 * it. Syntax matches an API proxy flow Condition.
58 */
59 filter?: string;
60 /**
61 * Seconds the session stays valid in the control plane. Min 1, max 15,
62 * default 10.
63 */
64 validity?: number;
65 /**
66 * Number of requests to trace. Min 1, max 15, default 10.
67 */
68 count?: number;
69 /**
70 * Maximum bytes captured from the response payload. Min 0, max 5120,
71 * default 5120.
72 */
73 tracesize?: number;
74};
75
76export type EnvironmentsApisRevisionsDebugsession = Resource<
77 "GCP.Apigee.EnvironmentsApisRevisionsDebugsession",
78 EnvironmentsApisRevisionsDebugsessionProps,
79 {
80 /** Full resource name `…/apis/{api}/revisions/{revision}/debugsessions/{id}`. */
81 name: string;
82 /** Debug session id. */
83 debugsessionId: string;
84 /** Apigee organization id. */
85 organizationId: string;
86 /** Environment id. */
87 environmentId: string;
88 /** API proxy id. */
89 api: string;
90 /** Revision id. */
91 revision: string;
92 /** Timeout seconds, if set. */
93 timeout: string | undefined;
94 /** Trace filter, if set. */
95 filter: string | undefined;
96 /** Validity seconds. */
97 validity: number | undefined;
98 /** Request count. */
99 count: number | undefined;
100 /** Trace size bytes. */
101 tracesize: number | undefined;
102 /** First transaction creation timestamp. */
103 createTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * An Apigee debug session for a deployed API proxy revision.
111 *
112 * Sessions are short-lived (max 15s validity) and have no labels.
113 * Alchemy generates a stable session id from the stack so `list` can
114 * match it. Delete clears captured data (`debugsessions.deleteData`);
115 * it does not cancel an already-running session.
116 *
117 * ### Creating a Debug Session
118 * **Example:** Trace ten requests
119 * ```typescript
120 * const session = yield* GCP.Apigee.EnvironmentsApisRevisionsDebugsession("Trace", {
121 * environment: "eval",
122 * api: "hello",
123 * revision: "1",
124 * count: 10,
125 * });
126 * ```
127 *
128 * @resource
129 * @category Apigee
130 */
131export const EnvironmentsApisRevisionsDebugsession =
132 Resource<EnvironmentsApisRevisionsDebugsession>(
133 "GCP.Apigee.EnvironmentsApisRevisionsDebugsession",
134 );
135
136export class EnvironmentsApisRevisionsDebugsessionNotResolved extends Data.TaggedError(
137 "GCP.Apigee.EnvironmentsApisRevisionsDebugsessionNotResolved",
138)<{
139 name: string;
140}> {}
141
142const revisionParent = (
143 organizationId: string,
144 environmentId: string,
145 api: string,
146 revision: string,
147) =>
148 `${environmentNameOf(organizationId, environmentId)}/apis/${api}/revisions/${revision}`;
149
150const sessionName = (parent: string, debugsessionId: string) =>
151 `${parent}/debugsessions/${debugsessionId}`;
152
153const parseSessionName = (name: string) => ({
154 organizationId: segmentAfter(name, "organizations") ?? "",
155 environmentId: segmentAfter(name, "environments") ?? "",
156 api: segmentAfter(name, "apis") ?? "",
157 revision: segmentAfter(name, "revisions") ?? "",
158 debugsessionId: segmentAfter(name, "debugsessions") ?? lastSegment(name),
159});
160
161const toAttrs = (
162 session: apigee.GoogleCloudApigeeV1DebugSession,
163 organizationId: string,
164 environmentId: string,
165 api: string,
166 revision: string,
167) => {
168 const raw = session.name ?? "";
169 const parsed = parseSessionName(raw.includes("/") ? raw : "");
170 const debugsessionId = parsed.debugsessionId || lastSegment(raw) || raw;
171 const parent = revisionParent(
172 parsed.organizationId || organizationId,
173 parsed.environmentId || environmentId,
174 parsed.api || api,
175 parsed.revision || revision,
176 );
177 return {
178 name: raw.includes("/") ? raw : sessionName(parent, debugsessionId),
179 debugsessionId,
180 organizationId: parsed.organizationId || organizationId,
181 environmentId: parsed.environmentId || environmentId,
182 api: parsed.api || api,
183 revision: parsed.revision || revision,
184 timeout: session.timeout,
185 filter: session.filter,
186 validity: session.validity,
187 count: session.count,
188 tracesize: session.tracesize,
189 createTime: session.createTime,
190 };
191};
192
193const getByName = (name: string) =>
194 name.length === 0
195 ? Effect.succeed(undefined)
196 : missingToUndefined(
197 apigee.getOrganizationsEnvironmentsApisRevisionsDebugsessions({
198 name,
199 }),
200 );
201
202const listAtRevision = (parent: string) =>
203 parent.length === 0
204 ? Effect.succeed([] as apigee.GoogleCloudApigeeV1Session[])
205 : apigee.listOrganizationsEnvironmentsApisRevisionsDebugsessions
206 .pages({ parent, pageSize: 100 })
207 .pipe(
208 Stream.flatMap((page) => Stream.fromIterable(page.sessions ?? [])),
209 Stream.runCollect,
210 Effect.map((chunk) => Array.from(chunk)),
211 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
212 Effect.succeed([] as apigee.GoogleCloudApigeeV1Session[]),
213 ),
214 );
215
216const listOrgApis = (organizationId: string) =>
217 emptyOnMissing(
218 apigee.listOrganizationsApis({
219 parent: organizationNameOf(organizationId),
220 includeMetaData: false,
221 }),
222 { proxies: [] as apigee.GoogleCloudApigeeV1ApiProxyList },
223 ).pipe(Effect.map((page) => page.proxies ?? []));
224
225const listApiSessions = (apiName: string) =>
226 apigee.listOrganizationsApisDebugsessions
227 .pages({ parent: apiName, pageSize: 100 })
228 .pipe(
229 Stream.flatMap((page) => Stream.fromIterable(page.sessions ?? [])),
230 Stream.runCollect,
231 Effect.map((chunk) => Array.from(chunk)),
232 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
233 Effect.succeed([] as apigee.GoogleCloudApigeeV1ApiDebugSession[]),
234 ),
235 );
236
237const isAlchemySessionId = (id: string | undefined) =>
238 (id ?? "").startsWith("alch-") || (id ?? "").includes("alchemy");
239
240export const EnvironmentsApisRevisionsDebugsessionProvider = () =>
241 Provider.succeed(EnvironmentsApisRevisionsDebugsession, {
242 stables: [
243 "name",
244 "debugsessionId",
245 "organizationId",
246 "environmentId",
247 "api",
248 "revision",
249 "createTime",
250 ],
251
252 diff: Effect.fn(function* ({ news, olds, output }) {
253 if (!isResolved(news)) return undefined;
254 const previousOrg = olds?.organization ?? output?.organizationId;
255 const previousEnv = olds?.environment ?? output?.environmentId;
256 const previousApi = olds?.api ?? output?.api;
257 const previousRevision = olds?.revision ?? output?.revision;
258 const previousId = olds?.debugsessionId ?? output?.debugsessionId;
259 if (
260 (previousOrg !== undefined &&
261 news.organization !== undefined &&
262 organizationIdOf(news.organization, "") !==
263 organizationIdOf(previousOrg, "")) ||
264 (previousEnv !== undefined &&
265 environmentIdOf(news.environment) !== environmentIdOf(previousEnv)) ||
266 (previousApi !== undefined && news.api !== previousApi) ||
267 (previousRevision !== undefined &&
268 news.revision !== previousRevision) ||
269 (previousId !== undefined &&
270 news.debugsessionId !== undefined &&
271 news.debugsessionId !== previousId)
272 ) {
273 return { action: "replace" as const, deleteFirst: true };
274 }
275 return undefined;
276 }),
277
278 read: Effect.fn(function* ({ id, olds, output }) {
279 const { project } = yield* GcpEnvironment.current;
280 const organizationId = organizationIdOf(
281 olds?.organization ?? output?.organizationId,
282 project,
283 );
284 const environmentId = environmentIdOf(
285 olds?.environment ?? output?.environmentId ?? "",
286 );
287 const api = olds?.api ?? output?.api ?? "";
288 const revision = olds?.revision ?? output?.revision ?? "";
289 const debugsessionId = yield* toResourceId(
290 id,
291 olds?.debugsessionId,
292 output?.debugsessionId,
293 { maxLength: 63, rfc1035: true },
294 );
295 const name =
296 output?.name ??
297 sessionName(
298 revisionParent(organizationId, environmentId, api, revision),
299 debugsessionId,
300 );
301 const existing = yield* getByName(name);
302 if (existing === undefined) return undefined;
303 const attrs = toAttrs(
304 existing,
305 organizationId,
306 environmentId,
307 api,
308 revision,
309 );
310 return output !== undefined || isAlchemySessionId(attrs.debugsessionId)
311 ? attrs
312 : Unowned(attrs);
313 }),
314
315 list: () =>
316 Effect.gen(function* () {
317 const environments = yield* listProjectEnvironments();
318 const found: EnvironmentsApisRevisionsDebugsession["Attributes"][] = [];
319 const orgs = Array.from(
320 new Set(environments.map((item) => item.organizationId)),
321 );
322 const apis = yield* Effect.forEach(orgs, listOrgApis, {
323 concurrency: 2,
324 });
325 const sessions = yield* Effect.forEach(
326 apis.flat(),
327 (proxy) =>
328 proxy.name
329 ? listApiSessions(proxy.name)
330 : Effect.succeed(
331 [] as apigee.GoogleCloudApigeeV1ApiDebugSession[],
332 ),
333 { concurrency: 4 },
334 );
335 for (const [proxy, listed] of apis.flat().map((proxy, index) => {
336 return [proxy, sessions[index] ?? []] as const;
337 })) {
338 const organizationId =
339 segmentAfter(proxy.name ?? "", "organizations") ?? "";
340 const api = lastSegment(proxy.name ?? "");
341 for (const session of listed) {
342 if (!isAlchemySessionId(session.id)) continue;
343 const environmentId = session.environmentId ?? "";
344 const revision = session.apiProxyRevisionId ?? "";
345 const debugsessionId = session.id ?? "";
346 found.push({
347 name: sessionName(
348 revisionParent(organizationId, environmentId, api, revision),
349 debugsessionId,
350 ),
351 debugsessionId,
352 organizationId,
353 environmentId,
354 api,
355 revision,
356 timeout: undefined,
357 filter: undefined,
358 validity: undefined,
359 count: undefined,
360 tracesize: undefined,
361 createTime: session.createTime,
362 });
363 }
364 }
365 return found;
366 }),
367
368 reconcile: Effect.fn(function* ({ id, news, output }) {
369 const { project } = yield* GcpEnvironment.current;
370 const organizationId = organizationIdOf(news.organization, project);
371 const environmentId = environmentIdOf(news.environment);
372 const api = news.api;
373 const revision = news.revision;
374 const debugsessionId = yield* toResourceId(
375 id,
376 news.debugsessionId,
377 output?.debugsessionId,
378 { maxLength: 63, rfc1035: true },
379 );
380 const parent = revisionParent(
381 organizationId,
382 environmentId,
383 api,
384 revision,
385 );
386 const name = sessionName(parent, debugsessionId);
387
388 let current = yield* getByName(output?.name ?? name);
389 if (
390 current !== undefined &&
391 !sameText(lastSegment(current.name ?? ""), debugsessionId)
392 ) {
393 current = undefined;
394 }
395
396 if (current === undefined) {
397 const created = yield* apigee
398 .createOrganizationsEnvironmentsApisRevisionsDebugsessions({
399 parent,
400 timeout: news.timeout,
401 body: {
402 name: debugsessionId,
403 timeout: news.timeout,
404 filter: news.filter,
405 validity: news.validity,
406 count: news.count,
407 tracesize: news.tracesize,
408 },
409 })
410 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
411 current = created ?? undefined;
412 }
413
414 if (current === undefined) {
415 return yield* new EnvironmentsApisRevisionsDebugsessionNotResolved({
416 name,
417 });
418 }
419
420 return toAttrs(current, organizationId, environmentId, api, revision);
421 }),
422
423 delete: Effect.fn(function* ({ output }) {
424 if (!output.name) return;
425 yield* apigee
426 .deleteDataOrganizationsEnvironmentsApisRevisionsDebugsessions({
427 name: output.name,
428 })
429 .pipe(
430 Effect.catchTag(
431 ["NotFound", "ApigeeResourceNotFound"],
432 () => Effect.void,
433 ),
434 );
435 }),
436 });