Skip to content

Commit 44cfc82

Browse files
committed
add buildbot-effects
This is an implementation of hercules-ci-effects in python.
1 parent 35079f8 commit 44cfc82

File tree

5 files changed

+348
-1
lines changed

5 files changed

+348
-1
lines changed

bin/buildbot-effects

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env python
2+
import sys
3+
from pathlib import Path
4+
sys.path.append(str(Path(__file__).parent.parent))
5+
6+
from hercules_effects.cli import main
7+
8+
if __name__ == '__main__':
9+
main()

buildbot_effects/__init__.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import json
2+
import os
3+
import shlex
4+
import shutil
5+
import subprocess
6+
import sys
7+
from collections.abc import Iterator
8+
from contextlib import contextmanager
9+
from pathlib import Path
10+
from tempfile import NamedTemporaryFile
11+
from typing import IO, Any
12+
13+
from .options import EffectsOptions
14+
15+
16+
def run(
17+
cmd: list[str],
18+
stdin: int | IO[str] | None = None,
19+
stdout: int | IO[str] | None = None,
20+
stderr: int | IO[str] | None = None,
21+
verbose: bool = True,
22+
) -> subprocess.CompletedProcess[str]:
23+
if verbose:
24+
print("$", shlex.join(cmd), file=sys.stderr)
25+
return subprocess.run(
26+
cmd,
27+
check=True,
28+
text=True,
29+
stdin=stdin,
30+
stdout=stdout,
31+
stderr=stderr,
32+
)
33+
34+
35+
def git_command(args: list[str], path: Path) -> str:
36+
cmd = ["git", "-C", str(path), *args]
37+
proc = run(cmd, stdout=subprocess.PIPE)
38+
return proc.stdout.strip()
39+
40+
41+
def get_git_rev(path: Path) -> str:
42+
return git_command(["rev-parse", "--verify", "HEAD"], path)
43+
44+
45+
def get_git_branch(path: Path) -> str:
46+
return git_command(["rev-parse", "--abbrev-ref", "HEAD"], path)
47+
48+
49+
def get_git_remote_url(path: Path) -> str | None:
50+
try:
51+
return git_command(["remote", "get-url", "origin"], path)
52+
except subprocess.CalledProcessError:
53+
return None
54+
55+
56+
def git_get_tag(path: Path, rev: str) -> str | None:
57+
tags = git_command(["tag", "--points-at", rev], path)
58+
if tags:
59+
return tags.splitlines()[1]
60+
return None
61+
62+
63+
def effects_args(opts: EffectsOptions) -> dict[str, Any]:
64+
rev = opts.rev or get_git_rev(opts.path)
65+
short_rev = rev[:7]
66+
branch = opts.branch or get_git_branch(opts.path)
67+
repo = opts.repo or opts.path.name
68+
tag = opts.tag or git_get_tag(opts.path, rev)
69+
url = opts.url or get_git_remote_url(opts.path)
70+
primary_repo = dict(
71+
name=repo,
72+
branch=branch,
73+
# TODO: support ref
74+
ref=None,
75+
tag=tag,
76+
rev=rev,
77+
shortRev=short_rev,
78+
remoteHttpUrl=url,
79+
)
80+
return {
81+
"primaryRepo": primary_repo,
82+
**primary_repo,
83+
}
84+
85+
86+
def nix_command(*args: str) -> list[str]:
87+
return ["nix", "--extra-experimental-features", "nix-command flakes", *args]
88+
89+
90+
def effect_function(opts: EffectsOptions) -> str:
91+
args = effects_args(opts)
92+
rev = args["rev"]
93+
escaped_args = json.dumps(json.dumps(args))
94+
url = json.dumps(f"git+file://{opts.path}?rev={rev}#")
95+
return f"""(((builtins.getFlake {url}).outputs.herculesCI (builtins.fromJSON {escaped_args})).onPush.default.outputs.hci-effects)"""
96+
97+
98+
def list_effects(opts: EffectsOptions) -> list[str]:
99+
cmd = nix_command(
100+
"eval",
101+
"--json",
102+
"--expr",
103+
f"builtins.attrNames {effect_function(opts)}",
104+
)
105+
proc = run(cmd, stdout=subprocess.PIPE)
106+
return json.loads(proc.stdout)
107+
108+
109+
def instantiate_effects(opts: EffectsOptions) -> str:
110+
cmd = [
111+
"nix-instantiate",
112+
"--expr",
113+
f"{effect_function(opts)}.deploy.run",
114+
]
115+
proc = run(cmd, stdout=subprocess.PIPE)
116+
return proc.stdout.rstrip()
117+
118+
119+
def parse_derivation(path: str) -> dict[str, Any]:
120+
cmd = [
121+
"nix",
122+
"--extra-experimental-features",
123+
"nix-command flakes",
124+
"derivation",
125+
"show",
126+
f"{path}^*",
127+
]
128+
proc = run(cmd, stdout=subprocess.PIPE)
129+
return json.loads(proc.stdout)
130+
131+
132+
def env_args(env: dict[str, str]) -> list[str]:
133+
result = []
134+
for k, v in env.items():
135+
result.append("--setenv")
136+
result.append(f"{k}")
137+
result.append(f"{v}")
138+
return result
139+
140+
141+
@contextmanager
142+
def pipe() -> Iterator[tuple[IO[str], IO[str]]]:
143+
r, w = os.pipe()
144+
r_file = os.fdopen(r, "r")
145+
w_file = os.fdopen(w, "w")
146+
try:
147+
yield r_file, w_file
148+
finally:
149+
r_file.close()
150+
w_file.close()
151+
152+
153+
def run_effects(
154+
drv_path: str, drv: dict[str, Any], secrets: dict[str, Any] = {}
155+
) -> None:
156+
builder = drv["builder"]
157+
args = drv["args"]
158+
sandboxed_cmd = [
159+
builder,
160+
*args,
161+
]
162+
env = {}
163+
env["IN_HERCULES_CI_EFFECT"] = "true"
164+
env["HERCULES_CI_SECRETS_JSON"] = "/run/secrets.json"
165+
env["NIX_BUILD_TOP"] = "/build"
166+
bwrap = shutil.which("bwrap")
167+
if bwrap is None:
168+
raise Exception("bwrap not found")
169+
170+
bubblewrap_cmd = [
171+
"nix",
172+
"develop",
173+
"-i",
174+
f"{drv_path}^*",
175+
"-c",
176+
bwrap,
177+
"--unshare-all",
178+
"--share-net",
179+
"--new-session",
180+
"--die-with-parent",
181+
"--dir",
182+
"/build",
183+
"--chdir",
184+
"/build",
185+
"--tmpfs",
186+
"/tmp",
187+
"--tmpfs",
188+
"/build",
189+
"--proc",
190+
"/proc",
191+
"--dev",
192+
"/dev",
193+
"--ro-bind",
194+
"/etc/resolv.conf",
195+
"/etc/resolv.conf",
196+
"--ro-bind",
197+
"/etc/hosts",
198+
"/etc/hosts",
199+
"--ro-bind",
200+
"/nix/store",
201+
"/nix/store",
202+
]
203+
204+
with NamedTemporaryFile() as tmp:
205+
secrets = secrets.copy()
206+
secrets["hercules-ci"] = {"data": {"token": "dummy"}}
207+
tmp.write(json.dumps(secrets).encode())
208+
bubblewrap_cmd.extend(
209+
[
210+
"--ro-bind",
211+
tmp.name,
212+
"/run/secrets.json",
213+
]
214+
)
215+
bubblewrap_cmd.extend(env_args(env))
216+
bubblewrap_cmd.append("--")
217+
bubblewrap_cmd.extend(sandboxed_cmd)
218+
with pipe() as (r_file, w_file):
219+
print("$", shlex.join(bubblewrap_cmd), file=sys.stderr)
220+
proc = subprocess.Popen(
221+
bubblewrap_cmd,
222+
text=True,
223+
stdin=subprocess.DEVNULL,
224+
stdout=w_file,
225+
stderr=w_file,
226+
)
227+
w_file.close()
228+
with proc:
229+
for line in r_file:
230+
print(line, end="")
231+
proc.wait()
232+
if proc.returncode != 0:
233+
raise Exception(f"command failed with exit code {proc.returncode}")

buildbot_effects/cli.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import argparse
2+
from collections.abc import Callable
3+
from pathlib import Path
4+
import json
5+
6+
from .options import EffectsOptions
7+
from . import list_effects, instantiate_effects, parse_derivation, run_effects
8+
9+
10+
def list_command(options: EffectsOptions) -> None:
11+
print(list_effects(options))
12+
13+
14+
def run_command(options: EffectsOptions) -> None:
15+
drv_path = instantiate_effects(options)
16+
drvs = parse_derivation(drv_path)
17+
drv = next(iter(drvs.values()))
18+
19+
if options.secrets:
20+
secrets = json.loads(options.secrets.read_text())
21+
else:
22+
secrets = {}
23+
run_effects(drv_path, drv, secrets=secrets)
24+
25+
26+
def run_all_command(options: EffectsOptions) -> None:
27+
print("TODO")
28+
29+
30+
def parse_args() -> tuple[Callable[[EffectsOptions], None], EffectsOptions]:
31+
parser = argparse.ArgumentParser(description="Run effects from a hercules-ci flake")
32+
parser.add_argument(
33+
"--secrets",
34+
type=Path,
35+
help="Path to a json file with secrets",
36+
)
37+
parser.add_argument(
38+
"--rev",
39+
type=str,
40+
help="Git revision to use",
41+
)
42+
parser.add_argument(
43+
"--branch",
44+
type=str,
45+
help="Git branch to use",
46+
)
47+
parser.add_argument(
48+
"--repo",
49+
type=str,
50+
help="Git repo to prepend to be",
51+
)
52+
parser.add_argument(
53+
"--path",
54+
type=str,
55+
help="Path to the repository",
56+
)
57+
subparser = parser.add_subparsers(
58+
dest="command",
59+
required=True,
60+
help="Command to run",
61+
)
62+
list_parser = subparser.add_parser(
63+
"list",
64+
help="List available effects",
65+
)
66+
list_parser.set_defaults(command=list_command)
67+
run_parser = subparser.add_parser(
68+
"run",
69+
help="Run an effect",
70+
)
71+
run_parser.set_defaults(command=run_command)
72+
run_parser.add_argument(
73+
"effect",
74+
help="Effect to run",
75+
)
76+
run_all_parser = subparser.add_parser(
77+
"run-all",
78+
help="Run all effects",
79+
)
80+
run_all_parser.set_defaults(command=run_all_command)
81+
82+
args = parser.parse_args()
83+
return args.command, EffectsOptions(secrets=args.secrets)
84+
85+
86+
def main() -> None:
87+
command, options = parse_args()
88+
command(options)

buildbot_effects/options.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from dataclasses import dataclass, field
2+
from pathlib import Path
3+
4+
5+
@dataclass
6+
class EffectsOptions:
7+
secrets: Path | None = None
8+
path: Path = field(default_factory=lambda: Path.cwd())
9+
repo: str | None = ""
10+
rev: str | None = None
11+
branch: str | None = None
12+
url: str | None = None
13+
tag: str | None = None

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ classifiers = [
2121
"Programming Language :: Python"
2222
]
2323
version = "0.0.1"
24+
scripts = { buildbot-effects = "hercules_effects.cli:main" }
2425

2526
[tool.setuptools]
26-
packages = ["buildbot_nix"]
27+
packages = [
28+
"buildbot_nix",
29+
"buildbot_effects"
30+
]
2731

2832
[tool.ruff]
2933
target-version = "py311"

0 commit comments

Comments
 (0)