Compare commits

...

27 commits

Author SHA1 Message Date
eldritch horrors ab0767bedd feat: faster dependency computation
Co-authored-by: Raito Bezarius <raito@lix.systems> (just for wiring up
things)
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-27 18:35:14 +01:00
jade 1aa37c4b80 Merge pull request 'fix(reporters): restore old behavior for Gerrit reporting' (#27) from bring-back-old-gerrit-reporting into forkos
Reviewed-on: #27
Reviewed-by: jade <jade@noreply.git.lix.systems>
2024-10-18 23:41:07 +00:00
raito 879e9cdcdf feat: print which allowed origins are set for this buildbot server
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-13 18:46:31 +02:00
raito ea08a7f4a1 fix(reporters): restore old behavior for Gerrit reporting
This is an attempt to restore the old formatting, e.g. with failed
checks and a link to the URI.

At the same time, this attempts to fix the eager +1 Verified tag which
is sent when nix-eval is started (?) and not done or when the evaluation
is done instead of the whole nix-eval job seen as completed.

One of the root cause was the hell-ish expected builder name check…

This is also a big cleanup of all the typing issues we accumulated over
time.

Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-13 18:46:22 +02:00
raito f61128981a devshell: enable typing for buildbot
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-12 12:25:47 +02:00
raito 60860d3084 feat: introduce backward compat with Lix deployments
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-07 15:22:33 +02:00
Yureka ed8f940717 randomly pick a builder and pass it as --store 2024-10-05 23:01:55 +02:00
Yureka 935e5cba2f fix error: builtins.TypeError: object of type 'int' has no len() 2024-10-05 22:55:02 +02:00
raito 021e2064ae fix(build): use per-worker slot store
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 22:54:56 +02:00
raito 4aa1d7e78c feat: add incoming ref data to non-flakes entrypoint
We can now implement a Nix library for Buildbot CI. :)

We dump it into a file, it's better to pass large stuff and easier to
escape things.

Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 22:49:00 +02:00
raito b3a0b5a69e fix(auth): remove userinfoUri as a positional argument
This broke the authentication, because we were expecting the client ID &
client secret.

Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:43:45 +02:00
raito 77d0ed37d1 fix(args): pass the right string
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 79bcbba46d fix(buildbot-name): nix_configure → determining jobs
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 1484a875dc fix(ret): return success and write \n
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito c824b084e8 fix(steps): add *steps* not *step*
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 2a2a2793e4 fix(workdir): rebase in build/ for ShellMixin
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 97dee6cfec feat(nix-configure): name it
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito dfb9595f7d fix(log): add the stdio log if it doesn't exist
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito c5dea04717 feat: support non-flake entrypoint
The way to use Buildbot jobs without Flakes is via `.ci/buildbot.nix`.

Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 1711bfd840 fix(eval): event.change.project is also a buildprop for project name
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 2d51fda98e feat(eval): in case of total failure, do not derivation show
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito af13c5948e fix(eval): event.refUpdate.project instead of event.project
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 253e44646d fix(properties): use getProperty
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito bbac51e09e fix(gerrit): pass properly the ssh private key and not its path
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 83ac74f18f fix(gerrit): repourl was not formatted
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito 2411875825 fix(sshkey): PosixPath does not play well with Buildbot APIs
It expected a `str`, not a `PosixPath`.

Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 21:21:26 +02:00
raito fa83000f07 feat(debug): add manhole debugging
Signed-off-by: Raito Bezarius <raito@lix.systems>
2024-10-05 20:52:52 +02:00
7 changed files with 336 additions and 131 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -7,37 +7,53 @@ import base64
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import Any, NamedTuple
import buildbot import buildbot
from buildbot.configurators import ConfiguratorBase from buildbot.configurators import ConfiguratorBase
from buildbot.plugins import reporters, schedulers, secrets, steps, util, worker from buildbot.plugins import reporters, schedulers, secrets, steps, util, worker
from buildbot.process import buildstep, logobserver, remotecommand from buildbot.process import buildstep, logobserver, remotecommand
from buildbot.process.log import StreamLog
from buildbot.process.project import Project from buildbot.process.project import Project
from buildbot.process.properties import Properties from buildbot.process.properties import Properties
from buildbot.process.results import ALL_RESULTS, statusToString from buildbot.process.results import ALL_RESULTS, statusToString
from buildbot.www.auth import AuthBase from buildbot.www.auth import AuthBase
from buildbot.www.oauth2 import OAuth2Auth from buildbot.www.oauth2 import OAuth2Auth
from buildbot.changes.gerritchangesource import GerritChangeSource from buildbot.changes.gerritchangesource import GerritChangeSource
from buildbot.reporters.utils import getURLForBuildrequest
from buildbot.reporters.generators.build import BuildStatusGenerator from buildbot.reporters.generators.build import BuildStatusGenerator
from buildbot.reporters.message import MessageFormatterFunction from buildbot.reporters.utils import getURLForBuildrequest
from buildbot.process.buildstep import EXCEPTION from buildbot.process.buildstep import EXCEPTION
from buildbot.process.buildstep import SUCCESS from buildbot.process.buildstep import SUCCESS
from buildbot.process.results import worst_status from buildbot.process.results import worst_status
import requests import requests
if TYPE_CHECKING:
from buildbot.process.log import Log
from twisted.internet import defer from twisted.internet import defer
from twisted.logger import Logger from twisted.logger import Logger
from .binary_cache import S3BinaryCacheConfig from .binary_cache import S3BinaryCacheConfig
from .message_formatter import ReasonableMessageFormatter, CallbackPayloadBuild, CallbackPayloadBuildSet, CallbackReturn
log = Logger() log = Logger()
FLAKE_TARGET_ATTRIBUTE_FOR_JOBS = "buildbotJobs" # util is a plugin variable, not a module...
BuilderConfig = util.BuilderConfig
MasterLock = util.MasterLock
FLAKE_TARGET_ATTRIBUTE_FOR_JOBS = "hydraJobs"
class Job(NamedTuple):
commit: str
name: str
expensive: bool
deps: set[str] = set()
@dataclass
class EvaluatorSettings:
supported_systems: list[str]
worker_count: int
max_memory_size: int
gc_roots_dir: str
lock: MasterLock
@dataclass @dataclass
class NixBuilder: class NixBuilder:
@ -49,14 +65,24 @@ class NixBuilder:
publicHostKey: str | None = None publicHostKey: str | None = None
sshUser: str | None = None sshUser: str | None = None
sshKey: str | None = None sshKey: str | None = None
systems: list[str] = field(default_factory=lambda: ["-"]) systems: list[str] = field(default_factory=lambda: [])
supportedFeatures: list[str] = field(default_factory=lambda: ["-"]) supportedFeatures: list[str] = field(default_factory=lambda: [])
mandatoryFeatures: list[str] = field(default_factory=lambda: ["-"]) mandatoryFeatures: list[str] = field(default_factory=lambda: [])
def to_nix_line(self): def to_nix_store(self):
encoded_public_key = base64.b64encode(self.publicHostKey.encode('ascii')).decode('ascii') if self.publicHostKey is not None else "-" fullConnection = f"{self.sshUser}@{self.hostName}" if self.sshUser is not None else self.hostName
fullConnection = f"{self.protocol}://{self.sshUser}@{self.hostName}" if self.sshUser is not None else self.hostName fullConnection = f"{self.protocol}://{fullConnection}"
return f"{fullConnection} {",".join(self.systems)} {self.sshKey or "-"} {self.maxJobs} {self.speedFactor} {",".join(self.supportedFeatures)} {",".join(self.mandatoryFeatures)} {encoded_public_key}" params = []
if self.sshKey is not None:
params.append(f"ssh-key={self.sshKey}")
if self.publicHostKey is not None:
encoded_public_key = base64.b64encode(self.publicHostKey.encode('ascii')).decode('ascii')
params.append(f"base64-ssh-public-host-key={encoded_public_key}")
if params != []:
fullConnection += "?"
fullConnection += "&".join(params)
return fullConnection
@dataclass @dataclass
@ -71,9 +97,10 @@ class OAuth2Config:
debug: bool = False debug: bool = False
class KeycloakOAuth2Auth(OAuth2Auth): class KeycloakOAuth2Auth(OAuth2Auth):
def __init__(self, userinfoUri: str, *args, debug=False, **kwargs): userinfoUri: str
def __init__(self, *args, debug=False, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.userinfoUri = userinfoUri
self.debug = debug self.debug = debug
def createSessionFromToken(self, token): def createSessionFromToken(self, token):
@ -130,7 +157,7 @@ class GerritConfig:
""" """
Returns the prefix to build a repourl using that gerrit configuration. Returns the prefix to build a repourl using that gerrit configuration.
""" """
return 'ssh://{self.username}@{self.domain}:{self.port}/' return f'ssh://{self.username}@{self.domain}:{self.port}/'
class BuildTrigger(steps.BuildStep): class BuildTrigger(steps.BuildStep):
def __init__( def __init__(
@ -148,7 +175,7 @@ class BuildTrigger(steps.BuildStep):
self.ended = False self.ended = False
self.waitForFinishDeferred = None self.waitForFinishDeferred = None
self.brids = [] self.brids = []
self.description = f"building {len(jobs)} hydra jobs" self.description = f"building {len(jobs)} jobs"
super().__init__(**kwargs) super().__init__(**kwargs)
def interrupt(self, reason): def interrupt(self, reason):
@ -177,15 +204,16 @@ class BuildTrigger(steps.BuildStep):
return sch return sch
def schedule_one(self, build_props: Properties, job): def schedule_one(self, build_props: Properties, job):
project_name = build_props.getProperty('event.project') project_name = build_props.getProperty("event.refUpdate.project") or build_props.getProperty("event.change.project")
source = f"{project_name}-eval-lix" source = f"{project_name}-eval"
attr = job.get("attr", "eval-error") attr = job.get("attr", "eval-error")
name = attr # FIXME(raito): this was named this way for backward compatibility with Lix deployment.
name = f"{FLAKE_TARGET_ATTRIBUTE_FOR_JOBS}.{name}" # We should just parametrize this.
name = f"hydraJobs.{attr}"
error = job.get("error") error = job.get("error")
props = Properties() props = Properties()
props.setProperty("virtual_builder_name", name, source) props.setProperty("virtual_builder_name", name, source)
props.setProperty("status_name", f"nix-build .#{FLAKE_TARGET_ATTRIBUTE_FOR_JOBS}.{attr}", source) props.setProperty("status_name", f"building hydraJobs.{attr}", source)
props.setProperty("virtual_builder_tags", "", source) props.setProperty("virtual_builder_tags", "", source)
if error is not None: if error is not None:
@ -234,7 +262,7 @@ class BuildTrigger(steps.BuildStep):
def run(self): def run(self):
self.running = True self.running = True
build_props = self.build.getProperties() build_props = self.build.getProperties()
logs: Log = yield self.addLog("build info") logs: StreamLog = yield self.addLog("build info")
builds_to_schedule = list(self.jobs) builds_to_schedule = list(self.jobs)
build_schedule_order = [] build_schedule_order = []
@ -372,7 +400,8 @@ class NixEvalCommand(buildstep.ShellMixin, steps.BuildStep):
# run nix-eval-jobs --flake .#$FLAKE_TARGET_ATTRIBUTE_FOR_JOBS to generate the dict of stages # run nix-eval-jobs --flake .#$FLAKE_TARGET_ATTRIBUTE_FOR_JOBS to generate the dict of stages
cmd: remotecommand.RemoteCommand = yield self.makeRemoteShellCommand() cmd: remotecommand.RemoteCommand = yield self.makeRemoteShellCommand()
build_props = self.build.getProperties() build_props = self.build.getProperties()
project_name = build_props.get('event.project') project_name = build_props.getProperty("event.refUpdate.project") or build_props.getProperty("event.change.project")
assert project_name is not None, "`event.refUpdate.project` or `event.change.project` is not available on the build properties, unexpected build type!"
yield self.runCommand(cmd) yield self.runCommand(cmd)
@ -396,42 +425,47 @@ class NixEvalCommand(buildstep.ShellMixin, steps.BuildStep):
if not system or system in self.supported_systems: # report eval errors if not system or system in self.supported_systems: # report eval errors
filtered_jobs.append(job) filtered_jobs.append(job)
drv_show_log: Log = yield self.getLog("stdio") # Filter out failed evaluations
drv_show_log.addStdout(f"getting derivation infos\n") succeeded_jobs = [job for job in filtered_jobs if job.get('error') is None]
cmd = yield self.makeRemoteShellCommand(
stdioLogName=None, drv_show_log: StreamLog = yield self.getLog("stdio")
collectStdout=True,
command=(
["nix", "derivation", "show", "--recursive"]
+ [ drv for drv in (job.get("drvPath") for job in filtered_jobs) if drv ]
),
)
yield self.runCommand(cmd)
drv_show_log.addStdout(f"done\n")
try:
drv_info = json.loads(cmd.stdout)
except json.JSONDecodeError as e:
msg = f"Failed to parse `nix derivation show` output for {cmd.command}"
raise BuildbotNixError(msg) from e
all_deps = dict() all_deps = dict()
for drv, info in drv_info.items():
all_deps[drv] = set(info.get("inputDrvs").keys())
def closure_of(key, deps): def find_deps(drv_info: dict[Any, Any]) -> dict[str, set[str]]:
r, size = set([key]), 0 deps = {}
while len(r) != size: for drv, info in drv_info.items():
size = len(r) deps[drv] = set(info["inputDrvs"].keys())
r.update(*[ deps[k] for k in r ]) for n in graphlib.TopologicalSorter(deps).static_order():
return r.difference([key]) for d in tuple(deps[n]):
deps[n].update(deps[d])
return deps
job_set = set(( drv for drv in ( job.get("drvPath") for job in filtered_jobs ) if drv ))
all_deps = { k: list(closure_of(k, all_deps).intersection(job_set)) for k in job_set } if succeeded_jobs:
drv_show_log.addStdout(f"getting derivation infos for valid derivations\n")
cmd = yield self.makeRemoteShellCommand(
stdioLogName=None,
collectStdout=True,
command=(
["nix", "derivation", "show", "--recursive"]
+ [ drv for drv in (job.get("drvPath") for job in succeeded_jobs) if drv ]
),
)
yield self.runCommand(cmd)
drv_show_log.addStdout(f"done\n")
try:
drv_info = json.loads(cmd.stdout)
except json.JSONDecodeError as e:
msg = f"Failed to parse `nix derivation show` output for {cmd.command}"
raise BuildbotNixError(msg) from e
all_deps = find_deps(drv_info)
self.build.addStepsAfterCurrentStep( self.build.addStepsAfterCurrentStep(
[ [
BuildTrigger( BuildTrigger(
builds_scheduler_group=f"{project_name}-nix-build", builds_scheduler_group=f"{project_name}-nix-build",
name="build flake", name="build derivations",
jobs=filtered_jobs, jobs=filtered_jobs,
all_deps=all_deps, all_deps=all_deps,
), ),
@ -440,6 +474,91 @@ class NixEvalCommand(buildstep.ShellMixin, steps.BuildStep):
return result return result
def make_job_evaluator(name: str, settings: EvaluatorSettings, flake: bool, incoming_ref_filename: str) -> NixEvalCommand:
actual_command = []
if flake:
actual_command += ["--flake", f".#{FLAKE_TARGET_ATTRIBUTE_FOR_JOBS}"]
else:
actual_command += ["--expr",
f"import ./.ci/buildbot.nix {{ incoming_ref_data = builtins.fromJSON (builtins.readFile {incoming_ref_filename}); }}"]
return NixEvalCommand(
env={},
name=name,
supported_systems=settings.supported_systems,
command=[
"nix-eval-jobs",
"--workers",
str(settings.worker_count),
"--max-memory-size",
str(settings.max_memory_size),
"--gc-roots-dir",
settings.gc_roots_dir,
"--force-recurse",
"--check-cache-status",
] + actual_command,
haltOnFailure=True,
locks=[settings.lock.access("exclusive")]
)
class NixConfigure(buildstep.CommandMixin, steps.BuildStep):
name = "determining jobs"
"""
Determine what `NixEvalCommand` step should be added after
based on the existence of:
- flake.nix
- .ci/buildbot.nix
"""
def __init__(self, eval_settings: EvaluatorSettings, **kwargs: Any) -> None:
self.evaluator_settings = eval_settings
super().__init__(**kwargs)
self.observer = logobserver.BufferLogObserver()
self.addLogObserver("stdio", self.observer)
@defer.inlineCallbacks
def run(self) -> Generator[Any, object, Any]:
try:
configure_log: StreamLog = yield self.getLog("stdio")
except Exception:
configure_log: StreamLog = yield self.addLog("stdio")
# Takes precedence.
configure_log.addStdout("checking if there's a .ci/buildbot.nix...\n")
ci_buildbot_defn_exists = yield self.pathExists('build/.ci/buildbot.nix')
if ci_buildbot_defn_exists:
configure_log.addStdout(".ci/buildbot.nix found, configured for non-flake CI\n")
self.build.addStepsAfterCurrentStep(
[
make_job_evaluator(
"evaluate `.ci/buildbot.nix` jobs",
self.evaluator_settings,
False,
"./incoming-ref.json"
)
]
)
return SUCCESS
flake_exists = yield self.pathExists('build/flake.nix')
if flake_exists:
configure_log.addStdout(f"flake.nix found")
self.build.addStepsAfterCurrentStep([
make_job_evaluator(
"evaluate `flake.nix` jobs",
self.evaluator_settings,
True,
"./incoming-ref.json"
)
]
)
return SUCCESS
configure_log.addStdout("neither flake.nix found neither .ci/buildbot.nix, no CI to run!")
return SUCCESS
class NixBuildCommand(buildstep.ShellMixin, steps.BuildStep): class NixBuildCommand(buildstep.ShellMixin, steps.BuildStep):
"""Builds a nix derivation.""" """Builds a nix derivation."""
@ -453,7 +572,7 @@ class NixBuildCommand(buildstep.ShellMixin, steps.BuildStep):
if error := self.getProperty("error"): if error := self.getProperty("error"):
attr = self.getProperty("attr") attr = self.getProperty("attr")
# show eval error # show eval error
error_log: Log = yield self.addLog("nix_error") error_log: StreamLog = yield self.addLog("nix_error")
error_log.addStderr(f"{attr} failed to evaluate:\n{error}") error_log.addStderr(f"{attr} failed to evaluate:\n{error}")
return util.FAILURE return util.FAILURE
@ -477,14 +596,23 @@ def nix_eval_config(
project: GerritProject, project: GerritProject,
worker_names: list[str], worker_names: list[str],
supported_systems: list[str], supported_systems: list[str],
eval_lock: util.MasterLock, eval_lock: MasterLock,
worker_count: int, worker_count: int,
max_memory_size: int, max_memory_size: int,
) -> util.BuilderConfig: ) -> BuilderConfig:
"""Uses nix-eval-jobs to evaluate $FLAKE_TARGET_ATTRIBUTE_FOR_JOBS (`.#hydraJobs` by default) from flake.nix in parallel. """
Uses nix-eval-jobs to evaluate the entrypoint of this project.
For each evaluated attribute a new build pipeline is started. For each evaluated attribute a new build pipeline is started.
""" """
factory = util.BuildFactory() factory = util.BuildFactory()
gerrit_private_key = None
with open(project.private_sshkey_path, 'r') as f:
gerrit_private_key = f.read()
if gerrit_private_key is None:
raise RuntimeError('No gerrit private key to fetch the repositories')
# check out the source # check out the source
factory.addStep( factory.addStep(
steps.Gerrit( steps.Gerrit(
@ -492,9 +620,10 @@ def nix_eval_config(
mode="full", mode="full",
retry=[60, 60], retry=[60, 60],
timeout=3600, timeout=3600,
sshPrivateKey=project.private_sshkey_path sshPrivateKey=gerrit_private_key
), ),
) )
# use one gcroots directory per worker. this should be scoped to the largest unique resource # use one gcroots directory per worker. this should be scoped to the largest unique resource
# in charge of builds (ie, buildnumber is too narrow) to not litter the system with permanent # in charge of builds (ie, buildnumber is too narrow) to not litter the system with permanent
# gcroots in case of worker restarts. # gcroots in case of worker restarts.
@ -503,27 +632,27 @@ def nix_eval_config(
"/nix/var/nix/gcroots/per-user/buildbot-worker/%(prop:project)s/drvs/%(prop:workername)s/", "/nix/var/nix/gcroots/per-user/buildbot-worker/%(prop:project)s/drvs/%(prop:workername)s/",
) )
eval_settings = EvaluatorSettings(
supported_systems=supported_systems,
worker_count=worker_count,
max_memory_size=max_memory_size,
gc_roots_dir=drv_gcroots_dir,
lock=eval_lock
)
# This information can be passed at job evaluation time
# to skip some jobs, e.g. expensive jobs, etc.
# Transfer incoming ref data to the target.
factory.addStep(steps.JSONPropertiesDownload(workerdest="incoming-ref.json"))
# NixConfigure will choose
# how to add a NixEvalCommand job
# based on whether there's a flake.nix or
# a .ci/buildbot.nix.
factory.addStep( factory.addStep(
NixEvalCommand( NixConfigure(
env={}, eval_settings
name="evaluate flake", )
supported_systems=supported_systems,
command=[
"nix-eval-jobs",
"--workers",
str(worker_count),
"--max-memory-size",
str(max_memory_size),
"--gc-roots-dir",
drv_gcroots_dir,
"--force-recurse",
"--check-cache-status",
"--flake",
f".#{FLAKE_TARGET_ATTRIBUTE_FOR_JOBS}"
],
haltOnFailure=True,
locks=[eval_lock.access("exclusive")],
),
) )
factory.addStep( factory.addStep(
@ -551,12 +680,17 @@ def nix_build_config(
project: GerritProject, project: GerritProject,
worker_arch: str, worker_arch: str,
worker_names: list[str], worker_names: list[str],
builders_spec: str, build_stores: list[str],
signing_keyfile: str | None = None, signing_keyfile: str | None = None,
binary_cache_config: S3BinaryCacheConfig | None = None binary_cache_config: S3BinaryCacheConfig | None = None
) -> util.BuilderConfig: ) -> BuilderConfig:
"""Builds one nix flake attribute.""" """Builds one nix flake attribute."""
factory = util.BuildFactory() factory = util.BuildFactory()
# pick a store to run the build on
# TODO proper scheduling instead of picking the first builder
build_store = build_stores[0]
factory.addStep( factory.addStep(
NixBuildCommand( NixBuildCommand(
env={}, env={},
@ -576,8 +710,10 @@ def nix_build_config(
# kill builds after two hours regardless of activity # kill builds after two hours regardless of activity
"--timeout", "--timeout",
"7200", "7200",
"--builders", "--store",
builders_spec, build_store,
"--eval-store",
"ssh-ng://localhost",
"--out-link", "--out-link",
util.Interpolate("result-%(prop:attr)s"), util.Interpolate("result-%(prop:attr)s"),
util.Interpolate("%(prop:drv_path)s^*"), util.Interpolate("%(prop:drv_path)s^*"),
@ -597,6 +733,8 @@ def nix_build_config(
"nix", "nix",
"store", "store",
"sign", "sign",
"--store",
build_store,
"--key-file", "--key-file",
signing_keyfile, signing_keyfile,
util.Interpolate( util.Interpolate(
@ -613,6 +751,8 @@ def nix_build_config(
command=[ command=[
"nix", "nix",
"copy", "copy",
"--store",
build_store,
"--to", "--to",
f"s3://{binary_cache_config.bucket}?profile={binary_cache_config.profile}&region={binary_cache_config.region}&endpoint={binary_cache_config.endpoint}", f"s3://{binary_cache_config.bucket}?profile={binary_cache_config.profile}&region={binary_cache_config.region}&endpoint={binary_cache_config.endpoint}",
util.Property( util.Property(
@ -673,11 +813,11 @@ def config_for_project(
nix_supported_systems: list[str], nix_supported_systems: list[str],
nix_eval_worker_count: int, nix_eval_worker_count: int,
nix_eval_max_memory_size: int, nix_eval_max_memory_size: int,
eval_lock: util.MasterLock, eval_lock: MasterLock,
builders_spec: str, nix_builders: list[NixBuilder],
signing_keyfile: str | None = None, signing_keyfile: str | None = None,
binary_cache_config: S3BinaryCacheConfig | None = None binary_cache_config: S3BinaryCacheConfig | None = None
) -> Project: ) -> None:
config["projects"].append(Project(project.name)) config["projects"].append(Project(project.name))
config["schedulers"].extend( config["schedulers"].extend(
[ [
@ -712,12 +852,6 @@ def config_for_project(
), ),
], ],
) )
gerrit_private_key = None
with open(project.private_sshkey_path, 'r') as f:
gerrit_private_key = f.read()
if gerrit_private_key is None:
raise RuntimeError('No gerrit private key to fetch the repositories')
config["builders"].extend( config["builders"].extend(
[ [
@ -737,7 +871,7 @@ def config_for_project(
project, project,
arch, arch,
[ f"{w}-{arch}" for w in worker_names ], [ f"{w}-{arch}" for w in worker_names ],
builders_spec, [b.to_nix_store() for b in nix_builders if arch in b.systems or arch == "other"],
signing_keyfile=signing_keyfile, signing_keyfile=signing_keyfile,
binary_cache_config=binary_cache_config binary_cache_config=binary_cache_config
) )
@ -758,25 +892,25 @@ class PeriodicWithStartup(schedulers.Periodic):
yield self.setState("last_build", None) yield self.setState("last_build", None)
yield super().activate() yield super().activate()
def gerritReviewFmt(url, data): def gerritReviewFmt(url: str, payload: CallbackPayloadBuild | CallbackPayloadBuildSet) -> CallbackReturn:
if 'build' not in data: assert isinstance(payload, CallbackPayloadBuild), "BuildSet are not handled yet!"
raise ValueError('`build` is supposed to be present to format a build')
build = data['build'] build = payload.build
if 'builder' not in build and 'name' not in build['builder']: if 'builder' not in build and 'name' not in build['builder']:
raise ValueError('either `builder` or `builder.name` is not present in the build dictionary, unexpected format request') raise ValueError('either `builder` or `builder.name` is not present in the build dictionary, unexpected format request')
builderName = build['builder']['name'] builderName = build['builder']['name']
if len(build['results']) != 1: result = build['results']
raise ValueError('this review request contains more than one build results, unexpected format request') log.info("Formatting a message for a Gerrit build: {} -- result is {}".format(builderName, result))
result = build['results'][0]
if result == util.RETRY: if result == util.RETRY:
return dict() return CallbackReturn()
if builderName != f'{build["properties"].get("event.project")}/nix-eval': expectedBuilderName = f'{build["properties"].get("event.project")[0]}/nix-eval'
return dict()
if builderName != expectedBuilderName:
log.info("Passing {} builder which is not of the form '{}'".format(builderName, expectedBuilderName))
return CallbackReturn()
failed = build['properties'].get('failed_builds', [[]])[0] failed = build['properties'].get('failed_builds', [[]])[0]
@ -800,7 +934,8 @@ def gerritReviewFmt(url, data):
message += "\nFor more details visit:\n" message += "\nFor more details visit:\n"
message += build['url'] + "\n" message += build['url'] + "\n"
return dict(message=message, labels=labels) log.info("Message formatted: {}, labels: Verified={}".format(message, labels['Verified']))
return CallbackReturn(body=message, extra_info={'labels': labels})
class GerritNixConfigurator(ConfiguratorBase): class GerritNixConfigurator(ConfiguratorBase):
"""Janitor is a configurator which create a Janitor Builder with all needed Janitor steps""" """Janitor is a configurator which create a Janitor Builder with all needed Janitor steps"""
@ -824,13 +959,15 @@ class GerritNixConfigurator(ConfiguratorBase):
prometheus_config: dict[str, int | str] | None = None, prometheus_config: dict[str, int | str] | None = None,
binary_cache_config: dict[str, str] | None = None, binary_cache_config: dict[str, str] | None = None,
auth_method: AuthBase | None = None, auth_method: AuthBase | None = None,
manhole: Any = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.manhole = manhole
self.allowed_origins = allowed_origins self.allowed_origins = allowed_origins
self.gerrit_server = gerrit_server self.gerrit_server = gerrit_server
self.gerrit_user = gerrit_user self.gerrit_user = gerrit_user
self.gerrit_port = gerrit_port self.gerrit_port = gerrit_port
self.gerrit_sshkey_path = gerrit_sshkey_path self.gerrit_sshkey_path = str(gerrit_sshkey_path)
self.gerrit_config = GerritConfig(domain=self.gerrit_server, self.gerrit_config = GerritConfig(domain=self.gerrit_server,
username=self.gerrit_user, username=self.gerrit_user,
port=self.gerrit_port) port=self.gerrit_port)
@ -856,30 +993,32 @@ class GerritNixConfigurator(ConfiguratorBase):
self.auth_method = auth_method self.auth_method = auth_method
def configure(self, config: dict[str, Any]) -> None: def configure(self, config_dict: dict[str, Any]) -> None:
worker_config = json.loads(read_secret_file(self.nix_workers_secret_name)) worker_config_dict = json.loads(read_secret_file(self.nix_workers_secret_name))
worker_names = [] worker_names = []
config.setdefault("projects", []) if self.manhole is not None:
config.setdefault("secretsProviders", []) config_dict["manhole"] = self.manhole
config.setdefault("www", {
'allowed_origins': self.allowed_origins
})
for item in worker_config: config_dict.setdefault("projects", [])
config_dict.setdefault("secretsProviders", [])
print('Default allowed origins for this Buildbot server: {}'.format(', '.join(self.allowed_origins)))
config_dict["www"]["allowed_origins"] = self.allowed_origins
for item in worker_config_dict:
cores = item.get("cores", 0) cores = item.get("cores", 0)
for i in range(cores): for i in range(cores):
for arch in self.nix_supported_systems + ["other"]: for arch in self.nix_supported_systems + ["other"]:
worker_name = f"{item['name']}-{i:03}" worker_name = f"{item['name']}-{i:03}"
config["workers"].append(worker.Worker(f"{worker_name}-{arch}", item["pass"])) config_dict["workers"].append(worker.Worker(f"{worker_name}-{arch}", item["pass"]))
worker_names.append(worker_name) worker_names.append(worker_name)
eval_lock = util.MasterLock("nix-eval") eval_lock = util.MasterLock("nix-eval")
builders_spec = " ; ".join(builder.to_nix_line() for builder in self.nix_builders)
for project in self.projects: for project in self.projects:
config_for_project( config_for_project(
config, config_dict,
self.gerrit_config, self.gerrit_config,
GerritProject(name=project, private_sshkey_path=self.gerrit_sshkey_path), GerritProject(name=project, private_sshkey_path=self.gerrit_sshkey_path),
worker_names, worker_names,
@ -887,20 +1026,20 @@ class GerritNixConfigurator(ConfiguratorBase):
self.nix_eval_worker_count or multiprocessing.cpu_count(), self.nix_eval_worker_count or multiprocessing.cpu_count(),
self.nix_eval_max_memory_size, self.nix_eval_max_memory_size,
eval_lock, eval_lock,
builders_spec, self.nix_builders,
signing_keyfile=self.signing_keyfile, signing_keyfile=self.signing_keyfile,
binary_cache_config=self.binary_cache_config binary_cache_config=self.binary_cache_config
) )
config["change_source"] = self.gerrit_change_source config_dict["change_source"] = self.gerrit_change_source
config["services"].append( config_dict["services"].append(
reporters.GerritStatusPush(self.gerrit_server, self.gerrit_user, reporters.GerritStatusPush(self.gerrit_server, self.gerrit_user,
port=self.gerrit_port, port=self.gerrit_port,
identity_file=self.gerrit_sshkey_path, identity_file=self.gerrit_sshkey_path,
generators=[ generators=[
# gerritReviewCB / self.url
BuildStatusGenerator( BuildStatusGenerator(
message_formatter=MessageFormatterFunction( mode='all',
message_formatter=ReasonableMessageFormatter(
lambda data: gerritReviewFmt(self.url, data), lambda data: gerritReviewFmt(self.url, data),
"plain", "plain",
want_properties=True, want_properties=True,
@ -908,11 +1047,10 @@ class GerritNixConfigurator(ConfiguratorBase):
), ),
), ),
]) ])
# startCB, summaryCB are too noisy, we won't use them.
) )
if self.prometheus_config is not None: if self.prometheus_config is not None:
config['services'].append(reporters.Prometheus(port=self.prometheus_config.get('port', 9100), interface=self.prometheus_config.get('address', ''))) config_dict['services'].append(reporters.Prometheus(port=self.prometheus_config.get('port', 9100), interface=self.prometheus_config.get('address', '')))
# Upstream defaults pretend they already do something similar # Upstream defaults pretend they already do something similar
# but they didn't work, hence the custom function. # but they didn't work, hence the custom function.
@ -922,7 +1060,7 @@ class GerritNixConfigurator(ConfiguratorBase):
return ref return ref
return ref.rsplit('/', 1)[0] return ref.rsplit('/', 1)[0]
config["services"].append( config_dict["services"].append(
util.OldBuildCanceller( util.OldBuildCanceller(
"build_canceller", "build_canceller",
filters=[ filters=[
@ -945,12 +1083,12 @@ class GerritNixConfigurator(ConfiguratorBase):
systemd_secrets = secrets.SecretInAFile( systemd_secrets = secrets.SecretInAFile(
dirname=os.environ["CREDENTIALS_DIRECTORY"], dirname=os.environ["CREDENTIALS_DIRECTORY"],
) )
config["secretsProviders"].append(systemd_secrets) config_dict["secretsProviders"].append(systemd_secrets)
config["www"].setdefault("plugins", {}) config_dict["www"].setdefault("plugins", {})
if "authz" not in config["www"]: if "authz" not in config_dict["www"]:
config["www"]["authz"] = util.Authz( config_dict["www"]["authz"] = util.Authz(
allowRules=[ allowRules=[
util.AnyEndpointMatcher(role="admin", defaultDeny=False), util.AnyEndpointMatcher(role="admin", defaultDeny=False),
util.StopBuildEndpointMatcher(role="owner"), util.StopBuildEndpointMatcher(role="owner"),
@ -964,5 +1102,5 @@ class GerritNixConfigurator(ConfiguratorBase):
], ],
) )
if "auth" not in config["www"] and self.auth_method is not None: if "auth" not in config_dict["www"] and self.auth_method is not None:
config["www"]["auth"] = self.auth_method config_dict["www"]["auth"] = self.auth_method

View file

@ -0,0 +1,46 @@
from typing import Any, Callable, Literal
from buildbot.reporters.message import MessageFormatterBase
import dataclasses
@dataclasses.dataclass
class CallbackPayloadBuild:
# buddy i have no idea what the fuck is in this
build: dict[str, Any]
@dataclasses.dataclass
class CallbackPayloadBuildSet:
buildset: dict[str, Any]
# i have no idea what the fuck is in this honestly
builds: Any
@dataclasses.dataclass
class CallbackReturn:
body: str | None = None
subject: str | None = None
type: Literal['plain'] | Literal['html'] | Literal['json'] = 'plain'
extra_info: dict[str, Any] | None = None
# FIXME: support other template types, if they actually become necessary
template_type: Literal['plain'] = 'plain'
class ReasonableMessageFormatter(MessageFormatterBase):
"""
Message formatter which uses strongly typed data classes to reduce suffering slightly.
"""
CallbackFunc = Callable[[CallbackPayloadBuild | CallbackPayloadBuildSet], CallbackReturn]
def __init__(self, function: CallbackFunc, template_type: str, **kwargs):
super().__init__(**kwargs)
self.template_type = template_type
self._function = function
def format_message_for_build(self, master, build, **kwargs):
return dataclasses.asdict(self._function(CallbackPayloadBuild(build=build)))
def format_message_for_buildset(self, master, buildset, builds, **kwargs):
return dataclasses.asdict(self._function(CallbackPayloadBuildSet(buildset=buildset, builds=builds)))
# These only exist as callbacks, the only one actually used is render_message_dict
def render_message_body(self, context):
return None
def render_message_subject(self, context):
return None

View file

@ -1,4 +1,5 @@
{ setuptools, buildPythonPackage }: { setuptools, buildPythonPackage }:
# TODO: figure out how to make this depend upon buildbot without leaking it further.
buildPythonPackage { buildPythonPackage {
name = "buildbot-nix"; name = "buildbot-nix";
format = "pyproject"; format = "pyproject";

View file

@ -42,6 +42,11 @@
pkgs.ruff pkgs.ruff
]; ];
}; };
devShells.default = pkgs.mkShell {
packages = [
self'.packages.buildbot-nix
];
};
packages.buildbot-nix = pkgs.python3.pkgs.callPackage ./default.nix { }; packages.buildbot-nix = pkgs.python3.pkgs.callPackage ./default.nix { };
checks = checks =
let let

View file

@ -7,6 +7,9 @@
let let
inherit (lib) filterAttrs; inherit (lib) filterAttrs;
cfg = config.services.buildbot-nix.coordinator; cfg = config.services.buildbot-nix.coordinator;
debuggingManhole = if cfg.debugging.enable then
"manhole.TelnetManhole(${toString cfg.debugging.port}, 'admin', 'admin')"
else "None";
in in
{ {
options = { options = {
@ -28,6 +31,14 @@ in
description = "List of local remote builders machines associated to that Buildbot instance"; description = "List of local remote builders machines associated to that Buildbot instance";
}; };
debugging = {
enable = lib.mkEnableOption "manhole's buildbot debugging on localhost using `admin:admin`";
port = lib.mkOption {
type = lib.types.port;
default = 15000;
};
};
oauth2 = { oauth2 = {
name = lib.mkOption { name = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@ -216,6 +227,7 @@ in
extraImports = '' extraImports = ''
from datetime import timedelta from datetime import timedelta
from buildbot_nix import GerritNixConfigurator, read_secret_file, make_oauth2_method, OAuth2Config, assemble_secret_file_path from buildbot_nix import GerritNixConfigurator, read_secret_file, make_oauth2_method, OAuth2Config, assemble_secret_file_path
from buildbot import manhole
# TODO(raito): make me configurable from the NixOS module. # TODO(raito): make me configurable from the NixOS module.
# how? # how?
@ -257,7 +269,8 @@ in
auth_method=CustomOAuth2(${builtins.toJSON cfg.oauth2.clientId}, auth_method=CustomOAuth2(${builtins.toJSON cfg.oauth2.clientId},
read_secret_file('buildbot-oauth2-secret'), read_secret_file('buildbot-oauth2-secret'),
autologin=True autologin=True
) ),
manhole=${debuggingManhole}
) )
'' ''
]; ];

View file

@ -62,6 +62,7 @@ in
pkgs.openssh pkgs.openssh
pkgs.nix pkgs.nix
pkgs.nix-eval-jobs pkgs.nix-eval-jobs
pkgs.bash
]; ];
environment.PYTHONPATH = "${python.withPackages (_: [cfg.package])}/${python.sitePackages}"; environment.PYTHONPATH = "${python.withPackages (_: [cfg.package])}/${python.sitePackages}";
environment.MASTER_URL = cfg.coordinatorUrl; environment.MASTER_URL = cfg.coordinatorUrl;