Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

repo: install package repositories in "arbitrary" paths #80

Merged
merged 2 commits into from
May 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion craft_archives/repo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@

"""Package repository helpers."""

from .installer import install
from .installer import install, install_in_root
from .projects import validate_repository

__all__ = [
"install",
"install_in_root",
"validate_repository",
]
13 changes: 13 additions & 0 deletions craft_archives/repo/apt_key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ def find_asset_with_key_id(self, *, key_id: str) -> Optional[pathlib.Path]:

return None

@classmethod
def keyrings_path_for_root(
cls, root: Optional[pathlib.Path] = None
) -> pathlib.Path:
"""Get the location for Apt keyrings with ``root`` as the system root.

:param root: The optional system root to consider, or None to assume the standard
system root "/".
"""
if root is None:
return KEYRINGS_PATH
return root / "etc/apt/keyrings"

@classmethod
def get_key_fingerprints(cls, *, key: str) -> List[str]:
"""List fingerprints found in specified key.
Expand Down
11 changes: 11 additions & 0 deletions craft_archives/repo/apt_preferences_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ def __init__(
self._path = path or _DEFAULT_PREFERENCES_FILE
self._preferences: typing.List[Preference] = []

@classmethod
def preferences_path_for_root(cls, root: typing.Optional[Path] = None) -> Path:
"""Get the location for the Apt preferences file with ``root`` as the system root.

:param root: The optional system root to consider, or None to assume the standard
system root "/".
"""
if root is None:
return _DEFAULT_PREFERENCES_FILE
return root / "etc/apt/preferences.d/craft-archives"

def read(self) -> None:
"""Read the preferences file and populate Preferences objects."""
if not self._path.exists():
Expand Down
34 changes: 31 additions & 3 deletions craft_archives/repo/apt_sources_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
logger = logging.getLogger(__name__)

_DEFAULT_SOURCES_DIRECTORY = Path("/etc/apt/sources.list.d")
_DEFAULT_SIGNED_BY_ROOT = Path("/")


def _construct_deb822_source( # noqa: PLR0913
Expand Down Expand Up @@ -85,9 +86,31 @@ def __init__(
*,
sources_list_d: Optional[Path] = None,
keyrings_dir: Optional[Path] = None,
signed_by_root: Optional[Path] = None,
) -> None:
"""Create a manager for Apt repository sources listings.

:param sources_list_d: The path to the directory containing the sources listings.
:param keyrings_dir: The path to the directory containing the (already installed)
keyrings.
:param signed_by_root: The path that should be considered as "system root" when
filling the "Signed-By" field in the sources listings. Used to configure an
Apt-based system that will eventually be chrooted into.
"""
self._sources_list_d = sources_list_d or _DEFAULT_SOURCES_DIRECTORY
self._keyrings_dir = keyrings_dir or apt_key_manager.KEYRINGS_PATH
self._signed_by_root = signed_by_root or _DEFAULT_SIGNED_BY_ROOT

@classmethod
def sources_path_for_root(cls, root: Optional[Path] = None) -> Path:
"""Get the location for Apt source listings with ``root`` as the system root.

:param root: The optional system root to consider, or None to assume the standard
system root "/".
"""
if root is None:
return _DEFAULT_SOURCES_DIRECTORY
return root / "etc/apt/sources.list.d"

def _install_sources( # noqa: PLR0913
self,
Expand All @@ -110,6 +133,8 @@ def _install_sources( # noqa: PLR0913
if keyring_path and not keyring_path.is_file():
raise errors.AptGPGKeyringError(keyring_path)

keyring_path = Path("/") / keyring_path.relative_to(self._signed_by_root)

config = _construct_deb822_source(
architectures=architectures,
components=components,
Expand Down Expand Up @@ -266,14 +291,17 @@ def install_package_repository_sources(
changed = self._install_sources_apt(package_repo=package_repo)
architectures = package_repo.architectures
if changed and architectures:
_add_architecture(architectures)
_add_architecture(architectures, root=self._signed_by_root)
return changed

raise RuntimeError(f"unhandled package repository: {package_repository!r}")


def _add_architecture(architectures: List[str]) -> None:
def _add_architecture(architectures: List[str], root: Path) -> None:
"""Add package repository architecture."""
for arch in architectures:
logger.info(f"Add repository architecture: {arch}")
subprocess.run(["dpkg", "--add-architecture", arch], check=True)
subprocess.run(
["dpkg", "--add-architecture", arch, "--root", str(root)],
check=True,
)
47 changes: 43 additions & 4 deletions craft_archives/repo/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""Package repository installer."""

import pathlib
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

from . import errors
from .apt_key_manager import AptKeyManager
Expand All @@ -41,9 +41,48 @@ def install(

:return: Whether a package list refresh is required.
"""
key_manager = AptKeyManager(key_assets=key_assets)
sources_manager = AptSourcesManager()
preferences_manager = AptPreferencesManager()
return _install_repos(
project_repositories=project_repositories, key_assets=key_assets
)


def install_in_root(
project_repositories: List[Dict[str, Any]],
root: pathlib.Path,
*,
key_assets: pathlib.Path,
) -> bool:
"""Add package repositories to the system located at ``root``.

:param project_repositories: A list of package repositories to install.
:param key_assets: The directory containing repository keys.
:param root: The directory containing the Apt-based system installation.

:return: Whether a package list refresh is required.
"""
return _install_repos(
project_repositories=project_repositories, root=root, key_assets=key_assets
)


def _install_repos(
*,
project_repositories: List[Dict[str, Any]],
root: Optional[pathlib.Path] = None,
key_assets: pathlib.Path,
) -> bool:
keyrings_path = AptKeyManager.keyrings_path_for_root(root)
key_manager = AptKeyManager(keyrings_path=keyrings_path, key_assets=key_assets)

sources_list_d = AptSourcesManager.sources_path_for_root(root)
sources_manager = AptSourcesManager(
sources_list_d=sources_list_d,
keyrings_dir=keyrings_path,
signed_by_root=root,
)

preferences_path = AptPreferencesManager.preferences_path_for_root(root)
preferences_manager = AptPreferencesManager(path=preferences_path)

package_repositories = _unmarshal_repositories(project_repositories)

Expand Down
46 changes: 35 additions & 11 deletions tests/integration/repo/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,25 +87,35 @@
).lstrip()


def create_etc_apt_dirs(etc_apt: Path):
etc_apt.mkdir(parents=True)

keyrings_dir = etc_apt / "keyrings"
keyrings_dir.mkdir()

sources_dir = etc_apt / "sources.list.d"
sources_dir.mkdir()

preferences_dir = etc_apt / "preferences.d"
preferences_dir.mkdir()


@pytest.fixture
def fake_etc_apt(tmp_path, mocker) -> Path:
"""Mock the default paths used to store keys, sources and preferences."""
etc_apt = tmp_path / "etc/apt"
etc_apt.mkdir(parents=True)
create_etc_apt_dirs(etc_apt)

keyrings_dir = etc_apt / "keyrings"
keyrings_dir.mkdir()
mocker.patch("craft_archives.repo.apt_key_manager.KEYRINGS_PATH", new=keyrings_dir)

sources_dir = etc_apt / "sources.list.d"
sources_dir.mkdir()
mocker.patch(
"craft_archives.repo.apt_sources_manager._DEFAULT_SOURCES_DIRECTORY",
new=sources_dir,
)

preferences_dir = etc_apt / "preferences.d"
preferences_dir.mkdir()
preferences_dir = preferences_dir / "craft-archives"
mocker.patch(
"craft_archives.repo.apt_preferences_manager._DEFAULT_PREFERENCES_FILE",
Expand Down Expand Up @@ -160,10 +170,24 @@ def test_install(fake_etc_apt, all_repo_types, test_keys_dir):
assert repo.install(project_repositories=all_repo_types, key_assets=test_keys_dir)

check_keyrings(fake_etc_apt)
check_sources(fake_etc_apt)
check_sources(fake_etc_apt, signed_by_location=fake_etc_apt / "keyrings")
check_preferences(fake_etc_apt)


def test_install_in_root(tmp_path, all_repo_types, test_keys_dir):
"""Integrated test that checks the configuration of keys, sources and pins."""
etc_apt = tmp_path / "etc/apt"
create_etc_apt_dirs(etc_apt)

assert repo.install_in_root(
project_repositories=all_repo_types, key_assets=test_keys_dir, root=tmp_path
)

check_keyrings(etc_apt)
check_sources(etc_apt, signed_by_location=Path("/etc/apt/keyrings"))
check_preferences(etc_apt)


def check_keyrings(etc_apt_dir: Path) -> None:
keyrings_dir = etc_apt_dir / "keyrings"

Expand All @@ -176,30 +200,30 @@ def check_keyrings(etc_apt_dir: Path) -> None:
assert keyring_file.is_file()


def check_sources(etc_apt_dir: Path) -> None:
def check_sources(etc_apt_dir: Path, signed_by_location: Path) -> None:
sources_dir = etc_apt_dir / "sources.list.d"

keyrings_location = etc_apt_dir / "keyrings"
keyrings_on_fs = etc_apt_dir / "keyrings"

cloud_name = CLOUD_DATA["cloud"]
codename = CLOUD_DATA["codename"]

# Must have exactly these sources files, one for each repo
source_to_contents = {
"http_ppa_launchpad_net_snappy_dev_snapcraft_daily_ubuntu": APT_SOURCES.format(
key_location=keyrings_location
key_location=signed_by_location
),
"ppa-deadsnakes_ppa": PPA_SOURCES.format(
codename=VERSION_CODENAME, key_location=keyrings_location
codename=VERSION_CODENAME, key_location=signed_by_location
),
f"cloud-{cloud_name}": CLOUD_SOURCES.format(
cloud=cloud_name,
codename=codename,
key_location=keyrings_location,
key_location=signed_by_location,
),
}

assert len(list(keyrings_location.iterdir())) == len(source_to_contents)
assert len(list(keyrings_on_fs.iterdir())) == len(source_to_contents)

for source_repo, expected_contents in source_to_contents.items():
source_file = sources_dir / f"craft-{source_repo}.sources"
Expand Down
10 changes: 9 additions & 1 deletion tests/unit/repo/test_apt_key_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import subprocess
from pathlib import Path
from unittest import mock
from unittest.mock import call

import pytest
from craft_archives.repo import apt_ppa, errors, package_repository
from craft_archives.repo.apt_key_manager import AptKeyManager
from craft_archives.repo.apt_key_manager import KEYRINGS_PATH, AptKeyManager
from craft_archives.repo.package_repository import (
PackageRepositoryApt,
PackageRepositoryAptPPA,
Expand Down Expand Up @@ -468,3 +469,10 @@ def test_install_package_repository_key_uca_from_keyserver(apt_gpg, mocker):
assert mock_install_key_from_keyserver.mock_calls == [
call(key_id="FAKE-UCA-KEY-ID", key_server="keyserver.ubuntu.com")
]


def test_keyrings_path_for_root():
assert AptKeyManager.keyrings_path_for_root() == KEYRINGS_PATH
assert AptKeyManager.keyrings_path_for_root(Path("/my/root")) == Path(
"/my/root/etc/apt/keyrings"
)
11 changes: 11 additions & 0 deletions tests/unit/repo/test_apt_preferences_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
#
"""Tests for apt_preferencs_manager"""
import shutil
from pathlib import Path
from textwrap import dedent

import pytest
from craft_archives.repo.apt_preferences_manager import (
_DEFAULT_PREFERENCES_FILE,
AptPreferencesManager,
Preference,
)
Expand Down Expand Up @@ -234,4 +236,13 @@ def test_preferences_added(test_data_dir, tmp_path, preferences, expected_file):
assert actual_path.read_text() == expected_path.read_text()


def test_preferences_path_for_root():
assert (
AptPreferencesManager.preferences_path_for_root() == _DEFAULT_PREFERENCES_FILE
)
assert AptPreferencesManager.preferences_path_for_root(Path("/my/root")) == Path(
"/my/root/etc/apt/preferences.d/craft-archives"
)


# endregion
Loading