diff --git a/craft_application/application.py b/craft_application/application.py index b41d8d9b..62d6e8e5 100644 --- a/craft_application/application.py +++ b/craft_application/application.py @@ -16,6 +16,7 @@ """Main application classes for a craft-application.""" from __future__ import annotations +import argparse import functools import os import pathlib @@ -30,7 +31,8 @@ import craft_providers from xdg.BaseDirectory import save_cache_path # type: ignore[import] -from craft_application import commands, models +from craft_application import commands, models, util +from craft_application.models import BuildInfo if TYPE_CHECKING: from craft_application.services import service_factory @@ -40,6 +42,15 @@ ) +class _Dispatcher(craft_cli.Dispatcher): + """Application command dispatcher.""" + + @property + def parsed_args(self) -> argparse.Namespace: + """The map of parsed command-line arguments.""" + return self._parsed_command_args or argparse.Namespace() + + @final @dataclass(frozen=True) class AppMetadata: @@ -119,7 +130,7 @@ def cache_dir(self) -> str: # xdg types: https://github.com/python/typeshed/pull/10163 return save_cache_path(self.app.name) # type: ignore[no-any-return] - def _configure_services(self) -> None: + def _configure_services(self, build_for: str | None) -> None: """Configure additional keyword arguments for any service classes. Any child classes that override this must either call this directly or must @@ -129,35 +140,52 @@ def _configure_services(self) -> None: "lifecycle", cache_dir=self.cache_dir, work_dir=self._work_dir, + build_for=build_for, ) @functools.cached_property def project(self) -> models.Project: """Get this application's Project metadata.""" project_file = (self._work_dir / f"{self.app.name}.yaml").resolve() + craft_cli.emit.debug(f"Loading project file '{project_file!s}'") return self.app.ProjectClass.from_yaml_file(project_file) - def run_managed(self) -> None: + def run_managed(self, build_for: str | None) -> None: """Run the application in a managed instance.""" - craft_cli.emit.debug(f"Running {self.app.name} in a managed instance...") - instance_path = pathlib.PosixPath("/root/project") - with self.services.provider.instance( - self.project.effective_base, work_dir=self._work_dir - ) as instance: - try: - # Pyright doesn't fully understand craft_providers's CompletedProcess. - instance.execute_run( # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] - [self.app.name, *sys.argv[1:]], cwd=instance_path, check=True - ) - except subprocess.CalledProcessError as exc: - raise craft_providers.ProviderError( - f"Failed to execute {self.app.name} in instance." - ) from exc + extra_args: dict[str, Any] = {} + + build_plan = self.project.get_build_plan() + build_plan = _filter_plan(build_plan, build_for) + + if build_for: + extra_args["env"] = {"CRAFT_BUILD_FOR": build_for} + + for build_info in build_plan: + craft_cli.emit.debug( + f"Running {self.app.name} in {build_info.build_for} instance..." + ) + instance_path = pathlib.PosixPath("/root/project") + + with self.services.provider.instance( + build_info, work_dir=self._work_dir + ) as instance: + try: + # Pyright doesn't fully understand craft_providers's CompletedProcess. + instance.execute_run( # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] + [self.app.name, *sys.argv[1:]], + cwd=instance_path, + check=True, + **extra_args, + ) + except subprocess.CalledProcessError as exc: + raise craft_providers.ProviderError( + f"Failed to execute {self.app.name} in instance." + ) from exc def configure(self, global_args: dict[str, Any]) -> None: """Configure the application using any global arguments.""" - def _get_dispatcher(self) -> craft_cli.Dispatcher: + def _get_dispatcher(self) -> _Dispatcher: """Configure this application. Should be called by the run method. Side-effect: This method may exit the process. @@ -171,7 +199,7 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher: log_filepath=self.log_path, ) - dispatcher = craft_cli.Dispatcher( + dispatcher = _Dispatcher( self.app.name, self.command_groups, summary=str(self.app.summary), @@ -223,7 +251,6 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher: def run(self) -> int: """Bootstrap and run the application.""" dispatcher = self._get_dispatcher() - self._configure_services() craft_cli.emit.trace("Preparing application...") return_code = 1 # General error @@ -237,14 +264,19 @@ def run(self) -> int: } ), ) + build_for = getattr(dispatcher.parsed_args, "build_for", None) + self._configure_services(build_for) + if not command.run_managed: # command runs in the outer instance craft_cli.emit.debug(f"Running {self.app.name} {command.name} on host") + if command.always_load_project: + self.services.project = self.project return_code = dispatcher.run() or 0 elif not self.services.ProviderClass.is_managed(): # command runs in inner instance, but this is the outer instance self.services.project = self.project - self.run_managed() + self.run_managed(build_for) return_code = 0 else: # command runs in inner instance @@ -280,3 +312,18 @@ def _emit_error( error.logpath_report = False craft_cli.emit.error(error) + + +def _filter_plan(build_plan: list[BuildInfo], build_for: str | None) -> list[BuildInfo]: + """Filter out builds not matching build-on and build-for.""" + host_arch = util.get_host_architecture() + + plan: list[BuildInfo] = [] + for build_info in build_plan: + build_on_matches = build_info.build_on == host_arch + build_for_matches = not build_for or build_info.build_for == build_for + + if build_on_matches and build_for_matches: + plan.append(build_info) + + return plan diff --git a/craft_application/commands/base.py b/craft_application/commands/base.py index ec43ae48..af0e484e 100644 --- a/craft_application/commands/base.py +++ b/craft_application/commands/base.py @@ -33,6 +33,9 @@ class AppCommand(BaseCommand): run_managed: bool = False """Whether this command should run in managed mode.""" + always_load_project: bool = False + """The project is also loaded in non-managed mode.""" + def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) self._app: application.AppMetadata = config["app"] diff --git a/craft_application/commands/lifecycle.py b/craft_application/commands/lifecycle.py index fd3e3dc1..035eb962 100644 --- a/craft_application/commands/lifecycle.py +++ b/craft_application/commands/lifecycle.py @@ -14,6 +14,7 @@ """Basic lifecycle commands for a Craft Application.""" from __future__ import annotations +import os import pathlib import textwrap from typing import TYPE_CHECKING @@ -96,6 +97,14 @@ def fill_parser(self, parser: argparse.ArgumentParser) -> None: help="Shell into the environment after the step has run.", ) + parser.add_argument( + "--build-for", + type=str, + metavar="arch", + default=os.getenv("CRAFT_BUILD_FOR"), + help="Set architecture to build for", + ) + @override def get_managed_cmd(self, parsed_args: argparse.Namespace) -> list[str]: """Get the command to run in managed mode. @@ -119,10 +128,11 @@ def run( ) -> None: """Run a lifecycle step command.""" super().run(parsed_args) - step_name = step_name or self.name - - self._services.lifecycle.run(step_name=step_name, part_names=parsed_args.parts) + self._services.lifecycle.run( + step_name=step_name, + part_names=parsed_args.parts, + ) class PullCommand(_LifecycleStepCommand): @@ -259,6 +269,7 @@ class CleanCommand(_LifecyclePartsCommand): remove the packing environment. """ ) + always_load_project = True @override def run(self, parsed_args: argparse.Namespace) -> None: diff --git a/craft_application/models/__init__.py b/craft_application/models/__init__.py index cd0ed48e..296f3358 100644 --- a/craft_application/models/__init__.py +++ b/craft_application/models/__init__.py @@ -24,11 +24,12 @@ VersionStr, ) from craft_application.models.metadata import BaseMetadata -from craft_application.models.project import Project +from craft_application.models.project import BuildInfo, Project __all__ = [ "BaseMetadata", + "BuildInfo", "CraftBaseConfig", "CraftBaseModel", "Project", diff --git a/craft_application/models/project.py b/craft_application/models/project.py index 68e35c03..abbf0baa 100644 --- a/craft_application/models/project.py +++ b/craft_application/models/project.py @@ -17,9 +17,11 @@ This defines the structure of the input file (e.g. snapcraft.yaml) """ -from typing import Any, Dict, Optional, Union +import dataclasses +from typing import Any, Dict, List, Optional, Union import craft_parts +import craft_providers.bases import pydantic from pydantic import AnyUrl @@ -33,6 +35,15 @@ ) +@dataclasses.dataclass +class BuildInfo: + """Platform build information.""" + + build_on: str + build_for: str + base: craft_providers.bases.BaseName + + class Project(CraftBaseModel): """Craft Application project definition.""" @@ -67,3 +78,9 @@ def effective_base(self) -> Any: # noqa: ANN401 app specific classes can improv if self.base is not None: return self.base raise RuntimeError("Could not determine effective base") + + def get_build_plan(self) -> List[BuildInfo]: + """Obtain the list of architectures and bases from the project file.""" + raise NotImplementedError( + f"{self.__class__.__name__!s} must implement get_build_plan" + ) diff --git a/craft_application/services/lifecycle.py b/craft_application/services/lifecycle.py index 708ef4a3..be53ff75 100644 --- a/craft_application/services/lifecycle.py +++ b/craft_application/services/lifecycle.py @@ -24,6 +24,7 @@ from craft_application import errors from craft_application.services import base +from craft_application.util import convert_architecture_deb_to_platform if TYPE_CHECKING: # pragma: no cover from pathlib import Path @@ -102,22 +103,26 @@ class LifecycleService(base.BaseService): :param app: An AppMetadata object containing metadata about the application. :param project: The Project object that describes this project. :param work_dir: The working directory for parts processing. + :param cache_dir: The cache directory for parts processing. + :param build_for: The architecture or platform we are building for. :param lifecycle_kwargs: Additional keyword arguments are passed through to the LifecycleManager on initialisation. """ - def __init__( + def __init__( # noqa: PLR0913 (too many arguments) self, app: AppMetadata, project: Project, *, work_dir: Path | str, cache_dir: Path | str, + build_for: str, **lifecycle_kwargs: Any, # noqa: ANN401 - eventually used in an Any ) -> None: super().__init__(app, project) self._work_dir = work_dir self._cache_dir = cache_dir + self._build_for = build_for self._manager_kwargs = lifecycle_kwargs self._lcm = self._init_lifecycle_manager() @@ -133,6 +138,7 @@ def _init_lifecycle_manager(self) -> LifecycleManager: return LifecycleManager( {"parts": self._project.parts}, application_name=self._app.name, + arch=convert_architecture_deb_to_platform(self._build_for), cache_dir=self._cache_dir, work_dir=self._work_dir, ignore_local_sources=self._app.source_ignore_patterns, @@ -183,7 +189,8 @@ def clean(self, part_names: list[str] | None = None) -> None: def __repr__(self) -> str: work_dir = self._work_dir cache_dir = self._cache_dir + build_for = self._build_for return ( f"{self.__class__.__name__}({self._app!r}, {self._project!r}, " - f"{work_dir=}, {cache_dir=}, **{self._manager_kwargs!r})" + f"{work_dir=}, {cache_dir=}, {build_for=}, **{self._manager_kwargs!r})" ) diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py index 51d51166..264f1203 100644 --- a/craft_application/services/provider.py +++ b/craft_application/services/provider.py @@ -71,7 +71,7 @@ def is_managed(cls) -> bool: @contextlib.contextmanager def instance( self, - base_name: bases.BaseName | tuple[str, str], + build_info: models.BuildInfo, *, work_dir: pathlib.Path, allow_unstable: bool = True, @@ -84,9 +84,13 @@ def instance( :param allow_unstable: Whether to allow the use of unstable images. :returns: a context manager of the provider instance. """ - emit.debug("Preparing managed instance") work_dir_inode = work_dir.stat().st_ino - instance_name = f"{self._app.name}-{self._project.name}-{work_dir_inode}" + instance_name = ( + f"{self._app.name}-{self._project.name}-on-{build_info.build_on}-" + f"for-{build_info.build_for}-{work_dir_inode}" + ) + emit.debug("Preparing managed instance {instance_name!r}") + base_name = build_info.base base = self.get_base(base_name, instance_name=instance_name, **kwargs) provider = self.get_provider() diff --git a/craft_application/util/__init__.py b/craft_application/util/__init__.py index 9b709a09..799615bf 100644 --- a/craft_application/util/__init__.py +++ b/craft_application/util/__init__.py @@ -16,7 +16,13 @@ """Utilities for craft-application.""" from craft_application.util.yaml import safe_yaml_load +from craft_application.util.platforms import ( + get_host_architecture, + convert_architecture_deb_to_platform, +) __all__ = [ "safe_yaml_load", + "get_host_architecture", + "convert_architecture_deb_to_platform", ] diff --git a/craft_application/util/platforms.py b/craft_application/util/platforms.py new file mode 100644 index 00000000..4b793e7f --- /dev/null +++ b/craft_application/util/platforms.py @@ -0,0 +1,64 @@ +# This file is part of craft_application. +# +# Copyright 2023 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""OS and architecture helpers for craft applications.""" +from __future__ import annotations + +import functools +import platform + + +@functools.lru_cache(maxsize=1) +def get_host_architecture() -> str: + """Get host architecture in deb format.""" + machine = platform.machine() + return _ARCH_TRANSLATIONS_PLATFORM_TO_DEB.get(machine, machine) + + +def convert_architecture_deb_to_platform(arch: str) -> str: + """Convert an architecture from deb/snap syntax to platform syntax. + + :param architecture: architecture string in debian/snap syntax + :return: architecture in platform syntax + """ + return _ARCH_TRANSLATIONS_DEB_TO_PLATFORM.get(arch, arch) + + +# architecture translations from the platform syntax to the deb/snap syntax +# These two architecture mappings are almost inverses of each other, except one map is +# not reversible (same value for different keys) +_ARCH_TRANSLATIONS_PLATFORM_TO_DEB = { + "aarch64": "arm64", + "armv7l": "armhf", + "i686": "i386", + "ppc": "powerpc", + "ppc64le": "ppc64el", + "x86_64": "amd64", + "AMD64": "amd64", # Windows support + "s390x": "s390x", + "riscv64": "riscv64", +} + +# architecture translations from the deb/snap syntax to the platform syntax +_ARCH_TRANSLATIONS_DEB_TO_PLATFORM = { + "arm64": "aarch64", + "armhf": "armv7l", + "i386": "i686", + "powerpc": "ppc", + "ppc64el": "ppc64le", + "amd64": "x86_64", + "s390x": "s390x", + "riscv64": "riscv64", +} diff --git a/tests/conftest.py b/tests/conftest.py index a2959561..b54ac301 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,13 +23,20 @@ import craft_application import craft_parts import pytest -from craft_application import application, models, services +from craft_application import application, models, services, util from craft_cli import EmitterMode, emit +from craft_providers import bases if TYPE_CHECKING: # pragma: no cover from collections.abc import Iterator +class MyProject(models.Project): + def get_build_plan(self) -> list[models.BuildInfo]: + arch = util.get_host_architecture() + return [models.BuildInfo(arch, arch, bases.BaseName("ubuntu", "22.04"))] + + @pytest.fixture() def app_metadata() -> craft_application.AppMetadata: with pytest.MonkeyPatch.context() as m: @@ -37,13 +44,14 @@ def app_metadata() -> craft_application.AppMetadata: return craft_application.AppMetadata( "testcraft", "A fake app for testing craft-application", + ProjectClass=MyProject, source_ignore_patterns=["*.snap", "*.charm", "*.starcraft"], ) @pytest.fixture() -def fake_project() -> craft_application.models.Project: - return craft_application.models.Project( +def fake_project() -> models.Project: + return MyProject( name="full-project", # pyright: ignore[reportGeneralTypeIssues] title="A fully-defined project", # pyright: ignore[reportGeneralTypeIssues] base="core24", @@ -72,12 +80,14 @@ def lifecycle_service( ) -> services.LifecycleService: work_dir = tmp_path / "work" cache_dir = tmp_path / "cache" + build_for = util.get_host_architecture() return services.LifecycleService( app_metadata, fake_project, work_dir=work_dir, cache_dir=cache_dir, + build_for=build_for, ) @@ -121,6 +131,7 @@ def __init__( project, work_dir=tmp_path / "work", cache_dir=tmp_path / "cache", + build_for=util.get_host_architecture(), **lifecycle_kwargs, ) diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py index 81bf5736..b67a41ae 100644 --- a/tests/integration/services/test_lifecycle.py +++ b/tests/integration/services/test_lifecycle.py @@ -18,6 +18,7 @@ import pytest import pytest_check from craft_application.services.lifecycle import LifecycleService +from craft_application.util import get_host_architecture @pytest.fixture( @@ -37,6 +38,7 @@ def parts_lifecycle(app_metadata, fake_project, tmp_path, request): fake_project, work_dir=tmp_path / "work", cache_dir=tmp_path / "cache", + build_for=get_host_architecture(), ) diff --git a/tests/integration/services/test_provider.py b/tests/integration/services/test_provider.py index e71b8783..8a7c660b 100644 --- a/tests/integration/services/test_provider.py +++ b/tests/integration/services/test_provider.py @@ -19,6 +19,9 @@ import craft_providers import pytest +from craft_application.models import BuildInfo +from craft_application.util import get_host_architecture +from craft_providers import bases @pytest.mark.parametrize( @@ -54,7 +57,9 @@ def test_provider_lifecycle( pytest.skip("multipass only provides ubuntu images") provider_service.get_provider(name) - instance = provider_service.instance(base_name, work_dir=snap_safe_tmp_path) + arch = get_host_architecture() + build_info = BuildInfo(arch, arch, bases.BaseName(*base_name)) + instance = provider_service.instance(build_info, work_dir=snap_safe_tmp_path) executor = None try: with instance as executor: diff --git a/tests/integration/services/test_service_factory.py b/tests/integration/services/test_service_factory.py index 8fb3a798..949608fc 100644 --- a/tests/integration/services/test_service_factory.py +++ b/tests/integration/services/test_service_factory.py @@ -47,6 +47,6 @@ def test_real_service_error(app_metadata, fake_project): with pytest.raises( TypeError, # Python 3.8 doesn't specify the LifecycleService, 3.10 does. - match=r"(LifecycleService.)?__init__\(\) missing 2 required keyword-only arguments: 'work_dir' and 'cache_dir'", + match=r"(LifecycleService.)?__init__\(\) missing 3 required keyword-only arguments: 'work_dir', 'cache_dir', and 'build_for'", ): _ = factory.lifecycle diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index 6c8b27c1..b5ec909e 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -137,3 +137,15 @@ def run(self, parsed_args: argparse.Namespace) -> None: app.add_command_group("Nothing", [NothingCommand]) app.run() + + +@pytest.mark.parametrize("cmd", ["clean", "pull", "build", "stage", "prime", "pack"]) +def test_run_always_load_project(monkeypatch, app, cmd): + """Run a lifecycle command without having a project shall fail.""" + monkeypatch.setenv("CRAFT_DEBUG", "1") + monkeypatch.setattr("sys.argv", ["testcraft", cmd]) + + with pytest.raises(FileNotFoundError) as raised: + app.run() + + assert str(raised.value).endswith("/testcraft.yaml'") is True diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py index 071e87c7..2287a33d 100644 --- a/tests/unit/commands/test_lifecycle.py +++ b/tests/unit/commands/test_lifecycle.py @@ -132,7 +132,7 @@ def test_step_command_fill_parser( ): cls = get_fake_command_class(_LifecycleStepCommand, managed=True) parser = argparse.ArgumentParser("step_command") - expected = {"parts": parts_args, **shell_dict} + expected = {"parts": parts_args, "build_for": None, **shell_dict} command = cls({"app": app_metadata, "services": fake_services}) command.fill_parser(parser) @@ -238,7 +238,12 @@ def test_pack_fill_parser( app_metadata, mock_services, parts_args, shell_args, shell_dict, output_arg ): parser = argparse.ArgumentParser("step_command") - expected = {"parts": parts_args, "output": pathlib.Path(output_arg), **shell_dict} + expected = { + "parts": parts_args, + "build_for": None, + "output": pathlib.Path(output_arg), + **shell_dict, + } command = PackCommand({"app": app_metadata, "services": mock_services}) command.fill_parser(parser) diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py index d8155094..94d1dfc6 100644 --- a/tests/unit/models/test_project.py +++ b/tests/unit/models/test_project.py @@ -101,6 +101,12 @@ def test_unmarshal_then_marshal(project_dict): assert Project.unmarshal(project_dict).marshal() == project_dict +@pytest.mark.parametrize("project", [BASIC_PROJECT, FULL_PROJECT]) +def test_build_plan_not_implemented(project): + with pytest.raises(NotImplementedError): + project.get_build_plan() + + @pytest.mark.parametrize( ("project_file", "expected"), [ diff --git a/tests/unit/services/test_lifecycle.py b/tests/unit/services/test_lifecycle.py index 0f2869eb..2ed93a86 100644 --- a/tests/unit/services/test_lifecycle.py +++ b/tests/unit/services/test_lifecycle.py @@ -23,6 +23,7 @@ import craft_parts.errors import pytest import pytest_check +from craft_application import util from craft_application.errors import PartsLifecycleError from craft_application.services import lifecycle from craft_parts import Action, ActionType, LifecycleManager, Step @@ -44,8 +45,13 @@ def _init_lifecycle_manager(self) -> LifecycleManager: def fake_parts_lifecycle(app_metadata, fake_project, tmp_path): work_dir = tmp_path / "work" cache_dir = tmp_path / "cache" + build_for = util.get_host_architecture() return FakePartsLifecycle( - app_metadata, fake_project, work_dir=work_dir, cache_dir=cache_dir + app_metadata, + fake_project, + work_dir=work_dir, + cache_dir=cache_dir, + build_for=build_for, ) @@ -167,7 +173,7 @@ def test_progress_messages(fake_parts_lifecycle, emitter): lcm = fake_parts_lifecycle._lcm lcm.plan.return_value = actions - fake_parts_lifecycle.run("prime") + fake_parts_lifecycle.run("prime", "arch") emitter.assert_progress("Pulling my-part") emitter.assert_progress("Building my-part") @@ -203,7 +209,11 @@ def test_get_step_failure(step_name): # region PartsLifecycle tests def test_init_success(app_metadata, fake_project, tmp_path): lifecycle.LifecycleService( - app_metadata, fake_project, work_dir=tmp_path, cache_dir=tmp_path + app_metadata, + fake_project, + work_dir=tmp_path, + cache_dir=tmp_path, + build_for=util.get_host_architecture(), ) @@ -228,7 +238,11 @@ def test_init_parts_error( with pytest.raises(type(expected)) as exc_info: lifecycle.LifecycleService( - app_metadata, fake_project, work_dir=tmp_path, cache_dir=tmp_path + app_metadata, + fake_project, + work_dir=tmp_path, + cache_dir=tmp_path, + build_for=util.get_host_architecture(), ) assert exc_info.value.args == expected.args @@ -306,7 +320,8 @@ def test_repr(fake_parts_lifecycle, app_metadata, fake_project): pytest_check.is_true( re.fullmatch( r"FakePartsLifecycle\(.+, work_dir=(Posix|Windows)Path\('.+'\), " - r"cache_dir=(Posix|Windows)Path\('.+'\), \*\*{}\)", + r"cache_dir=(Posix|Windows)Path\('.+'\), " + r"build_for='.+', \*\*{}\)", actual, ) ) diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py index 6b63828f..604d1143 100644 --- a/tests/unit/services/test_provider.py +++ b/tests/unit/services/test_provider.py @@ -18,7 +18,7 @@ import craft_providers import pytest -from craft_application import errors +from craft_application import errors, models, util from craft_application.services import provider from craft_providers import bases, lxd, multipass from craft_providers.actions.snap_installer import Snap @@ -163,9 +163,11 @@ def test_instance( mock_provider = mock.MagicMock(spec=craft_providers.Provider) monkeypatch.setattr(provider_service, "get_provider", lambda: mock_provider) spy_pause = mocker.spy(provider.emit, "pause") + arch = util.get_host_architecture() + build_info = models.BuildInfo(arch, arch, base_name) with provider_service.instance( - base_name, work_dir=tmp_path, allow_unstable=allow_unstable + build_info, work_dir=tmp_path, allow_unstable=allow_unstable ) as instance: pass diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 97a78b0a..1d2e6262 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -27,6 +27,11 @@ import pytest import pytest_check from craft_application import application, commands, services +from craft_application.models import BuildInfo +from craft_application.util import ( + get_host_architecture, # pyright: ignore[reportGeneralTypeIssues] +) +from craft_providers import bases EMPTY_COMMAND_GROUP = craft_cli.CommandGroup("FakeCommands", []) @@ -46,8 +51,10 @@ def app(app_metadata, fake_services): @pytest.fixture() def mock_dispatcher(monkeypatch): - dispatcher = mock.Mock(spec_set=craft_cli.Dispatcher) - monkeypatch.setattr("craft_cli.Dispatcher", mock.Mock(return_value=dispatcher)) + dispatcher = mock.Mock(spec_set=application._Dispatcher) + monkeypatch.setattr( + "craft_application.application._Dispatcher", mock.Mock(return_value=dispatcher) + ) return dispatcher @@ -97,9 +104,10 @@ def test_run_managed_success(app, fake_project, emitter): app.services.provider = mock_provider app.project = fake_project - app.run_managed() + arch = get_host_architecture() + app.run_managed(arch) - emitter.assert_debug("Running testcraft in a managed instance...") + emitter.assert_debug(f"Running testcraft in {arch} instance...") def test_run_managed_failure(app, fake_project): @@ -110,11 +118,54 @@ def test_run_managed_failure(app, fake_project): app.project = fake_project with pytest.raises(craft_providers.ProviderError) as exc_info: - app.run_managed() + app.run_managed(get_host_architecture()) assert exc_info.value.brief == "Failed to execute testcraft in instance." +def test_run_managed_multiple(app, fake_project, emitter, monkeypatch): + mock_provider = mock.MagicMock(spec_set=services.ProviderService) + app.services.provider = mock_provider + app.project = fake_project + + arch = get_host_architecture() + monkeypatch.setattr( + app.project.__class__, + "get_build_plan", + lambda _: [ + BuildInfo(arch, "arch1", bases.BaseName("base", "1")), + BuildInfo(arch, "arch2", bases.BaseName("base", "2")), + ], + ) + app.run_managed(None) + + emitter.assert_debug("Running testcraft in arch1 instance...") + emitter.assert_debug("Running testcraft in arch2 instance...") + + +def test_run_managed_specified(app, fake_project, emitter, monkeypatch): + mock_provider = mock.MagicMock(spec_set=services.ProviderService) + app.services.provider = mock_provider + app.project = fake_project + + arch = get_host_architecture() + monkeypatch.setattr( + app.project.__class__, + "get_build_plan", + lambda _: [ + BuildInfo(arch, "arch1", bases.BaseName("base", "1")), + BuildInfo(arch, "arch2", bases.BaseName("base", "2")), + ], + ) + app.run_managed("arch2") + + emitter.assert_debug("Running testcraft in arch2 instance...") + assert ( + mock.call("debug", "Running testcraft in arch1 instance...") + not in emitter.interactions + ) + + @pytest.mark.parametrize( ("managed", "error", "exit_code", "message"), [ @@ -188,7 +239,18 @@ def test_run_success_managed(monkeypatch, app, fake_project): pytest_check.equal(app.run(), 0) - app.run_managed.assert_called_once_with() + app.run_managed.assert_called_once_with(None) # --build-for not used + + +def test_run_success_managed_with_arch(monkeypatch, app, fake_project): + app.project = fake_project + app.run_managed = mock.Mock() + arch = get_host_architecture() + monkeypatch.setattr(sys, "argv", ["testcraft", "pull", f"--build-for={arch}"]) + + pytest_check.equal(app.run(), 0) + + app.run_managed.assert_called_once_with(arch) @pytest.mark.parametrize("return_code", [None, 0, 1]) @@ -247,3 +309,26 @@ def test_run_error_debug(monkeypatch, mock_dispatcher, app, fake_project, error) with pytest.raises(error.__class__): app.run() + + +_base = bases.BaseName("", "") +_on_a_for_a = BuildInfo("a", "a", _base) +_on_a_for_b = BuildInfo("a", "b", _base) + + +@pytest.mark.parametrize( + ("plan", "build_for", "host_arch", "result"), + [ + ([_on_a_for_a], None, "a", [_on_a_for_a]), + ([_on_a_for_a], "a", "a", [_on_a_for_a]), + ([_on_a_for_a], "b", "a", []), + ([_on_a_for_a], "a", "b", []), + ([_on_a_for_a, _on_a_for_b], "b", "a", [_on_a_for_b]), + ([_on_a_for_a, _on_a_for_b], "b", "b", []), + ([_on_a_for_a, _on_a_for_b], None, "b", []), + ([_on_a_for_a, _on_a_for_b], None, "a", [_on_a_for_a, _on_a_for_b]), + ], +) +def test_filter_plan(mocker, plan, build_for, host_arch, result): + mocker.patch("craft_application.util.get_host_architecture", return_value=host_arch) + assert application._filter_plan(plan, build_for) == result