storage: implemented filesystem

test coverage to be improved
This commit is contained in:
dogeystamp 2023-04-08 18:35:56 -04:00
parent 743c8b9eaa
commit 95ed51dd64
Signed by: dogeystamp
GPG Key ID: 7225FE3592EFFA38
7 changed files with 253 additions and 2 deletions

View File

@ -3,3 +3,5 @@
SECRET_KEY: "" SECRET_KEY: ""
# BCRYPT_LOG_ROUNDS: 13 # BCRYPT_LOG_ROUNDS: 13
# SACHET_STORAGE: "filesystem"
# SACHET_FILE_DIR: "/srv/sachet/storage"

View File

@ -21,6 +21,17 @@ bcrypt = Bcrypt(app)
db = SQLAlchemy(app) db = SQLAlchemy(app)
ma = Marshmallow() ma = Marshmallow()
_storage_method = app.config["SACHET_STORAGE"]
storage = None
from sachet.storage import FileSystem
if _storage_method == "filesystem":
storage = FileSystem()
else:
raise ValueError(f"{_storage_method} is not a valid storage method.")
import sachet.server.commands import sachet.server.commands
from sachet.server.users.views import users_blueprint from sachet.server.users.views import users_blueprint

View File

@ -8,16 +8,20 @@ class BaseConfig:
SQLALCHEMY_DATABASE_URI = sqlalchemy_base + ".db" SQLALCHEMY_DATABASE_URI = sqlalchemy_base + ".db"
BCRYPT_LOG_ROUNDS = 13 BCRYPT_LOG_ROUNDS = 13
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SACHET_STORAGE = "filesystem"
SACHET_FILE_DIR = "/srv/sachet/storage"
class TestingConfig(BaseConfig): class TestingConfig(BaseConfig):
SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_test" + ".db" SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_test" + ".db"
BCRYPT_LOG_ROUNDS = 4 BCRYPT_LOG_ROUNDS = 4
SACHET_FILE_DIR = "storage_test"
class DevelopmentConfig(BaseConfig): class DevelopmentConfig(BaseConfig):
SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_dev" + ".db" SQLALCHEMY_DATABASE_URI = sqlalchemy_base + "_dev" + ".db"
BCRYPT_LOG_ROUNDS = 4 BCRYPT_LOG_ROUNDS = 4
SACHET_FILE_DIR = "storage_dev"
class ProductionConfig(BaseConfig): class ProductionConfig(BaseConfig):

View File

@ -0,0 +1,71 @@
class Storage:
"""Generic storage interface.
Raises:
OSError if SACHET_FILE_DIR could not be opened as a directory.
"""
def create(self, name):
"""Create file along with the metadata.
Raises:
OSError if the file already exists.
"""
pass
def open(self, name, mode="r"):
"""Open a file for reading/writing.
Raises:
OSError if the file does not exist.
Returns:
Stream to access file (similar to open()'s handle.)
"""
pass
def read_metadata(self, name):
"""Get metadata for a file.
Raises:
OSError if the file does not exist.
"""
pass
def write_metadata(self, name, data):
"""Set metadata for a file.
Raises:
OSError if the file does not exist.
"""
pass
def delete(self, name):
"""Delete file and associated metadata.
Raises:
OSError if the file does not exist.
"""
pass
def rename(self, name, new_name):
"""Rename a file.
Raises:
OSError if the file does not exist.
"""
pass
def list_files(self):
"""Lists all files."""
pass
from .filesystem import FileSystem

View File

@ -0,0 +1,95 @@
from sachet.storage import Storage
from pathlib import Path
from sachet.server import app
from werkzeug.utils import secure_filename
import json
class FileSystem(Storage):
def __init__(self):
config_path = Path(app.config["SACHET_FILE_DIR"])
if config_path.is_absolute():
self._directory = config_path
else:
self._directory = Path(app.instance_path) / config_path
self._files_directory = self._directory / Path("files")
self._meta_directory = self._directory / Path("meta")
self._files_directory.mkdir(mode=0o700, exist_ok=True, parents=True)
self._meta_directory.mkdir(mode=0o700, exist_ok=True, parents=True)
if not self._directory.is_dir():
raise OSError(f"'{app.config['SACHET_FILE_DIR']}' is not a directory.")
def _get_path(self, name):
name = secure_filename(name)
return self._files_directory / Path(name)
def _get_meta_path(self, name):
name = secure_filename(name)
return self._meta_directory / Path(name)
def create(self, name):
path = self._get_path(name)
if path.exists():
raise OSError(f"Path {path} already exists.")
meta_path = self._get_meta_path(name)
if meta_path.exists():
raise OSError(f"Path {meta_path} already exists.")
path.touch()
meta_path.touch()
def delete(self, name):
path = self._get_path(name)
if not path.exists():
raise OSError(f"Path {path} does not exist.")
path.unlink()
def open(self, name, mode="r"):
path = self._get_path(name)
if not path.exists():
raise OSError(f"Path {path} does not exist.")
return path.open(mode=mode)
def read_metadata(self, name):
meta_path = self._get_meta_path(name)
if not meta_path.exists():
raise OSError(f"Path {meta_path} does not exist.")
with meta_path.open() as meta_file:
content = meta_file.read()
return json.loads(content)
def write_metadata(self, name, data):
meta_path = self._get_meta_path(name)
if not meta_path.exists():
raise OSError(f"Path {meta_path} does not exist.")
with meta_path.open("w") as meta_file:
content = json.dumps(data)
meta_file.write(content)
def rename(self, name, new_name):
path = self._get_path(name)
if not path.exists():
raise OSError(f"Path {path} does not exist.")
new_path = self._get_path(new_name)
if path.exists():
raise OSError(f"Path {path} already exists.")
meta_path = self._get_meta_path(name)
if not path.exists():
raise OSError(f"Path {meta_path} does not exist.")
new_meta_path = self._get_meta_path(name)
if path.exists():
raise OSError(f"Path {path} already exists.")
path.rename(new_path)
meta_path.rename(new_meta_path)
def list_files(self):
return [x for x in self._files_directory.iterdir() if x.is_file()]

View File

@ -2,22 +2,50 @@ import pytest
import yaml import yaml
from sachet.server.users import manage from sachet.server.users import manage
from click.testing import CliRunner from click.testing import CliRunner
from sachet.server import app, db from sachet.server import app, db, storage
from sachet.server.models import Permissions, User from sachet.server.models import Permissions, User
from bitmask import Bitmask from bitmask import Bitmask
from pathlib import Path
import random
import itertools
@pytest.fixture @pytest.fixture
def client(): def rand():
"""Deterministic random data generator.
Be sure to seed 0 with each test!
"""
r = random.Random()
r.seed(0)
return r
@pytest.fixture
def client(config={}):
"""Flask application with DB already set up and ready.""" """Flask application with DB already set up and ready."""
with app.test_client() as client: with app.test_client() as client:
with app.app_context(): with app.app_context():
for k, v in config.items():
app.config[k] = v
db.drop_all() db.drop_all()
db.create_all() db.create_all()
db.session.commit() db.session.commit()
yield client yield client
db.session.remove() db.session.remove()
db.drop_all() db.drop_all()
if app.config["SACHET_STORAGE"] == "filesystem":
for file in itertools.chain(
storage._meta_directory.iterdir(),
storage._files_directory.iterdir(),
):
if file.is_relative_to(Path(app.instance_path)) and file.is_file():
file.unlink()
else:
raise OSError(
f"Attempted to delete {file}: please delete it yourself."
)
@pytest.fixture @pytest.fixture

40
tests/test_storage.py Normal file
View File

@ -0,0 +1,40 @@
import pytest
from sachet.server import storage
from uuid import UUID
"""Test suite for storage backends (not their API endpoints)."""
# if other storage backends are implemented we test them with the same suite with this line
@pytest.mark.parametrize("client", [{"SACHET_STORAGE": "filesystem"}], indirect=True)
class TestSuite:
def test_creation(self, client, rand):
"""Test the process of creating, writing, then reading files with metadata."""
files = [
dict(
name=str(UUID(bytes=rand.randbytes(16))),
data=rand.randbytes(4000),
metadata=dict(
sdljkf=dict(abc="def", aaa="bbb"),
lkdsjf=dict(ld="sdlfj", sdljf="sdlkjf"),
sdlfkj="sjdlkfsldk",
ssjdklf=rand.randint(-1000, 1000),
),
)
for i in range(25)
]
for file in files:
storage.create(file["name"])
with storage.open(file["name"], mode="wb") as f:
f.write(file["data"])
storage.write_metadata(file["name"], file["metadata"])
for file in files:
with storage.open(file["name"], mode="rb") as f:
saved_data = f.read()
assert saved_data == file["data"]
saved_meta = storage.read_metadata(file["name"])
assert saved_meta == file["metadata"]