Skip to content

Commit

Permalink
Merge branch 'main' into test-spread-retry-snap-install-lxd
Browse files Browse the repository at this point in the history
  • Loading branch information
mr-cal authored Nov 16, 2022
2 parents 8dae7fe + 3410375 commit c9431b4
Show file tree
Hide file tree
Showing 16 changed files with 346 additions and 18 deletions.
2 changes: 1 addition & 1 deletion schema/snapcraft.json
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@
"additionalProperties": false,
"validation-failure": "{!r} is not a valid system-username.",
"patternProperties": {
"^snap_(daemon|microk8s)$": {
"^snap_(daemon|microk8s|aziotedge|aziotdu)$": {
"oneOf": [
{
"$ref": "#/definitions/system-username-scope"
Expand Down
14 changes: 14 additions & 0 deletions snapcraft/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ def get_verbosity() -> EmitterMode:
if utils.strtobool(os.getenv("SNAPCRAFT_ENABLE_DEVELOPER_DEBUG", "n").strip()):
verbosity = EmitterMode.DEBUG

# if defined, use environmental variable SNAPCRAFT_VERBOSITY_LEVEL
verbosity_env = os.getenv("SNAPCRAFT_VERBOSITY_LEVEL")
if verbosity_env:
try:
verbosity = EmitterMode[verbosity_env.strip().upper()]
except KeyError:
values = utils.humanize_list(
[e.name.lower() for e in EmitterMode], "and", sort=False
)
raise ArgumentParsingError(
f"cannot parse verbosity level {verbosity_env!r} from environment "
f"variable SNAPCRAFT_VERBOSITY_LEVEL (valid values are {values})"
) from KeyError

return verbosity


Expand Down
16 changes: 13 additions & 3 deletions snapcraft/snap_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import pydantic
from craft_cli import emit
from snaphelpers import SnapConfigOptions
from snaphelpers import SnapConfigOptions, SnapCtlError

from snapcraft.utils import is_snapcraft_running_from_snap

Expand Down Expand Up @@ -75,8 +75,18 @@ def get_snap_config() -> Optional[SnapConfig]:
)
return None

snap_config = SnapConfigOptions(keys=["provider"])
snap_config.fetch()
try:
snap_config = SnapConfigOptions(keys=["provider"])
# even if the initialization of SnapConfigOptions succeeds, `fetch()` may
# raise the same errors since it makes calls to snapd
snap_config.fetch()
except (AttributeError, SnapCtlError) as error:
# snaphelpers raises an error (either AttributeError or SnapCtlError) when
# it fails to get the snap config. this can occur when running inside a
# docker or podman container where snapd is not available
emit.debug("Could not retrieve the snap config. Is snapd running?")
emit.trace(f"snaphelpers error: {error!r}")
return None

emit.debug(f"Retrieved snap config: {snap_config.as_dict()}")

Expand Down
12 changes: 10 additions & 2 deletions snapcraft/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,19 +290,27 @@ def prompt(prompt_text: str, *, hide: bool = False) -> str:


def humanize_list(
items: Iterable[str], conjunction: str, item_format: str = "{!r}"
items: Iterable[str],
conjunction: str,
item_format: str = "{!r}",
sort: bool = True,
) -> str:
"""Format a list into a human-readable string.
:param items: list to humanize.
:param conjunction: the conjunction used to join the final element to
the rest of the list (e.g. 'and').
:param item_format: format string to use per item.
:param sort: if true, sort the list.
"""
if not items:
return ""

quoted_items = [item_format.format(item) for item in sorted(items)]
quoted_items = [item_format.format(item) for item in items]

if sort:
quoted_items = sorted(quoted_items)

if len(quoted_items) == 1:
return quoted_items[0]

Expand Down
16 changes: 13 additions & 3 deletions snapcraft_legacy/internal/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from pathlib import Path
from typing import Callable, Dict, List, Optional, Union

from snaphelpers import SnapConfigOptions
from snaphelpers import SnapConfigOptions, SnapCtlError

from snapcraft_legacy.internal import errors

Expand Down Expand Up @@ -384,8 +384,18 @@ def get_snap_config() -> Optional[Dict[str, str]]:
)
return None

snap_config = SnapConfigOptions(keys=["provider"])
snap_config.fetch()
try:
snap_config = SnapConfigOptions(keys=["provider"])
# even if the initialization of SnapConfigOptions succeeds, `fetch()` may
# raise the same errors since it makes calls to snapd
snap_config.fetch()
except (AttributeError, SnapCtlError) as error:
# snaphelpers raises an error (either AttributeError or SnapCtlError) when
# it fails to get the snap config. this can occur when running inside a
# docker or podman container where snapd is not available
logger.debug("Could not retrieve the snap config. Is snapd running?")
logger.debug("snaphelpers error: {%r}", error)
return None

logger.debug("Retrieved snap config: %s", snap_config.as_dict())
return snap_config.as_dict()
17 changes: 16 additions & 1 deletion snapcraft_legacy/internal/repo/apt_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def _populate_stage_cache_dir(self) -> None:
return

# Copy apt configuration from host.
etc_apt_path = Path("/etc/apt")
cache_etc_apt_path = Path(self.stage_cache, "etc", "apt")

# Delete potentially outdated cache configuration.
Expand All @@ -120,7 +121,21 @@ def _populate_stage_cache_dir(self) -> None:

# Copy current cache configuration.
cache_etc_apt_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree("/etc/apt", cache_etc_apt_path)

# systems with ubuntu pro have an auth file inside the /etc/apt directory.
# this auth file is readable only by root, so the copytree call below may
# fail when attempting to copy this file into the cache directory
try:
shutil.copytree(etc_apt_path, cache_etc_apt_path)
except shutil.Error as error:
# copytree is a multi-file operation, so it generates a list of exceptions
# each exception in the list is a 3-element tuple: (source, dest, reason)
raise errors.PopulateCacheDirError(error.args[0]) from error
except PermissionError as error:
# catch the PermissionError raised when `/etc/apt` is unreadable
raise errors.PopulateCacheDirError(
[(etc_apt_path, cache_etc_apt_path, error)]
) from error

# Specify default arch (if specified).
if self.stage_cache_arch is not None:
Expand Down
23 changes: 22 additions & 1 deletion snapcraft_legacy/internal/repo/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from pathlib import Path
from typing import List, Optional, Sequence
from typing import List, Optional, Sequence, Tuple

from snapcraft_legacy import formatting_utils
from snapcraft_legacy.internal import errors
Expand Down Expand Up @@ -57,6 +57,27 @@ def __init__(self, errors: str) -> None:
super().__init__(errors=errors)


class PopulateCacheDirError(SnapcraftException):
def __init__(self, copy_errors: List[Tuple]) -> None:
""":param copy_errors: A list of tuples containing the copy errors, where each
tuple is ordered as (source, destination, reason)."""
self.copy_errors = copy_errors

def get_brief(self) -> str:
return "Could not populate apt cache directory."

def get_details(self) -> str:
"""Build a readable list of errors."""
details = ""
for error in self.copy_errors:
source, dest, reason = error
details += f"Unable to copy {source} to {dest}: {reason}\n"
return details

def get_resolution(self) -> str:
return "Verify user has read access to contents of /etc/apt."


class FileProviderNotFound(RepoError):

fmt = "{file_path} is not provided by any package."
Expand Down
20 changes: 18 additions & 2 deletions tests/legacy/unit/project/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1225,13 +1225,29 @@ def test_invalid_command_chain(data, command_chain):
assert expected_message in str(error.value)


@pytest.mark.parametrize("username", ["snap_daemon", "snap_microk8s"])
@pytest.mark.parametrize(
"username",
[
"snap_daemon",
"snap_microk8s",
"snap_aziotedge",
"snap_aziotdu",
],
)
def test_yaml_valid_system_usernames_long(data, username):
data["system-usernames"] = {username: {"scope": "shared"}}
Validator(data).validate()


@pytest.mark.parametrize("username", ["snap_daemon", "snap_microk8s"])
@pytest.mark.parametrize(
"username",
[
"snap_daemon",
"snap_microk8s",
"snap_aziotedge",
"snap_aziotdu",
],
)
def test_yaml_valid_system_usernames_short(data, username):
data["system-usernames"] = {username: "shared"}
Validator(data).validate()
Expand Down
62 changes: 62 additions & 0 deletions tests/legacy/unit/repo/test_apt_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import os
import shutil
import unittest
from pathlib import Path
from unittest.mock import call

import fixtures
import pytest
from testtools.matchers import Equals

from snapcraft_legacy.internal.repo.apt_cache import AptCache
from snapcraft_legacy.internal.repo.errors import PopulateCacheDirError
from tests.legacy import unit


Expand Down Expand Up @@ -191,3 +194,62 @@ def test_host_get_installed_version(self):
self.assertThat(
apt_cache.get_installed_version("fake-news-bears"), Equals(None)
)


def test_populate_stage_cache_dir_shutil_error(mocker, tmp_path):
"""Raise an error when the apt cache directory cannot be populated."""
mock_copytree = mocker.patch(
"snapcraft_legacy.internal.repo.apt_cache.shutil.copytree",
side_effect=shutil.Error(
[
(
"/etc/apt/source-file-1",
"/root/.cache/dest-file-1",
"[Errno 13] Permission denied: '/etc/apt/source-file-1'",
),
(
"/etc/apt/source-file-2",
"/root/.cache/dest-file-2",
"[Errno 13] Permission denied: '/etc/apt/source-file-2'",
),
]
),
)

with pytest.raises(PopulateCacheDirError) as raised:
with AptCache() as apt_cache:
# set stage_cache directory so method does not return early
apt_cache.stage_cache = tmp_path
apt_cache._populate_stage_cache_dir()

assert mock_copytree.mock_calls == [call(Path("/etc/apt"), tmp_path / "etc/apt")]

# verify the data inside the shutil error was passed to PopulateCacheDirError
assert raised.value.get_details() == (
"Unable to copy /etc/apt/source-file-1 to /root/.cache/dest-file-1: "
"[Errno 13] Permission denied: '/etc/apt/source-file-1'\n"
"Unable to copy /etc/apt/source-file-2 to /root/.cache/dest-file-2: "
"[Errno 13] Permission denied: '/etc/apt/source-file-2'\n"
)


def test_populate_stage_cache_dir_permission_error(mocker, tmp_path):
"""Raise an error when the apt cache directory cannot be populated."""
mock_copytree = mocker.patch(
"snapcraft_legacy.internal.repo.apt_cache.shutil.copytree",
side_effect=PermissionError("[Errno 13] Permission denied: '/etc/apt"),
)

with pytest.raises(PopulateCacheDirError) as raised:
with AptCache() as apt_cache:
# set stage_cache directory so method does not return early
apt_cache.stage_cache = tmp_path
apt_cache._populate_stage_cache_dir()

assert mock_copytree.mock_calls == [call(Path("/etc/apt"), tmp_path / "etc/apt")]

# verify the data inside the permission error was passed to PopulateCacheDirError
assert raised.value.get_details() == (
f"Unable to copy {Path('/etc/apt')} to {tmp_path / 'etc/apt'}: "
"[Errno 13] Permission denied: '/etc/apt\n"
)
30 changes: 30 additions & 0 deletions tests/legacy/unit/repo/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,33 @@ def test_multiple_packages_not_found_error():
assert exception.get_details() is None
assert exception.get_docs_url() is None
assert exception.get_reportable() is False


def test_snapcraft_exception_handling():
exception = errors.PopulateCacheDirError(
[
(
"/etc/apt/source-file-1",
"/root/.cache/dest-file-1",
"[Errno 13] Permission denied: '/etc/apt/source-file-1'",
),
(
"/etc/apt/source-file-2",
"/root/.cache/dest-file-2",
"[Errno 13] Permission denied: '/etc/apt/source-file-2'",
),
]
)

assert exception.get_brief() == "Could not populate apt cache directory."
assert exception.get_resolution() == (
"Verify user has read access to contents of /etc/apt."
)
assert exception.get_details() == (
"Unable to copy /etc/apt/source-file-1 to /root/.cache/dest-file-1: "
"[Errno 13] Permission denied: '/etc/apt/source-file-1'\n"
"Unable to copy /etc/apt/source-file-2 to /root/.cache/dest-file-2: "
"[Errno 13] Permission denied: '/etc/apt/source-file-2'\n"
)
assert exception.get_docs_url() is None
assert exception.get_reportable() is False
Loading

0 comments on commit c9431b4

Please sign in to comment.