mirror of
				https://github.com/arcan1s/ahriman.git
				synced 2025-10-30 21:33:43 +00:00 
			
		
		
		
	add workers autodicsovery feature
This commit is contained in:
		| @ -135,6 +135,8 @@ def _parser() -> argparse.ArgumentParser: | |||||||
|     _set_service_setup_parser(subparsers) |     _set_service_setup_parser(subparsers) | ||||||
|     _set_service_shell_parser(subparsers) |     _set_service_shell_parser(subparsers) | ||||||
|     _set_service_tree_migrate_parser(subparsers) |     _set_service_tree_migrate_parser(subparsers) | ||||||
|  |     _set_service_worker_register_parser(subparsers) | ||||||
|  |     _set_service_worker_unregister_parser(subparsers) | ||||||
|     _set_user_add_parser(subparsers) |     _set_user_add_parser(subparsers) | ||||||
|     _set_user_list_parser(subparsers) |     _set_user_list_parser(subparsers) | ||||||
|     _set_user_remove_parser(subparsers) |     _set_user_remove_parser(subparsers) | ||||||
| @ -1052,6 +1054,40 @@ def _set_service_tree_migrate_parser(root: SubParserAction) -> argparse.Argument | |||||||
|     return parser |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _set_service_worker_register_parser(root: SubParserAction) -> argparse.ArgumentParser: | ||||||
|  |     """ | ||||||
|  |     add parser for remote worker registration subcommand | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         root(SubParserAction): subparsers for the commands | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         argparse.ArgumentParser: created argument parser | ||||||
|  |     """ | ||||||
|  |     parser = root.add_parser("service-worker-register", help="register itself as worker", | ||||||
|  |                              description="call remote service registering itself as available worker", | ||||||
|  |                              formatter_class=_formatter) | ||||||
|  |     parser.set_defaults(handler=handlers.Triggers, trigger=["ahriman.core.distributed.WorkerRegisterTrigger"]) | ||||||
|  |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _set_service_worker_unregister_parser(root: SubParserAction) -> argparse.ArgumentParser: | ||||||
|  |     """ | ||||||
|  |     add parser for remote worker removal subcommand | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         root(SubParserAction): subparsers for the commands | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         argparse.ArgumentParser: created argument parser | ||||||
|  |     """ | ||||||
|  |     parser = root.add_parser("service-worker-unregister", help="unregister itself as worker", | ||||||
|  |                              description="call remote service removing itself from list of available workers", | ||||||
|  |                              formatter_class=_formatter) | ||||||
|  |     parser.set_defaults(handler=handlers.Triggers, trigger=["ahriman.core.distributed.WorkerUnregisterTrigger"]) | ||||||
|  |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
| def _set_user_add_parser(root: SubParserAction) -> argparse.ArgumentParser: | def _set_user_add_parser(root: SubParserAction) -> argparse.ArgumentParser: | ||||||
|     """ |     """ | ||||||
|     add parser for create user subcommand |     add parser for create user subcommand | ||||||
|  | |||||||
| @ -24,6 +24,7 @@ from collections.abc import Generator | |||||||
| from ahriman.application.handlers import Handler | from ahriman.application.handlers import Handler | ||||||
| from ahriman.core.configuration import Configuration | from ahriman.core.configuration import Configuration | ||||||
| from ahriman.core.spawn import Spawn | from ahriman.core.spawn import Spawn | ||||||
|  | from ahriman.core.triggers import TriggerLoader | ||||||
| from ahriman.models.repository_id import RepositoryId | from ahriman.models.repository_id import RepositoryId | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -53,13 +54,16 @@ class Web(Handler): | |||||||
|         spawner = Spawn(args.parser(), list(spawner_args)) |         spawner = Spawn(args.parser(), list(spawner_args)) | ||||||
|         spawner.start() |         spawner.start() | ||||||
|  |  | ||||||
|  |         triggers = TriggerLoader.load(repository_id, configuration) | ||||||
|  |         triggers.on_start() | ||||||
|  |  | ||||||
|         dummy_args = argparse.Namespace( |         dummy_args = argparse.Namespace( | ||||||
|             architecture=None, |             architecture=None, | ||||||
|             configuration=args.configuration, |             configuration=args.configuration, | ||||||
|             repository=None, |             repository=None, | ||||||
|             repository_id=None, |             repository_id=None, | ||||||
|         ) |         ) | ||||||
|         repositories = cls.repositories_extract(dummy_args) |         repositories = Web.repositories_extract(dummy_args) | ||||||
|         application = setup_server(configuration, spawner, repositories) |         application = setup_server(configuration, spawner, repositories) | ||||||
|         run_server(application) |         run_server(application) | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								src/ahriman/core/database/migrations/m013_workers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/ahriman/core/database/migrations/m013_workers.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | |||||||
|  | # | ||||||
|  | # 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/>. | ||||||
|  | # | ||||||
|  | __all__ = ["steps"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | steps = [ | ||||||
|  |     """ | ||||||
|  |     create table workers ( | ||||||
|  |         identifier text not null, | ||||||
|  |         address text not null, | ||||||
|  |         unique (identifier) | ||||||
|  |     ) | ||||||
|  |     """ | ||||||
|  | ] | ||||||
| @ -25,3 +25,4 @@ from ahriman.core.database.operations.changes_operations import ChangesOperation | |||||||
| from ahriman.core.database.operations.logs_operations import LogsOperations | from ahriman.core.database.operations.logs_operations import LogsOperations | ||||||
| from ahriman.core.database.operations.package_operations import PackageOperations | from ahriman.core.database.operations.package_operations import PackageOperations | ||||||
| from ahriman.core.database.operations.patch_operations import PatchOperations | from ahriman.core.database.operations.patch_operations import PatchOperations | ||||||
|  | from ahriman.core.database.operations.workers_operations import WorkersOperations | ||||||
|  | |||||||
							
								
								
									
										83
									
								
								src/ahriman/core/database/operations/workers_operations.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/ahriman/core/database/operations/workers_operations.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | |||||||
|  | # | ||||||
|  | # 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 sqlite3 import Connection | ||||||
|  |  | ||||||
|  | from ahriman.core.database.operations import Operations | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class WorkersOperations(Operations): | ||||||
|  |     """ | ||||||
|  |     operations for remote workers | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def workers_get(self) -> list[Worker]: | ||||||
|  |         """ | ||||||
|  |         retrieve registered workers | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             list[Worker]: list of available workers | ||||||
|  |         """ | ||||||
|  |         def run(connection: Connection) -> list[Worker]: | ||||||
|  |             return [ | ||||||
|  |                 Worker(row["address"], identifier=row["identifier"]) | ||||||
|  |                 for row in connection.execute("""select * from workers""") | ||||||
|  |             ] | ||||||
|  |  | ||||||
|  |         return self.with_connection(run) | ||||||
|  |  | ||||||
|  |     def workers_insert(self, worker: Worker) -> None: | ||||||
|  |         """ | ||||||
|  |         insert or update worker in database | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             worker(Worker): remote worker descriptor | ||||||
|  |         """ | ||||||
|  |         def run(connection: Connection) -> None: | ||||||
|  |             connection.execute( | ||||||
|  |                 """ | ||||||
|  |                 insert into workers | ||||||
|  |                 (identifier, address) | ||||||
|  |                 values | ||||||
|  |                 (:identifier, :address) | ||||||
|  |                 on conflict (identifier) do update set | ||||||
|  |                 address = :address | ||||||
|  |                 """, | ||||||
|  |                 worker.view() | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         return self.with_connection(run, commit=True) | ||||||
|  |  | ||||||
|  |     def workers_remove(self, identifier: str | None = None) -> None: | ||||||
|  |         """ | ||||||
|  |         unregister remote worker | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             identifier(str | None, optional): remote worker identifier. If none set it will clear all workers | ||||||
|  |                 (Default value = None) | ||||||
|  |         """ | ||||||
|  |         def run(connection: Connection) -> None: | ||||||
|  |             connection.execute( | ||||||
|  |                 """ | ||||||
|  |                 delete from workers where (:identifier is null or identifier = :identifier) | ||||||
|  |                 """, | ||||||
|  |                 {"identifier": identifier}) | ||||||
|  |  | ||||||
|  |         return self.with_connection(run, commit=True) | ||||||
| @ -26,11 +26,12 @@ from typing import Self | |||||||
| from ahriman.core.configuration import Configuration | from ahriman.core.configuration import Configuration | ||||||
| from ahriman.core.database.migrations import Migrations | from ahriman.core.database.migrations import Migrations | ||||||
| from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, LogsOperations, \ | from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, LogsOperations, \ | ||||||
|     PackageOperations, PatchOperations |     PackageOperations, PatchOperations, WorkersOperations | ||||||
|  |  | ||||||
|  |  | ||||||
| # pylint: disable=too-many-ancestors | # pylint: disable=too-many-ancestors | ||||||
| class SQLite(AuthOperations, BuildOperations, ChangesOperations, LogsOperations, PackageOperations, PatchOperations): | class SQLite(AuthOperations, BuildOperations, ChangesOperations, LogsOperations, PackageOperations, PatchOperations, | ||||||
|  |              WorkersOperations): | ||||||
|     """ |     """ | ||||||
|     wrapper for sqlite3 database |     wrapper for sqlite3 database | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								src/ahriman/core/distributed/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/ahriman/core/distributed/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | # | ||||||
|  | # 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_register_trigger import WorkerRegisterTrigger | ||||||
|  | from ahriman.core.distributed.worker_trigger import WorkerTrigger | ||||||
|  | from ahriman.core.distributed.worker_unregister_trigger import WorkerUnregisterTrigger | ||||||
							
								
								
									
										170
									
								
								src/ahriman/core/distributed/distributed_system.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/ahriman/core/distributed/distributed_system.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,170 @@ | |||||||
|  | # | ||||||
|  | # 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 tempfile | ||||||
|  | import uuid | ||||||
|  |  | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         identifier_path(Path): path to cached worker identifier | ||||||
|  |         worker(Worker): unique self identifier | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     CONFIGURATION_SCHEMA: ConfigurationSchema = { | ||||||
|  |         "worker": { | ||||||
|  |             "type": "dict", | ||||||
|  |             "schema": { | ||||||
|  |                 "address": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "required": True, | ||||||
|  |                     "empty": False, | ||||||
|  |                     "is_url": [], | ||||||
|  |                 }, | ||||||
|  |                 "identifier": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "empty": False, | ||||||
|  |                 }, | ||||||
|  |                 "identifier_path": { | ||||||
|  |                     "type": "path", | ||||||
|  |                     "coerce": "absolute_path", | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |  | ||||||
|  |         section = next(iter(self.configuration_sections(configuration))) | ||||||
|  |         self.identifier_path = configuration.getpath( | ||||||
|  |             section, "identifier_path", fallback=Path(tempfile.gettempdir()) / "ahriman-worker-identifier") | ||||||
|  |         self._owe_identifier = False | ||||||
|  |  | ||||||
|  |         identifier = self.load_identifier(configuration, section) | ||||||
|  |         self.worker = Worker(configuration.get(section, "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, identifier: str = "") -> str: | ||||||
|  |         """ | ||||||
|  |         workers url generator | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             identifier(str, optional): worker identifier (Default value = "") | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             str: full url of web service for specific worker | ||||||
|  |         """ | ||||||
|  |         suffix = f"/{identifier}" if identifier else "" | ||||||
|  |         return f"{self.address}/api/v1/distributed{suffix}" | ||||||
|  |  | ||||||
|  |     def load_identifier(self, configuration: Configuration, section: str) -> str: | ||||||
|  |         """ | ||||||
|  |         load identifier from filesystem if available or from configuration otherwise. If cache file is available, | ||||||
|  |         the method will read from it. Otherwise, it will try to read it from configuration. And, finally, if no | ||||||
|  |         identifier set, it will generate uuid | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             configuration(Configuration): configuration instance | ||||||
|  |             section(str): settings section name | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             str: unique worker identifier | ||||||
|  |         """ | ||||||
|  |         if self.identifier_path.is_file():  # load cached value | ||||||
|  |             return self.identifier_path.read_text(encoding="utf8") | ||||||
|  |         return configuration.get(section, "identifier", fallback=str(uuid.uuid4())) | ||||||
|  |  | ||||||
|  |     def register(self, force: bool = False) -> None: | ||||||
|  |         """ | ||||||
|  |         register itself in remote system | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             force(bool, optional): register worker even if it has been already registered before (Default value = False) | ||||||
|  |         """ | ||||||
|  |         if self.identifier_path.is_file() and not force: | ||||||
|  |             return  # there is already registered identifier | ||||||
|  |  | ||||||
|  |         self.make_request("POST", self._workers_url(), json=self.worker.view()) | ||||||
|  |         # save identifier | ||||||
|  |         self.identifier_path.write_text(self.worker.identifier, encoding="utf8") | ||||||
|  |         self._owe_identifier = True | ||||||
|  |         self.logger.info("registered instance %s at %s", self.worker, self.address) | ||||||
|  |  | ||||||
|  |     def unregister(self, force: bool = False) -> None: | ||||||
|  |         """ | ||||||
|  |         unregister itself in remote system | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             force(bool, optional): unregister worker even if it has been registered in another process | ||||||
|  |                 (Default value = False) | ||||||
|  |         """ | ||||||
|  |         if not self._owe_identifier and not force: | ||||||
|  |             return  # we do not owe this identifier | ||||||
|  |  | ||||||
|  |         self.make_request("DELETE", self._workers_url(self.worker.identifier)) | ||||||
|  |         self.identifier_path.unlink(missing_ok=True) | ||||||
|  |         self.logger.info("unregistered instance %s at %s", self.worker, self.address) | ||||||
|  |  | ||||||
|  |     def workers(self) -> list[Worker]: | ||||||
|  |         """ | ||||||
|  |         retrieve list of available remote workers | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             list[Worker]: currently registered workers | ||||||
|  |         """ | ||||||
|  |         response = self.make_request("GET", self._workers_url()) | ||||||
|  |         response_json = response.json() | ||||||
|  |  | ||||||
|  |         return [ | ||||||
|  |             Worker(worker["address"], identifier=worker["identifier"]) | ||||||
|  |             for worker in response_json | ||||||
|  |         ] | ||||||
							
								
								
									
										37
									
								
								src/ahriman/core/distributed/worker_loader_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/ahriman/core/distributed/worker_loader_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | |||||||
|  | # | ||||||
|  | # 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()] | ||||||
|  |         self.logger.info("load workers %s", workers) | ||||||
|  |         self.configuration.set_option("build", "workers", " ".join(workers)) | ||||||
							
								
								
									
										32
									
								
								src/ahriman/core/distributed/worker_register_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/ahriman/core/distributed/worker_register_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | # | ||||||
|  | # 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 WorkerRegisterTrigger(DistributedSystem): | ||||||
|  |     """ | ||||||
|  |     remote worker registration trigger | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def on_start(self) -> None: | ||||||
|  |         """ | ||||||
|  |         trigger action which will be called at the start of the application | ||||||
|  |         """ | ||||||
|  |         self.register(force=True) | ||||||
							
								
								
									
										38
									
								
								src/ahriman/core/distributed/worker_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/ahriman/core/distributed/worker_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | |||||||
|  | # | ||||||
|  | # 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 WorkerTrigger(DistributedSystem): | ||||||
|  |     """ | ||||||
|  |     remote worker processor trigger (client side) | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def on_start(self) -> None: | ||||||
|  |         """ | ||||||
|  |         trigger action which will be called at the start of the application | ||||||
|  |         """ | ||||||
|  |         self.register() | ||||||
|  |  | ||||||
|  |     def on_stop(self) -> None: | ||||||
|  |         """ | ||||||
|  |         trigger action which will be called before the stop of the application | ||||||
|  |         """ | ||||||
|  |         self.unregister() | ||||||
							
								
								
									
										32
									
								
								src/ahriman/core/distributed/worker_unregister_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/ahriman/core/distributed/worker_unregister_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | # | ||||||
|  | # 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 WorkerUnregisterTrigger(DistributedSystem): | ||||||
|  |     """ | ||||||
|  |     remote worker registration trigger | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def on_start(self) -> None: | ||||||
|  |         """ | ||||||
|  |         trigger action which will be called at the start of the application | ||||||
|  |         """ | ||||||
|  |         self.unregister(force=True) | ||||||
| @ -47,8 +47,8 @@ class SyncHttpClient(LazyLogging): | |||||||
|         default constructor |         default constructor | ||||||
|  |  | ||||||
|         Args: |         Args: | ||||||
|             configuration(Configuration | None): configuration instance (Default value = None) |             configuration(Configuration | None, optional): configuration instance (Default value = None) | ||||||
|             section(str, optional): settings section name (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) |             suppress_errors(bool, optional): suppress logging of request errors (Default value = False) | ||||||
|         """ |         """ | ||||||
|         if configuration is None: |         if configuration is None: | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ from ahriman.models.log_record_id import LogRecordId | |||||||
| from ahriman.models.package import Package | from ahriman.models.package import Package | ||||||
| from ahriman.models.pkgbuild_patch import PkgbuildPatch | from ahriman.models.pkgbuild_patch import PkgbuildPatch | ||||||
| from ahriman.models.repository_id import RepositoryId | from ahriman.models.repository_id import RepositoryId | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
| class Watcher(LazyLogging): | class Watcher(LazyLogging): | ||||||
| @ -223,3 +224,30 @@ class Watcher(LazyLogging): | |||||||
|             status(BuildStatusEnum): new service status |             status(BuildStatusEnum): new service status | ||||||
|         """ |         """ | ||||||
|         self.status = BuildStatus(status) |         self.status = BuildStatus(status) | ||||||
|  |  | ||||||
|  |     def workers_get(self) -> list[Worker]: | ||||||
|  |         """ | ||||||
|  |         retrieve registered remote workers | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             list[Worker]: list of currently available workers | ||||||
|  |         """ | ||||||
|  |         return self.database.workers_get() | ||||||
|  |  | ||||||
|  |     def workers_remove(self, identifier: str | None = None) -> None: | ||||||
|  |         """ | ||||||
|  |         unregister remote worker | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             identifier(str | None, optional): remote worker identifier if any (Default value = None) | ||||||
|  |         """ | ||||||
|  |         self.database.workers_remove(identifier) | ||||||
|  |  | ||||||
|  |     def workers_update(self, worker: Worker) -> None: | ||||||
|  |         """ | ||||||
|  |         register or update remote worker | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             worker(Worker): worker to register | ||||||
|  |         """ | ||||||
|  |         self.database.workers_insert(worker) | ||||||
|  | |||||||
| @ -118,7 +118,6 @@ class WebClient(Client, SyncAhrimanClient): | |||||||
|         Returns: |         Returns: | ||||||
|             str: full url of web service for specific package base |             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 "" |         suffix = f"/{package_base}" if package_base else "" | ||||||
|         return f"{self.address}/api/v1/packages{suffix}" |         return f"{self.address}/api/v1/packages{suffix}" | ||||||
|  |  | ||||||
|  | |||||||
| @ -18,8 +18,11 @@ | |||||||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||||
| # | # | ||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
|  | from typing import Any | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
|  |  | ||||||
|  | from ahriman.core.util import dataclass_view | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass(frozen=True) | @dataclass(frozen=True) | ||||||
| class Worker: | class Worker: | ||||||
| @ -39,3 +42,12 @@ class Worker: | |||||||
|         update identifier based on settings |         update identifier based on settings | ||||||
|         """ |         """ | ||||||
|         object.__setattr__(self, "identifier", self.identifier or urlparse(self.address).netloc) |         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) | ||||||
|  | |||||||
| @ -47,5 +47,7 @@ from ahriman.web.schemas.remote_schema import RemoteSchema | |||||||
| from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema | from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema | ||||||
| from ahriman.web.schemas.search_schema import SearchSchema | from ahriman.web.schemas.search_schema import SearchSchema | ||||||
| from ahriman.web.schemas.status_schema import StatusSchema | 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.update_flags_schema import UpdateFlagsSchema | ||||||
|  | from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema | ||||||
|  | from ahriman.web.schemas.worker_id_schema import WorkerIdSchema | ||||||
|  | from ahriman.web.schemas.worker_schema import WorkerSchema | ||||||
|  | |||||||
							
								
								
									
										31
									
								
								src/ahriman/web/schemas/worker_id_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/ahriman/web/schemas/worker_id_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | |||||||
|  | # | ||||||
|  | # 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 WorkerIdSchema(Schema): | ||||||
|  |     """ | ||||||
|  |     request and response schema for workers | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     identifier = fields.String(required=True, metadata={ | ||||||
|  |         "description": "Worker unique identifier", | ||||||
|  |         "example": "42f03a62-48f7-46b7-af40-dacc720e92fa", | ||||||
|  |     }) | ||||||
							
								
								
									
										33
									
								
								src/ahriman/web/schemas/worker_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/ahriman/web/schemas/worker_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | |||||||
|  | # | ||||||
|  | # 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 fields | ||||||
|  |  | ||||||
|  | from ahriman.web.schemas.worker_id_schema import WorkerIdSchema | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class WorkerSchema(WorkerIdSchema): | ||||||
|  |     """ | ||||||
|  |     request and response schema for workers | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     address = fields.String(required=True, metadata={ | ||||||
|  |         "description": "Worker address", | ||||||
|  |         "example": "http://localhost:8081", | ||||||
|  |     }) | ||||||
							
								
								
									
										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/>. | ||||||
|  | # | ||||||
							
								
								
									
										99
									
								
								src/ahriman/web/views/v1/distributed/worker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/ahriman/web/views/v1/distributed/worker.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,99 @@ | |||||||
|  | # | ||||||
|  | # 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 aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response | ||||||
|  |  | ||||||
|  | from ahriman.models.user_access import UserAccess | ||||||
|  | from ahriman.web.schemas import AuthSchema, ErrorSchema, WorkerIdSchema, WorkerSchema | ||||||
|  | from ahriman.web.views.base import BaseView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class WorkerView(BaseView): | ||||||
|  |     """ | ||||||
|  |     distributed worker view | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         DELETE_PERMISSION(UserAccess): (class attribute) delete permissions of self | ||||||
|  |         GET_PERMISSION(UserAccess): (class attribute) get permissions of self | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     DELETE_PERMISSION = GET_PERMISSION = UserAccess.Full | ||||||
|  |     ROUTES = ["/api/v1/distributed/{identifier}"] | ||||||
|  |  | ||||||
|  |     @aiohttp_apispec.docs( | ||||||
|  |         tags=["Distributed"], | ||||||
|  |         summary="Unregister worker", | ||||||
|  |         description="Unregister worker and remove it 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) | ||||||
|  |     @aiohttp_apispec.match_info_schema(WorkerIdSchema) | ||||||
|  |     async def delete(self) -> None: | ||||||
|  |         """ | ||||||
|  |         unregister worker | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             HTTPNoContent: on success response | ||||||
|  |         """ | ||||||
|  |         identifier = self.request.match_info["identifier"] | ||||||
|  |         self.service().workers_remove(identifier) | ||||||
|  |  | ||||||
|  |         raise HTTPNoContent | ||||||
|  |  | ||||||
|  |     @aiohttp_apispec.docs( | ||||||
|  |         tags=["Distributed"], | ||||||
|  |         summary="Get worker", | ||||||
|  |         description="Retrieve registered worker by its identifier", | ||||||
|  |         responses={ | ||||||
|  |             200: {"description": "Success response", "schema": WorkerSchema(many=True)}, | ||||||
|  |             401: {"description": "Authorization required", "schema": ErrorSchema}, | ||||||
|  |             403: {"description": "Access is forbidden", "schema": ErrorSchema}, | ||||||
|  |             404: {"description": "Worker is unknown", "schema": ErrorSchema}, | ||||||
|  |             500: {"description": "Internal server error", "schema": ErrorSchema}, | ||||||
|  |         }, | ||||||
|  |         security=[{"token": [GET_PERMISSION]}], | ||||||
|  |     ) | ||||||
|  |     @aiohttp_apispec.cookies_schema(AuthSchema) | ||||||
|  |     @aiohttp_apispec.match_info_schema(WorkerIdSchema) | ||||||
|  |     async def get(self) -> Response: | ||||||
|  |         """ | ||||||
|  |         get worker by identifier | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             Response: 200 with workers list on success | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             HTTPNotFound: if no worker was found | ||||||
|  |         """ | ||||||
|  |         identifier = self.request.match_info["identifier"] | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             worker = next(worker for worker in self.service().workers_get() if worker.identifier == identifier) | ||||||
|  |         except StopIteration: | ||||||
|  |             raise HTTPNotFound(reason=f"Worker {identifier} not found") | ||||||
|  |  | ||||||
|  |         return json_response([worker.view()]) | ||||||
							
								
								
									
										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.service().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.service().workers_get() | ||||||
|  |  | ||||||
|  |         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.service().workers_update(worker) | ||||||
|  |  | ||||||
|  |         raise HTTPNoContent | ||||||
| @ -39,6 +39,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: | |||||||
|     setup_mock = mocker.patch("ahriman.web.web.setup_server") |     setup_mock = mocker.patch("ahriman.web.web.setup_server") | ||||||
|     run_mock = mocker.patch("ahriman.web.web.run_server") |     run_mock = mocker.patch("ahriman.web.web.run_server") | ||||||
|     start_mock = mocker.patch("ahriman.core.spawn.Spawn.start") |     start_mock = mocker.patch("ahriman.core.spawn.Spawn.start") | ||||||
|  |     trigger_mock = mocker.patch("ahriman.core.triggers.TriggerLoader.load") | ||||||
|     stop_mock = mocker.patch("ahriman.core.spawn.Spawn.stop") |     stop_mock = mocker.patch("ahriman.core.spawn.Spawn.stop") | ||||||
|     join_mock = mocker.patch("ahriman.core.spawn.Spawn.join") |     join_mock = mocker.patch("ahriman.core.spawn.Spawn.join") | ||||||
|     _, repository_id = configuration.check_loaded() |     _, repository_id = configuration.check_loaded() | ||||||
| @ -48,6 +49,8 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: | |||||||
|     setup_mock.assert_called_once_with(configuration, pytest.helpers.anyvar(int), [repository_id]) |     setup_mock.assert_called_once_with(configuration, pytest.helpers.anyvar(int), [repository_id]) | ||||||
|     run_mock.assert_called_once_with(pytest.helpers.anyvar(int)) |     run_mock.assert_called_once_with(pytest.helpers.anyvar(int)) | ||||||
|     start_mock.assert_called_once_with() |     start_mock.assert_called_once_with() | ||||||
|  |     trigger_mock.assert_called_once_with(repository_id, configuration) | ||||||
|  |     trigger_mock().on_start.assert_called_once_with() | ||||||
|     stop_mock.assert_called_once_with() |     stop_mock.assert_called_once_with() | ||||||
|     join_mock.assert_called_once_with() |     join_mock.assert_called_once_with() | ||||||
|  |  | ||||||
|  | |||||||
| @ -1320,6 +1320,80 @@ def test_subparsers_service_tree_migrate(parser: argparse.ArgumentParser) -> Non | |||||||
|     assert not args.report |     assert not args.report | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_register(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-register command must imply trigger | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-register"]) | ||||||
|  |     assert args.trigger == ["ahriman.core.distributed.WorkerRegisterTrigger"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_register_option_architecture(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-register command must correctly parse architecture list | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-register"]) | ||||||
|  |     assert args.architecture is None | ||||||
|  |     args = parser.parse_args(["-a", "x86_64", "service-worker-register"]) | ||||||
|  |     assert args.architecture == "x86_64" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_register_option_repository(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-register command must correctly parse repository list | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-register"]) | ||||||
|  |     assert args.repository is None | ||||||
|  |     args = parser.parse_args(["-r", "repo", "service-worker-register"]) | ||||||
|  |     assert args.repository == "repo" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_register_repo_triggers(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-register must have same keys as repo-triggers | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-register"]) | ||||||
|  |     reference_args = parser.parse_args(["repo-triggers"]) | ||||||
|  |     assert dir(args) == dir(reference_args) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_unregister(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-unregister command must imply trigger | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-unregister"]) | ||||||
|  |     assert args.trigger == ["ahriman.core.distributed.WorkerUnregisterTrigger"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_unregister_option_architecture(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-unregister command must correctly parse architecture list | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-unregister"]) | ||||||
|  |     assert args.architecture is None | ||||||
|  |     args = parser.parse_args(["-a", "x86_64", "service-worker-unregister"]) | ||||||
|  |     assert args.architecture == "x86_64" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_unregister_option_repository(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-unregister command must correctly parse repository list | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-unregister"]) | ||||||
|  |     assert args.repository is None | ||||||
|  |     args = parser.parse_args(["-r", "repo", "service-worker-unregister"]) | ||||||
|  |     assert args.repository == "repo" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_subparsers_service_worker_unregister_repo_triggers(parser: argparse.ArgumentParser) -> None: | ||||||
|  |     """ | ||||||
|  |     service-worker-unregister must have same keys as repo-triggers | ||||||
|  |     """ | ||||||
|  |     args = parser.parse_args(["service-worker-unregister"]) | ||||||
|  |     reference_args = parser.parse_args(["repo-triggers"]) | ||||||
|  |     assert dir(args) == dir(reference_args) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_subparsers_user_add(parser: argparse.ArgumentParser) -> None: | def test_subparsers_user_add(parser: argparse.ArgumentParser) -> None: | ||||||
|     """ |     """ | ||||||
|     user-add command must imply action, architecture, exit code, lock, quiet, report and repository |     user-add command must imply action, architecture, exit code, lock, quiet, report and repository | ||||||
|  | |||||||
| @ -0,0 +1,8 @@ | |||||||
|  | from ahriman.core.database.migrations.m013_workers import steps | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_migration_workers() -> None: | ||||||
|  |     """ | ||||||
|  |     migration must not be empty | ||||||
|  |     """ | ||||||
|  |     assert steps | ||||||
| @ -0,0 +1,46 @@ | |||||||
|  | from ahriman.core.database import SQLite | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_get_insert(database: SQLite) -> None: | ||||||
|  |     """ | ||||||
|  |     must insert workers to database | ||||||
|  |     """ | ||||||
|  |     database.workers_insert(Worker("address1", identifier="1")) | ||||||
|  |     database.workers_insert(Worker("address2", identifier="2")) | ||||||
|  |     assert database.workers_get() == [ | ||||||
|  |         Worker("address1", identifier="1"), Worker("address2", identifier="2") | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_insert_remove(database: SQLite) -> None: | ||||||
|  |     """ | ||||||
|  |     must remove worker from database | ||||||
|  |     """ | ||||||
|  |     database.workers_insert(Worker("address1", identifier="1")) | ||||||
|  |     database.workers_insert(Worker("address2", identifier="2")) | ||||||
|  |     database.workers_remove("1") | ||||||
|  |  | ||||||
|  |     assert database.workers_get() == [Worker("address2", identifier="2")] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_insert_remove_all(database: SQLite) -> None: | ||||||
|  |     """ | ||||||
|  |     must remove all workers | ||||||
|  |     """ | ||||||
|  |     database.workers_insert(Worker("address1", identifier="1")) | ||||||
|  |     database.workers_insert(Worker("address2", identifier="2")) | ||||||
|  |     database.workers_remove() | ||||||
|  |  | ||||||
|  |     assert database.workers_get() == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_insert_insert(database: SQLite) -> None: | ||||||
|  |     """ | ||||||
|  |     must update worker in database | ||||||
|  |     """ | ||||||
|  |     database.workers_insert(Worker("address1", identifier="1")) | ||||||
|  |     assert database.workers_get() == [Worker("address1", identifier="1")] | ||||||
|  |  | ||||||
|  |     database.workers_insert(Worker("address2", identifier="1")) | ||||||
|  |     assert database.workers_get() == [Worker("address2", identifier="1")] | ||||||
							
								
								
									
										20
									
								
								tests/ahriman/core/distributed/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								tests/ahriman/core/distributed/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed.distributed_system import DistributedSystem | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def distributed_system(configuration: Configuration) -> DistributedSystem: | ||||||
|  |     """ | ||||||
|  |     distributed system fixture | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         configuration(Configuration): configuration fixture | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         DistributedSystem: distributed system test instance | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |     return DistributedSystem(repository_id, configuration) | ||||||
							
								
								
									
										176
									
								
								tests/ahriman/core/distributed/test_distributed_system.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								tests/ahriman/core/distributed/test_distributed_system.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,176 @@ | |||||||
|  | import json | ||||||
|  | import requests | ||||||
|  |  | ||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed.distributed_system import DistributedSystem | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_identifier_path(configuration: Configuration) -> None: | ||||||
|  |     """ | ||||||
|  |     must correctly set default identifier path | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |     assert DistributedSystem(repository_id, configuration).identifier_path | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_configuration_sections(configuration: Configuration) -> None: | ||||||
|  |     """ | ||||||
|  |     must correctly parse target list | ||||||
|  |     """ | ||||||
|  |     assert DistributedSystem.configuration_sections(configuration) == ["worker"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_url(distributed_system: DistributedSystem) -> None: | ||||||
|  |     """ | ||||||
|  |     must generate workers url correctly | ||||||
|  |     """ | ||||||
|  |     assert distributed_system._workers_url().startswith(distributed_system.address) | ||||||
|  |     assert distributed_system._workers_url().endswith("/api/v1/distributed") | ||||||
|  |  | ||||||
|  |     assert distributed_system._workers_url("id").startswith(distributed_system.address) | ||||||
|  |     assert distributed_system._workers_url("id").endswith("/api/v1/distributed/id") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_load_identifier(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must generate identifier | ||||||
|  |     """ | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=False) | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |     system = DistributedSystem(repository_id, configuration) | ||||||
|  |  | ||||||
|  |     assert system.load_identifier(configuration, "worker") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_load_identifier_configuration(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must load identifier from configuration | ||||||
|  |     """ | ||||||
|  |     identifier = "id" | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=False) | ||||||
|  |     configuration.set_option("worker", "identifier", identifier) | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |     system = DistributedSystem(repository_id, configuration) | ||||||
|  |  | ||||||
|  |     assert system.worker.identifier == identifier | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_load_identifier_filesystem(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must load identifier from filesystem | ||||||
|  |     """ | ||||||
|  |     identifier = "id" | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=True) | ||||||
|  |     read_mock = mocker.patch("pathlib.Path.read_text", return_value=identifier) | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |     system = DistributedSystem(repository_id, configuration) | ||||||
|  |  | ||||||
|  |     assert system.worker.identifier == identifier | ||||||
|  |     read_mock.assert_called_once_with(encoding="utf8") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_register(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must register service | ||||||
|  |     """ | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=False) | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     write_mock = mocker.patch("pathlib.Path.write_text") | ||||||
|  |  | ||||||
|  |     distributed_system.register() | ||||||
|  |     run_mock.assert_called_once_with("POST", f"{distributed_system.address}/api/v1/distributed", | ||||||
|  |                                      json=distributed_system.worker.view()) | ||||||
|  |     write_mock.assert_called_once_with(distributed_system.worker.identifier, encoding="utf8") | ||||||
|  |     assert distributed_system._owe_identifier | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_register_skip(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must skip service registration if it doesn't owe the identifier | ||||||
|  |     """ | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=True) | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     write_mock = mocker.patch("pathlib.Path.write_text") | ||||||
|  |  | ||||||
|  |     distributed_system.register() | ||||||
|  |     run_mock.assert_not_called() | ||||||
|  |     write_mock.assert_not_called() | ||||||
|  |     assert not distributed_system._owe_identifier | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_register_force(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must register service even if it doesn't owe the identifier if force is supplied | ||||||
|  |     """ | ||||||
|  |     mocker.patch("pathlib.Path.is_file", return_value=True) | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     write_mock = mocker.patch("pathlib.Path.write_text") | ||||||
|  |  | ||||||
|  |     distributed_system.register(force=True) | ||||||
|  |     run_mock.assert_called_once_with("POST", f"{distributed_system.address}/api/v1/distributed", | ||||||
|  |                                      json=distributed_system.worker.view()) | ||||||
|  |     write_mock.assert_called_once_with(distributed_system.worker.identifier, encoding="utf8") | ||||||
|  |     assert distributed_system._owe_identifier | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unregister(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must unregister service | ||||||
|  |     """ | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     remove_mock = mocker.patch("pathlib.Path.unlink") | ||||||
|  |     distributed_system._owe_identifier = True | ||||||
|  |  | ||||||
|  |     distributed_system.unregister() | ||||||
|  |     run_mock.assert_called_once_with( | ||||||
|  |         "DELETE", f"{distributed_system.address}/api/v1/distributed/{distributed_system.worker.identifier}") | ||||||
|  |     remove_mock.assert_called_once_with(missing_ok=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unregister_skip(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must skip service removal if it doesn't owe the identifier | ||||||
|  |     """ | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     remove_mock = mocker.patch("pathlib.Path.unlink") | ||||||
|  |  | ||||||
|  |     distributed_system.unregister() | ||||||
|  |     run_mock.assert_not_called() | ||||||
|  |     remove_mock.assert_not_called() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unregister_force(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must remove service even if it doesn't owe the identifier if force is supplied | ||||||
|  |     """ | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.distributed_system.DistributedSystem.make_request") | ||||||
|  |     remove_mock = mocker.patch("pathlib.Path.unlink") | ||||||
|  |  | ||||||
|  |     distributed_system.unregister(force=True) | ||||||
|  |     run_mock.assert_called_once_with( | ||||||
|  |         "DELETE", f"{distributed_system.address}/api/v1/distributed/{distributed_system.worker.identifier}") | ||||||
|  |     remove_mock.assert_called_once_with(missing_ok=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_get(distributed_system: DistributedSystem, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must return available remote workers | ||||||
|  |     """ | ||||||
|  |     worker = Worker("remote") | ||||||
|  |     response_obj = requests.Response() | ||||||
|  |     response_obj._content = json.dumps([worker.view()]).encode("utf8") | ||||||
|  |     response_obj.status_code = 200 | ||||||
|  |  | ||||||
|  |     requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", | ||||||
|  |                                  return_value=response_obj) | ||||||
|  |  | ||||||
|  |     result = distributed_system.workers() | ||||||
|  |     requests_mock.assert_called_once_with("GET", distributed_system._workers_url()) | ||||||
|  |     assert result == [worker] | ||||||
							
								
								
									
										34
									
								
								tests/ahriman/core/distributed/test_worker_loader_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								tests/ahriman/core/distributed/test_worker_loader_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,34 @@ | |||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed import WorkerLoaderTrigger | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must load workers from remote | ||||||
|  |     """ | ||||||
|  |     worker = Worker("address") | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerLoaderTrigger.workers", return_value=[worker]) | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerLoaderTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_start() | ||||||
|  |     run_mock.assert_called_once_with() | ||||||
|  |     assert configuration.getlist("build", "workers") == [worker.address] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_start_skip(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must skip loading if option is already set | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     configuration.set_option("build", "workers", "address") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerLoaderTrigger.workers") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerLoaderTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_start() | ||||||
|  |     run_mock.assert_not_called() | ||||||
| @ -0,0 +1,17 @@ | |||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed import WorkerRegisterTrigger | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must register itself as worker | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerRegisterTrigger.register") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerRegisterTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_start() | ||||||
|  |     run_mock.assert_called_once_with(force=True) | ||||||
							
								
								
									
										30
									
								
								tests/ahriman/core/distributed/test_worker_trigger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								tests/ahriman/core/distributed/test_worker_trigger.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | |||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed import WorkerTrigger | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must register itself as worker | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerTrigger.register") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_start() | ||||||
|  |     run_mock.assert_called_once_with() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_stop(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must unregister itself as worker | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerTrigger.unregister") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_stop() | ||||||
|  |     run_mock.assert_called_once_with() | ||||||
| @ -0,0 +1,17 @@ | |||||||
|  | from pytest_mock import MockerFixture | ||||||
|  |  | ||||||
|  | from ahriman.core.configuration import Configuration | ||||||
|  | from ahriman.core.distributed import WorkerUnregisterTrigger | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must unregister itself as worker | ||||||
|  |     """ | ||||||
|  |     configuration.set_option("status", "address", "http://localhost:8081") | ||||||
|  |     run_mock = mocker.patch("ahriman.core.distributed.WorkerUnregisterTrigger.unregister") | ||||||
|  |     _, repository_id = configuration.check_loaded() | ||||||
|  |  | ||||||
|  |     trigger = WorkerUnregisterTrigger(repository_id, configuration) | ||||||
|  |     trigger.on_start() | ||||||
|  |     run_mock.assert_called_once_with(force=True) | ||||||
| @ -10,6 +10,7 @@ from ahriman.models.changes import Changes | |||||||
| from ahriman.models.log_record_id import LogRecordId | from ahriman.models.log_record_id import LogRecordId | ||||||
| from ahriman.models.package import Package | from ahriman.models.package import Package | ||||||
| from ahriman.models.pkgbuild_patch import PkgbuildPatch | from ahriman.models.pkgbuild_patch import PkgbuildPatch | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: | def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: | ||||||
| @ -227,3 +228,40 @@ def test_status_update(watcher: Watcher) -> None: | |||||||
|     """ |     """ | ||||||
|     watcher.status_update(BuildStatusEnum.Success) |     watcher.status_update(BuildStatusEnum.Success) | ||||||
|     assert watcher.status.status == BuildStatusEnum.Success |     assert watcher.status.status == BuildStatusEnum.Success | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_get(watcher: Watcher, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must retrieve workers | ||||||
|  |     """ | ||||||
|  |     worker = Worker("remote") | ||||||
|  |     worker_mock = mocker.patch("ahriman.core.database.SQLite.workers_get", return_value=[worker]) | ||||||
|  |  | ||||||
|  |     assert watcher.workers_get() == [worker] | ||||||
|  |     worker_mock.assert_called_once_with() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_remove(watcher: Watcher, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must remove workers | ||||||
|  |     """ | ||||||
|  |     identifier = "identifier" | ||||||
|  |     worker_mock = mocker.patch("ahriman.core.database.SQLite.workers_remove") | ||||||
|  |  | ||||||
|  |     watcher.workers_remove(identifier) | ||||||
|  |     watcher.workers_remove() | ||||||
|  |     worker_mock.assert_has_calls([ | ||||||
|  |         MockCall(identifier), | ||||||
|  |         MockCall(None), | ||||||
|  |     ]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_workers_update(watcher: Watcher, mocker: MockerFixture) -> None: | ||||||
|  |     """ | ||||||
|  |     must update workers | ||||||
|  |     """ | ||||||
|  |     worker = Worker("remote") | ||||||
|  |     worker_mock = mocker.patch("ahriman.core.database.SQLite.workers_insert") | ||||||
|  |  | ||||||
|  |     watcher.workers_update(worker) | ||||||
|  |     worker_mock.assert_called_once_with(worker) | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ from ahriman.models.changes import Changes | |||||||
| from ahriman.models.internal_status import InternalStatus | from ahriman.models.internal_status import InternalStatus | ||||||
| from ahriman.models.log_record_id import LogRecordId | from ahriman.models.log_record_id import LogRecordId | ||||||
| from ahriman.models.package import Package | from ahriman.models.package import Package | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_address(configuration: Configuration) -> None: | def test_parse_address(configuration: Configuration) -> None: | ||||||
| @ -32,14 +33,6 @@ def test_parse_address(configuration: Configuration) -> None: | |||||||
|     assert WebClient.parse_address(configuration) == ("status", "http://localhost:8082") |     assert WebClient.parse_address(configuration) == ("status", "http://localhost:8082") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_status_url(web_client: WebClient) -> None: |  | ||||||
|     """ |  | ||||||
|     must generate package status url correctly |  | ||||||
|     """ |  | ||||||
|     assert web_client._status_url().startswith(web_client.address) |  | ||||||
|     assert web_client._status_url().endswith("/api/v1/status") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_changes_url(web_client: WebClient, package_ahriman: Package) -> None: | def test_changes_url(web_client: WebClient, package_ahriman: Package) -> None: | ||||||
|     """ |     """ | ||||||
|     must generate changes url correctly |     must generate changes url correctly | ||||||
| @ -67,6 +60,14 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None: | |||||||
|     assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") |     assert web_client._package_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_status_url(web_client: WebClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must generate package status url correctly | ||||||
|  |     """ | ||||||
|  |     assert web_client._status_url().startswith(web_client.address) | ||||||
|  |     assert web_client._status_url().endswith("/api/v1/status") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_package_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: | def test_package_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: | ||||||
|     """ |     """ | ||||||
|     must process package addition |     must process package addition | ||||||
|  | |||||||
| @ -8,3 +8,17 @@ def test_post_init() -> None: | |||||||
|     assert Worker("http://localhost:8080").identifier == "localhost:8080" |     assert Worker("http://localhost:8080").identifier == "localhost:8080" | ||||||
|     assert Worker("remote").identifier == ""  # not a valid url |     assert Worker("remote").identifier == ""  # not a valid url | ||||||
|     assert Worker("remote", identifier="id").identifier == "id" |     assert Worker("remote", identifier="id").identifier == "id" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_view() -> None: | ||||||
|  |     """ | ||||||
|  |     must generate json view | ||||||
|  |     """ | ||||||
|  |     worker = Worker("address") | ||||||
|  |     assert worker.view() == {"address": worker.address, "identifier": worker.identifier} | ||||||
|  |  | ||||||
|  |     worker = Worker("http://localhost:8080") | ||||||
|  |     assert worker.view() == {"address": worker.address, "identifier": worker.identifier} | ||||||
|  |  | ||||||
|  |     worker = Worker("http://localhost:8080", identifier="abc") | ||||||
|  |     assert worker.view() == {"address": worker.address, "identifier": worker.identifier} | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								tests/ahriman/web/schemas/test_worker_id_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								tests/ahriman/web/schemas/test_worker_id_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | # schema testing goes in view class tests | ||||||
							
								
								
									
										1
									
								
								tests/ahriman/web/schemas/test_worker_schema.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								tests/ahriman/web/schemas/test_worker_schema.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | # schema testing goes in view class tests | ||||||
| @ -0,0 +1,70 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp.test_utils import TestClient | ||||||
|  |  | ||||||
|  | from ahriman.models.user_access import UserAccess | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  | from ahriman.web.views.v1.distributed.worker import WorkerView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_permission() -> None: | ||||||
|  |     """ | ||||||
|  |     must return correct permission for the request | ||||||
|  |     """ | ||||||
|  |     for method in ("DELETE", "GET"): | ||||||
|  |         request = pytest.helpers.request("", "", method) | ||||||
|  |         assert await WorkerView.get_permission(request) == UserAccess.Full | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_routes() -> None: | ||||||
|  |     """ | ||||||
|  |     must return correct routes | ||||||
|  |     """ | ||||||
|  |     assert WorkerView.ROUTES == ["/api/v1/distributed/{identifier}"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_delete(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must delete single worker | ||||||
|  |     """ | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address1", "identifier": "1"}) | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address2", "identifier": "2"}) | ||||||
|  |  | ||||||
|  |     response = await client.delete("/api/v1/distributed/1") | ||||||
|  |     assert response.status == 204 | ||||||
|  |  | ||||||
|  |     response = await client.get("/api/v1/distributed/1") | ||||||
|  |     assert response.status == 404 | ||||||
|  |  | ||||||
|  |     response = await client.get("/api/v1/distributed/2") | ||||||
|  |     assert response.ok | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must return specific worker | ||||||
|  |     """ | ||||||
|  |     worker = Worker("address1", identifier="1") | ||||||
|  |  | ||||||
|  |     await client.post("/api/v1/distributed", json=worker.view()) | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address2", "identifier": "2"}) | ||||||
|  |     response_schema = pytest.helpers.schema_response(WorkerView.get) | ||||||
|  |  | ||||||
|  |     response = await client.get(f"/api/v1/distributed/{worker.identifier}") | ||||||
|  |     assert response.ok | ||||||
|  |     json = await response.json() | ||||||
|  |     assert not response_schema.validate(json, many=True) | ||||||
|  |  | ||||||
|  |     workers = [Worker(item["address"], identifier=item["identifier"]) for item in json] | ||||||
|  |     assert workers == [worker] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_not_found(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must return Not Found for unknown package | ||||||
|  |     """ | ||||||
|  |     response_schema = pytest.helpers.schema_response(WorkerView.get, code=404) | ||||||
|  |  | ||||||
|  |     response = await client.get("/api/v1/distributed/1") | ||||||
|  |     assert response.status == 404 | ||||||
|  |     assert not response_schema.validate(await response.json()) | ||||||
| @ -0,0 +1,83 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp.test_utils import TestClient | ||||||
|  |  | ||||||
|  | from ahriman.models.user_access import UserAccess | ||||||
|  | from ahriman.models.worker import Worker | ||||||
|  | from ahriman.web.views.v1.distributed.workers import WorkersView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_permission() -> None: | ||||||
|  |     """ | ||||||
|  |     must return correct permission for the request | ||||||
|  |     """ | ||||||
|  |     for method in ("DELETE", "GET", "POST"): | ||||||
|  |         request = pytest.helpers.request("", "", method) | ||||||
|  |         assert await WorkersView.get_permission(request) == UserAccess.Full | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_routes() -> None: | ||||||
|  |     """ | ||||||
|  |     must return correct routes | ||||||
|  |     """ | ||||||
|  |     assert WorkersView.ROUTES == ["/api/v1/distributed"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_delete(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must delete all workers | ||||||
|  |     """ | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address1", "identifier": "1"}) | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address2", "identifier": "2"}) | ||||||
|  |  | ||||||
|  |     response = await client.delete("/api/v1/distributed") | ||||||
|  |     assert response.status == 204 | ||||||
|  |  | ||||||
|  |     response = await client.get("/api/v1/distributed") | ||||||
|  |     json = await response.json() | ||||||
|  |     assert not json | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must return all workers | ||||||
|  |     """ | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address1", "identifier": "1"}) | ||||||
|  |     await client.post("/api/v1/distributed", json={"address": "address2", "identifier": "2"}) | ||||||
|  |     response_schema = pytest.helpers.schema_response(WorkersView.get) | ||||||
|  |  | ||||||
|  |     response = await client.get("/api/v1/distributed") | ||||||
|  |     assert response.ok | ||||||
|  |     json = await response.json() | ||||||
|  |     assert not response_schema.validate(json, many=True) | ||||||
|  |  | ||||||
|  |     workers = [Worker(item["address"], identifier=item["identifier"]) for item in json] | ||||||
|  |     assert workers == [Worker("address1", identifier="1"), Worker("address2", identifier="2")] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must update worker | ||||||
|  |     """ | ||||||
|  |     worker = Worker("address1", identifier="1") | ||||||
|  |     request_schema = pytest.helpers.schema_request(WorkersView.post) | ||||||
|  |  | ||||||
|  |     payload = worker.view() | ||||||
|  |     assert not request_schema.validate(payload) | ||||||
|  |  | ||||||
|  |     response = await client.post("/api/v1/distributed", json=payload) | ||||||
|  |     assert response.status == 204 | ||||||
|  |  | ||||||
|  |     response = await client.get(f"/api/v1/distributed/{worker.identifier}") | ||||||
|  |     assert response.ok | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_exception(client: TestClient) -> None: | ||||||
|  |     """ | ||||||
|  |     must raise exception on invalid payload | ||||||
|  |     """ | ||||||
|  |     response_schema = pytest.helpers.schema_response(WorkersView.post, code=400) | ||||||
|  |  | ||||||
|  |     response = await client.post("/api/v1/distributed", json={}) | ||||||
|  |     assert response.status == 400 | ||||||
|  |     assert not response_schema.validate(await response.json()) | ||||||
| @ -116,3 +116,6 @@ enable_archive_upload = yes | |||||||
| host = 127.0.0.1 | host = 127.0.0.1 | ||||||
| static_path = ../web/templates/static | static_path = ../web/templates/static | ||||||
| templates = ../web/templates | templates = ../web/templates | ||||||
|  |  | ||||||
|  | [worker] | ||||||
|  | address = http://localhost:8081 | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user