From bd29f0f1c79371feecabc8872fd23248241d7e27 Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Wed, 10 Sep 2025 19:29:17 -0400 Subject: [PATCH] feat: add a `get_base` factory function Also adds a quick how-to about using this function. --- craft_providers/__init__.py | 2 + craft_providers/bases/__init__.py | 13 ++ craft_providers/bases/_factory.py | 160 +++++++++++++++++++++++++ craft_providers/lxd/lxc.py | 2 +- docs/conf.py | 14 ++- docs/how-to-guides/index.rst | 2 + docs/how-to-guides/launch-instance.rst | 52 ++++++++ docs/index.rst | 4 + docs/reference/changelog.rst | 10 ++ docs/reference/index.rst | 2 + tests/integration/test_relaunch.py | 44 ++++--- tests/unit/bases/test_factory.py | 47 ++++++++ tests/unit/bases/test_get_base.py | 5 + 13 files changed, 331 insertions(+), 26 deletions(-) create mode 100644 craft_providers/bases/_factory.py create mode 100644 docs/how-to-guides/launch-instance.rst create mode 100644 tests/unit/bases/test_factory.py diff --git a/craft_providers/__init__.py b/craft_providers/__init__.py index ed127ae80..c67b823b4 100644 --- a/craft_providers/__init__.py +++ b/craft_providers/__init__.py @@ -21,6 +21,7 @@ from .errors import ProviderError from .executor import Executor from .provider import Provider +from .bases._factory import get_base try: from ._version import __version__ @@ -38,4 +39,5 @@ "Executor", "ProviderError", "Provider", + "get_base", ] diff --git a/craft_providers/bases/__init__.py b/craft_providers/bases/__init__.py index e154545a5..f7eaef658 100644 --- a/craft_providers/bases/__init__.py +++ b/craft_providers/bases/__init__.py @@ -20,6 +20,7 @@ from enum import Enum import sys from typing import Literal, NamedTuple, overload +import warnings from craft_providers.errors import BaseCompatibilityError, BaseConfigurationError from craft_providers.base import Base @@ -27,6 +28,7 @@ from . import almalinux, centos from . import ubuntu from . import ubuntu as buildd +from ._factory import get_base from .checks import ensure_guest_compatible from .ubuntu import BuilddBase, BuilddBaseAlias @@ -46,6 +48,7 @@ "BuilddBaseAlias", "BaseCompatibilityError", "BaseConfigurationError", + "get_base", ] @@ -88,6 +91,11 @@ def get_base_alias( def get_base_alias(base_name: BaseName) -> BaseAlias: ... def get_base_alias(base_name: tuple[str, str]) -> BaseAlias: """Return a Base alias from a base (name, version) tuple.""" + warnings.warn( + "get_base_alias is deprecated. Use craft_providers.get_base instead.", + category=DeprecationWarning, + stacklevel=2, + ) base_name = BaseName(*base_name) if base_name.name == "ubuntu" and base_name in BASE_NAME_TO_BASE_ALIAS: return BASE_NAME_TO_BASE_ALIAS[base_name] @@ -110,6 +118,11 @@ def get_base_from_alias( ) -> type[almalinux.AlmaLinuxBase]: ... def get_base_from_alias(alias: BaseAlias) -> type[Base[Enum]]: """Return a Base class from a known base alias.""" + warnings.warn( + "get_base_from_alias is deprecated. Use craft_providers.get_base instead.", + category=DeprecationWarning, + stacklevel=2, + ) match alias: case ubuntu.BuilddBaseAlias(): return ubuntu.BuilddBase diff --git a/craft_providers/bases/_factory.py b/craft_providers/bases/_factory.py new file mode 100644 index 000000000..684fd5fb8 --- /dev/null +++ b/craft_providers/bases/_factory.py @@ -0,0 +1,160 @@ +# Copyright 2025 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""A factory for providing bases.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, overload + +if TYPE_CHECKING: + import enum + import pathlib + + from craft_providers.actions.snap_installer import Snap + from craft_providers.base import Base + + # These are used in code, but we import them there so we don't have to import + # them if they don't get used. + from craft_providers.bases.almalinux import AlmaLinuxBase + from craft_providers.bases.centos import CentOSBase + from craft_providers.bases.ubuntu import BuilddBase + + +@overload +def get_base( + *, + distribution: Literal["almalinux"], + series: str, + compatibility_tag: str | None = None, + environment: dict[str, str | None] | None = None, + hostname: str = "craft-instance", + snaps: list[Snap] | None = None, + packages: list[str] | None = None, + use_default_packages: bool = True, + cache_path: pathlib.Path | None = None, +) -> AlmaLinuxBase: ... +@overload +def get_base( + *, + distribution: Literal["centos"], + series: str, + compatibility_tag: str | None = None, + environment: dict[str, str | None] | None = None, + hostname: str = "craft-instance", + snaps: list[Snap] | None = None, + packages: list[str] | None = None, + use_default_packages: bool = True, + cache_path: pathlib.Path | None = None, +) -> CentOSBase: ... +@overload +def get_base( + *, + distribution: Literal["ubuntu"], + series: str, + compatibility_tag: str | None = None, + environment: dict[str, str | None] | None = None, + hostname: str = "craft-instance", + snaps: list[Snap] | None = None, + packages: list[str] | None = None, + use_default_packages: bool = True, + cache_path: pathlib.Path | None = None, +) -> BuilddBase: ... +@overload +def get_base( + *, + distribution: str, + series: str, + compatibility_tag: str | None = None, + environment: dict[str, str | None] | None = None, + hostname: str = "craft-instance", + snaps: list[Snap] | None = None, + packages: list[str] | None = None, + use_default_packages: bool = True, + cache_path: pathlib.Path | None = None, +) -> Base[enum.Enum]: ... +def get_base( # noqa: PLR0913 + *, + distribution: str, + series: str, + compatibility_tag: str | None = None, + environment: dict[str, str | None] | None = None, + hostname: str = "craft-instance", + snaps: list[Snap] | None = None, + packages: list[str] | None = None, + use_default_packages: bool = True, + cache_path: pathlib.Path | None = None, +) -> Base[enum.Enum]: + """Get a base according to the provided distribution and series. + + :param distribution: The distribution of the base (e.g. ubuntu) + :param series: The series of the base (e.g. 26.04) + :param compatibility_tag: Tag/Version for variant of build configuration and + setup. Any change to this version would indicate that prior [versioned] + instances are incompatible and must be cleaned. As such, any new value + should be unique to old values (e.g. incrementing). It is suggested to + extend this tag, not overwrite it, e.g.: compatibility_tag = + f"{appname}-{Base.compatibility_tag}.{apprevision}" to ensure base + compatibility levels are maintained. + :param environment: Environment to set in /etc/environment. + :param hostname: Hostname to configure. + :param snaps: Optional list of snaps to install on the base image. + :param packages: Optional list of system packages to install on the base image. + :param use_default_packages: Optional bool to enable/disable default packages. + :param cache_path: Optional path to the shared cache directory. If this is + provided, shared cache directories will be mounted as appropriate. + """ + # We're importing within the function here so we don't have to import bases + # that we aren't going to use. + alias: BuilddBaseAlias | AlmaLinuxBaseAlias | CentOSBaseAlias + cls: type[BuilddBase | AlmaLinuxBase | CentOSBase] + match distribution: + case "ubuntu": + from .ubuntu import BuilddBase, BuilddBaseAlias # noqa: PLC0415 + + try: + alias = BuilddBaseAlias(series) + except ValueError: + raise ValueError(f"Unknown Ubuntu series: {series}") from None + cls = BuilddBase + case "almalinux": + from .almalinux import AlmaLinuxBase, AlmaLinuxBaseAlias # noqa: PLC0415 + + try: + alias = AlmaLinuxBaseAlias(series) + except ValueError: + raise ValueError(f"Unknown Alma Linux series: {series}") from None + cls = AlmaLinuxBase + case "centos": + from .centos import CentOSBase, CentOSBaseAlias # noqa: PLC0415 + + try: + alias = CentOSBaseAlias(series) + except ValueError: + raise ValueError(f"Unknown CentOS series: {series}") from None + cls = CentOSBase + case _: + raise ValueError(f"Unknown distribution {distribution!r}") + + return cls( + # Ignore argument type here because we set the matching pair above. + alias=alias, # type: ignore[arg-type] + compatibility_tag=compatibility_tag, + environment=environment, + hostname=hostname, + snaps=snaps, + packages=packages, + use_default_packages=use_default_packages, + cache_path=cache_path, + ) diff --git a/craft_providers/lxd/lxc.py b/craft_providers/lxd/lxc.py index 4be401e59..0e9ade7f3 100644 --- a/craft_providers/lxd/lxc.py +++ b/craft_providers/lxd/lxc.py @@ -115,7 +115,7 @@ def _run_lxc( check: bool = True, project: str | None = None, stdin: StdinType = StdinType.INTERACTIVE, - text: Literal[False, None] = None, + text: Literal[False] | None = None, encoding: None = None, errors: None = None, **kwargs: Any, diff --git a/docs/conf.py b/docs/conf.py index ccec110f1..57e3afe8f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,9 +44,21 @@ # These axes were empty, so hide their scaffolding for now "tutorials", - "how-to-guides", ] +# Links to ignore when checking links +linkcheck_ignore = [ + # GNU's site is a bit unreliable + "https://www.gnu.org/.*", + # https://github.com/rust-lang/crates.io/issues/788 + "https://crates.io/", + # Ignore releases, since we'll include the next release before it exists. + "https://github.com/canonical/[a-z]*craft[a-z-]*/releases/.*", + # returns a 403 from GitHub CI + "https://rsync.samba.org", +] + + extensions.extend( [ "sphinx.ext.autodoc", diff --git a/docs/how-to-guides/index.rst b/docs/how-to-guides/index.rst index 1549165ef..6d9cd9189 100644 --- a/docs/how-to-guides/index.rst +++ b/docs/how-to-guides/index.rst @@ -5,3 +5,5 @@ How-to guides .. toctree:: :maxdepth: 1 + + launch-instance diff --git a/docs/how-to-guides/launch-instance.rst b/docs/how-to-guides/launch-instance.rst new file mode 100644 index 000000000..a152a1156 --- /dev/null +++ b/docs/how-to-guides/launch-instance.rst @@ -0,0 +1,52 @@ +.. _how-to-launch: + +Launch a VM or container +======================== + +In order to launch a virtual machine or container with Craft Providers, you must know: + +1. Which provider to use. +2. What distribution and series name you want to launch. + +The provider is an instance of any :py:class:`~craft_providers.provider.Provider` +subclass. Craft Providers provides +:py:class:`~craft_providers.lxd.lxd_provider.LXDProvider` and +:py:class:`~craft_providers.multipass.multipass_provider.MultipassProvider` classes +for this purpose. + +.. code-block:: python + + from craft_providers.lxd.lxd_provider import LXDProvider + + provider = LXDProvider(project="my-project") + +Each Provider class has a +:py:class:`~craft_providers.provider.Provider.launched_environment` context manager, +which provides an :py:class:`~craft_providers.executor.Executor` instance. It is +just a matter of passing some project details and the base to this provider. The +:py:func:`~craft_providers.get_base` function provides a convenient way to get a base +object from its distribution name and series. + +.. code-block:: python + + import pathlib + import craft_providers + + base = craft_providers.get_base(distribution="ubuntu", series="24.04") + + with provider.launched_environment( + project_name="my-project", + project_path=pathlib.Path(), + base_configuration=base, + instance_name="my-instance", + ) as executor: + instance_info = executor.execute_run( + ["cat", "/etc/os-release"], + capture_output=True, + text=True, + ).stdout + +When the context of a launched environment is exited, Craft Providers will shut down +the provider according to the value of the ``shutdown_delay_mins`` parameter. +If no shutdown delay is specified, the provider will be shut down while exiting the +context manager. diff --git a/docs/index.rst b/docs/index.rst index 965840f98..9ca818fed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,11 +19,15 @@ framework that need to provide support for additional build environments. :maxdepth: 1 :hidden: + how-to-guides/index reference/index explanation/index .. list-table:: + * - | + - | :ref:`How-to Guides ` + | **Step-by-step guides** covering key operations and common tasks * - | :ref:`Reference ` | **Technical information** about Craft Providers - | :ref:`Explanation ` diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 9489d2639..bffc91aee 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -4,6 +4,15 @@ Changelog See the `Releases page`_ on GitHub for a complete list of commits that are included in each version. +.. _release-3.2.0: + +3.2.0 (unreleased) +------------------ + +- Add a + +For a complete list of commits, check out the `3.2.0`_ release on GitHub. + 3.1.0 (2025-09-08) ------------------ @@ -467,3 +476,4 @@ Note: The provided name for a LXD executor object is converted to comply with - Update documentation .. _Releases page: https://github.com/canonical/craft-providers/releases +.. _3.2.0: https://github.com/canonical/craft-providers/releases/tag/3.2.0 diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 38516da9a..49c99e484 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -10,6 +10,8 @@ Reference changelog executors +.. autofunction:: craft_providers.get_base + Indices and tables ------------------ diff --git a/tests/integration/test_relaunch.py b/tests/integration/test_relaunch.py index 3a4736cb7..817a2f8ff 100644 --- a/tests/integration/test_relaunch.py +++ b/tests/integration/test_relaunch.py @@ -21,10 +21,7 @@ import craft_providers import pytest -from craft_providers import bases from craft_providers.bases.almalinux import AlmaLinuxBaseAlias -from craft_providers.bases.centos import CentOSBaseAlias -from craft_providers.bases.ubuntu import BuilddBaseAlias from craft_providers.multipass.multipass_provider import MultipassProvider if TYPE_CHECKING: @@ -33,37 +30,36 @@ @pytest.mark.slow @pytest.mark.parametrize( - "base_alias", + ("distribution", "series"), [ - *bases.almalinux.AlmaLinuxBaseAlias, - bases.BuilddBaseAlias.NOBLE, + ("almalinux", "9"), + ("ubuntu", "24.04"), # https://github.com/canonical/craft-providers/issues/765 # We should enable all of these for weekly tests. - # *bases.ubuntu.BuilddBaseAlias, + # Uncomment: *(("ubuntu", alias.value) for alias in BuilddBaseAlias) ], ) def test_relaunch( session_provider: craft_providers.Provider, - base_alias: BuilddBaseAlias | CentOSBaseAlias | AlmaLinuxBaseAlias, + distribution: str, + series: str, tmp_path: pathlib.Path, ): - if ( - isinstance(session_provider, MultipassProvider) - and base_alias not in bases.ubuntu.BuilddBaseAlias - ): + if isinstance(session_provider, MultipassProvider) and distribution != "ubuntu": pytest.skip("Non-Ubuntu bases not supported with Multipass.") - if base_alias == bases.ubuntu.BuilddBaseAlias.XENIAL: - pytest.skip( - "Xenial not supported: https://github.com/canonical/craft-providers/issues/582" - ) - if base_alias == bases.ubuntu.BuilddBaseAlias.ORACULAR: - pytest.skip( - "Oracular is unsupported: https://github.com/canonical/craft-providers/issues/598" - ) + match (distribution, series): + case ("ubuntu", "16.04"): + pytest.skip( + "Xenial not supported: https://github.com/canonical/craft-providers/issues/582" + ) + case ("ubuntu", "24.10"): + pytest.skip( + "Oracular is unsupported: https://github.com/canonical/craft-providers/issues/598" + ) - base_cls = bases.get_base_from_alias(base_alias) - base = base_cls(alias=base_alias) # type: ignore[reportArgumentType, arg-type] - project_name = f"relaunch-{base_alias.name}" + base = craft_providers.get_base(distribution=distribution, series=series) + + project_name = f"relaunch-{base.alias.name}" try: # Set up both a file that should exist for the whole run and one that should @@ -81,7 +77,7 @@ def test_relaunch( # Alma Linux only clears tmp files after 10 days by default. # This configures systemd-tmpfiles to clear them on every boot. - if isinstance(base_alias, AlmaLinuxBaseAlias): + if isinstance(base.alias, AlmaLinuxBaseAlias): content = io.BytesIO(b"r! /tmp/* 1777 root root 0") instance.push_file_io( destination=pathlib.PurePosixPath( diff --git a/tests/unit/bases/test_factory.py b/tests/unit/bases/test_factory.py new file mode 100644 index 000000000..35e2c21a9 --- /dev/null +++ b/tests/unit/bases/test_factory.py @@ -0,0 +1,47 @@ +# Copyright 2025 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +"""Tests for the base factory.""" + +from typing import Literal + +import pytest +from craft_providers import get_base +from craft_providers.bases.almalinux import AlmaLinuxBase, AlmaLinuxBaseAlias +from craft_providers.bases.centos import CentOSBase, CentOSBaseAlias +from craft_providers.bases.ubuntu import BuilddBase, BuilddBaseAlias + + +@pytest.mark.parametrize( + ("distribution", "series", "base_cls"), + [ + *(("ubuntu", alias.value, BuilddBase) for alias in BuilddBaseAlias), + *(("centos", alias.value, CentOSBase) for alias in CentOSBaseAlias), + *(("almalinux", alias.value, AlmaLinuxBase) for alias in AlmaLinuxBaseAlias), + ], +) +def test_get_base_correct( + distribution: Literal["ubuntu", "centos", "almalinux"], series: str, base_cls: type +): + assert isinstance(get_base(distribution=distribution, series=series), base_cls) + + +def test_unknown_distribution(): + with pytest.raises(ValueError, match="Unknown distribution 'invalid'"): + get_base(distribution="invalid", series="4") + + +def test_bad_series(): + with pytest.raises(ValueError, match="Unknown Ubuntu series: 4.04"): + get_base(distribution="ubuntu", series="4.04") diff --git a/tests/unit/bases/test_get_base.py b/tests/unit/bases/test_get_base.py index 88d976c54..ee3f9fa9f 100644 --- a/tests/unit/bases/test_get_base.py +++ b/tests/unit/bases/test_get_base.py @@ -19,6 +19,11 @@ from craft_providers.bases import get_base_alias, get_base_from_alias, ubuntu from craft_providers.errors import BaseConfigurationError +pytestmark = [ + pytest.mark.filterwarnings("ignore:get_base_alias is deprecated"), + pytest.mark.filterwarnings("ignore:get_base_from_alias is deprecated"), +] + def test_get_base_alias(): assert get_base_alias(("ubuntu", "22.04")) == ubuntu.BuilddBaseAlias.JAMMY