Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 28 additions & 116 deletions web/src/features/image/domain/image.types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,184 +134,96 @@ describe("filterImages — webhook filters", () => {
});

describe("annotateImages", () => {
it("does nothing when there are only spec workloads", () => {
it("does nothing when changeType is none or unset", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tagType: "semver",
changeType: "none",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
];
const out = annotateImages(input);
expect(out[0]?.workloads[0]?.mutated).toBeUndefined();
expect(out[0]?.workloads[0]?.hidden).toBeUndefined();
expect(out[0]?.hasMutation).toBeUndefined();
});

it("marks a pod ref as mutated and hides the matching spec ref", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
repository: "acme/web",
tag: "1.0.0",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.1-pinned",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "pod" },
{ kind: "Deployment", namespace: "default", name: "web", container: "main", source: "spec" },
],
},
];
const out = annotateImages(input);
expect(out[0]?.workloads[0]?.hidden).toBe(true);
expect(out[1]?.workloads[0]?.mutated).toBe(true);
expect(out[1]?.hasMutation).toBe(true);
expect(out[0]?.hasMutation).toBeUndefined();
expect(out[0]?.hasInjection).toBeUndefined();
expect(out[0]?.workloads[0]?.mutated).toBeUndefined();
expect(out[1]?.hasMutation).toBeUndefined();
});

it("marks injected sidecars (pod-only) as injected, not mutated", () => {
it("flags hasMutation and tags every workload when changeType is mutated", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tag: "1.0.1-pinned",
tagType: "semver",
changeType: "mutated",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
];
const out = annotateImages(input);
expect(out[0]?.hasMutation).toBe(true);
expect(out[0]?.hasInjection).toBeUndefined();
expect(out[0]?.workloads[0]?.mutated).toBe(true);
expect(out[0]?.workloads[0]?.injected).toBeUndefined();
});

it("flags hasInjection and tags every workload when changeType is injected", () => {
const input: Image[] = [
{
registry: "docker.io",
repository: "istio/proxyv2",
tag: "1.20.0",
tagType: "semver",
changeType: "injected",
workloads: [
{
kind: "Deployment",
namespace: "default",
name: "api",
container: "istio-proxy",
source: "pod",
source: "spec",
},
],
},
];
const out = annotateImages(input);
expect(out[1]?.workloads[0]?.mutated).toBeUndefined();
expect(out[1]?.workloads[0]?.injected).toBe(true);
expect(out[1]?.hasMutation).toBeUndefined();
expect(out[1]?.hasInjection).toBe(true);
});

it("does not mark spec refs as injected", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
];
const out = annotateImages(input);
expect(out[0]?.workloads[0]?.injected).toBeUndefined();
expect(out[0]?.hasInjection).toBeUndefined();
expect(out[0]?.hasInjection).toBe(true);
expect(out[0]?.hasMutation).toBeUndefined();
expect(out[0]?.workloads[0]?.injected).toBe(true);
expect(out[0]?.workloads[0]?.mutated).toBeUndefined();
});

it("does not mark mutated pod refs as injected", () => {
it("hasVisibleWorkloads keeps images that have any non-hidden workload", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.1-pinned",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "pod" },
],
},
];
const out = annotateImages(input);
expect(out[1]?.workloads[0]?.mutated).toBe(true);
expect(out[1]?.workloads[0]?.injected).toBeUndefined();
expect(out[1]?.hasInjection).toBeUndefined();
});

it("hasVisibleWorkloads drops images whose every ref is hidden", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tagType: "semver",
changeType: "mutated",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
],
},
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.1-pinned",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "pod" },
],
},
];
const out = annotateImages(input).filter(hasVisibleWorkloads);
expect(out).toHaveLength(1);
expect(out[0]?.tag).toBe("1.0.1-pinned");
});

it("preserves visible spec refs for unmutated containers on the same image", () => {
const input: Image[] = [
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.0",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "spec" },
{ kind: "Deployment", namespace: "default", name: "api", container: "worker", source: "spec" },
],
},
{
registry: "ghcr.io",
repository: "acme/api",
tag: "1.0.1-pinned",
tagType: "semver",
workloads: [
{ kind: "Deployment", namespace: "default", name: "api", container: "web", source: "pod" },
],
},
];
const out = annotateImages(input).filter(hasVisibleWorkloads);
// Spec image still visible thanks to "worker" ref; "web" hidden.
expect(out).toHaveLength(2);
const spec = out.find((i) => i.tag === "1.0.0");
expect(spec?.workloads.find((w) => w.container === "web")?.hidden).toBe(true);
expect(spec?.workloads.find((w) => w.container === "worker")?.hidden).toBeUndefined();
});
});

Expand Down
74 changes: 17 additions & 57 deletions web/src/features/image/domain/image.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,68 +86,28 @@ export interface ImageGroup {
readonly stats: ImageGroupStats;
}

const containerKey = (w: WorkloadRef): string =>
`${w.kind}/${w.namespace}/${w.name}/${w.container}`;

// annotateImages enriches the API response with webhook-activity flags.
// annotateImages derives webhook-activity flags from the canonical
// `changeType` field set per image by the backend (ImageRegistry pipeline).
//
// Pod-source refs come in two flavors:
// - Mutated: same workload+container appears in both a spec-source ref
// (image A) and a pod-source ref (image B). The backend only emits the
// pod ref when A != B, so any (spec, pod) pair for the same container is
// a mutation. The pod ref is marked `mutated`, the matching spec ref is
// `hidden` so the UI shows the actual running image with priority.
// - Injected: pod-source ref with no spec counterpart anywhere — the
// container was added by a MutatingWebhook (e.g. Istio sidecar). The ref
// is marked `injected` so the UI can display it distinctly.
// Each ImageRegistry entry already classifies its image as "none", "mutated",
// or "injected" — there is no need for cross-image (spec, pod) ref pairing.
// We propagate the image-level classification down to every visible workload
// so the per-row badges in WorkloadList keep rendering correctly.
export function annotateImages(images: readonly Image[]): Image[] {
const specByContainer = new Map<string, Array<{ imgIdx: number; refIdx: number }>>();
const podByContainer = new Map<string, Array<{ imgIdx: number; refIdx: number }>>();

images.forEach((img, imgIdx) => {
img.workloads.forEach((w, refIdx) => {
const k = containerKey(w);
const bucket = w.source === "pod" ? podByContainer : specByContainer;
const list = bucket.get(k);
if (list) list.push({ imgIdx, refIdx });
else bucket.set(k, [{ imgIdx, refIdx }]);
});
});

const mutated = new Set<string>();
const hidden = new Set<string>();
const injected = new Set<string>();
for (const [k, podLocs] of podByContainer) {
const specLocs = specByContainer.get(k);
if (specLocs?.length) {
for (const l of podLocs) mutated.add(`${l.imgIdx}:${l.refIdx}`);
for (const l of specLocs) hidden.add(`${l.imgIdx}:${l.refIdx}`);
} else {
for (const l of podLocs) injected.add(`${l.imgIdx}:${l.refIdx}`);
}
}

return images.map((img, imgIdx) => {
const workloads = img.workloads.map((w, refIdx) => {
const key = `${imgIdx}:${refIdx}`;
const isMutated = mutated.has(key);
const isInjected = injected.has(key);
const isHidden = hidden.has(key);
if (!isMutated && !isInjected && !isHidden) return w;
return {
...w,
mutated: isMutated || undefined,
injected: isInjected || undefined,
hidden: isHidden || undefined,
};
});
const hasMutation = workloads.some((w) => w.mutated);
const hasInjection = workloads.some((w) => w.injected);
return images.map((img) => {
const isMutated = img.changeType === "mutated";
const isInjected = img.changeType === "injected";
if (!isMutated && !isInjected) return img;
const workloads = img.workloads.map((w) => ({
...w,
mutated: isMutated || undefined,
injected: isInjected || undefined,
}));
return {
...img,
workloads,
hasMutation: hasMutation || undefined,
hasInjection: hasInjection || undefined,
hasMutation: isMutated || undefined,
hasInjection: isInjected || undefined,
};
});
}
Expand Down