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
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<summary>{{ subgraph.nodes | length }} node(s)</summary>
<ul>
{% for node in subgraph.nodes %}
<li><code>{{ node.ref or node.key }}</code>{% if node.digest %} <span class="badge">{{ node.digest[:19] }}…</span>{% endif %}</li>
<li><code>{{ node.ref or node.key }}</code>{% if node.digest %} <span class="badge">{{ node.digest[:19] }}…</span>{% endif %}{% if node.deletedAt %} <span class="badge badge-deleted">deleted</span>{% endif %}</li>
{% endfor %}
</ul>
</details>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% if error %}
<p class="empty">{{ error }}</p>
{% elif not ref %}
<p class="empty">Enter an image reference to list its referrer artifacts.</p>
{% elif not subgraph or not subgraph.edges %}
<p class="empty">No referrers found for <code>{{ ref }}</code>.</p>
{% else %}
<p>
Referrers of <code>{{ ref }}</code>:
<span class="badge">{{ subgraph.edges | length }} referrer(s)</span>
</p>
<table class="graph-edges">
<thead>
<tr>
<th>Artifact type</th>
<th>Referrer</th>
<th>Subject</th>
</tr>
</thead>
<tbody>
{% for edge in subgraph.edges %}
<tr>
<td>{{ edge.artifactType or edge.type }}</td>
<td><code>{{ edge.from }}</code></td>
<td><code>{{ edge.to }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,17 @@
</form>

<div id="graph-neighborhood"></div>

<form class="graph-explore"
hx-get="/graph/referrers"
hx-target="#graph-referrers"
hx-swap="innerHTML">
<label for="referrers-ref">List referrer artifacts (SBOM, provenance, VEX, signatures)</label>
<input id="referrers-ref" type="text" name="ref"
placeholder="ghcr.io/toddysm/golden/python:3.14-slim" />
<input type="hidden" name="depth" value="3" />
<button type="submit">Referrers</button>
</form>

<div id="graph-referrers"></div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)

49 changes: 48 additions & 1 deletion apps/python-app/services/dashboard-web/tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,37 @@
],
}

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

def neighborhood(self, ref, depth=3):
return self._subgraph

def referrers(self, ref, depth=3):
return self._referrers


def _app(graph: FakeGraph):
registry = StageRegistry()
Expand Down Expand Up @@ -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"
Expand All @@ -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"
19 changes: 19 additions & 0 deletions apps/python-app/services/graph-service/src/graph_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions apps/python-app/services/graph-service/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
),
]


Expand Down Expand Up @@ -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)
Expand Down
Loading