diff --git a/lib/charms/operator_libs_linux/v0/apt.py b/lib/charms/operator_libs_linux/v0/apt.py index b8913c0e..1c67d400 100644 --- a/lib/charms/operator_libs_linux/v0/apt.py +++ b/lib/charms/operator_libs_linux/v0/apt.py @@ -106,10 +106,9 @@ import os import re import subprocess -from collections.abc import Mapping from enum import Enum from subprocess import PIPE, CalledProcessError, check_output -from typing import Iterable, List, Optional, Tuple, Union +from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Tuple, Union from urllib.parse import urlparse logger = logging.getLogger(__name__) @@ -122,11 +121,12 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 14 +LIBPATCH = 15 VALID_SOURCE_TYPES = ("deb", "deb-src") OPTIONS_MATCHER = re.compile(r"\[.*?\]") +_GPG_KEY_DIR = "/etc/apt/trusted.gpg.d/" class Error(Exception): @@ -814,9 +814,9 @@ def remove_package( package_names: the name of a package Raises: - PackageNotFoundError if the package is not found. + TypeError: if no packages are provided """ - packages = [] + packages: List[DebianPackage] = [] package_names = [package_names] if isinstance(package_names, str) else package_names if not package_names: @@ -837,7 +837,17 @@ def remove_package( def update() -> None: """Update the apt cache via `apt-get update`.""" - subprocess.run(["apt-get", "update", "--error-on=any"], capture_output=True, check=True) + cmd = ["apt-get", "update", "--error-on=any"] + try: + subprocess.run(cmd, capture_output=True, check=True) + except CalledProcessError as e: + logger.error( + "%s:\nstdout:\n%s\nstderr:\n%s", + " ".join(cmd), + e.stdout.decode(), + e.stderr.decode(), + ) + raise def import_key(key: str) -> str: @@ -876,7 +886,7 @@ def import_key(key: str) -> str: key_bytes = key.encode("utf-8") key_name = DebianRepository._get_keyid_by_gpg_key(key_bytes) key_gpg = DebianRepository._dearmor_gpg_key(key_bytes) - gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key_name) + gpg_key_filename = os.path.join(_GPG_KEY_DIR, "{}.gpg".format(key_name)) DebianRepository._write_apt_gpg_keyfile( key_name=gpg_key_filename, key_material=key_gpg ) @@ -897,7 +907,7 @@ def import_key(key: str) -> str: key_asc = DebianRepository._get_key_by_keyid(key) # write the key in GPG format so that apt-key list shows it key_gpg = DebianRepository._dearmor_gpg_key(key_asc.encode("utf-8")) - gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key) + gpg_key_filename = os.path.join(_GPG_KEY_DIR, "{}.gpg".format(key)) DebianRepository._write_apt_gpg_keyfile(key_name=gpg_key_filename, key_material=key_gpg) return gpg_key_filename @@ -913,6 +923,9 @@ class GPGKeyError(Error): class DebianRepository: """An abstraction to represent a repository.""" + _deb822_stanza: Optional["_Deb822Stanza"] = None + """set by Deb822Stanza after creating a DebianRepository""" + def __init__( self, enabled: bool, @@ -920,9 +933,9 @@ def __init__( uri: str, release: str, groups: List[str], - filename: Optional[str] = "", - gpg_key_filename: Optional[str] = "", - options: Optional[dict] = None, + filename: str = "", + gpg_key_filename: str = "", + options: Optional[Dict[str, str]] = None, ): self._enabled = enabled self._repotype = repotype @@ -970,14 +983,15 @@ def filename(self, fname: str) -> None: Args: fname: a filename to write the repository information to. """ - if not fname.endswith(".list"): - raise InvalidSourceError("apt source filenames should end in .list!") - + if not fname.endswith((".list", ".sources")): + raise InvalidSourceError("apt source filenames should end in .list or .sources!") self._filename = fname @property def gpg_key(self): """Returns the path to the GPG key for this repository.""" + if not self._gpg_key_filename and self._deb822_stanza is not None: + self._gpg_key_filename = self._deb822_stanza.get_gpg_key_filename() return self._gpg_key_filename @property @@ -985,21 +999,19 @@ def options(self): """Returns any additional repo options which are set.""" return self._options - def make_options_string(self) -> str: - """Generate the complete options string for a a repository. + def make_options_string(self, include_signed_by: bool = True) -> str: + """Generate the complete one-line-style options string for a repository. - Combining `gpg_key`, if set, and the rest of the options to find - a complex repo string. + Combining `gpg_key`, if set (and include_signed_by is True), with any other + provided options to form the options section of a one-line-style definition. """ options = self._options if self._options else {} - if self._gpg_key_filename: - options["signed-by"] = self._gpg_key_filename - - return ( - "[{}] ".format(" ".join(["{}={}".format(k, v) for k, v in options.items()])) - if options - else "" - ) + if include_signed_by and self.gpg_key: + options["signed-by"] = self.gpg_key + if not options: + return "" + pairs = ("{}={}".format(k, v) for k, v in sorted(options.items())) + return "[{}] ".format(" ".join(pairs)) @staticmethod def prefix_from_uri(uri: str) -> str: @@ -1012,47 +1024,46 @@ def prefix_from_uri(uri: str) -> str: @staticmethod def from_repo_line(repo_line: str, write_file: Optional[bool] = True) -> "DebianRepository": - """Instantiate a new `DebianRepository` a `sources.list` entry line. + """Instantiate a new `DebianRepository` from a `sources.list` entry line. Args: repo_line: a string representing a repository entry - write_file: boolean to enable writing the new repo to disk + write_file: boolean to enable writing the new repo to disk. True by default. + Expect it to result in an add-apt-repository call under the hood, like: + add-apt-repository --no-update --sourceslist="$repo_line" """ - repo = RepositoryMapping._parse(repo_line, "UserInput") - fname = "{}-{}.list".format( - DebianRepository.prefix_from_uri(repo.uri), repo.release.replace("/", "-") + repo = RepositoryMapping._parse( # pyright: ignore[reportPrivateUsage] + repo_line, filename="UserInput" # temp filename ) - repo.filename = fname - - options = repo.options if repo.options else {} - if repo.gpg_key: - options["signed-by"] = repo.gpg_key - - # For Python 3.5 it's required to use sorted in the options dict in order to not have - # different results in the order of the options between executions. - options_str = ( - "[{}] ".format(" ".join(["{}={}".format(k, v) for k, v in sorted(options.items())])) - if options - else "" - ) - + repo.filename = repo._make_filename() if write_file: - with open(fname, "wb") as f: - f.write( - ( - "{}".format("#" if not repo.enabled else "") - + "{} {}{} ".format(repo.repotype, options_str, repo.uri) - + "{} {}\n".format(repo.release, " ".join(repo.groups)) - ).encode("utf-8") - ) - + _add_repository(repo) return repo + def _make_filename(self) -> str: + """Construct a filename from uri and release. + + For internal use when a filename isn't set. + Should match the filename written to by add-apt-repository. + """ + return "{}-{}.list".format( + DebianRepository.prefix_from_uri(self.uri), + self.release.replace("/", "-"), + ) + def disable(self) -> None: - """Remove this repository from consideration. + """Remove this repository by disabling it in the source file. + + WARNING: This method does NOT alter the `self.enabled` flag. - Disable it instead of removing from the repository file. + WARNING: disable is currently not implemented for repositories defined + by a deb822 stanza. Raises a NotImplementedError in this case. """ + if self._deb822_stanza is not None: + raise NotImplementedError( + "Disabling a repository defined by a deb822 format source is not implemented." + " Please raise an issue if you require this feature." + ) searcher = "{} {}{} {}".format( self.repotype, self.make_options_string(), self.uri, self.release ) @@ -1181,7 +1192,27 @@ def _write_apt_gpg_keyfile(key_name: str, key_material: bytes) -> None: keyf.write(key_material) -class RepositoryMapping(Mapping): +def _repo_to_identifier(repo: DebianRepository) -> str: + """Return str identifier derived from repotype, uri, and release. + + Private method used to produce the identifiers used by RepositoryMapping. + """ + return "{}-{}-{}".format(repo.repotype, repo.uri, repo.release) + + +def _repo_to_line(repo: DebianRepository, include_signed_by: bool = True) -> str: + """Return the one-per-line format repository definition.""" + return "{prefix}{repotype} {options}{uri} {release} {groups}".format( + prefix="" if repo.enabled else "#", + repotype=repo.repotype, + options=repo.make_options_string(include_signed_by=include_signed_by), + uri=repo.uri, + release=repo.release, + groups=" ".join(repo.groups), + ) + + +class RepositoryMapping(Mapping[str, DebianRepository]): """An representation of known repositories. Instantiation of `RepositoryMapping` will iterate through the @@ -1197,29 +1228,53 @@ class RepositoryMapping(Mapping): )) """ + _apt_dir = "/etc/apt" + _sources_subdir = "sources.list.d" + _default_list_name = "sources.list" + _default_sources_name = "ubuntu.sources" + _last_errors: Tuple[Error, ...] = () + def __init__(self): - self._repository_map = {} - # Repositories that we're adding -- used to implement mode param - self.default_file = "/etc/apt/sources.list" + self._repository_map: Dict[str, DebianRepository] = {} + self.default_file = os.path.join(self._apt_dir, self._default_list_name) + # ^ public attribute for backwards compatibility only + sources_dir = os.path.join(self._apt_dir, self._sources_subdir) + default_sources = os.path.join(sources_dir, self._default_sources_name) # read sources.list if it exists + # ignore InvalidSourceError if ubuntu.sources also exists + # -- in this case, sources.list just contains a comment if os.path.isfile(self.default_file): - self.load(self.default_file) + try: + self.load(self.default_file) + except InvalidSourceError: + if not os.path.isfile(default_sources): + raise # read sources.list.d - for file in glob.iglob("/etc/apt/sources.list.d/*.list"): + for file in glob.iglob(os.path.join(sources_dir, "*.list")): self.load(file) + for file in glob.iglob(os.path.join(sources_dir, "*.sources")): + self.load_deb822(file) - def __contains__(self, key: str) -> bool: - """Magic method for checking presence of repo in mapping.""" + def __contains__(self, key: Any) -> bool: + """Magic method for checking presence of repo in mapping. + + Checks against the string names used to identify repositories. + """ return key in self._repository_map def __len__(self) -> int: """Return number of repositories in map.""" return len(self._repository_map) - def __iter__(self) -> Iterable[DebianRepository]: - """Return iterator for RepositoryMapping.""" + def __iter__(self) -> Iterator[DebianRepository]: + """Return iterator for RepositoryMapping. + + Iterates over the DebianRepository values rather than the string names. + FIXME: this breaks the expectations of the Mapping abstract base class + for example when it provides methods like keys and items + """ return iter(self._repository_map.values()) def __getitem__(self, repository_uri: str) -> DebianRepository: @@ -1230,16 +1285,69 @@ def __setitem__(self, repository_uri: str, repository: DebianRepository) -> None """Add a `DebianRepository` to the cache.""" self._repository_map[repository_uri] = repository + def load_deb822(self, filename: str) -> None: + """Load a deb822 format repository source file into the cache. + + In contrast to one-line-style, the deb822 format specifies a repository + using a multi-line stanza. Stanzas are separated by whitespace, + and each definition consists of lines that are either key: value pairs, + or continuations of the previous value. + + Read more about the deb822 format here: + https://manpages.ubuntu.com/manpages/noble/en/man5/sources.list.5.html + For instance, ubuntu 24.04 (noble) lists its sources using deb822 style in: + /etc/apt/sources.list.d/ubuntu.sources + """ + with open(filename, "r") as f: + repos, errors = self._parse_deb822_lines(f, filename=filename) + for repo in repos: + self._repository_map[_repo_to_identifier(repo)] = repo + if errors: + self._last_errors = tuple(errors) + logger.debug( + "the following %d error(s) were encountered when reading deb822 sources:\n%s", + len(errors), + "\n".join(str(e) for e in errors), + ) + if repos: + logger.info("parsed %d apt package repositories from %s", len(repos), filename) + else: + raise InvalidSourceError("all repository lines in '{}' were invalid!".format(filename)) + + @classmethod + def _parse_deb822_lines( + cls, + lines: Iterable[str], + filename: str = "", + ) -> Tuple[List[DebianRepository], List[InvalidSourceError]]: + """Parse lines from a deb822 file into a list of repos and a list of errors. + + The semantics of `_parse_deb822_lines` slightly different to `_parse`: + `_parse` reads a commented out line as an entry that is not enabled + `_parse_deb822_lines` strips out comments entirely when parsing a file into stanzas, + instead only reading the 'Enabled' key to determine if an entry is enabled + """ + repos: List[DebianRepository] = [] + errors: List[InvalidSourceError] = [] + for numbered_lines in _iter_deb822_stanzas(lines): + try: + stanza = _Deb822Stanza(numbered_lines=numbered_lines, filename=filename) + except InvalidSourceError as e: + errors.append(e) + else: + repos.extend(stanza.repos) + return repos, errors + def load(self, filename: str): - """Load a repository source file into the cache. + """Load a one-line-style format repository source file into the cache. Args: filename: the path to the repository file """ - parsed = [] - skipped = [] + parsed: List[int] = [] + skipped: List[int] = [] with open(filename, "r") as f: - for n, line in enumerate(f): + for n, line in enumerate(f, start=1): # 1 indexed line numbers try: repo = self._parse(line, filename) except InvalidSourceError: @@ -1255,7 +1363,7 @@ def load(self, filename: str): logger.debug("skipped the following lines in file '%s': %s", filename, skip_list) if parsed: - logger.info("parsed %d apt package repositories", len(parsed)) + logger.info("parsed %d apt package repositories from %s", len(parsed), filename) else: raise InvalidSourceError("all repository lines in '{}' were invalid!".format(filename)) @@ -1314,48 +1422,319 @@ def _parse(line: str, filename: str) -> DebianRepository: else: raise InvalidSourceError("An invalid sources line was found in %s!", filename) - def add(self, repo: DebianRepository, default_filename: Optional[bool] = False) -> None: - """Add a new repository to the system. + def add( # noqa: D417 # undocumented-param: default_filename intentionally undocumented + self, repo: DebianRepository, default_filename: Optional[bool] = False + ) -> None: + """Add a new repository to the system using add-apt-repository. Args: - repo: a `DebianRepository` object - default_filename: an (Optional) filename if the default is not desirable - """ - new_filename = "{}-{}.list".format( - DebianRepository.prefix_from_uri(repo.uri), repo.release.replace("/", "-") - ) + repo: a DebianRepository object + if repo.enabled is falsey, will return without adding the repository + Raises: + CalledProcessError: if there's an error running apt-add-repository + + WARNING: Does not associate the repository with a signing key. + Use `import_key` to add a signing key globally. - fname = repo.filename or new_filename + WARNING: if repo.enabled is falsey, will return without adding the repository - options = repo.options if repo.options else {} - if repo.gpg_key: - options["signed-by"] = repo.gpg_key + WARNING: Don't forget to call `apt.update` before installing any packages! + Or call `apt.add_package` with `update_cache=True`. - with open(fname, "wb") as f: - f.write( + WARNING: the default_filename keyword argument is provided for backwards compatibility + only. It is not used, and was not used in the previous revision of this library. + """ + if not repo.enabled: + logger.warning( ( - "{}".format("#" if not repo.enabled else "") - + "{} {}{} ".format(repo.repotype, repo.make_options_string(), repo.uri) - + "{} {}\n".format(repo.release, " ".join(repo.groups)) - ).encode("utf-8") + "Returning from RepositoryMapping.add(repo=%s) without adding the repo" + " because repo.enabled is %s" + ), + repo, + repo.enabled, ) - - self._repository_map["{}-{}-{}".format(repo.repotype, repo.uri, repo.release)] = repo + return + _add_repository(repo) + self._repository_map[_repo_to_identifier(repo)] = repo def disable(self, repo: DebianRepository) -> None: - """Remove a repository. Disable by default. + """Remove a repository by disabling it in the source file. - Args: - repo: a `DebianRepository` to disable + WARNING: disable is currently not implemented for repositories defined + by a deb822 stanza, and will raise a NotImplementedError if called on one. + + WARNING: This method does NOT alter the `.enabled` flag on the DebianRepository. """ - searcher = "{} {}{} {}".format( - repo.repotype, repo.make_options_string(), repo.uri, repo.release + repo.disable() + self._repository_map[_repo_to_identifier(repo)] = repo + # ^ adding to map on disable seems like a bug, but this is the previous behaviour + + +def _add_repository( + repo: DebianRepository, + remove: bool = False, + update_cache: bool = False, +) -> None: + line = _repo_to_line(repo, include_signed_by=False) + key_file = repo.gpg_key + if key_file and not remove and not os.path.exists(key_file): + msg = ( + "Adding repository '{line}' with add-apt-repository." + " Key file '{key_file}' does not exist." + " Ensure it is imported correctly to use this repository." + ).format(line=line, key_file=key_file) + logger.warning(msg) + cmd = [ + "add-apt-repository", + "--yes", + "--sourceslist=" + line, + ] + if remove: + cmd.append("--remove") + if not update_cache: + cmd.append("--no-update") + logger.info("%s", cmd) + try: + subprocess.run(cmd, check=True, capture_output=True) + except CalledProcessError as e: + logger.error( + "subprocess.run(%s):\nstdout:\n%s\nstderr:\n%s", + cmd, + e.stdout.decode(), + e.stderr.decode(), ) + raise + + +class _Deb822Stanza: + """Representation of a stanza from a deb822 source file. + + May define multiple DebianRepository objects. + """ + + def __init__(self, numbered_lines: List[Tuple[int, str]], filename: str = ""): + self._filename = filename + self._numbered_lines = numbered_lines + if not numbered_lines: + self._repos = () + self._gpg_key_filename = "" + self._gpg_key_from_stanza = None + return + options, line_numbers = _deb822_stanza_to_options(numbered_lines) + repos, gpg_key_info = _deb822_options_to_repos( + options, line_numbers=line_numbers, filename=filename + ) + for repo in repos: + repo._deb822_stanza = self # pyright: ignore[reportPrivateUsage] + self._repos = repos + self._gpg_key_filename, self._gpg_key_from_stanza = gpg_key_info + + @property + def repos(self) -> Tuple[DebianRepository, ...]: + """The repositories defined by this deb822 stanza.""" + return self._repos + + def get_gpg_key_filename(self) -> str: + """Return the path to the GPG key for this stanza. + + Import the key first, if the key itself was provided in the stanza. + Return an empty string if no filename or key was provided. + """ + if self._gpg_key_filename: + return self._gpg_key_filename + if self._gpg_key_from_stanza is None: + return "" + # a gpg key was provided in the stanza + # and we haven't already imported it + self._gpg_key_filename = import_key(self._gpg_key_from_stanza) + return self._gpg_key_filename + + +class MissingRequiredKeyError(InvalidSourceError): + """Missing a required value in a source file.""" + + def __init__(self, message: str = "", *, file: str, line: Optional[int], key: str) -> None: + super().__init__(message, file, line, key) + self.file = file + self.line = line + self.key = key + + +class BadValueError(InvalidSourceError): + """Bad value for an entry in a source file.""" + + def __init__( + self, + message: str = "", + *, + file: str, + line: Optional[int], + key: str, + value: str, + ) -> None: + super().__init__(message, file, line, key, value) + self.file = file + self.line = line + self.key = key + self.value = value - for line in fileinput.input(repo.filename, inplace=True): - if re.match(r"^{}\s".format(re.escape(searcher)), line): - print("# {}".format(line), end="") - else: - print(line, end="") - self._repository_map["{}-{}-{}".format(repo.repotype, repo.uri, repo.release)] = repo +def _iter_deb822_stanzas(lines: Iterable[str]) -> Iterator[List[Tuple[int, str]]]: + """Given lines from a deb822 format file, yield a stanza of lines. + + Args: + lines: an iterable of lines from a deb822 sources file + + Yields: + lists of numbered lines (a tuple of line number and line) that make up + a deb822 stanza, with comments stripped out (but accounted for in line numbering) + """ + current_stanza: List[Tuple[int, str]] = [] + for n, line in enumerate(lines, start=1): # 1 indexed line numbers + if not line.strip(): # blank lines separate stanzas + if current_stanza: + yield current_stanza + current_stanza = [] + continue + content, _delim, _comment = line.partition("#") + if content.strip(): # skip (potentially indented) comment line + current_stanza.append((n, content.rstrip())) # preserve indent + if current_stanza: + yield current_stanza + + +def _deb822_stanza_to_options( + lines: Iterable[Tuple[int, str]], +) -> Tuple[Dict[str, str], Dict[str, int]]: + """Turn numbered lines into a dict of options and a dict of line numbers. + + Args: + lines: an iterable of numbered lines (a tuple of line number and line) + + Returns: + a dictionary of option names to (potentially multiline) values, and + a dictionary of option names to starting line number + """ + parts: Dict[str, List[str]] = {} + line_numbers: Dict[str, int] = {} + current = None + for n, line in lines: + assert "#" not in line # comments should be stripped out + if line.startswith(" "): # continuation of previous key's value + assert current is not None + parts[current].append(line.rstrip()) # preserve indent + continue + raw_key, _, raw_value = line.partition(":") + current = raw_key.strip() + parts[current] = [raw_value.strip()] + line_numbers[current] = n + options = {k: "\n".join(v) for k, v in parts.items()} + return options, line_numbers + + +def _deb822_options_to_repos( + options: Dict[str, str], line_numbers: Mapping[str, int] = {}, filename: str = "" +) -> Tuple[Tuple[DebianRepository, ...], Tuple[str, Optional[str]]]: + """Return a collections of DebianRepository objects defined by this deb822 stanza. + + Args: + options: a dictionary of deb822 field names to string options + line_numbers: a dictionary of field names to line numbers (for error messages) + filename: the file the options were read from (for repository object and errors) + + Returns: + a tuple of `DebianRepository`s, and + a tuple of the gpg key filename and optional in-stanza provided key itself + + Raises: + InvalidSourceError if any options are malformed or required options are missing + """ + # Enabled + enabled_field = options.pop("Enabled", "yes") + if enabled_field == "yes": + enabled = True + elif enabled_field == "no": + enabled = False + else: + raise BadValueError( + "Must be one of yes or no (default: yes).", + file=filename, + line=line_numbers.get("Enabled"), + key="Enabled", + value=enabled_field, + ) + # Signed-By + gpg_key_file = options.pop("Signed-By", "") + gpg_key_from_stanza: Optional[str] = None + if "\n" in gpg_key_file: + # actually a literal multi-line gpg-key rather than a filename + gpg_key_from_stanza = gpg_key_file + gpg_key_file = "" + # Types + try: + repotypes = options.pop("Types").split() + uris = options.pop("URIs").split() + suites = options.pop("Suites").split() + except KeyError as e: + [key] = e.args + raise MissingRequiredKeyError( + key=key, + line=min(line_numbers.values()) if line_numbers else None, + file=filename, + ) + # Components + # suite can specify an exact path, in which case the components must be omitted and suite must end with a slash (/). + # If suite does not specify an exact path, at least one component must be present. + # https://manpages.ubuntu.com/manpages/noble/man5/sources.list.5.html + components: List[str] + if len(suites) == 1 and suites[0].endswith("/"): + if "Components" in options: + msg = ( + "Since 'Suites' (line {suites_line}) specifies" + " a path relative to 'URIs' (line {uris_line})," + " 'Components' must be ommitted." + ).format( + suites_line=line_numbers.get("Suites"), + uris_line=line_numbers.get("URIs"), + ) + raise BadValueError( + msg, + file=filename, + line=line_numbers.get("Components"), + key="Components", + value=options["Components"], + ) + components = [] + else: + if "Components" not in options: + msg = ( + "Since 'Suites' (line {suites_line}) does not specify" + " a path relative to 'URIs' (line {uris_line})," + " 'Components' must be present in this stanza." + ).format( + suites_line=line_numbers.get("Suites"), + uris_line=line_numbers.get("URIs"), + ) + raise MissingRequiredKeyError( + msg, + file=filename, + line=min(line_numbers.values()) if line_numbers else None, + key="Components", + ) + components = options.pop("Components").split() + repos = tuple( + DebianRepository( + enabled=enabled, + repotype=repotype, + uri=uri, + release=suite, + groups=components, + filename=filename, + gpg_key_filename=gpg_key_file, + options=options, + ) + for repotype in repotypes + for uri in uris + for suite in suites + ) + return repos, (gpg_key_file, gpg_key_from_stanza) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index d481a56b..8f89d133 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -5,7 +5,7 @@ from subprocess import CalledProcessError, check_output -def get_command_path(command): +def get_command_path(command: str) -> str: try: return check_output(["which", command]).decode().strip() except CalledProcessError: diff --git a/tests/integration/keys/FISH_KEY.asc b/tests/integration/keys/FISH_KEY.asc new file mode 100644 index 00000000..edd2cf43 --- /dev/null +++ b/tests/integration/keys/FISH_KEY.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGY0i8EBEAC787CEn0bb9R6lgxSjwEzrfUGK2HVfUoSfKiKILlU/p+nwdBdx +zKOGEtl2O8W4Q+KZkwEbD4llJKPFhzjF2CeP2qRKd/PLpiZoKhAr/NR7klQveqGx +Hop3uXsdGFxsNv2z0VEyJ7vhO+X0AuAW9BcXpXBexltrIzqbuGg+BKTwAOc5qaWx +xzDgcTvXR7xeudHv4vqXez93pd93WaRnjNB0vOUm03cKqFvfzfn1mJruThO3WE6S +ISjC/rQF4IquNS5ncA2+NLfb7fsPpYUmfKCTucYK2i929Xw/cwLfqE668kQLfJ6G +TiUK6B0EMK/Cvb/LYdN7RMXFrnWt6FnZORZM+9u4ZiCIa60OfW+xXjhmU6Lln3ZV +enhcD0qZHqZLQIxWGxK9rKJsasoBFnbDzBNyMM6BfukQqh6VP3UzdXixPCW1lOFl +omsPj3eoqmIf6tmyINMiJbmQrZmJ7Zn1tpTqScfdPJU06z26hcTQrn2+rvHtQV5V +ebj9JMqvKuk5mG7zkjFgMHoqP6jLlVqsReCfY66VuYLg4o2m0r2F+wv7ZfFXYcCq +LOycszdQe/SgJHobJc/yt/brZMrqFmGtYAERU7NcbRceuc4i2k1VnlygBT1AuvGT +HoSgm9ct0MZlfKRLhkZm+izwxLqxVSAsB/DZJ3uWp4XsKx4XHjw2VFw5aQARAQAB +tChMYXVuY2hwYWQgUFBBIGZvciBGaXNoIHNoZWxsIG1haW50YWluZXJziQJOBBMB +CgA4FiEEiEIecD7cevVJZ97Uc8n8yeK7SNoFAmY0i8ECGwMFCwkIBwIGFQoJCAsC +BBYCAwECHgECF4AACgkQc8n8yeK7SNoJUA//bnOSCrdit87tvsDmaok9+kQ2YYqL +X4KVsKxI2QVsKhRXPJb3yRwQRLArEUhT8yg5kQBIaHKdDhAkH9PRFZeCin25x/lq +LvjpnyslhlI9fhnImC21KqAPs8Svrb2LrvtfQK+072Fw5bISGJ6qlINU1vmJ0AIF +poojZaudcGLiQym1hkyf7HU4loHdkyUKX0jCTS8c9E8pRrDpwdHkKI3pEZpVWva1 +zsmPgYXR/RRCt4zV4/lwX2WiCmYBBGdXn3D2mfA+ONfExngDfjpzXAQ8Dp9m3bDI +AmtlaKRjHa9VKKd0wt6LH2JYmt52lUwoEvoiVf8M0poKrmPN+ft8EaOMpgORS8ut +RZzVndHx4jFyZ+pVDt00J16Bblq6MSaGZalPneIfycx4tuwPPTX7CALUeMQ0V/Gy +p053yvotLwsBC6KGjKugxUzgbSlp7WWiPEbYXZfjb7IZpNmKXtuvfyBo8rs3icez +ZkUmljl2J27bhSIUZQ124ham6yQ3B8MfXUdctiWUCkOdunqUuBeHa5yeKJHTcFUH +Y5nyyn79L5VLZ6YlwYn9qW0V/LvfnW2JmrXTVFjEhfUoEm0Zck8UBqWn2wfSagTI +2KWU87IgN3MF7QG5EiLEAUA/DfagBwgjCpRFH8VUeWqMjTkQs9kjB/kvCcvHGp8g +WVimNlxjmpCZsvk= +=5r0P +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/integration/keys/HPEPUBLICKEY2048_KEY1.asc b/tests/integration/keys/HPEPUBLICKEY2048_KEY1.asc new file mode 100644 index 00000000..5551e650 --- /dev/null +++ b/tests/integration/keys/HPEPUBLICKEY2048_KEY1.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.12 (GNU/Linux) + +mQENBFZp0LkBCACXajRw3b4x7G7dulNYj0hUID4BtVFq/MjEb6PHckTxGxZDoQRX +RK54tiTFA9wq3b4P3yEFnOjbjRoI0d7Ls67FADugFO+cDCtsV9yuDlaYP/U/h2nX +N0R4AdYbsVd5yr6xr+GAy66Hmx5jFH3kbC+zJpOcI0tU9hcyU7gjbxu6KQ1ypI2Q +VRKf8sRBJXgmkOlbYx35ZUMFcmVxrLJXvUuxmAVXgT9f5M3Z3rsGt/ab+/+1TFSb +RsaqHsIPE0QH8ikqW4IeDQAo1T99pCdf7FWr45KFFTo7O4AZdLMWVgqeFHaSoZxJ +307VIINsWiwQoPp0tfU5NOOOwB1Sv3x9QgFtABEBAAG0P0hld2xldHQgUGFja2Fy +ZCBFbnRlcnByaXNlIENvbXBhbnkgUlNBLTIwNDgtMjUgPHNpZ25ocEBocGUuY29t +PokBPQQTAQIAJwUCVmnQuQIbLwUJEswDAAYLCQgHAwIGFQgCCQoLAxYCAQIeAQIX +gAAKCRDCCK3eJsK3l9G+B/0ekblsBeN+xHIJ28pvo2aGb2KtWBwbT1ugI+aIS17K +UQyHZJUQH+ZeRLvosuoiQEdcGIqmOxi2hVhSCQAOV1LAonY16ACveA5DFAEBz1+a +WQyx6sOLLEAVX1VqGlBXxh3XLEUWOhlAf1gZPNtHsmURTUy2h1Lv/Yoj8KLyuK2n +DmrLOS3Ro+RqWocaJfvAgXKgt6Fq/ChDUHOnar7lGswzMsbE/yzLJ7He4y89ImK+ +2ktR5HhDuxqgCe9CWH6Q/1WGhUa0hZ3nbluq7maa+kPe2g7JcRzPH/nJuDCAOZ7U +6mHE8j0kMQMYjgaYEx2wc02aQRmPyxhbDLjSbtjomXRr +=voON +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/tests/integration/keys/HPPUBLICKEY1024.asc b/tests/integration/keys/HPPUBLICKEY1024.asc new file mode 100644 index 00000000..c872555d --- /dev/null +++ b/tests/integration/keys/HPPUBLICKEY1024.asc @@ -0,0 +1,30 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.0 (MingW32) + +mQGiBEIxWpoRBADb06sJgnD7MJnm2Ny1nmTFLDSZ8vkubP+pmfn9N9TE26oit+KI +OnVTRVbSPl3F15wTjSBGR453MEfnzp1NrMk1GIa/m1nKAmgQ4t1714C4jQab0to+ +gP51XhPhtAGt7BggorQw2RXa4KdTCh8ByOIaDKRYcESmMazSZ+Pscy2XRwCgm771 +21RCM0RcG2dmHZZgKH8fTscD/RiY3CHI2jJl9WosIYXbZpOySzrLn0lRCRdNdpew +Y5m1f3lhqoSvJk7pXjs4U+3XlOlUhgWl5HiXuWSVyPu2ilfGdfgpJslawI85fBQg +Ul5kcrjLHHsApeG8oGStFJE2JAc+0D+whmGmJbjWKwuZJmgpm9INplA4h1BYJbx+ +6A3MBACFiMTttDPpJ+5eWr1VSZwxCZNqvPWmjpL5Nh9F8xzE7q+ad2CFKSebvRrv +Jf7Y2m+wY9bmo5nJ3wHYEX3Aatt+QVF10G6wTdIz/Ohm/Pc4Li4NhzYOv7FKxVam +97UN0O8Rsl4GhE2eE8H+Q3QYFvknAWoTj3Rq3/A5FA6FsRFhxbQwSGV3bGV0dC1Q +YWNrYXJkIENvbXBhbnkgKEhQIENvZGVzaWduaW5nIFNlcnZpY2UpiGQEExECACQF +AkIxWpoCGwMFCRLMAwAGCwkIBwMCAxUCAwMWAgECHgECF4AACgkQUnvFOiaJuIc1 +2wCgj2UotUgSegPHmcKdApY+4WFaz/QAnjI58l5bDD8eElBCErHVoq9uPMczuQIN +BEIxWqUQCADnBXqoU8QeZPEy38oI0GrN2q7nvS+4UBQeIRVy8x+cOqDRDcE8PHej +7NtxP698U0WFGK47GszjiV4WTnvexuJk0B5AMEBHana8fVj7uRUcmyYZqOZd7EXn +Q3Ivi8itfkTICkhZi7bmGsSF0iJ0eAI5n2bCqJykNQvJ6a3dWJKP8EgaBCZj+TGL +WWJHDZsrn8g4BeaNS/MbmsCLAk8N6bWMGzAKfgxUraMCwuZ9fVyHFavHdeChUtna +qnF4uw0hHLaGWmTJjziXVvVC1a8+inTxPZkVpAvD0A+/LNlkP7TtAdaVOJqv3+a3 +ybMQL851bRTFyt+H0XGHhzhhtuu9+DyfAAMFCADRWGxIfniVG7O4wtwLD3sWzR/W +LmFlJYu4s9rSDgn3NDjigQzZoVtbuv3Z9IZxBMoYa50MuybuVDp55z/wmxvYoW2G +25kOFDKx/UmkKkUBLdokb5V1p9j5SJorGBSfsNAHflhmBhyuMP4CDISbBUSN7oO1 +Oj41jNxpqhy+8ayygSVcTNwMe909J/HdC//xFANLDhjKPf3ZAulWNhOvjTlpF46B +yt1l8ZNinIeE7CFL7H+LlMl2Ml6wsOkrxsSauBis6nER4sYVqrMdzpUU2Sr2hj6Q +sJ+9TS+IURcnxL/M851KCwLhwZKdphQjT3mXXsoCx/l3rI6cxpwYgjiKiZhOiE8E +GBECAA8FAkIxWqUCGwwFCRLMAwAACgkQUnvFOiaJuIenewCdHcEvMxBYprqRjKUw +04EypyFtZTgAn0wds0nbpd2+VZ5WHbVRfU4y5Y5Y +=+cX+ +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/tests/integration/keys/HPPUBLICKEY2048.asc b/tests/integration/keys/HPPUBLICKEY2048.asc new file mode 100644 index 00000000..36202be3 --- /dev/null +++ b/tests/integration/keys/HPPUBLICKEY2048.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.10 (MingW32) + +mQENBFC+QboBCAC1bodHD7AmR00SkDMB4u9MXy+Z5vv8wbmGRaKDBYScpAknOljX +d5tBADffAetd1hgLnrLKN8vHdIsYkmUyeEeEsnIUKtwvbx/f6PoZZPOIIIRh1d2W +Mjw9qXIE+tgr2gWlq0Gi5BZzaKse1+khRQ2rewJBppblSGWgcmCMIq8OwAsrdbtr +z7+37c/g/Y2VfAahc23YZW9LQ5MiaI4nS4JMZbWPYtBdF78B/D2t5FvmvDG0Cgjk +Qi1U9IVjiFKixuoi6nRsvBLFYL/cI+vo4iyUC5x7qmKd8gN7A030gS67VrleNRki +q0vaF6J46XpIl4o58t23FSAKKRbTwavYzdMpABEBAAG0NEhld2xldHQtUGFja2Fy +ZCBDb21wYW55IFJTQSAoSFAgQ29kZXNpZ25pbmcgU2VydmljZSmJAT4EEwECACgF +AlC+QboCGwMFCRLMAwAGCwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJELBwaApc +4tR2x7sH/A3D4XxEEyrX6Z3HeWSSA80+n+r5QwfXm5unxsWEL3JyNg6sojlrJY4K +8k4ih4nkY4iblChTCSQwnqKXqkL5U+RIr+AJoPx+55M98u4eRTVYMHZD7/jFq85z +ZFGUkFkars9E2aRzWhqbz0LINb9OUeX0tT5qQseHflO2PaJykxNPC14WhsBKC2lg +dZWnGhO5QJFp69AnSp4k+Uo/1LMk87YEJIL1NDR0lrlKgRvFfFyTpRBt+Qb1Bb7g +rjN0171g8t5GaPWamN3Oua/v4aZg15f3xydRF8y9TsYjiNz+2TzRjKv7AkpZaJST +06CqMjCgiZ6UFFGN0/oqLnwxdP3Mmh4= +=aphN +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/tests/integration/keys/HPPUBLICKEY2048_KEY1.asc b/tests/integration/keys/HPPUBLICKEY2048_KEY1.asc new file mode 100644 index 00000000..5eb30d4f --- /dev/null +++ b/tests/integration/keys/HPPUBLICKEY2048_KEY1.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1.4.12 (MingW32) + +mQENBFRtGAgBCADlSku65P14hVdx9E/W0n6MwuB3WGqmsyKNoa3HezFdMjWERldI +NNUdi8O28cZ6j2+Hi9L1HeQIQ9+7FHpR3JyQePBJtRX8WSEusfRtML98opDhJxKm +8Jyxb7aTvCwdNHz3yxADINkMtOj5oRm7VCr8XHkG7YU27ELs8B+BXWvjO21oSosi +FurnhT+H3hQsYXfYA55aa21q0qX+L5dFJSNdzZVo7m9ybioVv2R5+PfBvdaSxCnm +OpcGXFaKAsqVHeTW0pd3sdkin1rkbhOBaU5lFBt2ZiMtKpKHpT8TZnqHpFHFbgi8 +j2ARJj4IDct2OGILddUIZSFyue6WE2hpV5c/ABEBAAG0OEhld2xldHQtUGFja2Fy +ZCBDb21wYW55IFJTQSAoSFAgQ29kZXNpZ25pbmcgU2VydmljZSkgLSAxiQE+BBMB +AgAoBQJUbRgIAhsDBQkSzAMABgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRD6 +3Y1ksSdeo6BJCADOfIPPLPpIOnFK9jH4t8lLUd+RyMc+alA3uTDPUJa/ZHa6DHfh +42iaPYVEV8OG0tnbMlHmwvsZ5c1/MRMw1UbxCvD88P2qM4SUrUjQUlSCms2GLGvF +ftFXBiOJQ7/yBc9o+yoSvwPrrTxSCk4+Sqm0IfVXVzChDM9dM9YPY2Vzjd+LUaYC +3X+eSuggUDO0TmJLJd7tZdF9fVXq3lr63BZ5PY98MTCuOoeSMDa9FIUQf6vn6UUJ +MDSRZ9OzhpNJOKR+ShVRwDK6My8gtVIW1EAW2w3VQWI2UNF07aLeO8UG6nTNWA23 ++OuZkUdgQovjcq01caSefgOkmiQOx6d74CAk +=X+eo +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/tests/integration/test_apt.py b/tests/integration/test_apt.py index 90c5147b..5c61bd91 100644 --- a/tests/integration/test_apt.py +++ b/tests/integration/test_apt.py @@ -4,6 +4,10 @@ import logging +import os +import subprocess +from pathlib import Path +from typing import List from urllib.request import urlopen from charms.operator_libs_linux.v0 import apt @@ -11,31 +15,27 @@ logger = logging.getLogger(__name__) +KEY_DIR = Path(__file__).parent / "keys" -def test_install_package(): - try: - apt.update() - apt.add_package("zsh") - apt.add_package(["golang-cfssl", "jq"]) - except apt.PackageNotFoundError: - logger.error("A specified package not found in package cache or on system") - except apt.PackageError as e: - logger.error("Could not install package. Reason: {}".format(e.message)) +def test_install_packages(): + apt.update() + apt.add_package("zsh") assert get_command_path("zsh") == "/usr/bin/zsh" + apt.add_package(["golang-cfssl", "jq"]) assert get_command_path("cfssl") == "/usr/bin/cfssl" assert get_command_path("jq") == "/usr/bin/jq" def test_install_package_error(): + package = apt.DebianPackage( + name="ceci-n'est-pas-un-paquet", + version="1.0", + epoch="", + arch="amd64", + state=apt.PackageState.Available, + ) try: - package = apt.DebianPackage( - "ceci-n'est-pas-un-paquet", - "1.0", - "", - "amd64", - apt.PackageState.Available, - ) package.ensure(apt.PackageState.Present) except apt.PackageError as e: assert "Unable to locate package" in str(e) @@ -44,53 +44,148 @@ def test_install_package_error(): def test_remove_package(): # First ensure the package is present cfssl = apt.DebianPackage.from_apt_cache("golang-cfssl") - if not cfssl.present: - apt.add_package("golang-cfssl") - assert get_command_path("cfssl") == "/usr/bin/cfssl" + assert not cfssl.present + # Add package + apt.add_package("golang-cfssl") + assert get_command_path("cfssl") + subprocess.run(["cfssl", "version"], check=True) # Now remove the package and check its bins disappear too apt.remove_package("golang-cfssl") - assert get_command_path("cfssl") == "" - - -def test_install_package_external_repository(): - repositories = apt.RepositoryMapping() - - # Get the Hashicorp GPG key - key = urlopen("https://apt.releases.hashicorp.com/gpg").read().decode() - - # Add the hashicorp repository if it doesn't already exist - if "deb-apt.releases.hashicorp.com-focal" not in repositories: - line = "deb [arch=amd64] https://apt.releases.hashicorp.com focal main" - repo = apt.DebianRepository.from_repo_line(line) - # Import the repository's key - repo.import_key(key) - repositories.add(repo) - + assert not get_command_path("cfssl") + + +def test_install_package_from_external_repository(): + repo_id = "deb-https://repo.mongodb.org/apt/ubuntu-jammy/mongodb-org/8.0" + repos = apt.RepositoryMapping() + assert repo_id not in repos + assert not get_command_path("mongod") + ## steps + key = urlopen("https://www.mongodb.org/static/pgp/server-8.0.asc").read().decode() + key_file = apt.import_key(key) + line = ( + "deb [ arch=amd64,arm64 signed-by={} ]" + " https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/8.0 multiverse" + ).format(key_file) + ## if: use implicit write_file + repo = apt.DebianRepository.from_repo_line(line) # write_file=True by default + assert repo_id not in repos + ## if: don't use implicit write_file + # repo = apt.DebianRepository.from_repo_line(line, write_file=False) + # repos.add(repo) + # assert repo_id in repos + ## fi + assert repo_id in apt.RepositoryMapping() apt.update() - apt.add_package("terraform") - - assert get_command_path("terraform") == "/usr/local/bin/terraform" + apt.add_package("mongodb-org") + assert get_command_path("mongod") + subprocess.run(["mongod", "--version"], check=True) + ## cleanup + os.remove(key_file) + apt._add_repository(repo, remove=True) # pyright: ignore[reportPrivateUsage] + assert repo_id not in apt.RepositoryMapping() + apt.update() + apt.remove_package("mongodb-org") + assert get_command_path("mongod") # mongodb-org is a metapackage + subprocess.run(["apt", "autoremove"], check=True) + assert not get_command_path("mongod") + + +def test_install_higher_version_package_from_external_repository(): + repo_id = "deb-https://ppa.launchpadcontent.net/fish-shell/release-3/ubuntu/-jammy" + repos = apt.RepositoryMapping() + assert repo_id not in repos + ## version before + if not get_command_path("fish"): + apt.add_package("fish") + assert get_command_path("fish") + version_before = subprocess.run( + ["fish", "--version"], + capture_output=True, + check=True, + text=True, + ).stdout + apt.remove_package("fish") + assert not get_command_path("fish") + ## steps + repo = apt.DebianRepository( + enabled=True, + repotype="deb", + uri="https://ppa.launchpadcontent.net/fish-shell/release-3/ubuntu/", + release="jammy", + groups=["main"], + ) + repos.add(repo) # update_cache=False by default + assert repo_id in repos + assert repo_id in apt.RepositoryMapping() + key_file = apt.import_key((KEY_DIR / "FISH_KEY.asc").read_text()) + apt.update() + apt.add_package("fish") + assert get_command_path("fish") + version_after = subprocess.run( + ["fish", "--version"], + capture_output=True, + check=True, + text=True, + ).stdout + assert version_after > version_before # lexical comparison :( + ## cleanup + os.remove(key_file) + apt._add_repository(repo, remove=True) # pyright: ignore[reportPrivateUsage] + assert repo_id not in apt.RepositoryMapping() + apt.update() + apt.remove_package("fish") + assert not get_command_path("fish") -def test_list_file_generation_external_repository(): - repositories = apt.RepositoryMapping() +def test_install_hardware_observer_ssacli(): + """Test the ability to install a package used by the hardware-observer charm. - # Get the mongo GPG key - key = urlopen(" https://www.mongodb.org/static/pgp/server-5.0.asc").read().decode() + Here we follow the order of operations and arguments used in the charm: + for key in HP_KEYS: + apt.import_key(key) - # Add the mongo repository if it doesn't already exist - repo_name = "deb-https://repo.mongodb.org/apt/ubuntu-focal/mongodb-org/5.0" - if repo_name not in repositories: - line = "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/5.0 multiverse" - repo = apt.DebianRepository.from_repo_line(line) - # Import the repository's key - repo.import_key(key) + repositories = apt.RepositoryMapping() + repo = apt.DebianRepository.from_repo_line(...) repositories.add(repo) + apt.add_package(self.pkg, update_cache=True) + """ + line = "deb http://downloads.linux.hpe.com/SDR/repo/mcp stretch/current non-free" + repo_id = apt._repo_to_identifier( # pyright: ignore[reportPrivateUsage] + apt.DebianRepository.from_repo_line(line, write_file=False) + ) + assert repo_id not in apt.RepositoryMapping() + assert not get_command_path("ssacli") + key_files: List[str] = [] # just for cleanup + ## steps + for path in ( + KEY_DIR / "HPEPUBLICKEY2048_KEY1.asc", + KEY_DIR / "HPPUBLICKEY2048_KEY1.asc", + KEY_DIR / "HPPUBLICKEY2048.asc", + KEY_DIR / "HPPUBLICKEY1024.asc", + ): + key_file = apt.import_key(path.read_text()) + key_files.append(key_file) + repos = apt.RepositoryMapping() + repo = apt.DebianRepository.from_repo_line(line) # write_file=True by default + assert repo_id in apt.RepositoryMapping() + # repo added to system but repos doesn't know about it yet + assert repo_id not in repos + repos.add(repo) + assert repo_id in repos + # `add` call is redundant with `from_repo_line` from system pov + # but adds an entry to the RepositoryMapping + apt.add_package("ssacli", update_cache=True) + # apt.update not required since update_cache=True + assert get_command_path("ssacli") + ## cleanup + for key_file in key_files: + os.remove(key_file) + apt._add_repository(repo, remove=True) # pyright: ignore[reportPrivateUsage] + assert repo_id not in apt.RepositoryMapping() apt.update() - apt.add_package("mongodb-org") - - assert get_command_path("mongod") == "/usr/bin/mongod" + apt.remove_package("ssacli") + assert not get_command_path("ssacli") def test_from_apt_cache_error(): diff --git a/tests/unit/data/fake-apt-dirs/bionic/sources.list b/tests/unit/data/fake-apt-dirs/bionic/sources.list new file mode 100755 index 00000000..5fdc50fb --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/bionic/sources.list @@ -0,0 +1,42 @@ +# See http://help.ubuntu.com/community/UpgradeNotes for how to upgrade to +# newer versions of the distribution. +deb http://nz.archive.ubuntu.com/ubuntu/ bionic main restricted +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic main restricted + +## Major bug fix updates produced after the final release of the +## distribution. +deb http://nz.archive.ubuntu.com/ubuntu/ bionic-updates main restricted +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic-updates main restricted + +## N.B. software from this repository is ENTIRELY UNSUPPORTED by the Ubuntu +## team. Also, please note that software in universe WILL NOT receive any +## review or updates from the Ubuntu security team. +deb http://nz.archive.ubuntu.com/ubuntu/ bionic universe +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic universe +deb http://nz.archive.ubuntu.com/ubuntu/ bionic-updates universe +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic-updates universe + +## N.B. software from this repository is ENTIRELY UNSUPPORTED by the Ubuntu +## team, and may not be under a free licence. Please satisfy yourself as to +## your rights to use the software. Also, please note that software in +## multiverse WILL NOT receive any review or updates from the Ubuntu +## security team. +deb http://nz.archive.ubuntu.com/ubuntu/ bionic multiverse +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic multiverse +deb http://nz.archive.ubuntu.com/ubuntu/ bionic-updates multiverse +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic-updates multiverse + +## N.B. software from this repository may not have been tested as +## extensively as that contained in the main release, although it includes +## newer versions of some applications which may provide useful features. +## Also, please note that software in backports WILL NOT receive any review +## or updates from the Ubuntu security team. +deb http://nz.archive.ubuntu.com/ubuntu/ bionic-backports main restricted universe multiverse +deb-src http://nz.archive.ubuntu.com/ubuntu/ bionic-backports main restricted universe multiverse + +## Uncomment the following two lines to add software from Canonical's +## 'partner' repository. +## This software is not part of Ubuntu, but is offered by Canonical and the +## respective vendors as a service to Ubuntu users. +# deb http://archive.canonical.com/ubuntu bionic partner +# deb-src http://archive.canonical.com/ubuntu bionic partner \ No newline at end of file diff --git a/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list b/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list new file mode 100644 index 00000000..eb39b946 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list @@ -0,0 +1 @@ +# Ubuntu sources have moved to /etc/apt/sources.list.d/ubuntu.sources diff --git a/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list.d/ubuntu.sources b/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list.d/ubuntu.sources new file mode 100644 index 00000000..53bc2b4d --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-empty-sources/sources.list.d/ubuntu.sources @@ -0,0 +1 @@ +# this is a bad file \ No newline at end of file diff --git a/tests/unit/data/fake-apt-dirs/noble-in-one-per-line-format/sources.list b/tests/unit/data/fake-apt-dirs/noble-in-one-per-line-format/sources.list new file mode 100644 index 00000000..03481c54 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-in-one-per-line-format/sources.list @@ -0,0 +1,4 @@ +deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble main restricted universe multiverse +deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble-updates main restricted universe multiverse +deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://nz.archive.ubuntu.com/ubuntu/ noble-backports main restricted universe multiverse +deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] http://security.ubuntu.com/ubuntu noble-security main restricted universe multiverse \ No newline at end of file diff --git a/tests/unit/data/fake-apt-dirs/noble-no-sources/sources.list b/tests/unit/data/fake-apt-dirs/noble-no-sources/sources.list new file mode 100644 index 00000000..eb39b946 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-no-sources/sources.list @@ -0,0 +1 @@ +# Ubuntu sources have moved to /etc/apt/sources.list.d/ubuntu.sources diff --git a/tests/unit/data/fake-apt-dirs/noble-with-comments-etc/sources.list.d/ubuntu.sources b/tests/unit/data/fake-apt-dirs/noble-with-comments-etc/sources.list.d/ubuntu.sources new file mode 100644 index 00000000..87a867eb --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-with-comments-etc/sources.list.d/ubuntu.sources @@ -0,0 +1,45 @@ +Components: main restricted universe multiverse # Components first! I don't think deb822 cares about ordering +Types: deb deb-src # this could be one or both of these options +URIs: http://nz.archive.ubuntu.com/ubuntu/ http://archive.ubuntu.com/ubuntu/ + # there can be multiple space separated URIs + # sources are specified in priority order + # apt does some de-duplication of sources after parsing too +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +# let's make this insecure! (jk, just testing parsing) +Suites: noble noble-updates noble-backports + +Foo: Bar # this is a separate (malformed) entry + +## a fully commented out stanza +#Types: deb +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +## disable security updates while we're at it + +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ + +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ +Components: main # this is an error, can't have components with an exact path + +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +# this is an error, must have at least one component if suites isn't an exact path + +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: no + +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: bad \ No newline at end of file diff --git a/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list new file mode 100644 index 00000000..eb39b946 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list @@ -0,0 +1 @@ +# Ubuntu sources have moved to /etc/apt/sources.list.d/ubuntu.sources diff --git a/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/inkscape_dev-ubuntu-stable-noble.sources b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/inkscape_dev-ubuntu-stable-noble.sources new file mode 100644 index 00000000..9bce7f16 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/inkscape_dev-ubuntu-stable-noble.sources @@ -0,0 +1,34 @@ +Types: deb +URIs: https://ppa.launchpadcontent.net/inkscape.dev/stable/ubuntu/ +Suites: noble +Components: main +Signed-By: + -----BEGIN PGP PUBLIC KEY BLOCK----- + . + mQINBGY0ViQBEACsQsdIRXzyEkk38x2oDt1yQ/Kt3dsiDKJFNLbs/xiDHrgIW6cU + 1wZB0pfb3lCyG3/ZH5uvR0arCSHJvCvkdCFTqqkZndSA+/pXreCSLeP8CNawf/RM + 3cNbdJlE8jXzaX2qzSEC9FDNqu4cQHIHR7xMAbSCPW8rxKvRCWmkZccfndDuQyN2 + vg3b2x9DWKS3DBRffivglF3yT49OuLemG5qJHujKOmNJZ32JoRniIivsuk1CCS1U + NDK6xWkr13aNe056QhVAh2iPF6MRE85zail+mQxt4LAgl/aLR0JSDSpWkbQH7kbu + 5cJVan8nYF9HelVJ3QuMwdz3VQn4YVO2Wc8s0YfnNdQttYaUx3lz35Fct6CaOqjP + pgsZ4467lmB9ut74G+wDCQXmT232hsBkTz4D0DyVPB/ziBgGWUKti0AWNT/3kFww + 2VM/80XADaDz0Sc/Hzi/cr9ZrbW3drMwoVvKtfMtOT7FMbeuPRWZKYZnDbvNm62e + ToKVudE0ijfsksxbcHKmGdvWntCxlinp0i39Jfz6y54pppjmbHRQgasmqm2EQLfA + RUNW/zB7gX80KTUCGjcLOTPajBIN5ZfjVITetryAFjv7fnK0qpf2OpqwF832W4V/ + S3GZtErupPktYG77Z9jOUxcJeEGYjWbVlbit8mTKDRdQUXOeOO6TzB4RcwARAQAB + tCVMYXVuY2hwYWQgUFBBIGZvciBJbmtzY2FwZSBEZXZlbG9wZXJziQJOBBMBCgA4 + FiEEVr3/0vHJaz0VdeO4XJoLhs0vyzgFAmY0ViQCGwMFCwkIBwIGFQoJCAsCBBYC + AwECHgECF4AACgkQXJoLhs0vyzh3RBAAo7Hee8i2I4n03/iq58lqml/OVJH9ZEle + amk3e0wsiVS0QdT/zB8/AMVDB1obazBfrHKJP9Ck+JKH0uxaGRxYBohTbO3Y3sBO + qRHz5VLcFzuyk7AA53AZkNx8Zbv6D0O4JTCPDJn9Gwbd/PpnvJm9Ri+zEiVPhXNu + oSBryGM09un2Yvi0DA+ulouSKTy9dkbI1R7flPZ2M/mKT8Lk0n1pJu5FvgPC4E6R + PT0Njw9+k/iHtP76U4SqHJZZx2I/TGlXMD1memyTK4adWZeGLaAiFadsoeJsDoDE + MkHFxFkr9n0E4wJhRGgL0OxDWugJemkUvHbzXFNUaeX5Spw/aO7r1CtTh8lyqiom + 4ebAkURjESRFOFzcsM7nyQnmt2OgQkEShSL3NrDMkj1+3+FgQtd8sbeVpmpGBq87 + J3iq0YMsUysWq8DJSz4RnBTfeGlJWJkl3XxB9VbG3BqqbN9fRp+ccgZ51g5/DEA/ + q8jYS7Ur5bAlSoeA4M3SvKSlQM8+aT35dteFzejvo1N+2n0E0KoymuRsDBdzby0z + lJDKe244L5D6UPJo9YTmtE4kG/fGNZ5/JdRA+pbe7f/z84HVcJ3ziGdF/Nio/D8k + uFjZP2M/mxC7j+WnmKAruqmY+5vkAEqUPTobsloDjT3B+z0rzWk8FG/G5KFccsBO + 2ekz6IVTXVA= + =VF33 + -----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/ubuntu.sources b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/ubuntu.sources new file mode 100644 index 00000000..06c19eae --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble-with-inkscape/sources.list.d/ubuntu.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg diff --git a/tests/unit/data/fake-apt-dirs/noble/sources.list b/tests/unit/data/fake-apt-dirs/noble/sources.list new file mode 100644 index 00000000..eb39b946 --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble/sources.list @@ -0,0 +1 @@ +# Ubuntu sources have moved to /etc/apt/sources.list.d/ubuntu.sources diff --git a/tests/unit/data/fake-apt-dirs/noble/sources.list.d/ubuntu.sources b/tests/unit/data/fake-apt-dirs/noble/sources.list.d/ubuntu.sources new file mode 100644 index 00000000..06c19eae --- /dev/null +++ b/tests/unit/data/fake-apt-dirs/noble/sources.list.d/ubuntu.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg diff --git a/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources b/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources new file mode 100644 index 00000000..a8d58999 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-components-missing-without-exact-path.sources @@ -0,0 +1,4 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +# this is an error, must have at least one component if suites isn't an exact path \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources b/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources new file mode 100644 index 00000000..b0cd1ec7 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-components-present-with-exact-path.sources @@ -0,0 +1,4 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ +Components: main # this is an error, can't have components with an exact path \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources b/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources new file mode 100644 index 00000000..2d707b53 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-enabled-bad.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: bad \ No newline at end of file diff --git a/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources b/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources new file mode 100644 index 00000000..6eca1805 --- /dev/null +++ b/tests/unit/data/individual-files/bad-stanza-missing-required-keys.sources @@ -0,0 +1,3 @@ +# a comment + +Foo: Bar # this is a separate (malformed) entry \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-comments.sources b/tests/unit/data/individual-files/good-stanza-comments.sources new file mode 100644 index 00000000..1f4972ae --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-comments.sources @@ -0,0 +1,9 @@ +Components: main restricted universe multiverse # Components first! I don't think deb822 cares about ordering +Types: deb deb-src # this could be one or both of these options +URIs: http://nz.archive.ubuntu.com/ubuntu/ http://archive.ubuntu.com/ubuntu/ + # there can be multiple space separated URIs + # sources are specified in priority order + # apt does some de-duplication of sources after parsing too +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +# let's make this insecure! (jk, just testing parsing) +Suites: noble noble-updates noble-backports \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-enabled-no.sources b/tests/unit/data/individual-files/good-stanza-enabled-no.sources new file mode 100644 index 00000000..9c48d4e2 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-enabled-no.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Enabled: no \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-exact-path.sources b/tests/unit/data/individual-files/good-stanza-exact-path.sources new file mode 100644 index 00000000..6b2a587a --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-exact-path.sources @@ -0,0 +1,3 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: an/exact/path/ \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources b/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources new file mode 100644 index 00000000..777a4f4c --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-noble-main-etc.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-noble-security.sources b/tests/unit/data/individual-files/good-stanza-noble-security.sources new file mode 100644 index 00000000..c1dcc762 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-noble-security.sources @@ -0,0 +1,5 @@ +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources b/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources new file mode 100644 index 00000000..9bce7f16 --- /dev/null +++ b/tests/unit/data/individual-files/good-stanza-with-gpg-key.sources @@ -0,0 +1,34 @@ +Types: deb +URIs: https://ppa.launchpadcontent.net/inkscape.dev/stable/ubuntu/ +Suites: noble +Components: main +Signed-By: + -----BEGIN PGP PUBLIC KEY BLOCK----- + . + mQINBGY0ViQBEACsQsdIRXzyEkk38x2oDt1yQ/Kt3dsiDKJFNLbs/xiDHrgIW6cU + 1wZB0pfb3lCyG3/ZH5uvR0arCSHJvCvkdCFTqqkZndSA+/pXreCSLeP8CNawf/RM + 3cNbdJlE8jXzaX2qzSEC9FDNqu4cQHIHR7xMAbSCPW8rxKvRCWmkZccfndDuQyN2 + vg3b2x9DWKS3DBRffivglF3yT49OuLemG5qJHujKOmNJZ32JoRniIivsuk1CCS1U + NDK6xWkr13aNe056QhVAh2iPF6MRE85zail+mQxt4LAgl/aLR0JSDSpWkbQH7kbu + 5cJVan8nYF9HelVJ3QuMwdz3VQn4YVO2Wc8s0YfnNdQttYaUx3lz35Fct6CaOqjP + pgsZ4467lmB9ut74G+wDCQXmT232hsBkTz4D0DyVPB/ziBgGWUKti0AWNT/3kFww + 2VM/80XADaDz0Sc/Hzi/cr9ZrbW3drMwoVvKtfMtOT7FMbeuPRWZKYZnDbvNm62e + ToKVudE0ijfsksxbcHKmGdvWntCxlinp0i39Jfz6y54pppjmbHRQgasmqm2EQLfA + RUNW/zB7gX80KTUCGjcLOTPajBIN5ZfjVITetryAFjv7fnK0qpf2OpqwF832W4V/ + S3GZtErupPktYG77Z9jOUxcJeEGYjWbVlbit8mTKDRdQUXOeOO6TzB4RcwARAQAB + tCVMYXVuY2hwYWQgUFBBIGZvciBJbmtzY2FwZSBEZXZlbG9wZXJziQJOBBMBCgA4 + FiEEVr3/0vHJaz0VdeO4XJoLhs0vyzgFAmY0ViQCGwMFCwkIBwIGFQoJCAsCBBYC + AwECHgECF4AACgkQXJoLhs0vyzh3RBAAo7Hee8i2I4n03/iq58lqml/OVJH9ZEle + amk3e0wsiVS0QdT/zB8/AMVDB1obazBfrHKJP9Ck+JKH0uxaGRxYBohTbO3Y3sBO + qRHz5VLcFzuyk7AA53AZkNx8Zbv6D0O4JTCPDJn9Gwbd/PpnvJm9Ri+zEiVPhXNu + oSBryGM09un2Yvi0DA+ulouSKTy9dkbI1R7flPZ2M/mKT8Lk0n1pJu5FvgPC4E6R + PT0Njw9+k/iHtP76U4SqHJZZx2I/TGlXMD1memyTK4adWZeGLaAiFadsoeJsDoDE + MkHFxFkr9n0E4wJhRGgL0OxDWugJemkUvHbzXFNUaeX5Spw/aO7r1CtTh8lyqiom + 4ebAkURjESRFOFzcsM7nyQnmt2OgQkEShSL3NrDMkj1+3+FgQtd8sbeVpmpGBq87 + J3iq0YMsUysWq8DJSz4RnBTfeGlJWJkl3XxB9VbG3BqqbN9fRp+ccgZ51g5/DEA/ + q8jYS7Ur5bAlSoeA4M3SvKSlQM8+aT35dteFzejvo1N+2n0E0KoymuRsDBdzby0z + lJDKe244L5D6UPJo9YTmtE4kG/fGNZ5/JdRA+pbe7f/z84HVcJ3ziGdF/Nio/D8k + uFjZP2M/mxC7j+WnmKAruqmY+5vkAEqUPTobsloDjT3B+z0rzWk8FG/G5KFccsBO + 2ekz6IVTXVA= + =VF33 + -----END PGP PUBLIC KEY BLOCK----- diff --git a/tests/unit/data/individual-files/stanzas-fully-commented-out.sources b/tests/unit/data/individual-files/stanzas-fully-commented-out.sources new file mode 100644 index 00000000..791814d7 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-fully-commented-out.sources @@ -0,0 +1,13 @@ +#a fully commented out stanza +#Types: deb # with an inline comment too +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +#another fully commented out stanza +#Types: deb-src # with an inline comment too +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/stanzas-noble.sources b/tests/unit/data/individual-files/stanzas-noble.sources new file mode 100644 index 00000000..021ad293 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-noble.sources @@ -0,0 +1,11 @@ +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble noble-updates noble-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu +Suites: noble-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg \ No newline at end of file diff --git a/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources b/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources new file mode 100644 index 00000000..0b2195a5 --- /dev/null +++ b/tests/unit/data/individual-files/stanzas-one-good-one-bad-comments.sources @@ -0,0 +1,15 @@ +# a good entry that defines one repository +Types: deb +URIs: http://nz.archive.ubuntu.com/ubuntu/ +Suites: noble +Components: main + +Foo: Bar # this is a separate (malformed) entry + +# this is fully commented out and will be skipped entirely +#Types: deb +#URIs: http://security.ubuntu.com/ubuntu +#Suites: noble-security +#Components: main restricted universe multiverse +#Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +## disable security updates while we're at it \ No newline at end of file diff --git a/tests/unit/test_deb822.py b/tests/unit/test_deb822.py new file mode 100644 index 00000000..96bf88bc --- /dev/null +++ b/tests/unit/test_deb822.py @@ -0,0 +1,348 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +# pyright: reportPrivateUsage=false + +import itertools +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from charms.operator_libs_linux.v0 import apt + +TEST_DATA_DIR = Path(__file__).parent / "data" +FAKE_APT_DIRS = TEST_DATA_DIR / "fake-apt-dirs" +SOURCES_DIR = TEST_DATA_DIR / "individual-files" + + +@pytest.fixture +def repo_mapping(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "empty"), + ): + repository_mapping = apt.RepositoryMapping() + return repository_mapping + + +def test_init_no_files(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "empty"), + ): + repository_mapping = apt.RepositoryMapping() + assert not repository_mapping._repository_map + + +def test_init_with_good_sources_list(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "bionic"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_with_bad_sources_list_no_fallback(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-no-sources"), + ): + with pytest.raises(apt.InvalidSourceError): + apt.RepositoryMapping() + + +def test_init_with_bad_sources_list_fallback_ok(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_with_bad_ubuntu_sources(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-empty-sources"), + ): + with pytest.raises(apt.InvalidSourceError): + apt.RepositoryMapping() + + +def test_init_with_third_party_inkscape_source(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-with-inkscape"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_init_w_comments(): + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-with-comments-etc"), + ): + repository_mapping = apt.RepositoryMapping() + assert repository_mapping._repository_map + + +def test_deb822_format_equivalence(): + """Mock file opening to initialise a RepositoryMapping from deb822 and one-line-style. + + They should be equivalent with the sample data being used. + """ + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble"), + ): + repos_deb822 = apt.RepositoryMapping() + + with patch.object( + apt.RepositoryMapping, + "_apt_dir", + str(FAKE_APT_DIRS / "noble-in-one-per-line-format"), + ): + repos_one_per_line = apt.RepositoryMapping() + + list_keys = sorted(repos_one_per_line._repository_map.keys()) + sources_keys = sorted(repos_deb822._repository_map.keys()) + assert sources_keys == list_keys + + for list_key, sources_key in zip(list_keys, sources_keys): + list_repo = repos_one_per_line[list_key] + sources_repo = repos_deb822[sources_key] + assert list_repo.enabled == sources_repo.enabled + assert list_repo.repotype == sources_repo.repotype + assert list_repo.uri == sources_repo.uri + assert list_repo.release == sources_repo.release + assert list_repo.groups == sources_repo.groups + assert list_repo.gpg_key == sources_repo.gpg_key + assert ( + list_repo.options # pyright: ignore[reportUnknownMemberType] + == sources_repo.options # pyright: ignore[reportUnknownMemberType] + ) + + +def test_load_deb822_missing_components(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-components-missing-without-exact-path.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.MissingRequiredKeyError) + assert error.key == "Components" + + +def test_load_deb822_components_with_exact_path(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822( + str(SOURCES_DIR / "bad-stanza-components-present-with-exact-path.sources") + ) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.BadValueError) + assert error.key == "Components" + + +def test_load_deb822_bad_enabled_value(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822(str(SOURCES_DIR / "bad-stanza-enabled-bad.sources")) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.BadValueError) + assert error.key == "Enabled" + + +def test_load_deb822_missing_required_keys(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822(str(SOURCES_DIR / "bad-stanza-missing-required-keys.sources")) + assert len(repo_mapping._last_errors) == 1 + [error] = repo_mapping._last_errors + assert isinstance(error, apt.MissingRequiredKeyError) + assert error.key in ("Types", "URIs", "Suites") + + +def test_load_deb822_comments(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-comments.sources") + repo_mapping.load_deb822(filename) + repotypes = ("deb", "deb-src") + uris = ("http://nz.archive.ubuntu.com/ubuntu/", "http://archive.ubuntu.com/ubuntu/") + suites = ("noble", "noble-updates", "noble-backports") + assert len(repo_mapping) == len(repotypes) * len(uris) * len(suites) + for repo, (repotype, uri, suite) in zip( + repo_mapping, itertools.product(repotypes, uris, suites) + ): + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == repotype + assert repo.uri == uri + assert repo.release == suite + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "" + + +def test_load_deb822_enabled_no(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-enabled-no.sources") + repo_mapping.load_deb822(filename) + for repo in repo_mapping: + assert isinstance(repo, apt.DebianRepository) + assert not repo.enabled + + +def test_load_deb822_exact_path(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-exact-path.sources") + repo_mapping.load_deb822(filename) + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + assert repo.uri + assert repo.release.endswith("/") + assert not repo.groups + + +def test_load_deb822_fully_commented_out_stanzas(repo_mapping: apt.RepositoryMapping): + with pytest.raises(apt.InvalidSourceError): + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-fully-commented-out.sources")) + assert not repo_mapping._repository_map + assert not repo_mapping._last_errors # no individual errors, just no good entries + + +def test_load_deb822_one_good_stanza_one_bad(repo_mapping: apt.RepositoryMapping): + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-one-good-one-bad-comments.sources")) + repos = repo_mapping._repository_map.values() + errors = repo_mapping._last_errors + assert len(repos) == 1 # one good stanza defines one repository + assert len(errors) == 1 # one stanza was bad + [error] = errors + assert isinstance(error, apt.MissingRequiredKeyError) + + +def test_load_deb822_ubuntu_sources(repo_mapping: apt.RepositoryMapping): + assert not repo_mapping._repository_map + repo_mapping.load_deb822(str(SOURCES_DIR / "stanzas-noble.sources")) + assert sorted(repo_mapping._repository_map.keys()) == [ + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble", + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-backports", + "deb-http://nz.archive.ubuntu.com/ubuntu/-noble-updates", + "deb-http://security.ubuntu.com/ubuntu-noble-security", + ] + assert not repo_mapping._last_errors + + +def test_load_deb822_with_gpg_key(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-with-gpg-key.sources") + repo_mapping.load_deb822(filename) + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + # DebianRepository.gpg_key is expected to be a string file path + # the inkscape sources file provides the key inline + # in this case the library imports the key to a file on first access + with tempfile.TemporaryDirectory() as tmpdir: + assert not list(Path(tmpdir).iterdir()) + with patch.object(apt, "_GPG_KEY_DIR", tmpdir): + inkscape_key_file = repo.gpg_key + key_paths = list(Path(tmpdir).iterdir()) + assert len(key_paths) == 1 + [key_path] = key_paths + assert Path(inkscape_key_file).name == key_path.name + assert Path(inkscape_key_file).parent == key_path.parent + # the filename is cached for subsequent access + with tempfile.TemporaryDirectory() as tmpdir: + assert not list(Path(tmpdir).iterdir()) + with patch.object(apt, "_GPG_KEY_DIR", tmpdir): + inkscape_key_file_cached = repo.gpg_key + assert not list(Path(tmpdir).iterdir()) + assert inkscape_key_file == inkscape_key_file_cached + + +def test_load_deb822_stanza_ubuntu_main_etc(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-noble-main-etc.sources") + repo_mapping.load_deb822(filename) + assert len(repo_mapping) == 3 + for repo, suite in zip(repo_mapping, ("noble", "noble-updates", "noble-backports")): + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == "deb" + assert repo.uri == "http://nz.archive.ubuntu.com/ubuntu/" + assert repo.release == suite + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" + + +def test_load_deb822_stanza_ubuntu_security(repo_mapping: apt.RepositoryMapping): + filename = str(SOURCES_DIR / "good-stanza-noble-security.sources") + repo_mapping.load_deb822(filename) + assert len(repo_mapping) == 1 + [repo] = repo_mapping + assert isinstance(repo, apt.DebianRepository) + assert repo.enabled + assert repo.repotype == "deb" + assert repo.uri == "http://security.ubuntu.com/ubuntu" + assert repo.release == "noble-security" + assert repo.groups == ["main", "restricted", "universe", "multiverse"] + assert repo.filename == filename + assert repo.gpg_key == "/usr/share/keyrings/ubuntu-archive-keyring.gpg" + + +def test_disable_with_deb822(repo_mapping: apt.RepositoryMapping): + repo = apt.DebianRepository( + enabled=True, + repotype="deb", + uri="http://nz.archive.ubuntu.com/ubuntu/", + release="noble", + groups=["main", "restricted"], + ) + repo._deb822_stanza = apt._Deb822Stanza(numbered_lines=[]) + with pytest.raises(NotImplementedError): + repo_mapping.disable(repo) + + +def test_add_with_deb822(repo_mapping: apt.RepositoryMapping): + with (SOURCES_DIR / "good-stanza-exact-path.sources").open() as f: + repos, errors = repo_mapping._parse_deb822_lines(f) + assert len(repos) == 1 + assert not errors + [repo] = repos + identifier = apt._repo_to_identifier(repo) + with patch.object(apt.subprocess, "run") as mock_run_1: + repo_mapping.add(repo) + assert identifier in repo_mapping + mock_run_1.assert_called_once_with( + [ + "add-apt-repository", + "--yes", + "--sourceslist=deb http://nz.archive.ubuntu.com/ubuntu/ an/exact/path/ ", + "--no-update", + ], + check=True, + capture_output=True, + ) + # we re-raise CalledProcessError after logging + error = apt.CalledProcessError(1, "cmd") + error.stdout = error.stderr = b"" + with patch.object(apt.subprocess, "run", side_effect=error): + with patch.object(apt.logger, "error") as mock_error: + with pytest.raises(apt.CalledProcessError): + repo_mapping.add(repo) + mock_error.assert_called_once() + # call add with a disabled repository + repo._enabled = False + with patch.object(apt, "_add_repository") as mock_add: + with patch.object(apt.logger, "warning") as mock_warning: + repo_mapping.add(repo) + mock_add.assert_not_called() + mock_warning.assert_called_once() diff --git a/tests/unit/test_repo.py b/tests/unit/test_repo.py index 0233518e..7b7cec1a 100644 --- a/tests/unit/test_repo.py +++ b/tests/unit/test_repo.py @@ -1,8 +1,6 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. -import os - from charms.operator_libs_linux.v0 import apt from pyfakefs.fake_filesystem_unittest import TestCase @@ -88,51 +86,22 @@ def test_can_disable_repositories(self): open(other.filename).readlines(), ) - def test_can_add_repositories(self): - r = apt.RepositoryMapping() - d = apt.DebianRepository( - True, - "deb", - "http://example.com", - "test", - ["group"], - "/etc/apt/sources.list.d/example-test.list", - ) - r.add(d, default_filename=False) - self.assertIn( - "{} {} {} {}\n".format(d.repotype, d.uri, d.release, " ".join(d.groups)), - open(d.filename).readlines(), + def test_can_create_repo_from_repo_line(self): + d = apt.DebianRepository.from_repo_line( + "deb https://example.com/foo focal bar baz", + write_file=False, ) - - def test_can_add_repositories_from_string(self): - d = apt.DebianRepository.from_repo_line("deb https://example.com/foo focal bar baz") self.assertEqual(d.enabled, True) self.assertEqual(d.repotype, "deb") self.assertEqual(d.uri, "https://example.com/foo") self.assertEqual(d.release, "focal") self.assertEqual(d.groups, ["bar", "baz"]) self.assertEqual(d.filename, "/etc/apt/sources.list.d/foo-focal.list") - self.assertIn("deb https://example.com/foo focal bar baz\n", open(d.filename).readlines()) - - def test_valid_list_file(self): - line = "deb https://repo.example.org/fiz/baz focal/foo-bar/5.0 multiverse" - d = apt.DebianRepository.from_repo_line(line) - self.assertEqual(d.filename, "/etc/apt/sources.list.d/fiz-baz-focal-foo-bar-5.0.list") - - r = apt.RepositoryMapping() - d = apt.DebianRepository( - True, - "deb", - "https://repo.example.org/fiz/baz", - "focal/foo-bar/5.0", - ["multiverse"], - ) - r.add(d, default_filename=False) - assert os.path.exists("/etc/apt/sources.list.d/fiz-baz-focal-foo-bar-5.0.list") def test_can_add_repositories_from_string_with_options(self): d = apt.DebianRepository.from_repo_line( - "deb [signed-by=/foo/gpg.key arch=amd64] https://example.com/foo focal bar baz" + "deb [signed-by=/foo/gpg.key arch=amd64] https://example.com/foo focal bar baz", + write_file=False, ) self.assertEqual(d.enabled, True) self.assertEqual(d.repotype, "deb") @@ -142,7 +111,3 @@ def test_can_add_repositories_from_string_with_options(self): self.assertEqual(d.filename, "/etc/apt/sources.list.d/foo-focal.list") self.assertEqual(d.gpg_key, "/foo/gpg.key") self.assertEqual(d.options["arch"], "amd64") - self.assertIn( - "deb [arch=amd64 signed-by=/foo/gpg.key] https://example.com/foo focal bar baz\n", - open(d.filename).readlines(), - )