mirror of
https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api.git
synced 2025-03-13 02:04:09 +00:00
Merge pull request 'rebuild-when-moving' (#101) from rebuild-when-moving into master
Reviewed-on: https://git.selfprivacy.org/SelfPrivacy/selfprivacy-rest-api/pulls/101 Reviewed-by: Inex Code <inex.code@selfprivacy.org>
This commit is contained in:
commit
efc6b47cfe
9 changed files with 141 additions and 91 deletions
selfprivacy_api
graphql/common_types
jobs
models
services
utils
tests/test_graphql
|
@ -2,6 +2,7 @@ import typing
|
|||
import strawberry
|
||||
|
||||
|
||||
# TODO: use https://strawberry.rocks/docs/integrations/pydantic when it is stable
|
||||
@strawberry.type
|
||||
class DnsRecord:
|
||||
"""DNS record"""
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
from enum import Enum
|
||||
import typing
|
||||
import strawberry
|
||||
from typing import Optional, List
|
||||
import datetime
|
||||
import strawberry
|
||||
|
||||
from selfprivacy_api.graphql.common_types.backup import BackupReason
|
||||
from selfprivacy_api.graphql.common_types.dns import DnsRecord
|
||||
|
||||
from selfprivacy_api.services import get_service_by_id, get_services_by_location
|
||||
from selfprivacy_api.services import Service as ServiceInterface
|
||||
from selfprivacy_api.services import ServiceDnsRecord
|
||||
|
||||
from selfprivacy_api.utils.block_devices import BlockDevices
|
||||
import selfprivacy_api.utils.network as network_utils
|
||||
from selfprivacy_api.utils.network import get_ip4, get_ip6
|
||||
|
||||
|
||||
def get_usages(root: "StorageVolume") -> list["StorageUsageInterface"]:
|
||||
|
@ -33,8 +36,8 @@ class StorageVolume:
|
|||
used_space: str
|
||||
root: bool
|
||||
name: str
|
||||
model: typing.Optional[str]
|
||||
serial: typing.Optional[str]
|
||||
model: Optional[str]
|
||||
serial: Optional[str]
|
||||
type: str
|
||||
|
||||
@strawberry.field
|
||||
|
@ -46,7 +49,7 @@ class StorageVolume:
|
|||
@strawberry.interface
|
||||
class StorageUsageInterface:
|
||||
used_space: str
|
||||
volume: typing.Optional[StorageVolume]
|
||||
volume: Optional[StorageVolume]
|
||||
title: str
|
||||
|
||||
|
||||
|
@ -54,7 +57,7 @@ class StorageUsageInterface:
|
|||
class ServiceStorageUsage(StorageUsageInterface):
|
||||
"""Storage usage for a service"""
|
||||
|
||||
service: typing.Optional["Service"]
|
||||
service: Optional["Service"]
|
||||
|
||||
|
||||
@strawberry.enum
|
||||
|
@ -86,6 +89,20 @@ def get_storage_usage(root: "Service") -> ServiceStorageUsage:
|
|||
)
|
||||
|
||||
|
||||
# TODO: This won't be needed when deriving DnsRecord via strawberry pydantic integration
|
||||
# https://strawberry.rocks/docs/integrations/pydantic
|
||||
# Remove when the link above says it got stable.
|
||||
def service_dns_to_graphql(record: ServiceDnsRecord) -> DnsRecord:
|
||||
return DnsRecord(
|
||||
record_type=record.type,
|
||||
name=record.name,
|
||||
content=record.content,
|
||||
ttl=record.ttl,
|
||||
priority=record.priority,
|
||||
display_name=record.display_name,
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class Service:
|
||||
id: str
|
||||
|
@ -98,16 +115,26 @@ class Service:
|
|||
can_be_backed_up: bool
|
||||
backup_description: str
|
||||
status: ServiceStatusEnum
|
||||
url: typing.Optional[str]
|
||||
dns_records: typing.Optional[typing.List[DnsRecord]]
|
||||
url: Optional[str]
|
||||
|
||||
@strawberry.field
|
||||
def dns_records(self) -> Optional[List[DnsRecord]]:
|
||||
service = get_service_by_id(self.id)
|
||||
if service is None:
|
||||
raise LookupError(f"no service {self.id}. Should be unreachable")
|
||||
|
||||
raw_records = service.get_dns_records(get_ip4(), get_ip6())
|
||||
dns_records = [service_dns_to_graphql(record) for record in raw_records]
|
||||
return dns_records
|
||||
|
||||
@strawberry.field
|
||||
def storage_usage(self) -> ServiceStorageUsage:
|
||||
"""Get storage usage for a service"""
|
||||
return get_storage_usage(self)
|
||||
|
||||
# TODO: fill this
|
||||
@strawberry.field
|
||||
def backup_snapshots(self) -> typing.Optional[typing.List["SnapshotInfo"]]:
|
||||
def backup_snapshots(self) -> Optional[List["SnapshotInfo"]]:
|
||||
return None
|
||||
|
||||
|
||||
|
@ -133,23 +160,10 @@ def service_to_graphql_service(service: ServiceInterface) -> Service:
|
|||
backup_description=service.get_backup_description(),
|
||||
status=ServiceStatusEnum(service.get_status().value),
|
||||
url=service.get_url(),
|
||||
dns_records=[
|
||||
DnsRecord(
|
||||
record_type=record.type,
|
||||
name=record.name,
|
||||
content=record.content,
|
||||
ttl=record.ttl,
|
||||
priority=record.priority,
|
||||
display_name=record.display_name,
|
||||
)
|
||||
for record in service.get_dns_records(
|
||||
network_utils.get_ip4(), network_utils.get_ip6()
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_volume_by_id(volume_id: str) -> typing.Optional[StorageVolume]:
|
||||
def get_volume_by_id(volume_id: str) -> Optional[StorageVolume]:
|
||||
"""Get volume by id"""
|
||||
volume = BlockDevices().get_block_device(volume_id)
|
||||
if volume is None:
|
||||
|
|
|
@ -63,9 +63,13 @@ def check_running_status(job: Job, unit_name: str):
|
|||
return False
|
||||
|
||||
|
||||
@huey.task()
|
||||
def rebuild_system_task(job: Job, upgrade: bool = False):
|
||||
"""Rebuild the system"""
|
||||
def rebuild_system(job: Job, upgrade: bool = False):
|
||||
"""
|
||||
Broken out to allow calling it synchronously.
|
||||
We cannot just block until task is done because it will require a second worker
|
||||
Which we do not have
|
||||
"""
|
||||
|
||||
unit_name = "sp-nixos-upgrade.service" if upgrade else "sp-nixos-rebuild.service"
|
||||
try:
|
||||
command = ["systemctl", "start", unit_name]
|
||||
|
@ -124,3 +128,9 @@ def rebuild_system_task(job: Job, upgrade: bool = False):
|
|||
status=JobStatus.ERROR,
|
||||
status_text=str(e),
|
||||
)
|
||||
|
||||
|
||||
@huey.task()
|
||||
def rebuild_system_task(job: Job, upgrade: bool = False):
|
||||
"""Rebuild the system"""
|
||||
rebuild_system(job, upgrade)
|
||||
|
|
24
selfprivacy_api/models/services.py
Normal file
24
selfprivacy_api/models/services.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServiceStatus(Enum):
|
||||
"""Enum for service status"""
|
||||
|
||||
ACTIVE = "ACTIVE"
|
||||
RELOADING = "RELOADING"
|
||||
INACTIVE = "INACTIVE"
|
||||
FAILED = "FAILED"
|
||||
ACTIVATING = "ACTIVATING"
|
||||
DEACTIVATING = "DEACTIVATING"
|
||||
OFF = "OFF"
|
||||
|
||||
|
||||
class ServiceDnsRecord(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
content: str
|
||||
ttl: int
|
||||
display_name: str
|
||||
priority: Optional[int] = None
|
|
@ -1,13 +1,16 @@
|
|||
"""Abstract class for a service running on a server"""
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from selfprivacy_api.jobs import Job, Jobs, JobStatus, report_progress
|
||||
|
||||
from selfprivacy_api import utils
|
||||
from selfprivacy_api.utils import ReadUserData, WriteUserData
|
||||
from selfprivacy_api.utils.waitloop import wait_until_true
|
||||
from selfprivacy_api.utils.block_devices import BlockDevice, BlockDevices
|
||||
|
||||
from selfprivacy_api.jobs import Job, Jobs, JobStatus, report_progress
|
||||
from selfprivacy_api.jobs.upgrade_system import rebuild_system
|
||||
|
||||
from selfprivacy_api.models.services import ServiceStatus, ServiceDnsRecord
|
||||
from selfprivacy_api.services.generic_size_counter import get_storage_usage
|
||||
from selfprivacy_api.services.owned_path import OwnedPath, Bind
|
||||
from selfprivacy_api.services.moving import (
|
||||
|
@ -20,34 +23,10 @@ from selfprivacy_api.services.moving import (
|
|||
move_data_to_volume,
|
||||
)
|
||||
|
||||
from selfprivacy_api import utils
|
||||
from selfprivacy_api.utils.waitloop import wait_until_true
|
||||
from selfprivacy_api.utils import ReadUserData, WriteUserData
|
||||
|
||||
DEFAULT_START_STOP_TIMEOUT = 5 * 60
|
||||
|
||||
|
||||
class ServiceStatus(Enum):
|
||||
"""Enum for service status"""
|
||||
|
||||
ACTIVE = "ACTIVE"
|
||||
RELOADING = "RELOADING"
|
||||
INACTIVE = "INACTIVE"
|
||||
FAILED = "FAILED"
|
||||
ACTIVATING = "ACTIVATING"
|
||||
DEACTIVATING = "DEACTIVATING"
|
||||
OFF = "OFF"
|
||||
|
||||
|
||||
class ServiceDnsRecord(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
content: str
|
||||
ttl: int
|
||||
display_name: str
|
||||
priority: Optional[int] = None
|
||||
|
||||
|
||||
class Service(ABC):
|
||||
"""
|
||||
Service here is some software that is hosted on the server and
|
||||
|
@ -387,14 +366,6 @@ class Service(ABC):
|
|||
report_progress(95, job, f"Finishing moving {service_name}...")
|
||||
self.set_location(new_volume)
|
||||
|
||||
Jobs.update(
|
||||
job=job,
|
||||
status=JobStatus.FINISHED,
|
||||
result=f"{service_name} moved successfully.",
|
||||
status_text=f"Starting {service_name}...",
|
||||
progress=100,
|
||||
)
|
||||
|
||||
def move_to_volume(self, volume: BlockDevice, job: Job) -> Job:
|
||||
service_name = self.get_display_name()
|
||||
|
||||
|
@ -407,6 +378,17 @@ class Service(ABC):
|
|||
report_progress(9, job, "Stopped service, starting the move...")
|
||||
self.do_move_to_volume(volume, job)
|
||||
|
||||
report_progress(98, job, "Move complete, rebuilding...")
|
||||
rebuild_system(job, upgrade=False)
|
||||
|
||||
Jobs.update(
|
||||
job=job,
|
||||
status=JobStatus.FINISHED,
|
||||
result=f"{service_name} moved successfully.",
|
||||
status_text=f"Starting {service_name}...",
|
||||
progress=100,
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -8,10 +8,9 @@ from os import path
|
|||
|
||||
# from enum import Enum
|
||||
|
||||
from selfprivacy_api.jobs import Job, Jobs, JobStatus
|
||||
from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus
|
||||
from selfprivacy_api.jobs import Job
|
||||
from selfprivacy_api.services.service import Service, ServiceStatus
|
||||
from selfprivacy_api.utils.block_devices import BlockDevice
|
||||
import selfprivacy_api.utils.network as network_utils
|
||||
|
||||
from selfprivacy_api.services.test_service.icon import BITWARDEN_ICON
|
||||
|
||||
|
@ -89,7 +88,7 @@ class DummyService(Service):
|
|||
@classmethod
|
||||
def set_status(cls, status: ServiceStatus):
|
||||
with open(cls.status_file(), "w") as file:
|
||||
status_string = file.write(status.value)
|
||||
file.write(status.value)
|
||||
|
||||
@classmethod
|
||||
def get_status(cls) -> ServiceStatus:
|
||||
|
@ -102,16 +101,17 @@ class DummyService(Service):
|
|||
cls, new_status: ServiceStatus, delay_sec: float
|
||||
):
|
||||
"""simulating a delay on systemd side"""
|
||||
status_file = cls.status_file()
|
||||
if delay_sec == 0:
|
||||
cls.set_status(new_status)
|
||||
return
|
||||
|
||||
status_file = cls.status_file()
|
||||
command = [
|
||||
"bash",
|
||||
"-c",
|
||||
f" sleep {delay_sec} && echo {new_status.value} > {status_file}",
|
||||
]
|
||||
handle = subprocess.Popen(command)
|
||||
if delay_sec == 0:
|
||||
handle.communicate()
|
||||
subprocess.Popen(command)
|
||||
|
||||
@classmethod
|
||||
def set_backuppable(cls, new_value: bool) -> None:
|
||||
|
@ -192,6 +192,5 @@ class DummyService(Service):
|
|||
if self.simulate_moving is False:
|
||||
return super(DummyService, self).do_move_to_volume(volume, job)
|
||||
else:
|
||||
Jobs.update(job, status=JobStatus.FINISHED)
|
||||
self.set_drive(volume.name)
|
||||
return job
|
||||
|
|
|
@ -2,16 +2,16 @@
|
|||
import subprocess
|
||||
from typing import List
|
||||
|
||||
from selfprivacy_api.services.service import ServiceStatus
|
||||
from selfprivacy_api.models.services import ServiceStatus
|
||||
|
||||
|
||||
def get_service_status(service: str) -> ServiceStatus:
|
||||
def get_service_status(unit: str) -> ServiceStatus:
|
||||
"""
|
||||
Return service status from systemd.
|
||||
Use systemctl show to get the status of a service.
|
||||
Get ActiveState from the output.
|
||||
"""
|
||||
service_status = subprocess.check_output(["systemctl", "show", service])
|
||||
service_status = subprocess.check_output(["systemctl", "show", unit])
|
||||
if b"LoadState=not-found" in service_status:
|
||||
return ServiceStatus.OFF
|
||||
if b"ActiveState=active" in service_status:
|
||||
|
|
|
@ -13,8 +13,7 @@ from selfprivacy_api.services.test_service import DummyService
|
|||
|
||||
from tests.common import generate_service_query
|
||||
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_graphql.test_system_nixos_tasks import prepare_nixos_rebuild_calls
|
||||
|
||||
LSBLK_BLOCKDEVICES_DICTS = [
|
||||
{
|
||||
|
@ -618,10 +617,7 @@ def test_graphql_move_service_without_folders_on_old_volume(
|
|||
|
||||
|
||||
def test_graphql_move_service(
|
||||
authorized_client,
|
||||
generic_userdata,
|
||||
mock_check_volume,
|
||||
dummy_service_with_binds,
|
||||
authorized_client, generic_userdata, mock_check_volume, dummy_service_with_binds, fp
|
||||
):
|
||||
dummy_service = dummy_service_with_binds
|
||||
|
||||
|
@ -633,10 +629,30 @@ def test_graphql_move_service(
|
|||
dummy_service.set_drive(origin)
|
||||
dummy_service.set_simulated_moves(False)
|
||||
|
||||
unit_name = "sp-nixos-rebuild.service"
|
||||
rebuild_command = ["systemctl", "start", unit_name]
|
||||
prepare_nixos_rebuild_calls(fp, unit_name)
|
||||
|
||||
# We will be mounting and remounting folders
|
||||
mount_command = ["mount", fp.any()]
|
||||
unmount_command = ["umount", fp.any()]
|
||||
fp.pass_command(mount_command, 2)
|
||||
fp.pass_command(unmount_command, 2)
|
||||
|
||||
# We will be changing ownership
|
||||
chown_command = ["chown", fp.any()]
|
||||
fp.pass_command(chown_command, 2)
|
||||
|
||||
mutation_response = api_move(authorized_client, dummy_service, target)
|
||||
|
||||
data = get_data(mutation_response)["services"]["moveService"]
|
||||
assert_ok(data)
|
||||
assert data["service"] is not None
|
||||
|
||||
assert fp.call_count(rebuild_command) == 1
|
||||
assert fp.call_count(mount_command) == 2
|
||||
assert fp.call_count(unmount_command) == 2
|
||||
assert fp.call_count(chown_command) == 2
|
||||
|
||||
|
||||
def test_mailservice_cannot_enable_disable(authorized_client):
|
||||
|
|
|
@ -97,16 +97,7 @@ def test_graphql_system_rebuild_unauthorized(client, fp, action):
|
|||
assert fp.call_count([fp.any()]) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["rebuild", "upgrade"])
|
||||
def test_graphql_system_rebuild(authorized_client, fp, action, mock_sleep_intervals):
|
||||
"""Test system rebuild"""
|
||||
unit_name = f"sp-nixos-{action}.service"
|
||||
query = (
|
||||
API_REBUILD_SYSTEM_MUTATION
|
||||
if action == "rebuild"
|
||||
else API_UPGRADE_SYSTEM_MUTATION
|
||||
)
|
||||
|
||||
def prepare_nixos_rebuild_calls(fp, unit_name):
|
||||
# Start the unit
|
||||
fp.register(["systemctl", "start", unit_name])
|
||||
|
||||
|
@ -129,6 +120,19 @@ def test_graphql_system_rebuild(authorized_client, fp, action, mock_sleep_interv
|
|||
|
||||
fp.register(["systemctl", "show", unit_name], stdout="ActiveState=inactive")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["rebuild", "upgrade"])
|
||||
def test_graphql_system_rebuild(authorized_client, fp, action, mock_sleep_intervals):
|
||||
"""Test system rebuild"""
|
||||
unit_name = f"sp-nixos-{action}.service"
|
||||
query = (
|
||||
API_REBUILD_SYSTEM_MUTATION
|
||||
if action == "rebuild"
|
||||
else API_UPGRADE_SYSTEM_MUTATION
|
||||
)
|
||||
|
||||
prepare_nixos_rebuild_calls(fp, unit_name)
|
||||
|
||||
response = authorized_client.post(
|
||||
"/graphql",
|
||||
json={
|
||||
|
|
Loading…
Add table
Reference in a new issue