Skip to content

Commit 22b4caf

Browse files
authored
Merge pull request #65 from Mic92/hercules
Hercules ci effects: add cli
2 parents 05d12f9 + 8b9c060 commit 22b4caf

File tree

9 files changed

+559
-122
lines changed

9 files changed

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

buildbot_effects/cli.py

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

0 commit comments

Comments
 (0)