Repository: canonical/snapcraft

Issue:
remote-build: add control logic for snap base
### What needs to get done

This adds control logic to determine whether to execute the new or legacy remote-build code.

Requires parsing the snapcraft.yaml and checking the base.  Only core20 and core22 snaps can use the legacy remote-build code.  

Devel, core24, and onwards must use the new remote-build code.


![image](https://github.com/snapcore/snapcraft/assets/60674096/a8acba52-5474-4875-a3c6-73d7f8bac050)


### Why it needs to get done

remote-build needs to be migrated because it does not leverage the new craft libraries, has issues with building core22 snaps, and has issues related to how the local project is bundled.


Discussion:

Resolving patch:
diff --git a/snapcraft/commands/remote.py b/snapcraft/commands/remote.py
--- a/snapcraft/commands/remote.py
+++ b/snapcraft/commands/remote.py
@@ -24,8 +24,9 @@
 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.yaml_utils import get_snap_project, process_yaml
+from snapcraft.parts import yaml_utils
 from snapcraft.utils import confirm_with_user
 from snapcraft_legacy.internal.remote_build.errors import AcceptPublicUploadError
 
@@ -88,15 +89,58 @@ def fill_parser(self, parser: argparse.ArgumentParser) -> None:
             help="acknowledge that uploaded code will be publicly available.",
         )
 
+    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 or if the
+        snapcraft.yaml cannot be loaded.
+        :raises MaintenanceBase: If the base is not supported
+        """
+        snapcraft_yaml = yaml_utils.get_snap_project().project_file
+
+        with open(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(snapcraft_yaml)!r}."
+            )
+
+        emit.debug(f"Got base {base!r} from {str(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(snapcraft_yaml)!r}.")
+
+        return base
+
+    def _run_remote_build(self, base: str) -> None:
+        # bases newer than core22 must use the new remote-build
+        if base in yaml_utils.CURRENT_BASES - {"core22"}:
+            emit.debug(
+                "Using fallback remote-build because new remote-build is not available."
+            )
+            # TODO: use new remote-build code (#4323)
+            run_legacy()
+            return
+
+        emit.debug("Running fallback remote-build.")
+        run_legacy()
+
     @overrides
-    def run(self, parsed_args):
+    def run(self, parsed_args) -> None:
         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."
+            "snapcraft remote-build is experimental and is subject to change "
+            "- use with caution."
         )
 
         if parsed_args.build_on:
@@ -108,14 +152,5 @@ def run(self, parsed_args):
         ):
             raise AcceptPublicUploadError()
 
-        snap_project = get_snap_project()
-        # TODO proper core22 support would mean we need to load the project
-        # yaml_data = process_yaml(snap_project.project_file)
-        # for now, only log explicitly that we are falling back to legacy to
-        # remote build for core22
-        process_yaml(snap_project.project_file)
-
-        emit.debug(
-            "core22 not yet supported in new code base: re-executing into legacy for remote-build"
-        )
-        run_legacy()
+        base = self._get_effective_base()
+        self._run_remote_build(base)
diff --git a/snapcraft/parts/yaml_utils.py b/snapcraft/parts/yaml_utils.py
--- a/snapcraft/parts/yaml_utils.py
+++ b/snapcraft/parts/yaml_utils.py
@@ -31,8 +31,15 @@
 
 _CORE_PART_KEYS = ["build-packages", "build-snaps"]
 _CORE_PART_NAME = "snapcraft/core"
-_ESM_BASES = {"core", "core18"}
-_LEGACY_BASES = {"core20"}
+
+# All bases recognized by snapcraft
+BASES = {"core", "core18", "core20", "core22", "devel"}
+# Bases no longer supported by the current version of snapcraft
+ESM_BASES = {"core", "core18"}
+# Bases handled by the legacy snapcraft codebase
+LEGACY_BASES = {"core20"}
+# Bases handled by the current snapcraft codebase
+CURRENT_BASES = BASES - ESM_BASES - LEGACY_BASES
 
 
 @dataclass
@@ -133,9 +140,9 @@ def load(filestream: TextIO) -> Dict[str, Any]:
 
     if build_base is None:
         raise errors.LegacyFallback("no base defined")
-    if build_base in _ESM_BASES:
+    if build_base in ESM_BASES:
         raise errors.MaintenanceBase(build_base)
-    if build_base in _LEGACY_BASES:
+    if build_base in LEGACY_BASES:
         raise errors.LegacyFallback(f"base is {build_base}")
 
     filestream.seek(0)


Test patch:
diff --git a/tests/unit/commands/test_remote_build.py b/tests/unit/commands/test_remote_build.py
--- a/tests/unit/commands/test_remote_build.py
+++ b/tests/unit/commands/test_remote_build.py
@@ -1,6 +1,6 @@
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022-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
@@ -13,162 +13,261 @@
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
-import argparse
-from unittest.mock import call
+import sys
 
 import pytest
 
-from snapcraft import errors
-from snapcraft.commands.remote import RemoteBuildCommand
+from snapcraft import cli
+from snapcraft.parts.yaml_utils import CURRENT_BASES, ESM_BASES, LEGACY_BASES
+from snapcraft_legacy.internal.remote_build.errors import AcceptPublicUploadError
 
 
-@pytest.fixture()
-def legacy_run_mock(mocker):
-    mock = mocker.patch("snapcraft.commands.remote.run_legacy")
-    return mock
+@pytest.fixture
+def fake_sudo(monkeypatch):
+    monkeypatch.setenv("SUDO_USER", "fake")
+    monkeypatch.setattr("os.geteuid", lambda: 0)
 
 
-@pytest.fixture(autouse=True)
-def get_project_mock(mocker):
-    mocker.patch("snapcraft.commands.remote.get_snap_project")
-    return mocker.patch("snapcraft.commands.remote.process_yaml")
+@pytest.fixture()
+def mock_argv(mocker):
+    """Mock `snapcraft remote-build` cli."""
+    return mocker.patch.object(sys, "argv", ["snapcraft", "remote-build"])
 
 
 @pytest.fixture
-def confirm_mock(mocker):
+def mock_confirm(mocker):
     return mocker.patch(
         "snapcraft.commands.remote.confirm_with_user", return_value=True
     )
 
 
-@pytest.fixture
-def fake_sudo(monkeypatch):
-    monkeypatch.setenv("SUDO_USER", "fake")
-    monkeypatch.setattr("os.geteuid", lambda: 0)
+@pytest.fixture()
+def mock_get_effective_base(request, mocker):
+    """Mock loading a project with a specific base."""
+    return mocker.patch(
+        "snapcraft.commands.remote.RemoteBuildCommand._get_effective_base",
+        return_value=request.param,
+    )
 
 
-def test_command_accept_upload_runs_legacy(legacy_run_mock, emitter):
-    cmd = RemoteBuildCommand(None)
-    cmd.run(
-        argparse.Namespace(
-            status=None,
-            recover=None,
-            build_for=None,
-            build_on=None,
-            launchpad_accept_public_upload=True,
-        )
+@pytest.fixture()
+def mock_run_remote_build(mocker):
+    return mocker.patch(
+        "snapcraft.commands.remote.RemoteBuildCommand._run_remote_build"
     )
 
-    assert legacy_run_mock.mock_calls == [call()]
-    emitter.assert_message(
-        "snapcraft remote-build is experimental and is subject to change "
-        "- "
-        "use with caution."
+
+@pytest.fixture()
+def mock_run_legacy(mocker):
+    return mocker.patch("snapcraft.commands.remote.run_legacy")
+
+
+#############
+# CLI tests #
+#############
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES | LEGACY_BASES, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_argv")
+def test_command_user_confirms_upload(mock_confirm, mock_run_remote_build):
+    """Run remote-build if the user confirms the upload prompt."""
+    cli.run()
+
+    mock_confirm.assert_called_once_with(
+        "All data sent to remote builders will be publicly available. "
+        "Are you sure you want to continue?"
     )
-    emitter.assert_debug(
-        "core22 not yet supported in new code base: re-executing into legacy for remote-build"
+    mock_run_remote_build.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES | LEGACY_BASES, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_argv")
+def test_command_user_denies_upload(mock_confirm, mock_run_remote_build):
+    """Raise an error if the user denies the upload prompt."""
+    mock_confirm.return_value = False
+
+    with pytest.raises(AcceptPublicUploadError):
+        cli.run()
+
+    mock_confirm.assert_called_once_with(
+        "All data sent to remote builders will be publicly available. "
+        "Are you sure you want to continue?"
     )
+    mock_run_remote_build.assert_not_called()
 
 
-def test_command_user_confirms_upload_runs_legacy(
-    legacy_run_mock, emitter, confirm_mock
-):
-    cmd = RemoteBuildCommand(None)
-    cmd.run(
-        argparse.Namespace(
-            status=None,
-            recover=None,
-            build_for=None,
-            build_on=None,
-            launchpad_accept_public_upload=None,
-        )
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES | LEGACY_BASES, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base")
+def test_command_accept_upload(mock_confirm, mock_run_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"]
     )
 
-    assert legacy_run_mock.mock_calls == [call()]
-    assert confirm_mock.mock_calls == [
-        call(
-            "All data sent to remote builders will be publicly available. "
-            "Are you sure you want to continue?"
-        )
-    ]
-    emitter.assert_message(
-        "snapcraft remote-build is experimental and is subject to change "
-        "- "
-        "use with caution."
+    cli.run()
+
+    mock_confirm.assert_not_called()
+    mock_run_remote_build.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES | LEGACY_BASES, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_confirm")
+def test_command_build_on_warning(emitter, mocker, mock_run_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_remote_build.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES | LEGACY_BASES, indirect=True
+)
+@pytest.mark.usefixtures(
+    "mock_get_effective_base", "mock_confirm", "fake_sudo", "mock_argv"
+)
+def test_remote_build_sudo_warns(emitter, mock_run_remote_build):
+    """Warn when snapcraft is run with sudo."""
+    cli.run()
 
-def test_command_legacy_exec_on_project_fail_after_confirming_with_user(
-    emitter, confirm_mock, get_project_mock
-):
-    """Legacy fallback is raised after confirming with the user.
-
-    In this scenario, Snapcraft's exit handler handles the re-exec.
-    """
-    get_project_mock.side_effect = errors.LegacyFallback("Unsupported base")
-    cmd = RemoteBuildCommand(None)
-
-    with pytest.raises(errors.LegacyFallback):
-        cmd.run(
-            argparse.Namespace(
-                status=None,
-                recover=None,
-                build_for=None,
-                build_on=None,
-                launchpad_accept_public_upload=None,
-            )
-        )
-
-    assert confirm_mock.mock_calls == [
-        call(
-            "All data sent to remote builders will be publicly available. "
-            "Are you sure you want to continue?"
-        )
-    ]
     emitter.assert_message(
-        "snapcraft remote-build is experimental and is subject to change "
-        "- "
-        "use with caution."
+        "Running with 'sudo' may cause permission errors and is discouraged."
     )
+    mock_run_remote_build.assert_called_once()
 
 
-def test_command_use_of_build_on_warns(legacy_run_mock, emitter):
-    cmd = RemoteBuildCommand(None)
-    cmd.run(
-        argparse.Namespace(
-            status=None,
-            recover=None,
-            build_for=None,
-            build_on="amd64",
-            launchpad_accept_public_upload=True,
-        )
-    )
+################################
+# Snapcraft project base tests #
+################################
 
-    assert legacy_run_mock.mock_calls == [call()]
-    emitter.assert_message(
-        "snapcraft remote-build is experimental and is subject to change "
-        "- "
-        "use with caution."
+
+@pytest.mark.usefixtures("mock_argv", "mock_confirm")
+@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES)
+def test_get_effective_base(base, snapcraft_yaml, mock_run_remote_build):
+    """Get the base from a snapcraft.yaml file."""
+    snapcraft_yaml(base=base)
+
+    cli.run()
+
+    mock_run_remote_build.assert_called_once_with(base)
+
+
+@pytest.mark.usefixtures("mock_argv", "mock_confirm")
+@pytest.mark.parametrize(
+    ("base", "build_base"),
+    [
+        ("core24", "devel"),
+        ("core18", "core22"),
+        ("bare", "core22"),
+    ],
+)
+def test_get_effective_base_with_build_base(
+    base, build_base, snapcraft_yaml, mock_run_remote_build
+):
+    """The effective base should be the build-base, when provided."""
+    snapcraft_yaml(**{"base": base, "build-base": build_base})
+
+    cli.run()
+
+    mock_run_remote_build.assert_called_once_with(build_base)
+
+
+@pytest.mark.usefixtures("mock_argv", "mock_confirm")
+@pytest.mark.parametrize("base", CURRENT_BASES | LEGACY_BASES)
+def test_get_effective_base_type(base, snapcraft_yaml, mock_run_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_remote_build.assert_called_once_with(base)
+
+
+@pytest.mark.usefixtures("mock_argv", "mock_confirm", "new_dir")
+def test_get_effective_base_cannot_load_snapcraft_yaml(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.usefixtures("mock_argv", "mock_confirm")
+def test_get_effective_base_unknown(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_confirm")
+def test_get_effective_base_none(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_confirm")
+@pytest.mark.parametrize("base", ESM_BASES)
+def test_get_effective_base_esm(base, capsys, snapcraft_yaml):
+    """Raise an error if an ESM base is used."""
+    snapcraft_yaml(base=base)
+
+    cli.run()
+
+    _, err = capsys.readouterr()
+    assert f"{base!r} is not supported on this version of Snapcraft." in err
+
+
+#######################
+# Control logic tests #
+#######################
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES - {"core22"}, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_confirm", "mock_argv")
+def test_run_newer_than_core_22(emitter, mock_run_legacy):
+    """Bases newer than core22 must use new remote-build."""
+    cli.run()
+
+    # this should fail when new remote-build code is used (#4323)
+    mock_run_legacy.assert_called_once()
     emitter.assert_debug(
-        "core22 not yet supported in new code base: re-executing into legacy for remote-build"
+        "Using fallback remote-build because new remote-build is not available."
     )
-    emitter.assert_message("Use --build-for instead of --build-on")
 
 
-@pytest.mark.usefixtures("fake_sudo", "legacy_run_mock")
-def test_remote_build_sudo_warns(emitter):
-    cmd = RemoteBuildCommand(None)
-    cmd.run(
-        argparse.Namespace(
-            status=None,
-            recover=None,
-            build_for=None,
-            build_on="amd64",
-            launchpad_accept_public_upload=True,
-        )
-    )
+@pytest.mark.parametrize(
+    "mock_get_effective_base", LEGACY_BASES | {"core22"}, indirect=True
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_confirm", "mock_argv")
+def test_run_core22_and_older(emitter, mock_run_legacy):
+    """core22 and older bases can use fallback remote-build."""
+    cli.run()
 
-    emitter.assert_message(
-        "Running with 'sudo' may cause permission errors and is discouraged."
-    )
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug("Running fallback remote-build.")