mirror of
				https://github.com/arcan1s/ahriman.git
				synced 2025-10-31 13:53:41 +00:00 
			
		
		
		
	implement atomic_move method, move files only with lock
This commit is contained in:
		| @ -165,6 +165,11 @@ Again, the most checks can be performed by `tox` command, though some additional | ||||
|  | ||||
|     # Blank line again and package imports | ||||
|     from ahriman.core.configuration import Configuration | ||||
|     # Multiline import example | ||||
|     from ahriman.core.database.operations import ( | ||||
|         AuthOperations, | ||||
|         BuildOperations, | ||||
|     ) | ||||
|     ``` | ||||
|  | ||||
| * One file should define only one class, exception is class satellites in case if file length remains less than 400 lines. | ||||
|  | ||||
| @ -25,8 +25,16 @@ from typing import Self | ||||
|  | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.database.migrations import Migrations | ||||
| from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \ | ||||
|     DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations | ||||
| from ahriman.core.database.operations import ( | ||||
|     AuthOperations, | ||||
|     BuildOperations, | ||||
|     ChangesOperations, | ||||
|     DependenciesOperations, | ||||
|     EventOperations, | ||||
|     LogsOperations, | ||||
|     PackageOperations, | ||||
|     PatchOperations, | ||||
| ) | ||||
| from ahriman.models.repository_id import RepositoryId | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -17,8 +17,6 @@ | ||||
| # You should have received a copy of the GNU General Public License | ||||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
| # | ||||
| import shutil  # shutil.move is used here to ensure cross fs file movement | ||||
|  | ||||
| from collections.abc import Iterable | ||||
| from pathlib import Path | ||||
| from tempfile import TemporaryDirectory | ||||
| @ -27,7 +25,7 @@ from ahriman.core.build_tools.package_archive import PackageArchive | ||||
| from ahriman.core.build_tools.task import Task | ||||
| from ahriman.core.repository.cleaner import Cleaner | ||||
| from ahriman.core.repository.package_info import PackageInfo | ||||
| from ahriman.core.utils import safe_filename | ||||
| from ahriman.core.utils import atomic_move, safe_filename | ||||
| from ahriman.models.changes import Changes | ||||
| from ahriman.models.event import EventType | ||||
| from ahriman.models.package import Package | ||||
| @ -54,7 +52,7 @@ class Executor(PackageInfo, Cleaner): | ||||
|             return  # suppress type checking, it never can be none actually | ||||
|  | ||||
|         if (safe := safe_filename(description.filename)) != description.filename: | ||||
|             (self.paths.packages / description.filename).rename(self.paths.packages / safe) | ||||
|             atomic_move(self.paths.packages / description.filename, self.paths.packages / safe) | ||||
|             description.filename = safe | ||||
|  | ||||
|     def _package_build(self, package: Package, path: Path, packager: str | None, | ||||
| @ -81,7 +79,7 @@ class Executor(PackageInfo, Cleaner): | ||||
|         package.with_packages(built, self.pacman) | ||||
|         for src in built: | ||||
|             dst = self.paths.packages / src.name | ||||
|             shutil.move(src, dst) | ||||
|             atomic_move(src, dst) | ||||
|  | ||||
|         return commit_sha | ||||
|  | ||||
| @ -130,7 +128,7 @@ class Executor(PackageInfo, Cleaner): | ||||
|  | ||||
|         for src in files: | ||||
|             dst = self.paths.archive_for(package_base) / src.name | ||||
|             src.rename(dst)  # move package to archive directory | ||||
|             atomic_move(src, dst)  # move package to archive directory | ||||
|             if not (symlink := self.paths.repository / dst.name).exists(): | ||||
|                 symlink.symlink_to(dst.relative_to(symlink.parent, walk_up=True))  # create link to archive | ||||
|  | ||||
|  | ||||
| @ -19,12 +19,14 @@ | ||||
| # | ||||
| # pylint: disable=too-many-lines | ||||
| import datetime | ||||
| import fcntl | ||||
| import io | ||||
| import itertools | ||||
| import logging | ||||
| import os | ||||
| import re | ||||
| import selectors | ||||
| import shutil | ||||
| import subprocess | ||||
|  | ||||
| from collections.abc import Callable, Generator, Iterable, Mapping | ||||
| @ -39,6 +41,7 @@ from ahriman.models.repository_paths import RepositoryPaths | ||||
|  | ||||
|  | ||||
| __all__ = [ | ||||
|     "atomic_move", | ||||
|     "check_output", | ||||
|     "check_user", | ||||
|     "dataclass_view", | ||||
| @ -65,6 +68,34 @@ __all__ = [ | ||||
| T = TypeVar("T") | ||||
|  | ||||
|  | ||||
| def atomic_move(src: Path, dst: Path) -> None: | ||||
|     """ | ||||
|     move file from ``source`` location to ``destination``. This method uses lock and :func:`shutil.move` to ensure that | ||||
|     file will be copied (if not rename) atomically. This method blocks execution until lock is available | ||||
|  | ||||
|     Args: | ||||
|         src(Path): path to the source file | ||||
|         dst(Path): path to the destination | ||||
|  | ||||
|     Examples: | ||||
|         This method is a drop-in replacement for :func:`shutil.move` (except it doesn't allow to override copy method) | ||||
|         which first locking destination file. To use it simply call method with arguments:: | ||||
|  | ||||
|             >>> atomic_move(src, dst) | ||||
|     """ | ||||
|     lock_path = dst.with_name(f".{dst.name}") | ||||
|     try: | ||||
|         with lock_path.open("ab") as lock_file: | ||||
|             fd = lock_file.fileno() | ||||
|             try: | ||||
|                 fcntl.flock(fd, fcntl.LOCK_EX)  # lock file and wait lock is until available | ||||
|                 shutil.move(src, dst) | ||||
|             finally: | ||||
|                 fcntl.flock(fd, fcntl.LOCK_UN)  # unlock file first | ||||
|     finally: | ||||
|         lock_path.unlink(missing_ok=True)  # remove lock file at the end | ||||
|  | ||||
|  | ||||
| # pylint: disable=too-many-locals | ||||
| def check_output(*args: str, exception: Exception | Callable[[int, list[str], str, str], Exception] | None = None, | ||||
|                  cwd: Path | None = None, input_data: str | None = None, | ||||
|  | ||||
| @ -21,8 +21,18 @@ import aiohttp_jinja2 | ||||
| import logging | ||||
|  | ||||
| from aiohttp.typedefs import Middleware | ||||
| from aiohttp.web import HTTPClientError, HTTPException, HTTPMethodNotAllowed, HTTPNoContent, HTTPServerError, \ | ||||
|     HTTPUnauthorized, Request, StreamResponse, json_response, middleware | ||||
| from aiohttp.web import ( | ||||
|     HTTPClientError, | ||||
|     HTTPException, | ||||
|     HTTPMethodNotAllowed, | ||||
|     HTTPNoContent, | ||||
|     HTTPServerError, | ||||
|     HTTPUnauthorized, | ||||
|     Request, | ||||
|     StreamResponse, | ||||
|     json_response, | ||||
|     middleware, | ||||
| ) | ||||
|  | ||||
| from ahriman.web.middlewares import HandlerType | ||||
|  | ||||
|  | ||||
| @ -25,8 +25,12 @@ from ahriman.models.build_status import BuildStatusEnum | ||||
| from ahriman.models.package import Package | ||||
| from ahriman.models.user_access import UserAccess | ||||
| from ahriman.web.apispec.decorators import apidocs | ||||
| from ahriman.web.schemas import PackageNameSchema, PackageStatusSchema, PackageStatusSimplifiedSchema, \ | ||||
|     RepositoryIdSchema | ||||
| from ahriman.web.schemas import ( | ||||
|     PackageNameSchema, | ||||
|     PackageStatusSchema, | ||||
|     PackageStatusSimplifiedSchema, | ||||
|     RepositoryIdSchema, | ||||
| ) | ||||
| from ahriman.web.views.base import BaseView | ||||
| from ahriman.web.views.status_view_guard import StatusViewGuard | ||||
|  | ||||
|  | ||||
| @ -26,6 +26,7 @@ from tempfile import NamedTemporaryFile | ||||
| from typing import ClassVar | ||||
|  | ||||
| from ahriman.core.configuration import Configuration | ||||
| from ahriman.core.utils import atomic_move | ||||
| from ahriman.models.repository_paths import RepositoryPaths | ||||
| from ahriman.models.user_access import UserAccess | ||||
| from ahriman.web.apispec.decorators import apidocs | ||||
| @ -152,10 +153,8 @@ class UploadView(BaseView): | ||||
|  | ||||
|             files.append(await self.save_file(part, target, max_body_size=max_body_size)) | ||||
|  | ||||
|         # and now we can rename files, which is relatively fast operation | ||||
|         # it is probably good way to call lock here, however | ||||
|         for filename, current_location in files: | ||||
|             target_location = current_location.parent / filename | ||||
|             current_location.rename(target_location) | ||||
|             atomic_move(current_location, target_location) | ||||
|  | ||||
|         raise HTTPCreated | ||||
|  | ||||
| @ -20,10 +20,10 @@ def test_archive_rename(executor: Executor, package_ahriman: Package, mocker: Mo | ||||
|     path = "gconf-3.2.6+11+g07808097-10-x86_64.pkg.tar.zst" | ||||
|     safe_path = "gconf-3.2.6-11-g07808097-10-x86_64.pkg.tar.zst" | ||||
|     package_ahriman.packages[package_ahriman.base].filename = path | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|  | ||||
|     executor._archive_rename(package_ahriman.packages[package_ahriman.base], package_ahriman.base) | ||||
|     rename_mock.assert_called_once_with(executor.paths.packages / safe_path) | ||||
|     rename_mock.assert_called_once_with(executor.paths.packages / path, executor.paths.packages / safe_path) | ||||
|     assert package_ahriman.packages[package_ahriman.base].filename == safe_path | ||||
|  | ||||
|  | ||||
| @ -32,7 +32,7 @@ def test_archive_rename_empty_filename(executor: Executor, package_ahriman: Pack | ||||
|     must skip renaming if filename is not set | ||||
|     """ | ||||
|     package_ahriman.packages[package_ahriman.base].filename = None | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|  | ||||
|     executor._archive_rename(package_ahriman.packages[package_ahriman.base], package_ahriman.base) | ||||
|     rename_mock.assert_not_called() | ||||
| @ -46,13 +46,13 @@ def test_package_build(executor: Executor, package_ahriman: Package, mocker: Moc | ||||
|     status_client_mock = mocker.patch("ahriman.core.status.Client.set_building") | ||||
|     init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init", return_value="sha") | ||||
|     with_packages_mock = mocker.patch("ahriman.models.package.Package.with_packages") | ||||
|     move_mock = mocker.patch("shutil.move") | ||||
|     rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|  | ||||
|     assert executor._package_build(package_ahriman, Path("local"), "packager", None) == "sha" | ||||
|     status_client_mock.assert_called_once_with(package_ahriman.base) | ||||
|     init_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), None) | ||||
|     with_packages_mock.assert_called_once_with([Path(package_ahriman.base)], executor.pacman) | ||||
|     move_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base) | ||||
|     rename_mock.assert_called_once_with(Path(package_ahriman.base), executor.paths.packages / package_ahriman.base) | ||||
|  | ||||
|  | ||||
| def test_package_remove(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: | ||||
| @ -94,7 +94,7 @@ def test_package_update(executor: Executor, package_ahriman: Package, user: User | ||||
|     """ | ||||
|     must update built package in repository | ||||
|     """ | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|     symlink_mock = mocker.patch("pathlib.Path.symlink_to") | ||||
|     repo_add_mock = mocker.patch("ahriman.core.alpm.repo.Repo.add") | ||||
|     sign_package_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_package", side_effect=lambda fn, _: [fn]) | ||||
| @ -102,7 +102,8 @@ def test_package_update(executor: Executor, package_ahriman: Package, user: User | ||||
|  | ||||
|     executor._package_update(filepath, package_ahriman.base, user.key) | ||||
|     # must move files (once) | ||||
|     rename_mock.assert_called_once_with(executor.paths.archive_for(package_ahriman.base) / filepath) | ||||
|     rename_mock.assert_called_once_with( | ||||
|         executor.paths.packages / filepath, executor.paths.archive_for(package_ahriman.base) / filepath) | ||||
|     # must sign package | ||||
|     sign_package_mock.assert_called_once_with(executor.paths.packages / filepath, user.key) | ||||
|     # symlink to the archive | ||||
| @ -122,7 +123,7 @@ def test_package_update_empty_filename(executor: Executor, package_ahriman: Pack | ||||
|     """ | ||||
|     must skip update for package which does not have path | ||||
|     """ | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|     executor._package_update(None, package_ahriman.base, None) | ||||
|     rename_mock.assert_not_called() | ||||
|  | ||||
| @ -155,7 +156,7 @@ def test_process_build_bump_pkgrel(executor: Executor, package_ahriman: Package, | ||||
|     """ | ||||
|     mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) | ||||
|     mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)]) | ||||
|     mocker.patch("shutil.move") | ||||
|     mocker.patch("ahriman.core.repository.executor.atomic_move") | ||||
|     init_mock = mocker.patch("ahriman.core.build_tools.task.Task.init") | ||||
|  | ||||
|     executor.process_build([package_ahriman], Packagers("packager"), bump_pkgrel=True) | ||||
| @ -172,7 +173,7 @@ def test_process_build_failure(executor: Executor, package_ahriman: Package, moc | ||||
|     mocker.patch("ahriman.core.repository.executor.Executor.packages_built") | ||||
|     mocker.patch("ahriman.core.build_tools.task.Task.build", return_value=[Path(package_ahriman.base)]) | ||||
|     mocker.patch("ahriman.core.build_tools.task.Task.init") | ||||
|     mocker.patch("shutil.move", side_effect=Exception) | ||||
|     mocker.patch("ahriman.core.repository.executor.atomic_move", side_effect=Exception) | ||||
|     status_client_mock = mocker.patch("ahriman.core.status.Client.set_failed") | ||||
|  | ||||
|     executor.process_build([package_ahriman]) | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import datetime | ||||
| import fcntl | ||||
| import logging | ||||
| import os | ||||
| import pytest | ||||
| @ -9,15 +10,82 @@ from typing import Any | ||||
| from unittest.mock import call as MockCall | ||||
|  | ||||
| from ahriman.core.exceptions import BuildError, CalledProcessError, OptionError, UnsafeRunError | ||||
| from ahriman.core.utils import check_output, check_user, dataclass_view, enum_values, extract_user, filter_json, \ | ||||
|     full_version, minmax, package_like, parse_version, partition, pretty_datetime, pretty_interval, pretty_size, \ | ||||
|     safe_filename, srcinfo_property, srcinfo_property_list, trim_package, utcnow, walk | ||||
| from ahriman.core.utils import ( | ||||
|     atomic_move, | ||||
|     check_output, | ||||
|     check_user, | ||||
|     dataclass_view, | ||||
|     enum_values, | ||||
|     extract_user, | ||||
|     filter_json, | ||||
|     full_version, | ||||
|     minmax, | ||||
|     package_like, | ||||
|     parse_version, | ||||
|     partition, | ||||
|     pretty_datetime, | ||||
|     pretty_interval, | ||||
|     pretty_size, | ||||
|     safe_filename, | ||||
|     srcinfo_property, | ||||
|     srcinfo_property_list, | ||||
|     trim_package, | ||||
|     utcnow, | ||||
|     walk, | ||||
| ) | ||||
| from ahriman.models.package import Package | ||||
| from ahriman.models.package_source import PackageSource | ||||
| from ahriman.models.repository_id import RepositoryId | ||||
| from ahriman.models.repository_paths import RepositoryPaths | ||||
|  | ||||
|  | ||||
| def test_atomic_move(mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must move file with locking | ||||
|     """ | ||||
|     lock_mock = mocker.patch("fcntl.flock") | ||||
|     open_mock = mocker.patch("pathlib.Path.open", autospec=True) | ||||
|     move_mock = mocker.patch("shutil.move") | ||||
|     unlink_mock = mocker.patch("pathlib.Path.unlink") | ||||
|  | ||||
|     atomic_move(Path("source"), Path("destination")) | ||||
|     open_mock.assert_called_once_with(Path(".destination"), "ab") | ||||
|     lock_mock.assert_has_calls([ | ||||
|         MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_EX), | ||||
|         MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_UN), | ||||
|     ]) | ||||
|     move_mock.assert_called_once_with(Path("source"), Path("destination")) | ||||
|     unlink_mock.assert_called_once_with(missing_ok=True) | ||||
|  | ||||
|  | ||||
| def test_atomic_move_remove_lock(mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must remove lock file in case of exception | ||||
|     """ | ||||
|     mocker.patch("pathlib.Path.open", side_effect=Exception) | ||||
|     unlink_mock = mocker.patch("pathlib.Path.unlink") | ||||
|  | ||||
|     with pytest.raises(Exception): | ||||
|         atomic_move(Path("source"), Path("destination")) | ||||
|     unlink_mock.assert_called_once_with(missing_ok=True) | ||||
|  | ||||
|  | ||||
| def test_atomic_move_unlock(mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must unlock file in case of exception | ||||
|     """ | ||||
|     mocker.patch("pathlib.Path.open") | ||||
|     mocker.patch("shutil.move", side_effect=Exception) | ||||
|     lock_mock = mocker.patch("fcntl.flock") | ||||
|  | ||||
|     with pytest.raises(Exception): | ||||
|         atomic_move(Path("source"), Path("destination")) | ||||
|     lock_mock.assert_has_calls([ | ||||
|         MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_EX), | ||||
|         MockCall(pytest.helpers.anyvar(int), fcntl.LOCK_UN), | ||||
|     ]) | ||||
|  | ||||
|  | ||||
| def test_check_output(mocker: MockerFixture) -> None: | ||||
|     """ | ||||
|     must run command and log result | ||||
|  | ||||
| @ -109,7 +109,7 @@ async def test_post(client: TestClient, repository_paths: RepositoryPaths, mocke | ||||
|     local = Path("local") | ||||
|     save_mock = pytest.helpers.patch_view(client.app, "save_file", | ||||
|                                           AsyncMock(return_value=("filename", local / ".filename"))) | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.web.views.v1.service.upload.atomic_move") | ||||
|     # no content validation here because it has invalid schema | ||||
|  | ||||
|     data = FormData() | ||||
| @ -118,7 +118,7 @@ async def test_post(client: TestClient, repository_paths: RepositoryPaths, mocke | ||||
|     response = await client.post("/api/v1/service/upload", data=data) | ||||
|     assert response.ok | ||||
|     save_mock.assert_called_once_with(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None) | ||||
|     rename_mock.assert_called_once_with(local / "filename") | ||||
|     rename_mock.assert_called_once_with(local / ".filename", local / "filename") | ||||
|  | ||||
|  | ||||
| async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: | ||||
| @ -131,7 +131,7 @@ async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPat | ||||
|                                               ("filename", local / ".filename"), | ||||
|                                               ("filename.sig", local / ".filename.sig"), | ||||
|                                           ])) | ||||
|     rename_mock = mocker.patch("pathlib.Path.rename") | ||||
|     rename_mock = mocker.patch("ahriman.web.views.v1.service.upload.atomic_move") | ||||
|     # no content validation here because it has invalid schema | ||||
|  | ||||
|     data = FormData() | ||||
| @ -145,8 +145,8 @@ async def test_post_with_sig(client: TestClient, repository_paths: RepositoryPat | ||||
|         MockCall(pytest.helpers.anyvar(int), repository_paths.packages, max_body_size=None), | ||||
|     ]) | ||||
|     rename_mock.assert_has_calls([ | ||||
|         MockCall(local / "filename"), | ||||
|         MockCall(local / "filename.sig"), | ||||
|         MockCall(local / ".filename", local / "filename"), | ||||
|         MockCall(local / ".filename.sig", local / "filename.sig"), | ||||
|     ]) | ||||
|  | ||||
|  | ||||
|  | ||||
		Reference in New Issue
	
	Block a user