diff --git a/.github/workflows/spread-scheduled.yaml b/.github/workflows/spread-scheduled.yaml index 70e333cf546..78f563a7661 100644 --- a/.github/workflows/spread-scheduled.yaml +++ b/.github/workflows/spread-scheduled.yaml @@ -51,6 +51,35 @@ jobs: run: | spread google:ubuntu-22.04-64:tests/spread/plugins/${{ matrix.type }}/kernel + remote-build-core24: + runs-on: self-hosted + needs: [snap-build] + strategy: + fail-fast: false + matrix: + system: + - ubuntu-24.04-64 + steps: + - name: Cleanup job workspace + run: | + rm -rf "${{ github.workspace }}" + mkdir "${{ github.workspace }}" + - name: Checkout snapcraft + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + - name: Download snap artifact + uses: actions/download-artifact@v4 + with: + name: snap + path: tests + - name: remote-build test + env: + LAUNCHPAD_TOKEN: "${{ secrets.LAUNCHPAD_TOKEN }}" + run: | + spread google:${{ matrix.system }}:tests/spread/core24/remote-build + remote-build: runs-on: self-hosted needs: [snap-build] @@ -79,4 +108,4 @@ jobs: env: LAUNCHPAD_TOKEN: "${{ secrets.LAUNCHPAD_TOKEN }}" run: | - spread google:${{ matrix.system }}:tests/spread/general/remote-build + spread google:${{ matrix.system }}:tests/spread/general/remote-build \ No newline at end of file diff --git a/requirements-devel.txt b/requirements-devel.txt index 5f17e23b32e..b72acc66b04 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -11,7 +11,7 @@ click==8.1.7 codespell==2.2.6 colorama==0.4.6 coverage==7.4.0 -craft-application @ git+https://github.com/canonical/craft-application@main +craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/requirements.txt b/requirements.txt index d4d59947ec2..ab152975f05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ cffi==1.16.0 chardet==5.2.0 charset-normalizer==3.3.2 click==8.1.7 -craft-application @ git+https://github.com/canonical/craft-application@main +craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform craft-archives==1.1.3 craft-cli==2.5.1 craft-grammar==1.1.2 diff --git a/setup.py b/setup.py index afa88e9a6ed..ce4eb97a030 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def recursive_data_files(directory, install_directory): "attrs", "catkin-pkg; sys_platform == 'linux'", "click", - "craft-application @ git+https://github.com/canonical/craft-application@main", + "craft-application @ git+https://github.com/canonical/craft-application@CRAFT-2562-build-for-platform", "craft-archives", "craft-cli", "craft-grammar", diff --git a/snapcraft/application.py b/snapcraft/application.py index 63ce9587eab..16d98749fc7 100644 --- a/snapcraft/application.py +++ b/snapcraft/application.py @@ -224,7 +224,7 @@ def create_app() -> Snapcraft: craft_app_commands.lifecycle.PrimeCommand, craft_app_commands.lifecycle.PackCommand, commands.SnapCommand, # Hidden (legacy compatibility) - unimplemented.RemoteBuild, + commands.RemoteBuildCommand, unimplemented.Plugins, unimplemented.ListPlugins, unimplemented.Try, diff --git a/snapcraft/commands/__init__.py b/snapcraft/commands/__init__.py index 6f4195c5c99..8af8b07978b 100644 --- a/snapcraft/commands/__init__.py +++ b/snapcraft/commands/__init__.py @@ -19,11 +19,13 @@ from . import core22, legacy from .extensions import ExpandExtensions, ListExtensions from .lifecycle import SnapCommand +from .remote import RemoteBuildCommand __all__ = [ "core22", "legacy", "SnapCommand", + "RemoteBuildCommand", "ExpandExtensions", "ListExtensions", ] diff --git a/snapcraft/commands/remote.py b/snapcraft/commands/remote.py new file mode 100644 index 00000000000..0d6b8981112 --- /dev/null +++ b/snapcraft/commands/remote.py @@ -0,0 +1,270 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 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 that using craft-application.""" + +import argparse +import os +import textwrap +import time +from collections.abc import Collection +from pathlib import Path +from typing import Any, cast + +from craft_application.commands import ExtensibleCommand +from craft_application.launchpad.models import Build, BuildState +from craft_application.remote.utils import get_build_id +from craft_cli import emit +from overrides import overrides + +from snapcraft import models +from snapcraft.const import SUPPORTED_ARCHS +from snapcraft.utils import confirm_with_user + +_CONFIRMATION_PROMPT = ( + "All data sent to remote builders will be publicly available. " + "Are you sure you want to continue?" +) + + +class RemoteBuildCommand(ExtensibleCommand): + """Command passthrough for the remote-build command.""" + + always_load_project = True + name = "remote-build" + help_msg = "Build a snap remotely on Launchpad." + 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 --platforms 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: + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--platform", + type=str, + metavar="name", + default=os.getenv("CRAFT_PLATFORM"), + help="Set platform to build for", + ) + group.add_argument( + "--build-for", + type=str, + metavar="arch", + default=os.getenv("CRAFT_BUILD_FOR"), + help="Set architecture to build for", + ) + + 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-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.", + ) + + # pylint: disable=too-many-branches, too-many-statements + def _run(self, parsed_args: argparse.Namespace, **kwargs: Any) -> int | None: + """Run the remote-build command. + + :param parsed_args: Snapcraft's argument namespace. + + :raises AcceptPublicUploadError: If the user does not agree to upload data. + """ + if os.getenv("SUDO_USER") and os.geteuid() == 0: + emit.progress( + "Running with 'sudo' may cause permission errors and is discouraged.", + permanent=True, + ) + # Give the user a bit of time to process this before proceeding. + time.sleep(1) + + emit.progress( + "remote-build is experimental and is subject to change. Use with caution.", + permanent=True, + ) + + if not parsed_args.launchpad_accept_public_upload and not confirm_with_user( + _CONFIRMATION_PROMPT, default=False + ): + emit.progress( + "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`.", + permanent=True, + ) + return 77 + + builder = self._services.remote_build + project = cast(models.Project, self._services.project) + config = cast(dict[str, Any], self.config) + project_dir = ( + Path(config.get("global_args", {}).get("project_dir") or ".") + .expanduser() + .resolve() + ) + + emit.trace(f"Project directory: {project_dir}") + + build_planner = self._app.BuildPlannerClass.unmarshal(project.marshal()) + full_build_plan = build_planner.get_build_plan() + + plan_architectures: set[str] = set() + + # platform and build-for are mutually exclusive + if parsed_args.platform: + for build_info in full_build_plan: + if build_info.platform == parsed_args.platform: + plan_architectures.add(build_info.build_for) + + if not plan_architectures: + emit.progress( + f"platform {parsed_args.platform} is not present in the build plan." + ) + return 1 + + if parsed_args.build_for: + if parsed_args.build_for in SUPPORTED_ARCHS: + plan_architectures.add(parsed_args.build_for) + else: + emit.progress(f"build-for {parsed_args.build_for} is not supported.") + return 1 + + architectures: list[str] | None = list(plan_architectures) + + if not architectures: + architectures = None + + if parsed_args.launchpad_timeout: + emit.debug(f"Setting timeout to {parsed_args.launchpad_timeout} seconds") + builder.set_timeout(parsed_args.launchpad_timeout) + + build_id = get_build_id(self._app.name, project.name, project_dir) + if parsed_args.recover: + emit.progress(f"Recovering build {build_id}") + builds = builder.resume_builds(build_id) + else: + emit.progress( + "Starting new build. It may take a while to upload large projects." + ) + builds = builder.start_builds(project_dir, architectures=architectures) + + try: + returncode = self._monitor_and_complete(build_id, builds) + except KeyboardInterrupt: + if confirm_with_user("Cancel builds?", default=True): + emit.progress("Cancelling builds.") + builder.cancel_builds() + emit.progress("Cleaning up") + builder.cleanup() + returncode = 0 + else: + emit.progress("Cleaning up") + builder.cleanup() + return returncode + + def _monitor_and_complete( + self, build_id: str | None, builds: Collection[Build] + ) -> int: + builder = self._services.remote_build + emit.progress("Monitoring build") + try: + for states in builder.monitor_builds(): + building: set[str] = set() + succeeded: set[str] = set() + uploading: set[str] = set() + not_building: set[str] = set() + for arch, build_state in states.items(): + if build_state.is_running: + building.add(arch) + elif build_state == BuildState.SUCCESS: + succeeded.add(arch) + elif build_state == BuildState.UPLOADING: + uploading.add(arch) + else: + not_building.add(arch) + progress_parts: list[str] = [] + if not_building: + progress_parts.append("Stopped: " + ",".join(sorted(not_building))) + if building: + progress_parts.append("Building: " + ", ".join(sorted(building))) + if uploading: + progress_parts.append("Uploading: " + ",".join(sorted(uploading))) + if succeeded: + progress_parts.append("Succeeded: " + ", ".join(sorted(succeeded))) + emit.progress("; ".join(progress_parts)) + except TimeoutError: + if build_id: + resume_command = ( + f"{self._app.name} remote-build --recover --build-id={build_id}" + ) + else: + resume_command = f"{self._app.name} remote-build --recover" + emit.message( + f"Timed out waiting for build.\nTo resume, run {resume_command!r}" + ) + return 75 # Temporary failure + + emit.progress(f"Fetching {len(builds)} build logs...") + logs = builder.fetch_logs(Path.cwd()) + + emit.progress("Fetching build artifacts...") + artifacts = builder.fetch_artifacts(Path.cwd()) + + log_names = sorted(path.name for path in logs.values() if path) + artifact_names = sorted(path.name for path in artifacts) + + emit.message( + "Build completed.\n" + f"Log files: {', '.join(log_names)}\n" + f"Artifacts: {', '.join(artifact_names)}" + ) + return 0 diff --git a/snapcraft/commands/unimplemented.py b/snapcraft/commands/unimplemented.py index e783a211f58..69b57940643 100644 --- a/snapcraft/commands/unimplemented.py +++ b/snapcraft/commands/unimplemented.py @@ -285,9 +285,3 @@ class EditValidationSets( UnimplementedMixin, commands.core22.StoreEditValidationSetsCommand ): # noqa: D101 (missing docstring) pass - - -class RemoteBuild( - UnimplementedMixin, commands.core22.RemoteBuildCommand -): # noqa: D101 (missing docstring) - pass diff --git a/snapcraft/services/__init__.py b/snapcraft/services/__init__.py index b16a65cc62c..f0d0045b716 100644 --- a/snapcraft/services/__init__.py +++ b/snapcraft/services/__init__.py @@ -19,11 +19,13 @@ from snapcraft.services.lifecycle import Lifecycle from snapcraft.services.package import Package from snapcraft.services.provider import Provider +from snapcraft.services.remotebuild import RemoteBuild from snapcraft.services.service_factory import SnapcraftServiceFactory __all__ = [ "Lifecycle", "Package", "Provider", + "RemoteBuild", "SnapcraftServiceFactory", ] diff --git a/snapcraft/services/remotebuild.py b/snapcraft/services/remotebuild.py new file mode 100644 index 00000000000..7612935ac7c --- /dev/null +++ b/snapcraft/services/remotebuild.py @@ -0,0 +1,78 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 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 Lifecycle Service.""" + +from collections.abc import Collection, Mapping +from datetime import datetime +from pathlib import Path +from typing import Any + +from craft_application import launchpad +from craft_application.services import remotebuild +from overrides import override + + +class RemoteBuild(remotebuild.RemoteBuildService): + """Snapcraft remote build service.""" + + RecipeClass = launchpad.models.SnapRecipe + + def fetch_logs(self, output_dir: Path) -> Mapping[str, Path | None]: + """Fetch the logs for each build to the given directory. + + :param output_dir: The directory into which to place the logs. + :returns: A mapping of the architecture to its build log. + """ + if not self._is_setup: + raise RuntimeError( + "RemoteBuildService must be set up using start_builds " + "or resume_builds before fetching logs." + ) + project_name = self._name.split("-", maxsplit=2)[1] + logs: dict[str, Path | None] = {} + log_downloads: dict[str, Path] = {} + fetch_time = datetime.now().isoformat(timespec="seconds") + for build in self._builds: + url = build.build_log_url + if not url: + logs[build.arch_tag] = None + continue + filename = ( + f"{project_name}_{build.distribution.name}-" + f"{build.distro_series.version}-{build.arch_tag}-{fetch_time}.txt" + ) + logs[build.arch_tag] = output_dir / filename + log_downloads[url] = output_dir / filename + self.request.download_files_with_progress(log_downloads) + return logs + + @override + def _new_recipe( + self, + name: str, + repository: launchpad.models.GitRepository, + architectures: Collection[str] | None = None, + **_: Any, # noqa: ANN401 + ) -> launchpad.models.Recipe: + """Create a new recipe.""" + return launchpad.models.SnapRecipe.new( + self.lp, + name, + self.lp.username, + architectures=architectures, + project=self._lp_project.name, + git_ref=repository.self_link + "/+ref/main", + ) diff --git a/snapcraft/services/service_factory.py b/snapcraft/services/service_factory.py index 62bd617274e..c1c781425fa 100644 --- a/snapcraft/services/service_factory.py +++ b/snapcraft/services/service_factory.py @@ -41,3 +41,6 @@ class SnapcraftServiceFactory(ServiceFactory): PackageClass: type[services.Package] = ( # type: ignore[reportIncompatibleVariableOverride] services.Package ) + RemoteBuildClass: type[ # type: ignore[reportIncompatibleVariableOverride] + services.RemoteBuild + ] = services.RemoteBuild diff --git a/tests/spread/core24/remote-build/task.yaml b/tests/spread/core24/remote-build/task.yaml new file mode 100644 index 00000000000..0702e05f947 --- /dev/null +++ b/tests/spread/core24/remote-build/task.yaml @@ -0,0 +1,35 @@ +summary: Test the remote builder for core24 +manual: true +kill-timeout: 180m + +environment: + LAUNCHPAD_TOKEN: "$(HOST: echo ${LAUNCHPAD_TOKEN})" + +prepare: | + if [[ -z "$LAUNCHPAD_TOKEN" ]]; then + echo "No credentials set in env LAUNCHPAD_TOKEN" + exit 1 + fi + + snapcraft init + sed -i 's/base: core22/base: core24/' snap/snapcraft.yaml + echo "build-base: devel" >> snap/snapcraft.yaml + + # Commit the project + git config --global --add safe.directory "$PWD" + git add snap/snapcraft.yaml + git commit -m "Initial Commit" + + # Setup launchpad token + mkdir -p ~/.local/share/snapcraft/ + echo -e "$LAUNCHPAD_TOKEN" >> ~/.local/share/snapcraft/launchpad-credentials + +restore: | + rm -f ./*.snap ./*.txt + + rm -rf snap .git + +execute: | + snapcraft remote-build --launchpad-accept-public-upload + + find . -maxdepth 1 -name "*.snap" | MATCH ".snap" diff --git a/tests/unit/commands/test_remote.py b/tests/unit/commands/test_remote.py index 8fb3e5bd163..32a30378213 100644 --- a/tests/unit/commands/test_remote.py +++ b/tests/unit/commands/test_remote.py @@ -20,13 +20,15 @@ import shutil import subprocess import sys +import time from pathlib import Path from unittest.mock import ANY, call import pytest from yaml import safe_dump -from snapcraft import cli +from snapcraft import application, cli +from snapcraft.errors import ClassicFallback from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES, LEGACY_BASES from snapcraft.remote import GitRepo @@ -76,12 +78,20 @@ def mock_core22_confirm(mocker): @pytest.fixture() def mock_remote_builder(mocker): _mock_remote_builder = mocker.patch( - "snapcraft.commands.core22.remote.RemoteBuilder" + "craft_application.services.remotebuild.RemoteBuildService" ) - _mock_remote_builder.return_value.has_outstanding_build.return_value = False + _mock_remote_builder._is_setup = True return _mock_remote_builder +@pytest.fixture() +def mock_remote_builder_start_builds(mocker): + _mock_start_builds = mocker.patch( + "snapcraft.services.remotebuild.RemoteBuild.start_builds" + ) + return _mock_start_builds + + @pytest.fixture() def mock_core22_remote_builder(mocker): _mock_remote_builder = mocker.patch( @@ -122,6 +132,26 @@ def mock_run_legacy(mocker): ############# +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv", "fake_services") +def test_command_user_confirms_upload( + snapcraft_yaml, + base, + mock_confirm, +): + """Run remote-build if the user confirms the upload prompt.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + app = application.create_app() + app.run() + + mock_confirm.assert_called_once_with( + "All data sent to remote builders will be publicly available. " + "Are you sure you want to continue?", + default=False, + ) + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -139,6 +169,29 @@ def test_command_user_confirms_upload_core22( mock_run_core22_or_fallback_remote_build.assert_called_once() +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv", "fake_services") +def test_command_user_denies_upload( + capsys, + snapcraft_yaml, + base, + mock_confirm, +): + """Raise an error if the user denies the upload prompt.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + mock_confirm.return_value = False + app = application.create_app() + app.run() + + _, err = capsys.readouterr() + + assert ( + "Remote build needs explicit acknowledgement that data sent to build " + "servers is public." + ) in err + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -159,6 +212,24 @@ def test_command_user_denies_upload_core22( ) in err +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv", "fake_services") +def test_command_accept_upload( + mocker, snapcraft_yaml, base, mock_confirm, mock_run_remote_build +): + """Do not prompt user if `--launchpad-accept-public-upload` is provided.""" + mocker.patch.object( + sys, "argv", ["snapcraft", "remote-build", "--launchpad-accept-public-upload"] + ) + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + app = application.create_app() + app.run() + + mock_confirm.assert_not_called() + mock_run_remote_build.assert_called_once() + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -234,6 +305,22 @@ def test_command_build_on_warning_core22( mock_run_core22_or_fallback_remote_build.assert_called_once() +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@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): + """Warn when snapcraft is run with sudo.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + app = application.create_app() + app.run() + + emitter.assert_progress( + "Running with 'sudo' may cause permission errors and is discouraged.", + permanent=True, + ) + mock_run_remote_build.assert_called_once() + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -252,6 +339,15 @@ def test_remote_build_sudo_warns_core22( 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.""" + app = application.create_app() + + with pytest.raises(ClassicFallback): + 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.""" @@ -264,6 +360,22 @@ def test_cannot_load_snapcraft_yaml_core22(capsys): ) +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") +def test_launchpad_timeout_default(mocker, snapcraft_yaml, base, fake_services): + """Use the default timeout `0` when `--launchpad-timeout` is not provided.""" + 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" + ) + app = application.create_app() + app.run() + + mock_start_builds.assert_called_once() + assert app.services.remote_build._deadline is None + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -287,6 +399,26 @@ def test_launchpad_timeout_default_core22(mock_core22_remote_builder): ) +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_argv", "mock_confirm", "fake_services") +def test_launchpad_timeout(mocker, snapcraft_yaml, base, fake_services): + """Pass the `--launchpad-timeout` to the remote builder.""" + mocker.patch.object( + sys, "argv", ["snapcraft", "remote-build", "--launchpad-timeout", "100"] + ) + 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" + ) + app = application.create_app() + app.run() + + mock_start_builds.assert_called_once() + assert app.services.remote_build._deadline is not None + assert app.services.remote_build._deadline > time.monotonic_ns() + 90 * 10**9 + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -426,25 +558,53 @@ def test_get_effective_base_core18_esm_warning_core22( ####################### -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES - {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_core22(emitter, mock_run_core22_remote_build): - """Bases that is core22 must use core22 remote-build.""" - cli.run() +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@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.""" + 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() + + +@pytest.mark.parametrize("base", {"core22"}) +@pytest.mark.usefixtures("mock_core22_confirm", "mock_argv") +def test_run_core22( + emitter, + 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() - emitter.assert_debug("Running new remote-build because base is newer than core22") + 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( - "create_snapcraft_yaml", LEGACY_BASES | {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_core22_and_older(emitter, mock_run_legacy): +@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 +): """core22 and older bases can use fallback remote-build.""" - 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") @@ -457,9 +617,7 @@ def test_run_core22_and_older(emitter, mock_run_legacy): "envvar", ["force-fallback", "disable-fallback", "badvalue", None] ) @pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") -def test_run_envvar_newer_than_core22( - envvar, emitter, mock_run_core22_remote_build, monkeypatch -): +def test_run_envvar_core22(envvar, emitter, mock_run_core22_remote_build, monkeypatch): """Bases newer than core22 run new remote-build regardless of envvar.""" if envvar: monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", envvar) @@ -583,21 +741,23 @@ def test_run_not_in_repo_core22(emitter, mock_run_legacy): emitter.assert_debug("Running fallback remote-build") -@pytest.mark.parametrize( - "create_snapcraft_yaml", CURRENT_BASES - {"core22"}, indirect=True -) -@pytest.mark.usefixtures("create_snapcraft_yaml", "mock_core22_confirm", "mock_argv") +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") def test_run_in_repo_newer_than_core22( - emitter, mock_run_core22_remote_build, monkeypatch, new_dir + emitter, mocker, snapcraft_yaml, base, new_dir, fake_services ): - """Bases newer than core22 run new remote-build regardless of being in a repo.""" + """Bases newer than core22 run craft-application remote-build regardless of being in a repo.""" # initialize a git repo GitRepo(new_dir) + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) - cli.run() + mock_start_builds = mocker.patch( + "snapcraft.services.remotebuild.RemoteBuild.start_builds" + ) + application.main() - mock_run_core22_remote_build.assert_called_once() - emitter.assert_debug("Running new remote-build because base is newer than core22") + mock_start_builds.assert_called_once() @pytest.mark.parametrize( @@ -647,6 +807,69 @@ def test_run_in_shallow_repo_core22(emitter, mock_run_legacy, new_dir): 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.usefixtures( + "mock_confirm", + "mock_argv", +) +def test_run_in_shallow_repo_unsupported( + capsys, + new_dir, + snapcraft_yaml, + base, + mock_remote_builder_start_builds, + fake_services, +): + """devel / core24 and newer bases run new remote-build in a shallow git repo.""" + root_path = Path(new_dir) + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + + 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 + application.main() + _, err = capsys.readouterr() + + assert ( + "Remote builds are not supported for projects in shallowly cloned " + "git repositories." + ) in err + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES - {"core22"}, indirect=True ) @@ -1006,6 +1229,45 @@ def test_recover_no_build_core22(emitter, mocker): emitter.assert_progress("No build found", permanent=True) +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_confirm") +def test_recover_build(mocker, snapcraft_yaml, base, fake_services): + """Recover a build when `--recover` is provided.""" + mocker.patch.object(sys, "argv", ["snapcraft", "remote-build", "--recover"]) + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + + mock_resume_builds = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.resume_builds" + ) + + mock_monitor_builds = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.monitor_builds" + ) + + mock_fetch_logs = mocker.patch( + "snapcraft.services.remotebuild.RemoteBuild.fetch_logs" + ) + + mock_fetch_artifacts = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.fetch_artifacts", + return_value=[Path("test.snap")], + ) + + mock_cleanup = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.cleanup" + ) + + app = application.create_app() + app.run() + + mock_resume_builds.assert_called_once() + mock_monitor_builds.assert_called_once() + mock_fetch_logs.assert_called_once() + mock_fetch_artifacts.assert_called_once() + mock_cleanup.assert_called_once() + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) @@ -1074,6 +1336,44 @@ def test_recover_build_user_denies_core22(emitter, mocker, mock_core22_remote_bu ] +@pytest.mark.parametrize("base", CURRENT_BASES - {"core22"}) +@pytest.mark.usefixtures("mock_confirm", "mock_argv") +def test_remote_build(mocker, snapcraft_yaml, base, fake_services): + """Clean and start a new build.""" + snapcraft_yaml_dict = {"base": base, "build-base": "devel", "grade": "devel"} + snapcraft_yaml(**snapcraft_yaml_dict) + + mock_start_builds = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.start_builds" + ) + + mock_monitor_builds = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.monitor_builds" + ) + + mock_fetch_logs = mocker.patch( + "snapcraft.services.remotebuild.RemoteBuild.fetch_logs" + ) + + mock_fetch_artifacts = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.fetch_artifacts", + return_value=[Path("test.snap")], + ) + + mock_cleanup = mocker.patch( + "craft_application.services.remotebuild.RemoteBuildService.cleanup" + ) + + app = application.create_app() + app.run() + + mock_start_builds.assert_called_once() + mock_monitor_builds.assert_called_once() + mock_fetch_logs.assert_called_once() + mock_fetch_artifacts.assert_called_once() + mock_cleanup.assert_called_once() + + @pytest.mark.parametrize( "create_snapcraft_yaml", CURRENT_BASES | LEGACY_BASES, indirect=True ) diff --git a/tests/unit/models/test_projects.py b/tests/unit/models/test_projects.py index 7267e20d04c..f3afc78a147 100644 --- a/tests/unit/models/test_projects.py +++ b/tests/unit/models/test_projects.py @@ -270,7 +270,6 @@ def test_project_version_invalid(self, project_yaml_data): # We only test one invalid version as this model is inherited # from Craft Application. with pytest.raises(errors.ProjectValidationError) as raised: - Project.unmarshal(project_yaml_data(version="1=1")) assert str(raised.value) == (