From 795a842470cbd7036716f099ce9e666a3e81b57f Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Fri, 14 Aug 2026 19:48:43 -0700 Subject: [PATCH] graph: surface referrers + deletions in service + dashboard Completes #205: exposes the referrer/deletion data (from #201/#202, queried in #208) through the graph-service HTTP API and the dashboard. - graph-service: new GET /artifacts/referrers (json|cytoscape|mermaid); the existing /artifacts/show already returns referrers + the deletion tombstone. - dashboard-web: GraphClient.referrers + a /graph/referrers htmx route + a 'List referrer artifacts' form on the observability view; deleted occurrences now get a 'deleted' badge in the neighborhood node list. - Tests: graph-service 18, dashboard-web 25 (referrers endpoint/route/ client + deletion marker). Closes #205 --- .../src/dashboard_web/clients.py | 10 ++++ .../templates/stages/_graph_neighborhood.html | 2 +- .../templates/stages/_graph_referrers.html | 30 ++++++++++++ .../templates/stages/observability.html | 13 +++++ .../src/dashboard_web/web/routes.py | 17 +++++++ .../dashboard-web/tests/test_graph.py | 49 ++++++++++++++++++- .../graph-service/src/graph_service/app.py | 19 +++++++ .../services/graph-service/tests/test_app.py | 26 ++++++++++ 8 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py b/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py index 7cfd041..390b451 100644 --- a/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py +++ b/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py @@ -18,6 +18,8 @@ def readiness(self) -> dict[str, Any]: ... def neighborhood(self, ref: str, depth: int = 3) -> dict[str, Any]: ... + def referrers(self, ref: str, depth: int = 3) -> dict[str, Any]: ... + class PackagesServiceClient: def __init__( @@ -105,3 +107,11 @@ def neighborhood(self, ref: str, depth: int = 3) -> dict[str, Any]: ) response.raise_for_status() return response.json() + + def referrers(self, ref: str, depth: int = 3) -> dict[str, Any]: + response = self._client.get( + f"{self._base}/artifacts/referrers", + params={"ref": ref, "depth": depth, "format": "json"}, + ) + response.raise_for_status() + return response.json() diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html index 7614e0b..ca16986 100644 --- a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html +++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html @@ -38,7 +38,7 @@ {{ subgraph.nodes | length }} node(s) diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html new file mode 100644 index 0000000..7267202 --- /dev/null +++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html @@ -0,0 +1,30 @@ +{% if error %} +

{{ error }}

+{% elif not ref %} +

Enter an image reference to list its referrer artifacts.

+{% elif not subgraph or not subgraph.edges %} +

No referrers found for {{ ref }}.

+{% else %} +

+ Referrers of {{ ref }}: + {{ subgraph.edges | length }} referrer(s) +

+ + + + + + + + + + {% for edge in subgraph.edges %} + + + + + + {% endfor %} + +
Artifact typeReferrerSubject
{{ edge.artifactType or edge.type }}{{ edge.from }}{{ edge.to }}
+{% endif %} diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html index 6ef53ca..f20cfdc 100644 --- a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html +++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html @@ -28,4 +28,17 @@
+ +
+ + + + +
+ +
diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py b/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py index f7ed4b0..f6123b8 100644 --- a/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py +++ b/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py @@ -99,3 +99,20 @@ def graph_neighborhood(request: Request, ref: str = "", depth: int = 3) -> HTMLR context={"ref": ref, "subgraph": subgraph, "error": error}, ) + @app.get("/graph/referrers", response_class=HTMLResponse) + def graph_referrers(request: Request, ref: str = "", depth: int = 3) -> HTMLResponse: + ref = ref.strip() + subgraph = None + error = None + if ref: + try: + subgraph = graph.referrers(ref, depth=depth) + except Exception: # log details server-side, show a generic message + logger.exception("Failed to load referrers for %s", ref) + error = "the referrers could not be loaded for that reference" + return templates.TemplateResponse( + request=request, + name="stages/_graph_referrers.html", + context={"ref": ref, "subgraph": subgraph, "error": error}, + ) + diff --git a/apps/python-app/services/dashboard-web/tests/test_graph.py b/apps/python-app/services/dashboard-web/tests/test_graph.py index 20dfa42..30eeb32 100644 --- a/apps/python-app/services/dashboard-web/tests/test_graph.py +++ b/apps/python-app/services/dashboard-web/tests/test_graph.py @@ -33,11 +33,27 @@ ], } +REFERRERS_SUBGRAPH = { + "nodes": [ + {"key": "ghcr.io/toddysm/golden/python@sha256:bb", "ref": "ghcr.io/toddysm/golden/python", "digest": "sha256:bb"}, + {"key": "ghcr.io/toddysm/golden/python@sha256:cc", "ref": "ghcr.io/toddysm/golden/python", "digest": "sha256:cc"}, + ], + "edges": [ + { + "type": "REFERS_TO", + "from": "ghcr.io/toddysm/golden/python@sha256:cc", + "to": "ghcr.io/toddysm/golden/python@sha256:bb", + "artifactType": "application/vnd.in-toto+json", + } + ], +} + class FakeGraph: - def __init__(self, ready: bool = True, records: int = 3, by_kind=None, subgraph=None): + def __init__(self, ready: bool = True, records: int = 3, by_kind=None, subgraph=None, referrers=None): self._readiness = {"ready": ready, "records": records, "by_kind": by_kind or {"ArtifactBuilt": 2}} self._subgraph = subgraph if subgraph is not None else SUBGRAPH + self._referrers = referrers if referrers is not None else REFERRERS_SUBGRAPH def readiness(self): return self._readiness @@ -45,6 +61,9 @@ def readiness(self): def neighborhood(self, ref, depth=3): return self._subgraph + def referrers(self, ref, depth=3): + return self._referrers + def _app(graph: FakeGraph): registry = StageRegistry() @@ -90,6 +109,24 @@ def test_neighborhood_route_empty_subgraph(): assert "No graph found" in body +def test_referrers_route_renders_artifact_type(): + client = TestClient(_app(FakeGraph())) + resp = client.get("/graph/referrers", params={"ref": "ghcr.io/toddysm/golden/python:3.14-slim"}) + assert resp.status_code == 200 + assert "application/vnd.in-toto+json" in resp.text + + +def test_referrers_route_without_ref_prompts(): + client = TestClient(_app(FakeGraph())) + assert "list its referrer artifacts" in client.get("/graph/referrers").text + + +def test_referrers_route_empty(): + client = TestClient(_app(FakeGraph(referrers={"nodes": [], "edges": []}))) + body = client.get("/graph/referrers", params={"ref": "ghcr.io/none:0"}).text + assert "No referrers found" in body + + def test_graph_client_readiness_ready(): def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/readyz" @@ -116,3 +153,13 @@ def handler(request: httpx.Request) -> httpx.Response: client = GraphServiceClient("http://graph", client=httpx.Client(transport=httpx.MockTransport(handler))) assert client.neighborhood("ghcr.io/x:1")["edges"][0]["type"] == "BUILT_FROM" + + +def test_graph_client_referrers(): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/artifacts/referrers" + assert request.url.params["format"] == "json" + return httpx.Response(200, json=REFERRERS_SUBGRAPH) + + client = GraphServiceClient("http://graph", client=httpx.Client(transport=httpx.MockTransport(handler))) + assert client.referrers("ghcr.io/x:1")["edges"][0]["type"] == "REFERS_TO" diff --git a/apps/python-app/services/graph-service/src/graph_service/app.py b/apps/python-app/services/graph-service/src/graph_service/app.py index 15b9567..ce22d2c 100644 --- a/apps/python-app/services/graph-service/src/graph_service/app.py +++ b/apps/python-app/services/graph-service/src/graph_service/app.py @@ -111,6 +111,25 @@ def artifact_show(ref: str = Query(...)) -> dict[str, Any]: raise HTTPException(status_code=404, detail=f"no occurrence for '{ref}'") return result + @app.get("/artifacts/referrers") + def artifact_referrers( + ref: str | None = Query(None), + digest: str | None = Query(None), + depth: int = Query(3, ge=0), + format: str = Query("json", pattern="^(json|cytoscape|mermaid)$"), + ) -> Any: + if not ref and not digest: + raise HTTPException(status_code=400, detail="provide ref or digest") + with reading() as store: + subgraph = queries.referrers( + store, digest=digest, ref=ref, depth=min(depth, settings.max_depth) + ) + if format == "cytoscape": + return queries.to_cytoscape(subgraph) + if format == "mermaid": + return Response(content=queries.to_mermaid(subgraph), media_type="text/plain") + return subgraph + @app.get("/repositories/tags/history") def tag_history(ref: str = Query(...), tag: str = Query(...)) -> dict[str, Any]: with reading() as store: diff --git a/apps/python-app/services/graph-service/tests/test_app.py b/apps/python-app/services/graph-service/tests/test_app.py index f3e002d..f8faa1f 100644 --- a/apps/python-app/services/graph-service/tests/test_app.py +++ b/apps/python-app/services/graph-service/tests/test_app.py @@ -27,6 +27,7 @@ DIGEST_APP = "sha256:" + "a" * 64 DIGEST_GOLDEN = "sha256:" + "b" * 64 DIGEST_UP = "sha256:" + "c" * 64 +DIGEST_REF = "sha256:" + "d" * 64 SOURCE = { "type": "github-actions", @@ -84,6 +85,19 @@ "chart": {"name": "cssc-dashboard", "version": "0.1.2"}, }, ), + ( + "referrer-observed", + { + "schemaVersion": 1, + "kind": "ReferrerObserved", + "recordedAt": "2026-08-10T00:00:00Z", + "source": SOURCE, + "occurrence": {"registry": "ghcr.io", "repository": APP_REPO}, + "subject": {"digest": DIGEST_APP, "tag": "0.1.0"}, + "referrer": {"digest": DIGEST_REF, "artifactType": "application/vnd.in-toto+json"}, + "observedAt": "2026-08-10T00:00:00Z", + }, + ), ] @@ -193,6 +207,18 @@ def test_neighborhood_cytoscape(client: TestClient) -> None: assert "elements" in body +def test_referrers_endpoint(client: TestClient) -> None: + body = client.get("/artifacts/referrers", params={"ref": f"{APP_REF}@{DIGEST_APP}"}).json() + assert any( + e["type"] == "REFERS_TO" and e.get("artifactType") == "application/vnd.in-toto+json" + for e in body["edges"] + ) + + +def test_referrers_requires_selector(client: TestClient) -> None: + assert client.get("/artifacts/referrers").status_code == 400 + + def test_depth_is_capped(client: TestClient, tmp_path: Path) -> None: # A max_depth of 1 must stop bases traversal at the first hop. root = _write_data_root(tmp_path / "capped", RECORDS)