Repository: canonical/snapcraft

Issue:
Reported error message building base: core18 with newer snapcrafts is wrong
### Bug Description

`snapcraft` on `latest/` recently removed support for core18 bases. When building such a snap with these releases of `snapcraft` I would expect an error very early on during builds. However, the error instead occurs somewhere else and incorrectly attributes build failures to the plugin I've chosen, rather than the base. This is, of course, not true; the (for example) nil plugin *is* supported by `base: core18` snaps, I'm just not using a version of `snapcraft` which supports building such snaps.

### To Reproduce

```
snap install --channel=latest/edge snapcraft
mkdir test-snap && cd test-snap
snapcraft init
sed -i 's/core22/core18/' snap/snapcraft.yaml
snapcraft --use-lxd
```



### Environment

Running at least snapcraft rev 9410, but any sufficiently recent release beyond 7.4.3 will probably exhibit this behavior -- using 7.x risks still works.

Running on Ubuntu 23.04 using LXD as the build provider.

### snapcraft.yaml

```shell
name: my-snap-name # you probably want to 'snapcraft register <name>'
base: core18 # the base snap is the execution environment for this snap
version: '0.1' # just for humans, typically '1.2+git' or '1.3.2'
summary: Single-line elevator pitch for your amazing snap # 79 char long summary
description: |
  This is my-snap's description. You have a paragraph or two to tell the
  most important story about your snap. Keep it under 100 words though,
  we live in tweetspace and your description wants to look good in the snap
  store.

grade: devel # must be 'stable' to release into candidate/stable channels
confinement: devmode # use 'strict' once you have the right plugs and slots

parts:
  my-part:
    # See 'snapcraft plugins'
    plugin: nil
```


### Relevant log output

```shell
dilyn@Ares:~/tt -> snapcraft --use-lxd      
Starting Snapcraft 7.4.1.post31+gitf5b2f2e0                                                                                                            
Logging execution to '/home/dilyn/.local/state/snapcraft/log/snapcraft-20230629-214349.990383.log'                                                     
Launching a container.
Waiting for container to be ready
Waiting for network to be ready...
snap "core18" has no updates available
The plugin 'nil' does not support snaps using base 'core18'.
Run the same command again with --debug to shell into the environment if you wish to introspect this failure.
```
```


### Additional context

_No response_


Discussion:
I believe this is the same error you would get if you set core17.

Given that core18 once worked, we could make this error much more helpful

Resolving patch:
diff --git a/snapcraft/commands/lint.py b/snapcraft/commands/lint.py
--- a/snapcraft/commands/lint.py
+++ b/snapcraft/commands/lint.py
@@ -32,8 +32,7 @@
 from craft_providers.util import snap_cmd
 from overrides import overrides
 
-from snapcraft import linters, projects, providers
-from snapcraft.errors import LegacyFallback, SnapcraftError
+from snapcraft import errors, linters, projects, providers
 from snapcraft.meta import snap_yaml
 from snapcraft.parts.lifecycle import apply_yaml, extract_parse_info, process_yaml
 from snapcraft.utils import (
@@ -148,14 +147,14 @@ def _prepare_instance(
         :param http_proxy: http proxy to add to environment
         :param https_proxy: https proxy to add to environment
 
-        :raises SnapcraftError: If `snapcraft lint` fails inside the instance.
+        :raises errors.SnapcraftError: If `snapcraft lint` fails inside the instance.
         """
         emit.progress("Checking build provider availability.")
 
         provider = providers.get_provider()
         if isinstance(provider, MultipassProvider):
             # blocked by https://github.com/canonical/craft-providers/issues/169
-            raise SnapcraftError(
+            raise errors.SnapcraftError(
                 "'snapcraft lint' is not supported with Multipass as the build provider"
             )
         providers.ensure_provider_is_available(provider)
@@ -198,7 +197,7 @@ def _prepare_instance(
                 with emit.pause():
                     instance.execute_run(command, check=True)
             except subprocess.CalledProcessError as error:
-                raise SnapcraftError(
+                raise errors.SnapcraftError(
                     f"failed to execute {shlex.join(command)!r} in instance",
                 ) from error
             finally:
@@ -231,7 +230,7 @@ def _unsquash_snap(self, snap_file: Path) -> Iterator[Path]:
 
         :yields: Path to the snap's unsquashed directory.
 
-        :raises SnapcraftError: If the snap fails to unsquash.
+        :raises errors.SnapcraftError: If the snap fails to unsquash.
         """
         snap_file = snap_file.resolve()
 
@@ -252,7 +251,7 @@ def _unsquash_snap(self, snap_file: Path) -> Iterator[Path]:
             try:
                 subprocess.run(extract_command, capture_output=True, check=True)
             except subprocess.CalledProcessError as error:
-                raise SnapcraftError(
+                raise errors.SnapcraftError(
                     f"could not unsquash snap file {snap_file.name!r}"
                 ) from error
 
@@ -275,8 +274,8 @@ def _load_project(self, snapcraft_yaml_file: Path) -> Optional[projects.Project]
         try:
             # process_yaml will not parse core, core18, and core20 snaps
             yaml_data = process_yaml(snapcraft_yaml_file)
-        except LegacyFallback as error:
-            raise SnapcraftError(
+        except (errors.LegacyFallback, errors.MaintenanceBase) as error:
+            raise errors.SnapcraftError(
                 "can not lint snap using a base older than core22"
             ) from error
 
@@ -305,7 +304,7 @@ def _install_snap(
 
         :returns: Path to where snap was installed.
 
-        :raises SnapcraftError: If the snap cannot be installed.
+        :raises errors.SnapcraftError: If the snap cannot be installed.
         """
         is_dangerous = not bool(assert_file)
 
@@ -338,7 +337,7 @@ def _install_snap(
         try:
             subprocess.run(install_command, capture_output=True, check=True)
         except subprocess.CalledProcessError as error:
-            raise SnapcraftError(
+            raise errors.SnapcraftError(
                 f"could not install snap file {snap_file.name!r}"
             ) from error
 
diff --git a/snapcraft/errors.py b/snapcraft/errors.py
--- a/snapcraft/errors.py
+++ b/snapcraft/errors.py
@@ -16,6 +16,8 @@
 
 """Snapcraft error definitions."""
 
+from typing import Optional
+
 from craft_cli import CraftError
 
 
@@ -117,6 +119,27 @@ class LegacyFallback(Exception):
     """Fall back to legacy snapcraft implementation."""
 
 
+class MaintenanceBase(SnapcraftError):
+    """Error for bases under ESM and no longer supported in this release."""
+
+    def __init__(self, base: str) -> None:
+        channel: Optional[str] = None
+        if base == "core":
+            channel = "4.x"
+        elif base == "core18":
+            channel = "7.x"
+
+        resolution: Optional[str] = None
+        if channel:
+            resolution = f"Install from or refresh to the {channel!r} channel."
+
+        super().__init__(
+            f"{base!r} is not supported on this version of Snapcraft.",
+            resolution=resolution,
+            docs_url="https://snapcraft.io/docs/base-snaps",
+        )
+
+
 class StoreCredentialsUnauthorizedError(SnapcraftError):
     """Error raised for 401 responses from the Snap Store."""
 
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
@@ -23,7 +23,8 @@
 
 from snapcraft import errors, utils
 
-_LEGACY_BASES = {"core", "core18", "core20"}
+_ESM_BASES = {"core", "core18"}
+_LEGACY_BASES = {"core20"}
 
 
 def _check_duplicate_keys(node):
@@ -90,6 +91,8 @@ def load(filestream: TextIO) -> Dict[str, Any]:
 
         if build_base is None:
             raise errors.LegacyFallback("no base defined")
+        if build_base in _ESM_BASES:
+            raise errors.MaintenanceBase(build_base)
         if build_base in _LEGACY_BASES:
             raise errors.LegacyFallback(f"base is {build_base}")
     except yaml.error.YAMLError as err:


Test patch:
diff --git a/tests/unit/parts/test_yaml_utils.py b/tests/unit/parts/test_yaml_utils.py
--- a/tests/unit/parts/test_yaml_utils.py
+++ b/tests/unit/parts/test_yaml_utils.py
@@ -128,6 +128,19 @@ def test_yaml_load_not_core22_base():
     assert str(raised.value) == "base is core20"
 
 
+def test_yaml_load_esm_base():
+    with pytest.raises(errors.MaintenanceBase):
+        yaml_utils.load(
+            io.StringIO(
+                dedent(
+                    """\
+            base: core
+    """
+                )
+            )
+        )
+
+
 def test_yaml_load_no_base():
     with pytest.raises(errors.LegacyFallback) as raised:
         yaml_utils.load(
diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/test_errors.py
@@ -0,0 +1,42 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright 2023 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Snapcraft error tests."""
+
+import pytest
+
+from snapcraft import errors
+
+
+@pytest.mark.parametrize("base,channel", [("core", "4.x"), ("core18", "7.x")])
+def test_maintenance_base(base, channel):
+    message = f"{base!r} is not supported on this version of Snapcraft."
+    resolution = f"Install from or refresh to the {channel!r} channel."
+
+    error = errors.MaintenanceBase(base)
+
+    assert str(error) == message
+    assert error.resolution == resolution
+    assert error.docs_url == "https://snapcraft.io/docs/base-snaps"
+
+
+def test_maintenance_base_fallback():
+    """For when the base is not explicitly handled in the exception."""
+    error = errors.MaintenanceBase("unknown-base")
+
+    assert str(error) == "'unknown-base' is not supported on this version of Snapcraft."
+    assert error.resolution is None
+    assert error.docs_url == "https://snapcraft.io/docs/base-snaps"