mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-10 04:25:47 +00:00
feat: add workers autodicsovery feature (#121)
* add workers autodicsovery feature * suppress erros while retrieving worker list * update recipes * fix tests and update docs * filter health checks * ping based workers
This commit is contained in:
@ -24,6 +24,7 @@ from collections.abc import Generator
|
||||
from ahriman.application.handlers import Handler
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.triggers import TriggerLoader
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
|
||||
@ -53,13 +54,16 @@ class Web(Handler):
|
||||
spawner = Spawn(args.parser(), list(spawner_args))
|
||||
spawner.start()
|
||||
|
||||
triggers = TriggerLoader.load(repository_id, configuration)
|
||||
triggers.on_start()
|
||||
|
||||
dummy_args = argparse.Namespace(
|
||||
architecture=None,
|
||||
configuration=args.configuration,
|
||||
repository=None,
|
||||
repository_id=None,
|
||||
)
|
||||
repositories = cls.repositories_extract(dummy_args)
|
||||
repositories = Web.repositories_extract(dummy_args)
|
||||
application = setup_server(configuration, spawner, repositories)
|
||||
run_server(application)
|
||||
|
||||
|
22
src/ahriman/core/distributed/__init__.py
Normal file
22
src/ahriman/core/distributed/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from ahriman.core.distributed.worker_loader_trigger import WorkerLoaderTrigger
|
||||
from ahriman.core.distributed.worker_trigger import WorkerTrigger
|
||||
from ahriman.core.distributed.workers_cache import WorkersCache
|
130
src/ahriman/core/distributed/distributed_system.py
Normal file
130
src/ahriman/core/distributed/distributed_system.py
Normal file
@ -0,0 +1,130 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import contextlib
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.configuration.schema import ConfigurationSchema
|
||||
from ahriman.core.status.web_client import WebClient
|
||||
from ahriman.core.triggers import Trigger
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.worker import Worker
|
||||
|
||||
|
||||
class DistributedSystem(Trigger, WebClient):
|
||||
"""
|
||||
simple class to (un)register itself as a distributed worker
|
||||
"""
|
||||
|
||||
CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"worker": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"empty": False,
|
||||
"is_url": [],
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
},
|
||||
"time_to_live": {
|
||||
"type": "integer",
|
||||
"coerce": "integer",
|
||||
"min": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
|
||||
"""
|
||||
default constructor
|
||||
|
||||
Args:
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
"""
|
||||
Trigger.__init__(self, repository_id, configuration)
|
||||
WebClient.__init__(self, repository_id, configuration)
|
||||
|
||||
@cached_property
|
||||
def worker(self) -> Worker:
|
||||
"""
|
||||
load and set worker. Lazy property loaded because it is not always required
|
||||
|
||||
Returns:
|
||||
Worker: unique self worker identifier
|
||||
"""
|
||||
section = next(iter(self.configuration_sections(self.configuration)))
|
||||
|
||||
address = self.configuration.get(section, "address")
|
||||
identifier = self.configuration.get(section, "identifier", fallback="")
|
||||
return Worker(address, identifier=identifier)
|
||||
|
||||
@classmethod
|
||||
def configuration_sections(cls, configuration: Configuration) -> list[str]:
|
||||
"""
|
||||
extract configuration sections from configuration
|
||||
|
||||
Args:
|
||||
configuration(Configuration): configuration instance
|
||||
|
||||
Returns:
|
||||
list[str]: read configuration sections belong to this trigger
|
||||
"""
|
||||
return list(cls.CONFIGURATION_SCHEMA.keys())
|
||||
|
||||
def _workers_url(self) -> str:
|
||||
"""
|
||||
workers url generator
|
||||
|
||||
Returns:
|
||||
str: full url of web service for workers
|
||||
"""
|
||||
return f"{self.address}/api/v1/distributed"
|
||||
|
||||
def register(self) -> None:
|
||||
"""
|
||||
register itself in remote system
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
self.make_request("POST", self._workers_url(), json=self.worker.view())
|
||||
|
||||
def workers(self) -> list[Worker]:
|
||||
"""
|
||||
retrieve list of available remote workers
|
||||
|
||||
Returns:
|
||||
list[Worker]: currently registered workers
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
response = self.make_request("GET", self._workers_url())
|
||||
response_json = response.json()
|
||||
|
||||
return [
|
||||
Worker(worker["address"], identifier=worker["identifier"])
|
||||
for worker in response_json
|
||||
]
|
||||
|
||||
return []
|
40
src/ahriman/core/distributed/worker_loader_trigger.py
Normal file
40
src/ahriman/core/distributed/worker_loader_trigger.py
Normal file
@ -0,0 +1,40 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from ahriman.core.distributed.distributed_system import DistributedSystem
|
||||
|
||||
|
||||
class WorkerLoaderTrigger(DistributedSystem):
|
||||
"""
|
||||
remote worker processor trigger (server side)
|
||||
"""
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""
|
||||
trigger action which will be called at the start of the application
|
||||
"""
|
||||
if self.configuration.has_option("build", "workers"):
|
||||
return # there is manually set option
|
||||
|
||||
workers = [worker.address for worker in self.workers()]
|
||||
if not workers:
|
||||
return
|
||||
|
||||
self.logger.info("load workers %s", workers)
|
||||
self.configuration.set_option("build", "workers", " ".join(workers))
|
70
src/ahriman/core/distributed/worker_trigger.py
Normal file
70
src/ahriman/core/distributed/worker_trigger.py
Normal file
@ -0,0 +1,70 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from threading import Timer
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.distributed.distributed_system import DistributedSystem
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
|
||||
class WorkerTrigger(DistributedSystem):
|
||||
"""
|
||||
remote worker processor trigger (client side)
|
||||
|
||||
Attributes:
|
||||
ping_interval(float): interval to call remote service in seconds, defined as ``worker.time_to_live / 4``
|
||||
timer(Timer): timer object
|
||||
"""
|
||||
|
||||
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
|
||||
"""
|
||||
default constructor
|
||||
|
||||
Args:
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
"""
|
||||
DistributedSystem.__init__(self, repository_id, configuration)
|
||||
|
||||
section = next(iter(self.configuration_sections(configuration)))
|
||||
self.ping_interval = configuration.getint(section, "time_to_live", fallback=60) / 4.0
|
||||
self.timer = Timer(self.ping_interval, self.ping)
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""
|
||||
trigger action which will be called at the start of the application
|
||||
"""
|
||||
self.logger.info("registering instance %s at %s", self.worker, self.address)
|
||||
self.timer.start()
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""
|
||||
trigger action which will be called before the stop of the application
|
||||
"""
|
||||
self.logger.info("removing instance %s at %s", self.worker, self.address)
|
||||
self.timer.cancel()
|
||||
|
||||
def ping(self) -> None:
|
||||
"""
|
||||
register itself as alive worker and update the timer
|
||||
"""
|
||||
self.register()
|
||||
self.timer = Timer(self.ping_interval, self.ping)
|
||||
self.timer.start()
|
73
src/ahriman/core/distributed/workers_cache.py
Normal file
73
src/ahriman/core/distributed/workers_cache.py
Normal file
@ -0,0 +1,73 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import time
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.log import LazyLogging
|
||||
from ahriman.models.worker import Worker
|
||||
|
||||
|
||||
class WorkersCache(LazyLogging):
|
||||
"""
|
||||
cached storage for healthy workers
|
||||
|
||||
Attributes:
|
||||
time_to_live(int): maximal amount of time in seconds to keep worker alive
|
||||
"""
|
||||
|
||||
def __init__(self, configuration: Configuration) -> None:
|
||||
"""
|
||||
default constructor
|
||||
|
||||
Args:
|
||||
configuration(Configuration): configuration instance
|
||||
"""
|
||||
self.time_to_live = configuration.getint("worker", "time_to_live", fallback=60)
|
||||
self._workers: dict[str, tuple[Worker, float]] = {}
|
||||
|
||||
@property
|
||||
def workers(self) -> list[Worker]:
|
||||
"""
|
||||
extract currently healthy workers
|
||||
|
||||
Returns:
|
||||
list[Worker]: list of currently registered workers which have been seen not earlier than :attr:`time_to_live`
|
||||
"""
|
||||
valid_from = time.monotonic() - self.time_to_live
|
||||
return [
|
||||
worker
|
||||
for worker, last_seen in self._workers.values()
|
||||
if last_seen > valid_from
|
||||
]
|
||||
|
||||
def workers_remove(self) -> None:
|
||||
"""
|
||||
remove all workers from the cache
|
||||
"""
|
||||
self._workers = {}
|
||||
|
||||
def workers_update(self, worker: Worker) -> None:
|
||||
"""
|
||||
register or update remote worker
|
||||
|
||||
Args:
|
||||
worker(Worker): worker to register
|
||||
"""
|
||||
self._workers[worker.identifier] = (worker, time.monotonic())
|
@ -47,8 +47,8 @@ class SyncHttpClient(LazyLogging):
|
||||
default constructor
|
||||
|
||||
Args:
|
||||
configuration(Configuration | None): configuration instance (Default value = None)
|
||||
section(str, optional): settings section name (Default value = None)
|
||||
configuration(Configuration | None, optional): configuration instance (Default value = None)
|
||||
section(str | None, optional): settings section name (Default value = None)
|
||||
suppress_errors(bool, optional): suppress logging of request errors (Default value = False)
|
||||
"""
|
||||
if configuration is None:
|
||||
|
@ -27,13 +27,44 @@ class FilteredAccessLogger(AccessLogger):
|
||||
access logger implementation with log filter enabled
|
||||
|
||||
Attributes:
|
||||
DISTRIBUTED_PATH_REGEX(str): (class attribute) regex used for distributed system uri
|
||||
HEALTH_PATH_REGEX(re.Pattern): (class attribute) regex for health check endpoint
|
||||
LOG_PATH_REGEX(re.Pattern): (class attribute) regex for logs uri
|
||||
PROCESS_PATH_REGEX(re.Pattern): (class attribute) regex for process uri
|
||||
"""
|
||||
|
||||
DISTRIBUTED_PATH_REGEX = "/api/v1/distributed"
|
||||
HEALTH_PATH_REGEX = "/api/v1/info"
|
||||
LOG_PATH_REGEX = re.compile(r"^/api/v1/packages/[^/]+/logs$")
|
||||
# technically process id is uuid, but we might change it later
|
||||
PROCESS_PATH_REGEX = re.compile(r"^/api/v1/service/process/[^/]+$")
|
||||
|
||||
@staticmethod
|
||||
def is_distributed_post(request: BaseRequest) -> bool:
|
||||
"""
|
||||
check if the request is for distributed services ping
|
||||
|
||||
Args:
|
||||
request(BaseRequest): http reqeust descriptor
|
||||
|
||||
Returns:
|
||||
bool: True in case if request is distributed service ping endpoint and False otherwise
|
||||
"""
|
||||
return request.method == "POST" and FilteredAccessLogger.DISTRIBUTED_PATH_REGEX == request.path
|
||||
|
||||
@staticmethod
|
||||
def is_info_get(request: BaseRequest) -> bool:
|
||||
"""
|
||||
check if the request is for health check
|
||||
|
||||
Args:
|
||||
request(BaseRequest): http reqeust descriptor
|
||||
|
||||
Returns:
|
||||
bool: True in case if request is health check and false otherwise
|
||||
"""
|
||||
return request.method == "GET" and FilteredAccessLogger.HEALTH_PATH_REGEX == request.path
|
||||
|
||||
@staticmethod
|
||||
def is_logs_post(request: BaseRequest) -> bool:
|
||||
"""
|
||||
@ -69,7 +100,9 @@ class FilteredAccessLogger(AccessLogger):
|
||||
response(StreamResponse): streaming response object
|
||||
time(float): log record timestamp
|
||||
"""
|
||||
if self.is_logs_post(request) \
|
||||
if self.is_distributed_post(request) \
|
||||
or self.is_info_get(request) \
|
||||
or self.is_logs_post(request) \
|
||||
or self.is_process_get(request):
|
||||
return
|
||||
AccessLogger.log(self, request, response, time)
|
||||
|
@ -118,7 +118,6 @@ class WebClient(Client, SyncAhrimanClient):
|
||||
Returns:
|
||||
str: full url of web service for specific package base
|
||||
"""
|
||||
# in case if unix socket is used we need to normalize url
|
||||
suffix = f"/{package_base}" if package_base else ""
|
||||
return f"{self.address}/api/v1/packages{suffix}"
|
||||
|
||||
|
@ -262,6 +262,6 @@ class TriggerLoader(LazyLogging):
|
||||
run triggers before the application exit
|
||||
"""
|
||||
self.logger.debug("executing triggers on stop")
|
||||
for trigger in self.triggers:
|
||||
for trigger in reversed(self.triggers):
|
||||
with self.__execute_trigger(trigger):
|
||||
trigger.on_stop()
|
||||
|
@ -18,8 +18,11 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ahriman.core.util import dataclass_view
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Worker:
|
||||
@ -39,3 +42,12 @@ class Worker:
|
||||
update identifier based on settings
|
||||
"""
|
||||
object.__setattr__(self, "identifier", self.identifier or urlparse(self.address).netloc)
|
||||
|
||||
def view(self) -> dict[str, Any]:
|
||||
"""
|
||||
generate json patch view
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: json-friendly dictionary
|
||||
"""
|
||||
return dataclass_view(self)
|
||||
|
@ -21,6 +21,7 @@ from aiohttp.web import AppKey
|
||||
|
||||
from ahriman.core.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.distributed import WorkersCache
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
@ -31,6 +32,7 @@ __all__ = [
|
||||
"ConfigurationKey",
|
||||
"SpawnKey",
|
||||
"WatcherKey",
|
||||
"WorkersKey",
|
||||
]
|
||||
|
||||
|
||||
@ -38,3 +40,4 @@ AuthKey = AppKey("validator", Auth)
|
||||
ConfigurationKey = AppKey("configuration", Configuration)
|
||||
SpawnKey = AppKey("spawn", Spawn)
|
||||
WatcherKey = AppKey("watcher", dict[RepositoryId, Watcher])
|
||||
WorkersKey = AppKey("workers", WorkersCache)
|
||||
|
@ -47,5 +47,6 @@ from ahriman.web.schemas.remote_schema import RemoteSchema
|
||||
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
|
||||
from ahriman.web.schemas.search_schema import SearchSchema
|
||||
from ahriman.web.schemas.status_schema import StatusSchema
|
||||
from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema
|
||||
from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema
|
||||
from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema
|
||||
from ahriman.web.schemas.worker_schema import WorkerSchema
|
||||
|
35
src/ahriman/web/schemas/worker_schema.py
Normal file
35
src/ahriman/web/schemas/worker_schema.py
Normal file
@ -0,0 +1,35 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
|
||||
class WorkerSchema(Schema):
|
||||
"""
|
||||
request and response schema for workers
|
||||
"""
|
||||
|
||||
address = fields.String(required=True, metadata={
|
||||
"description": "Worker address",
|
||||
"example": "http://localhost:8081",
|
||||
})
|
||||
identifier = fields.String(required=True, metadata={
|
||||
"description": "Worker unique identifier",
|
||||
"example": "42f03a62-48f7-46b7-af40-dacc720e92fa",
|
||||
})
|
@ -24,12 +24,13 @@ from typing import TypeVar
|
||||
|
||||
from ahriman.core.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.distributed import WorkersCache
|
||||
from ahriman.core.sign.gpg import GPG
|
||||
from ahriman.core.spawn import Spawn
|
||||
from ahriman.core.status.watcher import Watcher
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey
|
||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
||||
|
||||
|
||||
T = TypeVar("T", str, list[str])
|
||||
@ -97,6 +98,16 @@ class BaseView(View, CorsViewMixin):
|
||||
"""
|
||||
return self.request.app[AuthKey]
|
||||
|
||||
@property
|
||||
def workers(self) -> WorkersCache:
|
||||
"""
|
||||
get workers cache instance
|
||||
|
||||
Returns:
|
||||
WorkersCache: workers service
|
||||
"""
|
||||
return self.request.app[WorkersKey]
|
||||
|
||||
@classmethod
|
||||
async def get_permission(cls, request: Request) -> UserAccess:
|
||||
"""
|
||||
|
19
src/ahriman/web/views/v1/distributed/__init__.py
Normal file
19
src/ahriman/web/views/v1/distributed/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
126
src/ahriman/web/views/v1/distributed/workers.py
Normal file
126
src/ahriman/web/views/v1/distributed/workers.py
Normal file
@ -0,0 +1,126 @@
|
||||
#
|
||||
# Copyright (c) 2021-2023 ahriman team.
|
||||
#
|
||||
# This file is part of ahriman
|
||||
# (see https://github.com/arcan1s/ahriman).
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import aiohttp_apispec # type: ignore[import-untyped]
|
||||
|
||||
from collections.abc import Callable
|
||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
||||
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.models.worker import Worker
|
||||
from ahriman.web.schemas import AuthSchema, ErrorSchema, WorkerSchema
|
||||
from ahriman.web.views.base import BaseView
|
||||
|
||||
|
||||
class WorkersView(BaseView):
|
||||
"""
|
||||
distributed workers view
|
||||
|
||||
Attributes:
|
||||
DELETE_PERMISSION(UserAccess): (class attribute) delete permissions of self
|
||||
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
||||
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
|
||||
"""
|
||||
|
||||
DELETE_PERMISSION = GET_PERMISSION = POST_PERMISSION = UserAccess.Full
|
||||
ROUTES = ["/api/v1/distributed"]
|
||||
|
||||
@aiohttp_apispec.docs(
|
||||
tags=["Distributed"],
|
||||
summary="Unregister all workers",
|
||||
description="Unregister and remove all known workers from the service",
|
||||
responses={
|
||||
204: {"description": "Success response"},
|
||||
401: {"description": "Authorization required", "schema": ErrorSchema},
|
||||
403: {"description": "Access is forbidden", "schema": ErrorSchema},
|
||||
500: {"description": "Internal server error", "schema": ErrorSchema},
|
||||
},
|
||||
security=[{"token": [DELETE_PERMISSION]}],
|
||||
)
|
||||
@aiohttp_apispec.cookies_schema(AuthSchema)
|
||||
async def delete(self) -> None:
|
||||
"""
|
||||
unregister worker
|
||||
|
||||
Raises:
|
||||
HTTPNoContent: on success response
|
||||
"""
|
||||
self.workers.workers_remove()
|
||||
|
||||
raise HTTPNoContent
|
||||
|
||||
@aiohttp_apispec.docs(
|
||||
tags=["Distributed"],
|
||||
summary="Get workers",
|
||||
description="Retrieve registered workers",
|
||||
responses={
|
||||
200: {"description": "Success response", "schema": WorkerSchema(many=True)},
|
||||
401: {"description": "Authorization required", "schema": ErrorSchema},
|
||||
403: {"description": "Access is forbidden", "schema": ErrorSchema},
|
||||
500: {"description": "Internal server error", "schema": ErrorSchema},
|
||||
},
|
||||
security=[{"token": [GET_PERMISSION]}],
|
||||
)
|
||||
@aiohttp_apispec.cookies_schema(AuthSchema)
|
||||
async def get(self) -> Response:
|
||||
"""
|
||||
get workers list
|
||||
|
||||
Returns:
|
||||
Response: 200 with workers list on success
|
||||
"""
|
||||
workers = self.workers.workers
|
||||
|
||||
comparator: Callable[[Worker], str] = lambda item: item.identifier
|
||||
response = [worker.view() for worker in sorted(workers, key=comparator)]
|
||||
|
||||
return json_response(response)
|
||||
|
||||
@aiohttp_apispec.docs(
|
||||
tags=["Distributed"],
|
||||
summary="Register worker",
|
||||
description="Register or update remote worker",
|
||||
responses={
|
||||
204: {"description": "Success response"},
|
||||
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
|
||||
401: {"description": "Authorization required", "schema": ErrorSchema},
|
||||
403: {"description": "Access is forbidden", "schema": ErrorSchema},
|
||||
500: {"description": "Internal server error", "schema": ErrorSchema},
|
||||
},
|
||||
security=[{"token": [POST_PERMISSION]}],
|
||||
)
|
||||
@aiohttp_apispec.cookies_schema(AuthSchema)
|
||||
@aiohttp_apispec.json_schema(WorkerSchema)
|
||||
async def post(self) -> None:
|
||||
"""
|
||||
register remote worker
|
||||
|
||||
Raises:
|
||||
HTTPBadRequest: if bad data is supplied
|
||||
HTTPNoContent: in case of success response
|
||||
"""
|
||||
try:
|
||||
data = await self.request.json()
|
||||
worker = Worker(data["address"], identifier=data["identifier"])
|
||||
except Exception as ex:
|
||||
raise HTTPBadRequest(reason=str(ex))
|
||||
|
||||
self.workers.workers_update(worker)
|
||||
|
||||
raise HTTPNoContent
|
@ -27,6 +27,7 @@ from aiohttp.web import Application, normalize_path_middleware, run_app
|
||||
from ahriman.core.auth import Auth
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.database import SQLite
|
||||
from ahriman.core.distributed import WorkersCache
|
||||
from ahriman.core.exceptions import InitializeError
|
||||
from ahriman.core.log.filtered_access_logger import FilteredAccessLogger
|
||||
from ahriman.core.spawn import Spawn
|
||||
@ -34,7 +35,7 @@ from ahriman.core.status.watcher import Watcher
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.web.apispec import setup_apispec
|
||||
from ahriman.web.cors import setup_cors
|
||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey
|
||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
||||
from ahriman.web.middlewares.exception_handler import exception_handler
|
||||
from ahriman.web.routes import setup_routes
|
||||
|
||||
@ -159,7 +160,8 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
||||
application.logger.info("setup configuration")
|
||||
application[ConfigurationKey] = configuration
|
||||
|
||||
application.logger.info("setup watchers")
|
||||
application.logger.info("setup services")
|
||||
# package cache
|
||||
if not repositories:
|
||||
raise InitializeError("No repositories configured, exiting")
|
||||
database = SQLite.load(configuration)
|
||||
@ -168,8 +170,9 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
||||
application.logger.info("load repository %s", repository_id)
|
||||
watchers[repository_id] = Watcher(repository_id, database)
|
||||
application[WatcherKey] = watchers
|
||||
|
||||
application.logger.info("setup process spawner")
|
||||
# workers cache
|
||||
application[WorkersKey] = WorkersCache(configuration)
|
||||
# process spawner
|
||||
application[SpawnKey] = spawner
|
||||
|
||||
application.logger.info("setup authorization")
|
||||
|
Reference in New Issue
Block a user