mirror of
https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git
synced 2024-11-25 13:31:27 +00:00
refactor(services): introduce Bind class and test moving deeper
This commit is contained in:
parent
1e51f51844
commit
305e5cc2c3
|
@ -67,6 +67,6 @@ def ensure_folder_ownership(folders: List[Bind]) -> None:
|
||||||
folder.ensure_ownership()
|
folder.ensure_ownership()
|
||||||
|
|
||||||
|
|
||||||
def bind_folders(folders: List[Bind], volume: BlockDevice):
|
def bind_folders(folders: List[Bind]):
|
||||||
for folder in folders:
|
for folder in folders:
|
||||||
folder.bind()
|
folder.bind()
|
||||||
|
|
|
@ -2,9 +2,13 @@ from __future__ import annotations
|
||||||
import subprocess
|
import subprocess
|
||||||
import pathlib
|
import pathlib
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from os.path import exists
|
||||||
|
|
||||||
from selfprivacy_api.utils.block_devices import BlockDevice, BlockDevices
|
from selfprivacy_api.utils.block_devices import BlockDevice, BlockDevices
|
||||||
|
|
||||||
|
# tests override it to a tmpdir
|
||||||
|
VOLUMES_PATH = "/volumes"
|
||||||
|
|
||||||
|
|
||||||
class BindError(Exception):
|
class BindError(Exception):
|
||||||
pass
|
pass
|
||||||
|
@ -19,9 +23,8 @@ class OwnedPath(BaseModel):
|
||||||
|
|
||||||
class Bind:
|
class Bind:
|
||||||
"""
|
"""
|
||||||
A directory that resides on some volume but we mount it into fs
|
A directory that resides on some volume but we mount it into fs where we need it.
|
||||||
where we need it.
|
Used for storing service data.
|
||||||
Used for service data.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, binding_path: str, owner: str, group: str, drive: BlockDevice):
|
def __init__(self, binding_path: str, owner: str, group: str, drive: BlockDevice):
|
||||||
|
@ -30,7 +33,7 @@ class Bind:
|
||||||
self.group = group
|
self.group = group
|
||||||
self.drive = drive
|
self.drive = drive
|
||||||
|
|
||||||
# TODO: make Service return a list of binds instead of owned paths
|
# TODO: delete owned path interface from Service
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_owned_path(path: OwnedPath, drive_name: str) -> Bind:
|
def from_owned_path(path: OwnedPath, drive_name: str) -> Bind:
|
||||||
drive = BlockDevices().get_block_device(drive_name)
|
drive = BlockDevices().get_block_device(drive_name)
|
||||||
|
@ -45,9 +48,9 @@ class Bind:
|
||||||
return self.binding_path.split("/")[-1]
|
return self.binding_path.split("/")[-1]
|
||||||
|
|
||||||
def location_at_volume(self) -> str:
|
def location_at_volume(self) -> str:
|
||||||
return f"/volumes/{self.drive.name}/{self.bind_foldername()}"
|
return f"{VOLUMES_PATH}/{self.drive.name}/{self.bind_foldername()}"
|
||||||
|
|
||||||
def validate(self) -> str:
|
def validate(self) -> None:
|
||||||
path = pathlib.Path(self.location_at_volume())
|
path = pathlib.Path(self.location_at_volume())
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
@ -58,23 +61,29 @@ class Bind:
|
||||||
raise BindError(f"{path} is not owned by {self.owner}.")
|
raise BindError(f"{path} is not owned by {self.owner}.")
|
||||||
|
|
||||||
def bind(self) -> None:
|
def bind(self) -> None:
|
||||||
|
if not exists(self.binding_path):
|
||||||
|
raise BindError(f"cannot bind to a non-existing path: {self.binding_path}")
|
||||||
|
|
||||||
|
source = self.location_at_volume()
|
||||||
|
target = self.binding_path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
["mount", "--bind", source, target],
|
||||||
"mount",
|
stderr=subprocess.PIPE,
|
||||||
"--bind",
|
|
||||||
self.location_at_volume(),
|
|
||||||
self.binding_path,
|
|
||||||
],
|
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
print(error.output)
|
print(error.stderr)
|
||||||
raise BindError(f"Unable to mount new volume:{error.output}")
|
raise BindError(f"Unable to bind {source} to {target} :{error.stderr}")
|
||||||
|
|
||||||
def unbind(self) -> None:
|
def unbind(self) -> None:
|
||||||
|
if not exists(self.binding_path):
|
||||||
|
raise BindError(f"cannot unbind a non-existing path: {self.binding_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
# umount -l ?
|
||||||
["umount", self.binding_path],
|
["umount", self.binding_path],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
@ -94,10 +103,11 @@ class Bind:
|
||||||
true_location,
|
true_location,
|
||||||
],
|
],
|
||||||
check=True,
|
check=True,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
print(error.output)
|
print(error.stderr)
|
||||||
error_message = (
|
error_message = (
|
||||||
f"Unable to set ownership of {true_location} :{error.output}"
|
f"Unable to set ownership of {true_location} :{error.stderr}"
|
||||||
)
|
)
|
||||||
raise BindError(error_message)
|
raise BindError(error_message)
|
||||||
|
|
|
@ -171,6 +171,9 @@ class BlockDevice:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: SingletonMetaclass messes with tests and is able to persist state
|
||||||
|
# between them. If you have very weird test crosstalk that's probably why
|
||||||
|
# I am not sure it NEEDS to be SingletonMetaclass
|
||||||
class BlockDevices(metaclass=SingletonMetaclass):
|
class BlockDevices(metaclass=SingletonMetaclass):
|
||||||
"""Singleton holding all Block devices"""
|
"""Singleton holding all Block devices"""
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import datetime
|
import datetime
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from os import path
|
from os import path
|
||||||
from os import makedirs
|
from os import makedirs
|
||||||
|
@ -135,6 +136,18 @@ def wrong_auth_client(huey_database, redis_repo_with_tokens):
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def volume_folders(tmpdir, mocker):
|
||||||
|
volumes_dir = path.join(tmpdir, "volumes")
|
||||||
|
|
||||||
|
makedirs(volumes_dir)
|
||||||
|
volumenames = ["sda1", "sda2"]
|
||||||
|
for d in volumenames:
|
||||||
|
service_dir = path.join(volumes_dir, d)
|
||||||
|
makedirs(service_dir)
|
||||||
|
mock = mocker.patch("selfprivacy_api.services.owned_path.VOLUMES_PATH", volumes_dir)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def raw_dummy_service(tmpdir):
|
def raw_dummy_service(tmpdir):
|
||||||
dirnames = ["test_service", "also_test_service"]
|
dirnames = ["test_service", "also_test_service"]
|
||||||
|
@ -161,11 +174,38 @@ def raw_dummy_service(tmpdir):
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_exists(user: str):
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["useradd", "-U", user], stderr=subprocess.PIPE, shell=False
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
if b"already exists" not in error.stderr:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["useradd", user], stderr=subprocess.PIPE, shell=False
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as error:
|
||||||
|
assert b"already exists" in error.stderr
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError("could not create user", user)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def dummy_service(
|
def dummy_service(
|
||||||
tmpdir, raw_dummy_service, generic_userdata
|
tmpdir, raw_dummy_service, generic_userdata
|
||||||
) -> Generator[Service, None, None]:
|
) -> Generator[Service, None, None]:
|
||||||
service = raw_dummy_service
|
service = raw_dummy_service
|
||||||
|
user = service.get_user()
|
||||||
|
|
||||||
|
# TODO: use create_user from users actions. But it will need NIXOS to be there
|
||||||
|
# and react to our changes to files.
|
||||||
|
# from selfprivacy_api.actions.users import create_user
|
||||||
|
# create_user(user, "yay, it is me")
|
||||||
|
ensure_user_exists(user)
|
||||||
|
|
||||||
# register our service
|
# register our service
|
||||||
services.services.append(service)
|
services.services.append(service)
|
||||||
|
|
92
tests/test_binds.py
Normal file
92
tests/test_binds.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import pytest
|
||||||
|
from os import mkdir, rmdir
|
||||||
|
from os.path import join, exists
|
||||||
|
|
||||||
|
|
||||||
|
from tests.conftest import ensure_user_exists
|
||||||
|
from tests.test_graphql.test_services import mock_lsblk_devices
|
||||||
|
|
||||||
|
from selfprivacy_api.services.owned_path import Bind, BindError
|
||||||
|
from selfprivacy_api.utils.block_devices import BlockDevices
|
||||||
|
from selfprivacy_api.utils.waitloop import wait_until_true
|
||||||
|
|
||||||
|
|
||||||
|
BINDTESTS_USER = "binduser"
|
||||||
|
TESTFILE_CONTENTS = "testissimo"
|
||||||
|
TESTFILE_NAME = "testfile"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bind_user():
|
||||||
|
ensure_user_exists(BINDTESTS_USER)
|
||||||
|
return BINDTESTS_USER
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_test_bind(tmpdir, bind_user) -> Bind:
|
||||||
|
test_binding_name = "bindy_dir"
|
||||||
|
binding_path = join(tmpdir, test_binding_name)
|
||||||
|
drive = BlockDevices().get_block_device("sda2")
|
||||||
|
assert drive is not None
|
||||||
|
|
||||||
|
bind = Bind(
|
||||||
|
binding_path=binding_path, owner=bind_user, group=bind_user, drive=drive
|
||||||
|
)
|
||||||
|
|
||||||
|
source_dir = bind.location_at_volume()
|
||||||
|
mkdir(source_dir)
|
||||||
|
mkdir(binding_path)
|
||||||
|
|
||||||
|
testfile_path = join(source_dir, TESTFILE_NAME)
|
||||||
|
with open(testfile_path, "w") as file:
|
||||||
|
file.write(TESTFILE_CONTENTS)
|
||||||
|
|
||||||
|
return bind
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_unbind(volume_folders, tmpdir, bind_user, mock_lsblk_devices):
|
||||||
|
bind = prepare_test_bind(tmpdir, bind_user)
|
||||||
|
bind.ensure_ownership()
|
||||||
|
bind.validate()
|
||||||
|
|
||||||
|
testfile_path = join(bind.location_at_volume(), TESTFILE_NAME)
|
||||||
|
assert exists(testfile_path)
|
||||||
|
with open(testfile_path, "r") as file:
|
||||||
|
assert file.read() == TESTFILE_CONTENTS
|
||||||
|
|
||||||
|
bind.bind()
|
||||||
|
|
||||||
|
testfile_binding_path = join(bind.binding_path, TESTFILE_NAME)
|
||||||
|
assert exists(testfile_path)
|
||||||
|
with open(testfile_path, "r") as file:
|
||||||
|
assert file.read() == TESTFILE_CONTENTS
|
||||||
|
|
||||||
|
bind.unbind()
|
||||||
|
# wait_until_true(lambda : not exists(testfile_binding_path), timeout_sec=2)
|
||||||
|
assert not exists(testfile_binding_path)
|
||||||
|
assert exists(bind.binding_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_nonexistent_target(volume_folders, tmpdir, bind_user, mock_lsblk_devices):
|
||||||
|
bind = prepare_test_bind(tmpdir, bind_user)
|
||||||
|
|
||||||
|
bind.ensure_ownership()
|
||||||
|
bind.validate()
|
||||||
|
rmdir(bind.binding_path)
|
||||||
|
|
||||||
|
with pytest.raises(BindError):
|
||||||
|
bind.bind()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbind_nonexistent_target(
|
||||||
|
volume_folders, tmpdir, bind_user, mock_lsblk_devices
|
||||||
|
):
|
||||||
|
bind = prepare_test_bind(tmpdir, bind_user)
|
||||||
|
|
||||||
|
bind.ensure_ownership()
|
||||||
|
bind.validate()
|
||||||
|
bind.bind()
|
||||||
|
|
||||||
|
bind.binding_path = "/bogus"
|
||||||
|
|
||||||
|
with pytest.raises(BindError):
|
||||||
|
bind.unbind()
|
|
@ -410,6 +410,7 @@ def lsblk_full_mock(mocker):
|
||||||
mock = mocker.patch(
|
mock = mocker.patch(
|
||||||
"subprocess.check_output", autospec=True, return_value=FULL_LSBLK_OUTPUT
|
"subprocess.check_output", autospec=True, return_value=FULL_LSBLK_OUTPUT
|
||||||
)
|
)
|
||||||
|
BlockDevices().update()
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
import shutil
|
||||||
|
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
from os import mkdir
|
||||||
|
|
||||||
from selfprivacy_api.utils.block_devices import BlockDevices
|
from selfprivacy_api.utils.block_devices import BlockDevices
|
||||||
|
|
||||||
|
@ -8,13 +11,77 @@ from selfprivacy_api.services import get_service_by_id
|
||||||
from selfprivacy_api.services.service import Service, ServiceStatus
|
from selfprivacy_api.services.service import Service, ServiceStatus
|
||||||
from selfprivacy_api.services.test_service import DummyService
|
from selfprivacy_api.services.test_service import DummyService
|
||||||
|
|
||||||
# from selfprivacy_api.services.moving import check_folders
|
|
||||||
|
|
||||||
from tests.common import generate_service_query
|
from tests.common import generate_service_query
|
||||||
from tests.test_graphql.common import assert_empty, assert_ok, get_data
|
from tests.test_graphql.common import assert_empty, assert_ok, get_data
|
||||||
from tests.test_block_device_utils import lsblk_singular_mock
|
from tests.test_block_device_utils import lsblk_singular_mock
|
||||||
|
|
||||||
from subprocess import CompletedProcess
|
|
||||||
|
LSBLK_BLOCKDEVICES_DICTS = [
|
||||||
|
{
|
||||||
|
"name": "sda1",
|
||||||
|
"path": "/dev/sda1",
|
||||||
|
"fsavail": "4614107136",
|
||||||
|
"fssize": "19814920192",
|
||||||
|
"fstype": "ext4",
|
||||||
|
"fsused": "14345314304",
|
||||||
|
"mountpoints": ["/nix/store", "/"],
|
||||||
|
"label": None,
|
||||||
|
"uuid": "ec80c004-baec-4a2c-851d-0e1807135511",
|
||||||
|
"size": 20210236928,
|
||||||
|
"model": None,
|
||||||
|
"serial": None,
|
||||||
|
"type": "part",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sda2",
|
||||||
|
"path": "/dev/sda2",
|
||||||
|
"fsavail": "4614107136",
|
||||||
|
"fssize": "19814920192",
|
||||||
|
"fstype": "ext4",
|
||||||
|
"fsused": "14345314304",
|
||||||
|
"mountpoints": ["/home"],
|
||||||
|
"label": None,
|
||||||
|
"uuid": "deadbeef-baec-4a2c-851d-0e1807135511",
|
||||||
|
"size": 20210236928,
|
||||||
|
"model": None,
|
||||||
|
"serial": None,
|
||||||
|
"type": "part",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def mock_lsblk_devices(mocker):
|
||||||
|
mock = mocker.patch(
|
||||||
|
"selfprivacy_api.utils.block_devices.BlockDevices.lsblk_device_dicts",
|
||||||
|
autospec=True,
|
||||||
|
return_value=LSBLK_BLOCKDEVICES_DICTS,
|
||||||
|
)
|
||||||
|
BlockDevices().update()
|
||||||
|
assert BlockDevices().lsblk_device_dicts() == LSBLK_BLOCKDEVICES_DICTS
|
||||||
|
devices = BlockDevices().get_block_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 2
|
||||||
|
|
||||||
|
names = [device.name for device in devices]
|
||||||
|
assert "sda1" in names
|
||||||
|
assert "sda2" in names
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def dummy_service_with_binds(dummy_service, mock_lsblk_devices, volume_folders):
|
||||||
|
binds = dummy_service.binds()
|
||||||
|
for bind in binds:
|
||||||
|
path = bind.binding_path
|
||||||
|
shutil.move(bind.binding_path, bind.location_at_volume())
|
||||||
|
mkdir(bind.binding_path)
|
||||||
|
|
||||||
|
bind.ensure_ownership()
|
||||||
|
bind.validate()
|
||||||
|
|
||||||
|
bind.bind()
|
||||||
|
return dummy_service
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
@ -28,9 +95,17 @@ def only_dummy_service(dummy_service) -> Generator[DummyService, None, None]:
|
||||||
service_module.services.extend(back_copy)
|
service_module.services.extend(back_copy)
|
||||||
|
|
||||||
|
|
||||||
MOVER_MOCK_PROCESS = CompletedProcess(["ls"], returncode=0)
|
@pytest.fixture()
|
||||||
|
def mock_check_volume(mocker):
|
||||||
|
mock = mocker.patch(
|
||||||
|
"selfprivacy_api.services.service.check_volume",
|
||||||
|
autospec=True,
|
||||||
|
return_value=None,
|
||||||
|
)
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: remove
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def mock_check_service_mover_binds(mocker):
|
def mock_check_service_mover_binds(mocker):
|
||||||
mock = mocker.patch(
|
mock = mocker.patch(
|
||||||
|
@ -41,20 +116,6 @@ def mock_check_service_mover_binds(mocker):
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_subprocess_run(mocker):
|
|
||||||
mock = mocker.patch(
|
|
||||||
"subprocess.run", autospec=True, return_value=MOVER_MOCK_PROCESS
|
|
||||||
)
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def mock_shutil_move(mocker):
|
|
||||||
mock = mocker.patch("shutil.move", autospec=True, return_value=None)
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
API_START_MUTATION = """
|
API_START_MUTATION = """
|
||||||
mutation TestStartService($service_id: String!) {
|
mutation TestStartService($service_id: String!) {
|
||||||
services {
|
services {
|
||||||
|
@ -551,7 +612,7 @@ def test_move_same_volume(authorized_client, dummy_service):
|
||||||
def test_graphql_move_service_without_folders_on_old_volume(
|
def test_graphql_move_service_without_folders_on_old_volume(
|
||||||
authorized_client,
|
authorized_client,
|
||||||
generic_userdata,
|
generic_userdata,
|
||||||
lsblk_singular_mock,
|
mock_lsblk_devices,
|
||||||
dummy_service: DummyService,
|
dummy_service: DummyService,
|
||||||
):
|
):
|
||||||
target = "sda1"
|
target = "sda1"
|
||||||
|
@ -564,26 +625,27 @@ def test_graphql_move_service_without_folders_on_old_volume(
|
||||||
|
|
||||||
data = get_data(mutation_response)["services"]["moveService"]
|
data = get_data(mutation_response)["services"]["moveService"]
|
||||||
assert_errorcode(data, 400)
|
assert_errorcode(data, 400)
|
||||||
|
assert "sda2/test_service is not found" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
def test_graphql_move_service(
|
def test_graphql_move_service(
|
||||||
authorized_client,
|
authorized_client,
|
||||||
generic_userdata,
|
generic_userdata,
|
||||||
|
# TODO: substitute with a weaker mock or delete altogether
|
||||||
mock_check_service_mover_binds,
|
mock_check_service_mover_binds,
|
||||||
lsblk_singular_mock,
|
mock_check_volume,
|
||||||
dummy_service: DummyService,
|
dummy_service_with_binds,
|
||||||
mock_subprocess_run,
|
|
||||||
mock_shutil_move,
|
|
||||||
):
|
):
|
||||||
# Does not check real moving,
|
dummy_service = dummy_service_with_binds
|
||||||
# but tests the finished job propagation through API
|
|
||||||
|
|
||||||
target = "sda1"
|
origin = "sda1"
|
||||||
BlockDevices().update()
|
target = "sda2"
|
||||||
assert BlockDevices().get_block_device(target) is not None
|
assert BlockDevices().get_block_device(target) is not None
|
||||||
|
assert BlockDevices().get_block_device(origin) is not None
|
||||||
|
|
||||||
|
dummy_service.set_drive(origin)
|
||||||
dummy_service.set_simulated_moves(False)
|
dummy_service.set_simulated_moves(False)
|
||||||
dummy_service.set_drive("sda2")
|
|
||||||
mutation_response = api_move(authorized_client, dummy_service, target)
|
mutation_response = api_move(authorized_client, dummy_service, target)
|
||||||
|
|
||||||
data = get_data(mutation_response)["services"]["moveService"]
|
data = get_data(mutation_response)["services"]["moveService"]
|
||||||
|
|
Loading…
Reference in a new issue