diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8567a53ddf41d0aab416bd0582d9e7fbe1699cf6..cb7fc72143d433ebd2289030f011787abf0ebcd9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,9 +62,6 @@ gkfs: - sed -i 's/constexpr bool use_dentry_cache = false;/constexpr bool use_dentry_cache = true;/g' "${CI_PROJECT_DIR}/include/config.hpp" #- sed -i 's/constexpr auto zero_buffer_before_read = false;/constexpr auto zero_buffer_before_read = true;/g' "${CI_PROJECT_DIR}/include/config.hpp" #- sed -i 's/constexpr auto implicit_data_removal = true;/constexpr auto implicit_data_removal = false;/g' "${CI_PROJECT_DIR}/include/config.hpp" - # install libfuse - - apt-get update - - apt-get install -y libfuse3-dev fuse3 # use ccache - ccache --zero-stats -M 750MiB -F 800 --evict-older-than 10d - /usr/sbin/update-ccache-symlinks diff --git a/docker/0.9.6/deps/Dockerfile b/docker/0.9.6/deps/Dockerfile index f6ceceb4f5bdb4d9030891fadfb6751cbd2ebe10..1d9e62cc7c4731b17978c0487a019c3e6e31f786 100644 --- a/docker/0.9.6/deps/Dockerfile +++ b/docker/0.9.6/deps/Dockerfile @@ -22,6 +22,7 @@ RUN apt-get update && \ python3-venv \ python3-setuptools \ libnuma-dev libyaml-dev libcurl4-openssl-dev \ + libfuse3-dev fuse3 \ procps && \ rm -rf /var/lib/apt/lists/* && \ apt-get clean && apt-get autoclean diff --git a/docker/0.9.6/release/Dockerfile b/docker/0.9.6/release/Dockerfile index 218325ddae1db7202d87d545fbe61b3944c443e9..9d97ab04cb944f33c36d6e47124e4a25f6166c49 100644 --- a/docker/0.9.6/release/Dockerfile +++ b/docker/0.9.6/release/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Builder stage -FROM debian:bookworm-slim AS builder +FROM debian:trixie-slim AS builder # Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -25,6 +25,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcurl4-openssl-dev \ libffi-dev \ zlib1g-dev \ + libfuse3-dev \ python3 \ perl \ patch \ @@ -82,7 +83,7 @@ RUN cmake \ make install # Runtime stage -FROM debian:bookworm-slim +FROM debian:trixie-slim # Install runtime dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -94,6 +95,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcap2 \ libzmq5 \ liburing2 \ + fuse3 \ && rm -rf /var/lib/apt/lists/* # Copy GekkoFS artifacts diff --git a/src/daemon/handler/srv_metadata.cpp b/src/daemon/handler/srv_metadata.cpp index 19f3fba94084aa472124e3b96ca38a85421faaa8..39cb095ecef9704ead9b1b36a0596e0d94aefcd9 100644 --- a/src/daemon/handler/srv_metadata.cpp +++ b/src/daemon/handler/srv_metadata.cpp @@ -44,6 +44,7 @@ * @endinternal */ +#include #include #include #include @@ -1070,9 +1071,10 @@ rpc_srv_read_data_inline(const tl::request& req, if(md.size() > 0 && stored_data.empty()) { // Inline data key missing despite non-zero metadata size - // Treat as empty file or error state + // It might be that the file is in chunks (e.g., after a + // truncate) + out.err = EAGAIN; out.count = 0; - out.err = 0; } else if(in.offset >= stored_data.size()) { // EOF out.count = 0; diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index 7526770e5fdc14e9e7c9a0583ccfefe441c2ded2..f7c4d228ce569caaf87c68afbe0b0bce4a88bed6 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -171,6 +171,16 @@ if (GKFS_BUILD_FUSE) WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}/tests/integration SOURCE fuse/ ) + + if (GKFS_INSTALL_TESTS) + install(DIRECTORY fuse + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/gkfs/tests/integration + FILES_MATCHING + REGEX ".*\\.py" + PATTERN "__pycache__" EXCLUDE + PATTERN ".pytest_cache" EXCLUDE + ) + endif () endif () if (GKFS_INSTALL_TESTS) diff --git a/tests/integration/fuse/test_basic_operations.py b/tests/integration/fuse/test_basic_operations.py index 8c0142a8456f742a5348c02afd4e6cad90ba97cf..7a48942fdebbbe80f56fcfe384b9cb3cd38b30db 100644 --- a/tests/integration/fuse/test_basic_operations.py +++ b/tests/integration/fuse/test_basic_operations.py @@ -58,13 +58,13 @@ def test_read(gkfs_daemon, fuse_client): assert sh.wc("-c", str(file2)) == "20 " + str(file2) + "\n" sh.mkdir(str(dir)) assert sh.ls(fuse_client.mountdir) == "dir file file2\n" - sh.cd(str(dir)) + os.chdir(str(dir)) assert sh.pwd() == str(dir) + "\n" sh.mkdir("-p", "foo/bar") assert sh.ls() == "foo\n" - sh.cd("foo") + os.chdir("foo") sh.rmdir("bar") - sh.cd("..") + os.chdir("..") sh.rmdir("foo") sh.rm(str(file2)) assert sh.ls(fuse_client.mountdir) == "dir file\n" diff --git a/tests/integration/fuse/test_standard_compliance.py b/tests/integration/fuse/test_standard_compliance.py new file mode 100644 index 0000000000000000000000000000000000000000..af186968f638de40b96ffaa098b81f5878b3dab9 --- /dev/null +++ b/tests/integration/fuse/test_standard_compliance.py @@ -0,0 +1,172 @@ +import harness +from pathlib import Path +import errno +import stat +import os +import ctypes +import sh +import sys +import pytest +import time +import hashlib +import random +import string +from harness.logger import logger + +def calculate_md5(file_path): + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + +def generate_random_data(size): + return ''.join(random.choices(string.ascii_letters + string.digits, k=size)).encode() + +def test_large_io(gkfs_daemon, fuse_client): + """Test reading and writing large files spanning multiple chunks.""" + + # 2MB file (assuming default chunksize of 512KB, this covers multiple chunks) + file_size = 2 * 1024 * 1024 + file_path = gkfs_daemon.mountdir / "large_file" + + # Generate random data + data = generate_random_data(file_size) + + # Write data + with open(file_path, "wb") as f: + f.write(data) + + # Verify size via stat + st = os.stat(file_path) + assert st.st_size == file_size + + # Verify content md5 + assert calculate_md5(file_path) == hashlib.md5(data).hexdigest() + + # Read back and verify + with open(file_path, "rb") as f: + read_data = f.read() + assert read_data == data + +def test_truncate(gkfs_daemon, fuse_client): + """Test file truncation (extend and shrink).""" + + file_path = gkfs_daemon.mountdir / "truncate_file" + + # Start with small content + initial_data = b"start" + with open(file_path, "wb") as f: + f.write(initial_data) + + # Extend + new_size = 100 * 1024 # 100KB + os.truncate(file_path, new_size) + + assert os.stat(file_path).st_size == new_size + + # Verify hole is zero-filled + with open(file_path, "rb") as f: + content = f.read() + assert content[:len(initial_data)] == initial_data + assert content[len(initial_data):] == b'\0' * (new_size - len(initial_data)) + + # Shrink + shrink_size = 3 + os.truncate(file_path, shrink_size) + assert os.stat(file_path).st_size == shrink_size + + # Force flush to ensure consistency + fd = os.open(file_path, os.O_RDONLY) + os.fsync(fd) + os.close(fd) + + with open(file_path, "rb") as f: + content = f.read() + assert content == initial_data[:shrink_size] + +def test_metadata(gkfs_daemon, fuse_client): + """Verify metadata correctness.""" + + file_path = gkfs_daemon.mountdir / "meta_file" + + # Create file + with open(file_path, "w") as f: + f.write("test") + + # Check stat + st = os.stat(file_path) + assert stat.S_ISREG(st.st_mode) + assert st.st_size == 4 + + # Check permissions (basic check) + # Note: FUSE permissions depend on mount options, but should be consistent + original_mode = st.st_mode + new_mode = 0o777 + try: + os.chmod(file_path, new_mode) + # Refresh stat + st = os.stat(file_path) + # Mask with 0o777 to check permission bits only + # GekkoFS might not support chmod fully in all backends or FUSE might mask it + if (st.st_mode & 0o777) != new_mode: + logger.warning(f"chmod mismatch: expected {oct(new_mode)}, got {oct(st.st_mode & 0o777)}") + except OSError: + pass # Optional support + +def test_directories_nested(gkfs_daemon, fuse_client): + """Test nested directory operations.""" + + top_dir = gkfs_daemon.mountdir / "top" + nested_dir = top_dir / "nested" / "deep" + + os.makedirs(nested_dir) + + assert os.path.exists(nested_dir) + assert os.path.isdir(nested_dir) + + # Create file in deep dir + file_path = nested_dir / "deep_file" + with open(file_path, "w") as f: + f.write("deep") + + assert os.path.exists(file_path) + + # List directory + entries = os.listdir(nested_dir) + assert "deep_file" in entries + + # Remove + os.remove(file_path) + os.removedirs(nested_dir) # Should remove all empty parents up to 'top' if empty? + # removedirs removes parents if they become empty. + # 'top' is in mountdir. + + assert not os.path.exists(nested_dir) + +def test_overwrite_middle(gkfs_daemon, fuse_client): + """Test overwriting data in the middle of a file.""" + + file_path = gkfs_daemon.mountdir / "overwrite_file" + + # Write 10KB + size = 10 * 1024 + data = b'A' * size + with open(file_path, "wb") as f: + f.write(data) + + # Overwrite middle 2KB with 'B' + offset = 4 * 1024 + overwrite_len = 2 * 1024 + new_data = b'B' * overwrite_len + + with open(file_path, "r+b") as f: + f.seek(offset) + f.write(new_data) + + # Verify + expected = b'A' * offset + b'B' * overwrite_len + b'A' * (size - offset - overwrite_len) + with open(file_path, "rb") as f: + content = f.read() + + assert content == expected diff --git a/tests/integration/harness/gkfs.py b/tests/integration/harness/gkfs.py index e420d616ef94aff2aeca25ab121ce8a904fd85e3..9f3e0342a552eb510f1e3a1b08ae33c23fe10293 100644 --- a/tests/integration/harness/gkfs.py +++ b/tests/integration/harness/gkfs.py @@ -29,6 +29,7 @@ import warnings import os, shutil, sys, re, pytest, signal import random, socket, netifaces, time import subprocess +import sh from pathlib import Path from itertools import islice from time import perf_counter @@ -1850,8 +1851,10 @@ class ShellFwdClient: class FuseClient: def __init__(self, workspace): self._workspace = workspace - #self._cmd = sh.Command("printenv", ["/usr/bin/"])#self._workspace.bindirs) - self._cmd = sh.Command(gkfs_fuse_client, self._workspace.bindirs) + + self._cmd = find_command(gkfs_fuse_client, self._workspace.bindirs) + if not self._cmd: + raise Exception(f"Command {gkfs_fuse_client} not found") self._env = os.environ.copy() self._metadir = self.rootdir @@ -1867,20 +1870,19 @@ class FuseClient: self._env.update(self._patched_env) def run(self): - + + # sh module does not accept Path objects in arguments, so we must convert them to strings args = [ "-f", "-s", self._workspace.mountdir, "-o", "auto_unmount" ] print(f"spawning fuse client") print(f"cmdline: {self._cmd} " + " ".join(map(str, args))) print(f"patched env:\n{pformat(self._patched_env)}") - - self._proc = self._cmd( - args, - _env=self._env, - _out='/dev/null', - _err='/dev/null', - _bg=True, - _ok_code=list(range(0, 256)) + + self._proc = subprocess.Popen( + [str(self._cmd)] + [str(a) for a in args], + env=self._env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) print(f"fuse client process spawned (PID={self._proc.pid})") @@ -1890,14 +1892,16 @@ class FuseClient: def shutdown(self): try: - self._proc.terminate() + # self._proc.terminate() + # use fusermount to unmount the filesystem + # this will trigger the fuse client to exit + # and coverage data to be flushed + subprocess.run(["fusermount", "-u", "-z", str(self._workspace.mountdir)], check=True) time.sleep(1) # give fuse time to unmount err = self._proc.wait() except ProcessLookupError: print("Fuse client already gone at shutdown") pass - except sh.SignalException_SIGTERM: - pass except Exception: pass