Skip to content
Draft
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
51 changes: 32 additions & 19 deletions charmcraft/services/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
import craft_platforms
import craft_providers
from craft_application import services
from craft_cli import emit
from craft_cli import CraftError, emit
from craft_providers import bases
from packaging.version import InvalidVersion
from typing_extensions import override

from charmcraft import env
Expand Down Expand Up @@ -114,31 +115,43 @@ def instance(
**kwargs: bool | str | None,
) -> Generator[craft_providers.Executor, None, None]:
"""Instance override for Charmcraft."""
with super().instance(
ctx = super().instance(
build_info,
work_dir=work_dir,
allow_unstable=allow_unstable,
clean_existing=clean_existing,
prepare_instance=prepare_instance,
project_name=project_name,
**kwargs, # type: ignore[arg-type]
) as instance:
try:
instance.execute_run(["chmod", "a+rwx", "/tmp/craft-state"])
# Use /root/.cache even if we're in the snap.
instance.execute_run(
["rm", "-rf", "/root/snap/charmcraft/common/cache"], check=True
)
instance.execute_run(["mkdir", "-p", "/root/.cache"], check=True)
instance.execute_run(
["ln", "-s", "/root/.cache", "/root/snap/charmcraft/common/cache"],
check=True,
)
yield instance
finally:
if self._lock:
fcntl.flock(self._lock, fcntl.LOCK_UN)
self._lock.close()
)
try:
with ctx as instance:
try:
instance.execute_run(["chmod", "a+rwx", "/tmp/craft-state"])
# Use /root/.cache even if we're in the snap.
instance.execute_run(
["rm", "-rf", "/root/snap/charmcraft/common/cache"], check=True
)
instance.execute_run(["mkdir", "-p", "/root/.cache"], check=True)
instance.execute_run(
[
"ln",
"-s",
"/root/.cache",
"/root/snap/charmcraft/common/cache",
],
check=True,
)
yield instance
finally:
if self._lock:
fcntl.flock(self._lock, fcntl.LOCK_UN)
self._lock.close()
except InvalidVersion as exc:
raise CraftError(
f"Invalid version for Multipass: {exc}. "
"Please install a stable release of Multipass."
) from exc


def _maybe_lock_cache(path: pathlib.Path) -> io.TextIOBase | None:
Expand Down
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ dependencies = [
"distro>=1.7.0",
"docker>=7.0.0",
"humanize>=2.6.0",
"jsonschema~=4.0",
"jinja2",
"pydantic~=2.0",
"python-dateutil",
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/services/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
# For further info, check https://github.com/canonical/charmcraft
"""Unit tests for the provider service."""

import contextlib
import functools
import pathlib
from collections.abc import Iterator
from unittest import mock

import craft_application
import craft_platforms
import pytest
from craft_cli import CraftError
from craft_cli.pytest_plugin import RecordingEmitter
from craft_providers import bases
from packaging.version import InvalidVersion

from charmcraft.application.main import APP_METADATA
from charmcraft.services.provider import ProviderService, _maybe_lock_cache
Expand Down Expand Up @@ -145,3 +149,34 @@ def test_maybe_lock_cache_with_another_lock(tmp_path: pathlib.Path) -> None:
first_file_descriptor = _maybe_lock_cache(tmp_path)
assert first_file_descriptor
assert _maybe_lock_cache(tmp_path) is None


def test_instance_handles_invalid_multipass_version(
provider_service: ProviderService,
fake_path: pathlib.Path,
default_build_info: craft_platforms.BuildInfo,
) -> None:
# Reproducer for https://github.com/canonical/charmcraft/issues/1917
# When the installed multipass has a dev/pre-release version string like
# 1.15.0-dev.2929.pr661+gc67ef6641.mac, craft_providers raises InvalidVersion.
# Charmcraft's provider.instance() should handle this gracefully (e.g. by
# converting it to a CraftError) instead of crashing with an internal error.

@contextlib.contextmanager
def _raise_invalid_version(*args, **kwargs):
raise InvalidVersion("Invalid version: '1.15.0-dev.2929.pr661'")
yield # required for @contextlib.contextmanager

with mock.patch.object(
craft_application.services.ProviderService,
"instance",
_raise_invalid_version,
):
# InvalidVersion should be caught and converted to a user-friendly CraftError,
# not propagate as an unhandled internal error.
with pytest.raises(CraftError):
with provider_service.instance(
build_info=default_build_info,
work_dir=fake_path,
):
pass
Loading