Skip to content

Commit d52c30e

Browse files
feat: fetch channel information from Hydra
Since the source of truth for latest evaluations and builds is in Hydra, from now on we're only using the infrastructure team's `channel.nix` for information about the release state of channels. This change also reduces the number of evaluations to one per release branch. We hard-code tracking the small channel variants, since those advance fastest.
1 parent 8b8e133 commit d52c30e

16 files changed

Lines changed: 506 additions & 176 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ result
1414
*.dump
1515
*.jsonl
1616
*.log
17+
# Generated at build time for development use
1718
src/static
1819
src/webview/static/htmx.min.js
1920
src/webview/static/nixos-logo.svg
21+
src/shared/_release_channels.py
2022
# A shallow checkout of nixpkgs
2123
nixpkgs/
2224
nixpkgs-gc-roots/

default.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ rec {
162162
163163
ln -sf ${sources.htmx}/dist/htmx.js src/webview/static/htmx.min.js
164164
ln -sf ${sources.nixos-logo} src/webview/static/nixos-logo.svg
165+
ln -sf ${package.passthru.release-channels} src/shared/_release_channels.py
165166
166167
mkdir -p $CREDENTIALS_DIRECTORY
167168
# TODO(@fricklerhandwerk): move all configuration over to pydantic-settings

nix/overlay.nix

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ final: prev:
22
let
33
sources = import ../npins;
44
meta = with builtins; fromTOML (readFile ../src/pyproject.toml);
5+
release-channels = builtins.toFile "_release_channels.py" "channels = ${builtins.toJSON (import "${sources.infra}/channels.nix").channels}\n";
56
in
67
{
78
/*
@@ -92,7 +93,10 @@ in
9293
django-vite
9394
];
9495

95-
passthru.PLAYWRIGHT_BROWSERS_PATH = final.playwright-driver.browsers;
96+
passthru = {
97+
PLAYWRIGHT_BROWSERS_PATH = final.playwright-driver.browsers;
98+
inherit release-channels;
99+
};
96100

97101
postInstall = ''
98102
mkdir -p $out/bin
@@ -101,6 +105,7 @@ in
101105
wrapProgram $out/bin/manage.py --prefix PYTHONPATH : "$PYTHONPATH"
102106
cp ${sources.htmx}/dist/htmx.min.js* $out/${final.python3.sitePackages}/webview/static/
103107
cp ${sources.nixos-logo} $out/${final.python3.sitePackages}/webview/static/nixos-logo.svg
108+
cp ${release-channels} $out/${final.python3.sitePackages}/shared/_release_channels.py
104109
'';
105110
};
106111
}

nix/tests/channels.nix

Lines changed: 0 additions & 46 deletions
This file was deleted.

nix/tests/default.nix

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,24 @@ let
1515
diskSize = 4096;
1616
};
1717
};
18-
channels = with builtins; toFile "channels.json" (toJSON (import ./channels.nix));
19-
channels-port = toString 8080;
18+
hydra = {
19+
port = toString 8080;
20+
mock = pkgs.writeText "hydra-mock" ''
21+
from http.server import BaseHTTPRequestHandler, HTTPServer
22+
class H(BaseHTTPRequestHandler):
23+
def do_GET(self):
24+
self.send_response(200)
25+
self.send_header("Content-Type", "application/json")
26+
self.end_headers()
27+
self.wfile.write(b'${
28+
builtins.toJSON {
29+
inputs.nixpkgs.value = "https://github.com/NixOS/nixpkgs.git";
30+
}
31+
}')
32+
log_message = lambda *_: None
33+
HTTPServer(("", ${hydra.port}), H).serve_forever()
34+
'';
35+
};
2036
in
2137
pkgs.testers.runNixOSTest {
2238
name = "default";
@@ -65,7 +81,7 @@ pkgs.testers.runNixOSTest {
6581
domain = "example.org";
6682
settings = {
6783
DEBUG = true;
68-
CHANNEL_MONITORING_URL = "http://localhost:${channels-port}/channels.json";
84+
HYDRA_URL = "http://localhost:${hydra.port}";
6985
GIT_CLONE_URL = "file://${dummy-nixpkgs}";
7086
SYNC_GITHUB_STATE_AT_STARTUP = false;
7187
GH_ISSUES_PING_MAINTAINERS = true;
@@ -93,18 +109,11 @@ pkgs.testers.runNixOSTest {
93109
GH_APP_PRIVATE_KEY = dummy-str;
94110
};
95111
};
96-
systemd.services.mock-channels = {
112+
systemd.services.${hydra.mock.name} = {
97113
wantedBy = [ "multi-user.target" ];
98-
before = [ "${application}-server.service" ];
99-
path = with pkgs; [
100-
python3
101-
gnused
102-
];
103-
script = ''
104-
cd /tmp
105-
sed "s/@commit@/$(cat ${dummy-nixpkgs}/REVISION)/g" ${channels} > channels.json
106-
python -m http.server ${channels-port}
107-
'';
114+
before = [ "${application}-fetch-all-channels.service" ];
115+
path = [ pkgs.python3 ];
116+
script = "python ${hydra.mock}";
108117
};
109118
systemd.services.setup-git-repo = {
110119
wantedBy = [ "multi-user.target" ];
@@ -130,20 +139,22 @@ pkgs.testers.runNixOSTest {
130139
''
131140
server.wait_for_unit("${application}-server.service")
132141
server.wait_for_unit("${application}-worker.service")
133-
server.wait_for_unit("mock-channels.service")
142+
server.wait_for_unit("${hydra.mock.name}.service")
134143
135144
with subtest("Check that no migrations were missed"):
136145
server.succeed("wst-manage makemigrations --check --dry-run")
137146
138-
with subtest("Check that channel are fetched and evaluations enqueued"):
147+
with subtest("Check that channels are fetched and evaluations enqueued"):
139148
server.succeed("wst-manage fetch_all_channels")
140149
${in-shell "succeed" ''
141150
from shared.models import NixChannel
142-
assert NixChannel.objects.count() == 4
151+
from shared.models.nix_evaluation import NixpkgsBranch
152+
assert NixpkgsBranch.objects.count() == 1, f"expected 1 branch, got {NixpkgsBranch.objects.count()}"
153+
assert NixChannel.objects.count() >= 1, f"expected at least 1 channel, got {NixChannel.objects.count()}"
143154
''}
144-
${in-shell "succeed " ''
155+
${in-shell "succeed" ''
145156
from shared.models import NixEvaluation
146-
assert NixEvaluation.objects.count() == 1
157+
assert NixEvaluation.objects.count() == 1, f"expected 1 evaluation, got {NixEvaluation.objects.count()}"
147158
''}
148159
149160
with subtest("Application tests"):
@@ -214,11 +225,11 @@ pkgs.testers.runNixOSTest {
214225
# Maintainers should only be attached to derivations from the tracking branch.
215226
from django.conf import settings
216227
tracking_meta = NixDerivationMeta.objects.get(
217-
derivation__parent_evaluation__channel__channel_branch=settings.TRACKING_BRANCH,
228+
derivation__parent_evaluation__channel__release_branch__name=settings.TRACKING_BRANCH,
218229
)
219230
assert tracking_meta.maintainers.exists(), f"{settings.TRACKING_BRANCH} meta has no maintainers"
220231
for m in NixDerivationMeta.objects.exclude(
221-
derivation__parent_evaluation__channel__channel_branch=settings.TRACKING_BRANCH,
232+
derivation__parent_evaluation__channel__release_branch__name=settings.TRACKING_BRANCH,
222233
):
223234
assert not m.maintainers.exists(), f"{m.derivation.parent_evaluation.channel.channel_branch}) has unexpected maintainers"
224235
''

src/project/settings.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,17 @@ class DjangoSettings(BaseModel):
100100
By default, in the root of this Git repository.
101101
"""
102102
)
103-
CHANNEL_MONITORING_URL: HttpUrl = Field(
103+
HYDRA_URL: HttpUrl = Field(
104104
description="""
105-
URL from which to fetch the current channel structure.
105+
Base URL of the Hydra instance used to look up jobset inputs for branch resolution.
106106
""",
107-
default=HttpUrl(
108-
"https://monitoring.nixos.org/prometheus/api/v1/query?query=channel_revision"
109-
),
107+
default=HttpUrl("https://hydra.nixos.org"),
108+
)
109+
NETWORK_REQUEST_TIMEOUT: int = Field(
110+
description="""
111+
Timeout in seconds for outbound network requests.
112+
""",
113+
default=60,
110114
)
111115
SYNC_GITHUB_STATE_AT_STARTUP: bool = Field(
112116
description="""
@@ -169,7 +173,7 @@ class DjangoSettings(BaseModel):
169173
The branch that tracks upstream development.
170174
Serves as the source of truth for package metadata such as maintainers and descriptions.
171175
""",
172-
default="nixpkgs-unstable",
176+
default="master",
173177
)
174178
MAX_MATCHES: int = Field(
175179
description="""

src/shared/git.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,33 @@
44
import os.path
55
import pathlib
66
import random
7+
import subprocess
78
import time
89
from collections.abc import AsyncGenerator
910
from contextlib import asynccontextmanager
1011
from dataclasses import dataclass
1112
from typing import IO, Any
1213

1314
from django.conf import settings
15+
from pydantic import AnyUrl
1416

1517
logger = logging.getLogger(__name__)
1618

1719

20+
def get_head_sha1(url: AnyUrl, branch: str) -> str:
21+
result = subprocess.run(
22+
["git", "ls-remote", str(url), f"refs/heads/{branch}"],
23+
capture_output=True,
24+
text=True,
25+
check=True,
26+
timeout=settings.NETWORK_REQUEST_TIMEOUT,
27+
)
28+
line = result.stdout.strip()
29+
if not line:
30+
raise ValueError(f"branch {branch!r} not found at {url!r}")
31+
return line.split()[0]
32+
33+
1834
@dataclass
1935
class Worktree:
2036
path: pathlib.Path

src/shared/hydra.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import requests
2+
from django.conf import settings
3+
from pydantic import BaseModel, ConfigDict
4+
5+
6+
class JobsetInput(BaseModel):
7+
model_config = ConfigDict(extra="ignore")
8+
9+
value: str
10+
11+
12+
class Jobset(BaseModel):
13+
model_config = ConfigDict(extra="ignore")
14+
15+
inputs: dict[str, JobsetInput]
16+
17+
def input_branch(self, input_name: str, default: str) -> str:
18+
"""
19+
Get the branch tracked by the named jobset input.
20+
Use `default` when the input value specifies no explicit branch.
21+
22+
Raises `KeyError` when the input is absent.
23+
"""
24+
parts = self.inputs[input_name].value.split()
25+
return parts[1] if len(parts) > 1 else default
26+
27+
28+
def jobset_from_job(job: str) -> str:
29+
"""
30+
Extract jobset path from a full job path.
31+
32+
Example: `"nixos/release-26.05/tested"` → `"nixos/release-26.05"`.
33+
"""
34+
return "/".join(job.split("/")[:-1])
35+
36+
37+
class HydraClient:
38+
"""
39+
Minimal client for the Hydra API.
40+
"""
41+
42+
def __init__(self, base_url: str) -> None:
43+
self.base_url = base_url.rstrip("/")
44+
45+
def get_jobset(self, jobset_path: str) -> Jobset:
46+
"""
47+
Fetches jobset metadata for the given `<project>/<jobset>` path.
48+
"""
49+
resp = requests.get(
50+
f"{self.base_url}/jobset/{jobset_path}",
51+
headers={"Accept": "application/json"},
52+
timeout=settings.NETWORK_REQUEST_TIMEOUT,
53+
)
54+
resp.raise_for_status()
55+
return Jobset.model_validate(resp.json())
56+
57+
58+
def default_client() -> HydraClient:
59+
return HydraClient(str(settings.HYDRA_URL))

src/shared/listeners/nix_evaluation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ def _try_acquire_slot() -> bool:
305305

306306
@pgpubsub.post_insert_listener(NixEvaluationChannel)
307307
def run_evaluation_job(old: NixEvaluation, new: NixEvaluation) -> None:
308-
evaluation = NixEvaluation.objects.select_related("channel").get(pk=new.pk)
308+
evaluation = NixEvaluation.objects.select_related("channel__release_branch").get(
309+
pk=new.pk
310+
)
309311
average_evaluation_time = NixEvaluation.objects.aggregate(
310312
avg_eval_time=Avg("elapsed")
311313
)

0 commit comments

Comments
 (0)