Add ability to trigger updates from the web (#31)

* add external process spawner and update test cases

* pass no_report to handlers

* provide service api endpoints

* do not spawn process for single architecture run

* pass no report to handlers

* make _call method of handlers public and also simplify process spawn

* move update under add

* implement actions from web page

* clear logging & improve l&f
This commit is contained in:
2021-09-10 00:33:35 +03:00
committed by GitHub
parent 18de70154e
commit 98eb93c27a
101 changed files with 1417 additions and 295 deletions

View File

@ -27,11 +27,10 @@ from ahriman import version
from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings
from ahriman.models.user_access import UserAccess
# pylint thinks it is bad idea, but get the fuck off
from ahriman.models.user_access import UserAccess
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
@ -367,7 +366,7 @@ def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
parser = root.add_parser("web", help="start web server", description="start web server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Web, lock=None, no_report=True)
parser.set_defaults(handler=handlers.Web, lock=None, no_report=True, parser=_parser)
return parser

View File

@ -40,16 +40,17 @@ class Application:
:ivar repository: repository instance
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
def __init__(self, architecture: str, configuration: Configuration, no_report: bool) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
self.logger = logging.getLogger("root")
self.configuration = configuration
self.architecture = architecture
self.repository = Repository(architecture, configuration)
self.repository = Repository(architecture, configuration, no_report)
def _finalize(self, built_packages: Iterable[Package]) -> None:
"""

View File

@ -32,14 +32,16 @@ class Add(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
application = Application(architecture, configuration)
application = Application(architecture, configuration, no_report)
application.add(args.package, args.without_dependencies)
if not args.now:
return

View File

@ -32,12 +32,14 @@ class Clean(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).clean(args.no_build, args.no_cache, args.no_chroot,
args.no_manual, args.no_packages)
Application(architecture, configuration, no_report).clean(args.no_build, args.no_cache, args.no_chroot,
args.no_manual, args.no_packages)

View File

@ -34,12 +34,14 @@ class CreateUser(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
salt = CreateUser.get_salt(configuration)
user = CreateUser.create_user(args)

View File

@ -33,12 +33,14 @@ class Dump(Handler):
_print = print
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
dump = configuration.dump()
for section, values in sorted(dump.items()):

View File

@ -27,17 +27,20 @@ from typing import Set, Type
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
from ahriman.core.exceptions import MissingArchitecture, MultipleArchitecture
from ahriman.models.repository_paths import RepositoryPaths
class Handler:
"""
base handler class for command callbacks
:cvar ALLOW_MULTI_ARCHITECTURE_RUN: allow to run with multiple architectures
"""
ALLOW_MULTI_ARCHITECTURE_RUN = True
@classmethod
def _call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool:
def call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool:
"""
additional function to wrap all calls for multiprocessing library
:param args: command line args
@ -47,7 +50,7 @@ class Handler:
try:
configuration = Configuration.from_path(args.configuration, architecture, not args.no_log)
with Lock(args, architecture, configuration):
cls.run(args, architecture, configuration)
cls.run(args, architecture, configuration, args.no_report)
return True
except Exception:
logging.getLogger("root").exception("process exception")
@ -61,9 +64,18 @@ class Handler:
:return: 0 on success, 1 otherwise
"""
architectures = cls.extract_architectures(args)
with Pool(len(architectures)) as pool:
result = pool.starmap(
cls._call, [(args, architecture) for architecture in architectures])
# actually we do not have to spawn another process if it is single-process application, do we?
if len(architectures) > 1:
if not cls.ALLOW_MULTI_ARCHITECTURE_RUN:
raise MultipleArchitecture(args.command)
with Pool(len(architectures)) as pool:
result = pool.starmap(
cls.call, [(args, architecture) for architecture in architectures])
else:
result = [cls.call(args, architectures.pop())]
return 0 if all(result) else 1
@classmethod
@ -88,11 +100,13 @@ class Handler:
return architectures
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
raise NotImplementedError

View File

@ -32,11 +32,13 @@ class Init(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).repository.repo.init()
Application(architecture, configuration, no_report).repository.repo.init()

View File

@ -32,11 +32,13 @@ class KeyImport(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).repository.sign.import_key(args.key_server, args.key)
Application(architecture, configuration, no_report).repository.sign.import_key(args.key_server, args.key)

View File

@ -32,16 +32,18 @@ class Rebuild(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
depends_on = set(args.depends_on) if args.depends_on else None
application = Application(architecture, configuration)
application = Application(architecture, configuration, no_report)
packages = [
package
for package in application.repository.packages()

View File

@ -32,11 +32,13 @@ class Remove(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).remove(args.package)
Application(architecture, configuration, no_report).remove(args.package)

View File

@ -33,14 +33,16 @@ class RemoveUnknown(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
application = Application(architecture, configuration)
application = Application(architecture, configuration, no_report)
unknown_packages = application.unknown()
if args.dry_run:
for package in unknown_packages:

View File

@ -32,11 +32,13 @@ class Report(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).report(args.target, [])
Application(architecture, configuration, no_report).report(args.target, [])

View File

@ -32,12 +32,14 @@ class Search(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
search = " ".join(args.search)
packages = aur.search(search)

View File

@ -43,14 +43,16 @@ class Setup(Handler):
SUDOERS_PATH = Path("/etc/sudoers.d/ahriman")
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
application = Application(architecture, configuration)
application = Application(architecture, configuration, no_report)
Setup.create_makepkg_configuration(args.packager, application.repository.paths)
Setup.create_executable(args.build_command, architecture)
Setup.create_devtools_configuration(args.build_command, architecture, args.from_configuration,

View File

@ -32,11 +32,13 @@ class Sign(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).sign(args.package)
Application(architecture, configuration, no_report).sign(args.package)

View File

@ -34,24 +34,27 @@ class Status(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
application = Application(architecture, configuration)
# we are using reporter here
client = Application(architecture, configuration, no_report=False).repository.reporter
if args.ahriman:
ahriman = application.repository.reporter.get_self()
ahriman = client.get_self()
print(ahriman.pretty_print())
print()
if args.package:
packages: Iterable[Tuple[Package, BuildStatus]] = sum(
[application.repository.reporter.get(base) for base in args.package],
[client.get(base) for base in args.package],
start=[])
else:
packages = application.repository.reporter.get(None)
packages = client.get(None)
for package, package_status in sorted(packages, key=lambda item: item[0].base):
print(package.pretty_print())
print(f"\t{package.version}")

View File

@ -32,14 +32,17 @@ class StatusUpdate(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
client = Application(architecture, configuration).repository.reporter
# we are using reporter here
client = Application(architecture, configuration, no_report=False).repository.reporter
callback: Callable[[str], None] = lambda p: client.remove(p) if args.remove else client.update(p, args.status)
if args.package:
# update packages statuses

View File

@ -32,11 +32,13 @@ class Sync(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
Application(architecture, configuration).sync(args.target, [])
Application(architecture, configuration, no_report).sync(args.target, [])

View File

@ -32,14 +32,16 @@ class Update(Handler):
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
application = Application(architecture, configuration)
application = Application(architecture, configuration, no_report)
packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs,
Update.log_fn(application, args.dry_run))
if args.dry_run:

View File

@ -23,6 +23,7 @@ from typing import Type
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
from ahriman.core.spawn import Spawn
class Web(Handler):
@ -30,14 +31,23 @@ class Web(Handler):
web server handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # required to be able to spawn external processes
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str,
configuration: Configuration, no_report: bool) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
# we are using local import for optional dependencies
from ahriman.web.web import run_server, setup_service
application = setup_service(architecture, configuration)
spawner = Spawn(args.parser(), architecture, configuration)
spawner.start()
application = setup_service(architecture, configuration, spawner)
run_server(application)

View File

@ -19,7 +19,7 @@
#
from __future__ import annotations
from typing import Optional, Set, Type
from typing import Optional, Type
from ahriman.core.configuration import Configuration
from ahriman.models.auth_settings import AuthSettings
@ -36,8 +36,8 @@ class Auth:
:cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined
"""
ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html", "/login", "/logout"}
ALLOWED_PATHS_GROUPS: Set[str] = set()
ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html"}
ALLOWED_PATHS_GROUPS = {"/user-api"}
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
"""

View File

@ -109,6 +109,19 @@ class MissingArchitecture(Exception):
Exception.__init__(self, f"Architecture required for subcommand {command}, but missing")
class MultipleArchitecture(Exception):
"""
exception which will be raised if multiple architectures are not supported by the handler
"""
def __init__(self, command: str) -> None:
"""
default constructor
:param command: command name which throws exception
"""
Exception.__init__(self, f"Multiple architectures are not supported by subcommand {command}")
class ReportFailed(Exception):
"""
report generation exception

View File

@ -43,7 +43,13 @@ class Properties:
:ivar sign: GPG wrapper instance
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
def __init__(self, architecture: str, configuration: Configuration, no_report: bool) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
:param no_report: force disable reporting
"""
self.logger = logging.getLogger("builder")
self.architecture = architecture
self.configuration = configuration
@ -58,4 +64,4 @@ class Properties:
self.pacman = Pacman(configuration)
self.sign = GPG(architecture, configuration)
self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args)
self.reporter = Client.load(configuration)
self.reporter = Client() if no_report else Client.load(configuration)

137
src/ahriman/core/spawn.py Normal file
View File

@ -0,0 +1,137 @@
#
# Copyright (c) 2021 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 __future__ import annotations
import argparse
import logging
import uuid
from multiprocessing import Process, Queue
from threading import Lock, Thread
from typing import Callable, Dict, Iterable, Tuple
from ahriman.core.configuration import Configuration
class Spawn(Thread):
"""
helper to spawn external ahriman process
MUST NOT be used directly, the only one usage allowed is to spawn process from web services
:ivar active: map of active child processes required to avoid zombies
:ivar architecture: repository architecture
:ivar configuration: configuration instance
:ivar logger: spawner logger
:ivar queue: multiprocessing queue to read updates from processes
"""
def __init__(self, args_parser: argparse.ArgumentParser, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param args_parser: command line parser for the application
:param architecture: repository architecture
:param configuration: configuration instance
"""
Thread.__init__(self, name="spawn")
self.architecture = architecture
self.args_parser = args_parser
self.configuration = configuration
self.logger = logging.getLogger("http")
self.lock = Lock()
self.active: Dict[str, Process] = {}
# stupid pylint does not know that it is possible
self.queue: Queue[Tuple[str, bool]] = Queue() # pylint: disable=unsubscriptable-object
@staticmethod
def process(callback: Callable[[argparse.Namespace, str], bool], args: argparse.Namespace, architecture: str,
process_id: str, queue: Queue[Tuple[str, bool]]) -> None: # pylint: disable=unsubscriptable-object
"""
helper to run external process
:param callback: application run function (i.e. Handler.run method)
:param args: command line arguments
:param architecture: repository architecture
:param process_id: process unique identifier
:param queue: output queue
"""
result = callback(args, architecture)
queue.put((process_id, result))
def packages_add(self, packages: Iterable[str], now: bool) -> None:
"""
add packages
:param packages: packages list to add
:param now: build packages now
"""
kwargs = {"now": ""} if now else {}
self.spawn_process("add", *packages, **kwargs)
def packages_remove(self, packages: Iterable[str]) -> None:
"""
remove packages
:param packages: packages list to remove
"""
self.spawn_process("remove", *packages)
def spawn_process(self, command: str, *args: str, **kwargs: str) -> None:
"""
spawn external ahriman process with supplied arguments
:param command: subcommand to run
:param args: positional command arguments
:param kwargs: named command arguments
"""
# default arguments
arguments = ["--architecture", self.architecture]
if self.configuration.path is not None:
arguments.extend(["--configuration", str(self.configuration.path)])
# positional command arguments
arguments.append(command)
arguments.extend(args)
# named command arguments
for argument, value in kwargs.items():
arguments.append(f"--{argument}")
if value:
arguments.append(value)
process_id = str(uuid.uuid4())
self.logger.info("full command line arguments of %s are %s", process_id, arguments)
parsed = self.args_parser.parse_args(arguments)
callback = parsed.handler.call
process = Process(target=self.process,
args=(callback, parsed, self.architecture, process_id, self.queue),
daemon=True)
process.start()
with self.lock:
self.active[process_id] = process
def run(self) -> None:
"""
thread run method
"""
for process_id, status in iter(self.queue.get, None):
self.logger.info("process %s has been terminated with status %s", process_id, status)
with self.lock:
process = self.active.pop(process_id, None)
if process is not None:
process.terminate() # make sure lol
process.join()

View File

@ -49,7 +49,7 @@ class Watcher:
self.logger = logging.getLogger("http")
self.architecture = architecture
self.repository = Repository(architecture, configuration)
self.repository = Repository(architecture, configuration, no_report=True)
self.known: Dict[str, Tuple[Package, BuildStatus]] = {}
self.status = BuildStatus()

View File

@ -65,7 +65,7 @@ class WebClient(Client):
"""
:return: full url for web service to login
"""
return f"{self.address}/login"
return f"{self.address}/user-api/v1/login"
@property
def _status_url(self) -> str:

View File

@ -51,7 +51,7 @@ class User:
"""
if username is None or password is None:
return None
return cls(username, password, UserAccess.Status)
return cls(username, password, UserAccess.Read)
@staticmethod
def generate_password(length: int) -> str:

View File

@ -25,9 +25,7 @@ class UserAccess(Enum):
web user access enumeration
:cvar Read: user can read status page
:cvar Write: user can modify task and package list
:cvar Status: user can update statuses via API
"""
Read = "read"
Write = "write"
Status = "status"

View File

@ -73,9 +73,7 @@ def auth_handler(validator: Auth) -> MiddlewareType:
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
if request.path.startswith("/status-api"):
permission = UserAccess.Status
elif request.method in ("GET", "HEAD", "OPTIONS"):
if request.method in ("GET", "HEAD", "OPTIONS"):
permission = UserAccess.Read
else:
permission = UserAccess.Write

View File

@ -19,13 +19,16 @@
#
from aiohttp.web import Application
from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView
from ahriman.web.views.login import LoginView
from ahriman.web.views.logout import LogoutView
from ahriman.web.views.package import PackageView
from ahriman.web.views.packages import PackagesView
from ahriman.web.views.status import StatusView
from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.search import SearchView
from ahriman.web.views.status.ahriman import AhrimanView
from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView
from ahriman.web.views.status.status import StatusView
from ahriman.web.views.user.login import LoginView
from ahriman.web.views.user.logout import LogoutView
def setup_routes(application: Application) -> None:
@ -37,8 +40,13 @@ def setup_routes(application: Application) -> None:
GET / get build status page
GET /index.html same as above
POST /login login to service
POST /logout logout from service
POST /service-api/v1/add add new packages to repository
POST /service-api/v1/remove remove existing package from repository
POST /service-api/v1/update update packages in repository, actually it is just alias for add
GET /service-api/v1/search search for substring in AUR
GET /status-api/v1/ahriman get current service status
POST /status-api/v1/ahriman update service status
@ -52,13 +60,21 @@ def setup_routes(application: Application) -> None:
GET /status-api/v1/status get web service status itself
POST /user-api/v1/login login to service
POST /user-api/v1/logout logout from service
:param application: web application instance
"""
application.router.add_get("/", IndexView, allow_head=True)
application.router.add_get("/index.html", IndexView, allow_head=True)
application.router.add_post("/login", LoginView)
application.router.add_post("/logout", LogoutView)
application.router.add_post("/service-api/v1/add", AddView)
application.router.add_post("/service-api/v1/remove", RemoveView)
application.router.add_get("/service-api/v1/search", SearchView, allow_head=False)
application.router.add_post("/service-api/v1/update", AddView)
application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True)
application.router.add_post("/status-api/v1/ahriman", AhrimanView)
@ -71,3 +87,6 @@ def setup_routes(application: Application) -> None:
application.router.add_post("/status-api/v1/packages/{package}", PackageView)
application.router.add_get("/status-api/v1/status", StatusView, allow_head=True)
application.router.add_post("/user-api/v1/login", LoginView)
application.router.add_post("/user-api/v1/logout", LogoutView)

View File

@ -18,9 +18,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import View
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from ahriman.core.auth.auth import Auth
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
@ -37,6 +38,14 @@ class BaseView(View):
watcher: Watcher = self.request.app["watcher"]
return watcher
@property
def spawner(self) -> Spawn:
"""
:return: external process spawner instance
"""
spawner: Spawn = self.request.app["spawn"]
return spawner
@property
def validator(self) -> Auth:
"""
@ -45,13 +54,33 @@ class BaseView(View):
validator: Auth = self.request.app["validator"]
return validator
async def extract_data(self) -> Dict[str, Any]:
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:
"""
extract json data from either json or form data
:param list_keys: optional list of keys which must be forced to list from form data
:return: raw json object or form data converted to json
"""
try:
json: Dict[str, Any] = await self.request.json()
return json
except ValueError:
return dict(await self.request.post())
return await self.data_as_json(list_keys or [])
async def data_as_json(self, list_keys: List[str]) -> Dict[str, Any]:
"""
extract form data and convert it to json object
:param list_keys: list of keys which must be forced to list from form data
:return: form data converted to json. In case if a key is found multiple times it will be returned as list
"""
raw = await self.request.post()
json: Dict[str, Any] = {}
for key, value in raw.items():
if key in json and isinstance(json[key], list):
json[key].append(value)
elif key in json:
json[key] = [json[key], value]
elif key in list_keys:
json[key] = [value]
else:
json[key] = value
return json

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 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/>.
#

View File

@ -0,0 +1,52 @@
#
# Copyright (c) 2021 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 aiohttp.web import HTTPFound, Response, json_response
from ahriman.web.views.base import BaseView
class AddView(BaseView):
"""
add package web view
"""
async def post(self) -> Response:
"""
add new package
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name as in AUR
"build_now": true # optional flag which runs build
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
now = data.get("build_now", True)
packages = data["packages"]
except Exception as e:
return json_response(text=str(e), status=400)
self.spawner.packages_add(packages, now)
return HTTPFound("/")

View File

@ -0,0 +1,50 @@
#
# Copyright (c) 2021 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 aiohttp.web import HTTPFound, Response, json_response
from ahriman.web.views.base import BaseView
class RemoveView(BaseView):
"""
remove package web view
"""
async def post(self) -> Response:
"""
remove existing packages
JSON body must be supplied, the following model is used:
{
"packages": "ahriman", # either list of packages or package name
}
:return: redirect to main page on success
"""
data = await self.extract_data(["packages"])
try:
packages = data["packages"]
except Exception as e:
return json_response(text=str(e), status=400)
self.spawner.packages_remove(packages)
return HTTPFound("/")

View File

@ -0,0 +1,48 @@
#
# Copyright (c) 2021 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 aur # type: ignore
from aiohttp.web import Response, json_response
from typing import Iterator
from ahriman.web.views.base import BaseView
class SearchView(BaseView):
"""
AUR search web view
"""
async def get(self) -> Response:
"""
search packages in AUR
search string (non empty) must be supplied as `for` parameter
:return: 200 with found package bases sorted by name
"""
search: Iterator[str] = filter(lambda s: len(s) > 3, self.request.query.getall("for", default=[]))
search_string = " ".join(search)
if not search_string:
return json_response(text="Search string must not be empty", status=400)
packages = aur.search(search_string)
return json_response(sorted(package.package_base for package in packages))

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 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/>.
#

View File

@ -17,7 +17,7 @@
# 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 aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from aiohttp.web import HTTPNoContent, Response, json_response
from ahriman.models.build_status import BuildStatusEnum
from ahriman.web.views.base import BaseView
@ -51,7 +51,7 @@ class AhrimanView(BaseView):
try:
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(text=str(e))
return json_response(text=str(e), status=400)
self.service.update_self(status)

View File

@ -17,7 +17,7 @@
# 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 aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.core.exceptions import UnknownPackage
from ahriman.models.build_status import BuildStatusEnum
@ -80,11 +80,11 @@ class PackageView(BaseView):
package = Package.from_json(data["package"]) if "package" in data else None
status = BuildStatusEnum(data["status"])
except Exception as e:
raise HTTPBadRequest(text=str(e))
return json_response(text=str(e), status=400)
try:
self.service.update(base, status, package)
except UnknownPackage:
raise HTTPBadRequest(text=f"Package {base} is unknown, but no package body set")
return json_response(text=f"Package {base} is unknown, but no package body set", status=400)
return HTTPNoContent()

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021 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/>.
#

View File

@ -26,6 +26,7 @@ from aiohttp import web
from ahriman.core.auth.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InitializeException
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
from ahriman.web.middlewares.exception_handler import exception_handler
from ahriman.web.routes import setup_routes
@ -67,11 +68,12 @@ def run_server(application: web.Application) -> None:
access_log=logging.getLogger("http"))
def setup_service(architecture: str, configuration: Configuration) -> web.Application:
def setup_service(architecture: str, configuration: Configuration, spawner: Spawn) -> web.Application:
"""
create web application
:param architecture: repository architecture
:param configuration: configuration instance
:param spawner: spawner thread
:return: web application instance
"""
application = web.Application(logger=logging.getLogger("http"))
@ -93,6 +95,9 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic
application.logger.info("setup watcher")
application["watcher"] = Watcher(architecture, configuration)
application.logger.info("setup process spawner")
application["spawn"] = spawner
application.logger.info("setup authorization")
validator = application["validator"] = Auth.load(configuration)
if validator.enabled: