From 12d6b494b69894224ada801f46904ea3b2377b8a Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Wed, 10 Apr 2024 21:40:46 -0400 Subject: [PATCH 1/5] feat(remote)!: use craft-application remote build for core22 Deprecated and removed old remote build code. Use new remote-build for core22. Core20 can also use the new remote build if set env SNAPCRAFT_REMOTE_BUILD_STRATEGY="disable-fallback" --- snapcraft/application.py | 45 +- snapcraft/cli.py | 3 +- snapcraft/commands/core22/__init__.py | 2 - snapcraft/commands/core22/remote.py | 436 --------- snapcraft/remote/__init__.py | 62 -- snapcraft/remote/errors.py | 128 --- snapcraft/remote/git.py | 333 ------- snapcraft/remote/launchpad.py | 495 ---------- snapcraft/remote/remote_builder.py | 139 --- snapcraft/remote/utils.py | 154 ---- snapcraft/remote/worktree.py | 67 -- tests/unit/cli/test_exit.py | 40 - tests/unit/commands/test_remote.py | 1072 +++------------------- tests/unit/remote/__init__.py | 0 tests/unit/remote/test_errors.py | 122 --- tests/unit/remote/test_git.py | 602 ------------ tests/unit/remote/test_launchpad.py | 565 ------------ tests/unit/remote/test_remote_builder.py | 217 ----- tests/unit/remote/test_utils.py | 224 ----- tests/unit/remote/test_worktree.py | 91 -- 20 files changed, 155 insertions(+), 4642 deletions(-) delete mode 100644 snapcraft/commands/core22/remote.py delete mode 100644 snapcraft/remote/__init__.py delete mode 100644 snapcraft/remote/errors.py delete mode 100644 snapcraft/remote/git.py delete mode 100644 snapcraft/remote/launchpad.py delete mode 100644 snapcraft/remote/remote_builder.py delete mode 100644 snapcraft/remote/utils.py delete mode 100644 snapcraft/remote/worktree.py delete mode 100644 tests/unit/remote/__init__.py delete mode 100644 tests/unit/remote/test_errors.py delete mode 100644 tests/unit/remote/test_git.py delete mode 100644 tests/unit/remote/test_launchpad.py delete mode 100644 tests/unit/remote/test_remote_builder.py delete mode 100644 tests/unit/remote/test_utils.py delete mode 100644 tests/unit/remote/test_worktree.py diff --git a/snapcraft/application.py b/snapcraft/application.py index 6a8a92b1f6..8872c49fff 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -22,7 +22,7 @@ import os import pathlib import sys -from typing import Any +from typing import Any, Optional import craft_application.commands as craft_app_commands import craft_cli @@ -62,6 +62,25 @@ } +def _get_esm_error_for_base(base: str) -> None: + """Raise an error appropriate for the base under ESM.""" + channel: Optional[str] = None + match base: + case "core": + channel = "4.x" + version = "4" + case "core18": + channel = "7.x" + version = "7" + case _: + return + + raise RuntimeError( + f"ERROR: base {base!r} was last supported on Snapcraft {version} available " + f"on the {channel!r} channel." + ) + + class Snapcraft(Application): """Snapcraft application definition.""" @@ -163,11 +182,35 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher: yaml_data = util.safe_yaml_load(file) base = yaml_data.get("base") build_base = yaml_data.get("build-base") + _get_esm_error_for_base(base) if "core24" in (base, build_base) or build_base == "devel": # We know for sure that we're handling a core24 project self._known_core24 = True elif any(arg in ("version", "--version", "-V") for arg in sys.argv): pass + elif "remote-build" in sys.argv and any( + b in ("core20", "core22") for b in (base, build_base) + ): + build_strategy = os.environ.get("SNAPCRAFT_REMOTE_BUILD_STRATEGY", None) + if build_strategy not in ( + "force-fallback", + "disable-fallback", + "", + None, + ): + raise errors.SnapcraftError( + f"Unknown value {build_strategy!r} in environment variable " + "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. " + "Valid values are 'disable-fallback' and 'force-fallback'." + ) + if "core20" in (base, build_base): + # Use legacy snapcraft unless explicitly forced to use craft-application + if build_strategy != "disable-fallback": + raise errors.ClassicFallback() + if "core22" in (base, build_base): + # Use craft-application unless explicitly forced to use legacy snapcraft + if build_strategy == "force-fallback": + raise errors.ClassicFallback() else: raise errors.ClassicFallback() return super()._get_dispatcher() diff --git a/snapcraft/cli.py b/snapcraft/cli.py index 92d8bea9b3..e1b6f330b1 100644 --- a/snapcraft/cli.py +++ b/snapcraft/cli.py @@ -24,12 +24,12 @@ import craft_cli import craft_store +from craft_application.errors import RemoteBuildError from craft_cli import ArgumentParsingError, EmitterMode, ProvideHelpException, emit from craft_providers import ProviderError from snapcraft import errors, store, utils from snapcraft.parts import plugins -from snapcraft.remote import RemoteBuildError from . import commands from .legacy_cli import run_legacy @@ -44,7 +44,6 @@ commands.core22.StageCommand, commands.core22.PrimeCommand, commands.core22.PackCommand, - commands.core22.RemoteBuildCommand, commands.core22.SnapCommand, # hidden (legacy compatibility) commands.core22.PluginsCommand, commands.core22.ListPluginsCommand, diff --git a/snapcraft/commands/core22/__init__.py b/snapcraft/commands/core22/__init__.py index 3655f678f7..4168fed89b 100644 --- a/snapcraft/commands/core22/__init__.py +++ b/snapcraft/commands/core22/__init__.py @@ -34,7 +34,6 @@ TryCommand, ) from .lint import LintCommand -from .remote import RemoteBuildCommand __all__ = [ "BuildCommand", @@ -49,7 +48,6 @@ "PluginsCommand", "PrimeCommand", "PullCommand", - "RemoteBuildCommand", "SnapCommand", "StageCommand", "TryCommand", diff --git a/snapcraft/commands/core22/remote.py b/snapcraft/commands/core22/remote.py deleted file mode 100644 index 68fd54db85..0000000000 --- a/snapcraft/commands/core22/remote.py +++ /dev/null @@ -1,436 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2022-2024 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Snapcraft remote build command.""" - -import argparse -import os -import textwrap -from enum import Enum -from pathlib import Path -from typing import List, Optional - -from craft_cli import BaseCommand, emit -from craft_cli.helptexts import HIDDEN -from overrides import overrides - -from snapcraft.errors import MaintenanceBase, SnapcraftError -from snapcraft.legacy_cli import run_legacy -from snapcraft.parts import yaml_utils -from snapcraft.remote import ( - AcceptPublicUploadError, - GitType, - RemoteBuilder, - get_git_repo_type, -) -from snapcraft.utils import confirm_with_user, get_host_architecture, humanize_list - -_CONFIRMATION_PROMPT = ( - "All data sent to remote builders will be publicly available. " - "Are you sure you want to continue?" -) - - -_STRATEGY_ENVVAR = "SNAPCRAFT_REMOTE_BUILD_STRATEGY" - - -class _Strategies(Enum): - """Possible values of the build strategy.""" - - DISABLE_FALLBACK = "disable-fallback" - FORCE_FALLBACK = "force-fallback" - - -class RemoteBuildCommand(BaseCommand): - """Command passthrough for the remote-build command.""" - - name = "remote-build" - help_msg = "Dispatch a snap for remote build" - overview = textwrap.dedent( - """ - Command remote-build sends the current project to be built - remotely. After the build is complete, packages for each - architecture are retrieved and will be available in the - local filesystem. - - If not specified in the snapcraft.yaml file, the list of - architectures to build can be set using the --build-on option. - If both are specified, an error will occur. - - Interrupted remote builds can be resumed using the --recover - option, followed by the build number informed when the remote - build was originally dispatched. The current state of the - remote build for each architecture can be checked using the - --status option. - - To set a timeout on the remote-build command, use the option - ``--launchpad-timeout=``. The timeout is local, so the build on - launchpad will continue even if the local instance of snapcraft is - interrupted or times out. - """ - ) - - @overrides - def fill_parser(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--recover", action="store_true", help="recover an interrupted build" - ) - parser.add_argument( - "--status", action="store_true", help="display remote build status" - ) - parser.add_argument( - "--build-on", - type=lambda arg: [arch.strip() for arch in arg.split(",")], - metavar="arch", - help=HIDDEN, - ) - parser.add_argument( - "--build-for", - type=lambda arg: [arch.strip() for arch in arg.split(",")], - metavar="arch", - help="comma-separated list of architectures to build for", - ) - parser.add_argument( - "--build-id", metavar="build-id", help="specific build id to retrieve" - ) - parser.add_argument( - "--launchpad-accept-public-upload", - action="store_true", - help="acknowledge that uploaded code will be publicly available.", - ) - parser.add_argument( - "--launchpad-timeout", - type=int, - default=0, - metavar="", - help="Time in seconds to wait for launchpad to build.", - ) - - @overrides - def run(self, parsed_args: argparse.Namespace) -> None: - """Run the remote-build command. - - :param parsed_args: Snapcraft's argument namespace. - - :raises AcceptPublicUploadError: If the user does not agree to upload data. - :raises SnapcraftError: If the project cannot be loaded and parsed. - """ - if os.getenv("SUDO_USER") and os.geteuid() == 0: - emit.message( - "Running with 'sudo' may cause permission errors and is discouraged." - ) - - emit.message( - "snapcraft remote-build is experimental and is subject to change " - "- use with caution." - ) - - if parsed_args.build_on: - emit.message("Use --build-for instead of --build-on") - - if not parsed_args.launchpad_accept_public_upload and not confirm_with_user( - _CONFIRMATION_PROMPT - ): - raise AcceptPublicUploadError() - - # pylint: disable=attribute-defined-outside-init - self._snapcraft_yaml = yaml_utils.get_snap_project().project_file - self._parsed_args = parsed_args - # pylint: enable=attribute-defined-outside-init - try: - base = self._get_effective_base() - except MaintenanceBase as base_err: - base = base_err.base - emit.progress(_get_esm_warning_for_base(base), permanent=True) - - self._run_new_or_fallback_remote_build(base) - - def _run_new_or_fallback_remote_build(self, base: str) -> None: - """Run the new or fallback remote-build. - - Three checks determine whether to run the new or fallback remote-build: - 1. Base: snaps newer than core22 must use the new remote-build. Core22 and older - snaps may use the new or fallback remote-build. - 2. Envvar: If the envvar "SNAPCRAFT_REMOTE_BUILD_STRATEGY" is "force-fallback", - the fallback remote-build is used. If it is "disable-fallback", the new - remote-build code is used. - 3. Repo: If the project is in a git repository, the new remote-build is used. - Otherwise, the fallback remote-build is used. - - :param base: The effective base of the project. - """ - # bases newer than core22 must use the new remote-build - if base in yaml_utils.CURRENT_BASES - {"core22"}: - emit.debug("Running new remote-build because base is newer than core22") - self._run_new_remote_build() - return - - strategy = self._get_build_strategy() - - if strategy == _Strategies.DISABLE_FALLBACK: - emit.debug( - "Running new remote-build because environment variable " - f"{_STRATEGY_ENVVAR!r} is {_Strategies.DISABLE_FALLBACK.value!r}" - ) - self._run_new_remote_build() - return - - if strategy == _Strategies.FORCE_FALLBACK: - emit.debug( - "Running fallback remote-build because environment variable " - f"{_STRATEGY_ENVVAR!r} is {_Strategies.FORCE_FALLBACK.value!r}" - ) - run_legacy() - return - - git_type = get_git_repo_type(Path().absolute()) - - if git_type == GitType.NORMAL: - emit.debug( - "Running new remote-build because project is in a git repository" - ) - self._run_new_remote_build() - return - - if git_type == GitType.SHALLOW: - emit.debug("Current git repository is shallow cloned.") - emit.progress( - "Remote build for shallow clones is deprecated " - "and will be removed in core24", - permanent=True, - ) - # fall-through to legacy remote-build - - emit.debug("Running fallback remote-build") - run_legacy() - - def _get_project_name(self) -> str: - """Get the project name from the project's snapcraft.yaml. - - :returns: The project name. - - :raises SnapcraftError: If the snapcraft.yaml does not contain a 'name' keyword. - """ - with open(self._snapcraft_yaml, encoding="utf-8") as file: - data = yaml_utils.safe_load(file) - - project_name = data.get("name") - - if project_name: - emit.debug( - f"Using project name {project_name!r} from " - f"{str(self._snapcraft_yaml)!r}" - ) - return project_name - - raise SnapcraftError( - f"Could not get project name from {str(self._snapcraft_yaml)!r}." - ) - - def _run_new_remote_build(self) -> None: - """Run new remote-build code.""" - emit.progress("Setting up launchpad environment") - remote_builder = RemoteBuilder( - app_name="snapcraft", - build_id=self._parsed_args.build_id, - project_name=self._get_project_name(), - architectures=self._determine_architectures(), - project_dir=Path(), - timeout=self._parsed_args.launchpad_timeout, - ) - - if self._parsed_args.status: - remote_builder.print_status() - return - - emit.progress("Looking for existing build") - has_outstanding_build = remote_builder.has_outstanding_build() - if self._parsed_args.recover and not has_outstanding_build: - emit.progress("No build found", permanent=True) - return - - if has_outstanding_build: - emit.progress("Found existing build", permanent=True) - remote_builder.print_status() - - # If recovery specified, monitor build and exit. - if self._parsed_args.recover or confirm_with_user( - "Do you wish to recover this build?", default=True - ): - emit.progress("Building") - try: - remote_builder.monitor_build() - finally: - emit.progress("Cleaning") - remote_builder.clean_build() - emit.progress("Build task(s) completed", permanent=True) - return - - # Otherwise clean running build before we start a new one. - emit.progress("Cleaning existing build") - remote_builder.clean_build() - else: - emit.progress("No existing build task(s) found", permanent=True) - - emit.progress( - "If interrupted, resume with: 'snapcraft remote-build --recover " - f"--build-id {remote_builder.build_id}'", - permanent=True, - ) - emit.progress("Starting build") - remote_builder.start_build() - emit.progress("Building") - try: - remote_builder.monitor_build() - finally: - emit.progress("Cleaning") - remote_builder.clean_build() - emit.progress("Build task(s) completed", permanent=True) - - def _get_build_strategy(self) -> Optional[_Strategies]: - """Get the build strategy from the envvar `SNAPCRAFT_REMOTE_BUILD_STRATEGY`. - - :returns: The strategy or None. - - :raises SnapcraftError: If the variable is set to an invalid value. - """ - strategy = os.getenv(_STRATEGY_ENVVAR) - - if not strategy: - return None - - try: - return _Strategies(strategy) - except ValueError as err: - valid_strategies = humanize_list( - (strategy.value for strategy in _Strategies), "and" - ) - raise SnapcraftError( - f"Unknown value {strategy!r} in environment variable " - f"{_STRATEGY_ENVVAR!r}. Valid values are {valid_strategies}." - ) from err - - def _get_effective_base(self) -> str: - """Get a valid effective base from the project's snapcraft.yaml. - - :returns: The project's effective base. - - :raises SnapcraftError: If the base is unknown or missing. - :raises MaintenanceBase: If the base is not supported - """ - with open(self._snapcraft_yaml, encoding="utf-8") as file: - base = yaml_utils.get_base(file) - - if base is None: - raise SnapcraftError( - f"Could not determine base from {str(self._snapcraft_yaml)!r}." - ) - - emit.debug(f"Got base {base!r} from {str(self._snapcraft_yaml)!r}") - - if base in yaml_utils.ESM_BASES: - raise MaintenanceBase(base) - - if base not in yaml_utils.BASES: - raise SnapcraftError( - f"Unknown base {base!r} in {str(self._snapcraft_yaml)!r}." - ) - - return base - - def _get_project_build_on_architectures(self) -> List[str]: - """Get a list of build-on architectures from the project's snapcraft.yaml. - - :returns: A list of architectures. - """ - with open(self._snapcraft_yaml, encoding="utf-8") as file: - data = yaml_utils.safe_load(file) - - project_archs = data.get("architectures") - - archs = [] - if project_archs: - for item in project_archs: - if "build-on" in item: - new_arch = item["build-on"] - if isinstance(new_arch, list): - archs.extend(new_arch) - else: - archs.append(new_arch) - - return archs - - def _determine_architectures(self) -> List[str]: - """Determine architectures to build for. - - The build architectures can be set via the `--build-on` parameter or determined - from the build-on architectures listed in the project's snapcraft.yaml. - - To retain backwards compatibility, `--build-for` can also be used to - set the architectures. - - :returns: A list of architectures. - - :raises SnapcraftError: If `--build-on` was provided and architectures are - defined in the project's snapcraft.yaml. - :raises SnapcraftError: If `--build-on` and `--build-for` are both provided. - """ - # argparse's `add_mutually_exclusive_group()` cannot be used because - # ArgumentParsingErrors executes the legacy remote-builder before this module - # can decide if the project is allowed to use the legacy remote-builder - if self._parsed_args.build_on and self._parsed_args.build_for: - raise SnapcraftError( - # use the same error as argparse produces for consistency - "Error: argument --build-for: not allowed with argument --build-on" - ) - - project_architectures = self._get_project_build_on_architectures() - if project_architectures and self._parsed_args.build_for: - raise SnapcraftError( - "Cannot use `--build-on` because architectures are already defined in " - "snapcraft.yaml." - ) - - if project_architectures: - archs = project_architectures - elif self._parsed_args.build_on: - archs = self._parsed_args.build_on - elif self._parsed_args.build_for: - archs = self._parsed_args.build_for - else: - # default to typical snapcraft behavior (build for host) - archs = [get_host_architecture()] - - return archs - - -def _get_esm_warning_for_base(base: str) -> str: - """Return a warning appropriate for the base under ESM.""" - channel: Optional[str] = None - match base: - case "core": - channel = "4.x" - version = "4" - case "core18": - channel = "7.x" - version = "7" - case _: - raise RuntimeError(f"Unmatched base {base!r}") - - return ( - f"WARNING: base {base!r} was last supported on Snapcraft {version} available " - f"on the {channel!r} channel." - ) diff --git a/snapcraft/remote/__init__.py b/snapcraft/remote/__init__.py deleted file mode 100644 index fa97f792ae..0000000000 --- a/snapcraft/remote/__init__.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Remote-build and related utilities.""" - -from .errors import ( - AcceptPublicUploadError, - GitError, - LaunchpadHttpsError, - RemoteBuildError, - RemoteBuildFailedError, - RemoteBuildInvalidGitRepoError, - RemoteBuildTimeoutError, - UnsupportedArchitectureError, -) -from .git import ( - GitRepo, - GitType, - check_git_repo_for_remote_build, - get_git_repo_type, - is_repo, -) -from .launchpad import LaunchpadClient -from .remote_builder import RemoteBuilder -from .utils import get_build_id, humanize_list, rmtree, validate_architectures -from .worktree import WorkTree - -__all__ = [ - "check_git_repo_for_remote_build", - "get_build_id", - "get_git_repo_type", - "humanize_list", - "is_repo", - "rmtree", - "validate_architectures", - "AcceptPublicUploadError", - "GitError", - "GitRepo", - "GitType", - "LaunchpadClient", - "LaunchpadHttpsError", - "RemoteBuilder", - "RemoteBuildError", - "RemoteBuildFailedError", - "RemoteBuildInvalidGitRepoError", - "RemoteBuildTimeoutError", - "UnsupportedArchitectureError", - "WorkTree", -] diff --git a/snapcraft/remote/errors.py b/snapcraft/remote/errors.py deleted file mode 100644 index 27b88c0e7c..0000000000 --- a/snapcraft/remote/errors.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Remote build errors.""" - -from dataclasses import dataclass -from typing import List, Optional - - -@dataclass(repr=True) -class RemoteBuildError(Exception): - """Unexpected remote build error. - - :param brief: Brief description of error. - :param details: Detailed information. - """ - - brief: str - details: Optional[str] = None - - def __str__(self) -> str: - """Return the string representation of the error.""" - components = [self.brief] - - if self.details: - components.append(self.details) - - return "\n".join(components) - - -class GitError(RemoteBuildError): - """Git is not working as expected.""" - - def __init__(self, message: str) -> None: - self.message = message - brief = "Git operation failed." - details = message - - super().__init__(brief=brief, details=details) - - -class RemoteBuildTimeoutError(RemoteBuildError): - """Remote-build timed out.""" - - def __init__(self, recovery_command: str) -> None: - brief = "Remote build command timed out." - details = ( - "Build may still be running on Launchpad and can be recovered " - f"with {recovery_command!r}." - ) - - super().__init__(brief=brief, details=details) - - -class LaunchpadHttpsError(RemoteBuildError): - """Launchpad connectivity error.""" - - def __init__(self) -> None: - brief = "Failed to connect to Launchpad API service." - details = "Verify connectivity to https://api.launchpad.net and retry build." - - super().__init__(brief=brief, details=details) - - -class UnsupportedArchitectureError(RemoteBuildError): - """Unsupported architecture error.""" - - def __init__(self, architectures: List[str]) -> None: - brief = "Architecture not supported by the remote builder." - details = ( - "The following architectures are not supported by the remote builder: " - f"{architectures}.\nPlease remove them from the " - "architecture list and try again." - ) - - super().__init__(brief=brief, details=details) - - -class AcceptPublicUploadError(RemoteBuildError): - """Accept public upload error.""" - - def __init__(self) -> None: - brief = "Cannot upload data to build servers." - details = ( - "Remote build needs explicit acknowledgement that data sent to build " - "servers is public.\n" - "In non-interactive runs, please use the option " - "`--launchpad-accept-public-upload`." - ) - - super().__init__(brief=brief, details=details) - - -class RemoteBuildFailedError(RemoteBuildError): - """Remote build failed. - - :param details: Detailed information. - """ - - def __init__(self, details: str) -> None: - brief = "Remote build failed." - - super().__init__(brief=brief, details=details) - - -class RemoteBuildInvalidGitRepoError(RemoteBuildError): - """The Git repository is invalid for remote build. - - :param details: Detailed information. - """ - - def __init__(self, details: str) -> None: - brief = "The Git repository is invalid for remote build." - - super().__init__(brief=brief, details=details) diff --git a/snapcraft/remote/git.py b/snapcraft/remote/git.py deleted file mode 100644 index bc1be8146a..0000000000 --- a/snapcraft/remote/git.py +++ /dev/null @@ -1,333 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Git repository class and helper utilities.""" - -import logging -import os -import subprocess -import time -from enum import Enum -from pathlib import Path -from typing import Optional - -# Cannot catch the pygit2 error here raised by the global use of -# pygit2.Settings on import. We would ideally use pygit2.Settings -# for this -try: - import pygit2 -except Exception: # pylint: disable=broad-exception-caught - # This environment comes from ssl.get_default_verify_paths - _old_env = os.getenv("SSL_CERT_DIR") - # Needs updating when the base changes for Snapcraft - os.environ["SSL_CERT_DIR"] = "/snap/core22/current/etc/ssl/certs" - import pygit2 - - # Restore the environment in case Snapcraft shells out and the environment - # that was setup is required. - if _old_env is not None: - os.environ["SSL_CERT_DIR"] = _old_env - else: - del os.environ["SSL_CERT_DIR"] - -from .errors import GitError, RemoteBuildInvalidGitRepoError - -logger = logging.getLogger(__name__) - - -class GitType(Enum): - """Type of git repository.""" - - INVALID = 0 - NORMAL = 1 - SHALLOW = 2 - - -def is_repo(path: Path) -> bool: - """Check if a directory is a git repo. - - :param path: filepath to check - - :returns: True if path is a git repo. - - :raises GitError: if git fails while checking for a repository - """ - # `path.absolute().parent` prevents pygit2 from checking parent directories - try: - return bool( - pygit2.discover_repository(str(path), False, str(path.absolute().parent)) - ) - except pygit2.GitError as error: - raise GitError( - f"Could not check for git repository in {str(path)!r}." - ) from error - - -def get_git_repo_type(path: Path) -> GitType: - """Check if a directory is a git repo and return the type. - - :param path: filepath to check - - :returns: GitType - """ - if is_repo(path): - repo = pygit2.Repository(str(path)) - if repo.is_shallow: - return GitType.SHALLOW - return GitType.NORMAL - - return GitType.INVALID - - -def check_git_repo_for_remote_build(path: Path) -> None: - """Check if a directory meets the requirements of doing remote builds. - - :param path: filepath to check - - :raises RemoteBuildInvalidGitRepoError: if incompatible git repo is found - """ - git_type = get_git_repo_type(path.absolute()) - - if git_type == GitType.INVALID: - raise RemoteBuildInvalidGitRepoError( - f"Could not find a git repository in {str(path.absolute())!r}. " - "The project must be in the top-level of a git repository." - ) - - if git_type == GitType.SHALLOW: - raise RemoteBuildInvalidGitRepoError( - "Remote builds are not supported for projects in shallowly cloned " - "git repositories." - ) - - -class GitRepo: - """Git repository class.""" - - def __init__(self, path: Path) -> None: - """Initialize a git repo. - - If a git repo does not already exist, a new repo will be initialized. - - :param path: filepath of the repo - - :raises FileNotFoundError: if the directory does not exist - :raises GitError: if the repo cannot be initialized - """ - self.path = path - - if not path.is_dir(): - raise FileNotFoundError( - f"Could not initialize a git repository because {str(path)!r} does not " - "exist or is not a directory." - ) - - if not is_repo(path): - self._init_repo() - - self._repo = pygit2.Repository(str(path)) - - def add_all(self) -> None: - """Add all changes from the working tree to the index. - - :raises GitError: if the changes could not be added - """ - logger.debug("Adding all changes.") - - try: - self._repo.index.add_all() - self._repo.index.write() - except pygit2.GitError as error: - raise GitError( - f"Could not add changes for the git repository in {str(self.path)!r}." - ) from error - - def commit(self, message: str = "auto commit") -> str: - """Commit changes to the repo. - - :param message: the commit message - - :returns: object ID of the commit as str - - :raises GitError: if the commit could not be created - """ - logger.debug("Committing changes.") - - try: - tree = self._repo.index.write_tree() - except pygit2.GitError as error: - raise GitError( - f"Could not create a tree for the git repository in {str(self.path)!r}." - ) from error - - author = pygit2.Signature("auto commit", "auto commit") - - # a target is not needed for an unborn head (no existing commits in branch) - target = [] if self._repo.head_is_unborn else [self._repo.head.target] - - try: - return str( - self._repo.create_commit("HEAD", author, author, message, tree, target) - ) - except pygit2.GitError as error: - raise GitError( - "Could not create a commit for the git repository " - f"in {str(self.path)!r}." - ) from error - - def is_clean(self) -> bool: - """Check if the repo is clean. - - :returns: True if the repo is clean. - - :raises GitError: if git fails while checking if the repo is clean - """ - try: - # for a clean repo, `status()` will return an empty dict - return not bool(self._repo.status()) - except pygit2.GitError as error: - raise GitError( - f"Could not check if the git repository in {str(self.path)!r} is clean." - ) from error - - def _init_repo(self) -> None: - """Initialize a git repo. - - :raises GitError: if the repo cannot be initialized - """ - logger.debug("Initializing git repository in %r", str(self.path)) - - try: - pygit2.init_repository(self.path) - except pygit2.GitError as error: - raise GitError( - f"Could not initialize a git repository in {str(self.path)!r}." - ) from error - - def push_url( # pylint: disable=too-many-branches - self, - remote_url: str, - remote_branch: str, - ref: str = "HEAD", - token: Optional[str] = None, - push_tags: bool = False, - ) -> None: - """Push a reference to a branch on a remote url. - - :param remote_url: the remote repo URL to push to - :param remote_branch: the branch on the remote to push to - :param ref: name of shorthand ref to push (i.e. a branch, tag, or `HEAD`) - :param token: token in the url to hide in logs and errors - :param push_tags: if true, push all tags to URL (similar to `git push --tags`) - - :raises GitError: if the ref cannot be resolved or pushed - """ - resolved_ref = self._resolve_ref(ref) - refspec = f"{resolved_ref}:refs/heads/{remote_branch}" - - # hide secret tokens embedded in a url - if token: - stripped_url = remote_url.replace(token, "") - else: - stripped_url = remote_url - - logger.debug( - "Pushing %r to remote %r with refspec %r.", ref, stripped_url, refspec - ) - - # temporarily call git directly due to libgit2 bug that unable to push - # large repos using https. See https://github.com/libgit2/libgit2/issues/6385 - # and https://github.com/snapcore/snapcraft/issues/4478 - cmd: list[str] = ["git", "push", remote_url, refspec, "--progress"] - if push_tags: - cmd.append("--tags") - - git_proc: Optional[subprocess.Popen] = None - try: - with subprocess.Popen( - cmd, - cwd=str(self.path), - bufsize=1, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) as git_proc: - # do not block on reading from the pipes - # (has no effect on Windows until Python 3.12, so the readline() method is - # blocking on Windows but git will still proceed) - if git_proc.stdout: - os.set_blocking(git_proc.stdout.fileno(), False) - if git_proc.stderr: - os.set_blocking(git_proc.stderr.fileno(), False) - - git_stdout: str - git_stderr: str - - while git_proc.poll() is None: - if git_proc.stdout: - while git_stdout := git_proc.stdout.readline(): - logger.info(git_stdout.rstrip()) - if git_proc.stderr: - while git_stderr := git_proc.stderr.readline(): - logger.error(git_stderr.rstrip()) - # avoid too much looping, but not too slow to display progress - time.sleep(0.01) - - except subprocess.SubprocessError as error: - # logging the remaining output - if git_proc: - if git_proc.stdout: - for git_stdout in git_proc.stdout.readlines(): - logger.info(git_stdout.rstrip()) - if git_proc.stderr: - for git_stderr in git_proc.stderr.readlines(): - logger.error(git_stderr.rstrip()) - - raise GitError( - f"Could not push {ref!r} to {stripped_url!r} with refspec {refspec!r} " - f"for the git repository in {str(self.path)!r}: " - f"{error!s}" - ) from error - - if git_proc: - git_proc.wait() - if git_proc.returncode == 0: - return - - raise GitError( - f"Could not push {ref!r} to {stripped_url!r} with refspec {refspec!r} " - f"for the git repository in {str(self.path)!r}." - ) - - def _resolve_ref(self, ref: str) -> str: - """Get a full reference name for a shorthand ref. - - :param ref: shorthand ref name (i.e. a branch, tag, or `HEAD`) - - :returns: the full ref name (i.e. `refs/heads/main`) - - raises GitError: if the name could not be resolved - """ - try: - reference = self._repo.lookup_reference_dwim(ref).name - logger.debug("Resolved reference %r for name %r", reference, ref) - return reference - # raises a KeyError if the ref does not exist and a GitError for git errors - except (pygit2.GitError, KeyError) as error: - raise GitError( - f"Could not resolve reference {ref!r} for the git repository in " - f"{str(self.path)!r}." - ) from error diff --git a/snapcraft/remote/launchpad.py b/snapcraft/remote/launchpad.py deleted file mode 100644 index f16c3f3638..0000000000 --- a/snapcraft/remote/launchpad.py +++ /dev/null @@ -1,495 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2019, 2023 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -"""Class to manage remote builds on Launchpad.""" - -import gzip -import logging -import shutil -import time -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, cast -from urllib.parse import unquote, urlsplit - -import requests -from launchpadlib.launchpad import Launchpad -from lazr import restfulclient -from lazr.restfulclient.resource import Entry -from xdg import BaseDirectory - -from . import GitRepo, errors - -_LP_POLL_INTERVAL = 30 -_LP_SUCCESS_STATUS = "Successfully built" -_LP_FAIL_STATUS = "Failed to build" - -logger = logging.getLogger(__name__) - - -def _is_build_pending(build: Dict[str, Any]) -> bool: - """Check if build is pending. - - Possible values: - - Needs building - - Successfully built - - Failed to build - - Dependency wait - - Chroot problem - - Build for superseded Source - - Currently building - - Failed to upload - - Uploading build - - Cancelling build - - Cancelled build - """ - if _is_build_status_success(build) or _is_build_status_failure(build): - return False - - return True - - -def _is_build_status_success(build: Dict[str, Any]) -> bool: - build_state = build["buildstate"] - return build_state == "Successfully built" - - -def _is_build_status_failure(build: Dict[str, Any]) -> bool: - build_state = build["buildstate"] - return build_state in ["Failed to build", "Cancelled build"] - - -def _get_url_basename(url: str): - path = urlsplit(url).path - return unquote(path).split("/")[-1] - - -class LaunchpadClient: - """Launchpad remote builder operations. - - :param app_name: Name of the application. - :param build_id: Unique identifier for the build. - :param project_name: Name of the project. - :param architectures: List of architectures to build on. - :param timeout: Time in seconds to wait for the build to complete. - """ - - def __init__( - self, - *, - app_name: str, - build_id: str, - project_name: str, - architectures: Sequence[str], - timeout: int = 0, - ) -> None: - self._app_name = app_name - - self._cache_dir = self._create_cache_directory() - self._data_dir = self._create_data_directory() - self._credentials = self._data_dir / "credentials" - - self.architectures = architectures - self._build_id = build_id - self._lp_name = build_id - self._project_name = project_name - - self._lp: Launchpad = self._login() - self.user = self._lp.me.name # type: ignore - - # calculate deadline from the timeout - if timeout > 0: - self._deadline = int(time.time()) + timeout - else: - self._deadline = 0 - - @property - def architectures(self) -> Sequence[str]: - """Get architectures.""" - return self._architectures - - @architectures.setter - def architectures(self, architectures: Sequence[str]) -> None: - self._lp_processors: Optional[Sequence[str]] = None - - if architectures: - self._lp_processors = ["/+processors/" + a for a in architectures] - - self._architectures = architectures - - @property - def user(self) -> str: - """Get the launchpad user.""" - return self._lp_user - - @user.setter - def user(self, user: str) -> None: - self._lp_user = user - self._lp_owner = f"/~{user}" - - def _check_timeout_deadline(self) -> None: - if self._deadline <= 0: - return - - if int(time.time()) >= self._deadline: - raise errors.RemoteBuildTimeoutError( - recovery_command=( - f"{self._app_name} remote-build --recover --build-id {self._build_id}" - ) - ) - - def _create_data_directory(self) -> Path: - data_dir = Path( - BaseDirectory.save_data_path(self._app_name, "provider", "launchpad") - ) - data_dir.mkdir(mode=0o700, exist_ok=True) - return data_dir - - def _create_cache_directory(self) -> Path: - cache_dir = Path( - BaseDirectory.save_cache_path(self._app_name, "provider", "launchpad") - ) - cache_dir.mkdir(mode=0o700, exist_ok=True) - return cache_dir - - def _fetch_artifacts(self, snap: Optional[Entry]) -> None: - """Fetch build arftifacts (logs and snaps).""" - builds = self._get_builds(snap) - error_list: list[str] = [] - - logger.info("Downloading artifacts...") - for build in builds: - self._download_build_artifacts(build) - try: - self._download_log(build) - except errors.RemoteBuildFailedError as error: - error_list.append(str(error.details)) - - if error_list: - raise errors.RemoteBuildFailedError( - details="\n".join(error_list), - ) - - def _get_builds_collection_entry(self, snap: Optional[Entry]) -> Optional[Entry]: - logger.debug("Fetching builds collection information from Launchpad...") - if snap: - url = cast(str, snap.builds_collection_link) - return self._lp_load_url(url) - return None - - def _get_builds(self, snap: Optional[Entry]) -> List[Dict[str, Any]]: - builds_collection = self._get_builds_collection_entry(snap) - if builds_collection is None: - return [] - - return cast(List[Dict[str, Any]], builds_collection.entries) - - def _get_snap(self) -> Optional[Entry]: - try: - return self._lp.snaps.getByName( # type: ignore - name=self._lp_name, owner=self._lp_owner - ) - except restfulclient.errors.NotFound: # type: ignore - return None - - def _issue_build_request(self, snap: Entry) -> Entry: - dist = self._lp.distributions["ubuntu"] # type: ignore - archive = dist.main_archive - return snap.requestBuilds( # type: ignore - archive=archive, - pocket="Updates", - ) - - def _lp_load_url(self, url: str) -> Entry: - """Load Launchpad url with a retry in case the connection is lost.""" - try: - return self._lp.load(url) - except ConnectionResetError: - self._lp = self._login() - return self._lp.load(url) - - def _wait_for_build_request_acceptance(self, build_request: Entry) -> None: - # Not to be confused with the actual build(s), this is - # ensuring that Launchpad accepts the build request. - while build_request.status == "Pending": - # Check to see if we've run out of time. - self._check_timeout_deadline() - - logger.info("Waiting on Launchpad build request...") - logger.debug( - "status=%s error=%s", build_request.status, build_request.error_message - ) - - time.sleep(1) - - # Refresh status. - build_request.lp_refresh() - - if build_request.status == "Failed": - # Build request failed. - self.cleanup() - raise errors.RemoteBuildError(cast(str, build_request.error_message)) - - if build_request.status != "Completed": - # Shouldn't end up here. - self.cleanup() - raise errors.RemoteBuildError( - f"Unknown builder error - reported status: {build_request.status}" - ) - - if not build_request.builds.entries: # type: ignore - # Shouldn't end up here either. - self.cleanup() - raise errors.RemoteBuildError( - "Unknown builder error - no build entries found." - ) - - build_number = _get_url_basename(cast(str, build_request.self_link)) - logger.info("Build request accepted: %s", build_number) - - def _login(self) -> Launchpad: - """Login to launchpad.""" - try: - return Launchpad.login_with( - f"{self._app_name} remote-build", - "production", - self._cache_dir, - credentials_file=str(self._credentials), - version="devel", - ) - except (ConnectionRefusedError, TimeoutError) as error: - raise errors.LaunchpadHttpsError() from error - - def get_git_repo_path(self) -> str: - """Get path to the git repository.""" - return f"~{self._lp_user}/+git/{self._lp_name}" - - def get_git_https_url(self, token: Optional[str] = None) -> str: - """Get url for launchpad repository.""" - if token: - return ( - f"https://{self._lp_user}:{token}@git.launchpad.net/" - f"~{self._lp_user}/+git/{self._lp_name}/" - ) - - return ( - f"https://{self._lp_user}@git.launchpad.net/" - f"~{self._lp_user}/+git/{self._lp_name}/" - ) - - def _create_git_repository(self, force=False) -> Entry: - """Create git repository.""" - if force: - self._delete_git_repository() - - logger.info( - "creating git repo: name=%s, owner=%s, target=%s", - self._lp_name, - self._lp_owner, - self._lp_owner, - ) - return self._lp.git_repositories.new( # type: ignore - name=self._lp_name, owner=self._lp_owner, target=self._lp_owner - ) - - def _delete_git_repository(self) -> None: - """Delete git repository.""" - git_path = self.get_git_repo_path() - git_repo = self._lp.git_repositories.getByPath(path=git_path) # type: ignore - - # git_repositories.getByPath returns None if git repo does not exist. - if git_repo is None: - return - - logger.info("Deleting source repository from Launchpad...") - git_repo.lp_delete() - - def _create_snap(self, force=False) -> Entry: - """Create a snap recipe. Use force=true to replace existing snap.""" - git_url = self.get_git_https_url() - - if force: - self._delete_snap() - - optional_kwargs = {} - if self._lp_processors: - optional_kwargs["processors"] = self._lp_processors - - logger.info("Registering snap job on Launchpad...") - logger.debug( - "url=https://launchpad.net/%s/+snap/%s", self._lp_owner, self._lp_name - ) - - return self._lp.snaps.new( # type: ignore - name=self._lp_name, - owner=self._lp_owner, - git_repository_url=git_url, - git_path="main", - auto_build=False, - auto_build_archive="/ubuntu/+archive/primary", - auto_build_pocket="Updates", - **optional_kwargs, - ) - - def _delete_snap(self) -> None: - """Remove snap info and all associated files.""" - snap = self._get_snap() - if snap is None: - return - - logger.info("Removing snap job from Launchpad...") - snap.lp_delete() - - def cleanup(self) -> None: - """Delete snap and git repository from launchpad.""" - self._delete_snap() - self._delete_git_repository() - - def start_build(self) -> None: - """Start build with specified timeout (time.time() in seconds).""" - snap = self._create_snap(force=True) - - logger.info("Issuing build request on Launchpad...") - build_request = self._issue_build_request(snap) - self._wait_for_build_request_acceptance(build_request) - - def monitor_build(self, interval: int = _LP_POLL_INTERVAL) -> None: - """Check build progress, and download artifacts when ready.""" - snap = self._get_snap() - - while True: - # Check to see if we've run out of time. - self._check_timeout_deadline() - - builds = self._get_builds(snap) - pending = False - statuses = [] - for build in builds: - state = build["buildstate"] - arch = build["arch_tag"] - statuses.append(f"{arch}: {state}") - - if _is_build_pending(build): - pending = True - - logger.info(", ".join(statuses)) - - if pending is False: - break - - time.sleep(interval) - - # Build is complete - download build artifacts. - self._fetch_artifacts(snap) - - def get_build_status(self) -> Dict[str, str]: - """Get status of builds.""" - snap = self._get_snap() - builds = self._get_builds(snap) - build_status: Dict[str, str] = {} - for build in builds: - state = build["buildstate"] - arch = build["arch_tag"] - build_status[arch] = state - - return build_status - - def _get_logfile_name(self, arch: str) -> str: - index = 0 - base_name = f"{self._project_name}_{arch}" - log_name = f"{base_name}.txt" - - while Path(log_name).is_file(): - index += 1 - log_name = f"{base_name}.{index}.txt" - - return log_name - - def _download_log(self, build: Dict[str, Any]) -> None: - url = build["build_log_url"] - arch = build["arch_tag"] - if url is None: - logger.info("No build log available for %r.", arch) - else: - log_name = self._get_logfile_name(arch) - self._download_file(url=url, dst=log_name, gunzip=True) - logger.info("Build log available at %r.", log_name) - - if _is_build_status_failure(build): - logger.error("Build failed for arch %r.", arch) - raise errors.RemoteBuildFailedError(f"Build failed for arch {arch}.") - - def _download_file(self, *, url: str, dst: str, gunzip: bool = False) -> None: - # TODO: consolidate with, and use indicators.download_requests_stream - logger.info("Downloading: %s", url) - try: - with requests.get(url, stream=True, timeout=3600) as response: - # Wrap response with gzipfile if gunzip is requested. - stream = response.raw - if gunzip: - stream = gzip.GzipFile(fileobj=stream) - with open(dst, "wb") as f_dst: - shutil.copyfileobj(stream, f_dst) - response.raise_for_status() - except requests.exceptions.RequestException as error: - logger.error("Error downloading %s: %s", url, str(error)) - - def _download_build_artifacts(self, build: Dict[str, Any]) -> None: - arch = build["arch_tag"] - snap_build = self._lp_load_url(build["self_link"]) - urls = snap_build.getFileUrls() # type: ignore - - if not urls: - logger.error("Snap file not available for arch %r.", arch) - return - - for url in urls: - file_name = _get_url_basename(url) - - self._download_file(url=url, dst=file_name) - - if file_name.endswith(".snap"): - logger.info("Snapped %s", file_name) - else: - logger.info("Fetched %s", file_name) - - def has_outstanding_build(self) -> bool: - """Check if there is an existing build configured on Launchpad.""" - snap = self._get_snap() - return snap is not None - - def push_source_tree(self, repo_dir: Path) -> None: - """Push source tree to Launchpad.""" - lp_repo = self._create_git_repository(force=True) - # This token will be used multiple times, so we don't want it to - # expire too soon. Especially if the git push takes a long time. - date_expires = datetime.now(timezone.utc) + timedelta(minutes=60) - token = lp_repo.issueAccessToken( # type: ignore - description=f"{self._app_name} remote-build for {self._build_id}", - scopes=["repository:push"], - date_expires=date_expires.isoformat(), - ) - - url = self.get_git_https_url(token=token) - stripped_url = self.get_git_https_url( - token="" # noqa: S106 (hardcoded-password) - ) - - logger.info("Sending build data to Launchpad: %s", stripped_url) - - repo = GitRepo(repo_dir) - repo.push_url(url, "main", "HEAD", token, push_tags=True) diff --git a/snapcraft/remote/remote_builder.py b/snapcraft/remote/remote_builder.py deleted file mode 100644 index 889d5deccf..0000000000 --- a/snapcraft/remote/remote_builder.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Manager for creating, monitoring, and cleaning remote builds.""" - -import logging -from pathlib import Path -from typing import List, Optional - -from .git import check_git_repo_for_remote_build -from .launchpad import LaunchpadClient -from .utils import get_build_id, humanize_list, validate_architectures -from .worktree import WorkTree - -logger = logging.getLogger(__name__) - - -class RemoteBuilder: - """Remote builder class. - - :param app_name: Name of the application. - :param build_id: Unique identifier for the build. - :param project_name: Name of the project. - :param architectures: List of architectures to build on. - :param project_dir: Path of the project. - :param timeout: Time in seconds to wait for the build to complete. - - :raises UnsupportedArchitectureError: if any architecture is not supported - for remote building. - :raises LaunchpadHttpsError: If a connection to Launchpad cannot be established. - """ - - def __init__( # noqa: PLR0913 pylint: disable=too-many-arguments - self, - app_name: str, - build_id: Optional[str], - project_name: str, - architectures: List[str], - project_dir: Path, - timeout: int, - ): - self._app_name = app_name - self._project_name = project_name - self._project_dir = project_dir - - check_git_repo_for_remote_build(self._project_dir) - - if build_id: - self._build_id = build_id - else: - self._build_id = get_build_id( - app_name=self._app_name, - project_name=self._project_name, - project_path=self._project_dir, - ) - - validate_architectures(architectures) - self._architectures = architectures - - self._worktree = WorkTree( - app_name=self._app_name, - build_id=self._build_id, - project_dir=self._project_dir, - ) - - logger.debug("Setting up launchpad environment.") - - self._lpc = LaunchpadClient( - app_name=self._app_name, - build_id=self._build_id, - project_name=self._project_name, - architectures=self._architectures, - timeout=timeout, - ) - - @property - def build_id(self) -> str: - """Get the build id.""" - return self._build_id - - def print_status(self) -> None: - """Print the status of a remote build in Launchpad.""" - if self._lpc.has_outstanding_build(): - build_status = self._lpc.get_build_status() - for arch, status in build_status.items(): - logger.info("Build status for arch %s: %s", arch, status) - else: - logger.info("No build task(s) found.") - - def has_outstanding_build(self) -> bool: - """Check if there is an existing build on Launchpad. - - :returns: True if there is an existing (incomplete) build on Launchpad. - """ - return self._lpc.has_outstanding_build() - - def monitor_build(self) -> None: - """Monitor and periodically log the status of a remote build in Launchpad.""" - logger.info( - "Building snap package for %s. This may take some time to finish.", - humanize_list(self._lpc.architectures, "and", "{}"), - ) - - logger.info("Building...") - try: - self._lpc.monitor_build() - finally: - logger.info("Build task(s) complete.") - - def clean_build(self) -> None: - """Clean the cache and Launchpad build.""" - logger.info("Cleaning existing builds and artefacts.") - self._lpc.cleanup() - self._worktree.clean_cache() - - def start_build(self) -> None: - """Start a build in Launchpad. - - A local copy of the project is created and pushed to Launchpad via git. - """ - self._worktree.init_repo() - - logger.debug("Cached project at %s", self._worktree.repo_dir) - self._lpc.push_source_tree(repo_dir=self._worktree.repo_dir) - - self._lpc.start_build() diff --git a/snapcraft/remote/utils.py b/snapcraft/remote/utils.py deleted file mode 100644 index 6377747896..0000000000 --- a/snapcraft/remote/utils.py +++ /dev/null @@ -1,154 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Remote build utilities.""" - -import shutil -import stat -from functools import partial -from hashlib import md5 -from pathlib import Path -from typing import Iterable, List - -from .errors import UnsupportedArchitectureError - -_SUPPORTED_ARCHS = ["amd64", "arm64", "armhf", "i386", "ppc64el", "riscv64", "s390x"] - - -def validate_architectures(architectures: List[str]) -> None: - """Validate that architectures are supported for remote building. - - :param architectures: list of architectures to validate - - :raises UnsupportedArchitectureError: if any architecture in the list in not - supported for remote building. - """ - unsupported_archs = [] - for arch in architectures: - if arch not in _SUPPORTED_ARCHS: - unsupported_archs.append(arch) - if unsupported_archs: - raise UnsupportedArchitectureError(architectures=unsupported_archs) - - -def get_build_id(app_name: str, project_name: str, project_path: Path) -> str: - """Get the build id for a project. - - The build id is formatted as `--`. - The hash is a hash of all files in the project directory. - - :param app_name: Name of the application. - :param project_name: Name of the project. - :param project_path: Path of the project. - - :returns: The build id. - """ - project_hash = _compute_hash(project_path) - - return f"{app_name}-{project_name}-{project_hash}" - - -def _compute_hash(directory: Path) -> str: - """Compute an md5 hash from the contents of the files in a directory. - - If a file or its contents within the directory are modified, then the hash - will be different. - - The hash may not be unique if the contents of one file are moved to another file - or if files are reorganized. - - :returns: A string containing the md5 hash. - - :raises FileNotFoundError: If the path is not a directory or does not exist. - """ - if not directory.exists(): - raise FileNotFoundError( - f"Could not compute hash because directory {str(directory.absolute())} " - "does not exist." - ) - if not directory.is_dir(): - raise FileNotFoundError( - f"Could not compute hash because {str(directory.absolute())} is not " - "a directory." - ) - - files = sorted([file for file in Path().glob("**/*") if file.is_file()]) - hashes: List[str] = [] - - for file_path in files: - md5_hash = md5() # noqa: S324 (insecure-hash-function) - with open(file_path, "rb") as file: - # read files in chunks in case they are large - for block in iter(partial(file.read, 4096), b""): - md5_hash.update(block) - hashes.append(md5_hash.hexdigest()) - - all_hashes = "".join(hashes).encode() - return md5(all_hashes).hexdigest() # noqa: S324 (insecure-hash-function) - - -def humanize_list( - items: Iterable[str], - conjunction: str, - item_format: str = "{!r}", - sort: bool = True, -) -> str: - """Format a list into a human-readable string. - - :param items: list to humanize. - :param conjunction: the conjunction used to join the final element to - the rest of the list (e.g. 'and'). - :param item_format: format string to use per item. - :param sort: if true, sort the list. - """ - if not items: - return "" - - quoted_items = [item_format.format(item) for item in items] - - if sort: - quoted_items = sorted(quoted_items) - - if len(quoted_items) == 1: - return quoted_items[0] - - humanized = ", ".join(quoted_items[:-1]) - - if len(quoted_items) > 2: - humanized += "," - - return f"{humanized} {conjunction} {quoted_items[-1]}" - - -def rmtree(directory: Path) -> None: - """Cross-platform rmtree implementation. - - :param directory: Directory to remove. - """ - shutil.rmtree(str(directory.resolve()), onerror=_remove_readonly) - - -def _remove_readonly(func, filepath, _): - """Shutil onerror function to make read-only files writable. - - Try setting file to writeable if error occurs during rmtree. Known to be required - on Windows where file is not writeable, but it is owned by the user (who can - set file permissions). - - :param filepath: filepath to make writable - """ - Path(filepath).chmod(stat.S_IWRITE) - func(filepath) diff --git a/snapcraft/remote/worktree.py b/snapcraft/remote/worktree.py deleted file mode 100644 index 480bbbf04e..0000000000 --- a/snapcraft/remote/worktree.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2019, 2023 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Manages trees for remote builds.""" - -from pathlib import Path -from shutil import copytree - -from xdg import BaseDirectory - -from .git import GitRepo -from .utils import rmtree - - -class WorkTree: - """Class to manage trees for remote builds. - - :param app_name: Name of the application. - :param build_id: Unique identifier for the build. - :param project_dir: Path to project directory. - """ - - def __init__(self, app_name: str, build_id: str, project_dir: Path) -> None: - self._project_dir = project_dir - self._base_dir = Path( - BaseDirectory.save_cache_path(app_name, "remote-build", build_id) - ) - self._repo_dir = self._base_dir / "repo" - - @property - def repo_dir(self) -> Path: - """Get path the cached repository.""" - return self._repo_dir - - def init_repo(self) -> None: - """Initialize a clean repo.""" - if self._repo_dir.exists(): - rmtree(self._repo_dir) - - copytree(self._project_dir, self._repo_dir) - - self._gitify_repository() - - def _gitify_repository(self) -> None: - """Git-ify source repository tree.""" - repo = GitRepo(self._repo_dir) - if not repo.is_clean(): - repo.add_all() - repo.commit() - - def clean_cache(self): - """Clean the cache.""" - if self._base_dir.exists(): - rmtree(self._base_dir) diff --git a/tests/unit/cli/test_exit.py b/tests/unit/cli/test_exit.py index 55817a862c..5f9ad68e06 100644 --- a/tests/unit/cli/test_exit.py +++ b/tests/unit/cli/test_exit.py @@ -24,7 +24,6 @@ from craft_providers import ProviderError from snapcraft import cli -from snapcraft.remote import RemoteBuildError, RemoteBuildFailedError def test_no_keyring_error(capsys, mocker): @@ -75,24 +74,6 @@ def test_craft_providers_error(capsys, mocker): assert stderr[2].startswith("test resolution") -def test_remote_build_error(capsys, mocker): - """Catch remote-build errors.""" - mocker.patch.object(sys, "argv", ["cmd", "pull"]) - mocker.patch.object(sys.stdin, "isatty", return_value=True) - mocker.patch( - "snapcraft.commands.core22.lifecycle.PullCommand.run", - side_effect=RemoteBuildError(brief="test brief", details="test details"), - ) - - cli.run() - - stderr = capsys.readouterr().err.splitlines() - - # Simple verification that our expected message is being printed - assert stderr[0].startswith("remote-build error: test brief") - assert stderr[1].startswith("test details") - - @pytest.mark.parametrize("is_managed,report_errors", [(True, False), (False, True)]) def test_emit_error(emitter, mocker, is_managed, report_errors): mocker.patch("snapcraft.utils.is_managed_mode", return_value=is_managed) @@ -102,24 +83,3 @@ def test_emit_error(emitter, mocker, is_managed, report_errors): cli._emit_error(my_error) assert my_error.logpath_report == report_errors - - -def test_remote_build_failed(capsys, mocker): - """Catch remote-build failed errors.""" - mocker.patch.object(sys, "argv", ["cmd", "remote-build"]) - mocker.patch.object(sys.stdin, "isatty", return_value=True) - mocker.patch( - "snapcraft.commands.core22.remote.RemoteBuildCommand.run", - side_effect=RemoteBuildFailedError( - details="Build failed for arch amd64.\nBuild failed for arch arm64." - ), - ) - - cli.run() - - stderr = capsys.readouterr().err.splitlines() - - # Simple verification that our expected message is being printed - assert stderr[0].startswith("remote-build error: Remote build failed.") - assert stderr[1].startswith("Build failed for arch amd64.") - assert stderr[2].startswith("Build failed for arch arm64.") diff --git a/tests/unit/commands/test_remote.py b/tests/unit/commands/test_remote.py index e4de911a47..e01c7d905e 100644 --- a/tests/unit/commands/test_remote.py +++ b/tests/unit/commands/test_remote.py @@ -22,19 +22,18 @@ import sys import time from pathlib import Path -from unittest.mock import ANY, Mock, call +from unittest.mock import ANY, Mock import pytest from craft_application import launchpad from craft_application.errors import RemoteBuildError +from craft_application.remote.git import GitRepo from craft_application.remote.utils import get_build_id -from yaml import safe_dump -from snapcraft import application, cli +from snapcraft import application from snapcraft.const import SnapArch -from snapcraft.errors import ClassicFallback -from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES, LEGACY_BASES -from snapcraft.remote import GitRepo +from snapcraft.errors import ClassicFallback, SnapcraftError +from snapcraft.parts.yaml_utils import CURRENT_BASES, LEGACY_BASES from snapcraft.utils import get_host_architecture # remote-build control logic may check if the working dir is a git repo, @@ -48,12 +47,6 @@ def create_snapcraft_yaml(request, snapcraft_yaml): snapcraft_yaml(base=request.param) -@pytest.fixture() -def use_core22_remote_build(monkeypatch): - """Fixture to force using the core22 remote-build code.""" - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "disable-fallback") - - @pytest.fixture() def fake_sudo(monkeypatch): monkeypatch.setenv("SUDO_USER", "fake") @@ -74,10 +67,11 @@ def mock_confirm(mocker): @pytest.fixture() -def mock_core22_confirm(mocker): - return mocker.patch( - "snapcraft.commands.core22.remote.confirm_with_user", return_value=True +def mock_remote_build_run(mocker): + _mock_remote_build_run = mocker.patch( + "snapcraft.commands.remote.RemoteBuildCommand._run" ) + return _mock_remote_build_run @pytest.fixture() @@ -113,22 +107,6 @@ def mock_remote_builder_fake_build_process(mocker): mocker.patch("craft_application.services.remotebuild.RemoteBuildService.cleanup") -@pytest.fixture() -def mock_core22_remote_builder(mocker): - _mock_remote_builder = mocker.patch( - "snapcraft.commands.core22.remote.RemoteBuilder" - ) - _mock_remote_builder.return_value.has_outstanding_build.return_value = False - return _mock_remote_builder - - -@pytest.fixture() -def mock_run_core22_or_fallback_remote_build(mocker): - return mocker.patch( - "snapcraft.commands.core22.remote.RemoteBuildCommand._run_new_or_fallback_remote_build" - ) - - @pytest.fixture() def mock_run_remote_build(mocker): return mocker.patch( @@ -136,16 +114,9 @@ def mock_run_remote_build(mocker): ) -@pytest.fixture() -def mock_run_core22_remote_build(mocker): - return mocker.patch( - "snapcraft.commands.core22.remote.RemoteBuildCommand._run_new_remote_build" - ) - - @pytest.fixture() def mock_run_legacy(mocker): - return mocker.patch("snapcraft.commands.core22.remote.run_legacy") + return mocker.patch("snapcraft_legacy.cli.legacy.legacy_run") ############# @@ -153,7 +124,7 @@ def mock_run_legacy(mocker): ############# -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.parametrize("project_name", ["something", "something-else"]) def test_set_project( mocker, snapcraft_yaml, base, mock_confirm, fake_services, project_name @@ -173,7 +144,7 @@ def test_set_project( mock_confirm.assert_called_once() -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.parametrize("project_name", ["something", "something_else"]) def test_no_confirmation_for_private_project( mocker, snapcraft_yaml, base, mock_confirm, fake_services, project_name @@ -193,7 +164,7 @@ def test_no_confirmation_for_private_project( mock_confirm.assert_not_called() -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv") def test_command_user_confirms_upload( snapcraft_yaml, base, mock_confirm, fake_services @@ -213,24 +184,7 @@ def test_command_user_confirms_upload( ) -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_argv") -def test_command_user_confirms_upload_core22( - mock_core22_confirm, mock_run_core22_or_fallback_remote_build -): - """Run remote-build if the user confirms the upload prompt.""" - cli.run() - - mock_core22_confirm.assert_called_once_with( - "All data sent to remote builders will be publicly available. " - "Are you sure you want to continue?" - ) - mock_run_core22_or_fallback_remote_build.assert_called_once() - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "fake_services") def test_command_user_denies_upload( capsys, @@ -253,27 +207,7 @@ def test_command_user_denies_upload( ) in err -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_argv") -def test_command_user_denies_upload_core22( - capsys, mock_core22_confirm, mock_run_core22_or_fallback_remote_build -): - """Raise an error if the user denies the upload prompt.""" - mock_core22_confirm.return_value = False - - cli.run() - - _, err = capsys.readouterr() - assert ( - "Cannot upload data to build servers.\n" - "Remote build needs explicit acknowledgement " - "that data sent to build servers is public." - ) in err - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "fake_services") def test_command_accept_upload( mocker, snapcraft_yaml, base, mock_confirm, mock_run_remote_build @@ -291,82 +225,7 @@ def test_command_accept_upload( mock_run_remote_build.assert_called_once() -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml") -def test_command_accept_upload_core22( - mock_core22_confirm, mock_run_core22_or_fallback_remote_build, mocker -): - """Do not prompt user if `--launchpad-accept-public-upload` is provided.""" - mocker.patch.object( - sys, "argv", ["snapcraft", "remote-build", "--launchpad-accept-public-upload"] - ) - - cli.run() - - mock_core22_confirm.assert_not_called() - mock_run_core22_or_fallback_remote_build.assert_called_once() - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_command_new_build_arguments_mutually_exclusive_core22(capsys, mocker): - """`--build-for` and `--build-on` are mutually exclusive in the new remote-build.""" - mocker.patch.object( - sys, - "argv", - ["snapcraft", "remote-build", "--build-on", "amd64", "--build-for", "arm64"], - ) - - cli.run() - - _, err = capsys.readouterr() - assert "Error: argument --build-for: not allowed with argument --build-on" in err - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm") -def test_command_legacy_build_arguments_not_mutually_exclusive_core22( - mocker, mock_run_legacy -): - """`--build-for` and `--build-on` are not mutually exclusive for legacy.""" - mocker.patch.object( - sys, - "argv", - ["snapcraft", "remote-build", "--build-on", "amd64", "--build-for", "arm64"], - ) - - cli.run() - - mock_run_legacy.assert_called_once() - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm") -def test_command_build_on_warning_core22( - emitter, mocker, mock_run_core22_or_fallback_remote_build -): - """Warn when `--build-on` is passed.""" - mocker.patch.object( - sys, "argv", ["snapcraft", "remote-build", "--build-on", "arch"] - ) - - cli.run() - - emitter.assert_message("Use --build-for instead of --build-on") - mock_run_core22_or_fallback_remote_build.assert_called_once() - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services", "fake_sudo") def test_remote_build_sudo_warns(emitter, snapcraft_yaml, base, mock_run_remote_build): "Check if a warning is shown when snapcraft is run with sudo." @@ -382,24 +241,6 @@ def test_remote_build_sudo_warns(emitter, snapcraft_yaml, base, mock_run_remote_ mock_run_remote_build.assert_called_once() -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "fake_sudo", "mock_argv" -) -def test_remote_build_sudo_warns_core22( - emitter, mock_run_core22_or_fallback_remote_build -): - """Warn when snapcraft is run with sudo.""" - cli.run() - - emitter.assert_message( - "Running with 'sudo' may cause permission errors and is discouraged." - ) - mock_run_core22_or_fallback_remote_build.assert_called_once() - - @pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") def test_cannot_load_snapcraft_yaml(capsys, mocker): """Raise an error if the snapcraft.yaml does not exist.""" @@ -409,19 +250,7 @@ def test_cannot_load_snapcraft_yaml(capsys, mocker): app.run() -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -def test_cannot_load_snapcraft_yaml_core22(capsys): - """Raise an error if the snapcraft.yaml does not exist.""" - cli.run() - - _, err = capsys.readouterr() - assert ( - "Could not find snap/snapcraft.yaml. " - "Are you sure you are in the right directory?" in err - ) - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") def test_launchpad_timeout_default(mocker, snapcraft_yaml, base, fake_services): """Check if no timeout is set by default.""" @@ -437,30 +266,7 @@ def test_launchpad_timeout_default(mocker, snapcraft_yaml, base, fake_services): assert app.services.remote_build._deadline is None -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_core22_confirm", - "use_core22_remote_build", - "mock_argv", -) -def test_launchpad_timeout_default_core22(mock_core22_remote_builder): - """Use the default timeout `0` when `--launchpad-timeout` is not provided.""" - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=ANY, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") def test_launchpad_timeout(mocker, snapcraft_yaml, base, fake_services): """Set the timeout for the remote builder.""" @@ -480,332 +286,158 @@ def test_launchpad_timeout(mocker, snapcraft_yaml, base, fake_services): assert app.services.remote_build._deadline > time.monotonic_ns() + 90 * 10**9 -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_launchpad_timeout_core22(mocker, mock_core22_remote_builder): - """Pass the `--launchpad-timeout` to the remote builder.""" - mocker.patch.object( - sys, "argv", ["snapcraft", "remote-build", "--launchpad-timeout", "100"] - ) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=ANY, - project_dir=Path(), - timeout=100, - ) - - -######################################################### -# Snapcraft project base tests (no effect since core24) # -######################################################### - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES) -def test_get_effective_base_core22( - base, snapcraft_yaml, mock_run_core22_or_fallback_remote_build -): - """Get the base from a snapcraft.yaml file.""" - snapcraft_yaml(base=base) - - cli.run() - - mock_run_core22_or_fallback_remote_build.assert_called_once_with(base) - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -@pytest.mark.parametrize( - ("base", "build_base"), - [ - ("core24", "devel"), - ("core18", "core22"), - ("bare", "core22"), - ], -) -def test_get_effective_base_with_build_base_core22( - base, build_base, snapcraft_yaml, mock_run_core22_or_fallback_remote_build -): - """The effective base should be the build-base, when provided.""" - snapcraft_yaml(**{"base": base, "build-base": build_base}) - - cli.run() - - if build_base == "devel": - mock_run_core22_or_fallback_remote_build.assert_called_once_with(base) - else: - mock_run_core22_or_fallback_remote_build.assert_called_once_with(build_base) - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES | ESM_BASES) -def test_get_effective_base_type_core22( - base, snapcraft_yaml, mock_run_core22_or_fallback_remote_build -): - """The effective base should be the name when building a base.""" - snapcraft_yaml(**{"base": base, "name": base, "type": "base"}) - - cli.run() - - mock_run_core22_or_fallback_remote_build.assert_called_once_with(base) - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -def test_get_effective_base_unknown_core22(capsys, snapcraft_yaml): - """Raise an error for unknown bases.""" - snapcraft_yaml(base="core10") - - cli.run() - - _, err = capsys.readouterr() - assert "Unknown base 'core10' in 'snap/snapcraft.yaml'." in err - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -def test_get_effective_base_none_core22(capsys, snapcraft_yaml): - """Raise an error if there is no base in the snapcraft.yaml.""" - snapcraft_yaml() - - cli.run() - - _, err = capsys.readouterr() - assert "Could not determine base from 'snap/snapcraft.yaml'." in err - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -def test_get_effective_base_core_esm_warning_core22( - emitter, snapcraft_yaml, mock_run_core22_or_fallback_remote_build -): - """Warn if core, an ESM base, is used.""" - snapcraft_yaml(base="core") - - cli.run() - - mock_run_core22_or_fallback_remote_build.assert_called_once_with("core") - emitter.assert_progress( - "WARNING: base 'core' was last supported on Snapcraft 4 available on the " - "'4.x' channel.", - permanent=True, - ) - - -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm") -def test_get_effective_base_core18_esm_warning_core22( - emitter, snapcraft_yaml, mock_run_core22_or_fallback_remote_build -): - """Warn if core18, an ESM base, is used.""" - snapcraft_yaml(base="core18") - - cli.run() - - mock_run_core22_or_fallback_remote_build.assert_called_once_with("core18") - emitter.assert_progress( - "WARNING: base 'core18' was last supported on Snapcraft 7 available on the " - "'7.x' channel.", - permanent=True, - ) - - ####################### # Control logic tests # ####################### -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") -def test_run_core24_and_later(mocker, snapcraft_yaml, base, fake_services): - """Bases that are core24 and later must use craft-application remote-build.""" +def test_run_core22_and_later(snapcraft_yaml, base, mock_remote_build_run): + """Bases that are core22 and later will use craft-application remote-build.""" snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} snapcraft_yaml(**snapcraft_yaml_dict) - mock_start_builds = mocker.patch( - "snapcraft.services.remotebuild.RemoteBuild.start_builds" - ) application.main() - mock_start_builds.assert_called_once() + mock_remote_build_run.assert_called_once() -@pytest.mark.parametrize("base", {"core22"}) -@pytest.mark.usefixtures("mock_core22_confirm", "mock_argv") -def test_run_core22( - emitter, +@pytest.mark.parametrize("base", LEGACY_BASES) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_core20( snapcraft_yaml, base, - use_core22_remote_build, - mock_run_core22_remote_build, mock_run_legacy, - fake_services, -): - """Bases that is core22 should use core22 remote-build.""" - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - application.main() - - mock_run_core22_remote_build.assert_called_once() - mock_run_legacy.assert_not_called() - emitter.assert_debug( - "Running new remote-build because environment variable " - "'SNAPCRAFT_REMOTE_BUILD_STRATEGY' is 'disable-fallback'" - ) - - -@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) -@pytest.mark.usefixtures("mock_core22_confirm", "mock_argv") -def test_run_core22_and_older( - emitter, snapcraft_yaml, base, mock_run_legacy, fake_services + mock_remote_build_run, ): - """core22 and older bases can use fallback remote-build.""" + """core20 base use fallback remote-build.""" snapcraft_yaml_dict = {"base": base} snapcraft_yaml(**snapcraft_yaml_dict) application.main() mock_run_legacy.assert_called_once() - emitter.assert_debug("Running fallback remote-build") + mock_remote_build_run.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES - {"core22"}, indirect=True -) @pytest.mark.parametrize( "envvar", ["force-fallback", "disable-fallback", "badvalue", None] ) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_core22(envvar, emitter, mock_run_core22_remote_build, monkeypatch): - """Bases newer than core22 run new remote-build regardless of envvar.""" +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar( + monkeypatch, + snapcraft_yaml, + base, + envvar, + mock_remote_build_run, + mock_run_legacy, +): + """Bases core24 and later run new remote-build regardless of envvar.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + if envvar: monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", envvar) else: monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) - cli.run() + application.main() - mock_run_core22_remote_build.assert_called_once() - emitter.assert_debug("Running new remote-build because base is newer than core22") + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_disable_fallback_core22( - emitter, mock_run_core22_remote_build, monkeypatch +@pytest.mark.parametrize("base", LEGACY_BASES) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar_disable_fallback_core20( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch ): - """core22 and older bases run new remote-build if envvar is `disable-fallback`.""" + """core20 base run new remote-build if envvar is `disable-fallback`.""" monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "disable-fallback") + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) - cli.run() + application.main() - mock_run_core22_remote_build.assert_called_once() - emitter.assert_debug( - "Running new remote-build because environment variable " - "'SNAPCRAFT_REMOTE_BUILD_STRATEGY' is 'disable-fallback'" - ) + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_force_fallback_core22(emitter, mock_run_legacy, monkeypatch): +@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar_force_fallback_core22( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): """core22 and older bases run legacy remote-build if envvar is `force-fallback`.""" monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "force-fallback") - cli.run() + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() mock_run_legacy.assert_called_once() - emitter.assert_debug( - "Running fallback remote-build because environment variable " - "'SNAPCRAFT_REMOTE_BUILD_STRATEGY' is 'force-fallback'" - ) + mock_remote_build_run.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_force_fallback_unset_core22(emitter, mock_run_legacy, monkeypatch): - """core22 and older bases run legacy remote-build if envvar is unset.""" +@pytest.mark.parametrize("base", LEGACY_BASES) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar_force_fallback_unset_core20( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core20 base run legacy remote-build if envvar is unset.""" monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) - cli.run() + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() mock_run_legacy.assert_called_once() - emitter.assert_debug("Running fallback remote-build") + mock_remote_build_run.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_force_fallback_empty_core22(emitter, mock_run_legacy, monkeypatch): - """core22 and older bases run legacy remote-build if envvar is empty.""" +@pytest.mark.parametrize("base", {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar_force_fallback_empty_core22( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core22 bases run craft-application remote-build if envvar is empty.""" monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "") - cli.run() + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() - mock_run_legacy.assert_called_once() - emitter.assert_debug("Running fallback remote-build") + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_invalid_core22(capsys, emitter, mock_run_legacy, monkeypatch): - """core22 and older bases raise an error if the envvar is invalid.""" +@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_run_envvar_invalid(snapcraft_yaml, base, monkeypatch): + """core20 and core22 bases raise an error if the envvar is invalid.""" monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "badvalue") - cli.run() + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + with pytest.raises(SnapcraftError) as err: + application.main() - _, err = capsys.readouterr() - assert ( + assert err.match( "Unknown value 'badvalue' in environment variable " "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. Valid values are 'disable-fallback' and " "'force-fallback'" - ) in err - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_in_repo_core22(emitter, mock_run_core22_remote_build, new_dir): - """core22 and older bases run new remote-build if in a git repo.""" - # initialize a git repo - GitRepo(new_dir) - - cli.run() - - mock_run_core22_remote_build.assert_called_once() - emitter.assert_debug( - "Running new remote-build because project is in a git repository" ) -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_not_in_repo_core22(emitter, mock_run_legacy): - """core22 and older bases run legacy remote-build if not in a git repo.""" - cli.run() - - mock_run_legacy.assert_called_once() - emitter.assert_debug("Running fallback remote-build") - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm", "mock_argv") def test_run_in_repo_newer_than_core22( - emitter, mocker, snapcraft_yaml, base, new_dir, fake_services + emitter, + mocker, + snapcraft_yaml, + base, + new_dir, + fake_services, ): """Bases newer than core22 run craft-application remote-build regardless of being in a repo.""" # initialize a git repo @@ -821,55 +453,8 @@ def test_run_in_repo_newer_than_core22( mock_start_builds.assert_called_once() -@pytest.mark.parametrize( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_in_shallow_repo_core22(emitter, mock_run_legacy, new_dir): - """core22 and older bases fall back to legacy remote-build if in a shallow git repo.""" - root_path = Path(new_dir) - git_normal_path = root_path / "normal" - git_normal_path.mkdir() - git_shallow_path = root_path / "shallow" - - shutil.move(root_path / "snap", git_normal_path) - - repo_normal = GitRepo(git_normal_path) - (repo_normal.path / "1").write_text("1") - repo_normal.add_all() - repo_normal.commit("1") - - (repo_normal.path / "2").write_text("2") - repo_normal.add_all() - repo_normal.commit("2") - - (repo_normal.path / "3").write_text("3") - repo_normal.add_all() - repo_normal.commit("3") - - # pygit2 does not support shallow cloning, so we use git directly - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - git_normal_path.absolute().as_uri(), - git_shallow_path.absolute().as_posix(), - ], - check=True, - ) - - os.chdir(git_shallow_path) - cli.run() - - mock_run_legacy.assert_called_once() - emitter.assert_debug("Current git repository is shallow cloned.") - emitter.assert_debug("Running fallback remote-build") - - @pytest.mark.xfail(reason="not implemented in craft-application") -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures( "mock_confirm", "mock_argv", @@ -931,249 +516,12 @@ def test_run_in_shallow_repo_unsupported( ) in err -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES - {"core22"}, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_core22_confirm", - "mock_argv", - "use_core22_remote_build", -) -def test_run_in_shallow_repo_unsupported_core22(capsys, new_dir): - """devel / core24 and newer bases run new remote-build in a shallow git repo.""" - root_path = Path(new_dir) - git_normal_path = root_path / "normal" - git_normal_path.mkdir() - git_shallow_path = root_path / "shallow" - - shutil.move(root_path / "snap", git_normal_path) - - repo_normal = GitRepo(git_normal_path) - (repo_normal.path / "1").write_text("1") - repo_normal.add_all() - repo_normal.commit("1") - - (repo_normal.path / "2").write_text("2") - repo_normal.add_all() - repo_normal.commit("2") - - (repo_normal.path / "3").write_text("3") - repo_normal.add_all() - repo_normal.commit("3") - - # pygit2 does not support shallow cloning, so we use git directly - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - git_normal_path.absolute().as_uri(), - git_shallow_path.absolute().as_posix(), - ], - check=True, - ) - - os.chdir(git_shallow_path) - - # no exception because run() catches it - ret = cli.run() - assert ret != 0 - _, err = capsys.readouterr() - - assert ( - "Remote builds are not supported for projects in shallowly cloned " - "git repositories." - ) in err - - ###################### # Architecture tests # ###################### -@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES) -@pytest.mark.parametrize( - ["archs", "expected_archs"], - [ - # single arch as scalar - ([{"build-on": "arm64", "build-for": "arm64"}], ["arm64"]), - # single arch as list - ([{"build-on": ["arm64"], "build-for": ["arm64"]}], ["arm64"]), - # no build-for as scalar - ([{"build-on": "arm64"}], ["arm64"]), - # no build-for as list - ([{"build-on": ["arm64"]}], ["arm64"]), - # multiple archs as scalars - ( - [ - {"build-on": "amd64", "build-for": "amd64"}, - {"build-on": "arm64", "build-for": "arm64"}, - ], - ["amd64", "arm64"], - ), - # multiple archs as lists - ( - [ - {"build-on": ["amd64"], "build-for": ["amd64"]}, - {"build-on": ["arm64"], "build-for": ["arm64"]}, - ], - ["amd64", "arm64"], - ), - # multiple build-ons - ( - [ - {"build-on": ["amd64", "arm64"], "build-for": "amd64"}, - {"build-on": ["armhf", "powerpc"], "build-for": "arm64"}, - ], - ["amd64", "arm64", "armhf", "powerpc"], - ), - ], -) -@pytest.mark.usefixtures("mock_argv", "mock_core22_confirm", "use_core22_remote_build") -def test_determine_architectures_from_snapcraft_yaml_core22( - archs, expected_archs, base, snapcraft_yaml, mock_core22_remote_builder -): - """Parse `build-on` architectures from a snapcraft.yaml file.""" - snapcraft_yaml(base=base, architectures=archs) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=expected_archs, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_argv", - "mock_core22_confirm", - "use_core22_remote_build", -) -def test_determine_architectures_host_arch_core22(mocker, mock_core22_remote_builder): - """Use host architecture if not defined in the snapcraft.yaml.""" - mocker.patch( - "snapcraft.commands.core22.remote.get_host_architecture", return_value="arm64" - ) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=["arm64"], - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize("build_flag", ["--build-for", "--build-on"]) -@pytest.mark.parametrize( - ("archs", "expected_archs"), - [ - ("amd64", ["amd64"]), - ("amd64,arm64", ["amd64", "arm64"]), - ("amd64,amd64,arm64 ", ["amd64", "amd64", "arm64"]), - ], -) -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_determine_architectures_provided_by_user_duplicate_arguments_core22( - build_flag, archs, expected_archs, mocker, mock_core22_remote_builder -): - """Argparse should only consider the last argument provided for build flags.""" - mocker.patch.object( - sys, - "argv", - # `--build-{for|on} armhf` should get silently ignored by argparse - ["snapcraft", "remote-build", build_flag, "armhf", build_flag, archs], - ) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=expected_archs, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize("build_flag", ["--build-for", "--build-on"]) -@pytest.mark.parametrize( - ("archs", "expected_archs"), - [ - ("amd64", ["amd64"]), - ("amd64,arm64", ["amd64", "arm64"]), - ("amd64, arm64", ["amd64", "arm64"]), - ("amd64,arm64 ", ["amd64", "arm64"]), - ("amd64,arm64,armhf", ["amd64", "arm64", "armhf"]), - (" amd64 , arm64 , armhf ", ["amd64", "arm64", "armhf"]), - # launchpad will accept and ignore duplicates - (" amd64 , arm64 , arm64 ", ["amd64", "arm64", "arm64"]), - ], -) -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_determine_architectures_provided_by_user_core22( - build_flag, archs, expected_archs, mocker, mock_core22_remote_builder -): - """Use architectures provided by the user.""" - mocker.patch.object(sys, "argv", ["snapcraft", "remote-build", build_flag, archs]) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=expected_archs, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES) -@pytest.mark.usefixtures("mock_core22_confirm", "use_core22_remote_build") -def test_determine_architectures_error_core22(base, capsys, snapcraft_yaml, mocker): - """Error if `--build-for` is provided and archs are in the snapcraft.yaml.""" - mocker.patch.object( - sys, "argv", ["snapcraft", "remote-build", "--build-for", "amd64"] - ) - snapcraft_yaml( - base=base, architectures=[{"build-on": "arm64", "build-for": "arm64"}] - ) - - cli.run() - - _, err = capsys.readouterr() - assert ( - "Cannot use `--build-on` because architectures are already defined in " - "snapcraft.yaml." - ) in err - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) def test_no_platform_defined_no_platform_or_build_for( mocker, snapcraft_yaml, @@ -1355,7 +703,7 @@ def test_build_for( mock_start_builds.assert_called_once_with(ANY, architectures=[arch]) -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.parametrize( ["build_for", "arch"], [("amd64", SnapArch.amd64), ("arm64", SnapArch.arm64)] ) @@ -1427,106 +775,12 @@ def test_build_for_error( assert "build-for 'nonexistent' is not supported." in err -################## -# Build id tests # -################## - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_build_id_provided_core22(mocker, mock_core22_remote_builder): - """Pass the build id provided as an argument.""" - mocker.patch.object( - sys, "argv", ["snapcraft", "remote-build", "--build-id", "test-build-id"] - ) - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id="test-build-id", - project_name="mytest", - architectures=ANY, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_core22_confirm", - "mock_argv", - "use_core22_remote_build", -) -def test_build_id_not_provided_core22(mock_core22_remote_builder): - """Pass `None` for the build id if it is not provided as an argument.""" - - cli.run() - - mock_core22_remote_builder.assert_called_with( - app_name="snapcraft", - build_id=None, - project_name="mytest", - architectures=ANY, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES) -@pytest.mark.usefixtures("mock_core22_confirm", "mock_argv", "use_core22_remote_build") -def test_build_id_no_project_name_error_core22(base, capsys): - """Raise an error if there is no name in the snapcraft.yaml file.""" - content = { - "base": base, - "version": "0.1", - "summary": "test", - "description": "test", - "grade": "stable", - "confinement": "strict", - "parts": { - "part1": { - "plugin": "nil", - } - }, - } - yaml_path = Path("snapcraft.yaml") - yaml_path.write_text(safe_dump(content, indent=2), encoding="utf-8") - - cli.run() - - _, err = capsys.readouterr() - assert "Could not get project name from 'snapcraft.yaml'." in err - - ######################## # Remote builder tests # ######################## -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_status_core22(mocker, mock_core22_remote_builder): - """Print the status when `--status` is provided.""" - mocker.patch.object(sys, "argv", ["snapcraft", "remote-build", "--status"]) - - cli.run() - - assert mock_core22_remote_builder.mock_calls[-1] == call().print_status() - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm") def test_monitor_build(mocker, emitter, snapcraft_yaml, base, fake_services): """Test the monitor_build method and the progress emitter.""" @@ -1578,7 +832,7 @@ def test_monitor_build(mocker, emitter, snapcraft_yaml, base, fake_services): emitter.assert_progress("Succeeded: amd64") -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm") def test_monitor_build_error(mocker, emitter, snapcraft_yaml, base, fake_services): """Test the monitor_build cleanup when an error occurs.""" @@ -1621,7 +875,7 @@ def test_monitor_build_error(mocker, emitter, snapcraft_yaml, base, fake_service emitter.assert_progress("Cleaning up") -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm") def test_monitor_build_timeout(mocker, emitter, snapcraft_yaml, base, fake_services): """Test the monitor_build timeout.""" @@ -1665,25 +919,7 @@ def test_monitor_build_timeout(mocker, emitter, snapcraft_yaml, base, fake_servi ) -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_core22_confirm", - "mock_core22_remote_builder", - "use_core22_remote_build", -) -def test_recover_no_build_core22(emitter, mocker): - """Warn if no build is found when `--recover` is provided.""" - mocker.patch.object(sys, "argv", ["snapcraft", "remote-build", "--recover"]) - - cli.run() - - emitter.assert_progress("No build found", permanent=True) - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm") def test_recover_build(mocker, snapcraft_yaml, base, fake_services): """Recover a build when `--recover` is provided.""" @@ -1722,75 +958,7 @@ def test_recover_build(mocker, snapcraft_yaml, base, fake_services): mock_cleanup.assert_called_once() -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_core22_confirm", "use_core22_remote_build" -) -def test_recover_build_core22(emitter, mocker, mock_core22_remote_builder): - """Recover a build when `--recover` is provided.""" - mocker.patch.object(sys, "argv", ["snapcraft", "remote-build", "--recover"]) - mock_core22_remote_builder.return_value.has_outstanding_build.return_value = True - - cli.run() - - assert mock_core22_remote_builder.mock_calls[-3:] == [ - call().print_status(), - call().monitor_build(), - call().clean_build(), - ] - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_argv", - "mock_core22_confirm", - "use_core22_remote_build", -) -def test_recover_build_user_confirms_core22( - emitter, mocker, mock_core22_remote_builder -): - """Recover a build when a user confirms.""" - mock_core22_remote_builder.return_value.has_outstanding_build.return_value = True - - cli.run() - - assert mock_core22_remote_builder.mock_calls[-3:] == [ - call().print_status(), - call().monitor_build(), - call().clean_build(), - ] - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", "mock_argv", "use_core22_remote_build" -) -def test_recover_build_user_denies_core22(emitter, mocker, mock_core22_remote_builder): - """Clean and start a new build when a user denies to recover an existing build.""" - mocker.patch( - # confirm data upload, deny build recovery - "snapcraft.commands.core22.remote.confirm_with_user", - side_effect=[True, False], - ) - mock_core22_remote_builder.return_value.has_outstanding_build.return_value = True - - cli.run() - - assert mock_core22_remote_builder.mock_calls[-3:] == [ - call().start_build(), - call().monitor_build(), - call().clean_build(), - ] - - -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm", "mock_argv") def test_remote_build(mocker, snapcraft_yaml, base, fake_services): """Test the remote-build command.""" @@ -1828,7 +996,7 @@ def test_remote_build(mocker, snapcraft_yaml, base, fake_services): mock_cleanup.assert_called_once() -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm", "mock_argv") def test_remote_build_error(emitter, mocker, snapcraft_yaml, base, fake_services): """Test the remote-build command when an error occurs.""" @@ -1857,23 +1025,3 @@ def test_remote_build_error(emitter, mocker, snapcraft_yaml, base, fake_services emitter.assert_progress("Starting build failed.", permanent=True) emitter.assert_progress("Cleaning up") - - -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True -) -@pytest.mark.usefixtures( - "create_snapcraft_yaml", - "mock_argv", - "mock_core22_confirm", - "use_core22_remote_build", -) -def test_remote_build_core22(emitter, mocker, mock_core22_remote_builder): - """Clean and start a new build.""" - cli.run() - - assert mock_core22_remote_builder.mock_calls[-3:] == [ - call().start_build(), - call().monitor_build(), - call().clean_build(), - ] diff --git a/tests/unit/remote/__init__.py b/tests/unit/remote/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/unit/remote/test_errors.py b/tests/unit/remote/test_errors.py deleted file mode 100644 index 9420decaa9..0000000000 --- a/tests/unit/remote/test_errors.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from snapcraft.remote import errors - - -def test_git_error(): - """Test GitError.""" - error = errors.GitError("Error details.") - - assert str(error) == "Git operation failed.\nError details." - assert ( - repr(error) - == "GitError(brief='Git operation failed.', details='Error details.')" - ) - assert error.brief == "Git operation failed." - assert error.details == "Error details." - - -def test_remote_build_timeout_error(): - """Test RemoteBuildTimeoutError.""" - error = errors.RemoteBuildTimeoutError( - recovery_command="craftapp remote-build --recover --build-id test-id" - ) - - assert str(error) == ( - "Remote build command timed out.\nBuild may still be running on Launchpad and " - "can be recovered with 'craftapp remote-build --recover --build-id test-id'." - ) - assert repr(error) == ( - "RemoteBuildTimeoutError(brief='Remote build command timed out.', " - 'details="Build may still be running on Launchpad and can be recovered with ' - "'craftapp remote-build --recover --build-id test-id'.\")" - ) - assert error.brief == "Remote build command timed out." - assert error.details == ( - "Build may still be running on Launchpad and can be recovered with " - "'craftapp remote-build --recover --build-id test-id'." - ) - - -def test_launchpad_https_error(): - """Test LaunchpadHttpsError.""" - error = errors.LaunchpadHttpsError() - - assert str(error) == ( - "Failed to connect to Launchpad API service.\n" - "Verify connectivity to https://api.launchpad.net and retry build." - ) - assert repr(error) == ( - "LaunchpadHttpsError(brief='Failed to connect to Launchpad API service.', " - "details='Verify connectivity to https://api.launchpad.net and retry build.')" - ) - - assert error.brief == "Failed to connect to Launchpad API service." - assert error.details == ( - "Verify connectivity to https://api.launchpad.net and retry build." - ) - - -def test_unsupported_architecture_error(): - """Test UnsupportedArchitectureError.""" - error = errors.UnsupportedArchitectureError(architectures=["amd64", "arm64"]) - - assert str(error) == ( - "Architecture not supported by the remote builder.\nThe following " - "architectures are not supported by the remote builder: ['amd64', 'arm64'].\n" - "Please remove them from the architecture list and try again." - ) - assert repr(error) == ( - "UnsupportedArchitectureError(brief='Architecture not supported by the remote " - "builder.', details=\"The following architectures are not supported by the " - "remote builder: ['amd64', 'arm64'].\\nPlease remove them from the " - 'architecture list and try again.")' - ) - - assert error.brief == "Architecture not supported by the remote builder." - assert error.details == ( - "The following architectures are not supported by the remote builder: " - "['amd64', 'arm64'].\nPlease remove them from the architecture list and " - "try again." - ) - - -def test_accept_public_upload_error(): - """Test AcceptPublicUploadError.""" - error = errors.AcceptPublicUploadError() - - assert str(error) == ( - "Cannot upload data to build servers.\nRemote build needs explicit " - "acknowledgement that data sent to build servers is public.\n" - "In non-interactive runs, please use the option " - "`--launchpad-accept-public-upload`." - ) - assert repr(error) == ( - "AcceptPublicUploadError(brief='Cannot upload data to build servers.', " - "details='Remote build needs explicit acknowledgement that data sent to build " - "servers is public.\\n" - "In non-interactive runs, please use the option " - "`--launchpad-accept-public-upload`.')" - ) - - assert error.brief == "Cannot upload data to build servers." - assert error.details == ( - "Remote build needs explicit acknowledgement that data sent to build servers " - "is public.\n" - "In non-interactive runs, please use the option " - "`--launchpad-accept-public-upload`." - ) diff --git a/tests/unit/remote/test_git.py b/tests/unit/remote/test_git.py deleted file mode 100644 index b90f7d37d5..0000000000 --- a/tests/unit/remote/test_git.py +++ /dev/null @@ -1,602 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -"""Tests for the pygit2 wrapper class.""" - -import re -import subprocess -from pathlib import Path -from unittest.mock import ANY - -import pygit2 -import pytest - -from snapcraft.remote import ( - GitError, - GitRepo, - GitType, - RemoteBuildInvalidGitRepoError, - check_git_repo_for_remote_build, - get_git_repo_type, - is_repo, -) - - -def test_is_repo(new_dir): - """Check if directory is a repo.""" - GitRepo(new_dir) - - assert is_repo(new_dir) - - -def test_is_not_repo(new_dir): - """Check if a directory is not a repo.""" - assert not is_repo(new_dir) - - -def test_git_repo_type_invalid(new_dir): - """Check if directory is an invalid repo.""" - assert get_git_repo_type(new_dir) == GitType.INVALID - - -def test_git_repo_type_normal(new_dir): - """Check if directory is a repo.""" - GitRepo(new_dir) - - assert get_git_repo_type(new_dir) == GitType.NORMAL - - -def test_git_repo_type_shallow(new_dir): - """Check if directory is a shallow cloned repo.""" - root_path = Path(new_dir) - git_normal_path = root_path / "normal" - git_normal_path.mkdir() - git_shallow_path = root_path / "shallow" - - repo_normal = GitRepo(git_normal_path) - (repo_normal.path / "1").write_text("1") - repo_normal.add_all() - repo_normal.commit("1") - - (repo_normal.path / "2").write_text("2") - repo_normal.add_all() - repo_normal.commit("2") - - (repo_normal.path / "3").write_text("3") - repo_normal.add_all() - repo_normal.commit("3") - - # pygit2 does not support shallow cloning, so we use git directly - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - git_normal_path.absolute().as_uri(), - git_shallow_path.absolute().as_posix(), - ], - check=True, - ) - - assert get_git_repo_type(git_shallow_path) == GitType.SHALLOW - - -def test_is_repo_path_only(new_dir): - """Only look at the path for a repo.""" - Path("parent-repo/not-a-repo/child-repo").mkdir(parents=True) - # create the parent and child repos - GitRepo(Path("parent-repo")) - GitRepo(Path("parent-repo/not-a-repo/child-repo")) - - assert is_repo(Path("parent-repo")) - assert not is_repo(Path("parent-repo/not-a-repo")) - assert is_repo(Path("parent-repo/not-a-repo/child-repo")) - - -def test_is_repo_error(new_dir, mocker): - """Raise an error if git fails to check a repo.""" - mocker.patch("pygit2.discover_repository", side_effect=pygit2.GitError) - - with pytest.raises(GitError) as raised: - assert is_repo(new_dir) - - assert raised.value.details == ( - f"Could not check for git repository in {str(new_dir)!r}." - ) - - -def test_init_repo(new_dir): - """Initialize a GitRepo object.""" - repo = GitRepo(new_dir) - - assert is_repo(new_dir) - assert repo.path == new_dir - - -def test_init_existing_repo(new_dir): - """Initialize a GitRepo object in an existing git repository.""" - # initialize a repo - GitRepo(new_dir) - - # creating a new GitRepo object will not re-initialize the repo - repo = GitRepo(new_dir) - - assert is_repo(new_dir) - assert repo.path == new_dir - - -def test_init_repo_no_directory(new_dir): - """Raise an error if the directory is missing.""" - with pytest.raises(FileNotFoundError) as raised: - GitRepo(new_dir / "missing") - - assert str(raised.value) == ( - "Could not initialize a git repository because " - f"{str(new_dir / 'missing')!r} does not exist or is not a directory." - ) - - -def test_init_repo_not_a_directory(new_dir): - """Raise an error if the path is not a directory.""" - Path("regular-file").touch() - - with pytest.raises(FileNotFoundError) as raised: - GitRepo(new_dir / "regular-file") - - assert str(raised.value) == ( - "Could not initialize a git repository because " - f"{str(new_dir / 'regular-file')!r} does not exist or is not a directory." - ) - - -def test_init_repo_error(new_dir, mocker): - """Raise an error if the repo cannot be initialized.""" - mocker.patch("pygit2.init_repository", side_effect=pygit2.GitError) - - with pytest.raises(GitError) as raised: - GitRepo(new_dir) - - assert raised.value.details == ( - f"Could not initialize a git repository in {str(new_dir)!r}." - ) - - -def test_add_all(new_dir): - """Add all files.""" - repo = GitRepo(new_dir) - (repo.path / "foo").touch() - (repo.path / "bar").touch() - repo.add_all() - - status = pygit2.Repository( # type: ignore[reportAttributeAccessIssue] - new_dir - ).status() - - assert status == { - "foo": pygit2.GIT_STATUS_INDEX_NEW, - "bar": pygit2.GIT_STATUS_INDEX_NEW, - } - - -def test_add_all_no_files_to_add(new_dir): - """`add_all` should succeed even if there are no files to add.""" - repo = GitRepo(new_dir) - repo.add_all() - - status = pygit2.Repository( # type: ignore[reportAttributeAccessIssue] - new_dir - ).status() - - assert status == {} - - -def test_add_all_error(new_dir, mocker): - """Raise an error if the changes could not be added.""" - mocker.patch("pygit2.Index.add_all", side_effect=pygit2.GitError) - repo = GitRepo(new_dir) - - with pytest.raises(GitError) as raised: - repo.add_all() - - assert raised.value.details == ( - f"Could not add changes for the git repository in {str(new_dir)!r}." - ) - - -def test_commit(new_dir): - """Commit a file and confirm it is in the tree.""" - repo = GitRepo(new_dir) - (repo.path / "test-file").touch() - repo.add_all() - - repo.commit() - - # verify commit (the `isinstance` checks are to satsify pyright) - commit = pygit2.Repository( # type: ignore[reportAttributeAccessIssue] - new_dir - ).revparse_single("HEAD") - assert isinstance(commit, pygit2.Commit) - assert commit.message == "auto commit" - assert commit.committer.name == "auto commit" - assert commit.committer.email == "auto commit" - - # verify tree - tree = commit.tree - assert isinstance(tree, pygit2.Tree) - assert len(tree) == 1 - - # verify contents of tree - blob = tree[0] - assert isinstance(blob, pygit2.Blob) - assert blob.name == "test-file" - - -def test_commit_write_tree_error(new_dir, mocker): - """Raise an error if the tree cannot be created.""" - mocker.patch("pygit2.Index.write_tree", side_effect=pygit2.GitError) - repo = GitRepo(new_dir) - (repo.path / "test-file").touch() - repo.add_all() - - with pytest.raises(GitError) as raised: - repo.commit() - - assert raised.value.details == ( - f"Could not create a tree for the git repository in {str(new_dir)!r}." - ) - - -def test_commit_error(new_dir, mocker): - """Raise an error if the commit cannot be created.""" - mocker.patch("pygit2.Repository.create_commit", side_effect=pygit2.GitError) - repo = GitRepo(new_dir) - (repo.path / "test-file").touch() - repo.add_all() - - with pytest.raises(GitError) as raised: - repo.commit() - - assert raised.value.details == ( - f"Could not create a commit for the git repository in {str(new_dir)!r}." - ) - - -def test_is_clean(new_dir): - """Check if a repo is clean.""" - repo = GitRepo(new_dir) - - assert repo.is_clean() - - (repo.path / "foo").touch() - - assert not repo.is_clean() - - -def test_is_clean_error(new_dir, mocker): - """Check if git fails when checking if the repo is clean.""" - mocker.patch("pygit2.Repository.status", side_effect=pygit2.GitError) - repo = GitRepo(new_dir) - - with pytest.raises(GitError) as raised: - repo.is_clean() - - assert raised.value.details == ( - f"Could not check if the git repository in {str(new_dir)!r} is clean." - ) - - -def test_push_url(new_dir): - """Push the default ref (HEAD) to a remote branch.""" - # create a local repo and make a commit - Path("local-repo").mkdir() - repo = GitRepo(Path("local-repo")) - (repo.path / "test-file").touch() - repo.add_all() - repo.commit() - # create a bare remote repo - Path("remote-repo").mkdir() - remote = pygit2.init_repository(Path("remote-repo"), True) - - repo.push_url( - remote_url=f"file://{str(Path('remote-repo').absolute())}", - remote_branch="test-branch", - ) - - # verify commit in remote (the `isinstance` checks are to satsify pyright) - commit = remote.revparse_single("test-branch") - assert isinstance(commit, pygit2.Commit) - assert commit.message == "auto commit" - assert commit.committer.name == "auto commit" - assert commit.committer.email == "auto commit" - # verify tree in remote - tree = commit.tree - assert isinstance(tree, pygit2.Tree) - assert len(tree) == 1 - # verify contents of tree in remote - blob = tree[0] - assert isinstance(blob, pygit2.Blob) - assert blob.name == "test-file" - - -def test_push_url_detached_head(new_dir): - """Push a detached HEAD to a remote branch.""" - # create a local repo and make two commits - Path("local-repo").mkdir() - repo = GitRepo(Path("local-repo")) - (repo.path / "test-file-1").touch() - repo.add_all() - repo.commit() - (repo.path / "test-file-2").touch() - repo.add_all() - repo.commit() - # detach HEAD to first commit - first_commit = repo._repo.revparse_single("HEAD~1") - repo._repo.checkout_tree(first_commit) - repo._repo.set_head(first_commit.id) - # create a bare remote repo - Path("remote-repo").mkdir() - remote = pygit2.init_repository(Path("remote-repo"), True) - - # push the detached HEAD to the remote - repo.push_url( - remote_url=f"file://{str(Path('remote-repo').absolute())}", - remote_branch="test-branch", - ) - - # verify commit in remote (the `isinstance` checks are to satsify pyright) - commit = remote.revparse_single("test-branch") - assert isinstance(commit, pygit2.Commit) - assert commit.message == "auto commit" - assert commit.committer.name == "auto commit" - assert commit.committer.email == "auto commit" - # verify tree in remote - tree = commit.tree - assert isinstance(tree, pygit2.Tree) - assert len(tree) == 1 - # verify contents of tree in remote are from the first commit - blob = tree[0] - assert isinstance(blob, pygit2.Blob) - assert blob.name == "test-file-1" - - -def test_push_url_branch(new_dir): - """Push a branch to a remote branch.""" - # create a local repo and make a commit - Path("local-repo").mkdir() - repo = GitRepo(Path("local-repo")) - (repo.path / "test-file").touch() - repo.add_all() - repo.commit() - # create a bare remote repo - Path("remote-repo").mkdir() - remote = pygit2.init_repository(Path("remote-repo"), True) - - repo.push_url( - remote_url=f"file://{str(Path('remote-repo').absolute())}", - remote_branch="test-branch", - # use the branch name - ref=repo._repo.head.shorthand, - ) - - # verify commit in remote (the `isinstance` checks are to satsify pyright) - commit = remote.revparse_single("test-branch") - assert isinstance(commit, pygit2.Commit) - assert commit.message == "auto commit" - assert commit.committer.name == "auto commit" - assert commit.committer.email == "auto commit" - # verify tree in remote - tree = commit.tree - assert isinstance(tree, pygit2.Tree) - assert len(tree) == 1 - # verify contents of tree in remote - blob = tree[0] - assert isinstance(blob, pygit2.Blob) - assert blob.name == "test-file" - - -def test_push_tags(new_dir): - """Verify that tags are push by trying to ref them from the remote.""" - # create a local repo and make a commit - Path("local-repo").mkdir() - repo = GitRepo(Path("local-repo")) - (repo.path / "test-file").touch() - repo.add_all() - commit = repo.commit() - tag = "tag1" - repo._repo.create_reference(f"refs/tags/{tag}", commit) - # create a bare remote repo - Path("remote-repo").mkdir() - remote = pygit2.init_repository(Path("remote-repo"), True) - - repo.push_url( - remote_url=f"file://{str(Path('remote-repo').absolute())}", - remote_branch="test-branch", - push_tags=True, - ) - - # verify commit through tag in remote (the `isinstance` checks are to satsify pyright) - commit = remote.revparse_single(tag) - assert isinstance(commit, pygit2.Commit) - assert commit.message == "auto commit" - assert commit.committer.name == "auto commit" - assert commit.committer.email == "auto commit" - # verify tree in remote - tree = commit.tree - assert isinstance(tree, pygit2.Tree) - assert len(tree) == 1 - # verify contents of tree in remote - blob = tree[0] - assert isinstance(blob, pygit2.Blob) - assert blob.name == "test-file" - - -def test_push_url_refspec_unknown_ref(new_dir): - """Raise an error for an unknown refspec.""" - repo = GitRepo(new_dir) - - with pytest.raises(GitError) as raised: - repo.push_url(remote_url="test-url", remote_branch="test-branch", ref="bad-ref") - - assert raised.value.details == ( - "Could not resolve reference 'bad-ref' for the git repository " - f"in {str(new_dir)!r}." - ) - - -@pytest.mark.parametrize( - ("url", "expected_url"), - [ - # no-op if token is not in url - ("fake-url", "fake-url"), - # hide single occurrence of the token - ("fake-url/test-token", "fake-url/"), - # hide multiple occurrences of the token - ("fake-url/test-token/test-token", "fake-url//"), - ], -) -def test_push_url_hide_token(url, expected_url, mocker, new_dir): - """Hide the token in the log and error output.""" - mock_logs = mocker.patch("logging.Logger.debug") - - repo = GitRepo(new_dir) - (repo.path / "test-file").touch() - repo.add_all() - repo.commit() - expected_error_details = ( - f"Could not push 'HEAD' to {expected_url!r} with refspec " - "'.*:refs/heads/test-branch' for the git repository " - f"in {str(new_dir)!r}." - ) - - with pytest.raises(GitError) as raised: - repo.push_url( - remote_url=url, - remote_branch="test-branch", - token="test-token", - ) - - # token should be hidden in the log output - mock_logs.assert_called_with( - # The last argument is the refspec `.*:refs/heads/test-branch`, which can only - # be asserted with regex. It is not relevant to this test, so `ANY` is used. - "Pushing %r to remote %r with refspec %r.", - "HEAD", - expected_url, - ANY, - ) - - # token should be hidden in the error message - assert raised.value.details is not None - assert re.match(expected_error_details, raised.value.details) - - -def test_push_url_refspec_git_error(mocker, new_dir): - """Raise an error if git fails when looking for a refspec.""" - mocker.patch( - "pygit2.Repository.lookup_reference_dwim", - side_effect=pygit2.GitError, - ) - repo = GitRepo(new_dir) - - with pytest.raises(GitError) as raised: - repo.push_url(remote_url="test-url", remote_branch="test-branch", ref="bad-ref") - - assert raised.value.details == ( - "Could not resolve reference 'bad-ref' for the git repository " - f"in {str(new_dir)!r}." - ) - - -def test_push_url_push_error(new_dir): - """Raise an error when the refspec cannot be pushed.""" - repo = GitRepo(new_dir) - (repo.path / "test-file").touch() - repo.add_all() - repo.commit() - expected_error_details = ( - "Could not push 'HEAD' to 'bad-url' with refspec " - "'.*:refs/heads/test-branch' for the git repository " - f"in {str(new_dir)!r}." - ) - - with pytest.raises(GitError) as raised: - repo.push_url(remote_url="bad-url", remote_branch="test-branch") - - assert raised.value.details is not None - assert re.match(expected_error_details, raised.value.details) - - -def test_check_git_repo_for_remote_build_invalid(new_dir): - """Check if directory is an invalid repo.""" - with pytest.raises( - RemoteBuildInvalidGitRepoError, - match=( - f"Could not find a git repository in {str(new_dir.absolute())!r}. " - "The project must be in the top-level of a git repository." - ), - ): - check_git_repo_for_remote_build(new_dir) - - -def test_check_git_repo_for_remote_build_normal(new_dir): - """Check if directory is a repo.""" - GitRepo(new_dir) - check_git_repo_for_remote_build(new_dir) - - -def test_check_git_repo_for_remote_build_shallow(new_dir): - """Check if directory is a shallow cloned repo.""" - root_path = Path(new_dir) - git_normal_path = root_path / "normal" - git_normal_path.mkdir() - git_shallow_path = root_path / "shallow" - - repo_normal = GitRepo(git_normal_path) - (repo_normal.path / "1").write_text("1") - repo_normal.add_all() - repo_normal.commit("1") - - (repo_normal.path / "2").write_text("2") - repo_normal.add_all() - repo_normal.commit("2") - - (repo_normal.path / "3").write_text("3") - repo_normal.add_all() - repo_normal.commit("3") - - # pygit2 does not support shallow cloning, so we use git directly - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - git_normal_path.absolute().as_uri(), - git_shallow_path.absolute().as_posix(), - ], - check=True, - ) - - with pytest.raises( - RemoteBuildInvalidGitRepoError, - match=( - "Remote builds are not supported for projects in shallowly cloned " - "git repositories." - ), - ): - check_git_repo_for_remote_build(git_shallow_path) diff --git a/tests/unit/remote/test_launchpad.py b/tests/unit/remote/test_launchpad.py deleted file mode 100644 index 91c89a4cac..0000000000 --- a/tests/unit/remote/test_launchpad.py +++ /dev/null @@ -1,565 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2019, 2023 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Optional -from unittest.mock import ANY, MagicMock, Mock, call, patch - -import pytest - -from snapcraft.remote import LaunchpadClient, errors - - -class FakeLaunchpadObject: - """Mimic behavior of many launchpad objects.""" - - def __init__(self): - pass - - def __setitem__(self, key, value): - self.__setattr__(key, value) - - def __getitem__(self, key): - return self.__getattribute__(key) - - -class BuildImpl(FakeLaunchpadObject): - """Fake build implementation.""" - - def __init__(self, fake_arch="i386"): - self._fake_arch = fake_arch - self.getFileUrls_mock = Mock( - return_value=[f"url_for/snap_file_{self._fake_arch}.snap"] - ) - - def getFileUrls(self, *args, **kw): - return self.getFileUrls_mock(*args, **kw) - - -class SnapBuildEntryImpl(FakeLaunchpadObject): - """Fake snap build entry.""" - - def __init__( - self, - arch_tag="", - buildstate="", - self_link="", - build_log_url: Optional[str] = "", - ): - self.arch_tag = arch_tag - self.buildstate = buildstate - self.self_link = self_link - self.build_log_url = build_log_url - - -class SnapBuildsImpl(FakeLaunchpadObject): - """Fake snap builds.""" - - def __init__(self): - self.entries = [ - SnapBuildEntryImpl( - arch_tag="i386", - buildstate="Successfully built", - self_link="http://build_self_link_1", - build_log_url="url_for/build_log_file_1", - ), - SnapBuildEntryImpl( - arch_tag="amd64", - buildstate="Failed to build", - self_link="http://build_self_link_2", - build_log_url="url_for/build_log_file_2", - ), - SnapBuildEntryImpl( - arch_tag="arm64", - buildstate="Failed to build", - self_link="http://build_self_link_2", - build_log_url=None, - ), - ] - - -class SnapBuildReqImpl(FakeLaunchpadObject): - """Fake snap build requests.""" - - def __init__( - self, - status="Completed", - error_message="", - self_link="http://request_self_link/1234", - builds_collection_link="http://builds_collection_link", - ): - self.status = status - self.error_message = error_message - self.self_link = self_link - self.builds_collection_link = builds_collection_link - self.builds = SnapBuildsImpl() - - def lp_refresh(self): - pass - - -class SnapImpl(FakeLaunchpadObject): - """Fake snap.""" - - def __init__(self, builds_collection_link="http://builds_collection_link"): - self._req = SnapBuildReqImpl() - self.lp_delete_mock = Mock() - self.requestBuilds_mock = Mock(return_value=self._req) - self.builds_collection_link = builds_collection_link - - def lp_delete(self, *args, **kw): - return self.lp_delete_mock(*args, **kw) - - def requestBuilds(self, *args, **kw): - return self.requestBuilds_mock(*args, **kw) - - -class SnapsImpl(FakeLaunchpadObject): - """Fake snaps.""" - - def __init__(self): - self._snap = SnapImpl() - self.getByName_mock = Mock(return_value=self._snap) - self.new_mock = Mock(return_value=self._snap) - - def getByName(self, *args, **kw): - return self.getByName_mock(*args, **kw) - - def new(self, *args, **kw): - return self.new_mock(*args, **kw) - - -class GitImpl(FakeLaunchpadObject): - """Fake git.""" - - def __init__(self): - self.issueAccessToken_mock = Mock(return_value="access-token") - self.lp_delete_mock = Mock() - - def issueAccessToken(self, *args, **kw): - return self.issueAccessToken_mock(*args, **kw) - - def lp_delete(self, *args, **kw): - return self.lp_delete_mock(*args, **kw) - - -class GitRepositoriesImpl(FakeLaunchpadObject): - """Fake git repositories.""" - - def __init__(self): - self._git = GitImpl() - self.new_mock = Mock(return_value=self._git) - self.getByPath_mock = Mock(return_value=self._git) - - def getByPath(self, *args, **kw): - return self.getByPath_mock(*args, **kw) - - def new(self, *args, **kw): - return self.new_mock(*args, **kw) - - -class DistImpl(FakeLaunchpadObject): - """Fake distributions.""" - - def __init__(self): - self.main_archive = "main_archive" - - -class MeImpl(FakeLaunchpadObject): - """Fake 'me' object.""" - - def __init__(self): - self.name = "user" - - -class LaunchpadImpl(FakeLaunchpadObject): - """Fake implementation of the Launchpad object.""" - - def __init__(self): - self._login_mock = Mock() - self._load_mock = Mock() - self._rbi = SnapBuildReqImpl() - - self.git_repositories = GitRepositoriesImpl() - self.snaps = SnapsImpl() - self.people = {"user": "/~user"} - self.distributions = {"ubuntu": DistImpl()} - self.rbi = self._rbi - self.me = MeImpl() - - def load(self, url: str, *args, **kw): - self._load_mock(url, *args, **kw) - if "/+build-request/" in url: - return self._rbi - if "http://build_self_link_1" in url: - return BuildImpl(fake_arch="i386") - if "http://build_self_link_2" in url: - return BuildImpl(fake_arch="amd64") - return self._rbi.builds - - -@pytest.fixture() -def mock_git_repo(mocker): - """Returns a mocked GitRepo.""" - return mocker.patch("snapcraft.remote.launchpad.GitRepo") - - -@pytest.fixture() -def mock_login_with(mocker): - """Mock for launchpadlib's `login_with()`.""" - lp = LaunchpadImpl() - return mocker.patch("launchpadlib.launchpad.Launchpad.login_with", return_value=lp) - - -@pytest.fixture() -def launchpad_client(mock_login_with): - """Returns a LaunchpadClient object.""" - return LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - ) - - -def test_login(mock_login_with, launchpad_client): - - assert launchpad_client.user == "user" - - mock_login_with.assert_called_once_with( - "test-app remote-build", - "production", - ANY, - credentials_file=ANY, - version="devel", - ) - - -@pytest.mark.parametrize("error", [ConnectionRefusedError, TimeoutError]) -def test_login_connection_issues(error, mock_login_with): - mock_login_with.side_effect = error - - with pytest.raises(errors.LaunchpadHttpsError): - LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - ) - - mock_login_with.assert_called() - - -def test_load_connection_refused(launchpad_client, mock_login_with): - """ConnectionRefusedError should surface.""" - launchpad_client._lp._load_mock.side_effect = ConnectionRefusedError - - with pytest.raises(ConnectionRefusedError): - launchpad_client._lp_load_url("foo") - - mock_login_with.assert_called() - - -def test_load_connection_reset_once(launchpad_client, mock_login_with): - """Load URL should work OK after single connection reset.""" - launchpad_client._lp._load_mock.side_effect = [ConnectionResetError, None] - launchpad_client._lp_load_url(url="foo") - - mock_login_with.assert_called() - - -def test_load_connection_reset_twice(launchpad_client, mock_login_with): - """Load URL should fail with two connection resets.""" - launchpad_client._lp._load_mock.side_effect = [ - ConnectionResetError, - ConnectionResetError, - ] - - with pytest.raises(ConnectionResetError): - launchpad_client._lp_load_url("foo") - - mock_login_with.assert_called() - - -def test_create_snap(launchpad_client): - launchpad_client._create_snap() - launchpad_client._lp.snaps.new_mock.assert_called_with( - auto_build=False, - auto_build_archive="/ubuntu/+archive/primary", - auto_build_pocket="Updates", - git_path="main", - git_repository_url="https://user@git.launchpad.net/~user/+git/id/", - name="id", - owner="/~user", - ) - - -def test_create_snap_with_archs(launchpad_client): - launchpad_client.architectures = ["arch1", "arch2"] - launchpad_client._create_snap() - launchpad_client._lp.snaps.new_mock.assert_called_with( - auto_build=False, - auto_build_archive="/ubuntu/+archive/primary", - auto_build_pocket="Updates", - git_path="main", - git_repository_url="https://user@git.launchpad.net/~user/+git/id/", - name="id", - owner="/~user", - processors=["/+processors/arch1", "/+processors/arch2"], - ) - - -def test_delete_snap(launchpad_client): - launchpad_client._delete_snap() - launchpad_client._lp.snaps.getByName_mock.assert_called_with( - name="id", owner="/~user" - ) - - -def test_start_build(launchpad_client): - launchpad_client.start_build() - - -def test_start_build_error(mocker, launchpad_client): - mocker.patch( - "tests.unit.remote.test_launchpad.SnapImpl.requestBuilds", - return_value=SnapBuildReqImpl( - status="Failed", error_message="snapcraft.yaml not found..." - ), - ) - with pytest.raises(errors.RemoteBuildError) as raised: - launchpad_client.start_build() - - assert str(raised.value) == "snapcraft.yaml not found..." - - -def test_start_build_deadline_not_reached(mock_login_with, mocker): - """Do not raise an error if the deadline has not been reached.""" - - def lp_refresh(self): - """Update the status from Pending to Completed when refreshed.""" - self.status = "Completed" - - mocker.patch( - "tests.unit.remote.test_launchpad.SnapImpl.requestBuilds", - return_value=SnapBuildReqImpl(status="Pending", error_message=""), - ) - mocker.patch.object(SnapBuildReqImpl, "lp_refresh", lp_refresh) - mocker.patch("time.time", return_value=500) - - lpc = LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - timeout=100, - ) - - lpc.start_build() - - -def test_start_build_timeout_error(mock_login_with, mocker): - """Raise an error if the build times out.""" - mocker.patch( - "tests.unit.remote.test_launchpad.SnapImpl.requestBuilds", - return_value=SnapBuildReqImpl(status="Pending", error_message=""), - ) - mocker.patch("time.time", return_value=500) - lpc = LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - timeout=100, - ) - # advance 1 second past deadline - mocker.patch("time.time", return_value=601) - - with pytest.raises(errors.RemoteBuildTimeoutError) as raised: - lpc.start_build() - - assert str(raised.value) == ( - "Remote build command timed out.\nBuild may still be running on Launchpad and " - "can be recovered with 'test-app remote-build --recover --build-id id'." - ) - - -def test_issue_build_request_defaults(launchpad_client): - fake_snap = MagicMock() - - launchpad_client._issue_build_request(fake_snap) - - assert fake_snap.mock_calls == [ - call.requestBuilds( - archive="main_archive", - pocket="Updates", - ) - ] - - -@patch("snapcraft.remote.LaunchpadClient._download_file") -def test_monitor_build(mock_download_file, mock_login_with, new_dir, launchpad_client): - mock_login_with._mock_return_value.rbi.builds.entries = [ - SnapBuildEntryImpl( - arch_tag="i386", - buildstate="Successfully built", - self_link="http://build_self_link_1", - build_log_url="url_for/build_log_file_1", - ), - SnapBuildEntryImpl( - arch_tag="amd64", - buildstate="Successfully built", - self_link="http://build_self_link_2", - build_log_url="url_for/build_log_file_2", - ), - SnapBuildEntryImpl( - arch_tag="arm64", - buildstate="Successfully built", - self_link="http://build_self_link_2", - build_log_url=None, - ), - ] - Path("test-project_i386.txt", encoding="utf-8").touch() - Path("test-project_i386.1.txt", encoding="utf-8").touch() - - launchpad_client.start_build() - launchpad_client.monitor_build(interval=0) - - assert mock_download_file.mock_calls == [ - call(url="url_for/snap_file_i386.snap", dst="snap_file_i386.snap"), - call( - url="url_for/build_log_file_1", dst="test-project_i386.2.txt", gunzip=True - ), - call(url="url_for/snap_file_amd64.snap", dst="snap_file_amd64.snap"), - call(url="url_for/build_log_file_2", dst="test-project_amd64.txt", gunzip=True), - call(url="url_for/snap_file_amd64.snap", dst="snap_file_amd64.snap"), - ] - - -@patch("snapcraft.remote.LaunchpadClient._download_file") -@patch("logging.Logger.error") -def test_monitor_build_error(mock_log, mock_download_file, mocker, launchpad_client): - mocker.patch( - "tests.unit.remote.test_launchpad.BuildImpl.getFileUrls", return_value=[] - ) - launchpad_client.start_build() - with pytest.raises( - errors.RemoteBuildFailedError, match="Build failed for arch amd64." - ): - launchpad_client.monitor_build(interval=0) - - assert mock_download_file.mock_calls == [ - call(url="url_for/build_log_file_1", dst="test-project_i386.txt", gunzip=True), - call(url="url_for/build_log_file_2", dst="test-project_amd64.txt", gunzip=True), - ] - - assert mock_log.mock_calls == [ - call("Snap file not available for arch %r.", "i386"), - call("Snap file not available for arch %r.", "amd64"), - call("Build failed for arch %r.", "amd64"), - call("Snap file not available for arch %r.", "arm64"), - call("Build failed for arch %r.", "arm64"), - ] - - -def test_monitor_build_deadline_not_reached(mock_login_with, mocker): - """Do not raise an error if the deadline has not been reached.""" - mocker.patch("snapcraft.remote.LaunchpadClient._download_file") - lpc = LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - timeout=100, - ) - - lpc.start_build() - # should raise build failed error, not timeout error - with pytest.raises( - errors.RemoteBuildFailedError, match="Build failed for arch amd64." - ): - lpc.monitor_build(interval=0) - - -def test_monitor_build_timeout_error(mock_login_with, mocker): - """Raise an error if the build times out.""" - mocker.patch("snapcraft.remote.LaunchpadClient._download_file") - mocker.patch("time.time", return_value=500) - lpc = LaunchpadClient( - app_name="test-app", - build_id="id", - project_name="test-project", - architectures=[], - timeout=100, - ) - # advance 1 second past deadline - mocker.patch("time.time", return_value=601) - - lpc.start_build() - - with pytest.raises(errors.RemoteBuildTimeoutError) as raised: - lpc.monitor_build(interval=0) - - assert str(raised.value) == ( - "Remote build command timed out.\nBuild may still be running on Launchpad and " - "can be recovered with 'test-app remote-build --recover --build-id id'." - ) - - -def test_get_build_status(launchpad_client): - launchpad_client.start_build() - build_status = launchpad_client.get_build_status() - - assert build_status == { - "amd64": "Failed to build", - "arm64": "Failed to build", - "i386": "Successfully built", - } - - -def test_push_source_tree(new_dir, mock_git_repo, launchpad_client): - now = datetime.now(timezone.utc) - - with patch("snapcraft.remote.launchpad.datetime") as mock_datetime: - mock_datetime.now = lambda tz: now - launchpad_client.push_source_tree(Path()) - - launchpad_client._lp.git_repositories._git.issueAccessToken_mock.assert_called_once_with( - description="test-app remote-build for id", - scopes=["repository:push"], - date_expires=(now + timedelta(minutes=60)).isoformat(), - ) - - mock_git_repo.assert_has_calls( - [ - call(Path()), - call().push_url( - "https://user:access-token@git.launchpad.net/~user/+git/id/", - "main", - "HEAD", - "access-token", - push_tags=True, - ), - ] - ) - - -def test_push_source_tree_error(new_dir, mock_git_repo, launchpad_client): - mock_git_repo.return_value.push_url.side_effect = errors.GitError("test error") - - with pytest.raises(errors.GitError): - launchpad_client.push_source_tree(Path()) diff --git a/tests/unit/remote/test_remote_builder.py b/tests/unit/remote/test_remote_builder.py deleted file mode 100644 index 79835dfb49..0000000000 --- a/tests/unit/remote/test_remote_builder.py +++ /dev/null @@ -1,217 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Remote builder tests.""" - -import re -from pathlib import Path -from unittest.mock import call, patch - -import pytest - -from snapcraft.remote import GitType, RemoteBuilder, UnsupportedArchitectureError -from snapcraft.remote.utils import _SUPPORTED_ARCHS - - -@pytest.fixture(autouse=True) -def mock_launchpad_client(mocker): - """Returns a mocked LaunchpadClient.""" - _mock_launchpad_client = mocker.patch( - "snapcraft.remote.remote_builder.LaunchpadClient" - ) - _mock_launchpad_client.return_value.has_outstanding_build.return_value = False - _mock_launchpad_client.return_value.architectures = ["amd64"] - return _mock_launchpad_client - - -@pytest.fixture(autouse=True) -def mock_worktree(mocker): - """Returns a mocked WorkTree.""" - return mocker.patch("snapcraft.remote.remote_builder.WorkTree") - - -@pytest.fixture() -def fake_remote_builder(new_dir, mock_launchpad_client, mock_worktree): - """Returns a fake RemoteBuilder.""" - return RemoteBuilder( - app_name="test-app", - build_id="test-build-id", - project_name="test-project", - architectures=["amd64"], - project_dir=Path(), - timeout=0, - ) - - -@pytest.fixture() -def mock_git_check(mocker): - """Ignore git check.""" - mock_git_check = mocker.patch( - "snapcraft.remote.git.check_git_repo_for_remote_build" - ) - mock_git_check.return_value = None - return mock_git_check - - -@pytest.fixture() -def mock_git_type_check(mocker): - """Ignore git type check.""" - mock_git_type = mocker.patch("snapcraft.remote.git.get_git_repo_type") - mock_git_type.return_value = GitType.NORMAL - return mock_git_type - - -def test_remote_builder_init(mock_launchpad_client, mock_worktree): - """Verify remote builder is properly initialized.""" - RemoteBuilder( - app_name="test-app", - build_id="test-build-id", - project_name="test-project", - architectures=["amd64"], - project_dir=Path(), - timeout=10, - ) - - assert mock_launchpad_client.mock_calls == [ - call( - app_name="test-app", - build_id="test-build-id", - project_name="test-project", - architectures=["amd64"], - timeout=10, - ) - ] - assert mock_worktree.mock_calls == [ - call(app_name="test-app", build_id="test-build-id", project_dir=Path()) - ] - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -@pytest.mark.usefixtures("new_dir", "mock_git_check") -def test_build_id_computed(): - """Compute a build id if it is not provided.""" - remote_builder = RemoteBuilder( - app_name="test-app", - build_id=None, - project_name="test-project", - architectures=["amd64"], - project_dir=Path(), - timeout=0, - ) - - assert re.match("test-app-test-project-[0-9a-f]{32}", remote_builder.build_id) - - -@pytest.mark.parametrize("archs", (["amd64"], _SUPPORTED_ARCHS)) -def test_validate_architectures_supported(archs): - """Supported architectures should not raise an error.""" - RemoteBuilder( - app_name="test-app", - build_id="test-build-id", - project_name="test-project", - architectures=archs, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.parametrize( - "archs", - [ - # unsupported - ["bad"], - # supported and unsupported - ["amd64", "bad"], - # multiple supported and unsupported - ["bad", "amd64", "bad2", "arm64"], - ], -) -def test_validate_architectures_unsupported(archs): - """Raise an error for unsupported architectures.""" - with pytest.raises(UnsupportedArchitectureError): - RemoteBuilder( - app_name="test-app", - build_id="test-build-id", - project_name="test-project", - architectures=archs, - project_dir=Path(), - timeout=0, - ) - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -@patch("logging.Logger.info") -def test_print_status_builds_found( - mock_log, mock_launchpad_client, fake_remote_builder -): - """Print the status of a remote build.""" - mock_launchpad_client.return_value.has_outstanding_build.return_value = True - mock_launchpad_client.return_value.get_build_status.return_value = { - "amd64": "Needs building", - "arm64": "Currently building", - } - - fake_remote_builder.print_status() - - assert mock_log.mock_calls == [ - call("Build status for arch %s: %s", "amd64", "Needs building"), - call("Build status for arch %s: %s", "arm64", "Currently building"), - ] - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -@patch("logging.Logger.info") -def test_print_status_no_build_found(mock_log, fake_remote_builder): - """Print the status of a remote build.""" - fake_remote_builder.print_status() - - assert mock_log.mock_calls == [call("No build task(s) found.")] - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -@pytest.mark.parametrize("has_builds", (True, False)) -def test_has_outstanding_build(has_builds, fake_remote_builder, mock_launchpad_client): - """Check for outstanding builds.""" - mock_launchpad_client.return_value.has_outstanding_build.return_value = has_builds - - assert fake_remote_builder.has_outstanding_build() == has_builds - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -def test_monitor_build(fake_remote_builder, mock_launchpad_client): - """Monitor a build.""" - fake_remote_builder.monitor_build() - - mock_launchpad_client.return_value.monitor_build.assert_called_once() - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -def test_clean_build(fake_remote_builder, mock_launchpad_client, mock_worktree): - """Clean a build.""" - fake_remote_builder.clean_build() - - mock_launchpad_client.return_value.cleanup.assert_called_once() - mock_worktree.return_value.clean_cache.assert_called_once() - - -@pytest.mark.usefixtures("mock_git_check", "mock_git_type_check") -def test_start_build(fake_remote_builder, mock_launchpad_client, mock_worktree): - """Start a build.""" - fake_remote_builder.start_build() - - mock_worktree.return_value.init_repo.assert_called_once() - mock_launchpad_client.return_value.push_source_tree.assert_called_once() - mock_launchpad_client.return_value.start_build.assert_called_once() diff --git a/tests/unit/remote/test_utils.py b/tests/unit/remote/test_utils.py deleted file mode 100644 index 18068267e8..0000000000 --- a/tests/unit/remote/test_utils.py +++ /dev/null @@ -1,224 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Remote-build utility tests.""" - -import re -from pathlib import Path - -import pytest - -from snapcraft.remote import ( - UnsupportedArchitectureError, - get_build_id, - humanize_list, - rmtree, - validate_architectures, -) -from snapcraft.remote.utils import _SUPPORTED_ARCHS - -############################### -# validate architecture tests # -############################### - - -@pytest.mark.parametrize(("archs"), [["amd64"], _SUPPORTED_ARCHS]) -def test_validate_architectures(archs): - """Validate architectures.""" - assert validate_architectures(archs) is None - - -@pytest.mark.parametrize( - ("archs", "expected_archs"), - [ - # invalid arch - (["unknown"], ["unknown"]), - # valid and invalid archs - (["amd64", "unknown"], ["unknown"]), - # multiple invalid archs - (["unknown1", "unknown2"], ["unknown1", "unknown2"]), - # multiple valid and invalid archs - (["unknown1", "unknown2"], ["unknown1", "unknown2"]), - ], -) -def test_validate_architectures_error(archs, expected_archs): - """Raise an error if an unsupported architecture is passed.""" - with pytest.raises(UnsupportedArchitectureError) as raised: - validate_architectures(archs) - - assert ( - "The following architectures are not supported by the remote builder: " - f"{expected_archs}" - ) in str(raised.value) - - -################# -# Humanize List # -################# - - -@pytest.mark.parametrize( - "items,conjunction,expected", - ( - ([], "and", ""), - (["foo"], "and", "'foo'"), - (["foo", "bar"], "and", "'bar' and 'foo'"), - (["foo", "bar", "baz"], "and", "'bar', 'baz', and 'foo'"), - (["foo", "bar", "baz", "qux"], "and", "'bar', 'baz', 'foo', and 'qux'"), - ([], "or", ""), - (["foo"], "or", "'foo'"), - (["foo", "bar"], "or", "'bar' or 'foo'"), - (["foo", "bar", "baz"], "or", "'bar', 'baz', or 'foo'"), - (["foo", "bar", "baz", "qux"], "or", "'bar', 'baz', 'foo', or 'qux'"), - ), -) -def test_humanize_list(items, conjunction, expected): - """Test humanize_list.""" - assert humanize_list(items, conjunction) == expected - - -def test_humanize_list_sorted(): - """Verify `sort` parameter.""" - input_list = ["z", "a", "m test", "1"] - - # unsorted list is in the same order as the original list - expected_list_unsorted = "'z', 'a', 'm test', and '1'" - - # sorted list is sorted alphanumerically - expected_list_sorted = "'1', 'a', 'm test', and 'z'" - - assert humanize_list(input_list, "and") == expected_list_sorted - assert humanize_list(input_list, "and", sort=True) == expected_list_sorted - assert humanize_list(input_list, "and", sort=False) == expected_list_unsorted - - -################## -# build id tests # -################## - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id(): - """Get the build id.""" - Path("test").write_text("Hello, World!", encoding="utf-8") - - build_id = get_build_id("test-app", "test-project", Path()) - - assert re.match("test-app-test-project-[0-9a-f]{32}", build_id) - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_empty_dir(): - """An empty directory should still produce a valid build id.""" - build_id = get_build_id("test-app", "test-project", Path()) - - assert re.match("test-app-test-project-[0-9a-f]{32}", build_id) - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_is_reproducible(): - """The build id should be the same when there are no changes to the directory.""" - Path("test").write_text("Hello, World!", encoding="utf-8") - - build_id_1 = get_build_id("test-app", "test-project", Path()) - build_id_2 = get_build_id("test-app", "test-project", Path()) - - assert build_id_1 == build_id_2 - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_computed_is_unique_file_modified(): - """The build id should change when a file is modified.""" - Path("test1").write_text("Hello, World!", encoding="utf-8") - build_id_1 = get_build_id("test-app", "test-project", Path()) - - # adding a new file should change the build-id - Path("test2").write_text("Hello, World!", encoding="utf-8") - build_id_2 = get_build_id("test-app", "test-project", Path()) - - assert build_id_1 != build_id_2 - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_computed_is_unique_file_contents_modified(): - """The build id should change when the contents of a file is modified.""" - Path("test").write_text("Hello, World!", encoding="utf-8") - build_id_1 = get_build_id("test-app", "test-project", Path()) - - # modifying the contents of a file should change the build-id - Path("test").write_text("Goodbye, World!", encoding="utf-8") - build_id_2 = get_build_id("test-app", "test-project", Path()) - - assert build_id_1 != build_id_2 - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_directory_does_not_exist_error(): - """Raise an error if the directory does not exist.""" - with pytest.raises(FileNotFoundError) as raised: - get_build_id("test-app", "test-project", Path("does-not-exist")) - - assert str(raised.value) == ( - "Could not compute hash because directory " - f"{str(Path('does-not-exist').absolute())} does not exist." - ) - - -@pytest.mark.usefixtures("new_dir") -def test_get_build_id_directory_is_not_a_directory_error(): - """Raise an error if the directory is not a directory.""" - Path("regular-file").touch() - - with pytest.raises(FileNotFoundError) as raised: - get_build_id("test-app", "test-project", Path("regular-file")) - - assert str(raised.value) == ( - f"Could not compute hash because {str(Path('regular-file').absolute())} " - "is not a directory." - ) - - -################ -# rmtree tests # -################ - - -@pytest.fixture() -def stub_directory_tree(new_dir): - """Creates a tree of directories and files.""" - root_dir = Path("root-dir") - (root_dir / "dir1/dir2").mkdir(parents=True, exist_ok=True) - (root_dir / "dir3").mkdir(parents=True, exist_ok=True) - (root_dir / "file1").touch() - (root_dir / "dir1/file2").touch() - (root_dir / "dir1/dir2/file3").touch() - return root_dir - - -def test_rmtree(stub_directory_tree): - """Remove a directory tree.""" - rmtree(stub_directory_tree) - - assert not Path(stub_directory_tree).exists() - - -def test_rmtree_readonly(stub_directory_tree): - """Remove a directory tree that contains a read-only file.""" - (stub_directory_tree / "read-only-file").touch(mode=0o444) - - rmtree(stub_directory_tree) - - assert not Path(stub_directory_tree).exists() diff --git a/tests/unit/remote/test_worktree.py b/tests/unit/remote/test_worktree.py deleted file mode 100644 index ebf0068d8c..0000000000 --- a/tests/unit/remote/test_worktree.py +++ /dev/null @@ -1,91 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2023 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Unit tests for the worktree module.""" - -from pathlib import Path -from unittest.mock import call - -import pytest - -from snapcraft.remote import WorkTree - - -@pytest.fixture(autouse=True) -def mock_git_repo(mocker): - """Returns a mocked GitRepo.""" - return mocker.patch("snapcraft.remote.worktree.GitRepo") - - -@pytest.fixture(autouse=True) -def mock_base_directory(mocker, new_dir): - """Returns a mocked `xdg.BaseDirectory`.""" - _mock_base_directory = mocker.patch("snapcraft.remote.worktree.BaseDirectory") - _mock_base_directory.save_cache_path.return_value = new_dir - return _mock_base_directory - - -@pytest.fixture(autouse=True) -def mock_copytree(mocker): - """Returns a mocked `shutil.copytree()`.""" - return mocker.patch("snapcraft.remote.worktree.copytree") - - -def test_worktree_init_clean( - mock_base_directory, mock_copytree, mock_git_repo, new_dir -): - """Test initialization of a WorkTree with a clean git repository.""" - mock_git_repo.return_value.is_clean.return_value = True - - worktree = WorkTree(app_name="test-app", build_id="test-id", project_dir=Path()) - worktree.init_repo() - - assert isinstance(worktree, WorkTree) - mock_base_directory.save_cache_path.assert_called_with( - "test-app", "remote-build", "test-id" - ) - assert mock_git_repo.mock_calls == [ - call(Path().resolve() / "repo"), - call().is_clean(), - ] - - -def test_worktree_init_dirty( - mock_base_directory, mock_copytree, mock_git_repo, new_dir -): - """Test initialization of a WorkTree with a clean git repository.""" - mock_git_repo.return_value.is_clean.return_value = False - - worktree = WorkTree(app_name="test-app", build_id="test-id", project_dir=Path()) - worktree.init_repo() - - assert isinstance(worktree, WorkTree) - mock_base_directory.save_cache_path.assert_called_with( - "test-app", "remote-build", "test-id" - ) - assert mock_git_repo.mock_calls == [ - call(Path().resolve() / "repo"), - call().is_clean(), - call().add_all(), - call().commit(), - ] - - -def test_worktree_repo_dir(new_dir): - """Verify the `repo_dir` property.""" - worktree = WorkTree(app_name="test-app", build_id="test-id", project_dir=Path()) - - assert worktree.repo_dir == Path().resolve() / "repo" From ea245e4032b14af02ab40ac2ba8980b122d9a790 Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Thu, 11 Apr 2024 12:58:49 -0400 Subject: [PATCH 2/5] test: add esm test --- tests/unit/test_application.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index fb6da13bc3..7498a120fc 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -28,6 +28,7 @@ from snapcraft import application, services from snapcraft.models.project import Architecture +from snapcraft.parts.yaml_utils import ESM_BASES @pytest.fixture( @@ -295,3 +296,16 @@ def test_default_command_integrated(monkeypatch, mocker, new_dir): app.run() assert mocked_pack_run.called + + +@pytest.mark.parametrize("base", ESM_BASES) +def test_esm_error(snapcraft_yaml, base): + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + + app = application.create_app() + + with pytest.raises( + RuntimeError, match=f"ERROR: base {base!r} was last supported on Snapcraft" + ): + app.run() From aa308951e9dcef4fdfc77fa7cf04c2108845fa10 Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Thu, 11 Apr 2024 13:11:39 -0400 Subject: [PATCH 3/5] test: esm test --- tests/unit/test_application.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 7498a120fc..1559cba416 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -25,10 +25,11 @@ from craft_application.commands.lifecycle import PackCommand from craft_parts.packages import snaps from craft_providers import bases +from snapcraft.errors import ClassicFallback from snapcraft import application, services from snapcraft.models.project import Architecture -from snapcraft.parts.yaml_utils import ESM_BASES +from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES @pytest.fixture( @@ -300,6 +301,7 @@ def test_default_command_integrated(monkeypatch, mocker, new_dir): @pytest.mark.parametrize("base", ESM_BASES) def test_esm_error(snapcraft_yaml, base): + """Test that an error is raised when using an ESM base.""" snapcraft_yaml_dict = {"base": base} snapcraft_yaml(**snapcraft_yaml_dict) @@ -309,3 +311,23 @@ def test_esm_error(snapcraft_yaml, base): RuntimeError, match=f"ERROR: base {base!r} was last supported on Snapcraft" ): app.run() + + +@pytest.mark.parametrize("base", CURRENT_BASES) +def test_esm_pass(mocker, snapcraft_yaml, base): + """Test that no error is raised when using current supported bases.""" + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + + mock_dispatch = mocker.patch( + "craft_application.application.Application._get_dispatcher" + ) + + app = application.create_app() + + try: + app.run() + except ClassicFallback: + pass + else: + mock_dispatch.assert_called_once() From a26506fca2e88a4ccc138281d01adad538d67f40 Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Thu, 11 Apr 2024 13:27:36 -0400 Subject: [PATCH 4/5] fix: lint --- tests/unit/test_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 1559cba416..81fb44a895 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -25,9 +25,9 @@ from craft_application.commands.lifecycle import PackCommand from craft_parts.packages import snaps from craft_providers import bases -from snapcraft.errors import ClassicFallback from snapcraft import application, services +from snapcraft.errors import ClassicFallback from snapcraft.models.project import Architecture from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES From 90f76a28b43b40ba1f49481a6ed0160c08723798 Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Fri, 12 Apr 2024 12:46:21 -0400 Subject: [PATCH 5/5] fix: feedback --- snapcraft/application.py | 20 ++-- tests/unit/commands/test_remote.py | 112 +---------------------- tests/unit/test_application.py | 141 ++++++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 121 deletions(-) diff --git a/snapcraft/application.py b/snapcraft/application.py index 8872c49fff..75b96e65d6 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -203,14 +203,18 @@ def _get_dispatcher(self) -> craft_cli.Dispatcher: "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. " "Valid values are 'disable-fallback' and 'force-fallback'." ) - if "core20" in (base, build_base): - # Use legacy snapcraft unless explicitly forced to use craft-application - if build_strategy != "disable-fallback": - raise errors.ClassicFallback() - if "core22" in (base, build_base): - # Use craft-application unless explicitly forced to use legacy snapcraft - if build_strategy == "force-fallback": - raise errors.ClassicFallback() + # Use legacy snapcraft unless explicitly forced to use craft-application + if ( + "core20" in (base, build_base) + and build_strategy != "disable-fallback" + ): + raise errors.ClassicFallback() + # Use craft-application unless explicitly forced to use legacy snapcraft + if ( + "core22" in (base, build_base) + and build_strategy == "force-fallback" + ): + raise errors.ClassicFallback() else: raise errors.ClassicFallback() return super()._get_dispatcher() diff --git a/tests/unit/commands/test_remote.py b/tests/unit/commands/test_remote.py index e01c7d905e..920e22f8ad 100644 --- a/tests/unit/commands/test_remote.py +++ b/tests/unit/commands/test_remote.py @@ -32,7 +32,7 @@ from snapcraft import application from snapcraft.const import SnapArch -from snapcraft.errors import ClassicFallback, SnapcraftError +from snapcraft.errors import ClassicFallback from snapcraft.parts.yaml_utils import CURRENT_BASES, LEGACY_BASES from snapcraft.utils import get_host_architecture @@ -319,116 +319,6 @@ def test_run_core20( mock_remote_build_run.assert_not_called() -@pytest.mark.parametrize( - "envvar", ["force-fallback", "disable-fallback", "badvalue", None] -) -@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar( - monkeypatch, - snapcraft_yaml, - base, - envvar, - mock_remote_build_run, - mock_run_legacy, -): - """Bases core24 and later run new remote-build regardless of envvar.""" - snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} - snapcraft_yaml(**snapcraft_yaml_dict) - - if envvar: - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", envvar) - else: - monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) - - application.main() - - mock_remote_build_run.assert_called_once() - mock_run_legacy.assert_not_called() - - -@pytest.mark.parametrize("base", LEGACY_BASES) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar_disable_fallback_core20( - snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch -): - """core20 base run new remote-build if envvar is `disable-fallback`.""" - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "disable-fallback") - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - - application.main() - - mock_remote_build_run.assert_called_once() - mock_run_legacy.assert_not_called() - - -@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar_force_fallback_core22( - snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch -): - """core22 and older bases run legacy remote-build if envvar is `force-fallback`.""" - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "force-fallback") - - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - application.main() - - mock_run_legacy.assert_called_once() - mock_remote_build_run.assert_not_called() - - -@pytest.mark.parametrize("base", LEGACY_BASES) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar_force_fallback_unset_core20( - snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch -): - """core20 base run legacy remote-build if envvar is unset.""" - monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) - - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - application.main() - - mock_run_legacy.assert_called_once() - mock_remote_build_run.assert_not_called() - - -@pytest.mark.parametrize("base", {"core22"}) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar_force_fallback_empty_core22( - snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch -): - """core22 bases run craft-application remote-build if envvar is empty.""" - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "") - - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - application.main() - - mock_remote_build_run.assert_called_once() - mock_run_legacy.assert_not_called() - - -@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) -@pytest.mark.usefixtures("mock_confirm", "mock_argv") -def test_run_envvar_invalid(snapcraft_yaml, base, monkeypatch): - """core20 and core22 bases raise an error if the envvar is invalid.""" - monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "badvalue") - - snapcraft_yaml_dict = {"base": base} - snapcraft_yaml(**snapcraft_yaml_dict) - with pytest.raises(SnapcraftError) as err: - application.main() - - assert err.match( - "Unknown value 'badvalue' in environment variable " - "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. Valid values are 'disable-fallback' and " - "'force-fallback'" - ) - - @pytest.mark.parametrize("base", CURRENT_BASES) @pytest.mark.usefixtures("mock_confirm", "mock_argv") def test_run_in_repo_newer_than_core22( diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py index 81fb44a895..3a44027b63 100644 --- a/tests/unit/test_application.py +++ b/tests/unit/test_application.py @@ -16,6 +16,7 @@ """Unit tests for application classes.""" import json import os +import sys from textwrap import dedent import craft_cli @@ -27,9 +28,9 @@ from craft_providers import bases from snapcraft import application, services -from snapcraft.errors import ClassicFallback +from snapcraft.errors import ClassicFallback, SnapcraftError from snapcraft.models.project import Architecture -from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES +from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES, LEGACY_BASES @pytest.fixture( @@ -44,6 +45,32 @@ def architectures(request): return request.param +@pytest.fixture() +def mock_confirm(mocker): + return mocker.patch( + "snapcraft.commands.remote.confirm_with_user", return_value=True + ) + + +@pytest.fixture() +def mock_remote_build_run(mocker): + _mock_remote_build_run = mocker.patch( + "snapcraft.commands.remote.RemoteBuildCommand._run" + ) + return _mock_remote_build_run + + +@pytest.fixture() +def mock_run_legacy(mocker): + return mocker.patch("snapcraft_legacy.cli.legacy.legacy_run") + + +@pytest.fixture() +def mock_remote_build_argv(mocker): + """Mock `snapcraft remote-build` cli.""" + return mocker.patch.object(sys, "argv", ["snapcraft", "remote-build"]) + + @pytest.mark.parametrize("env_vars", application.MAPPED_ENV_VARS.items()) def test_application_map_build_on_env_var(monkeypatch, env_vars): """Test that instantiating the Snapcraft application class will set the value of the @@ -331,3 +358,113 @@ def test_esm_pass(mocker, snapcraft_yaml, base): pass else: mock_dispatch.assert_called_once() + + +@pytest.mark.parametrize( + "envvar", ["force-fallback", "disable-fallback", "badvalue", None] +) +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar( + monkeypatch, + snapcraft_yaml, + base, + envvar, + mock_remote_build_run, + mock_run_legacy, +): + """Bases core24 and later run new remote-build regardless of envvar.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + + if envvar: + monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", envvar) + else: + monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) + + application.main() + + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() + + +@pytest.mark.parametrize("base", LEGACY_BASES) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar_disable_fallback_core20( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core20 base run new remote-build if envvar is `disable-fallback`.""" + monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "disable-fallback") + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + + application.main() + + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() + + +@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar_force_fallback_core22( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core22 and older bases run legacy remote-build if envvar is `force-fallback`.""" + monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "force-fallback") + + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() + + mock_run_legacy.assert_called_once() + mock_remote_build_run.assert_not_called() + + +@pytest.mark.parametrize("base", LEGACY_BASES) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar_force_fallback_unset_core20( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core20 base run legacy remote-build if envvar is unset.""" + monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False) + + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() + + mock_run_legacy.assert_called_once() + mock_remote_build_run.assert_not_called() + + +@pytest.mark.parametrize("base", {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar_force_fallback_empty_core22( + snapcraft_yaml, base, mock_remote_build_run, mock_run_legacy, monkeypatch +): + """core22 bases run craft-application remote-build if envvar is empty.""" + monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "") + + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + application.main() + + mock_remote_build_run.assert_called_once() + mock_run_legacy.assert_not_called() + + +@pytest.mark.parametrize("base", LEGACY_BASES | {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_remote_build_argv") +def test_run_envvar_invalid(snapcraft_yaml, base, monkeypatch): + """core20 and core22 bases raise an error if the envvar is invalid.""" + monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "badvalue") + + snapcraft_yaml_dict = {"base": base} + snapcraft_yaml(**snapcraft_yaml_dict) + with pytest.raises(SnapcraftError) as err: + application.main() + + assert err.match( + "Unknown value 'badvalue' in environment variable " + "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. Valid values are 'disable-fallback' and " + "'force-fallback'" + )