Compare commits

..

1 Commits

Author SHA1 Message Date
c9e52f1b8a feat: add reload command and api endpoints 2025-07-08 11:46:38 +03:00
35 changed files with 451 additions and 212 deletions

View File

@ -5,6 +5,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/bin/ahriman web ExecStart=/usr/bin/ahriman web
ExecReload=/usr/bin/ahriman web-reload
User=ahriman User=ahriman
Group=ahriman Group=ahriman

View File

@ -8,7 +8,7 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -8,7 +8,7 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -8,7 +8,7 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock
@ -62,7 +62,7 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_REPOSITORY_SERVER: http://frontend/repo/$$repo/$$arch AHRIMAN_REPOSITORY_SERVER: http://frontend/repo/$$repo/$$arch

View File

@ -12,7 +12,7 @@ services:
AHRIMAN_PACMAN_MIRROR: https://de.mirror.archlinux32.org/$$arch/$$repo AHRIMAN_PACMAN_MIRROR: https://de.mirror.archlinux32.org/$$arch/$$repo
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -8,8 +8,8 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_POSTSETUP_COMMAND: ahriman --architecture x86_64 --repository another-demo service-setup --build-as-user ahriman --packager 'ahriman bot <ahriman@example.com>'
AHRIMAN_PRESETUP_COMMAND: ahriman --architecture x86_64 --repository another-demo service-setup --build-as-user ahriman --packager 'ahriman bot <ahriman@example.com>' AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -9,7 +9,7 @@ services:
AHRIMAN_OAUTH_CLIENT_SECRET: ${AHRIMAN_OAUTH_CLIENT_SECRET} AHRIMAN_OAUTH_CLIENT_SECRET: ${AHRIMAN_OAUTH_CLIENT_SECRET}
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: sudo -u ahriman ahriman user-add ${AHRIMAN_OAUTH_USER} -R full -p "" AHRIMAN_PRESETUP_COMMAND: sudo -u ahriman ahriman user-add ${AHRIMAN_OAUTH_USER} -R full -p ""
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -6,7 +6,7 @@ services:
environment: environment:
AHRIMAN_DEBUG: yes AHRIMAN_DEBUG: yes
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_POSTSETUP_COMMAND: sudo -u ahriman gpg --import /run/secrets/key AHRIMAN_PRESETUP_COMMAND: sudo -u ahriman gpg --import /run/secrets/key
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
configs: configs:

View File

@ -8,7 +8,7 @@ services:
AHRIMAN_OUTPUT: console AHRIMAN_OUTPUT: console
AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD} AHRIMAN_PASSWORD: ${AHRIMAN_PASSWORD}
AHRIMAN_PORT: 8080 AHRIMAN_PORT: 8080
AHRIMAN_POSTSETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full AHRIMAN_PRESETUP_COMMAND: (cat /run/secrets/password; echo; cat /run/secrets/password) | sudo -u ahriman ahriman user-add demo -R full
AHRIMAN_REPOSITORY: ahriman-demo AHRIMAN_REPOSITORY: ahriman-demo
AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock AHRIMAN_UNIX_SOCKET: /var/lib/ahriman/ahriman/ahriman.sock

View File

@ -0,0 +1,67 @@
#
# Copyright (c) 2021-2025 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 argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.models.repository_id import RepositoryId
class Reload(Handler):
"""
web server handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
client.configuration_reload()
@staticmethod
def _set_reload_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for web reload subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("web-reload", help="reload configuration",
description="reload web server configuration")
parser.set_defaults(architecture="", lock=None, quiet=True, report=False, repository="", unsafe=True)
return parser
arguments = [_set_reload_parser]

View File

@ -72,17 +72,16 @@ class Setup(Handler):
application = Application(repository_id, configuration, report=report) application = Application(repository_id, configuration, report=report)
with application.repository.paths.preserve_owner(): Setup.configuration_create_makepkg(args.packager, args.makeflags_jobs, application.repository.paths)
Setup.configuration_create_makepkg(args.packager, args.makeflags_jobs, application.repository.paths) Setup.executable_create(application.repository.paths, repository_id)
Setup.executable_create(application.repository.paths, repository_id) repository_server = f"file://{application.repository.paths.repository}" if args.server is None else args.server
repository_server = f"file://{application.repository.paths.repository}" if args.server is None else args.server Setup.configuration_create_devtools(
Setup.configuration_create_devtools( repository_id, args.from_configuration, args.mirror, args.multilib, repository_server)
repository_id, args.from_configuration, args.mirror, args.multilib, repository_server) Setup.configuration_create_sudo(application.repository.paths, repository_id)
Setup.configuration_create_sudo(application.repository.paths, repository_id)
application.repository.repo.init() application.repository.repo.init()
# lazy database sync # lazy database sync
application.repository.pacman.handle # pylint: disable=pointless-statement application.repository.pacman.handle # pylint: disable=pointless-statement
@staticmethod @staticmethod
def _set_service_setup_parser(root: SubParserAction) -> argparse.ArgumentParser: def _set_service_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
@ -281,5 +280,6 @@ class Setup(Handler):
command = Setup.build_command(paths.root, repository_id) command = Setup.build_command(paths.root, repository_id)
command.unlink(missing_ok=True) command.unlink(missing_ok=True)
command.symlink_to(Setup.ARCHBUILD_COMMAND_PATH) command.symlink_to(Setup.ARCHBUILD_COMMAND_PATH)
paths.chown(command) # we would like to keep owner inside ahriman's home
arguments = [_set_service_setup_parser] arguments = [_set_service_setup_parser]

View File

@ -130,8 +130,8 @@ class Pacman(LazyLogging):
return # database for some reason deos not exist return # database for some reason deos not exist
self.logger.info("copy pacman database %s from operating system root to ahriman's home %s", src, dst) self.logger.info("copy pacman database %s from operating system root to ahriman's home %s", src, dst)
with self.repository_paths.preserve_owner(dst.parent): shutil.copy(src, dst)
shutil.copy(src, dst) self.repository_paths.chown(dst)
def database_init(self, handle: Handle, repository: str, architecture: str) -> DB: def database_init(self, handle: Handle, repository: str, architecture: str) -> DB:
""" """
@ -267,8 +267,7 @@ class Pacman(LazyLogging):
Package: list of packages which were returned by the query Package: list of packages which were returned by the query
""" """
def is_package_provided(package: Package) -> bool: def is_package_provided(package: Package) -> bool:
provides = [trim_package(name) for name in package.provides] return package_name in package.provides
return package_name in provides
for database in self.handle.get_syncdbs(): for database in self.handle.get_syncdbs():
yield from filter(is_package_provided, database.search(package_name)) yield from filter(is_package_provided, database.search(package_name))

View File

@ -146,7 +146,7 @@ class AUR(Remote):
# search api provides reduced models # search api provides reduced models
for stub in self.package_search(package_name, pacman=pacman, search_by="provides") for stub in self.package_search(package_name, pacman=pacman, search_by="provides")
# verity that found package actually provides it # verity that found package actually provides it
if package_name in (package := self.package_info(stub.name, pacman=pacman)).provides if package_name in (package := self.package_info(stub.package_base, pacman=pacman)).provides
] ]
def package_search(self, *keywords: str, pacman: Pacman | None, search_by: str | None) -> list[AURPackage]: def package_search(self, *keywords: str, pacman: Pacman | None, search_by: str | None) -> list[AURPackage]:

View File

@ -94,13 +94,9 @@ class SQLite(
sqlite3.register_adapter(list, json.dumps) sqlite3.register_adapter(list, json.dumps)
sqlite3.register_converter("json", json.loads) sqlite3.register_converter("json", json.loads)
if not self._configuration.getboolean("settings", "apply_migrations", fallback=True): if self._configuration.getboolean("settings", "apply_migrations", fallback=True):
return
if self._repository_id.is_empty:
return # do not perform migration on empty repository identifier (e.g. multirepo command)
with self._repository_paths.preserve_owner():
self.with_connection(lambda connection: Migrations.migrate(connection, self._configuration)) self.with_connection(lambda connection: Migrations.migrate(connection, self._configuration))
self._repository_paths.chown(self.path)
def package_clear(self, package_base: str, repository_id: RepositoryId | None = None) -> None: def package_clear(self, package_base: str, repository_id: RepositoryId | None = None) -> None:
""" """

View File

@ -81,6 +81,11 @@ class Client:
return make_local_client() return make_local_client()
def configuration_reload(self) -> None:
"""
reload configuration
"""
def event_add(self, event: Event) -> None: def event_add(self, event: Event) -> None:
""" """
create new event create new event

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
# pylint: disable=too-many-public-methods
import contextlib import contextlib
from urllib.parse import quote_plus as url_encode from urllib.parse import quote_plus as url_encode
@ -165,6 +166,13 @@ class WebClient(Client, SyncAhrimanClient):
""" """
return f"{self.address}/api/v1/status" return f"{self.address}/api/v1/status"
def configuration_reload(self) -> None:
"""
reload configuration
"""
with contextlib.suppress(Exception):
self.make_request("POST", f"{self.address}/api/v1/service/config")
def event_add(self, event: Event) -> None: def event_add(self, event: Event) -> None:
""" """
create new event create new event

View File

@ -25,7 +25,7 @@ from dataclasses import dataclass, field, fields
from pyalpm import Package # type: ignore[import-not-found] from pyalpm import Package # type: ignore[import-not-found]
from typing import Any, Self from typing import Any, Self
from ahriman.core.utils import filter_json, full_version, trim_package from ahriman.core.utils import filter_json, full_version
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -103,17 +103,6 @@ class AURPackage:
keywords: list[str] = field(default_factory=list) keywords: list[str] = field(default_factory=list)
groups: list[str] = field(default_factory=list) groups: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
"""
update packages lists accordingly
"""
object.__setattr__(self, "depends", [trim_package(package) for package in self.depends])
object.__setattr__(self, "make_depends", [trim_package(package) for package in self.make_depends])
object.__setattr__(self, "opt_depends", [trim_package(package) for package in self.opt_depends])
object.__setattr__(self, "check_depends", [trim_package(package) for package in self.check_depends])
object.__setattr__(self, "conflicts", [trim_package(package) for package in self.conflicts])
object.__setattr__(self, "provides", [trim_package(package) for package in self.provides])
@classmethod @classmethod
def from_json(cls, dump: dict[str, Any]) -> Self: def from_json(cls, dump: dict[str, Any]) -> Self:
""" """

View File

@ -83,13 +83,12 @@ class PackageDescription:
def __post_init__(self) -> None: def __post_init__(self) -> None:
""" """
update packages lists accordingly update dependencies list accordingly
""" """
self.depends = [trim_package(package) for package in self.depends] self.depends = [trim_package(package) for package in self.depends]
self.make_depends = [trim_package(package) for package in self.make_depends]
self.opt_depends = [trim_package(package) for package in self.opt_depends] self.opt_depends = [trim_package(package) for package in self.opt_depends]
self.make_depends = [trim_package(package) for package in self.make_depends]
self.check_depends = [trim_package(package) for package in self.check_depends] self.check_depends = [trim_package(package) for package in self.check_depends]
self.provides = [trim_package(package) for package in self.provides]
@property @property
def filepath(self) -> Path | None: def filepath(self) -> Path | None:

View File

@ -17,7 +17,6 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import contextlib
import os import os
import shutil import shutil
@ -222,14 +221,22 @@ class RepositoryPaths(LazyLogging):
stat = path.stat() stat = path.stat()
return stat.st_uid, stat.st_gid return stat.st_uid, stat.st_gid
def _chown(self, path: Path) -> None: def cache_for(self, package_base: str) -> Path:
"""
get path to cached PKGBUILD and package sources for the package base
Args:
package_base(str): package base name
Returns:
Path: full path to directory for specified package base cache
"""
return self.cache / package_base
def chown(self, path: Path) -> None:
""" """
set owner of path recursively (from root) to root owner set owner of path recursively (from root) to root owner
Notes:
More likely you don't want to call this method explicitly, consider using :func:`preserve_owner`
as context manager instead
Args: Args:
path(Path): path to be chown path(Path): path to be chown
@ -249,56 +256,6 @@ class RepositoryPaths(LazyLogging):
set_owner(path) set_owner(path)
path = path.parent path = path.parent
def cache_for(self, package_base: str) -> Path:
"""
get path to cached PKGBUILD and package sources for the package base
Args:
package_base(str): package base name
Returns:
Path: full path to directory for specified package base cache
"""
return self.cache / package_base
@contextlib.contextmanager
def preserve_owner(self, path: Path | None = None) -> Generator[None, None, None]:
"""
perform any action preserving owner for any newly created file or directory
Args:
path(Path | None, optional): use this path as root instead of repository root (Default value = None)
Examples:
This method is designed to use as context manager when you are going to perform operations which might
change filesystem, especially if you are doing it under unsafe flag, e.g.::
>>> with paths.preserve_owner():
>>> paths.tree_create()
Note, however, that this method doesn't handle any exceptions and will eventually interrupt
if there will be any.
"""
path = path or self.root
def walk(root: Path) -> Generator[Path, None, None]:
# basically walk, but skipping some content
for child in root.iterdir():
yield child
if child in (self.chroot.parent,):
yield from child.iterdir() # we only yield top-level in chroot directory
elif child.is_dir():
yield from walk(child)
# get current filesystem and run action
previous_snapshot = set(walk(path))
yield
# get newly created files and directories and chown them
new_entries = set(walk(path)).difference(previous_snapshot)
for entry in new_entries:
self._chown(entry)
def tree_clear(self, package_base: str) -> None: def tree_clear(self, package_base: str) -> None:
""" """
clear package specific files clear package specific files
@ -317,13 +274,12 @@ class RepositoryPaths(LazyLogging):
""" """
if self.repository_id.is_empty: if self.repository_id.is_empty:
return # do not even try to create tree in case if no repository id set return # do not even try to create tree in case if no repository id set
for directory in (
with self.preserve_owner(): self.cache,
for directory in ( self.chroot,
self.cache, self.packages,
self.chroot, self.pacman,
self.packages, self.repository,
self.pacman, ):
self.repository, directory.mkdir(mode=0o755, parents=True, exist_ok=True)
): self.chown(directory)
directory.mkdir(mode=0o755, parents=True, exist_ok=True)

View File

@ -22,6 +22,7 @@ from ahriman.web.schemas.aur_package_schema import AURPackageSchema
from ahriman.web.schemas.auth_schema import AuthSchema from ahriman.web.schemas.auth_schema import AuthSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
from ahriman.web.schemas.changes_schema import ChangesSchema from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
from ahriman.web.schemas.counters_schema import CountersSchema from ahriman.web.schemas.counters_schema import CountersSchema
from ahriman.web.schemas.dependencies_schema import DependenciesSchema from ahriman.web.schemas.dependencies_schema import DependenciesSchema
from ahriman.web.schemas.error_schema import ErrorSchema from ahriman.web.schemas.error_schema import ErrorSchema

View File

@ -0,0 +1,39 @@
#
# Copyright (c) 2021-2025 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.web.apispec import Schema, fields
class ConfigurationSchema(Schema):
"""
response configuration schema
"""
key = fields.String(required=True, metadata={
"description": "Configuration key",
"example": "host",
})
section = fields.String(required=True, metadata={
"description": "Configuration section",
"example": "web",
})
value = fields.String(required=True, metadata={
"description": "Configuration value",
"example": "127.0.0.1",
})

View File

@ -0,0 +1,84 @@
#
# Copyright (c) 2021-2025 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 HTTPNoContent, Response, json_response
from typing import ClassVar
from ahriman.core.formatters import ConfigurationPrinter
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import ConfigurationSchema
from ahriman.web.views.base import BaseView
class ConfigView(BaseView):
"""
configuration control view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess]
ROUTES = ["/api/v1/service/config"]
@apidocs(
tags=["Actions"],
summary="Get configuration",
description="Get current web service configuration as nested dictionary",
permission=GET_PERMISSION,
schema=ConfigurationSchema(many=True),
)
async def get(self) -> Response:
"""
get current web service configuration
Returns:
Response: current web service configuration as nested dictionary
"""
dump = self.configuration.dump()
response = [
{
"section": section,
"key": key,
"value": value,
} for section, values in dump.items()
for key, value in values.items()
if key not in ConfigurationPrinter.HIDE_KEYS
]
return json_response(response)
@apidocs(
tags=["Actions"],
summary="Reload configuration",
description="Reload configuration from current files",
permission=POST_PERMISSION,
)
async def post(self) -> None:
"""
reload web service configuration
Raises:
HTTPNoContent: on success response
"""
self.configuration.reload()
raise HTTPNoContent

View File

@ -72,7 +72,7 @@ def _create_socket(configuration: Configuration, application: Application) -> so
async def remove_socket(_: Application) -> None: async def remove_socket(_: Application) -> None:
unix_socket.unlink(missing_ok=True) unix_socket.unlink(missing_ok=True)
application.on_shutdown.append(remove_socket) application.on_shutdown.append(remove_socket) # type: ignore[arg-type]
return sock return sock
@ -142,8 +142,8 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
InitializeError: if no repositories set InitializeError: if no repositories set
""" """
application = Application(logger=logging.getLogger(__name__)) application = Application(logger=logging.getLogger(__name__))
application.on_shutdown.append(_on_shutdown) application.on_shutdown.append(_on_shutdown) # type: ignore[arg-type]
application.on_startup.append(_on_startup) application.on_startup.append(_on_startup) # type: ignore[arg-type]
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True)) application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
application.middlewares.append(exception_handler(application.logger)) application.middlewares.append(exception_handler(application.logger))
@ -166,16 +166,11 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
# package cache # package cache
if not repositories: if not repositories:
raise InitializeError("No repositories configured, exiting") raise InitializeError("No repositories configured, exiting")
database = SQLite.load(configuration)
watchers: dict[RepositoryId, Watcher] = {} watchers: dict[RepositoryId, Watcher] = {}
configuration_path, _ = configuration.check_loaded()
for repository_id in repositories: for repository_id in repositories:
application.logger.info("load repository %s", repository_id) application.logger.info("load repository %s", repository_id)
# load settings explicitly for architecture if any client = Client.load(repository_id, configuration, database, report=False) # explicitly load local client
repository_configuration = Configuration.from_path(configuration_path, repository_id)
# load database instance, because it holds identifier
database = SQLite.load(repository_configuration)
# explicitly load local client
client = Client.load(repository_id, repository_configuration, database, report=False)
watchers[repository_id] = Watcher(client) watchers[repository_id] = Watcher(client)
application[WatcherKey] = watchers application[WatcherKey] = watchers
# workers cache # workers cache
@ -184,7 +179,6 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application[SpawnKey] = spawner application[SpawnKey] = spawner
application.logger.info("setup authorization") application.logger.info("setup authorization")
database = SQLite.load(configuration)
validator = application[AuthKey] = Auth.load(configuration, database) validator = application[AuthKey] = Auth.load(configuration, database)
if validator.enabled: if validator.enabled:
from ahriman.web.middlewares.auth_handler import setup_auth from ahriman.web.middlewares.auth_handler import setup_auth

View File

@ -0,0 +1,27 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers.reload import Reload
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
run_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.configuration_reload")
_, repository_id = configuration.check_loaded()
Reload.run(args, repository_id, configuration, report=False)
run_mock.assert_called_once_with()
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Reload.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@ -58,11 +58,9 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository:
sudo_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.configuration_create_sudo") sudo_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.configuration_create_sudo")
executable_mock = mocker.patch("ahriman.application.handlers.setup.Setup.executable_create") executable_mock = mocker.patch("ahriman.application.handlers.setup.Setup.executable_create")
init_mock = mocker.patch("ahriman.core.alpm.repo.Repo.init") init_mock = mocker.patch("ahriman.core.alpm.repo.Repo.init")
owner_guard_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
Setup.run(args, repository_id, configuration, report=False) Setup.run(args, repository_id, configuration, report=False)
owner_guard_mock.assert_called_once_with()
ahriman_configuration_mock.assert_called_once_with(args, repository_id, configuration) ahriman_configuration_mock.assert_called_once_with(args, repository_id, configuration)
devtools_configuration_mock.assert_called_once_with( devtools_configuration_mock.assert_called_once_with(
repository_id, args.from_configuration, args.mirror, args.multilib, f"file://{repository_paths.repository}") repository_id, args.from_configuration, args.mirror, args.multilib, f"file://{repository_paths.repository}")
@ -270,11 +268,13 @@ def test_executable_create(configuration: Configuration, repository_paths: Repos
""" """
must create executable must create executable
""" """
chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.chown")
symlink_mock = mocker.patch("pathlib.Path.symlink_to") symlink_mock = mocker.patch("pathlib.Path.symlink_to")
unlink_mock = mocker.patch("pathlib.Path.unlink") unlink_mock = mocker.patch("pathlib.Path.unlink")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
Setup.executable_create(repository_paths, repository_id) Setup.executable_create(repository_paths, repository_id)
chown_mock.assert_called_once_with(Setup.build_command(repository_paths.root, repository_id))
symlink_mock.assert_called_once_with(Setup.ARCHBUILD_COMMAND_PATH) symlink_mock.assert_called_once_with(Setup.ARCHBUILD_COMMAND_PATH)
unlink_mock.assert_called_once_with(missing_ok=True) unlink_mock.assert_called_once_with(missing_ok=True)

View File

@ -1563,6 +1563,35 @@ def test_subparsers_web_option_repository(parser: argparse.ArgumentParser) -> No
assert args.repository == "" assert args.repository == ""
def test_subparsers_web_reload(parser: argparse.ArgumentParser) -> None:
"""
web-reload command must imply architecture, lock, quiet, report, repository and unsafe
"""
args = parser.parse_args(["web-reload"])
assert args.architecture == ""
assert args.lock is None
assert args.quiet
assert not args.report
assert args.repository == ""
assert args.unsafe
def test_subparsers_web_reload_option_architecture(parser: argparse.ArgumentParser) -> None:
"""
web-reload command must correctly parse architecture list
"""
args = parser.parse_args(["-a", "x86_64", "web-reload"])
assert args.architecture == ""
def test_subparsers_web_reload_option_repository(parser: argparse.ArgumentParser) -> None:
"""
web-reload command must correctly parse repository list
"""
args = parser.parse_args(["-r", "repo", "web-reload"])
assert args.repository == ""
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
application must be run application must be run

View File

@ -62,12 +62,12 @@ def test_database_copy(pacman: Pacman, mocker: MockerFixture) -> None:
mocker.patch("pathlib.Path.is_file", autospec=True, side_effect=lambda p: p.is_relative_to(path)) mocker.patch("pathlib.Path.is_file", autospec=True, side_effect=lambda p: p.is_relative_to(path))
mkdir_mock = mocker.patch("pathlib.Path.mkdir") mkdir_mock = mocker.patch("pathlib.Path.mkdir")
copy_mock = mocker.patch("shutil.copy") copy_mock = mocker.patch("shutil.copy")
owner_guard_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner") chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.chown")
pacman.database_copy(pacman.handle, database, path, use_ahriman_cache=True) pacman.database_copy(pacman.handle, database, path, use_ahriman_cache=True)
mkdir_mock.assert_called_once_with(mode=0o755, exist_ok=True) mkdir_mock.assert_called_once_with(mode=0o755, exist_ok=True)
copy_mock.assert_called_once_with(path / "sync" / "core.db", dst_path) copy_mock.assert_called_once_with(path / "sync" / "core.db", dst_path)
owner_guard_mock.assert_called_once_with(dst_path.parent) chown_mock.assert_called_once_with(dst_path)
def test_database_copy_skip(pacman: Pacman, mocker: MockerFixture) -> None: def test_database_copy_skip(pacman: Pacman, mocker: MockerFixture) -> None:
@ -289,4 +289,3 @@ def test_package_provided_by(pacman: Pacman) -> None:
must search through the provides lists must search through the provides lists
""" """
assert list(pacman.provided_by("sh")) assert list(pacman.provided_by("sh"))
assert list(pacman.provided_by("libacl.so")) # case with exact version

View File

@ -36,17 +36,6 @@ def test_init_skip_migration(database: SQLite, mocker: MockerFixture) -> None:
migrate_schema_mock.assert_not_called() migrate_schema_mock.assert_not_called()
def test_init_skip_empty_repository(database: SQLite, mocker: MockerFixture) -> None:
"""
must skip migrations if repository identifier is not set
"""
database._repository_id = RepositoryId("", "")
migrate_schema_mock = mocker.patch("ahriman.core.database.migrations.Migrations.migrate")
database.init()
migrate_schema_mock.assert_not_called()
def test_package_clear(database: SQLite, repository_id: RepositoryId, mocker: MockerFixture) -> None: def test_package_clear(database: SQLite, repository_id: RepositoryId, mocker: MockerFixture) -> None:
""" """
must clear package data must clear package data

View File

@ -97,6 +97,13 @@ def test_load_web_client_from_legacy_unix_socket(configuration: Configuration, d
assert isinstance(Client.load(repository_id, configuration, database, report=True), WebClient) assert isinstance(Client.load(repository_id, configuration, database, report=True), WebClient)
def test_configuration_reload(client: Client) -> None:
"""
must do nothing on configuration reload
"""
client.configuration_reload()
def test_event_add(client: Client) -> None: def test_event_add(client: Client) -> None:
""" """
must raise not implemented on event insertion must raise not implemented on event insertion

View File

@ -107,6 +107,55 @@ def test_status_url(web_client: WebClient) -> None:
assert web_client._status_url().endswith("/api/v1/status") assert web_client._status_url().endswith("/api/v1/status")
def test_configuration_reload(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must reload configuration
"""
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
web_client.configuration_reload()
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True))
def test_configuration_reload_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during configuration reload
"""
mocker.patch("requests.Session.request", side_effect=Exception())
web_client.configuration_reload()
def test_configuration_reload_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during configuration reload
"""
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
web_client.configuration_reload()
def test_configuration_reload_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during configuration reload and don't log
"""
web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=Exception())
logging_mock = mocker.patch("logging.exception")
web_client.configuration_reload()
logging_mock.assert_not_called()
def test_configuration_reload_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during configuration reload and don't log
"""
web_client.suppress_errors = True
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
logging_mock = mocker.patch("logging.exception")
web_client.configuration_reload()
logging_mock.assert_not_called()
def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must create event must create event

View File

@ -2,7 +2,7 @@ import datetime
import json import json
import pyalpm # typing: ignore import pyalpm # typing: ignore
from dataclasses import asdict, fields, replace from dataclasses import asdict, fields
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any from typing import Any
@ -38,25 +38,6 @@ def _get_official_data(resource_path_root: Path) -> dict[str, Any]:
return json.loads(response)["results"][0] return json.loads(response)["results"][0]
def test_post_init(aur_package_ahriman: AURPackage) -> None:
"""
must trim versions and descriptions from packages list
"""
package = replace(
aur_package_ahriman,
depends=["a=1"],
make_depends=["b>=3"],
opt_depends=["c: a description"],
check_depends=["d=4"],
provides=["e=5"],
)
assert package.depends == ["a"]
assert package.make_depends == ["b"]
assert package.opt_depends == ["c"]
assert package.check_depends == ["d"]
assert package.provides == ["e"]
def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None: def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
""" """
must load package from json must load package from json

View File

@ -6,15 +6,10 @@ from ahriman.models.package_description import PackageDescription
def test_post_init() -> None: def test_post_init() -> None:
""" """
must trim versions and descriptions from packages list must trim versions and descriptions from dependencies list
""" """
assert PackageDescription( assert PackageDescription(depends=["a=1"], make_depends=["b>=3"], opt_depends=["c: a description"]) == \
depends=["a=1"], PackageDescription(depends=["a"], make_depends=["b"], opt_depends=["c"])
make_depends=["b>=3"],
opt_depends=["c: a description"],
check_depends=["d=4"],
provides=["e=5"]
) == PackageDescription(depends=["a"], make_depends=["b"], opt_depends=["c"], check_depends=["d"], provides=["e"])
def test_filepath(package_description_ahriman: PackageDescription) -> None: def test_filepath(package_description_ahriman: PackageDescription) -> None:

View File

@ -198,6 +198,15 @@ def test_owner(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None
assert RepositoryPaths.owner(repository_paths.root) == (42, 142) assert RepositoryPaths.owner(repository_paths.root) == (42, 142)
def test_cache_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None:
"""
must return correct path for cache directory
"""
path = repository_paths.cache_for(package_ahriman.base)
assert path.name == package_ahriman.base
assert path.parent == repository_paths.cache
def test_chown(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None: def test_chown(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None:
""" """
must correctly set owner for the directory must correctly set owner for the directory
@ -207,7 +216,7 @@ def test_chown(repository_paths: RepositoryPaths, mocker: MockerFixture) -> None
chown_mock = mocker.patch("os.chown") chown_mock = mocker.patch("os.chown")
path = repository_paths.root / "path" path = repository_paths.root / "path"
repository_paths._chown(path) repository_paths.chown(path)
chown_mock.assert_called_once_with(path, 42, 42, follow_symlinks=False) chown_mock.assert_called_once_with(path, 42, 42, follow_symlinks=False)
@ -220,7 +229,7 @@ def test_chown_parent(repository_paths: RepositoryPaths, mocker: MockerFixture)
chown_mock = mocker.patch("os.chown") chown_mock = mocker.patch("os.chown")
path = repository_paths.root / "parent" / "path" path = repository_paths.root / "parent" / "path"
repository_paths._chown(path) repository_paths.chown(path)
chown_mock.assert_has_calls([ chown_mock.assert_has_calls([
MockCall(path, 42, 42, follow_symlinks=False), MockCall(path, 42, 42, follow_symlinks=False),
MockCall(path.parent, 42, 42, follow_symlinks=False) MockCall(path.parent, 42, 42, follow_symlinks=False)
@ -236,7 +245,7 @@ def test_chown_skip(repository_paths: RepositoryPaths, mocker: MockerFixture) ->
chown_mock = mocker.patch("os.chown") chown_mock = mocker.patch("os.chown")
path = repository_paths.root / "path" path = repository_paths.root / "path"
repository_paths._chown(path) repository_paths.chown(path)
chown_mock.assert_not_called() chown_mock.assert_not_called()
@ -245,46 +254,7 @@ def test_chown_invalid_path(repository_paths: RepositoryPaths) -> None:
must raise invalid path exception in case if directory outside the root supplied must raise invalid path exception in case if directory outside the root supplied
""" """
with pytest.raises(PathError): with pytest.raises(PathError):
repository_paths._chown(repository_paths.root.parent) repository_paths.chown(repository_paths.root.parent)
def test_cache_for(repository_paths: RepositoryPaths, package_ahriman: Package) -> None:
"""
must return correct path for cache directory
"""
path = repository_paths.cache_for(package_ahriman.base)
assert path.name == package_ahriman.base
assert path.parent == repository_paths.cache
def test_preserve_owner(tmp_path: Path, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must preserve file owner during operations
"""
repository_paths = RepositoryPaths(tmp_path, repository_id)
repository_paths.tree_create()
chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths._chown")
with repository_paths.preserve_owner():
(repository_paths.root / "created1").touch()
(repository_paths.chroot / "created2").touch()
chown_mock.assert_has_calls([MockCall(repository_paths.root / "created1")])
def test_preserve_owner_specific(tmp_path: Path, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must preserve file owner during operations only in specific directory
"""
repository_paths = RepositoryPaths(tmp_path, repository_id)
repository_paths.tree_create()
(repository_paths.root / "content").mkdir()
chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths._chown")
with repository_paths.preserve_owner(repository_paths.root / "content"):
(repository_paths.root / "created1").touch()
(repository_paths.root / "content" / "created2").touch()
(repository_paths.chroot / "created3").touch()
chown_mock.assert_has_calls([MockCall(repository_paths.root / "content" / "created2")])
def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None: def test_tree_clear(repository_paths: RepositoryPaths, package_ahriman: Package, mocker: MockerFixture) -> None:
@ -323,11 +293,11 @@ def test_tree_create(repository_paths: RepositoryPaths, mocker: MockerFixture) -
and not callable(getattr(repository_paths, prop)) and not callable(getattr(repository_paths, prop))
} }
mkdir_mock = mocker.patch("pathlib.Path.mkdir") mkdir_mock = mocker.patch("pathlib.Path.mkdir")
owner_guard_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.preserve_owner") chown_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.chown")
repository_paths.tree_create() repository_paths.tree_create()
mkdir_mock.assert_has_calls([MockCall(mode=0o755, parents=True, exist_ok=True) for _ in paths], any_order=True) mkdir_mock.assert_has_calls([MockCall(mode=0o755, parents=True, exist_ok=True) for _ in paths], any_order=True)
owner_guard_mock.assert_called_once_with() chown_mock.assert_has_calls([MockCall(pytest.helpers.anyvar(int)) for _ in paths], any_order=True)
def test_tree_create_skip(mocker: MockerFixture) -> None: def test_tree_create_skip(mocker: MockerFixture) -> None:

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1,54 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.core.formatters.configuration_printer import ConfigurationPrinter
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.service.config import ConfigView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await ConfigView.get_permission(request) == UserAccess.Full
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await ConfigView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert ConfigView.ROUTES == ["/api/v1/service/config"]
async def test_get(client: TestClient) -> None:
"""
must get web configuration
"""
response_schema = pytest.helpers.schema_response(ConfigView.get)
response = await client.get("/api/v1/service/config")
assert response.status == 200
json = await response.json()
assert json # check that it is not empty
assert not response_schema.validate(json)
# check that there are no keys which have to be hidden
assert not any(value["key"] in ConfigurationPrinter.HIDE_KEYS for value in json)
async def test_post(client: TestClient, mocker: MockerFixture) -> None:
"""
must update package changes
"""
reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload")
response = await client.post("/api/v1/service/config")
assert response.status == 204
reload_mock.assert_called_once_with()