Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Buck reads the checkout in place, so a branch or an uncommitted edit is active i
```sh
tine buck <arguments> # run the pinned Buck2, passing <arguments> verbatim
tine box # run the project's root box target
tine exec <target> # run a target outside of the Buck daemon
tine mount <verb> # manage external directories mounted over project paths
tine cache-status # report on the shim serving the shared build cache
tine init [<path>] # write the configuration a project needs, for the checkout this command is in
Expand All @@ -130,6 +131,9 @@ tine completion <shell> # print the completion script for bash, fish or zsh

Use `tine buck` instead of running Buck2 directly so that builds use your selected checkouts.

`tine exec` is for running a long-lived process from a target (like a background service) without it
staying tied to a `buck run` client and its daemon. Arguments after `--` go to the target's command.

## Shell completion

Install shell completion from inside a project:
Expand Down
69 changes: 63 additions & 6 deletions bin/tine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,7 @@ def reexec_configured_wrapper(
VERBS = {
"buck": "run the pinned Buck2, which every other command configures",
"box": "run the project's root box target",
"exec": "build a runnable target and run its command outside of the Buck daemon",
"mount": "manage external directories mounted over project paths",
"cache-status": "report on the shim serving the shared build cache",
"init": "write the configuration a project needs to build against a checkout of the tine cell",
Expand Down Expand Up @@ -1787,7 +1788,15 @@ def before_shim(
return [str(binary), *([FLAG_ISOLATION, isolation] if isolation else [])]


def buck_command(argv: list[str]) -> None:
def setup_buck(argv: list[str], reexec: list[str]) -> Path | None:
"""Set up Buck to run `argv`: cache shim, mounts, configuration, and the pinned binary.

A project's tine cell may be a different checkout than the running one; its bin/tine then takes
over, re-entering with the `reexec` command line.

Returns the buck2 binary path. Returns None only for the `complete` subcommand if the pinned binary
is not fetched yet; shell completion must not trigger a download.
"""
root = project_root(cwd())
ensure_home(root)
settings = project_settings(root)
Expand All @@ -1807,13 +1816,11 @@ def buck_command(argv: list[str]) -> None:
if command.subcommand not in BUCK_COMMAND_NO_ACTION and not os.environ.get(MARKER):
cache_shim.ensure(cache, lambda: before_shim(root, config, settings, cache, command.isolation))
# A completing shell must neither download nor rewrite shared configuration on a keypress.
config, mounts = prepare_buck(
root, settings, ["buck", *argv], refresh_config=command.subcommand != "complete"
)
config, mounts = prepare_buck(root, settings, reexec, refresh_config=command.subcommand != "complete")
# Hand over first so wrapper_cell_root() reads the configured checkout's pin.
binary = buck2_binary(settings, wrapper_cell_root(), fetch=command.subcommand != "complete")
if binary is None:
return
return None
if command.subcommand != "complete" and not os.environ.get("BUCK2_BINARY"):
# The daemon reads the cache address only at startup, so a changed one has to replace it.
# Not `daemon_buster`: Buck takes startup constraints from the root `.buckconfig` without
Expand All @@ -1829,6 +1836,13 @@ def buck_command(argv: list[str]) -> None:
checkout,
collect_project_ignores(checkout, read_project_buckconfig(checkout), [], gitdirs),
)
return binary


def buck_command(argv: list[str]) -> None:
binary = setup_buck(argv, ["buck", *argv])
if binary is None:
return
# BUCK2_BINARY lets a tool nest a Buck2 command without refreshing config under its build.
environment = {"BUCK2_ARG0": "tine buck", "BUCK2_BINARY": str(binary)}
try:
Expand All @@ -1837,6 +1851,47 @@ def buck_command(argv: list[str]) -> None:
fail(f"cannot run {binary}: {error}")


def parse_exec_command(argv: list[str]) -> tuple[str, list[str], list[str]]:
"""Split `tine exec` arguments into the target, Buck options, and the command's own arguments."""
if not argv or argv[0].startswith("-"):
fail("usage: tine exec <target> [buck options] [-- arguments]")
target, *options = argv
arguments: list[str] = []
if "--" in options:
boundary = options.index("--")
options, arguments = options[:boundary], options[boundary + 1 :]
return target, options, arguments


def exec_command(argv: list[str]) -> None:
"""Build a runnable target, then exec its command line outside of Buck.

`--emit-shell` yields the argv that `buck run` would exec, so the command runs the same, but
from a plain process: it owns its pid for signals and runs independently of the Buck daemon.
The environment differs: `buck run` additions (BUCK_RUN_BUILD_ID) and tine's nested-Buck
variables are not set, so a Buck inside the command is its own top-level invocation.
"""
import shlex
import subprocess

target, options, arguments = parse_exec_command(argv)
buck_argv = [*options, "-v", "0", "run", "--emit-shell", target, "--", *arguments]
binary = setup_buck(buck_argv, ["exec", *argv])
assert binary is not None # only shell completion skips the fetch
environment = os.environ | {"BUCK2_ARG0": "tine buck"}
build = subprocess.run([str(binary), *buck_argv], stdout=subprocess.PIPE, text=True, env=environment)
if build.returncode != 0:
# Buck reported the failure on the inherited stderr.
raise SystemExit(build.returncode)
command = shlex.split(build.stdout)
if not command:
fail(f"{target} resolved to an empty command")
try:
os.execvpe(command[0], command, dict(os.environ))
except OSError as error:
fail(f"cannot run {command[0]}: {error}")


def completion_command(arguments: list[str]) -> None:
"""Print the pinned Buck completion script in the configured namespace."""
if len(arguments) != 1 or arguments[0] not in SHELLS:
Expand Down Expand Up @@ -1900,7 +1955,9 @@ def main(argv: list[str]) -> None:
if name == "buck":
buck_command(rest)
elif name == "box":
buck_command(["-v", "0", "run", "//:box", *rest])
exec_command(["//:box", *rest])
elif name == "exec":
exec_command(rest)
elif name == "mount":
mount_command(project_root(cwd()), rest)
elif name == "cache-status":
Expand Down
92 changes: 89 additions & 3 deletions bin/tine_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2178,6 +2178,92 @@ def test_a_run_with_a_home_keeps_it(self) -> None:
"""


class TestParseExecCommand(unittest.TestCase):
"""The target comes first, so Buck options never need disambiguating from it."""

def test_bare_target(self) -> None:
self.assertEqual(tine.parse_exec_command(["//:box"]), ("//:box", [], []))

def test_options_and_arguments(self) -> None:
self.assertEqual(
tine.parse_exec_command(["//t:service", "--no-buckd", "--", "--flag", "argument"]),
("//t:service", ["--no-buckd"], ["--flag", "argument"]),
)

def test_separator_without_arguments(self) -> None:
self.assertEqual(tine.parse_exec_command(["//t:service", "--"]), ("//t:service", [], []))

def test_rejects_a_leading_option(self) -> None:
with self.assertRaises(SystemExit):
tine.parse_exec_command(["--no-buckd", "//t:service"])

def test_rejects_no_target(self) -> None:
with self.assertRaises(SystemExit):
tine.parse_exec_command([])


class TestExecCommand(unittest.TestCase):
"""`tine exec` resolves a target's run command through Buck and execs it outside the daemon."""

@override
def setUp(self) -> None:
self.root = scratch(self)
(self.root / ".buckconfig").write_text("[cells]\nroot = .\n")
self.addCleanup(os.chdir, Path.cwd())
os.chdir(self.root)
self.binary = self.root / "buck2"
self.binary.write_text("")

@contextlib.contextmanager
def running(self, emitted: str, returncode: int = 0) -> collections.abc.Iterator[dict[str, object]]:
outcome: dict[str, object] = {}

def resolve(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
outcome.update(build=argv)
return subprocess.CompletedProcess(argv, returncode=returncode, stdout=emitted)

with contextlib.ExitStack() as patches:
patch = unittest.mock.patch.object
patches.enter_context(patch(tine, "buck2_binary", return_value=self.binary))
patches.enter_context(patch(tine, "kill_daemon", side_effect=AssertionError))
patches.enter_context(patch(subprocess, "run", side_effect=resolve))
patches.enter_context(
patch(
os,
"execvpe",
side_effect=lambda file, argv, _env: outcome.update(file=file, argv=argv),
)
)
yield outcome

def test_execs_the_emitted_command(self) -> None:
with self.running("python3 -B sandbox.py --relaxed -- serve 'a dir'\n") as outcome:
tine.exec_command(["//:service", "--", "argument"])
self.assertEqual(
outcome["build"],
[str(self.binary), "-v", "0", "run", "--emit-shell", "//:service", "--", "argument"],
)
self.assertEqual(
outcome["argv"], ["python3", "-B", "sandbox.py", "--relaxed", "--", "serve", "a dir"]
)
self.assertEqual(outcome["file"], "python3")

def test_buck_options_precede_the_run(self) -> None:
with self.running("command\n") as outcome:
tine.exec_command(["//:service", "--isolation-dir", "alternate"])
build = cast(list[str], outcome["build"])
self.assertEqual(build[1:3], ["--isolation-dir", "alternate"])

def test_a_failed_build_propagates_its_exit_code(self) -> None:
with self.assertRaises(SystemExit) as caught, self.running("", returncode=3):
tine.exec_command(["//:service"])
self.assertEqual(caught.exception.code, 3)

def test_an_empty_command_is_an_error(self) -> None:
with self.assertRaises(SystemExit), self.running("\n"):
tine.exec_command(["//:service"])


class TestRenderProjectBuckconfig(unittest.TestCase):
"""What a project's configuration takes from the cell's own, and what it decides for itself."""

Expand Down Expand Up @@ -3143,10 +3229,10 @@ def test_asking_a_verb_for_help_does_not_do_its_job(self) -> None:
tine.main(["init", "--help"])
self.assertIn("usage: tine init", printed.getvalue())

def test_box_quietly_runs_the_root_box_target(self) -> None:
with unittest.mock.patch.object(tine, "buck_command") as buck:
def test_box_runs_the_root_box_target_through_exec(self) -> None:
with unittest.mock.patch.object(tine, "exec_command") as execed:
tine.main(["box", "--", "pytest", "-q"])
buck.assert_called_once_with(["-v", "0", "run", "//:box", "--", "pytest", "-q"])
execed.assert_called_once_with(["//:box", "--", "pytest", "-q"])

def test_no_such_command(self) -> None:
with self.assertRaisesRegex(SystemExit, "no such command: nope"):
Expand Down
2 changes: 1 addition & 1 deletion tools/ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ secureboot_pkcs11() {
"${buck[@]}" build tine//tools:swtpm-signing.box

pkcs11_dir=$(mktemp -d)
"${buck[@]}" run tine//tools:signing-server -- "$pkcs11_dir" > "$pkcs11_dir/log" 2>&1 &
bin/tine exec tine//tools:signing-server -- "$pkcs11_dir" > "$pkcs11_dir/log" 2>&1 &
server_pid=$!
trap pkcs11_cleanup EXIT
until [ -S "$pkcs11_dir/sock/pkcs11" ]; do
Expand Down
Loading