diff --git a/swh/fuse/cache.py b/swh/fuse/cache.py --- a/swh/fuse/cache.py +++ b/swh/fuse/cache.py @@ -187,6 +187,12 @@ ) await self.conn.commit() + async def remove(self, swhid: SWHID) -> None: + await self.conn.execute( + "delete from metadata_cache where swhid=?", (str(swhid),), + ) + await self.conn.commit() + class BlobCache(AbstractCache): """ The blob cache map SWHIDs of type `cnt` to the bytes of their archived @@ -227,6 +233,12 @@ ) await self.conn.commit() + async def remove(self, swhid: SWHID) -> None: + await self.conn.execute( + "delete from blob_cache where swhid=?", (str(swhid),), + ) + await self.conn.commit() + class HistoryCache(AbstractCache): """ The history cache map SWHIDs of type `rev` to a list of `rev` SWHIDs @@ -374,7 +386,7 @@ return self.lru_cache.get(direntry.inode, None) def set(self, direntry: FuseDirEntry, entries: List[FuseEntry]) -> None: - if isinstance(direntry, (CacheDir, OriginDir)): + if isinstance(direntry, (CacheDir, CacheDir.ArtifactShardBySwhid, OriginDir)): # The `cache/` and `origin/` directories are populated on the fly pass elif ( diff --git a/swh/fuse/fs/entry.py b/swh/fuse/fs/entry.py --- a/swh/fuse/fs/entry.py +++ b/swh/fuse/fs/entry.py @@ -26,7 +26,11 @@ RDONLY_FILE = S_IFREG | 0o444 RDONLY_DIR = S_IFDIR | 0o555 - SYMLINK = S_IFLNK | 0o444 + RDONLY_LNK = S_IFLNK | 0o444 + + # `cache/` sub-directories need the write permission in order to invalidate + # cached artifacts using `rm {SWHID}` + RDWR_DIR = S_IFDIR | 0o755 @dataclass @@ -57,6 +61,9 @@ raise NotImplementedError + async def unlink(self, name: str) -> None: + raise NotImplementedError + def get_relative_root_path(self) -> str: return "../" * (self.depth - 1) @@ -132,7 +139,7 @@ target: path to symlink target """ - mode: int = field(init=False, default=int(EntryMode.SYMLINK)) + mode: int = field(init=False, default=int(EntryMode.RDONLY_LNK)) target: Union[str, bytes, Path] async def size(self) -> int: diff --git a/swh/fuse/fs/mountpoint.py b/swh/fuse/fs/mountpoint.py --- a/swh/fuse/fs/mountpoint.py +++ b/swh/fuse/fs/mountpoint.py @@ -176,6 +176,16 @@ target=Path(root_path, f"archive/{swhid}{JSON_SUFFIX}"), ) + async def unlink(self, name: str) -> None: + try: + if name.endswith(JSON_SUFFIX): + name = name[: -len(JSON_SUFFIX)] + swhid = parse_swhid(name) + await self.fuse.cache.metadata.remove(swhid) + await self.fuse.cache.blob.remove(swhid) + except ValidationError: + raise + async def compute_entries(self) -> AsyncIterator[FuseEntry]: prefixes = set() async for swhid in self.fuse.cache.get_cached_swhids(): @@ -185,7 +195,7 @@ yield self.create_child( CacheDir.ArtifactShardBySwhid, name=prefix, - mode=int(EntryMode.RDONLY_DIR), + mode=int(EntryMode.RDWR_DIR), prefix=prefix, ) diff --git a/swh/fuse/fuse.py b/swh/fuse/fuse.py --- a/swh/fuse/fuse.py +++ b/swh/fuse/fuse.py @@ -309,6 +309,26 @@ assert isinstance(entry, FuseSymlinkEntry) return os.fsencode(entry.get_target()) + async def unlink( + self, parent_inode: int, name: str, _ctx: pyfuse3.RequestContext + ) -> None: + """ Remove a file """ + + name = os.fsdecode(name) + parent_entry = self.inode2entry(parent_inode) + self.logger.debug( + "unlink(parent_name=%s, parent_inode=%d, name=%s)", + parent_entry.name, + parent_inode, + name, + ) + + try: + await parent_entry.unlink(name) + except Exception as err: + self.logger.exception("Cannot unlink: %s", err) + raise pyfuse3.FUSEError(errno.ENOENT) + async def main(swhids: List[SWHID], root_path: Path, conf: Dict[str, Any]) -> None: """ swh-fuse CLI entry-point """ diff --git a/swh/fuse/tests/test_cache.py b/swh/fuse/tests/test_cache.py new file mode 100644 --- /dev/null +++ b/swh/fuse/tests/test_cache.py @@ -0,0 +1,33 @@ +# Copyright (C) 2020 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import os + +from swh.fuse.tests.data.config import REGULAR_FILE +from swh.model.identifiers import parse_swhid + + +def test_cache_artifact(fuse_mntdir): + assert os.listdir(fuse_mntdir / "cache") == ["origin"] + + (fuse_mntdir / "archive" / REGULAR_FILE).is_file() + + swhid = parse_swhid(REGULAR_FILE) + assert os.listdir(fuse_mntdir / "cache") == [swhid.object_id[:2], "origin"] + + +def test_purge_artifact(fuse_mntdir): + DEFAULT_CACHE_CONTENT = ["origin"] + + assert os.listdir(fuse_mntdir / "cache") == DEFAULT_CACHE_CONTENT + + # Access a content artifact... + (fuse_mntdir / "archive" / REGULAR_FILE).is_file() + assert os.listdir(fuse_mntdir / "cache") != DEFAULT_CACHE_CONTENT + # ... and remove it from cache + swhid = parse_swhid(REGULAR_FILE) + os.unlink(fuse_mntdir / "cache" / swhid.object_id[:2] / str(swhid)) + + assert os.listdir(fuse_mntdir / "cache") == DEFAULT_CACHE_CONTENT