Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: lock shared cache directory #1888

Merged
merged 4 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ jobs:
run: |
sudo apt update
sudo apt install -y python3-pip python3-setuptools python3-wheel python3-venv libapt-pkg-dev
- name: Setup LXD
uses: canonical/setup-lxd@v0.1.1
with:
channel: latest/stable
lengau marked this conversation as resolved.
Show resolved Hide resolved
if: ${{ runner.os == 'Linux' }}
- name: Install skopeo (mac)
# This is only necessary for Linux until skopeo >= 1.11 is in repos.
# Once we're running on Noble, we can get skopeo from apt.
Expand Down
42 changes: 40 additions & 2 deletions charmcraft/services/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@
"""Service class for creating providers."""
from __future__ import annotations

import atexit

try:
import fcntl
except ModuleNotFoundError: # Not available on Windows.
fcntl = None # type: ignore[assignment]
import os
import pathlib
from typing import cast

import craft_providers
from craft_application import services
from craft_cli import emit
from craft_providers import bases

from charmcraft import env
Expand Down Expand Up @@ -56,12 +64,42 @@ def get_base(

If no cache_path is included, adds one.
"""
cache_path = cast(pathlib.Path, kwargs.get("cache_path", env.get_host_shared_cache_path()))
our_lock = _maybe_lock_cache(cache_path)

# Forward the shared cache path.
if "cache_path" not in kwargs:
kwargs["cache_path"] = env.get_host_shared_cache_path()
kwargs["cache_path"] = cache_path if our_lock else None
return super().get_base(
base_name,
instance_name=instance_name,
# craft-application annotation is incorrect
**kwargs, # type: ignore[arg-type]
)


def _maybe_lock_cache(path: pathlib.Path) -> bool:
"""Lock the cache so we only have one copy of Charmcraft using it at a time."""
if fcntl is None: # Don't lock on Windows - just don't cache.
return False
cache_lock_path = path / "charmcraft.lock"

emit.trace("Attempting to lock the cache path")
lock_file = cache_lock_path.open("w+")
try:
# Exclusive lock, but non-blocking.
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
emit.progress(
"Shared cache locked by another process; running without cache.", permanent=True
)
return False
else:
pid = str(os.getpid())
lock_file.write(pid)
lock_file.flush()
os.fsync(lock_file.fileno())
emit.trace(f"Cache path locked by this process ({pid})")
atexit.register(fcntl.flock, lock_file, fcntl.LOCK_UN)
atexit.register(cache_lock_path.unlink, missing_ok=True)

return True
21 changes: 12 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,19 @@ def service_factory(


@pytest.fixture
def default_build_plan():
def default_build_info() -> models.BuildInfo:
arch = util.get_host_architecture()
return [
models.BuildInfo(
base=bases.BaseName("ubuntu", "22.04"),
build_on=arch,
build_for="arm64",
platform="distro-1-test64",
)
]
return models.BuildInfo(
base=bases.BaseName("ubuntu", "22.04"),
build_on=arch,
build_for="arm64",
platform="distro-1-test64",
)


@pytest.fixture
def default_build_plan(default_build_info: models.BuildInfo):
return [default_build_info]


@pytest.fixture
Expand Down
17 changes: 2 additions & 15 deletions tests/integration/services/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,18 @@
#
# For further info, check https://github.com/canonical/charmcraft
"""Configuration for services integration tests."""
import contextlib
import sys

import pyfakefs.fake_filesystem
import pytest

from charmcraft import services
from charmcraft.application.main import APP_METADATA, Charmcraft


@pytest.fixture
def service_factory(
fs: pyfakefs.fake_filesystem.FakeFilesystem, fake_path, simple_charm
) -> services.CharmcraftServiceFactory:
fake_project_dir = fake_path / "project"
def service_factory(simple_charm, new_path) -> services.CharmcraftServiceFactory:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stops using the fake filesystem and uses the real FS. I think it was a mistake for me to use pyfakefs for integration tests.

fake_project_dir = new_path / "project"
fake_project_dir.mkdir()

# Allow access to the real venv library path.
# This is necessary because certifi lazy-loads the certificate file.
for python_path in sys.path:
if not python_path:
continue
with contextlib.suppress(OSError):
fs.add_real_directory(python_path)

factory = services.CharmcraftServiceFactory(app=APP_METADATA)

app = Charmcraft(app=APP_METADATA, services=factory)
Expand Down
37 changes: 15 additions & 22 deletions tests/integration/services/test_package.py
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes are to deal with the change from using pyfakefs to using a real path.

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
#
# For further info, check https://github.com/canonical/charmcraft
"""Tests for package service."""
import contextlib
import datetime
import pathlib

Expand All @@ -28,8 +27,8 @@


@pytest.fixture
def package_service(fake_path, service_factory, default_build_plan):
fake_project_dir = fake_path
def package_service(new_path: pathlib.Path, service_factory, default_build_plan):
fake_project_dir = new_path
svc = services.PackageService(
app=APP_METADATA,
project=service_factory.project,
Expand All @@ -49,12 +48,10 @@ def package_service(fake_path, service_factory, default_build_plan):
],
)
@freezegun.freeze_time(datetime.datetime(2020, 3, 14, 0, 0, 0, tzinfo=datetime.timezone.utc))
def test_write_metadata(monkeypatch, fs, package_service, project_path):
def test_write_metadata(monkeypatch, new_path, package_service, project_path):
monkeypatch.setattr(charmcraft, "__version__", "3.0-test-version")
with contextlib.suppress(FileExistsError):
fs.add_real_directory(project_path)
test_prime_dir = pathlib.Path("/prime")
fs.create_dir(test_prime_dir)
test_prime_dir = new_path / "prime"
test_prime_dir.mkdir()
expected_prime_dir = project_path / "prime"

project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml")
Expand All @@ -75,23 +72,21 @@ def test_write_metadata(monkeypatch, fs, package_service, project_path):
],
)
@freezegun.freeze_time(datetime.datetime(2020, 3, 14, 0, 0, 0, tzinfo=datetime.timezone.utc))
def test_overwrite_metadata(monkeypatch, fs, package_service, project_path):
def test_overwrite_metadata(monkeypatch, new_path, package_service, project_path):
"""Test that the metadata file gets rewritten for a charm.

Regression test for https://github.com/canonical/charmcraft/issues/1654
"""
monkeypatch.setattr(charmcraft, "__version__", "3.0-test-version")
with contextlib.suppress(FileExistsError):
fs.add_real_directory(project_path)
test_prime_dir = pathlib.Path("/prime")
fs.create_dir(test_prime_dir)
test_prime_dir = new_path / "prime"
test_prime_dir.mkdir()
expected_prime_dir = project_path / "prime"

project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml")
project._started_at = datetime.datetime.now(tz=datetime.timezone.utc)
package_service._project = project

fs.create_file(test_prime_dir / const.METADATA_FILENAME, contents="INVALID!!")
(test_prime_dir / const.METADATA_FILENAME).write_text("INVALID!!")

package_service.write_metadata(test_prime_dir)

Expand All @@ -100,20 +95,18 @@ def test_overwrite_metadata(monkeypatch, fs, package_service, project_path):


@freezegun.freeze_time(datetime.datetime(2020, 3, 14, 0, 0, 0, tzinfo=datetime.timezone.utc))
def test_no_overwrite_reactive_metadata(monkeypatch, fs, package_service):
def test_no_overwrite_reactive_metadata(monkeypatch, new_path, package_service):
"""Test that the metadata file doesn't get overwritten for a reactive charm..

Regression test for https://github.com/canonical/charmcraft/issues/1654
"""
monkeypatch.setattr(charmcraft, "__version__", "3.0-test-version")
project_path = pathlib.Path(__file__).parent / "sample_projects" / "basic-reactive"
with contextlib.suppress(FileExistsError):
fs.add_real_directory(project_path)
test_prime_dir = pathlib.Path("/prime")
fs.create_dir(test_prime_dir)
test_stage_dir = pathlib.Path("/stage")
fs.create_dir(test_stage_dir)
fs.create_file(test_stage_dir / const.METADATA_FILENAME, contents="INVALID!!")
test_prime_dir = new_path / "prime"
test_prime_dir.mkdir()
test_stage_dir = new_path / "stage"
test_stage_dir.mkdir()
(test_stage_dir / const.METADATA_FILENAME).write_text("INVALID!!")

project = models.CharmcraftProject.from_yaml_file(project_path / "project" / "charmcraft.yaml")
project._started_at = datetime.datetime.now(tz=datetime.timezone.utc)
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/services/test_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# For further info, check https://github.com/canonical/charmcraft
"""Integration tests for the provider service."""

import pathlib
import sys

import pytest
from craft_application.models import BuildInfo
from craft_cli.pytest_plugin import RecordingEmitter

from charmcraft import services
from charmcraft.services.provider import _maybe_lock_cache


@pytest.mark.skipif(sys.platform == "win32", reason="no cache on windows")
def test_locks_cache(
service_factory: services.CharmcraftServiceFactory,
tmp_path: pathlib.Path,
default_build_info: BuildInfo,
emitter: RecordingEmitter,
):
_maybe_lock_cache(tmp_path)
assert (tmp_path / "charmcraft.lock").exists()
provider = service_factory.provider
provider_kwargs = {
"build_info": default_build_info,
"work_dir": pathlib.Path(__file__).parent,
"cache_path": tmp_path,
}

with provider.instance(**provider_kwargs) as instance:
# Because we've already locked the cache, we shouldn't see the lockfile.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused here - why isn't the cache visible? Did the call to _maybe_lock_cache() inside provider.instance() fall into the except OSError: codepath?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because we run _maybe_lock_cache on line 36. Looking back I realise I named the function for the success case and tested for the failure case. I'll fix that

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is surprising to me because I thought flock() was per-process, so a process trying to flock a file it already holds would be a noop
If this isn't the case, what happens if we do multiple consecutive managed builds? Like a build plan with multiple entries

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right! I wasn't checking for that properly, either. However, it's not quite per-process either. It's per file descriptor, so the same process can't lock the same path in two places if it's got the file open separately twice. (This also requires making two separate Path objects, as pathlib will cache the file descriptor for the same object, as I learnt the hard way.)

I've updated it now with a much better lock and test.

lock_test = instance.execute_run(["test", "-f", "/root/.cache/charmcraft.lock"])
assert lock_test.returncode == 1

# Create a file in the cache and ensure it's not visible in the outer fs
instance.execute_run(["touch", "/root/.cache/cache_cached"])
assert not (tmp_path / "cache_cached").exists()
Loading
Loading