Skip to content

Commit ac1030d

Browse files
committed
Basic reporting
Example output: https://dpaste.com/FWAYRXSVM Several TODO's left, but perhaps functional enough to merge? Towards #9
1 parent f7a52f5 commit ac1030d

9 files changed

+386
-7
lines changed

ReadMe.md

+49
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,55 @@ Run the server with `uvicorn web:app --reload`
4040
};
4141
```
4242

43+
### Reporting
44+
45+
At the time of writing only reports on run-time closures are supported.
46+
Reporting is experimental and still expected to evolve, change, and
47+
grow support for build-time closures as well.
48+
49+
#### Defining a report
50+
51+
You define a report by uploading a JSON CycloneDX SBOM as produced by
52+
[nix-runtime-tree-to-sbom](https://codeberg.org/raboof/nix-runtime-tree-to-sbom):
53+
54+
```
55+
$ nix-store -q --tree $(nix-build '<nixpkgs/nixos/release-combined.nix>' -A nixos.iso_gnome.x86_64-linux) > tree.txt
56+
$ cat tree.txt | ~/dev/nix-runtime-tree-to-sbom/tree-to-cyclonedx.py > sbom.cdx.json
57+
$ export HASH_COLLECTION_TOKEN=XYX # your token
58+
$ curl -X PUT --data @sbom.cdx.json "http://localhost:8000/reports/gnome-iso-runtime" -H "Content-Type: application/json" -H "Authorization: Bearer $HASH_COLLECTION_TOKEN"
59+
```
60+
61+
#### Populating the report
62+
63+
If you want to populate the report with hashes from different builders (e.g. from
64+
cache.nixos.org and from your own rebuilds), use separate tokens for the different
65+
sources.
66+
67+
##### With hashes from cache.nixos.org
68+
69+
```
70+
$ nix shell .#utils
71+
$ export HASH_COLLECTION_TOKEN=XYX # your token for the cache.nixos.org import
72+
$ ./fetch-from-cache.sh
73+
```
74+
75+
This script is still very much WIP, and will enter an infinite loop retrying failed fetches.
76+
77+
##### By rebuilding
78+
79+
Make sure you have the post-build hook and diff hook configured as documented above.
80+
81+
TODO you have to make sure all derivations are available for building on your system -
82+
is there a smart way to do that?
83+
84+
```
85+
$ export HASH_COLLECTION_TOKEN=XYX # your token for the cache.nixos.org import
86+
$ ./rebuilder.sh
87+
```
88+
89+
This script is still very much WIP, and will enter an infinite loop retrying failed fetches.
90+
You can run multiple rebuilders in parallel.
91+
4392
## Related projects
4493

4594
* [nix-reproducible-builds-report](https://codeberg.org/raboof/nix-reproducible-builds-report/) aka `r13y`, which generates the reports at [https://reproducible.nixos.org](https://reproducible.nixos.org). Ideally the [reporting](https://github.com/JulienMalka/nix-hash-collection/issues/9) feature can eventually replace the reports there.

fetch-from-cache.sh

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
3+
REPORT=$1
4+
export HASH_COLLECTION_SERVER=http://localhost:8000
5+
6+
if [ "x" == "x$REPORT" ]; then
7+
echo "Usage: $0 <report-name>"
8+
exit 1
9+
fi
10+
11+
while true; do
12+
curl -H "Authorization: Bearer $HASH_COLLECTION_TOKEN" $HASH_COLLECTION_SERVER/reports/$REPORT/suggested | jq .[] | head -50 | tr -d \" | while read out
13+
do
14+
echo $out
15+
# TODO some/most of these can probably also be taken found in the
16+
# local cache (with a cache.nixos.org signature), so perhaps take them from there?
17+
copy-from-cache $out
18+
done
19+
done

flake.lock

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rebuilder.sh

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
3+
REPORT=$1
4+
5+
if [ "x" == "x$REPORT" ]; then
6+
echo "Usage: $0 <report-name>"
7+
exit 1
8+
fi
9+
10+
while true; do
11+
curl -H "Authorization: Bearer $HASH_COLLECTION_TOKEN" http://localhost:8000/reports/$REPORT/suggested | jq .[] | head | tr -d \" | while read out
12+
do
13+
(nix derivation show $out || exit 1) | jq keys.[] | tr -d \" | while read drv
14+
do
15+
# TODO select the right output to rebuild?
16+
nix-build $drv --check
17+
done
18+
done
19+
done

utils/src/bin/copy-from-cache.rs

+5
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ async fn fetch<'a>(out_path: &'a str) -> (String, OutputAttestation<'a>) {
1717
if response == "404" {
1818
panic!("Metadata for [{0}] not found on cache.nixos.org", out_path);
1919
}
20+
21+
// TODO Deriver is not populated for static inputs, and may be super useful:
22+
// the same output may have multiple derivers even for non-FOD derivations.
23+
// Should we make it optional in the data model / API as well?
24+
// https://github.com/JulienMalka/nix-hash-collection/issues/25
2025
let deriver = Regex::new(r"(?m)Deriver: (.*).drv").unwrap()
2126
.captures(&response)
2227
.expect(format!("Deriver not found in metadata for [{0}]", out_path).as_str())

web/__init__.py

+220-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from collections import defaultdict
2-
from sys import argv
2+
import json
3+
import random
34
import typing as t
4-
from fastapi import Depends, FastAPI, HTTPException
5+
from fastapi import Depends, FastAPI, Header, HTTPException, Response
56
from fastapi.security.http import HTTPAuthorizationCredentials, HTTPBearer
67
from fastapi.middleware.cors import CORSMiddleware
78
from sqlalchemy.orm import Session
@@ -86,6 +87,23 @@ def get_drv(drv_hash: str,
8687
def get_drv_recap(drv_hash: str, db: Session = Depends(get_db)) -> schemas.DerivationAttestation:
8788
return get_drv_recap_or_404(db, drv_hash)
8889

90+
# Suggested rebuilds
91+
@app.get("/reports/{name}/suggested")
92+
def derivations_suggested_for_rebuilding(
93+
name: str,
94+
token: str = Depends(get_token),
95+
db: Session = Depends(get_db),
96+
):
97+
report = crud.report(db, name)
98+
if report == None:
99+
raise HTTPException(status_code=404, detail="Report not found")
100+
paths = report_out_paths(report)
101+
102+
user = crud.get_user_with_token(db, token)
103+
suggestions = crud.suggest(db, paths, user)
104+
random.shuffle(suggestions)
105+
return suggestions[:50]
106+
89107
@app.post("/attestation/{drv_hash}")
90108
def record_attestation(
91109
drv_hash: str,
@@ -102,4 +120,204 @@ def record_attestation(
102120
"Attestation accepted"
103121
}
104122

123+
@app.get("/attestations/by-output/{output_path}")
124+
def attestations_by_out(output_path: str, db: Session = Depends(get_db)):
125+
return db.query(models.Attestation).filter_by(output_path="/nix/store/"+output_path).all()
126+
127+
def report_out_paths(report):
128+
paths = []
129+
for component in report['components']:
130+
for prop in component['properties']:
131+
if prop['name'] == "nix:out_path":
132+
paths.append(prop['value'])
133+
return paths
134+
135+
@app.get("/reports")
136+
def reports(db: Session = Depends(get_db)):
137+
reports = db.query(models.Report).all()
138+
names = []
139+
for report in reports:
140+
names.append(report.name)
141+
return names
142+
143+
def printtree(root, deps, results, cur_indent=0, seen=None):
144+
if seen is None:
145+
seen = {}
146+
if root in seen:
147+
return " " * cur_indent + "...\n"
148+
seen[root] = True;
149+
150+
result = " " * cur_indent + root[11:];
151+
if root in results:
152+
result = result + " " + results[root] + "\n"
153+
else:
154+
result = result + "\n"
155+
for dep in deps:
156+
if dep['ref'] == root and 'dependsOn' in dep:
157+
for d in dep['dependsOn']:
158+
result += printtree(d, deps, results, cur_indent+2, seen)
159+
#result = result + "\n " + d
160+
return result
161+
162+
def htmltree(root, deps, results):
163+
def icon(result):
164+
if result == "No builds":
165+
return "❔ "
166+
elif result == "One build":
167+
return "❎ "
168+
elif result == "Partially reproduced":
169+
return "❕ "
170+
elif result == "Successfully reproduced":
171+
return "✅ "
172+
elif result == "Consistently nondeterministic":
173+
return "❌ "
174+
else:
175+
return ""
176+
def generatetree(root, seen):
177+
if root in seen:
178+
return f'<summary title="{root}">...</summary>'
179+
seen[root] = True;
180+
181+
result = f'<summary title="{root}">'
182+
if root in results:
183+
result = result + f'<span title="{results[root]}">' + icon(results[root]) + "</span>" + root[44:] + " "
184+
else:
185+
result = result + root[44:]
186+
result = result + "</summary>\n"
187+
result = result + "<ul>"
188+
for dep in deps:
189+
if dep['ref'] == root and 'dependsOn' in dep:
190+
for d in dep['dependsOn']:
191+
result += f'<li><details class="{d}" open>'
192+
result += generatetree(d, seen)
193+
result += "</details></li>"
194+
result = result + "</ul>"
195+
return result
196+
tree = generatetree(root, {})
197+
return '''
198+
<html>
199+
<head>
200+
<style>
201+
.tree{
202+
--spacing : 1.5rem;
203+
--radius : 8px;
204+
}
205+
206+
.tree li{
207+
display : block;
208+
position : relative;
209+
padding-left : calc(2 * var(--spacing) - var(--radius) - 2px);
210+
}
211+
212+
.tree ul{
213+
margin-left : calc(var(--radius) - var(--spacing));
214+
padding-left : 0;
215+
}
216+
217+
.tree ul li{
218+
border-left : 2px solid #ddd;
219+
}
220+
221+
.tree ul li:last-child{
222+
border-color : transparent;
223+
}
224+
225+
.tree ul li::before{
226+
content : '';
227+
display : block;
228+
position : absolute;
229+
top : calc(var(--spacing) / -2);
230+
left : -2px;
231+
width : calc(var(--spacing) + 2px);
232+
height : calc(var(--spacing) + 1px);
233+
border : solid #ddd;
234+
border-width : 0 0 2px 2px;
235+
}
236+
237+
.tree summary{
238+
display : block;
239+
cursor : pointer;
240+
}
241+
242+
.tree summary::marker,
243+
.tree summary::-webkit-details-marker{
244+
display : none;
245+
}
246+
247+
.tree summary:focus{
248+
outline : none;
249+
}
250+
251+
.tree summary:focus-visible{
252+
outline : 1px dotted #000;
253+
}
254+
255+
.tree li::after,
256+
.tree summary::before{
257+
content : '';
258+
display : block;
259+
position : absolute;
260+
top : calc(var(--spacing) / 2 - var(--radius));
261+
left : calc(var(--spacing) - var(--radius) - 1px);
262+
width : calc(2 * var(--radius));
263+
height : calc(2 * var(--radius));
264+
border-radius : 50%;
265+
background : #ddd;
266+
}
267+
268+
</style>
269+
</head>
270+
''' + f'''
271+
<body>
272+
<ul class="tree">
273+
<li>
274+
{tree}
275+
</li>
276+
</ul>
277+
</body>
278+
</html>
279+
'''
280+
281+
@app.get("/reports/{name}")
282+
def report(
283+
name: str,
284+
accept: t.Optional[str] = Header(default="*/*"),
285+
db: Session = Depends(get_db),
286+
):
287+
report = crud.report(db, name)
288+
if report == None:
289+
raise HTTPException(status_code=404, detail="Report not found")
290+
291+
if 'application/vnd.cyclonedx+json' in accept:
292+
return Response(
293+
content=json.dumps(report),
294+
media_type='application/vnd.cyclonedx+json')
295+
296+
paths = report_out_paths(report)
105297

298+
root = report['metadata']['component']['bom-ref']
299+
results = crud.path_summaries(db, paths)
300+
301+
if 'text/html' in accept:
302+
return Response(
303+
content=htmltree(root, report['dependencies'], results),
304+
media_type='text/html')
305+
else:
306+
return Response(
307+
content=printtree(root, report['dependencies'], results),
308+
media_type='text/plain')
309+
310+
@app.put("/reports/{name}")
311+
def define_report(
312+
name: str,
313+
definition: schemas.ReportDefinition,
314+
token: str = Depends(get_token),
315+
db: Session = Depends(get_db),
316+
):
317+
user = crud.get_user_with_token(db, token)
318+
if user == None:
319+
raise HTTPException(status_code=401, detail="User not found")
320+
crud.define_report(db, name, definition.root)
321+
return {
322+
"Report defined"
323+
}

0 commit comments

Comments
 (0)