Repository: canonical/snapcraft

Issue:
remote-build: add control logic for envvar `SNAPCRAFT_REMOTE_BUILD_STRATEGY`
### What needs to get done

This adds control logic to determine whether to execute the new or legacy remote-build code.

There are four possibilities with `SNAPCRAFT_REMOTE_BUILD_STRATEGY`:

- `disable-fallback` - use new remote-build code
- `force-fallback` - use legacy remote-build code
- unset - continue on to next control logic step
- unknown - raise an error

![image](https://github.com/snapcore/snapcraft/assets/60674096/298ea1f2-96b2-4337-b2cc-3d3c3e2848b8)


### 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
@@ -19,6 +19,8 @@
 import argparse
 import os
 import textwrap
+from enum import Enum
+from typing import Optional
 
 from craft_cli import BaseCommand, emit
 from craft_cli.helptexts import HIDDEN
@@ -27,7 +29,7 @@
 from snapcraft.errors import MaintenanceBase, SnapcraftError
 from snapcraft.legacy_cli import run_legacy
 from snapcraft.parts import yaml_utils
-from snapcraft.utils import confirm_with_user
+from snapcraft.utils import confirm_with_user, humanize_list
 from snapcraft_legacy.internal.remote_build.errors import AcceptPublicUploadError
 
 _CONFIRMATION_PROMPT = (
@@ -36,6 +38,16 @@
 )
 
 
+_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."""
 
@@ -89,6 +101,29 @@ def fill_parser(self, parser: argparse.ArgumentParser) -> None:
             help="acknowledge that uploaded code will be publicly available.",
         )
 
+    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.
 
@@ -128,6 +163,25 @@ def _run_remote_build(self, base: str) -> None:
             run_legacy()
             return
 
+        strategy = self._get_build_strategy()
+
+        if strategy == _Strategies.DISABLE_FALLBACK:
+            emit.debug(
+                f"Environment variable {_STRATEGY_ENVVAR!r} is "
+                f"{_Strategies.DISABLE_FALLBACK.value!r} but running fallback "
+                "remote-build because new remote-build is not available."
+            )
+            run_legacy()
+            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
+
         emit.debug("Running fallback remote-build.")
         run_legacy()
 


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
@@ -271,3 +271,105 @@ def test_run_core22_and_older(emitter, mock_run_legacy):
 
     mock_run_legacy.assert_called_once()
     emitter.assert_debug("Running fallback remote-build.")
+
+
+@pytest.mark.parametrize(
+    "mock_get_effective_base", CURRENT_BASES - {"core22"}, indirect=True
+)
+@pytest.mark.parametrize(
+    "envvar", ["force-fallback", "disable-fallback", "badvalue", None]
+)
+@pytest.mark.usefixtures("mock_get_effective_base", "mock_confirm", "mock_argv")
+def test_run_envvar_newer_than_core22(envvar, emitter, mock_run_legacy, monkeypatch):
+    """Bases newer than core22 run new remote-build regardless of envvar."""
+    if envvar:
+        monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", envvar)
+    else:
+        monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False)
+
+    cli.run()
+
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug(
+        "Using fallback remote-build because new remote-build is not available."
+    )
+
+
+@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_envvar_disable_fallback(emitter, mock_run_legacy, monkeypatch):
+    """core22 and older bases run new remote-build if envvar is `disable-fallback`."""
+    monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "disable-fallback")
+
+    cli.run()
+
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug(
+        "Environment variable 'SNAPCRAFT_REMOTE_BUILD_STRATEGY' is 'disable-fallback' "
+        "but running fallback remote-build because new remote-build is not available."
+    )
+
+
+@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_envvar_force_fallback(emitter, 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()
+
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug(
+        "Running fallback remote-build because environment variable "
+        "'SNAPCRAFT_REMOTE_BUILD_STRATEGY' is 'force-fallback'."
+    )
+
+
+@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_envvar_force_fallback_unset(emitter, mock_run_legacy, monkeypatch):
+    """core22 and older bases run legacy remote-build if envvar is unset."""
+    monkeypatch.delenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", raising=False)
+
+    cli.run()
+
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug("Running fallback remote-build.")
+
+
+@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_envvar_force_fallback_empty(emitter, mock_run_legacy, monkeypatch):
+    """core22 and older bases run legacy remote-build if envvar is empty."""
+    monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "")
+
+    cli.run()
+
+    mock_run_legacy.assert_called_once()
+    emitter.assert_debug("Running fallback remote-build.")
+
+
+@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_envvar_invalid(capsys, emitter, mock_run_legacy, monkeypatch):
+    """core22 and older bases raise an error if the envvar is invalid."""
+    monkeypatch.setenv("SNAPCRAFT_REMOTE_BUILD_STRATEGY", "badvalue")
+
+    cli.run()
+
+    _, err = capsys.readouterr()
+    assert (
+        "Unknown value 'badvalue' in environment variable "
+        "'SNAPCRAFT_REMOTE_BUILD_STRATEGY'. Valid values are 'disable-fallback' and "
+        "'force-fallback'."
+    ) in err