From 9cd8d75f73cf9a2614d3147c5c4a2e3d9c888a68 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Tue, 2 Jul 2024 23:35:28 +0400 Subject: [PATCH] feat: service configuration prototype --- selfprivacy_api/dependencies.py | 2 +- .../graphql/common_types/service.py | 50 +++++++++++++ .../graphql/mutations/services_mutations.py | 38 ++++++++++ .../services/bitwarden/__init__.py | 8 +- selfprivacy_api/services/config_item.py | 73 +++++++++++++++++++ selfprivacy_api/services/forgejo/__init__.py | 69 ++++++++++++++++-- .../services/jitsimeet/__init__.py | 8 +- .../services/mailserver/__init__.py | 8 +- .../services/nextcloud/__init__.py | 8 +- selfprivacy_api/services/ocserv/__init__.py | 8 +- selfprivacy_api/services/pleroma/__init__.py | 8 +- selfprivacy_api/services/service.py | 10 +-- .../services/test_service/__init__.py | 8 +- 13 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 selfprivacy_api/services/config_item.py diff --git a/selfprivacy_api/dependencies.py b/selfprivacy_api/dependencies.py index 69ce319..7b3bab4 100644 --- a/selfprivacy_api/dependencies.py +++ b/selfprivacy_api/dependencies.py @@ -27,4 +27,4 @@ async def get_token_header( def get_api_version() -> str: """Get API version""" - return "3.2.2" + return "3.2.2+configs" diff --git a/selfprivacy_api/graphql/common_types/service.py b/selfprivacy_api/graphql/common_types/service.py index 275c14c..d97934a 100644 --- a/selfprivacy_api/graphql/common_types/service.py +++ b/selfprivacy_api/graphql/common_types/service.py @@ -103,6 +103,25 @@ def service_dns_to_graphql(record: ServiceDnsRecord) -> DnsRecord: ) +@strawberry.interface +class ConfigItem: + id: str + description: str + widget: str + type: str + + +@strawberry.type +class StringConfigItem(ConfigItem): + value: str + regex: Optional[str] + + +@strawberry.type +class BoolConfigItem(ConfigItem): + value: bool + + @strawberry.type class Service: id: str @@ -132,6 +151,37 @@ class Service: """Get storage usage for a service""" return get_storage_usage(self) + @strawberry.field + def configuration(self) -> Optional[List[ConfigItem]]: + """Get service configuration""" + service = get_service_by_id(self.id) + if service is None: + return None + config_items = service.get_configuration() + # If it is an empty dict, return none + if not config_items: + return None + # By the "type" field convert every dict into a ConfigItem. In the future there will be more types. + return [ + StringConfigItem( + id=item["id"], + description=item["description"], + widget=item["widget"], + type=item["type"], + value=item["value"], + regex=item.get("regex"), + ) + if item["type"] == "string" + else BoolConfigItem( + id=item["id"], + description=item["description"], + widget=item["widget"], + type=item["type"], + value=item["value"], + ) + for item in config_items + ] + # TODO: fill this @strawberry.field def backup_snapshots(self) -> Optional[List["SnapshotInfo"]]: diff --git a/selfprivacy_api/graphql/mutations/services_mutations.py b/selfprivacy_api/graphql/mutations/services_mutations.py index be0cb77..91ecaac 100644 --- a/selfprivacy_api/graphql/mutations/services_mutations.py +++ b/selfprivacy_api/graphql/mutations/services_mutations.py @@ -33,6 +33,16 @@ class ServiceMutationReturn(GenericMutationReturn): service: typing.Optional[Service] = None +@strawberry.input +class SetServiceConfigurationInput: + """Set service configuration input type. + The values might be of different types: str or bool. + """ + + service_id: str + configuration: strawberry.scalars.JSON + + @strawberry.input class MoveServiceInput: """Move service input type.""" @@ -157,6 +167,34 @@ class ServicesMutations: service=service_to_graphql_service(service), ) + @strawberry.mutation(permission_classes=[IsAuthenticated]) + def set_service_configuration( + self, input: SetServiceConfigurationInput + ) -> ServiceMutationReturn: + """Set the new configuration values""" + service = get_service_by_id(input.service_id) + if service is None: + return ServiceMutationReturn( + success=False, + message=f"Service does not exist: {input.service_id}", + code=404, + ) + try: + service.set_configuration(input.configuration) + return ServiceMutationReturn( + success=True, + message="Service configuration updated.", + code=200, + service=service_to_graphql_service(service), + ) + except Exception as e: + return ServiceMutationReturn( + success=False, + message=pretty_error(e), + code=400, + service=service_to_graphql_service(service), + ) + @strawberry.mutation(permission_classes=[IsAuthenticated]) def move_service(self, input: MoveServiceInput) -> ServiceJobMutationReturn: """Move service.""" diff --git a/selfprivacy_api/services/bitwarden/__init__.py b/selfprivacy_api/services/bitwarden/__init__.py index 52f1466..09c54af 100644 --- a/selfprivacy_api/services/bitwarden/__init__.py +++ b/selfprivacy_api/services/bitwarden/__init__.py @@ -84,12 +84,12 @@ class Bitwarden(Service): def restart(): subprocess.run(["systemctl", "restart", "vaultwarden.service"]) - @staticmethod - def get_configuration(): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/config_item.py b/selfprivacy_api/services/config_item.py new file mode 100644 index 0000000..3f10ca5 --- /dev/null +++ b/selfprivacy_api/services/config_item.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod +import re +from typing import Optional + + +class ConfigItem(ABC): + id: str + description: str + widget: str + type: str + + @abstractmethod + def get_value(self, service_options): + pass + + @abstractmethod + def set_value(self, value, service_options): + pass + + def as_dict(self, service_options): + return { + "id": self.id, + "type": self.type, + "description": self.description, + "widget": self.widget, + "value": self.get_value(service_options), + } + + +class StringConfigItem(ConfigItem): + def __init__( + self, + id: str, + default_value: str, + description: str, + regex: Optional[str] = None, + widget: Optional[str] = None, + ): + self.id = id + self.type = "string" + self.default_value = default_value + self.description = description + self.regex = re.compile(regex) if regex else None + self.widget = widget if widget else "text" + + def get_value(self, service_options): + return service_options.get(self.id, self.default_value) + + def set_value(self, value, service_options): + if self.regex and not self.regex.match(value): + raise ValueError(f"Value {value} does not match regex {self.regex}") + service_options[self.id] = value + + +class BoolConfigItem(ConfigItem): + def __init__( + self, + id: str, + default_value: bool, + description: str, + widget: Optional[str] = None, + ): + self.id = id + self.type = "bool" + self.default_value = default_value + self.description = description + self.widget = widget if widget else "switch" + + def get_value(self, service_options): + return service_options.get(self.id, self.default_value) + + def set_value(self, value, service_options): + service_options[self.id] = value diff --git a/selfprivacy_api/services/forgejo/__init__.py b/selfprivacy_api/services/forgejo/__init__.py index d035736..7a0f07a 100644 --- a/selfprivacy_api/services/forgejo/__init__.py +++ b/selfprivacy_api/services/forgejo/__init__.py @@ -3,11 +3,16 @@ import base64 import subprocess from typing import Optional, List -from selfprivacy_api.utils import get_domain +from selfprivacy_api.utils import get_domain, ReadUserData, WriteUserData from selfprivacy_api.utils.systemd import get_service_status from selfprivacy_api.services.service import Service, ServiceStatus from selfprivacy_api.services.forgejo.icon import FORGEJO_ICON +from selfprivacy_api.services.config_item import ( + StringConfigItem, + BoolConfigItem, + ConfigItem, +) class Forgejo(Service): @@ -16,6 +21,41 @@ class Forgejo(Service): Previously was Gitea, so some IDs are still called gitea for compatibility. """ + config_items: dict[str, ConfigItem] = { + "subdomain": StringConfigItem( + id="subdomain", + default_value="git", + description="Subdomain", + regex=r"[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]", + widget="subdomain", + ), + "appName": StringConfigItem( + id="appName", + default_value="SelfPrivacy git Service", + description="The name displayed in the web interface", + ), + "enableLfs": BoolConfigItem( + id="enableLfs", + default_value=True, + description="Enable Git LFS", + ), + "forcePrivate": BoolConfigItem( + id="forcePrivate", + default_value=False, + description="Force all new repositories to be private", + ), + "disableRegistration": BoolConfigItem( + id="disableRegistration", + default_value=False, + description="Disable registration of new users", + ), + "requireSigninView": BoolConfigItem( + id="requireSigninView", + default_value=False, + description="Require signin to view any page", + ), + } + @staticmethod def get_id() -> str: """Return service id. For compatibility keep in gitea.""" @@ -82,13 +122,28 @@ class Forgejo(Service): def restart(): subprocess.run(["systemctl", "restart", "forgejo.service"]) - @staticmethod - def get_configuration(): - return {} + @classmethod + def get_configuration(cls): + with ReadUserData() as user_data: + return { + key: cls.config_items[key].as_dict( + user_data.get("modules", {}).get(cls.get_id(), {}) + ) + for key in cls.config_items + } - @staticmethod - def set_configuration(config_items): - return super().set_configuration(config_items) + @classmethod + def set_configuration(cls, config_items): + with WriteUserData() as user_data: + if "modules" not in user_data: + user_data["modules"] = {} + if cls.get_id() not in user_data["modules"]: + user_data["modules"][cls.get_id()] = {} + for key, value in config_items.items(): + cls.config_items[key].set_value( + value, + user_data["modules"][cls.get_id()], + ) @staticmethod def get_logs(): diff --git a/selfprivacy_api/services/jitsimeet/__init__.py b/selfprivacy_api/services/jitsimeet/__init__.py index 53d572c..321bf17 100644 --- a/selfprivacy_api/services/jitsimeet/__init__.py +++ b/selfprivacy_api/services/jitsimeet/__init__.py @@ -88,12 +88,12 @@ class JitsiMeet(Service): ) subprocess.run(["systemctl", "restart", "jicofo.service"], check=False) - @staticmethod - def get_configuration(): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/mailserver/__init__.py b/selfprivacy_api/services/mailserver/__init__.py index d2e9b5d..67bfe16 100644 --- a/selfprivacy_api/services/mailserver/__init__.py +++ b/selfprivacy_api/services/mailserver/__init__.py @@ -89,12 +89,12 @@ class MailServer(Service): subprocess.run(["systemctl", "restart", "dovecot2.service"], check=False) subprocess.run(["systemctl", "restart", "postfix.service"], check=False) - @staticmethod - def get_configuration(): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/nextcloud/__init__.py b/selfprivacy_api/services/nextcloud/__init__.py index 3e5b8d3..7bffd4a 100644 --- a/selfprivacy_api/services/nextcloud/__init__.py +++ b/selfprivacy_api/services/nextcloud/__init__.py @@ -85,13 +85,13 @@ class Nextcloud(Service): """Restart Nextcloud service.""" subprocess.Popen(["systemctl", "restart", "phpfpm-nextcloud.service"]) - @staticmethod - def get_configuration() -> dict: + @classmethod + def get_configuration(cls) -> dict: """Return Nextcloud configuration.""" return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/ocserv/__init__.py b/selfprivacy_api/services/ocserv/__init__.py index 4dd802f..58ce915 100644 --- a/selfprivacy_api/services/ocserv/__init__.py +++ b/selfprivacy_api/services/ocserv/__init__.py @@ -69,12 +69,12 @@ class Ocserv(Service): def restart(): subprocess.run(["systemctl", "restart", "ocserv.service"], check=False) - @staticmethod - def get_configuration(): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/pleroma/__init__.py b/selfprivacy_api/services/pleroma/__init__.py index 44a9be8..abd52b8 100644 --- a/selfprivacy_api/services/pleroma/__init__.py +++ b/selfprivacy_api/services/pleroma/__init__.py @@ -72,12 +72,12 @@ class Pleroma(Service): subprocess.run(["systemctl", "restart", "pleroma.service"]) subprocess.run(["systemctl", "restart", "postgresql.service"]) - @staticmethod - def get_configuration(config_items): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod diff --git a/selfprivacy_api/services/service.py b/selfprivacy_api/services/service.py index 64a1e80..3a0a443 100644 --- a/selfprivacy_api/services/service.py +++ b/selfprivacy_api/services/service.py @@ -1,6 +1,6 @@ """Abstract class for a service running on a server""" from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Any, List, Optional from selfprivacy_api import utils from selfprivacy_api.utils import ReadUserData, WriteUserData @@ -179,14 +179,14 @@ class Service(ABC): """Restart the service. Usually this means restarting systemd unit.""" pass - @staticmethod + @classmethod @abstractmethod - def get_configuration(): + def get_configuration(cls) -> dict: pass - @staticmethod + @classmethod @abstractmethod - def set_configuration(config_items): + def set_configuration(cls, config_items): pass @staticmethod diff --git a/selfprivacy_api/services/test_service/__init__.py b/selfprivacy_api/services/test_service/__init__.py index caf4666..731b40d 100644 --- a/selfprivacy_api/services/test_service/__init__.py +++ b/selfprivacy_api/services/test_service/__init__.py @@ -163,12 +163,12 @@ class DummyService(Service): cls.set_status(ServiceStatus.RELOADING) # is a correct one? cls.change_status_with_async_delay(ServiceStatus.ACTIVE, cls.startstop_delay) - @staticmethod - def get_configuration(): + @classmethod + def get_configuration(cls): return {} - @staticmethod - def set_configuration(config_items): + @classmethod + def set_configuration(cls, config_items): return super().set_configuration(config_items) @staticmethod