diff --git a/selfprivacy_api/graphql/common_types/dns.py b/selfprivacy_api/graphql/common_types/dns.py index 1c79036..f47daa8 100644 --- a/selfprivacy_api/graphql/common_types/dns.py +++ b/selfprivacy_api/graphql/common_types/dns.py @@ -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""" diff --git a/selfprivacy_api/graphql/common_types/service.py b/selfprivacy_api/graphql/common_types/service.py index 314e6b6..275c14c 100644 --- a/selfprivacy_api/graphql/common_types/service.py +++ b/selfprivacy_api/graphql/common_types/service.py @@ -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: diff --git a/selfprivacy_api/jobs/upgrade_system.py b/selfprivacy_api/jobs/upgrade_system.py index 940efdb..ab16120 100644 --- a/selfprivacy_api/jobs/upgrade_system.py +++ b/selfprivacy_api/jobs/upgrade_system.py @@ -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) diff --git a/selfprivacy_api/models/services.py b/selfprivacy_api/models/services.py new file mode 100644 index 0000000..638ecf8 --- /dev/null +++ b/selfprivacy_api/models/services.py @@ -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 diff --git a/selfprivacy_api/services/service.py b/selfprivacy_api/services/service.py index 9add2dc..64a1e80 100644 --- a/selfprivacy_api/services/service.py +++ b/selfprivacy_api/services/service.py @@ -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 diff --git a/selfprivacy_api/services/test_service/__init__.py b/selfprivacy_api/services/test_service/__init__.py index f869bb3..caf4666 100644 --- a/selfprivacy_api/services/test_service/__init__.py +++ b/selfprivacy_api/services/test_service/__init__.py @@ -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 diff --git a/selfprivacy_api/utils/systemd.py b/selfprivacy_api/utils/systemd.py index f8b6244..3b3ec6c 100644 --- a/selfprivacy_api/utils/systemd.py +++ b/selfprivacy_api/utils/systemd.py @@ -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: diff --git a/tests/test_graphql/test_services.py b/tests/test_graphql/test_services.py index 06ef3a1..6e8dcf6 100644 --- a/tests/test_graphql/test_services.py +++ b/tests/test_graphql/test_services.py @@ -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): diff --git a/tests/test_graphql/test_system_nixos_tasks.py b/tests/test_graphql/test_system_nixos_tasks.py index b50223e..2b60fe5 100644 --- a/tests/test_graphql/test_system_nixos_tasks.py +++ b/tests/test_graphql/test_system_nixos_tasks.py @@ -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={