add buildbot-effects
This is an implementation of hercules-ci-effects in python.
This commit is contained in:
parent
35079f89e7
commit
44cfc8253b
9
bin/buildbot-effects
Executable file
9
bin/buildbot-effects
Executable file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.append(str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from hercules_effects.cli import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
233
buildbot_effects/__init__.py
Normal file
233
buildbot_effects/__init__.py
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import IO, Any
|
||||||
|
|
||||||
|
from .options import EffectsOptions
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
cmd: list[str],
|
||||||
|
stdin: int | IO[str] | None = None,
|
||||||
|
stdout: int | IO[str] | None = None,
|
||||||
|
stderr: int | IO[str] | None = None,
|
||||||
|
verbose: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
if verbose:
|
||||||
|
print("$", shlex.join(cmd), file=sys.stderr)
|
||||||
|
return subprocess.run(
|
||||||
|
cmd,
|
||||||
|
check=True,
|
||||||
|
text=True,
|
||||||
|
stdin=stdin,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git_command(args: list[str], path: Path) -> str:
|
||||||
|
cmd = ["git", "-C", str(path), *args]
|
||||||
|
proc = run(cmd, stdout=subprocess.PIPE)
|
||||||
|
return proc.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_rev(path: Path) -> str:
|
||||||
|
return git_command(["rev-parse", "--verify", "HEAD"], path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_branch(path: Path) -> str:
|
||||||
|
return git_command(["rev-parse", "--abbrev-ref", "HEAD"], path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_remote_url(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return git_command(["remote", "get-url", "origin"], path)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def git_get_tag(path: Path, rev: str) -> str | None:
|
||||||
|
tags = git_command(["tag", "--points-at", rev], path)
|
||||||
|
if tags:
|
||||||
|
return tags.splitlines()[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def effects_args(opts: EffectsOptions) -> dict[str, Any]:
|
||||||
|
rev = opts.rev or get_git_rev(opts.path)
|
||||||
|
short_rev = rev[:7]
|
||||||
|
branch = opts.branch or get_git_branch(opts.path)
|
||||||
|
repo = opts.repo or opts.path.name
|
||||||
|
tag = opts.tag or git_get_tag(opts.path, rev)
|
||||||
|
url = opts.url or get_git_remote_url(opts.path)
|
||||||
|
primary_repo = dict(
|
||||||
|
name=repo,
|
||||||
|
branch=branch,
|
||||||
|
# TODO: support ref
|
||||||
|
ref=None,
|
||||||
|
tag=tag,
|
||||||
|
rev=rev,
|
||||||
|
shortRev=short_rev,
|
||||||
|
remoteHttpUrl=url,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"primaryRepo": primary_repo,
|
||||||
|
**primary_repo,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def nix_command(*args: str) -> list[str]:
|
||||||
|
return ["nix", "--extra-experimental-features", "nix-command flakes", *args]
|
||||||
|
|
||||||
|
|
||||||
|
def effect_function(opts: EffectsOptions) -> str:
|
||||||
|
args = effects_args(opts)
|
||||||
|
rev = args["rev"]
|
||||||
|
escaped_args = json.dumps(json.dumps(args))
|
||||||
|
url = json.dumps(f"git+file://{opts.path}?rev={rev}#")
|
||||||
|
return f"""(((builtins.getFlake {url}).outputs.herculesCI (builtins.fromJSON {escaped_args})).onPush.default.outputs.hci-effects)"""
|
||||||
|
|
||||||
|
|
||||||
|
def list_effects(opts: EffectsOptions) -> list[str]:
|
||||||
|
cmd = nix_command(
|
||||||
|
"eval",
|
||||||
|
"--json",
|
||||||
|
"--expr",
|
||||||
|
f"builtins.attrNames {effect_function(opts)}",
|
||||||
|
)
|
||||||
|
proc = run(cmd, stdout=subprocess.PIPE)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate_effects(opts: EffectsOptions) -> str:
|
||||||
|
cmd = [
|
||||||
|
"nix-instantiate",
|
||||||
|
"--expr",
|
||||||
|
f"{effect_function(opts)}.deploy.run",
|
||||||
|
]
|
||||||
|
proc = run(cmd, stdout=subprocess.PIPE)
|
||||||
|
return proc.stdout.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_derivation(path: str) -> dict[str, Any]:
|
||||||
|
cmd = [
|
||||||
|
"nix",
|
||||||
|
"--extra-experimental-features",
|
||||||
|
"nix-command flakes",
|
||||||
|
"derivation",
|
||||||
|
"show",
|
||||||
|
f"{path}^*",
|
||||||
|
]
|
||||||
|
proc = run(cmd, stdout=subprocess.PIPE)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def env_args(env: dict[str, str]) -> list[str]:
|
||||||
|
result = []
|
||||||
|
for k, v in env.items():
|
||||||
|
result.append("--setenv")
|
||||||
|
result.append(f"{k}")
|
||||||
|
result.append(f"{v}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def pipe() -> Iterator[tuple[IO[str], IO[str]]]:
|
||||||
|
r, w = os.pipe()
|
||||||
|
r_file = os.fdopen(r, "r")
|
||||||
|
w_file = os.fdopen(w, "w")
|
||||||
|
try:
|
||||||
|
yield r_file, w_file
|
||||||
|
finally:
|
||||||
|
r_file.close()
|
||||||
|
w_file.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_effects(
|
||||||
|
drv_path: str, drv: dict[str, Any], secrets: dict[str, Any] = {}
|
||||||
|
) -> None:
|
||||||
|
builder = drv["builder"]
|
||||||
|
args = drv["args"]
|
||||||
|
sandboxed_cmd = [
|
||||||
|
builder,
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
env = {}
|
||||||
|
env["IN_HERCULES_CI_EFFECT"] = "true"
|
||||||
|
env["HERCULES_CI_SECRETS_JSON"] = "/run/secrets.json"
|
||||||
|
env["NIX_BUILD_TOP"] = "/build"
|
||||||
|
bwrap = shutil.which("bwrap")
|
||||||
|
if bwrap is None:
|
||||||
|
raise Exception("bwrap not found")
|
||||||
|
|
||||||
|
bubblewrap_cmd = [
|
||||||
|
"nix",
|
||||||
|
"develop",
|
||||||
|
"-i",
|
||||||
|
f"{drv_path}^*",
|
||||||
|
"-c",
|
||||||
|
bwrap,
|
||||||
|
"--unshare-all",
|
||||||
|
"--share-net",
|
||||||
|
"--new-session",
|
||||||
|
"--die-with-parent",
|
||||||
|
"--dir",
|
||||||
|
"/build",
|
||||||
|
"--chdir",
|
||||||
|
"/build",
|
||||||
|
"--tmpfs",
|
||||||
|
"/tmp",
|
||||||
|
"--tmpfs",
|
||||||
|
"/build",
|
||||||
|
"--proc",
|
||||||
|
"/proc",
|
||||||
|
"--dev",
|
||||||
|
"/dev",
|
||||||
|
"--ro-bind",
|
||||||
|
"/etc/resolv.conf",
|
||||||
|
"/etc/resolv.conf",
|
||||||
|
"--ro-bind",
|
||||||
|
"/etc/hosts",
|
||||||
|
"/etc/hosts",
|
||||||
|
"--ro-bind",
|
||||||
|
"/nix/store",
|
||||||
|
"/nix/store",
|
||||||
|
]
|
||||||
|
|
||||||
|
with NamedTemporaryFile() as tmp:
|
||||||
|
secrets = secrets.copy()
|
||||||
|
secrets["hercules-ci"] = {"data": {"token": "dummy"}}
|
||||||
|
tmp.write(json.dumps(secrets).encode())
|
||||||
|
bubblewrap_cmd.extend(
|
||||||
|
[
|
||||||
|
"--ro-bind",
|
||||||
|
tmp.name,
|
||||||
|
"/run/secrets.json",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
bubblewrap_cmd.extend(env_args(env))
|
||||||
|
bubblewrap_cmd.append("--")
|
||||||
|
bubblewrap_cmd.extend(sandboxed_cmd)
|
||||||
|
with pipe() as (r_file, w_file):
|
||||||
|
print("$", shlex.join(bubblewrap_cmd), file=sys.stderr)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
bubblewrap_cmd,
|
||||||
|
text=True,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=w_file,
|
||||||
|
stderr=w_file,
|
||||||
|
)
|
||||||
|
w_file.close()
|
||||||
|
with proc:
|
||||||
|
for line in r_file:
|
||||||
|
print(line, end="")
|
||||||
|
proc.wait()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise Exception(f"command failed with exit code {proc.returncode}")
|
88
buildbot_effects/cli.py
Normal file
88
buildbot_effects/cli.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import argparse
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .options import EffectsOptions
|
||||||
|
from . import list_effects, instantiate_effects, parse_derivation, run_effects
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(options: EffectsOptions) -> None:
|
||||||
|
print(list_effects(options))
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(options: EffectsOptions) -> None:
|
||||||
|
drv_path = instantiate_effects(options)
|
||||||
|
drvs = parse_derivation(drv_path)
|
||||||
|
drv = next(iter(drvs.values()))
|
||||||
|
|
||||||
|
if options.secrets:
|
||||||
|
secrets = json.loads(options.secrets.read_text())
|
||||||
|
else:
|
||||||
|
secrets = {}
|
||||||
|
run_effects(drv_path, drv, secrets=secrets)
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_command(options: EffectsOptions) -> None:
|
||||||
|
print("TODO")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> tuple[Callable[[EffectsOptions], None], EffectsOptions]:
|
||||||
|
parser = argparse.ArgumentParser(description="Run effects from a hercules-ci flake")
|
||||||
|
parser.add_argument(
|
||||||
|
"--secrets",
|
||||||
|
type=Path,
|
||||||
|
help="Path to a json file with secrets",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rev",
|
||||||
|
type=str,
|
||||||
|
help="Git revision to use",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--branch",
|
||||||
|
type=str,
|
||||||
|
help="Git branch to use",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo",
|
||||||
|
type=str,
|
||||||
|
help="Git repo to prepend to be",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--path",
|
||||||
|
type=str,
|
||||||
|
help="Path to the repository",
|
||||||
|
)
|
||||||
|
subparser = parser.add_subparsers(
|
||||||
|
dest="command",
|
||||||
|
required=True,
|
||||||
|
help="Command to run",
|
||||||
|
)
|
||||||
|
list_parser = subparser.add_parser(
|
||||||
|
"list",
|
||||||
|
help="List available effects",
|
||||||
|
)
|
||||||
|
list_parser.set_defaults(command=list_command)
|
||||||
|
run_parser = subparser.add_parser(
|
||||||
|
"run",
|
||||||
|
help="Run an effect",
|
||||||
|
)
|
||||||
|
run_parser.set_defaults(command=run_command)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"effect",
|
||||||
|
help="Effect to run",
|
||||||
|
)
|
||||||
|
run_all_parser = subparser.add_parser(
|
||||||
|
"run-all",
|
||||||
|
help="Run all effects",
|
||||||
|
)
|
||||||
|
run_all_parser.set_defaults(command=run_all_command)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args.command, EffectsOptions(secrets=args.secrets)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
command, options = parse_args()
|
||||||
|
command(options)
|
13
buildbot_effects/options.py
Normal file
13
buildbot_effects/options.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectsOptions:
|
||||||
|
secrets: Path | None = None
|
||||||
|
path: Path = field(default_factory=lambda: Path.cwd())
|
||||||
|
repo: str | None = ""
|
||||||
|
rev: str | None = None
|
||||||
|
branch: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
tag: str | None = None
|
|
@ -21,9 +21,13 @@ classifiers = [
|
||||||
"Programming Language :: Python"
|
"Programming Language :: Python"
|
||||||
]
|
]
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
|
scripts = { buildbot-effects = "hercules_effects.cli:main" }
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["buildbot_nix"]
|
packages = [
|
||||||
|
"buildbot_nix",
|
||||||
|
"buildbot_effects"
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
|
|
Loading…
Reference in a new issue