diff --git a/craft_parts/ctl.py b/craft_parts/ctl.py index dcd12fc09..982fd08f4 100644 --- a/craft_parts/ctl.py +++ b/craft_parts/ctl.py @@ -40,7 +40,7 @@ def run(cls, cmd: str, args: list[str]) -> str | None: :raises RuntimeError: If the command is not handled. """ - if cmd in ["default", "set"]: + if cmd in ["default", "set", "chroot"]: _client(cmd, args) return None diff --git a/craft_parts/executor/step_handler.py b/craft_parts/executor/step_handler.py index 56ab60951..d7ab5ad68 100644 --- a/craft_parts/executor/step_handler.py +++ b/craft_parts/executor/step_handler.py @@ -378,6 +378,52 @@ def _process_api_commands( message=f"invalid arguments to command {cmd_name!r}", ) self._execute_builtin_handler(step) + elif cmd_name == "chroot": + if self._step_info.step != step.OVERLAY: + raise invalid_control_api_call( + message=f"{cmd_name!r} can only run in overlay step", + ) + if len(cmd_args) < 1: + raise invalid_control_api_call( + message=( + f"invalid arguments to command {cmd_name!r} (want at least 1 argument)" + ), + ) + + try: + target_dir = self._part.part_layer_dir + commands = [ + f"mkdir -p {target_dir}/etc {target_dir}/dev {target_dir}/sys {target_dir}/proc {target_dir}/usr/share/ca-certificates {target_dir}/etc/apt {target_dir}/etc/ssl/certs", + f"cp /etc/resolv.conf {target_dir}/etc/", + f"mount --bind /dev {target_dir}/dev", + f"mount --bind /sys {target_dir}/sys", + f"mount --bind /proc {target_dir}/proc", + f"mount --bind /usr/share/ca-certificates/ {target_dir}/usr/share/ca-certificates", + f"mount --bind /etc/ssl/certs/ {target_dir}/etc/ssl/certs/", + f"mount --bind /etc/apt {target_dir}/etc/apt", + f"cp /etc/ca-certificates.conf {target_dir}/etc/", + " ".join(cmd_args), + f"umount {target_dir}/dev", + f"umount {target_dir}/sys", + f"umount {target_dir}/proc", + f"umount {target_dir}/etc/apt", + f"umount {target_dir}/usr/share/ca-certificates", + f"umount {target_dir}/etc/ssl/certs", + ] + _create_and_run_script( + commands, + script_path=target_dir.absolute() / "overlay-chroot.sh", + cwd=target_dir, + stdout=self._stdout, + stderr=self._stderr, + with_root_access=True, + ) + except (ValueError, RuntimeError) as err: + raise errors.InvalidControlAPICall( + part_name=self._part.name, + scriptlet_name=scriptlet_name, + message=str(err), + ) from err elif cmd_name == "set": if len(cmd_args) != 1: raise invalid_control_api_call( @@ -443,6 +489,8 @@ def _create_and_run_script( stdout: Stream, stderr: Stream, build_environment_script_path: Path | None = None, + *, + with_root_access: bool = False, ) -> None: """Create a script with step-specific commands and execute it.""" with script_path.open("w") as run_file: @@ -460,4 +508,9 @@ def _create_and_run_script( script_path.chmod(0o755) logger.debug("Executing %r", script_path) - process.run([script_path], cwd=cwd, stdout=stdout, stderr=stderr, check=True) + if with_root_access and Path("/usr/bin/sudo").exists(): + process.run( + ["sudo", script_path], cwd=cwd, stdout=stdout, stderr=stderr, check=True + ) + else: + process.run([script_path], cwd=cwd, stdout=stdout, stderr=stderr, check=True) diff --git a/tests/integration/lifecycle/test_craftctl.py b/tests/integration/lifecycle/test_craftctl.py index 415cfdced..c8595d637 100644 --- a/tests/integration/lifecycle/test_craftctl.py +++ b/tests/integration/lifecycle/test_craftctl.py @@ -51,6 +51,65 @@ def setup_fixture(new_dir, mocker): mocker.patch("craft_parts.utils.os_utils.umount") +def test_craftctl_chroot(new_dir, partitions, capfd, mocker): + mocker.patch("craft_parts.lifecycle_manager._ensure_overlay_supported") + mocker.patch("craft_parts.overlays.OverlayManager.refresh_packages_list") + + parts_yaml = textwrap.dedent( + """\ + parts: + foo: + plugin: dump + source: foo + override-pull: | + echo "pull step" + craftctl default + overlay-script: | + echo "overlay step" + craftctl chroot touch test.txt + """ + ) + parts = yaml.safe_load(parts_yaml) + + Path("foo").mkdir() + Path("foo/foo.txt").touch() + + lf = craft_parts.LifecycleManager( + parts, + application_name="test_ctl", + cache_dir=new_dir, + base_layer_dir=new_dir, + base_layer_hash=b"hash", + partitions=partitions, + ) + + # Check if planning resulted in the correct list of actions. + actions = lf.plan(Step.OVERLAY) + assert actions == [ + Action("foo", Step.PULL), + Action("foo", Step.OVERLAY), + ] + + # Execute each step and verify if scriptlet and built-in handler + # ran as expected. + + with lf.action_executor() as ctx: + # Execute the pull step. The source file must have been + # copied to the part src directory. + ctx.execute(actions[0]) + captured = capfd.readouterr() + assert captured.out == "pull step\n" + + # Execute the overlay step and add a file to the overlay + # directory to track file migration. + ctx.execute(actions[1]) + captured = capfd.readouterr() + assert Path("parts/foo/layer/test.txt").exists() is True + assert Path("parts/foo/layer/etc/resolv.conf").exists() is True + assert captured.out == "overlay step\n" + assert Path("overlay") + + def test_craftctl_default(new_dir, partitions, capfd, mocker): mocker.patch("craft_parts.lifecycle_manager._ensure_overlay_supported") mocker.patch("craft_parts.overlays.OverlayManager.refresh_packages_list")