Skip to content

Commit

Permalink
Basic reporting
Browse files Browse the repository at this point in the history
Example output: https://dpaste.com/FWAYRXSVM

Several TODO's left, but perhaps functional enough to merge?

Towards #9
  • Loading branch information
raboof committed May 6, 2024
1 parent 7d9311e commit 3434a83
Show file tree
Hide file tree
Showing 9 changed files with 386 additions and 6 deletions.
49 changes: 49 additions & 0 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,55 @@ Run the server with `uvicorn web:app --reload`
};
```

### Reporting

At the time of writing only reports on run-time closures are supported.
Reporting is experimental and still expected to evolve, change, and
grow support for build-time closures as well.

#### Defining a report

You define a report by uploading a JSON CycloneDX SBOM as produced by
[nix-runtime-tree-to-sbom](https://codeberg.org/raboof/nix-runtime-tree-to-sbom):

```
$ nix-store -q --tree $(nix-build '<nixpkgs/nixos/release-combined.nix>' -A nixos.iso_gnome.x86_64-linux) > tree.txt
$ cat tree.txt | ~/dev/nix-runtime-tree-to-sbom/tree-to-cyclonedx.py > sbom.cdx.json
$ export HASH_COLLECTION_TOKEN=XYX # your token
$ 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"
```

#### Populating the report

If you want to populate the report with hashes from different builders (e.g. from
cache.nixos.org and from your own rebuilds), use separate tokens for the different
sources.

##### With hashes from cache.nixos.org

```
$ nix shell .#utils
$ export HASH_COLLECTION_TOKEN=XYX # your token for the cache.nixos.org import
$ ./fetch-from-cache.sh
```

This script is still very much WIP, and will enter an infinite loop retrying failed fetches.

##### By rebuilding

Make sure you have the post-build hook and diff hook configured as documented above.

TODO you have to make sure all derivations are available for building on your system -
is there a smart way to do that?

```
$ export HASH_COLLECTION_TOKEN=XYX # your token for the cache.nixos.org import
$ ./rebuilder.sh
```

This script is still very much WIP, and will enter an infinite loop retrying failed fetches.
You can run multiple rebuilders in parallel.

## Related projects

* [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.
Expand Down
19 changes: 19 additions & 0 deletions fetch-from-cache.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash

REPORT=$1
export HASH_COLLECTION_SERVER=http://localhost:8000

if [ "x" == "x$REPORT" ]; then
echo "Usage: $0 <report-name>"
exit 1
fi

while true; do
curl -H "Authorization: Bearer $HASH_COLLECTION_TOKEN" $HASH_COLLECTION_SERVER/reports/$REPORT/suggested | jq .[] | head -50 | tr -d \" | while read out
do
echo $out
# TODO some/most of these can probably also be taken found in the
# local cache (with a cache.nixos.org signature), so perhaps take them from there?
copy-from-cache $out
done
done
6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions rebuilder.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash

REPORT=$1

if [ "x" == "x$REPORT" ]; then
echo "Usage: $0 <report-name>"
exit 1
fi

while true; do
curl -H "Authorization: Bearer $HASH_COLLECTION_TOKEN" http://localhost:8000/reports/$REPORT/suggested | jq .[] | head | tr -d \" | while read out
do
(nix derivation show $out || exit 1) | jq keys.[] | tr -d \" | while read drv
do
# TODO select the right output to rebuild?
nix-build $drv --check
done
done
done
5 changes: 5 additions & 0 deletions utils/src/bin/copy-from-cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ async fn fetch<'a>(out_path: &'a str) -> (String, OutputAttestation<'a>) {
if response == "404" {
panic!("Metadata for [{0}] not found on cache.nixos.org", out_path);
}

// TODO Deriver is not populated for static inputs, and may be super useful:
// the same output may have multiple derivers even for non-FOD derivations.
// Should we make it optional in the data model / API as well?
// https://github.com/JulienMalka/nix-hash-collection/issues/25
let deriver = Regex::new(r"(?m)Deriver: (.*).drv").unwrap()
.captures(&response)
.expect(format!("Deriver not found in metadata for [{0}]", out_path).as_str())
Expand Down
221 changes: 220 additions & 1 deletion web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from collections import defaultdict
import json
import random
import typing as t
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException, Response
from fastapi.security.http import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -77,6 +79,23 @@ def get_drv(drv_hash: str,
def get_drv_recap(drv_hash: str, db: Session = Depends(get_db)) -> schemas.DerivationAttestation:
return get_drv_recap_or_404(db, drv_hash)

# Suggested rebuilds
@app.get("/reports/{name}/suggested")
def derivations_suggested_for_rebuilding(
name: str,
token: str = Depends(get_token),
db: Session = Depends(get_db),
):
report = crud.report(db, name)
if report == None:
raise HTTPException(status_code=404, detail="Report not found")
paths = report_out_paths(report)

user = crud.get_user_with_token(db, token)
suggestions = crud.suggest(db, paths, user)
random.shuffle(suggestions)
return suggestions[:50]

@app.post("/attestation/{drv_hash}")
def record_attestation(
drv_hash: str,
Expand All @@ -93,4 +112,204 @@ def record_attestation(
"Attestation accepted"
}

@app.get("/attestations/by-output/{output_path}")
def attestations_by_out(output_path: str, db: Session = Depends(get_db)):
return db.query(models.Attestation).filter_by(output_path="/nix/store/"+output_path).all()

def report_out_paths(report):
paths = []
for component in report['components']:
for prop in component['properties']:
if prop['name'] == "nix:out_path":
paths.append(prop['value'])
return paths

@app.get("/reports")
def reports(db: Session = Depends(get_db)):
reports = db.query(models.Report).all()
names = []
for report in reports:
names.append(report.name)
return names

def printtree(root, deps, results, cur_indent=0, seen=None):
if seen is None:
seen = {}
if root in seen:
return " " * cur_indent + "...\n"
seen[root] = True;

result = " " * cur_indent + root[11:];
if root in results:
result = result + " " + results[root] + "\n"
else:
result = result + "\n"
for dep in deps:
if dep['ref'] == root and 'dependsOn' in dep:
for d in dep['dependsOn']:
result += printtree(d, deps, results, cur_indent+2, seen)
#result = result + "\n " + d
return result

def htmltree(root, deps, results):
def icon(result):
if result == "No builds":
return "❔ "
elif result == "One build":
return "❎ "
elif result == "Partially reproduced":
return "❕ "
elif result == "Successfully reproduced":
return "✅ "
elif result == "Consistently nondeterministic":
return "❌ "
else:
return ""
def generatetree(root, seen):
if root in seen:
return f'<summary title="{root}">...</summary>'
seen[root] = True;

result = f'<summary title="{root}">'
if root in results:
result = result + f'<span title="{results[root]}">' + icon(results[root]) + "</span>" + root[44:] + " "
else:
result = result + root[44:]
result = result + "</summary>\n"
result = result + "<ul>"
for dep in deps:
if dep['ref'] == root and 'dependsOn' in dep:
for d in dep['dependsOn']:
result += f'<li><details class="{d}" open>'
result += generatetree(d, seen)
result += "</details></li>"
result = result + "</ul>"
return result
tree = generatetree(root, {})
return '''
<html>
<head>
<style>
.tree{
--spacing : 1.5rem;
--radius : 8px;
}
.tree li{
display : block;
position : relative;
padding-left : calc(2 * var(--spacing) - var(--radius) - 2px);
}
.tree ul{
margin-left : calc(var(--radius) - var(--spacing));
padding-left : 0;
}
.tree ul li{
border-left : 2px solid #ddd;
}
.tree ul li:last-child{
border-color : transparent;
}
.tree ul li::before{
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / -2);
left : -2px;
width : calc(var(--spacing) + 2px);
height : calc(var(--spacing) + 1px);
border : solid #ddd;
border-width : 0 0 2px 2px;
}
.tree summary{
display : block;
cursor : pointer;
}
.tree summary::marker,
.tree summary::-webkit-details-marker{
display : none;
}
.tree summary:focus{
outline : none;
}
.tree summary:focus-visible{
outline : 1px dotted #000;
}
.tree li::after,
.tree summary::before{
content : '';
display : block;
position : absolute;
top : calc(var(--spacing) / 2 - var(--radius));
left : calc(var(--spacing) - var(--radius) - 1px);
width : calc(2 * var(--radius));
height : calc(2 * var(--radius));
border-radius : 50%;
background : #ddd;
}
</style>
</head>
''' + f'''
<body>
<ul class="tree">
<li>
{tree}
</li>
</ul>
</body>
</html>
'''

@app.get("/reports/{name}")
def report(
name: str,
accept: t.Optional[str] = Header(default="*/*"),
db: Session = Depends(get_db),
):
report = crud.report(db, name)
if report == None:
raise HTTPException(status_code=404, detail="Report not found")

if 'application/vnd.cyclonedx+json' in accept:
return Response(
content=json.dumps(report),
media_type='application/vnd.cyclonedx+json')

paths = report_out_paths(report)

root = report['metadata']['component']['bom-ref']
results = crud.path_summaries(db, paths)

if 'text/html' in accept:
return Response(
content=htmltree(root, report['dependencies'], results),
media_type='text/html')
else:
return Response(
content=printtree(root, report['dependencies'], results),
media_type='text/plain')

@app.put("/reports/{name}")
def define_report(
name: str,
definition: schemas.ReportDefinition,
token: str = Depends(get_token),
db: Session = Depends(get_db),
):
user = crud.get_user_with_token(db, token)
if user == None:
raise HTTPException(status_code=401, detail="User not found")
crud.define_report(db, name, definition.root)
return {
"Report defined"
}
Loading

0 comments on commit 3434a83

Please sign in to comment.