diff --git a/craft_archives/repo/apt_key_manager.py b/craft_archives/repo/apt_key_manager.py index faabca8a..13219a8c 100644 --- a/craft_archives/repo/apt_key_manager.py +++ b/craft_archives/repo/apt_key_manager.py @@ -25,7 +25,7 @@ from contextlib import contextmanager from typing import Iterable, Iterator, List, Optional -from . import apt_ppa, errors, package_repository +from . import apt_ppa, apt_uca, errors, package_repository logger = logging.getLogger(__name__) @@ -231,6 +231,9 @@ def install_package_repository_key( 3) Install key from key server, if available. An unspecified keyserver will default to using keyserver.ubuntu.com. + For Cloud Archive repositories, the keyring is installed + from a package + :param package_repo: Apt PackageRepository configuration. :returns: True if key configuration was changed. False if @@ -241,6 +244,8 @@ def install_package_repository_key( key_server = DEFAULT_APT_KEYSERVER if isinstance(package_repo, package_repository.PackageRepositoryAptPPA): key_id = apt_ppa.get_launchpad_ppa_key_id(ppa=package_repo.ppa) + elif isinstance(package_repo, package_repository.PackageRepositoryAptUCA): + return apt_uca.install_uca_keyring() elif isinstance(package_repo, package_repository.PackageRepositoryApt): key_id = package_repo.key_id if package_repo.key_server: diff --git a/craft_archives/repo/apt_sources_manager.py b/craft_archives/repo/apt_sources_manager.py index bf82822c..e1bde794 100644 --- a/craft_archives/repo/apt_sources_manager.py +++ b/craft_archives/repo/apt_sources_manager.py @@ -26,7 +26,7 @@ from craft_archives import os_release, utils -from . import apt_key_manager, apt_ppa, errors, package_repository +from . import apt_key_manager, apt_ppa, apt_uca, errors, package_repository logger = logging.getLogger(__name__) @@ -98,6 +98,7 @@ def _install_sources( suites: List[str], url: str, keyring_path: pathlib.Path, + ignore_keyring_path: bool = False, ) -> bool: """Install sources list configuration. @@ -106,7 +107,7 @@ def _install_sources( :returns: True if configuration was changed. """ - if keyring_path and not keyring_path.is_file(): + if not ignore_keyring_path and keyring_path and not keyring_path.is_file(): raise errors.AptGPGKeyringError(keyring_path) config = _construct_deb822_source( @@ -216,6 +217,37 @@ def _install_sources_ppa( keyring_path=keyring_path, ) + def _install_sources_uca( + self, *, package_repo: package_repository.PackageRepositoryAptUCA + ) -> bool: + """Install UCA formatted repository. + + Create a sources list config by: + - Looking up the codename of the host OS and using it as the "suites" + entry. + - Formulate deb URL to point to UCA. + - Enable only "deb" formats. + + :returns: True if source configuration was changed. + """ + cloud = package_repo.cloud + pocket = package_repo.pocket + + codename = os_release.OsRelease().version_codename() + apt_uca.check_release_compatibility(cloud, codename) + + keyring_path = apt_uca.get_uca_keyring_path() + + return self._install_sources( + components=["main"], + formats=["deb"], + name=f"cloud-{cloud}", + suites=[f"{codename}-{pocket}/{cloud}"], + url=package_repository.UCA_ARCHIVE, + keyring_path=keyring_path, + ignore_keyring_path=True, + ) + def install_package_repository_sources( self, *, @@ -231,6 +263,9 @@ def install_package_repository_sources( if isinstance(package_repo, package_repository.PackageRepositoryAptPPA): return self._install_sources_ppa(package_repo=package_repo) + if isinstance(package_repo, package_repository.PackageRepositoryAptUCA): + return self._install_sources_uca(package_repo=package_repo) + if isinstance(package_repo, package_repository.PackageRepositoryApt): changed = self._install_sources_apt(package_repo=package_repo) architectures = package_repo.architectures diff --git a/craft_archives/repo/apt_uca.py b/craft_archives/repo/apt_uca.py new file mode 100644 index 00000000..35e5271b --- /dev/null +++ b/craft_archives/repo/apt_uca.py @@ -0,0 +1,52 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2020-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 . + +"""Ubuntu Cloud Archive helpers.""" + + +import pathlib +import subprocess + +from . import errors +from .package_repository import RELEASE_MAP, UCA_KEYRING_PACKAGE, UCA_KEYRING_PATH + + +def install_uca_keyring() -> bool: + """Install UCA keyring if missing.""" + try: + subprocess.run( + ["dpkg", "--status", UCA_KEYRING_PACKAGE], + check=True, + capture_output=True, + ) + return False + except subprocess.CalledProcessError as e: + if b"not installed" not in e.stderr: + raise e + subprocess.run(["apt", "install", "--yes", UCA_KEYRING_PACKAGE], check=True) + return True + + +def get_uca_keyring_path() -> pathlib.Path: + """Return UCA keyring path.""" + return pathlib.Path(UCA_KEYRING_PATH) + + +def check_release_compatibility(cloud: str, codename: str) -> None: + """Raise an exception if the release is incompatible with codename.""" + series = RELEASE_MAP.get(cloud) + if not series or series != codename: + raise errors.AptUCAInstallError(cloud, f"not a valid release for {codename!r}") diff --git a/craft_archives/repo/errors.py b/craft_archives/repo/errors.py index 5f06f6d0..d5742376 100644 --- a/craft_archives/repo/errors.py +++ b/craft_archives/repo/errors.py @@ -69,6 +69,16 @@ def __init__(self, ppa: str, reason: str) -> None: ) +class AptUCAInstallError(PackageRepositoryError): + """Installation of an UCA repository failed.""" + + def __init__(self, cloud: str, reason: str) -> None: + super().__init__( + f"Failed to install UCA {cloud!r}: {reason}", + resolution="Verify UCA is correct and try again", + ) + + class AptGPGKeyringError(PackageRepositoryError): """GPG keyring for repository does not exist or not valid.""" diff --git a/craft_archives/repo/installer.py b/craft_archives/repo/installer.py index 12d5e908..639d767e 100644 --- a/craft_archives/repo/installer.py +++ b/craft_archives/repo/installer.py @@ -27,6 +27,7 @@ PackageRepository, PackageRepositoryApt, PackageRepositoryAptPPA, + PackageRepositoryAptUCA, ) @@ -55,7 +56,14 @@ def install( package_repo=package_repo ) if ( - isinstance(package_repo, (PackageRepositoryApt, PackageRepositoryAptPPA)) + isinstance( + package_repo, + ( + PackageRepositoryApt, + PackageRepositoryAptPPA, + PackageRepositoryAptUCA, + ), + ) and package_repo.priority is not None ): refresh_required |= preferences_manager.add( @@ -96,6 +104,8 @@ def _unmarshal_repositories( if "ppa" in data: pkg_repo = PackageRepositoryAptPPA.unmarshal(data) + elif "cloud" in data: + pkg_repo = PackageRepositoryAptUCA.unmarshal(data) else: pkg_repo = PackageRepositoryApt.unmarshal(data) diff --git a/craft_archives/repo/package_repository.py b/craft_archives/repo/package_repository.py index 41d23ae0..03cfbdf6 100644 --- a/craft_archives/repo/package_repository.py +++ b/craft_archives/repo/package_repository.py @@ -27,6 +27,27 @@ from . import errors +UCA_ARCHIVE = "http://ubuntu-cloud.archive.canonical.com/ubuntu" +UCA_NETLOC = urlparse(UCA_ARCHIVE).netloc +UCA_VALID_POCKETS = ["updates", "proposed"] +UCA_DEFAULT_POCKET = UCA_VALID_POCKETS[0] +UCA_KEYRING_PATH = "/usr/share/keyrings/ubuntu-cloud-keyring.gpg" + +UCA_KEYRING_PACKAGE = "ubuntu-cloud-keyring" +RELEASE_MAP = { + "rocky": "bionic", + "stein": "bionic", + "train": "bionic", + "ussuri": "bionic", + "victoria": "focal", + "wallaby": "focal", + "xena": "focal", + "yoga": "focal", + "zed": "jammy", + "antelope": "jammy", + "bobcat": "jammy", +} + class PriorityString(enum.IntEnum): """Convenience values that represent common deb priorities.""" @@ -215,6 +236,154 @@ def pin(self) -> str: return f"release o=LP-PPA-{ppa_origin}" +class PackageRepositoryAptUCA(PackageRepository): + """A cloud package repository.""" + + def __init__( + self, + *, + cloud: str, + pocket: str = UCA_DEFAULT_POCKET, + priority: Optional[int] = None, + ) -> None: + self.type = "apt" + self.cloud = cloud + self.pocket = pocket + self.priority = priority + + self.validate() + + @overrides + def marshal(self) -> Dict[str, Union[str, int]]: + """Return the package repository data as a dictionary.""" + data: Dict[str, Union[str, int]] = { + "type": "apt", + "cloud": self.cloud, + "pocket": self.pocket, + } + if self.priority is not None: + data["priority"] = self.priority + return data + + def validate(self) -> None: + """Ensure the current repository data is valid.""" + if not self.cloud: + raise errors.PackageRepositoryValidationError( + url=self.cloud, + brief="invalid cloud.", + details="clouds must be non-empty strings.", + resolution=( + "Verify repository configuration and ensure that " + "'cloud' is correctly specified." + ), + ) + if self.priority == 0: + raise errors.PackageRepositoryValidationError( + url=self.cloud, + brief=f"invalid priority {self.priority}.", + details=("Priority cannot be zero."), + resolution="Verify priority value.", + ) + + @classmethod + @overrides + def unmarshal(cls, data: Mapping[str, str]) -> "PackageRepositoryAptUCA": + """Create a package repository object from the given data.""" + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief="invalid object.", + details="Package repository must be a valid dictionary object.", + resolution=( + "Verify repository configuration and ensure that the correct " + "syntax is used." + ), + ) + + data_copy = deepcopy(data) + + cloud = data_copy.pop("cloud", "") + pocket = data_copy.pop("pocket", UCA_DEFAULT_POCKET) + repo_type = data_copy.pop("type", None) + priority = data_copy.pop("priority", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=cloud, + brief=f"unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution=( + "Verify repository configuration and ensure that 'type' " + "is correctly specified." + ), + ) + + if not isinstance(cloud, str) or cloud not in RELEASE_MAP: + raise errors.PackageRepositoryValidationError( + url=cloud, + brief="Invalid cloud {cloud!r}", + details="cloud is not a valid cloud archive.", + resolution=( + "Verify repository configuration and ensure that 'cloud' " + "is a valid cloud archive." + ), + ) + + if pocket not in UCA_VALID_POCKETS: + raise errors.PackageRepositoryValidationError( + url=cloud, + brief=f"Invalid pocket {pocket!r}.", + details=f"pocket must be a valid string and comprised in {UCA_VALID_POCKETS!r}", + resolution=( + "Verify repository configuration and ensure that 'pocket' " + "is correctly specified." + ), + ) + + if isinstance(priority, str): + priority = priority.upper() + if priority in PriorityString.__members__: + priority = PriorityString[priority] + else: + raise errors.PackageRepositoryValidationError( + url=str(cloud), + brief=f"invalid priority {priority!r}.", + details=( + "Priority must be 'always', 'prefer', 'defer' or a nonzero integer." + ), + resolution="Verify priority value.", + ) + elif priority is not None: + try: + priority = int(priority) + except TypeError: + raise errors.PackageRepositoryValidationError( + url=cloud, + brief=f"invalid priority {priority!r}.", + details=( + "Priority must be 'always', 'prefer', 'defer' or a nonzero integer." + ), + resolution="Verify priority value.", + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=cloud, + brief=f"unsupported properties {keys}.", + resolution=( + "Verify repository configuration and ensure that it is correct." + ), + ) + + return cls(cloud=cloud, pocket=pocket, priority=priority) + + @property + def pin(self) -> str: + """The pin string for this repository if needed.""" + return f'origin "{UCA_NETLOC}"' + + class PackageRepositoryApt(PackageRepository): """An APT package repository.""" diff --git a/craft_archives/repo/projects.py b/craft_archives/repo/projects.py index 0785fa41..eceb79ca 100644 --- a/craft_archives/repo/projects.py +++ b/craft_archives/repo/projects.py @@ -62,6 +62,7 @@ class Apt(abc.ABC, ProjectModel): # URL and PPA must be defined before priority so the validator can use their values url: Optional[str] ppa: Optional[str] + cloud: Optional[str] priority: Optional[PriorityValue] @classmethod @@ -69,6 +70,8 @@ def unmarshal(cls, data: Dict[str, Any]) -> "Apt": """Create an Apt subclass object from a dictionary.""" if "ppa" in data: return AptPPA.unmarshal(data) + if "cloud" in data: + return AptUCA.unmarshal(data) return AptDeb.unmarshal(data) @validator("priority") @@ -78,7 +81,7 @@ def priority_cannot_be_zero( """Priority cannot be zero per apt Preferences specification.""" if priority == 0: raise errors.PackageRepositoryValidationError( - url=str(values.get("url") or values.get("ppa")), + url=str(values.get("url") or values.get("ppa") or values.get("cloud")), brief=f"invalid priority {priority}.", details="Priority cannot be zero.", resolution="Verify priority value.", @@ -115,6 +118,18 @@ def unmarshal(cls, data: Dict[str, Any]) -> "AptPPA": return cls(**data) +class AptUCA(Apt): + """Ubuntu Cloud Archive repository definition.""" + + cloud: str + pocket: Optional[str] + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "AptUCA": + """Create an AptUCA object from dictionary data.""" + return cls(**data) + + def validate_repository(data: Dict[str, Any]) -> None: """Validate a package repository. @@ -122,9 +137,11 @@ def validate_repository(data: Dict[str, Any]) -> None: """ if not isinstance(data, dict): # pyright: reportUnnecessaryIsInstance=false raise TypeError("value must be a dictionary") - try: - AptPPA(**data) + if "ppa" in data: + AptPPA(**data) + elif "cloud" in data: + AptUCA(**data) return except pydantic.ValidationError: pass diff --git a/docs/howto/add_repo.rst b/docs/howto/add_repo.rst index 2525e649..3625a5bc 100644 --- a/docs/howto/add_repo.rst +++ b/docs/howto/add_repo.rst @@ -3,12 +3,13 @@ Add a Package Repository It's possible to add your own apt repositories as sources for build-packages and stage-packages, including those hosted on a PPA, the Personal Package Archive, -which serves personally hosted non-standard packages. +which serves personally hosted non-standard packages, and those hosted on the +UCA, the Ubuntu Cloud Archive. Third-party repositories can be added to the project file of a Craft Application (like Snapcraft, Rockcraft, or Charmcraft) by using the top-level -``package-repositories`` keyword with either a PPA-type repository, or a -deb-type repository: +``package-repositories`` keyword with either a PPA-type repository, an UCA-type +repository or a deb-type repository: PPA-type repository: @@ -18,6 +19,15 @@ PPA-type repository: - type: apt ppa: snappy-dev/snapcraft-daily +UCA-type repository: + +.. code-block:: yaml + + package-repositories: + - type: apt + cloud: antelope + pocket: updates + deb-type repository: .. code-block:: yaml @@ -29,10 +39,11 @@ deb-type repository: key-id: 78E1918602959B9C59103100F1831DDAFC42E99D url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu -As shown above, PPA-type repositories and traditional deb-type each require a -different set of properties. +As shown above, PPA-type repositories, UCA-type repositories and traditional +deb-type each require a different set of properties. * :ref:`PPA-type properties ` +* :ref:`UCA-type properties ` * :ref:`deb-type properties ` Once configured, packages provided by these repositories will become available diff --git a/docs/reference/repo_properties.rst b/docs/reference/repo_properties.rst index 0f757f5a..3aae648f 100644 --- a/docs/reference/repo_properties.rst +++ b/docs/reference/repo_properties.rst @@ -22,6 +22,30 @@ The following properties are supported for PPA-type repositories: - ``ppa: snappy-devs/snapcraft-daily`` - ``ppa: mozillateam/firefox-next`` +.. _uca-properties: + +The following properties are supported for UCA-type repositories: + +- type + - Type: enum[string] + - Description: Specifies type of package-repository, must currently be + ``apt`` + - Examples: ``type: apt`` +- cloud + - Type: string + - Description: UCA release name + - Format: + - Examples: + - ``cloud: antelope`` + - ``cloud: zed`` +- pocket + - Type: enum[string] + - Description: Pocket to get packages from, must be either ``updates`` + or ``proposed`` + - Default: If unspecified, pocket is assumed to be ``updates`` + - Examples: + - ``pocket: updates`` + - ``pocket: proposed`` .. _deb-properties: @@ -116,6 +140,23 @@ PPA repository using "ppa" property - type: apt ppa: snappy-dev/snapcraft-daily +UCA repository using "cloud" property +------------------------------------- + +.. code-block:: yaml + package-repositories: + - type: apt + cloud: antelope + +UCA repository using "pocket" property +-------------------------------------- + +.. code-block:: yaml + package-repositories: + - type: apt + cloud: antelope + pocket: updates + Typical apt repository with components and suites ------------------------------------------------- diff --git a/tests/unit/repo/test_apt_key_manager.py b/tests/unit/repo/test_apt_key_manager.py index 0fcde5d8..dbc8eff0 100644 --- a/tests/unit/repo/test_apt_key_manager.py +++ b/tests/unit/repo/test_apt_key_manager.py @@ -25,6 +25,7 @@ from craft_archives.repo.package_repository import ( PackageRepositoryApt, PackageRepositoryAptPPA, + PackageRepositoryAptUCA, ) with open(pathlib.Path(__file__).parent / "test_data/FC42E99D.asc") as _f: @@ -430,3 +431,27 @@ def test_install_package_repository_key_ppa_from_keyserver(apt_gpg, mocker): assert mock_install_key_from_keyserver.mock_calls == [ call(key_id="FAKE-PPA-SIGNING-KEY", key_server="keyserver.ubuntu.com") ] + + +def test_install_package_repository_key_uca_not_installed(apt_gpg, mocker): + mock_install_uca_keyring = mocker.patch( + "craft_archives.repo.apt_uca.install_uca_keyring", return_value=True + ) + package_repo = PackageRepositoryAptUCA(cloud="antelope") + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_uca_keyring.call_count == 1 + + +def test_install_package_repository_key_uca_already_installed(apt_gpg, mocker): + mock_install_uca_keyring = mocker.patch( + "craft_archives.repo.apt_uca.install_uca_keyring", return_value=False + ) + package_repo = PackageRepositoryAptUCA(cloud="antelope") + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is False + assert mock_install_uca_keyring.call_count == 1 diff --git a/tests/unit/repo/test_apt_sources_manager.py b/tests/unit/repo/test_apt_sources_manager.py index 1d74b912..ccd0ed5a 100644 --- a/tests/unit/repo/test_apt_sources_manager.py +++ b/tests/unit/repo/test_apt_sources_manager.py @@ -23,6 +23,7 @@ from craft_archives.repo.package_repository import ( PackageRepositoryApt, PackageRepositoryAptPPA, + PackageRepositoryAptUCA, ) @@ -61,6 +62,14 @@ def mock_version_codename(mocker): ) +@pytest.fixture(autouse=True) +def mock_uca_release_map(mocker): + yield mocker.patch.dict( + "craft_archives.repo.package_repository.RELEASE_MAP", + {"fake-cloud": "FAKE-CODENAME"}, + ) + + @pytest.fixture def apt_sources_mgr(tmp_path): sources_list_d = tmp_path / "sources.list.d" @@ -167,6 +176,20 @@ def apt_sources_mgr(tmp_path): """ ), ), + ( + PackageRepositoryAptUCA(cloud="fake-cloud"), + "snapcraft-cloud-fake-cloud.sources", + dedent( + """\ + Types: deb + URIs: http://ubuntu-cloud.archive.canonical.com/ubuntu + Suites: FAKE-CODENAME-updates/fake-cloud + Components: main + Architectures: FAKE-HOST-ARCH + Signed-By: /usr/share/keyrings/ubuntu-cloud-keyring.gpg + """ + ), + ), ], ) def test_install(package_repo, name, content_template, apt_sources_mgr, mocker): @@ -218,6 +241,23 @@ def test_install_ppa_invalid(apt_sources_mgr): ) +class UnvalidatedUcaRepo(PackageRepositoryAptUCA): + """Repository with no validation to use for invalid repositories.""" + + def validate(self) -> None: + pass + + +def test_install_uca_invalid(apt_sources_mgr): + repo = UnvalidatedUcaRepo(cloud="jammy") + with pytest.raises(errors.AptUCAInstallError) as raised: + apt_sources_mgr.install_package_repository_sources(package_repo=repo) + + assert str(raised.value) == ( + "Failed to install UCA 'jammy': not a valid release for 'FAKE-CODENAME'" + ) + + class UnvalidatedAptRepo(PackageRepositoryApt): """Repository with no validation to use for invalid repositories.""" diff --git a/tests/unit/repo/test_apt_uca.py b/tests/unit/repo/test_apt_uca.py new file mode 100644 index 00000000..cdf52ca6 --- /dev/null +++ b/tests/unit/repo/test_apt_uca.py @@ -0,0 +1,76 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2020-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 . + +import subprocess +from unittest.mock import call, patch + +import pytest +from craft_archives import errors +from craft_archives.repo import apt_uca, package_repository + + +@patch("subprocess.run") +def test_install_uca_keyring_already_installed(subprocess): + assert apt_uca.install_uca_keyring() is False + assert subprocess.mock_calls == [ + call( + ["dpkg", "--status", package_repository.UCA_KEYRING_PACKAGE], + check=True, + capture_output=True, + ) + ] + + +@patch( + "subprocess.run", + side_effect=[ + subprocess.CalledProcessError( + 1, + cmd="dpkg --status ubuntu-cloud-keyring", + stderr=b"dpkg-query: package 'ubuntu-cloud-keyring' is not installed and no information is available", + ), + None, + ], +) +def test_install_uca_keyring_not_installed(subprocess): + assert apt_uca.install_uca_keyring() is True + assert subprocess.call_count == 2 + assert subprocess.called_with( + ["apt", "install", "--yes", package_repository.UCA_KEYRING_PACKAGE], check=True + ) + + +@patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError( + 1, + cmd="dpkg --status ubuntu-cloud-keyring", + stderr=b"unknown error", + ), +) +def test_install_uca_keyring_unknown_error(subprocess_patched): + with pytest.raises(subprocess.CalledProcessError): + apt_uca.install_uca_keyring() + assert subprocess_patched.call_count == 1 + + +def test_check_release_compatibility(): + assert apt_uca.check_release_compatibility("antelope", "jammy") is None + + +def test_check_release_compatibility_invalid(): + with pytest.raises(errors.ArchivesError): + apt_uca.check_release_compatibility("antelope", "focal") diff --git a/tests/unit/repo/test_installer.py b/tests/unit/repo/test_installer.py index 208ffae7..cdf16bfb 100644 --- a/tests/unit/repo/test_installer.py +++ b/tests/unit/repo/test_installer.py @@ -18,6 +18,7 @@ from craft_archives.repo.package_repository import ( PackageRepositoryApt, PackageRepositoryAptPPA, + PackageRepositoryAptUCA, ) @@ -43,10 +44,15 @@ def test_unmarshal_repositories(): "key-id": "ABCDE12345" * 4, "priority": -2, }, + { + "type": "apt", + "cloud": "antelope", + "pocket": "proposed", + }, ] pkg_repos = installer._unmarshal_repositories(data) - assert len(pkg_repos) == 4 + assert len(pkg_repos) == 5 assert isinstance(pkg_repos[0], PackageRepositoryAptPPA) assert pkg_repos[0].ppa == "test/somerepo" assert pkg_repos[0].priority is None @@ -58,3 +64,6 @@ def test_unmarshal_repositories(): assert isinstance(pkg_repos[3], PackageRepositoryApt) assert pkg_repos[2].priority == -1 assert pkg_repos[3].priority == -2 + assert isinstance(pkg_repos[4], PackageRepositoryAptUCA) + assert pkg_repos[4].cloud == "antelope" + assert pkg_repos[4].pocket == "proposed" diff --git a/tests/unit/repo/test_package_repository.py b/tests/unit/repo/test_package_repository.py index 5690e6e8..70fd460a 100644 --- a/tests/unit/repo/test_package_repository.py +++ b/tests/unit/repo/test_package_repository.py @@ -18,13 +18,21 @@ import pytest from craft_archives.repo import errors from craft_archives.repo.package_repository import ( + UCA_VALID_POCKETS, PackageRepository, PackageRepositoryApt, PackageRepositoryAptPPA, + PackageRepositoryAptUCA, ) # region Test data and fixtures BASIC_PPA_MARSHALLED = {"type": "apt", "ppa": "test/foo", "priority": 123} +BASIC_UCA_MARSHALLED = { + "type": "apt", + "cloud": "antelope", + "pocket": "updates", + "priority": 123, +} BASIC_APT_MARSHALLED = { "architectures": ["amd64", "i386"], "components": ["main", "multiverse"], @@ -534,4 +542,122 @@ def test_unmarshal_package_repositories_invalid_data(): ) +# endregion +# region PackageRepositoryAptCloud +def test_uca_marshal(): + repo = PackageRepositoryAptUCA(cloud="antelope", priority=123) + + assert repo.marshal() == { + "type": "apt", + "cloud": "antelope", + "pocket": "updates", + "priority": 123, + } + + +@pytest.mark.parametrize( + "cloud", + [ + "", + None, + ], +) +def test_uca_invalid_cloud(cloud): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptUCA(cloud=cloud) + + err = raised.value + assert str(err) == (f"Invalid package repository for {cloud!r}: invalid cloud.") + assert err.details == "clouds must be non-empty strings." + assert err.resolution == ( + "Verify repository configuration and ensure that 'cloud' is correctly specified." + ) + + +def test_uca_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptUCA.unmarshal(test_dict) # type: ignore + + err = raised.value + assert str(err) == "Invalid package repository for 'not-a-dict': invalid object." + assert err.details == "Package repository must be a valid dictionary object." + assert err.resolution == ( + "Verify repository configuration and ensure that the correct syntax is used." + ) + + +@pytest.mark.parametrize( + "cloud,error,details,resolution", + [ + pytest.param( + {"type": "aptx", "cloud": "antelope"}, + "Invalid package repository for 'antelope': unsupported type 'aptx'.", + "The only currently supported type is 'apt'.", + "Verify repository configuration and ensure that 'type' is correctly specified.", + id="invalid_type", + ), + pytest.param( + {"type": "apt", "cloud": "antelope", "test": "foo"}, + "Invalid package repository for 'antelope': unsupported properties 'test'.", + None, + "Verify repository configuration and ensure that it is correct.", + id="extra_keys", + ), + pytest.param( + {"type": "apt", "cloud": "antelope", "pocket": "security_updates"}, + "Invalid package repository for 'antelope': Invalid pocket 'security_updates'.", + f"pocket must be a valid string and comprised in {UCA_VALID_POCKETS!r}", + ( + "Verify repository configuration and ensure that 'pocket' " + "is correctly specified." + ), + id="invalid_type", + ), + ], +) +def test_uca_unmarshal_error(check, cloud, error, details, resolution): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptUCA.unmarshal(cloud) + + check.equal(str(raised.value), error) + check.equal(raised.value.details, details) + check.equal(raised.value.resolution, resolution) + + +@pytest.mark.parametrize( + "priority_str,priority_int", + [ + ("always", 1000), + ("prefer", 990), + ("defer", 100), + ], +) +def test_uca_priority_correctly_converted(priority_str, priority_int): + repo_marshalled = BASIC_UCA_MARSHALLED.copy() + repo_marshalled["priority"] = priority_str + repo = PackageRepositoryAptUCA.unmarshal(repo_marshalled) + + assert repo.priority == priority_int + + +@pytest.mark.parametrize( + "cloud,pin", + [ + ("antelope", 'origin "ubuntu-cloud.archive.canonical.com"'), + ("zed", 'origin "ubuntu-cloud.archive.canonical.com"'), + ], +) +def test_uca_pin_value(cloud, pin): + repo = PackageRepositoryAptUCA.unmarshal( + { + "type": "apt", + "cloud": cloud, + } + ) + + assert repo.pin == pin + + # endregion diff --git a/tests/unit/repo/test_projects.py b/tests/unit/repo/test_projects.py index 9c1762b8..261c9034 100644 --- a/tests/unit/repo/test_projects.py +++ b/tests/unit/repo/test_projects.py @@ -17,7 +17,7 @@ import pydantic import pytest from craft_archives.repo import errors -from craft_archives.repo.projects import Apt, AptDeb, AptPPA +from craft_archives.repo.projects import Apt, AptDeb, AptPPA, AptUCA @pytest.fixture @@ -25,6 +25,11 @@ def ppa_dict(): return {"type": "apt", "ppa": "test/somerepo"} +@pytest.fixture +def uca_dict(): + return {"type": "apt", "cloud": "antelope", "pocket": "updates"} + + class TestAptPPAValidation: """AptPPA field validation.""" @@ -57,6 +62,38 @@ def test_project_package_ppa_repository_bad_type(self): AptPPA.unmarshal(repo) +class TestAptUCAValidation: + """AptUCA field validation.""" + + @pytest.mark.parametrize( + "priority", ["always", "prefer", "defer", 1000, 990, 500, 100, -1, None] + ) + def test_apt_uca_valid(self, priority, uca_dict): + if priority is not None: + uca_dict["priority"] = priority + apt_uca = AptUCA.unmarshal(uca_dict) + assert apt_uca.type == "apt" + assert apt_uca.cloud == "antelope" + assert apt_uca.priority == priority + + def test_apt_uca_repository_invalid(self): + repo = { + "cloud": "antelope", + } + error = r"type\s+field required" + with pytest.raises(pydantic.ValidationError, match=error): + AptUCA.unmarshal(repo) + + def test_project_package_uca_repository_bad_type(self): + repo = { + "type": "invalid", + "cloud": "antelope", + } + error = "unexpected value; permitted: 'apt'" + with pytest.raises(pydantic.ValidationError, match=error): + AptUCA.unmarshal(repo) + + class TestAptDebValidation: """AptDeb field validation.""" @@ -160,6 +197,7 @@ def test_apt_deb_formats(self, formats): "formats": ["deb"], }, ), + (AptUCA, {"type": "apt", "cloud": "antelope"}), ], ) def test_apt_unmarshal_returns_correct_subclass(subclass, value):