From 9f30506b2c217b32353b40483937946c80d74acc Mon Sep 17 00:00:00 2001 From: Nathan Williams Date: Fri, 3 Jul 2026 15:27:10 +0100 Subject: [PATCH 1/2] Add app type hint to cli shell function --- src/buildstream/_frontend/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/buildstream/_frontend/cli.py b/src/buildstream/_frontend/cli.py index e9e6ddfb9..e6f1d15a2 100644 --- a/src/buildstream/_frontend/cli.py +++ b/src/buildstream/_frontend/cli.py @@ -20,6 +20,7 @@ import shutil import click from .. import _yaml +from .._frontend.app import App from .._exceptions import BstError, LoadError, AppError, RemoteError from .complete import main_bashcomplete, complete_path, CompleteUnhandled from ..types import _CacheBuildTrees, _SchedulerErrorAction, _PipelineSelection, _HostMount, _Scope @@ -723,7 +724,7 @@ def show(app, elements, deps, except_, order, format_): @click.argument("command", type=click.STRING, nargs=-1) @click.pass_obj def shell( - app, + app: App, target, command, mount, From 88a34196011335257d91b2d77419423d158bb9cc Mon Sep 17 00:00:00 2001 From: Nathan Williams Date: Fri, 3 Jul 2026 15:34:10 +0100 Subject: [PATCH 2/2] Shell API and CLI: Add option for staging additional runtime targets This enables users to add additional functionality such as debug tooling in the shell sandbox, without needing to modify the target element. This is achived through introducing a new option to shell. All existing API and UX is maintained, to not break existing scripts. An alternative design was considered, to have the additional elements as positional arguments similar to the existing element, but this would need manual parsing to handle the cases where `--` is present and not present, splitting based on a `.bst` suffix. This UX could be re-visited in future. Example usage: bst shell --with base.bst example.bst -- cat example.txt Where: - example.bst is a simple import element with no dependencies that imports a file called example.txt - base.bst provides a basic alpine sysroot with a standard set of unix tooling (sh, df, cat etc). Changes: - Introduces `test_with_other_targets` integration test to the shell test suite. - Adds `--with` cli option to the shell subcommand and updates it's documentation. - option can be used multiple times by caller, providing a list of targets. - Extends the shell top level calling interface in Buildstream core to accept a list of other_targets - This is where the targets are loaded into elements and checked to make sure they are present - Extends the shell element implementation to accept a list of other targets - This is where the other elements are staged and integrated into the sandbox --- src/buildstream/_frontend/cli.py | 36 +++++++++++++++++++++++++++++- src/buildstream/_stream.py | 38 ++++++++++++++++++++++++++++++-- src/buildstream/element.py | 24 ++++++++++++++++++-- tests/integration/shell.py | 23 ++++++++++++++++++- 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/buildstream/_frontend/cli.py b/src/buildstream/_frontend/cli.py index e6f1d15a2..b03c60116 100644 --- a/src/buildstream/_frontend/cli.py +++ b/src/buildstream/_frontend/cli.py @@ -687,6 +687,13 @@ def show(app, elements, deps, except_, order, format_): metavar="HOSTPATH PATH", help="Mount a file or directory into the sandbox", ) +@click.option( + "--with", + "other_targets", + type=click.Path(readable=False), + multiple=True, + help="A additional target to stage into an element's sandbox environment", +) @click.option("--isolate", is_flag=True, help="Create an isolated build sandbox") @click.option( "--use-buildtree", @@ -726,6 +733,7 @@ def show(app, elements, deps, except_, order, format_): def shell( app: App, target, + other_targets, command, mount, isolate, @@ -749,13 +757,38 @@ def shell( otherwise bst may respond to them instead. e.g. \b - bst shell example.bst -- df -h + bst shell base.bst -- df -h Use the --build option to create a temporary sysroot for building the element instead. + Use the --with option to stage the artifacts of other elements + into the temporary sysroot to make them available to run e.g. + + \b + bst shell --with base.bst example.bst -- cat example.txt + If no COMMAND is specified, the default is to attempt to run an interactive shell. + + # Examples: + + For all examples on this page: + - example.bst is a simple import element with no dependencies + that imports a file called example.txt + - base.bst provides a basic alpine sysroot with a standard set of unix tooling (sh, df, cat etc). + + \b + # Attempt to run an interactive shell with example.bst + bst shell example.bst + # Attempt to run an df -h with example.bst + bst shell example.bst -- df h + # In a workspace directory, attempt to shell into the workspace element + bst shell + # Attempt to run cat from base.bst to read example.txt from example.bst + bst shell --with base.bst example.bst -- cat example.txt + # Attempt to run an interactive shell with the sources and all dependencies of example.bst + bst shell --build example.bst """ # Buildtree can only be used with build shells @@ -777,6 +810,7 @@ def shell( target, scope, app.shell_prompt, + other_targets=other_targets, mounts=mounts, isolate=isolate, command=command, diff --git a/src/buildstream/_stream.py b/src/buildstream/_stream.py index 913bc3f17..32ad5d0e6 100644 --- a/src/buildstream/_stream.py +++ b/src/buildstream/_stream.py @@ -243,6 +243,7 @@ def query_cache(self, elements, *, sources_of_cached_elements=False, only_source # target: The name of the element to run the shell for # scope: The scope for the shell, only BUILD or RUN are valid (_Scope) # prompt: A function to return the prompt to display in the shell + # other_targets (Iterable[str]): The name of other elements to stage in the shell # unique_id: (str): A unique_id to use to lookup an Element instance # mounts: Additional directories to mount into the sandbox # isolate (bool): Whether to isolate the environment like we do in builds @@ -259,9 +260,10 @@ def query_cache(self, elements, *, sources_of_cached_elements=False, only_source def shell( self, target: str, - scope: int, + scope: _Scope, prompt: Callable[[Element], str], *, + other_targets: Iterable[str] = (), unique_id: Optional[str] = None, mounts: Optional[List[_HostMount]] = None, isolate: bool = False, @@ -357,8 +359,40 @@ def shell( self._fetch([element]) _pipeline.assert_sources_cached(self._context, [element]) + # Load the other targets + try: + other_elements = self.load_selection( + other_targets, + selection=_PipelineSelection.RUN, + load_artifacts=True, + connect_artifact_cache=True, + connect_source_cache=True, + artifact_remotes=artifact_remotes, + source_remotes=source_remotes, + ignore_project_artifact_remotes=ignore_project_artifact_remotes, + ignore_project_source_remotes=ignore_project_source_remotes, + ) + except StreamError as e: + if e.reason == "deps-not-supported": + raise StreamError( + "Only buildtrees are supported with artifact names", + detail="Use the --build and --use-buildtree options to shell into a cached build tree", + reason="only-buildtrees-supported", + ) from e + raise + + self.query_cache(other_elements) + self._pull_missing_artifacts(other_elements) + missing_deps = [dep for dep in _pipeline.dependencies(other_elements, _Scope.RUN) if not dep._cached()] + if missing_deps: + raise StreamError( + "Elements need to be built or downloaded before staging a shell environment", + detail="\n".join(list(map(lambda x: x._get_full_name(), missing_deps))), + reason="shell-missing-deps", + ) + return element._shell( - scope, mounts=mounts, isolate=isolate, prompt=prompt(element), command=command, usebuildtree=usebuildtree + scope, mounts=mounts, isolate=isolate, prompt=prompt(element), command=command, usebuildtree=usebuildtree, other_elements=other_elements ) # build() diff --git a/src/buildstream/element.py b/src/buildstream/element.py index 6ac1ff1eb..d523a5aa5 100644 --- a/src/buildstream/element.py +++ b/src/buildstream/element.py @@ -72,7 +72,7 @@ from itertools import chain import string from threading import Lock -from typing import cast, TYPE_CHECKING, Dict, Iterator, Iterable, List, Optional, Set, Sequence +from typing import Self, cast, TYPE_CHECKING, Dict, Iterator, Iterable, List, Optional, Set, Sequence, Tuple from pyroaring import BitMap # pylint: disable=no-name-in-module @@ -2058,9 +2058,19 @@ def _push(self): # prompt (str): A suitable prompt string for PS1 # command (list): An argv to launch in the sandbox # usebuildtree (bool): Use the buildtree as its source + # other_elements (List[Element]): Optional list of other runtime elements to stage in the sandbox # # Returns: Exit code - def _shell(self, scope=None, *, mounts=None, isolate=False, prompt=None, command=None, usebuildtree=False): + def _shell(self, + scope:_Scope=None, + *, + mounts:List[Tuple[str,str]]=None, + isolate:bool=False, + prompt:str=None, + command:List[str]=None, + usebuildtree:bool=False, + other_elements:List[Self]=None + ): with self._prepare_sandbox(scope, shell=True, usebuildtree=usebuildtree) as sandbox: environment = sandbox._get_configured_environment() or self.get_environment() @@ -2076,6 +2086,16 @@ def _shell(self, scope=None, *, mounts=None, isolate=False, prompt=None, command if prompt is not None: environment["PS1"] = prompt + with self.timed_activity("Staging other_targets", silent_nested=True), self.__collect_overlaps(sandbox): + self.stage_dependency_artifacts(sandbox,other_elements) + + # Stage artifacts from other_elements into the sandbox. + for element in other_elements: + # Stage deps in the sandbox root + with element.timed_activity("Integrating sandbox"), sandbox.batch(): + for dep in element._dependencies(_Scope.RUN): + dep.integrate(sandbox) + # Special configurations for non-isolated sandboxes if not isolate: diff --git a/tests/integration/shell.py b/tests/integration/shell.py index 63c7af8b3..4c86cc8c4 100644 --- a/tests/integration/shell.py +++ b/tests/integration/shell.py @@ -46,11 +46,15 @@ # mount (tuple): A (host, target) tuple for the `--mount` option # element (str): The element to build and run a shell with # isolate (bool): Whether to pass --isolate to `bst shell` +# other_elements (list(str)): Other elements to stage in the sandbox # -def execute_shell(cli, project, command, *, config=None, mount=None, element="base.bst", isolate=False): +def execute_shell(cli, project, command, *, config=None, mount=None, element="base.bst", isolate=False,other_elements=[]): # Ensure the element is built result = cli.run_project_config(project=project, project_config=config, args=["build", element]) assert result.exit_code == 0 + for other_element in other_elements: + result = cli.run_project_config(project=project, project_config=config, args=["build", other_element]) + assert result.exit_code == 0 args = ["shell"] if isolate: @@ -58,6 +62,8 @@ def execute_shell(cli, project, command, *, config=None, mount=None, element="ba if mount is not None: host_path, target_path = mount args += ["--mount", host_path, target_path] + for other_element in other_elements: + args += ["--with", other_element] args += [element, "--", *command] return cli.run_project_config(project=project, project_config=config, args=args) @@ -85,6 +91,21 @@ def test_executable(cli, datafiles): assert result.exit_code == 0 assert result.output == "Horseys!\n" +# Test staging and running additional targets in the shell of the main target for debugging. +@pytest.mark.datafiles(DATA_DIR) +@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox") +def test_with_other_targets(cli, datafiles): + project = str(datafiles) + + # Show we can't cat in a shell for manual/import-file.bst + result = execute_shell(cli, project, ["/bin/cat", "test.txt"],element="manual/import-file.bst") + assert result.exit_code == 1, "Shouldn't be able to read content of test.txt as manual/import-file.bst is a simple import element with no dependencies" + + # Show we can now cat with base.bst in a shell for manual/import-file.bst + result = execute_shell(cli, project, ["/bin/cat", "test.txt"],element="manual/import-file.bst",other_elements=["base.bst"]) + assert result.exit_code == 0, "Should be able to read content of test.txt as we now stage in base.bst that provides /bin/cat" + assert result.output == "This is a test\n" + # Test shell environment variable explicit assignments @pytest.mark.parametrize("animal", [("Horse"), ("Pony")])