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):