1import * as developerconnect from "@distilled.cloud/gcp/developerconnect_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13 toLabels,
14} from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 compact,
18 expandParent,
19 fingerprint,
20 listAtLocation,
21 listLabeledPages,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userAnnotations,
29 userLabels,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33} from "./internal.ts";
34
35export type ArtifactConfig = {
36 /**
37 * URI of the artifact that is deployed (without tag or digest), e.g.
38 * `us-docker.pkg.dev/{project}/{repo}/image`. Immutable per entry.
39 */
40 uri?: string;
41 /**
42 * Artifact Analysis project that stores provenance.
43 */
44 googleArtifactAnalysis?: {
45 projectId?: string;
46 };
47 /**
48 * Artifact Registry package that stores the artifact.
49 */
50 googleArtifactRegistry?: {
51 artifactRegistryPackage?: string;
52 projectId?: string;
53 };
54};
55
56export type InsightsConfigProjects = {
57 /**
58 * Project ids to track with this InsightsConfig.
59 */
60 projectIds?: string[];
61};
62
63export type InsightsConfigProps = {
64 /**
65 * Insights config id (the `{insightsConfig}` segment of
66 * `projects/{project}/locations/{location}/insightsConfigs/{insightsConfig}`).
67 * If omitted, a unique RFC1035 name is generated. Immutable —
68 * changing it replaces the config.
69 */
70 insightsConfigId?: string;
71 /**
72 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
73 * replaces the config. `US-CENTRAL1` is accepted and normalized to
74 * `us-central1`.
75 * @default the stack's GCP region (`GCP.Region`, else the profile
76 * region, else `us-central1`)
77 */
78 location?: string;
79 /**
80 * App Hub application to associate, as
81 * `projects/{project}/locations/{location}/applications/{application}`
82 * or the application id (combined with `location`).
83 */
84 appHubApplication?: string;
85 /**
86 * Projects to track with this InsightsConfig.
87 */
88 projects?: InsightsConfigProjects;
89 /**
90 * Artifact configurations of the artifacts that are deployed.
91 */
92 artifactConfigs?: ArtifactConfig[];
93 /**
94 * User labels. Alchemy ownership labels are merged in automatically.
95 */
96 labels?: Record<string, string>;
97 /**
98 * User annotations (AIP-148).
99 */
100 annotations?: Record<string, string>;
101};
102
103export type InsightsConfig = Resource<
104 "GCP.DeveloperConnect.InsightsConfig",
105 InsightsConfigProps,
106 {
107 /** Full resource name. */
108 name: string;
109 /** Insights config id (last path segment). */
110 insightsConfigId: string;
111 /** Project id. */
112 project: string;
113 /** Location id. */
114 location: string;
115 /** App Hub application resource name, if any. */
116 appHubApplication: string | undefined;
117 /** Projects tracked by this config. */
118 projects: InsightsConfigProjects | undefined;
119 /** Artifact configurations. */
120 artifactConfigs: ArtifactConfig[];
121 /** User labels (Alchemy ownership labels stripped). */
122 labels: Record<string, string>;
123 /** User annotations. */
124 annotations: Record<string, string>;
125 /** Runtime configurations discovered for the application. */
126 runtimeConfigs: developerconnect.RuntimeConfig[] | undefined;
127 /** Server-reported state (`PENDING`, `COMPLETE`, `ERROR`). */
128 state: string | undefined;
129 /** True while Developer Connect is reconciling the config. */
130 reconciling: boolean;
131 /** Setup errors, if any. */
132 errors: developerconnect.Status[] | undefined;
133 /** RFC3339 creation timestamp. */
134 createTime: string | undefined;
135 /** RFC3339 last-update timestamp. */
136 updateTime: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A Developer Connect InsightsConfig — the SDLC event hub that ties an
144 * App Hub application, runtime environments, and deployed artifacts
145 * together.
146 *
147 * Changing `insightsConfigId`, `location`, `appHubApplication`, or
148 * `projects` replaces the config. Labels, annotations, and artifact configs
149 * update in place.
150 *
151 * ### Creating an Insights Config
152 * **Example:** Generated name
153 * ```typescript
154 * const insights = yield* GCP.DeveloperConnect.InsightsConfig("Sdlc", {
155 * projects: { projectIds: ["{project}"] },
156 * labels: { env: "prod" },
157 * });
158 * ```
159 *
160 * **Example:** App Hub application and artifacts
161 * ```typescript
162 * const insights = yield* GCP.DeveloperConnect.InsightsConfig("Sdlc", {
163 * insightsConfigId: "app-sdlc",
164 * appHubApplication: application.name,
165 * artifactConfigs: [
166 * {
167 * uri: "us-docker.pkg.dev/{project}/{repo}/image",
168 * googleArtifactRegistry: {
169 * projectId: "{project}",
170 * artifactRegistryPackage: "image",
171 * },
172 * },
173 * ],
174 * labels: { env: "prod" },
175 * });
176 * ```
177 *
178 * ### Updating an Insights Config
179 * **Example:** Labels on tracked projects
180 * ```typescript
181 * // Same logical id as before; only the changed props differ.
182 * const insights = yield* GCP.DeveloperConnect.InsightsConfig("Sdlc", {
183 * insightsConfigId: "app-sdlc",
184 * projects: { projectIds: ["{project}"] },
185 * labels: { env: "prod", team: "platform" },
186 * });
187 * ```
188 *
189 * @resource
190 * @category DeveloperConnect
191 */
192export const InsightsConfig = Resource<InsightsConfig>(
193 "GCP.DeveloperConnect.InsightsConfig",
194);
195
196const resourceName = (
197 project: string,
198 location: string,
199 insightsConfigId: string,
200) =>
201 `projects/${project}/locations/${location}/insightsConfigs/${insightsConfigId}`;
202
203const toArtifactConfigs = (
204 configs: ArtifactConfig[] | undefined,
205): developerconnect.ArtifactConfig[] | undefined => {
206 if (configs === undefined) return undefined;
207 return configs.map((config) =>
208 compact({
209 uri: config.uri,
210 googleArtifactAnalysis: config.googleArtifactAnalysis
211 ? compact({ projectId: config.googleArtifactAnalysis.projectId })
212 : undefined,
213 googleArtifactRegistry: config.googleArtifactRegistry
214 ? compact({
215 artifactRegistryPackage:
216 config.googleArtifactRegistry.artifactRegistryPackage,
217 projectId: config.googleArtifactRegistry.projectId,
218 })
219 : undefined,
220 }),
221 );
222};
223
224const fromArtifactConfigs = (
225 configs: developerconnect.ArtifactConfig[] | undefined,
226): ArtifactConfig[] =>
227 (configs ?? []).map((config) =>
228 compact({
229 uri: config.uri,
230 googleArtifactAnalysis: config.googleArtifactAnalysis
231 ? compact({ projectId: config.googleArtifactAnalysis.projectId })
232 : undefined,
233 googleArtifactRegistry: config.googleArtifactRegistry
234 ? compact({
235 artifactRegistryPackage:
236 config.googleArtifactRegistry.artifactRegistryPackage,
237 projectId: config.googleArtifactRegistry.projectId,
238 })
239 : undefined,
240 }),
241 );
242
243const toAttrs = (
244 item: developerconnect.InsightsConfig,
245 project: string,
246 region: string,
247) => {
248 const name = item.name ?? "";
249 const parsed = parseName(name, "insightsConfigs", region);
250 return {
251 name,
252 insightsConfigId: parsed.id,
253 project: parsed.project || project,
254 location: parsed.location,
255 appHubApplication: item.appHubApplication,
256 projects: item.projects
257 ? compact({ projectIds: item.projects.projectIds })
258 : undefined,
259 artifactConfigs: fromArtifactConfigs(item.artifactConfigs),
260 labels: userLabels(item.labels),
261 annotations: userAnnotations(item.annotations),
262 runtimeConfigs: item.runtimeConfigs,
263 state: item.state,
264 reconciling: item.reconciling === true,
265 errors: item.errors,
266 createTime: item.createTime,
267 updateTime: item.updateTime,
268 };
269};
270
271const getByName = (name: string) =>
272 developerconnect
273 .getProjectsLocationsInsightsConfigs({ name })
274 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
275
276const listOwned = (project: string, region: string) =>
277 listAtLocation(project, region, (parent) =>
278 listLabeledPages(
279 developerconnect.listProjectsLocationsInsightsConfigs.pages({
280 parent,
281 pageSize: 1000,
282 }),
283 (page) => page.insightsConfigs,
284 (item) => item.labels,
285 ),
286 );
287
288export const InsightsConfigProvider = () =>
289 Provider.succeed(InsightsConfig, {
290 stables: ["name", "insightsConfigId", "project", "location", "createTime"],
291
292 diff: Effect.fn(function* ({ news, olds, output }) {
293 if (!isResolved(news)) return undefined;
294 const env = yield* GcpEnvironment.current;
295 return replaceOnIdentity({
296 previousId: olds?.insightsConfigId ?? output?.insightsConfigId,
297 nextId:
298 news.insightsConfigId ??
299 olds?.insightsConfigId ??
300 output?.insightsConfigId,
301 previousLocation: normalizeLocation(
302 olds?.location ?? output?.location,
303 env.region,
304 ),
305 nextLocation: normalizeLocation(
306 news.location ?? olds?.location ?? output?.location,
307 env.region,
308 ),
309 // The API rejects patches to the tracked scope ("Projects cannot be
310 // updated"), so a new App Hub application or project set replaces.
311 extra:
312 olds !== undefined &&
313 ((olds.appHubApplication ?? "") !== (news.appHubApplication ?? "") ||
314 fingerprint(olds.projects?.projectIds ?? []) !==
315 fingerprint(news.projects?.projectIds ?? [])),
316 });
317 }),
318
319 read: Effect.fn(function* ({ id, olds, output }) {
320 const env = yield* GcpEnvironment.current;
321 const insightsConfigId = yield* toPhysicalId(
322 id,
323 olds?.insightsConfigId,
324 output?.insightsConfigId,
325 "insightsconfig",
326 );
327 const location = normalizeLocation(
328 olds?.location ?? output?.location,
329 env.region,
330 );
331 const name =
332 output?.name ?? resourceName(env.project, location, insightsConfigId);
333 const existing = yield* getByName(name);
334 if (existing === undefined) return undefined;
335 const attrs = toAttrs(existing, env.project, env.region);
336 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
337 ? attrs
338 : Unowned(attrs);
339 }),
340
341 list: () =>
342 Effect.gen(function* () {
343 const env = yield* GcpEnvironment.current;
344 const items = yield* listOwned(env.project, env.region);
345 return items.map((item) => toAttrs(item, env.project, env.region));
346 }),
347
348 reconcile: Effect.fn(function* ({ id, news, output }) {
349 const env = yield* GcpEnvironment.current;
350 const insightsConfigId = yield* toPhysicalId(
351 id,
352 news.insightsConfigId,
353 output?.insightsConfigId,
354 "insightsconfig",
355 );
356 const location = normalizeLocation(
357 news.location ?? output?.location,
358 env.region,
359 );
360 const name = resourceName(env.project, location, insightsConfigId);
361 const desiredLabels = {
362 ...toLabels(news.labels),
363 ...(yield* createInternalLabels(id)),
364 };
365 const annotations = news.annotations;
366 const appHubApplication =
367 news.appHubApplication === undefined
368 ? undefined
369 : expandParent(
370 news.appHubApplication,
371 env.project,
372 location,
373 "applications",
374 );
375 const body = compact({
376 appHubApplication,
377 projects: news.projects
378 ? compact({ projectIds: news.projects.projectIds })
379 : undefined,
380 artifactConfigs: toArtifactConfigs(news.artifactConfigs),
381 labels: desiredLabels,
382 annotations,
383 });
384
385 let current = yield* getByName(output?.name ?? name);
386
387 if (current === undefined) {
388 const created = yield* developerconnect
389 .createProjectsLocationsInsightsConfigs({
390 parent: parentOf(env.project, location),
391 insightsConfigId,
392 body,
393 })
394 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
395 if (created !== undefined) {
396 yield* waitForOperation(created);
397 }
398 current = yield* waitUntilExists(getByName(name), name);
399 }
400
401 if (current === undefined) {
402 return yield* new ResourceNotResolved({ name });
403 }
404
405 const observedLabels = tagRecord(current.labels);
406 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
407 const changed =
408 upsert.length > 0 ||
409 removed.length > 0 ||
410 fingerprint(userAnnotations(current.annotations)) !==
411 fingerprint(userAnnotations(annotations)) ||
412 fingerprint(fromArtifactConfigs(current.artifactConfigs)) !==
413 fingerprint(news.artifactConfigs ?? []);
414
415 if (changed) {
416 const operation = yield* developerconnect
417 .patchProjectsLocationsInsightsConfigs({
418 name: current.name ?? name,
419 body: compact({
420 ...body,
421 appHubApplication: undefined,
422 projects: undefined,
423 }),
424 })
425 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
426 if (operation === undefined) {
427 const created = yield* developerconnect
428 .createProjectsLocationsInsightsConfigs({
429 parent: parentOf(env.project, location),
430 insightsConfigId,
431 body,
432 })
433 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
434 if (created !== undefined) {
435 yield* waitForOperation(created);
436 }
437 } else {
438 yield* waitForOperation(operation);
439 }
440 current = yield* waitUntilExists(
441 getByName(current.name ?? name),
442 current.name ?? name,
443 );
444 }
445
446 return toAttrs(current, env.project, env.region);
447 }),
448
449 delete: Effect.fn(function* ({ output }) {
450 const operation = yield* developerconnect
451 .deleteProjectsLocationsInsightsConfigs({ name: output.name })
452 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
453 if (operation !== undefined) {
454 yield* waitForOperation(operation, { notFoundOk: true });
455 }
456 yield* waitUntilGone(getByName(output.name), output.name);
457 }),
458 });