1import * as containeranalysis from "@distilled.cloud/gcp/containeranalysis_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12import { isTransientGcpError } from "../Errors.ts";
13
14export const MAX_ID_LENGTH = 63;
15
16export class ResourceNotResolved extends Data.TaggedError(
17 "GCP.ContainerAnalysis.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export const lastSegment = (value: string) => {
23 const trimmed = value.replace(/\/+$/, "");
24 const parts = trimmed.split("/");
25 return parts[parts.length - 1] || trimmed;
26};
27
28export const rfc1035 = (name: string, fallback = "note"): string => {
29 let next = name
30 .toLowerCase()
31 .replace(/[^a-z0-9-]/g, "-")
32 .replace(/-+/g, "-")
33 .replace(/^-+|-+$/g, "");
34 if (!/^[a-z]/.test(next)) next = `n${next}`;
35 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "");
36 if (next.length === 0) return fallback;
37 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_ID_LENGTH - 1)}0`;
38 return next.slice(0, MAX_ID_LENGTH);
39};
40
41export const toPhysicalId = (
42 id: string,
43 explicit: string | undefined,
44 existing: string | undefined,
45 fallback = "note",
46) =>
47 Effect.gen(function* () {
48 if (explicit !== undefined) return rfc1035(explicit, fallback);
49 if (existing !== undefined) return existing;
50 return rfc1035(
51 yield* createPhysicalName({
52 id,
53 maxLength: MAX_ID_LENGTH,
54 lowercase: true,
55 }),
56 fallback,
57 );
58 });
59
60export const normalizeLocation = (
61 location: string | undefined,
62 fallback: string,
63) => lastSegment(location ?? fallback).toLowerCase();
64
65export const projectParent = (project: string) => `projects/${project}`;
66
67export const locationParent = (project: string, location: string) =>
68 `projects/${project}/locations/${location}`;
69
70export const parseName = (name: string, collection: string) => {
71 const parts = name.split("/").filter((part) => part.length > 0);
72 const collectionAt = parts.lastIndexOf(collection);
73 const locationsAt = parts.lastIndexOf("locations");
74 const projectsAt = parts.lastIndexOf("projects");
75 return {
76 project:
77 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
78 location:
79 locationsAt >= 0 && parts[locationsAt + 1]
80 ? parts[locationsAt + 1]
81 : undefined,
82 id:
83 collectionAt >= 0 && parts[collectionAt + 1]
84 ? parts[collectionAt + 1]!
85 : lastSegment(name),
86 parent:
87 collectionAt > 0
88 ? parts.slice(0, collectionAt).join("/")
89 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
90 };
91};
92
93export const expandNoteName = (
94 value: string,
95 project: string,
96 location?: string,
97) => {
98 if (value.includes("/")) return value.replace(/\/+$/, "");
99 return location !== undefined
100 ? `${locationParent(project, location)}/notes/${value}`
101 : `${projectParent(project)}/notes/${value}`;
102};
103
104export const encodeDescription = (
105 labels: Record<string, string>,
106 description: string | undefined,
107): string => {
108 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
109 return description ? `${marker}\n${description}` : marker;
110};
111
112export const parseDescription = (
113 description: string | undefined,
114): {
115 labels: Record<string, string>;
116 description: string | undefined;
117} => {
118 if (!description?.startsWith("[alchemy ")) {
119 return { labels: {}, description };
120 }
121 const end = description.indexOf("]");
122 if (end < 0) return { labels: {}, description };
123 const labels: Record<string, string> = {};
124 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
125 const eq = part.indexOf("=");
126 if (eq > 0) {
127 labels[part.slice(0, eq)] = part.slice(eq + 1);
128 }
129 }
130 const rest = description.slice(end + 1).replace(/^\n/, "");
131 return { labels, description: rest.length > 0 ? rest : undefined };
132};
133
134export const hasOwnershipMarker = (description: string | undefined) =>
135 Object.keys(parseDescription(description).labels).some((key) =>
136 key.startsWith("alchemy-"),
137 );
138
139export const sameText = (left: string | undefined, right: string | undefined) =>
140 (left ?? "") === (right ?? "");
141
142export const sameJson = (left: unknown, right: unknown) =>
143 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
144
145export const updateMaskOf = (...fields: Array<string | undefined>) =>
146 fields.filter((field): field is string => field !== undefined).join(",");
147
148export const replaceOnIdentity = (input: {
149 previousId?: string;
150 nextId?: string;
151 previousParent?: string;
152 nextParent?: string;
153 extra?: boolean;
154}) => {
155 if (input.extra === true) {
156 return { action: "replace" as const, deleteFirst: false };
157 }
158 const idChanged =
159 input.previousId !== undefined &&
160 input.nextId !== undefined &&
161 input.previousId !== input.nextId;
162 const parentChanged =
163 (input.previousParent ?? "") !== "" &&
164 (input.nextParent ?? "") !== "" &&
165 (input.previousParent ?? "") !== (input.nextParent ?? "");
166 if (!idChanged && !parentChanged) return undefined;
167 return { action: "replace" as const, deleteFirst: false };
168};
169
170export const retryTransient = <A, E extends { readonly _tag: string }, R>(
171 effect: Effect.Effect<A, E, R>,
172) =>
173 effect.pipe(
174 Effect.retry({
175 while: isTransientGcpError,
176 times: 8,
177 schedule: Schedule.exponential("250 millis"),
178 }),
179 );
180
181export const ignoreGone = <A, E extends { readonly _tag: string }, R>(
182 effect: Effect.Effect<A, E, R>,
183) =>
184 effect.pipe(
185 Effect.catchIf(
186 (error): error is E & { readonly _tag: "NotFound" } =>
187 error._tag === "NotFound",
188 () => Effect.void,
189 ),
190 );
191
192export const missingGet =
193 <A, E extends { readonly _tag: string }, R>(
194 effect: (input: { name: string }) => Effect.Effect<A, E, R>,
195 ) =>
196 (name: string) =>
197 name.length === 0
198 ? Effect.succeed(undefined)
199 : effect({ name }).pipe(
200 Effect.catchIf(
201 (error): error is E & { readonly _tag: "NotFound" } =>
202 error._tag === "NotFound",
203 () => Effect.succeed(undefined),
204 ),
205 );
206
207const emptyList = <A>() => Effect.succeed<A[]>([]);
208
209export const collectPages = <
210 Page,
211 Item,
212 E extends { readonly _tag: string },
213 R,
214>(
215 pages: Stream.Stream<Page, E, R>,
216 items: (page: Page) => readonly Item[] | null | undefined,
217) =>
218 pages.pipe(
219 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
220 Stream.runCollect,
221 Effect.map((chunk) => Array.from(chunk) as Item[]),
222 Effect.catchIf(
223 (error): error is E & { readonly _tag: "NotFound" } =>
224 error._tag === "NotFound",
225 () => emptyList<Item>(),
226 ),
227 );
228
229export type NoteNews = {
230 shortDescription?: string;
231 longDescription?: string;
232 expirationTime?: string;
233 relatedUrl?: containeranalysis.RelatedUrlList;
234 relatedNoteNames?: string[];
235 attestation?: containeranalysis.AttestationNote;
236 build?: containeranalysis.BuildNote;
237 discovery?: containeranalysis.DiscoveryNote;
238 image?: containeranalysis.ImageNote;
239 package?: containeranalysis.PackageNote;
240 vulnerability?: containeranalysis.VulnerabilityNote;
241 compliance?: containeranalysis.ComplianceNote;
242 deployment?: containeranalysis.DeploymentNote;
243 upgrade?: containeranalysis.UpgradeNote;
244 vulnerabilityAssessment?: containeranalysis.VulnerabilityAssessmentNote;
245 secret?: containeranalysis.SecretNote;
246 dsseAttestation?: containeranalysis.DSSEAttestationNote;
247 sbomReference?: containeranalysis.SBOMReferenceNote;
248 aiSkillAnalysis?: containeranalysis.AISkillAnalysisNote;
249};
250
251export type OccurrenceNews = {
252 noteName: string;
253 resourceUri: string;
254 remediation?: string;
255 envelope?: containeranalysis.Envelope;
256 attestation?: containeranalysis.AttestationOccurrence;
257 build?: containeranalysis.BuildOccurrence;
258 discovery?: containeranalysis.DiscoveryOccurrence;
259 image?: containeranalysis.ImageOccurrence;
260 package?: containeranalysis.PackageOccurrence;
261 vulnerability?: containeranalysis.VulnerabilityOccurrence;
262 compliance?: containeranalysis.ComplianceOccurrence;
263 deployment?: containeranalysis.DeploymentOccurrence;
264 upgrade?: containeranalysis.UpgradeOccurrence;
265 dsseAttestation?: containeranalysis.DSSEAttestationOccurrence;
266 sbomReference?: containeranalysis.SBOMReferenceOccurrence;
267 secret?: containeranalysis.SecretOccurrence;
268 aiSkillAnalysis?: containeranalysis.AISkillAnalysisOccurrence;
269};
270
271const DEFAULT_ATTESTATION_PAYLOAD = "YWxjaGVteQ==";
272
273export const noteKind = (news: NoteNews): string | undefined => {
274 if (news.attestation !== undefined) return "ATTESTATION";
275 if (news.build !== undefined) return "BUILD";
276 if (news.discovery !== undefined) return "DISCOVERY";
277 if (news.image !== undefined) return "IMAGE";
278 if (news.package !== undefined) return "PACKAGE";
279 if (news.vulnerability !== undefined) return "VULNERABILITY";
280 if (news.compliance !== undefined) return "COMPLIANCE";
281 if (news.deployment !== undefined) return "DEPLOYMENT";
282 if (news.upgrade !== undefined) return "UPGRADE";
283 if (news.vulnerabilityAssessment !== undefined) {
284 return "VULNERABILITY_ASSESSMENT";
285 }
286 if (news.secret !== undefined) return "SECRET";
287 if (news.dsseAttestation !== undefined) return "DSSE_ATTESTATION";
288 if (news.sbomReference !== undefined) return "SBOM_REFERENCE";
289 if (news.aiSkillAnalysis !== undefined) return "AI_SKILL_ANALYSIS";
290 return undefined;
291};
292
293export const occurrenceKind = (news: OccurrenceNews): string | undefined => {
294 if (news.attestation !== undefined) return "ATTESTATION";
295 if (news.build !== undefined) return "BUILD";
296 if (news.discovery !== undefined) return "DISCOVERY";
297 if (news.image !== undefined) return "IMAGE";
298 if (news.package !== undefined) return "PACKAGE";
299 if (news.vulnerability !== undefined) return "VULNERABILITY";
300 if (news.compliance !== undefined) return "COMPLIANCE";
301 if (news.deployment !== undefined) return "DEPLOYMENT";
302 if (news.upgrade !== undefined) return "UPGRADE";
303 if (news.secret !== undefined) return "SECRET";
304 if (news.dsseAttestation !== undefined) return "DSSE_ATTESTATION";
305 if (news.sbomReference !== undefined) return "SBOM_REFERENCE";
306 if (news.aiSkillAnalysis !== undefined) return "AI_SKILL_ANALYSIS";
307 return undefined;
308};
309
310export const noteKindFields = (news: NoteNews): containeranalysis.Note => {
311 const body: containeranalysis.Note = {
312 attestation: news.attestation,
313 build: news.build,
314 discovery: news.discovery,
315 image: news.image,
316 package: news.package,
317 vulnerability: news.vulnerability,
318 compliance: news.compliance,
319 deployment: news.deployment,
320 upgrade: news.upgrade,
321 vulnerabilityAssessment: news.vulnerabilityAssessment,
322 secret: news.secret,
323 dsseAttestation: news.dsseAttestation,
324 sbomReference: news.sbomReference,
325 aiSkillAnalysis: news.aiSkillAnalysis,
326 };
327 if (noteKind(news) === undefined) {
328 body.attestation = { hint: { humanReadableName: "alchemy" } };
329 }
330 return body;
331};
332
333export const occurrenceKindFields = (
334 news: OccurrenceNews,
335): containeranalysis.Occurrence => {
336 const body: containeranalysis.Occurrence = {
337 attestation: news.attestation,
338 build: news.build,
339 discovery: news.discovery,
340 image: news.image,
341 package: news.package,
342 vulnerability: news.vulnerability,
343 compliance: news.compliance,
344 deployment: news.deployment,
345 upgrade: news.upgrade,
346 dsseAttestation: news.dsseAttestation,
347 sbomReference: news.sbomReference,
348 secret: news.secret,
349 aiSkillAnalysis: news.aiSkillAnalysis,
350 envelope: news.envelope,
351 };
352 if (occurrenceKind(news) === undefined) {
353 body.attestation = {
354 serializedPayload: DEFAULT_ATTESTATION_PAYLOAD,
355 signatures: [
356 {
357 publicKeyId: "https://alchemy.local/keys/test",
358 signature: DEFAULT_ATTESTATION_PAYLOAD,
359 },
360 ],
361 };
362 }
363 return body;
364};
365
366export type NoteAttrs = {
367 name: string;
368 noteId: string;
369 project: string;
370 location: string | undefined;
371 shortDescription: string | undefined;
372 longDescription: string | undefined;
373 kind: string | undefined;
374 expirationTime: string | undefined;
375 relatedUrl: containeranalysis.RelatedUrlList;
376 relatedNoteNames: string[];
377 attestation: containeranalysis.AttestationNote | undefined;
378 build: containeranalysis.BuildNote | undefined;
379 discovery: containeranalysis.DiscoveryNote | undefined;
380 image: containeranalysis.ImageNote | undefined;
381 package: containeranalysis.PackageNote | undefined;
382 vulnerability: containeranalysis.VulnerabilityNote | undefined;
383 compliance: containeranalysis.ComplianceNote | undefined;
384 deployment: containeranalysis.DeploymentNote | undefined;
385 upgrade: containeranalysis.UpgradeNote | undefined;
386 vulnerabilityAssessment:
387 | containeranalysis.VulnerabilityAssessmentNote
388 | undefined;
389 secret: containeranalysis.SecretNote | undefined;
390 dsseAttestation: containeranalysis.DSSEAttestationNote | undefined;
391 sbomReference: containeranalysis.SBOMReferenceNote | undefined;
392 aiSkillAnalysis: containeranalysis.AISkillAnalysisNote | undefined;
393 createTime: string | undefined;
394 updateTime: string | undefined;
395};
396
397export const noteAttrs = (
398 note: containeranalysis.Note,
399 project: string,
400): NoteAttrs => {
401 const name = note.name ?? "";
402 const parsed = parseName(name, "notes");
403 const description = parseDescription(note.longDescription);
404 return {
405 name,
406 noteId: parsed.id,
407 project: parsed.project || project,
408 location: parsed.location,
409 shortDescription: note.shortDescription,
410 longDescription: description.description,
411 kind: note.kind,
412 expirationTime: note.expirationTime,
413 relatedUrl: note.relatedUrl ?? [],
414 relatedNoteNames: note.relatedNoteNames ?? [],
415 attestation: note.attestation,
416 build: note.build,
417 discovery: note.discovery,
418 image: note.image,
419 package: note.package,
420 vulnerability: note.vulnerability,
421 compliance: note.compliance,
422 deployment: note.deployment,
423 upgrade: note.upgrade,
424 vulnerabilityAssessment: note.vulnerabilityAssessment,
425 secret: note.secret,
426 dsseAttestation: note.dsseAttestation,
427 sbomReference: note.sbomReference,
428 aiSkillAnalysis: note.aiSkillAnalysis,
429 createTime: note.createTime,
430 updateTime: note.updateTime,
431 };
432};
433
434export type OccurrenceAttrs = {
435 name: string;
436 occurrenceId: string;
437 project: string;
438 location: string | undefined;
439 noteName: string;
440 resourceUri: string;
441 remediation: string | undefined;
442 kind: string | undefined;
443 envelope: containeranalysis.Envelope | undefined;
444 attestation: containeranalysis.AttestationOccurrence | undefined;
445 build: containeranalysis.BuildOccurrence | undefined;
446 discovery: containeranalysis.DiscoveryOccurrence | undefined;
447 image: containeranalysis.ImageOccurrence | undefined;
448 package: containeranalysis.PackageOccurrence | undefined;
449 vulnerability: containeranalysis.VulnerabilityOccurrence | undefined;
450 compliance: containeranalysis.ComplianceOccurrence | undefined;
451 deployment: containeranalysis.DeploymentOccurrence | undefined;
452 upgrade: containeranalysis.UpgradeOccurrence | undefined;
453 dsseAttestation: containeranalysis.DSSEAttestationOccurrence | undefined;
454 sbomReference: containeranalysis.SBOMReferenceOccurrence | undefined;
455 secret: containeranalysis.SecretOccurrence | undefined;
456 aiSkillAnalysis: containeranalysis.AISkillAnalysisOccurrence | undefined;
457 createTime: string | undefined;
458 updateTime: string | undefined;
459};
460
461export const occurrenceAttrs = (
462 occurrence: containeranalysis.Occurrence,
463 project: string,
464): OccurrenceAttrs => {
465 const name = occurrence.name ?? "";
466 const parsed = parseName(name, "occurrences");
467 const remediation = parseDescription(occurrence.remediation);
468 return {
469 name,
470 occurrenceId: parsed.id,
471 project: parsed.project || project,
472 location: parsed.location,
473 noteName: occurrence.noteName ?? "",
474 resourceUri: occurrence.resourceUri ?? "",
475 remediation: remediation.description,
476 kind: occurrence.kind,
477 envelope: occurrence.envelope,
478 attestation: occurrence.attestation,
479 build: occurrence.build,
480 discovery: occurrence.discovery,
481 image: occurrence.image,
482 package: occurrence.package,
483 vulnerability: occurrence.vulnerability,
484 compliance: occurrence.compliance,
485 deployment: occurrence.deployment,
486 upgrade: occurrence.upgrade,
487 dsseAttestation: occurrence.dsseAttestation,
488 sbomReference: occurrence.sbomReference,
489 secret: occurrence.secret,
490 aiSkillAnalysis: occurrence.aiSkillAnalysis,
491 createTime: occurrence.createTime,
492 updateTime: occurrence.updateTime,
493 };
494};
495
496export type NoteOps<E = unknown, R = unknown> = {
497 get: (
498 name: string,
499 ) => Effect.Effect<containeranalysis.Note | undefined, E, R>;
500 create: (input: {
501 parent: string;
502 noteId: string;
503 body: containeranalysis.Note;
504 }) => Effect.Effect<containeranalysis.Note | undefined, E, R>;
505 patch: (input: {
506 name: string;
507 updateMask: string;
508 body: containeranalysis.Note;
509 }) => Effect.Effect<containeranalysis.Note, E, R>;
510};
511
512export const reconcileNote = Effect.fn(function* (input: {
513 id: string;
514 name: string;
515 parent: string;
516 noteId: string;
517 news: NoteNews;
518 ops: NoteOps;
519}) {
520 const ownership = yield* createInternalLabels(input.id);
521 const desiredLong = encodeDescription(ownership, input.news.longDescription);
522 const kinds = noteKindFields({
523 ...input.news,
524 attestation:
525 input.news.attestation ??
526 (noteKind(input.news) === undefined
527 ? { hint: { humanReadableName: input.noteId } }
528 : undefined),
529 });
530
531 let current = yield* input.ops.get(input.name);
532
533 if (current === undefined) {
534 const created = yield* input.ops.create({
535 parent: input.parent,
536 noteId: input.noteId,
537 body: {
538 shortDescription: input.news.shortDescription,
539 longDescription: desiredLong,
540 expirationTime: input.news.expirationTime,
541 relatedUrl: input.news.relatedUrl,
542 relatedNoteNames: input.news.relatedNoteNames,
543 ...kinds,
544 },
545 });
546 current = created ?? undefined;
547 }
548
549 if (current === undefined) {
550 return yield* new ResourceNotResolved({ name: input.name });
551 }
552
553 const currentName = current.name ?? input.name;
554 const shortChanged = !sameText(
555 current.shortDescription,
556 input.news.shortDescription,
557 );
558 const longChanged = !sameText(current.longDescription, desiredLong);
559 const expirationChanged = !sameText(
560 current.expirationTime,
561 input.news.expirationTime,
562 );
563 const relatedUrlChanged = !sameJson(
564 current.relatedUrl ?? [],
565 input.news.relatedUrl ?? [],
566 );
567 const relatedNotesChanged = !sameJson(
568 [...(current.relatedNoteNames ?? [])].slice().sort(),
569 [...(input.news.relatedNoteNames ?? [])].slice().sort(),
570 );
571 const attestationChanged = !sameJson(current.attestation, kinds.attestation);
572 const buildChanged = !sameJson(current.build, kinds.build);
573 const discoveryChanged = !sameJson(current.discovery, kinds.discovery);
574 const imageChanged = !sameJson(current.image, kinds.image);
575 const packageChanged = !sameJson(current.package, kinds.package);
576 const vulnerabilityChanged = !sameJson(
577 current.vulnerability,
578 kinds.vulnerability,
579 );
580 const complianceChanged = !sameJson(current.compliance, kinds.compliance);
581 const deploymentChanged = !sameJson(current.deployment, kinds.deployment);
582 const upgradeChanged = !sameJson(current.upgrade, kinds.upgrade);
583 const assessmentChanged = !sameJson(
584 current.vulnerabilityAssessment,
585 kinds.vulnerabilityAssessment,
586 );
587 const secretChanged = !sameJson(current.secret, kinds.secret);
588 const dsseChanged = !sameJson(current.dsseAttestation, kinds.dsseAttestation);
589 const sbomChanged = !sameJson(current.sbomReference, kinds.sbomReference);
590 const skillChanged = !sameJson(
591 current.aiSkillAnalysis,
592 kinds.aiSkillAnalysis,
593 );
594
595 const updateMask = updateMaskOf(
596 shortChanged ? "shortDescription" : undefined,
597 longChanged ? "longDescription" : undefined,
598 expirationChanged ? "expirationTime" : undefined,
599 relatedUrlChanged ? "relatedUrl" : undefined,
600 relatedNotesChanged ? "relatedNoteNames" : undefined,
601 attestationChanged && kinds.attestation !== undefined
602 ? "attestation"
603 : undefined,
604 buildChanged && kinds.build !== undefined ? "build" : undefined,
605 discoveryChanged && kinds.discovery !== undefined ? "discovery" : undefined,
606 imageChanged && kinds.image !== undefined ? "image" : undefined,
607 packageChanged && kinds.package !== undefined ? "package" : undefined,
608 vulnerabilityChanged && kinds.vulnerability !== undefined
609 ? "vulnerability"
610 : undefined,
611 complianceChanged && kinds.compliance !== undefined
612 ? "compliance"
613 : undefined,
614 deploymentChanged && kinds.deployment !== undefined
615 ? "deployment"
616 : undefined,
617 upgradeChanged && kinds.upgrade !== undefined ? "upgrade" : undefined,
618 assessmentChanged && kinds.vulnerabilityAssessment !== undefined
619 ? "vulnerabilityAssessment"
620 : undefined,
621 secretChanged && kinds.secret !== undefined ? "secret" : undefined,
622 dsseChanged && kinds.dsseAttestation !== undefined
623 ? "dsseAttestation"
624 : undefined,
625 sbomChanged && kinds.sbomReference !== undefined
626 ? "sbomReference"
627 : undefined,
628 skillChanged && kinds.aiSkillAnalysis !== undefined
629 ? "aiSkillAnalysis"
630 : undefined,
631 );
632
633 if (updateMask.length > 0) {
634 current = yield* input.ops.patch({
635 name: currentName,
636 updateMask,
637 body: {
638 shortDescription: input.news.shortDescription,
639 longDescription: desiredLong,
640 expirationTime: input.news.expirationTime,
641 relatedUrl: input.news.relatedUrl,
642 relatedNoteNames: input.news.relatedNoteNames,
643 ...kinds,
644 },
645 });
646 }
647
648 return current;
649});
650
651export type OccurrenceOps<E = unknown, R = unknown> = {
652 get: (
653 name: string,
654 ) => Effect.Effect<containeranalysis.Occurrence | undefined, E, R>;
655 create: (input: {
656 parent: string;
657 body: containeranalysis.Occurrence;
658 }) => Effect.Effect<containeranalysis.Occurrence | undefined, E, R>;
659 patch: (input: {
660 name: string;
661 updateMask: string;
662 body: containeranalysis.Occurrence;
663 }) => Effect.Effect<containeranalysis.Occurrence, E, R>;
664};
665
666export const reconcileOccurrence = Effect.fn(function* (input: {
667 id: string;
668 name: string | undefined;
669 parent: string;
670 project: string;
671 location: string | undefined;
672 news: OccurrenceNews;
673 ops: OccurrenceOps;
674}) {
675 const ownership = yield* createInternalLabels(input.id);
676 const desiredRemediation = encodeDescription(
677 ownership,
678 input.news.remediation,
679 );
680 const noteName = expandNoteName(
681 input.news.noteName,
682 input.project,
683 input.location,
684 );
685 const kinds = occurrenceKindFields(input.news);
686
687 let current =
688 input.name !== undefined && input.name.length > 0
689 ? yield* input.ops.get(input.name)
690 : undefined;
691
692 if (current === undefined) {
693 const created = yield* input.ops.create({
694 parent: input.parent,
695 body: {
696 noteName,
697 resourceUri: input.news.resourceUri,
698 remediation: desiredRemediation,
699 ...kinds,
700 },
701 });
702 current = created ?? undefined;
703 }
704
705 if (current === undefined) {
706 return yield* new ResourceNotResolved({
707 name: input.name ?? `${input.parent}/occurrences`,
708 });
709 }
710
711 const currentName = current.name ?? input.name ?? "";
712 const remediationChanged = !sameText(current.remediation, desiredRemediation);
713 const envelopeChanged = !sameJson(current.envelope, kinds.envelope);
714 const attestationChanged = !sameJson(current.attestation, kinds.attestation);
715 const buildChanged = !sameJson(current.build, kinds.build);
716 const discoveryChanged = !sameJson(current.discovery, kinds.discovery);
717 const imageChanged = !sameJson(current.image, kinds.image);
718 const packageChanged = !sameJson(current.package, kinds.package);
719 const vulnerabilityChanged = !sameJson(
720 current.vulnerability,
721 kinds.vulnerability,
722 );
723 const complianceChanged = !sameJson(current.compliance, kinds.compliance);
724 const deploymentChanged = !sameJson(current.deployment, kinds.deployment);
725 const upgradeChanged = !sameJson(current.upgrade, kinds.upgrade);
726 const dsseChanged = !sameJson(current.dsseAttestation, kinds.dsseAttestation);
727 const sbomChanged = !sameJson(current.sbomReference, kinds.sbomReference);
728 const secretChanged = !sameJson(current.secret, kinds.secret);
729 const skillChanged = !sameJson(
730 current.aiSkillAnalysis,
731 kinds.aiSkillAnalysis,
732 );
733
734 const updateMask = updateMaskOf(
735 remediationChanged ? "remediation" : undefined,
736 envelopeChanged && kinds.envelope !== undefined ? "envelope" : undefined,
737 attestationChanged && kinds.attestation !== undefined
738 ? "attestation"
739 : undefined,
740 buildChanged && kinds.build !== undefined ? "build" : undefined,
741 discoveryChanged && kinds.discovery !== undefined ? "discovery" : undefined,
742 imageChanged && kinds.image !== undefined ? "image" : undefined,
743 packageChanged && kinds.package !== undefined ? "package" : undefined,
744 vulnerabilityChanged && kinds.vulnerability !== undefined
745 ? "vulnerability"
746 : undefined,
747 complianceChanged && kinds.compliance !== undefined
748 ? "compliance"
749 : undefined,
750 deploymentChanged && kinds.deployment !== undefined
751 ? "deployment"
752 : undefined,
753 upgradeChanged && kinds.upgrade !== undefined ? "upgrade" : undefined,
754 dsseChanged && kinds.dsseAttestation !== undefined
755 ? "dsseAttestation"
756 : undefined,
757 sbomChanged && kinds.sbomReference !== undefined
758 ? "sbomReference"
759 : undefined,
760 secretChanged && kinds.secret !== undefined ? "secret" : undefined,
761 skillChanged && kinds.aiSkillAnalysis !== undefined
762 ? "aiSkillAnalysis"
763 : undefined,
764 );
765
766 if (updateMask.length > 0) {
767 current = yield* input.ops.patch({
768 name: currentName,
769 updateMask,
770 body: {
771 noteName,
772 resourceUri: input.news.resourceUri,
773 remediation: desiredRemediation,
774 ...kinds,
775 },
776 });
777 }
778
779 return current;
780});
781
782export const ownedNote = (
783 id: string,
784 note: containeranalysis.Note,
785 project: string,
786) =>
787 Effect.gen(function* () {
788 const attrs = noteAttrs(note, project);
789 const { labels } = parseDescription(note.longDescription);
790 return (yield* hasAlchemyLabels(id, labels)) ? attrs : undefined;
791 });
792
793export const ownedOccurrence = (
794 id: string,
795 occurrence: containeranalysis.Occurrence,
796 project: string,
797) =>
798 Effect.gen(function* () {
799 const attrs = occurrenceAttrs(occurrence, project);
800 const { labels } = parseDescription(occurrence.remediation);
801 return (yield* hasAlchemyLabels(id, labels)) ? attrs : undefined;
802 });
803
804export const listProjectNotes = (project: string) =>
805 collectPages(
806 containeranalysis.listProjectsNotes.pages({
807 parent: projectParent(project),
808 pageSize: 1000,
809 }),
810 (page) => page.notes,
811 );
812
813export const listLocationNotes = (project: string, location: string) =>
814 collectPages(
815 containeranalysis.listProjectsLocationsNotes.pages({
816 parent: locationParent(project, location),
817 pageSize: 1000,
818 returnPartialSuccess: location === "-",
819 }),
820 (page) => page.notes,
821 );
822
823export const listProjectOccurrences = (project: string) =>
824 collectPages(
825 containeranalysis.listProjectsOccurrences.pages({
826 parent: projectParent(project),
827 pageSize: 1000,
828 }),
829 (page) => page.occurrences,
830 );
831
832export const listLocationOccurrences = (project: string, location: string) =>
833 collectPages(
834 containeranalysis.listProjectsLocationsOccurrences.pages({
835 parent: locationParent(project, location),
836 pageSize: 1000,
837 returnPartialSuccess: location === "-",
838 }),
839 (page) => page.occurrences,
840 );
841
842export const findOwnedNote = (
843 id: string,
844 notes: readonly containeranalysis.Note[],
845 project: string,
846) =>
847 Effect.gen(function* () {
848 for (const note of notes) {
849 const owned = yield* ownedNote(id, note, project);
850 if (owned !== undefined) return owned;
851 }
852 return undefined;
853 });
854
855export const findOwnedOccurrence = (
856 id: string,
857 occurrences: readonly containeranalysis.Occurrence[],
858 project: string,
859) =>
860 Effect.gen(function* () {
861 for (const occurrence of occurrences) {
862 const owned = yield* ownedOccurrence(id, occurrence, project);
863 if (owned !== undefined) return owned;
864 }
865 return undefined;
866 });