Skip to content

Commit ed9b02d

Browse files
committed
buildbot-effects: support flake references for remote repos
Avoids the painful workflow of cloning a repo into a tempdir just to run a one-off effect. Commands now accept Nix flake reference syntax: buildbot-effects run github:org/repo/branch#my-effect buildbot-effects list github:org/repo/branch buildbot-effects list-schedules github:org/repo/branch buildbot-effects run-scheduled github:org/repo#schedule effect When a flake ref is detected (by the presence of '#' in the positional arg, or as an optional positional for list commands), we resolve it via `nix flake metadata --json` to obtain the rev, branch, store path, and locked URL — then pass the locked URL directly to builtins.getFlake instead of constructing a git+file:// URL from a local checkout. Closes: #584
1 parent 5d54162 commit ed9b02d

File tree

5 files changed

+189
-9
lines changed

5 files changed

+189
-9
lines changed

buildbot_effects/buildbot_effects/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,23 @@ def nix_command(*args: str) -> list[str]:
9696
return ["nix", "--extra-experimental-features", "nix-command flakes", *args]
9797

9898

99+
def _flake_url(opts: EffectsOptions, rev: str) -> str:
100+
"""Return the Nix flake URL to use with builtins.getFlake.
101+
102+
When a locked_url is available (from a resolved remote flake ref),
103+
use it directly. Otherwise fall back to constructing a git+file:// URL
104+
from the local path.
105+
"""
106+
if opts.locked_url:
107+
return opts.locked_url
108+
return f"git+file://{opts.path}?rev={rev}#"
109+
110+
99111
def effect_function(opts: EffectsOptions) -> str:
100112
args = effects_args(opts)
101113
rev = args["rev"]
102114
escaped_args = json.dumps(json.dumps(args))
103-
url = json.dumps(f"git+file://{opts.path}?rev={rev}#")
115+
url = json.dumps(_flake_url(opts, rev))
104116
return f"""
105117
let
106118
flake = builtins.getFlake {url};
@@ -124,7 +136,7 @@ def scheduled_effect_function(opts: EffectsOptions) -> str:
124136
args = effects_args(opts)
125137
rev = args["rev"]
126138
escaped_args = json.dumps(json.dumps(args))
127-
url = json.dumps(f"git+file://{opts.path}?rev={rev}#")
139+
url = json.dumps(_flake_url(opts, rev))
128140
return f"""
129141
let
130142
flake = builtins.getFlake {url};

buildbot_effects/buildbot_effects/cli.py

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import argparse
44
import json
5+
import shlex
6+
import subprocess
57
import sys
68
from pathlib import Path
9+
from typing import Any
710

811
from . import (
912
instantiate_effects,
@@ -16,12 +19,53 @@
1619
from .options import EffectsOptions
1720

1821

22+
def resolve_flake(flake_ref: str, *, debug: bool = False) -> dict[str, Any]:
23+
"""Run `nix flake metadata --json` and return the parsed JSON."""
24+
cmd = [
25+
"nix",
26+
"--extra-experimental-features",
27+
"nix-command flakes",
28+
"flake",
29+
"metadata",
30+
"--json",
31+
flake_ref,
32+
]
33+
if debug:
34+
print("$", shlex.join(cmd), file=sys.stderr)
35+
proc = subprocess.run(cmd, check=True, text=True, capture_output=True)
36+
return json.loads(proc.stdout)
37+
38+
39+
def options_from_flake_ref(flake_ref: str, base: EffectsOptions) -> EffectsOptions:
40+
"""Resolve a flake reference to EffectsOptions via nix flake metadata."""
41+
meta = resolve_flake(flake_ref, debug=base.debug)
42+
locked = meta.get("locked", {})
43+
return EffectsOptions(
44+
secrets=base.secrets,
45+
path=Path(meta.get("path", "")),
46+
repo="",
47+
rev=locked.get("rev"),
48+
branch=locked.get("ref"),
49+
url=meta.get("resolvedUrl", meta.get("url", "")),
50+
locked_url=meta.get("lockedUrl", ""),
51+
debug=base.debug,
52+
)
53+
54+
1955
def list_command(_args: argparse.Namespace, options: EffectsOptions) -> None:
56+
if _args.flake_ref:
57+
options = options_from_flake_ref(_args.flake_ref, options)
2058
json.dump(list_effects(options), fp=sys.stdout, indent=2)
2159

2260

2361
def run_command(args: argparse.Namespace, options: EffectsOptions) -> None:
2462
effect = args.effect
63+
64+
# Support flakeref#effect syntax: github:org/repo/branch#my-effect
65+
if "#" in effect:
66+
flake_ref, _, effect = effect.partition("#")
67+
options = options_from_flake_ref(flake_ref, options)
68+
2569
drv_path = instantiate_effects(effect, options)
2670
if drv_path == "":
2771
print(f"Effect {effect} not found or not runnable for {options}")
@@ -41,17 +85,26 @@ def run_all_command(_args: argparse.Namespace, _options: EffectsOptions) -> None
4185

4286
def list_schedules_command(_args: argparse.Namespace, options: EffectsOptions) -> None:
4387
"""List all scheduled effects defined in the flake."""
88+
if _args.flake_ref:
89+
options = options_from_flake_ref(_args.flake_ref, options)
4490
json.dump(list_scheduled_effects(options), fp=sys.stdout, indent=2)
4591

4692

4793
def run_scheduled_command(args: argparse.Namespace, options: EffectsOptions) -> None:
4894
"""Run a specific effect from a schedule."""
4995
schedule_name = args.schedule_name
5096
effect = args.effect
97+
98+
# Support flakeref#schedule syntax: github:org/repo/branch#my-schedule
99+
if "#" in schedule_name:
100+
flake_ref, _, schedule_name = schedule_name.partition("#")
101+
options = options_from_flake_ref(flake_ref, options)
102+
51103
drv_path = instantiate_scheduled_effect(schedule_name, effect, options)
52104
if drv_path == "":
53105
print(
54-
f"Scheduled effect {schedule_name}/{effect} not found or not runnable for {options}"
106+
f"Scheduled effect {schedule_name}/{effect} not found or not runnable"
107+
f" for {options}"
55108
)
56109
return
57110
drvs = parse_derivation(drv_path)
@@ -64,7 +117,18 @@ def run_scheduled_command(args: argparse.Namespace, options: EffectsOptions) ->
64117

65118

66119
def parse_args() -> tuple[argparse.Namespace, EffectsOptions]:
67-
parser = argparse.ArgumentParser(description="Run effects from a hercules-ci flake")
120+
parser = argparse.ArgumentParser(
121+
description="Run effects from a hercules-ci flake",
122+
epilog=(
123+
"Flake reference syntax:\n"
124+
" Commands accept flake references to operate on remote repositories\n"
125+
" without requiring a local checkout:\n\n"
126+
" buildbot-effects run github:org/repo/branch#my-effect\n"
127+
" buildbot-effects list github:org/repo/branch\n"
128+
" buildbot-effects run-scheduled github:org/repo#schedule effect\n"
129+
),
130+
formatter_class=argparse.RawDescriptionHelpFormatter,
131+
)
68132
parser.add_argument(
69133
"--secrets",
70134
type=Path,
@@ -104,17 +168,23 @@ def parse_args() -> tuple[argparse.Namespace, EffectsOptions]:
104168
)
105169
list_parser = subparser.add_parser(
106170
"list",
107-
help="List available effects",
171+
help="List available effects (optionally from a flake reference)",
108172
)
109173
list_parser.set_defaults(command=list_command)
174+
list_parser.add_argument(
175+
"flake_ref",
176+
nargs="?",
177+
help="Flake reference (e.g. github:org/repo/branch)",
178+
)
179+
110180
run_parser = subparser.add_parser(
111181
"run",
112-
help="Run an effect",
182+
help="Run an effect (supports flakeref#effect syntax)",
113183
)
114184
run_parser.set_defaults(command=run_command)
115185
run_parser.add_argument(
116186
"effect",
117-
help="Effect to run",
187+
help="Effect to run, or flakeref#effect (e.g. github:org/repo/branch#deploy)",
118188
)
119189
run_all_parser = subparser.add_parser(
120190
"run-all",
@@ -124,9 +194,14 @@ def parse_args() -> tuple[argparse.Namespace, EffectsOptions]:
124194

125195
list_schedules_parser = subparser.add_parser(
126196
"list-schedules",
127-
help="List all scheduled effects defined in the flake",
197+
help="List all scheduled effects (optionally from a flake reference)",
128198
)
129199
list_schedules_parser.set_defaults(command=list_schedules_command)
200+
list_schedules_parser.add_argument(
201+
"flake_ref",
202+
nargs="?",
203+
help="Flake reference (e.g. github:org/repo/branch)",
204+
)
130205

131206
run_scheduled_parser = subparser.add_parser(
132207
"run-scheduled",
@@ -135,7 +210,7 @@ def parse_args() -> tuple[argparse.Namespace, EffectsOptions]:
135210
run_scheduled_parser.set_defaults(command=run_scheduled_command)
136211
run_scheduled_parser.add_argument(
137212
"schedule_name",
138-
help="Name of the schedule (from onSchedule.<name>)",
213+
help="Schedule name, or flakeref#schedule (e.g. github:org/repo#my-schedule)",
139214
)
140215
run_scheduled_parser.add_argument(
141216
"effect",

buildbot_effects/buildbot_effects/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ class EffectsOptions:
1313
branch: str | None = None
1414
url: str | None = None
1515
tag: str | None = None
16+
locked_url: str | None = None
1617
debug: bool = False

buildbot_effects/tests/test_cli.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Tests for CLI flake ref support."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
7+
8+
from buildbot_effects.cli import run_command
9+
from buildbot_effects.options import EffectsOptions
10+
11+
FAKE_METADATA: dict = {
12+
"url": "github:my-org/my-repo/main",
13+
"resolvedUrl": "github:my-org/my-repo/abc1234",
14+
"lockedUrl": "github:my-org/my-repo/abc1234def5678abc1234def5678abc1234def567",
15+
"path": "/nix/store/abc123-source",
16+
"locked": {
17+
"rev": "abc1234def5678abc1234def5678abc1234def567",
18+
"ref": "main",
19+
"lastModified": 1700000000,
20+
},
21+
}
22+
23+
24+
def _base_options() -> EffectsOptions:
25+
return EffectsOptions(
26+
secrets=Path("/tmp/secrets.json"), # noqa: S108
27+
debug=True,
28+
)
29+
30+
31+
class TestRunCommandFlakeRef:
32+
def test_flake_ref_resolves_and_splits_effect(self) -> None:
33+
args = MagicMock()
34+
args.effect = "github:my-org/my-repo/main#deploy"
35+
36+
with (
37+
patch(
38+
"buildbot_effects.cli.resolve_flake",
39+
return_value=FAKE_METADATA,
40+
) as mock_resolve,
41+
patch(
42+
"buildbot_effects.cli.instantiate_effects", return_value=""
43+
) as mock_inst,
44+
):
45+
run_command(args, _base_options())
46+
47+
mock_resolve.assert_called_once_with("github:my-org/my-repo/main", debug=True)
48+
assert mock_inst.call_args[0][0] == "deploy"
49+
50+
def test_plain_effect_skips_resolution(self) -> None:
51+
args = MagicMock()
52+
args.effect = "deploy"
53+
54+
with (
55+
patch("buildbot_effects.cli.resolve_flake") as mock_resolve,
56+
patch(
57+
"buildbot_effects.cli.instantiate_effects", return_value=""
58+
) as mock_inst,
59+
):
60+
run_command(args, _base_options())
61+
62+
mock_resolve.assert_not_called()
63+
assert mock_inst.call_args[0][0] == "deploy"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Test _flake_url: the bridge between EffectsOptions and builtins.getFlake."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from buildbot_effects import _flake_url
8+
from buildbot_effects.options import EffectsOptions
9+
10+
11+
class TestFlakeUrl:
12+
def test_local_path_fallback(self) -> None:
13+
opts = EffectsOptions(path=Path("/home/user/my-repo"))
14+
assert (
15+
_flake_url(opts, "abc1234") == "git+file:///home/user/my-repo?rev=abc1234#"
16+
)
17+
18+
def test_locked_url_used(self) -> None:
19+
opts = EffectsOptions(
20+
path=Path("/nix/store/xyz-source"),
21+
locked_url="github:org/repo/abc1234def5678",
22+
)
23+
assert _flake_url(opts, "abc1234") == "github:org/repo/abc1234def5678"
24+
25+
def test_empty_locked_url_falls_back(self) -> None:
26+
opts = EffectsOptions(path=Path("/home/user/my-repo"), locked_url="")
27+
assert (
28+
_flake_url(opts, "abc1234") == "git+file:///home/user/my-repo?rev=abc1234#"
29+
)

0 commit comments

Comments
 (0)