Skip to content

Commit

Permalink
apt: enable uca repositories
Browse files Browse the repository at this point in the history
This commit adds the possibility to enable repositories from the Ubuntu
Cloud Archive. The Ubuntu Cloud Archive is a repository that enables
users to install backported and patched versions of Openstack on Ubuntu
LTS releases.
  • Loading branch information
gboutry committed Mar 31, 2023
1 parent f69dfee commit 295dc07
Show file tree
Hide file tree
Showing 15 changed files with 678 additions and 14 deletions.
7 changes: 6 additions & 1 deletion craft_archives/repo/apt_key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
39 changes: 37 additions & 2 deletions craft_archives/repo/apt_sources_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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
Expand Down
52 changes: 52 additions & 0 deletions craft_archives/repo/apt_uca.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""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}")
10 changes: 10 additions & 0 deletions craft_archives/repo/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
12 changes: 11 additions & 1 deletion craft_archives/repo/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
PackageRepository,
PackageRepositoryApt,
PackageRepositoryAptPPA,
PackageRepositoryAptUCA,
)


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
169 changes: 169 additions & 0 deletions craft_archives/repo/package_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""

Expand Down
Loading

0 comments on commit 295dc07

Please sign in to comment.