1import * as apim from "@distilled.cloud/gcp/apim_v1alpha";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 type GclbObservationSource,
12 ResourceNotResolved,
13 expandGclb,
14 hasAlchemyId,
15 listAtLocation,
16 collectPages,
17 normalizeLocation,
18 parentOf,
19 parseName,
20 replaceOnIdentity,
21 resourceName,
22 sameJson,
23 toPhysicalId,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27 waitUntilReady,
28} from "./internal.ts";
29
30export type { GclbObservationSource, PscNetworkConfig } from "./internal.ts";
31
32const COLLECTION = "observationSources";
33const READY = new Set(["CREATED"]);
34
35export type ObservationSourceProps = {
36 /**
37 * Observation source id (the `{source}` segment of
38 * `projects/{project}/locations/{location}/observationSources/{source}`).
39 * Must be 4-63 lowercase letters, digits, or hyphens. If omitted, a
40 * unique `alch-` prefixed name is generated. Immutable — changing it
41 * replaces the source.
42 */
43 observationSourceId?: string;
44 /**
45 * Region of the source (`us-central1`, …). Immutable — changing it
46 * replaces the source. The API currently allows one source per region.
47 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
48 */
49 location?: string;
50 /**
51 * GCLB observation source. Required. `pscNetworkConfigs` names the
52 * VPC and subnet used to attach PSC NEGs to Cloud Load Balancers.
53 * Immutable — changing it replaces the source.
54 */
55 gclbObservationSource: GclbObservationSource;
56};
57
58export type ObservationSource = Resource<
59 "GCP.Apim.ObservationSource",
60 ObservationSourceProps,
61 {
62 /** Full resource name. */
63 name: string;
64 /** Observation source id (last path segment). */
65 observationSourceId: string;
66 /** Project id. */
67 project: string;
68 /** Location id. */
69 location: string;
70 /** GCLB observation source configuration. */
71 gclbObservationSource: GclbObservationSource | undefined;
72 /** Server-reported state. */
73 state: string | undefined;
74 /** RFC3339 creation timestamp. */
75 createTime: string | undefined;
76 /** RFC3339 last-update timestamp. */
77 updateTime: string | undefined;
78 },
79 never,
80 Providers
81>;
82
83/**
84 * An API Observation source — configuration for collecting traffic from
85 * Cloud Load Balancers in a VPC. Sources have no labels, description,
86 * or update API; Alchemy stamps ownership into generated ids (`alch-`)
87 * so `list` / nuke can find them. Identity, location, and GCLB network
88 * config replace the resource.
89 *
90 * ### Creating an Observation Source
91 * **Example:** Observe a VPC via PSC
92 * ```typescript
93 * const source = yield* GCP.Apim.ObservationSource("Edge", {
94 * gclbObservationSource: {
95 * pscNetworkConfigs: [
96 * {
97 * network: network.selfLink,
98 * subnetwork: subnet.selfLink,
99 * },
100 * ],
101 * },
102 * });
103 * ```
104 *
105 * @resource
106 * @category Apim
107 */
108export const ObservationSource = Resource<ObservationSource>(
109 "GCP.Apim.ObservationSource",
110);
111
112const toGclb = (
113 source: apim.GclbObservationSource | undefined,
114): GclbObservationSource | undefined => {
115 const configs = source?.pscNetworkConfigs ?? [];
116 if (configs.length === 0) return undefined;
117 return {
118 pscNetworkConfigs: configs.flatMap((config) =>
119 config.network && config.subnetwork
120 ? [
121 {
122 network: config.network,
123 subnetwork: config.subnetwork,
124 },
125 ]
126 : [],
127 ),
128 };
129};
130
131const toAttrs = (
132 location: string,
133 item: apim.ObservationSource,
134 project: string,
135) => {
136 const name = item.name ?? "";
137 const parsed = parseName(name, COLLECTION);
138 return {
139 name,
140 observationSourceId: parsed.id,
141 project: parsed.project || project,
142 location: parsed.location || location,
143 gclbObservationSource: toGclb(item.gclbObservationSource),
144 state: item.state,
145 createTime: item.createTime,
146 updateTime: item.updateTime,
147 };
148};
149
150const getByName = (name: string) =>
151 name.length === 0
152 ? Effect.succeed(undefined)
153 : apim
154 .getProjectsLocationsObservationSources({ name })
155 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
156
157const listOwned = (project: string, region: string) =>
158 listAtLocation(project, region, (parent) =>
159 collectPages(
160 apim.listProjectsLocationsObservationSources.pages({
161 parent,
162 pageSize: 1000,
163 }),
164 (page) => page.observationSources,
165 ),
166 ).pipe(
167 Effect.map((items) =>
168 items.filter((item) =>
169 hasAlchemyId(parseName(item.name ?? "", COLLECTION).id),
170 ),
171 ),
172 );
173
174export const ObservationSourceProvider = () =>
175 Provider.succeed(ObservationSource, {
176 stables: [
177 "name",
178 "observationSourceId",
179 "project",
180 "location",
181 "createTime",
182 ],
183
184 diff: Effect.fn(function* ({ news, olds, output }) {
185 if (!isResolved(news)) return undefined;
186 const env = yield* GcpEnvironment.current;
187 const location = normalizeLocation(
188 news.location ?? olds?.location ?? output?.location ?? env.region,
189 );
190 const previous = expandGclb(
191 olds?.gclbObservationSource ?? output?.gclbObservationSource,
192 env.project,
193 location,
194 );
195 const desired = expandGclb(
196 news.gclbObservationSource,
197 env.project,
198 location,
199 );
200 return replaceOnIdentity({
201 previousId: olds?.observationSourceId ?? output?.observationSourceId,
202 nextId:
203 news.observationSourceId ??
204 olds?.observationSourceId ??
205 output?.observationSourceId,
206 previousLocation: normalizeLocation(
207 olds?.location ?? output?.location ?? env.region,
208 ),
209 nextLocation: location,
210 extra: previous !== undefined && !sameJson(previous, desired),
211 // MVP: one source per region, so replace must delete first.
212 deleteFirst: true,
213 });
214 }),
215
216 read: Effect.fn(function* ({ id, olds, output }) {
217 const env = yield* GcpEnvironment.current;
218 const observationSourceId = yield* toPhysicalId(
219 id,
220 olds?.observationSourceId,
221 output?.observationSourceId,
222 "src",
223 );
224 const location = normalizeLocation(
225 olds?.location ?? output?.location ?? env.region,
226 );
227 const name =
228 output?.name ??
229 resourceName(env.project, location, COLLECTION, observationSourceId);
230 const existing = yield* getByName(name);
231 if (existing === undefined) return undefined;
232 const attrs = toAttrs(location, existing, env.project);
233 // No labels/description. Generated `alch-` ids are owned; an
234 // explicit id that already exists is Unowned unless we persisted it.
235 if (output !== undefined || hasAlchemyId(attrs.observationSourceId)) {
236 return attrs;
237 }
238 return Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const env = yield* GcpEnvironment.current;
244 const items = yield* listOwned(env.project, env.region);
245 return items.map((item) => toAttrs(env.region, item, env.project));
246 }),
247
248 reconcile: Effect.fn(function* ({ id, news, output }) {
249 const env = yield* GcpEnvironment.current;
250 const observationSourceId = yield* toPhysicalId(
251 id,
252 news.observationSourceId,
253 output?.observationSourceId,
254 "src",
255 );
256 const location = normalizeLocation(
257 news.location ?? output?.location ?? env.region,
258 );
259 const name = resourceName(
260 env.project,
261 location,
262 COLLECTION,
263 observationSourceId,
264 );
265 const gclbObservationSource = expandGclb(
266 news.gclbObservationSource,
267 env.project,
268 location,
269 );
270
271 let current = yield* getByName(output?.name ?? name);
272
273 if (current === undefined) {
274 const created = yield* apim
275 .createProjectsLocationsObservationSources({
276 parent: parentOf(env.project, location),
277 observationSourceId,
278 body: {
279 gclbObservationSource,
280 },
281 })
282 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
283 if (created !== undefined) {
284 yield* waitForOperation(created);
285 }
286 current = yield* waitUntilExists(getByName(name), name);
287 }
288
289 if (current === undefined) {
290 return yield* new ResourceNotResolved({ name });
291 }
292
293 current = yield* waitUntilReady(
294 getByName(current.name ?? name),
295 current.name ?? name,
296 (item) => item.state,
297 READY,
298 );
299
300 return toAttrs(location, current, env.project);
301 }),
302
303 delete: Effect.fn(function* ({ output }) {
304 const operation = yield* apim
305 .deleteProjectsLocationsObservationSources({ name: output.name })
306 .pipe(
307 Effect.retry({
308 while: (error) => error._tag === "Conflict",
309 times: 8,
310 schedule: Schedule.spaced("2 seconds"),
311 }),
312 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
313 );
314 if (operation !== undefined) {
315 yield* waitForOperation(operation, { notFoundOk: true });
316 }
317 yield* waitUntilGone(getByName(output.name), output.name);
318 }),
319 });