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) == (