diff --git a/README.md b/README.md index 87c63631..f2eca011 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Buck reads the checkout in place, so a branch or an uncommitted edit is active i ```sh tine buck # run the pinned Buck2, passing verbatim tine box # run the project's root box target +tine exec # run a target outside of the Buck daemon tine mount # manage external directories mounted over project paths tine cache-status # report on the shim serving the shared build cache tine init [] # write the configuration a project needs, for the checkout this command is in @@ -130,6 +131,9 @@ tine completion # 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: diff --git a/bin/tine.py b/bin/tine.py index 984e2515..09b174bc 100644 --- a/bin/tine.py +++ b/bin/tine.py @@ -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", @@ -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) @@ -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 @@ -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: @@ -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 [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: @@ -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": diff --git a/bin/tine_test.py b/bin/tine_test.py index 78f02a1a..090cbbf8 100644 --- a/bin/tine_test.py +++ b/bin/tine_test.py @@ -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.""" @@ -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"): diff --git a/tools/ci.sh b/tools/ci.sh index ef5c001c..b0c88367 100755 --- a/tools/ci.sh +++ b/tools/ci.sh @@ -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