mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-08-30 21:39:56 +00:00
Compare commits
5 Commits
db3f20546e
...
master
Author | SHA1 | Date | |
---|---|---|---|
10798b9ba3 | |||
358e3dc4d2 | |||
c13cd029bc | |||
ae32cc8fbb | |||
dff5b775a9 |
21
docs/ahriman.core.housekeeping.rst
Normal file
21
docs/ahriman.core.housekeeping.rst
Normal file
@ -0,0 +1,21 @@
|
||||
ahriman.core.housekeeping package
|
||||
=================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
ahriman.core.housekeeping.logs\_rotation\_trigger module
|
||||
--------------------------------------------------------
|
||||
|
||||
.. automodule:: ahriman.core.housekeeping.logs_rotation_trigger
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: ahriman.core.housekeeping
|
||||
:members:
|
||||
:no-undoc-members:
|
||||
:show-inheritance:
|
@ -15,6 +15,7 @@ Subpackages
|
||||
ahriman.core.distributed
|
||||
ahriman.core.formatters
|
||||
ahriman.core.gitremote
|
||||
ahriman.core.housekeeping
|
||||
ahriman.core.http
|
||||
ahriman.core.log
|
||||
ahriman.core.report
|
||||
|
@ -40,6 +40,7 @@ This package contains everything required for the most of application actions an
|
||||
* ``ahriman.core.distributed`` package with triggers and helpers for distributed build system.
|
||||
* ``ahriman.core.formatters`` package provides ``Printer`` sub-classes for printing data (e.g. package properties) to stdout which are used by some handlers.
|
||||
* ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly.
|
||||
* ``ahriman.core.housekeeping`` package provides few triggers for removing old data.
|
||||
* ``ahriman.core.http`` package provides HTTP clients which can be used later by other classes.
|
||||
* ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and some wrappers.
|
||||
* ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly.
|
||||
|
@ -65,6 +65,8 @@ will try to read value from ``SECRET`` environment variable. In case if the requ
|
||||
|
||||
will eventually lead ``key`` option in section ``section1`` to be set to the value of ``HOME`` environment variable (if available).
|
||||
|
||||
Moreover, configuration can be read from environment variables directly by following the same naming convention, e.g. in the example above, one can have environment variable named ``section1:key`` (e.g. ``section1:key=$HOME``) and it will be substituted to the configuration with the highest priority.
|
||||
|
||||
There is also additional subcommand which will allow to validate configuration and print found errors. In order to do so, run ``service-config-validate`` subcommand, e.g.:
|
||||
|
||||
.. code-block:: shell
|
||||
@ -81,7 +83,6 @@ Base configuration settings.
|
||||
* ``apply_migrations`` - perform database migrations on the application start, boolean, optional, default ``yes``. Useful if you are using git version. Note, however, that this option must be changed only if you know what to do and going to handle migrations manually.
|
||||
* ``database`` - path to the application SQLite database, string, required.
|
||||
* ``include`` - path to directory with configuration files overrides, string, optional. Files will be read in alphabetical order.
|
||||
* ``keep_last_logs`` - amount of build logs to be kept for each package, integer, optional ,default ``0``. Logs will be cleared at the end of each process.
|
||||
* ``logging`` - path to logging configuration, string, required. Check ``logging.ini`` for reference.
|
||||
|
||||
``alpm:*`` groups
|
||||
@ -138,6 +139,8 @@ Build related configuration. Group name can refer to architecture, e.g. ``build:
|
||||
|
||||
Base repository settings.
|
||||
|
||||
* ``architecture`` - repository architecture, string. This field is read-only and generated automatically from run options if possible.
|
||||
* ``name`` - repository name, string. This field is read-only and generated automatically from run options if possible.
|
||||
* ``root`` - root path for application, string, required.
|
||||
|
||||
``sign:*`` groups
|
||||
@ -180,7 +183,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
||||
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional.
|
||||
|
||||
``keyring`` group
|
||||
--------------------
|
||||
-----------------
|
||||
|
||||
Keyring package generator plugin.
|
||||
|
||||
@ -198,6 +201,13 @@ Keyring generator plugin
|
||||
* ``revoked`` - list of revoked packagers keys, space separated list of strings, optional.
|
||||
* ``trusted`` - list of master keys, space separated list of strings, optional, if not set, the ``key`` option from ``sign`` group will be used.
|
||||
|
||||
``housekeeping`` group
|
||||
----------------------
|
||||
|
||||
This section describes settings for the ``ahriman.core.housekeeping.LogsRotationTrigger`` plugin.
|
||||
|
||||
* ``keep_last_logs`` - amount of build logs to be kept for each package, integer, optional ,default ``0``. Logs will be cleared at the end of each process.
|
||||
|
||||
``mirrorlist`` group
|
||||
--------------------
|
||||
|
||||
|
@ -40,6 +40,7 @@ package_ahriman-core() {
|
||||
'rsync: sync by using rsync')
|
||||
install="$pkgbase.install"
|
||||
backup=('etc/ahriman.ini'
|
||||
'etc/ahriman.ini.d/00-housekeeping.ini'
|
||||
'etc/ahriman.ini.d/logging.ini')
|
||||
|
||||
cd "$pkgbase-$pkgver"
|
||||
@ -49,6 +50,7 @@ package_ahriman-core() {
|
||||
|
||||
# keep usr/share configs as reference and copy them to /etc
|
||||
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini" "$pkgdir/etc/ahriman.ini"
|
||||
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini.d/00-housekeeping.ini" "$pkgdir/etc/ahriman.ini.d/00-housekeeping.ini"
|
||||
install -Dm644 "$pkgdir/usr/share/$pkgbase/settings/ahriman.ini.d/logging.ini" "$pkgdir/etc/ahriman.ini.d/logging.ini"
|
||||
|
||||
install -Dm644 "$srcdir/$pkgbase.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgbase.conf"
|
||||
|
@ -7,8 +7,6 @@ logging = ahriman.ini.d/logging.ini
|
||||
;apply_migrations = yes
|
||||
; Path to the application SQLite database.
|
||||
database = ${repository:root}/ahriman.db
|
||||
; Keep last build logs for each package
|
||||
keep_last_logs = 5
|
||||
|
||||
[alpm]
|
||||
; Path to pacman system database cache.
|
||||
@ -45,9 +43,11 @@ triggers[] = ahriman.core.gitremote.RemotePullTrigger
|
||||
triggers[] = ahriman.core.report.ReportTrigger
|
||||
triggers[] = ahriman.core.upload.UploadTrigger
|
||||
triggers[] = ahriman.core.gitremote.RemotePushTrigger
|
||||
triggers[] = ahriman.core.housekeeping.LogsRotationTrigger
|
||||
; List of well-known triggers. Used only for configuration purposes.
|
||||
triggers_known[] = ahriman.core.gitremote.RemotePullTrigger
|
||||
triggers_known[] = ahriman.core.gitremote.RemotePushTrigger
|
||||
triggers_known[] = ahriman.core.housekeeping.LogsRotationTrigger
|
||||
triggers_known[] = ahriman.core.report.ReportTrigger
|
||||
triggers_known[] = ahriman.core.upload.UploadTrigger
|
||||
; Maximal age in seconds of the VCS packages before their version will be updated with its remote source.
|
||||
|
@ -0,0 +1,3 @@
|
||||
[logs-rotation]
|
||||
; Keep last build logs for each package
|
||||
keep_last_logs = 5
|
@ -28,6 +28,7 @@ from ahriman.core.alpm.remote import AUR, Official
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.exceptions import OptionError
|
||||
from ahriman.core.formatters import AurPrinter
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
|
||||
@ -115,7 +116,7 @@ class Search(Handler):
|
||||
raise OptionError(sort_by)
|
||||
# always sort by package name at the last
|
||||
# well technically it is not a string, but we can deal with it
|
||||
comparator: Callable[[AURPackage], tuple[str, str]] =\
|
||||
comparator: Callable[[AURPackage], Comparable] = \
|
||||
lambda package: (getattr(package, sort_by), package.name)
|
||||
return sorted(packages, key=comparator)
|
||||
|
||||
|
@ -25,6 +25,7 @@ from ahriman.application.application import Application
|
||||
from ahriman.application.handlers.handler import Handler, SubParserAction
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.formatters import PackagePrinter, StatusPrinter
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.core.utils import enum_values
|
||||
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
|
||||
from ahriman.models.package import Package
|
||||
@ -64,7 +65,7 @@ class Status(Handler):
|
||||
|
||||
Status.check_status(args.exit_code, packages)
|
||||
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda item: item[0].base
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda item: item[0].base
|
||||
filter_fn: Callable[[tuple[Package, BuildStatus]], bool] =\
|
||||
lambda item: args.status is None or item[1].status == args.status
|
||||
for package, package_status in sorted(filter(filter_fn, packages), key=comparator):
|
||||
|
@ -52,7 +52,7 @@ class Validate(Handler):
|
||||
"""
|
||||
from ahriman.core.configuration.validator import Validator
|
||||
|
||||
schema = Validate.schema(repository_id, configuration)
|
||||
schema = Validate.schema(configuration)
|
||||
validator = Validator(configuration=configuration, schema=schema)
|
||||
|
||||
if validator.validate(configuration.dump()):
|
||||
@ -83,12 +83,11 @@ class Validate(Handler):
|
||||
return parser
|
||||
|
||||
@staticmethod
|
||||
def schema(repository_id: RepositoryId, configuration: Configuration) -> ConfigurationSchema:
|
||||
def schema(configuration: Configuration) -> ConfigurationSchema:
|
||||
"""
|
||||
get schema with triggers
|
||||
|
||||
Args:
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
|
||||
Returns:
|
||||
@ -107,12 +106,12 @@ class Validate(Handler):
|
||||
continue
|
||||
|
||||
# default settings if any
|
||||
for schema_name, schema in trigger_class.configuration_schema(repository_id, None).items():
|
||||
for schema_name, schema in trigger_class.configuration_schema(None).items():
|
||||
erased = Validate.schema_erase_required(copy.deepcopy(schema))
|
||||
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), erased)
|
||||
|
||||
# settings according to enabled triggers
|
||||
for schema_name, schema in trigger_class.configuration_schema(repository_id, configuration).items():
|
||||
for schema_name, schema in trigger_class.configuration_schema(configuration).items():
|
||||
root[schema_name] = Validate.schema_merge(root.get(schema_name, {}), copy.deepcopy(schema))
|
||||
|
||||
return root
|
||||
|
@ -19,6 +19,7 @@
|
||||
#
|
||||
# pylint: disable=too-many-public-methods
|
||||
import configparser
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
@ -42,7 +43,6 @@ class Configuration(configparser.RawConfigParser):
|
||||
SYSTEM_CONFIGURATION_PATH(Path): (class attribute) default system configuration path distributed by package
|
||||
includes(list[Path]): list of includes which were read
|
||||
path(Path | None): path to root configuration file
|
||||
repository_id(RepositoryId | None): repository unique identifier
|
||||
|
||||
Examples:
|
||||
Configuration class provides additional method in order to handle application configuration. Since this class is
|
||||
@ -93,7 +93,7 @@ class Configuration(configparser.RawConfigParser):
|
||||
},
|
||||
)
|
||||
|
||||
self.repository_id: RepositoryId | None = None
|
||||
self._repository_id: RepositoryId | None = None
|
||||
self.path: Path | None = None
|
||||
self.includes: list[Path] = []
|
||||
|
||||
@ -128,6 +128,32 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
return self.getpath("settings", "logging")
|
||||
|
||||
@property
|
||||
def repository_id(self) -> RepositoryId | None:
|
||||
"""
|
||||
repository identifier
|
||||
|
||||
Returns:
|
||||
RepositoryId: repository unique identifier
|
||||
"""
|
||||
return self._repository_id
|
||||
|
||||
@repository_id.setter
|
||||
def repository_id(self, repository_id: RepositoryId | None) -> None:
|
||||
"""
|
||||
setter for repository identifier
|
||||
|
||||
Args:
|
||||
repository_id(RepositoryId | None): repository unique identifier
|
||||
"""
|
||||
self._repository_id = repository_id
|
||||
if repository_id is None or repository_id.is_empty:
|
||||
self.remove_option("repository", "name")
|
||||
self.remove_option("repository", "architecture")
|
||||
else:
|
||||
self.set_option("repository", "name", repository_id.name)
|
||||
self.set_option("repository", "architecture", repository_id.architecture)
|
||||
|
||||
@property
|
||||
def repository_name(self) -> str:
|
||||
"""
|
||||
@ -164,6 +190,7 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
configuration = cls()
|
||||
configuration.load(path)
|
||||
configuration.load_environment()
|
||||
configuration.merge_sections(repository_id)
|
||||
return configuration
|
||||
|
||||
@ -288,6 +315,16 @@ class Configuration(configparser.RawConfigParser):
|
||||
self.read(self.path)
|
||||
self.load_includes() # load includes
|
||||
|
||||
def load_environment(self) -> None:
|
||||
"""
|
||||
load environment variables into configuration
|
||||
"""
|
||||
for name, value in os.environ.items():
|
||||
if ":" not in name:
|
||||
continue
|
||||
section, key = name.rsplit(":", maxsplit=1)
|
||||
self.set_option(section, key, value)
|
||||
|
||||
def load_includes(self, path: Path | None = None) -> None:
|
||||
"""
|
||||
load configuration includes from specified path
|
||||
@ -356,11 +393,16 @@ class Configuration(configparser.RawConfigParser):
|
||||
"""
|
||||
reload configuration if possible or raise exception otherwise
|
||||
"""
|
||||
# get current properties and validate input
|
||||
path, repository_id = self.check_loaded()
|
||||
for section in self.sections(): # clear current content
|
||||
|
||||
# clear current content
|
||||
for section in self.sections():
|
||||
self.remove_section(section)
|
||||
self.load(path)
|
||||
self.merge_sections(repository_id)
|
||||
|
||||
# create another instance and copy values from there
|
||||
instance = self.from_path(path, repository_id)
|
||||
self.copy_from(instance)
|
||||
|
||||
def set_option(self, section: str, option: str, value: str) -> None:
|
||||
"""
|
||||
|
@ -45,11 +45,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"path_exists": True,
|
||||
"path_type": "dir",
|
||||
},
|
||||
"keep_last_logs": {
|
||||
"type": "integer",
|
||||
"coerce": "integer",
|
||||
"min": 0,
|
||||
},
|
||||
"logging": {
|
||||
"type": "path",
|
||||
"coerce": "absolute_path",
|
||||
@ -254,6 +249,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"repository": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"architecture": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
|
20
src/ahriman/core/housekeeping/__init__.py
Normal file
20
src/ahriman/core/housekeeping/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
#
|
||||
# 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.core.housekeeping.logs_rotation_trigger import LogsRotationTrigger
|
87
src/ahriman/core/housekeeping/logs_rotation_trigger.py
Normal file
87
src/ahriman/core/housekeeping/logs_rotation_trigger.py
Normal file
@ -0,0 +1,87 @@
|
||||
#
|
||||
# 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.core import context
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.core.triggers import Trigger
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.result import Result
|
||||
|
||||
|
||||
class LogsRotationTrigger(Trigger):
|
||||
"""
|
||||
rotate logs after build processes
|
||||
|
||||
Attributes:
|
||||
keep_last_records(int): number of last records to keep
|
||||
"""
|
||||
|
||||
CONFIGURATION_SCHEMA = {
|
||||
"logs-rotation": {
|
||||
"type": "dict",
|
||||
"schema": {
|
||||
"keep_last_logs": {
|
||||
"type": "integer",
|
||||
"required": True,
|
||||
"coerce": "integer",
|
||||
"min": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
|
||||
"""
|
||||
Args:
|
||||
repository_id(RepositoryId): repository unique identifier
|
||||
configuration(Configuration): configuration instance
|
||||
"""
|
||||
Trigger.__init__(self, repository_id, configuration)
|
||||
|
||||
section = next(iter(self.configuration_sections(configuration)))
|
||||
self.keep_last_records = configuration.getint( # read old-style first and then fallback to new style
|
||||
"settings", "keep_last_logs",
|
||||
fallback=configuration.getint(section, "keep_last_logs"))
|
||||
|
||||
@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 on_result(self, result: Result, packages: list[Package]) -> None:
|
||||
"""
|
||||
run trigger
|
||||
|
||||
Args:
|
||||
result(Result): build result
|
||||
packages(list[Package]): list of all available packages
|
||||
"""
|
||||
ctx = context.get()
|
||||
reporter = ctx.get(Client)
|
||||
reporter.logs_rotate(self.keep_last_records)
|
@ -17,7 +17,6 @@
|
||||
# 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 atexit
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@ -37,7 +36,6 @@ class HttpLogHandler(logging.Handler):
|
||||
method
|
||||
|
||||
Attributes:
|
||||
keep_last_records(int): number of last records to keep
|
||||
reporter(Client): build status reporter instance
|
||||
suppress_errors(bool): suppress logging errors (e.g. if no web server available)
|
||||
"""
|
||||
@ -56,7 +54,6 @@ class HttpLogHandler(logging.Handler):
|
||||
|
||||
self.reporter = Client.load(repository_id, configuration, report=report)
|
||||
self.suppress_errors = suppress_errors
|
||||
self.keep_last_records = configuration.getint("settings", "keep_last_logs", fallback=0)
|
||||
|
||||
@classmethod
|
||||
def load(cls, repository_id: RepositoryId, configuration: Configuration, *, report: bool) -> Self:
|
||||
@ -83,7 +80,6 @@ class HttpLogHandler(logging.Handler):
|
||||
root.addHandler(handler)
|
||||
|
||||
LogRecordId.DEFAULT_PROCESS_ID = str(uuid.uuid4()) # assign default process identifier for log records
|
||||
atexit.register(handler.rotate)
|
||||
|
||||
return handler
|
||||
|
||||
@ -104,9 +100,3 @@ class HttpLogHandler(logging.Handler):
|
||||
if self.suppress_errors:
|
||||
return
|
||||
self.handleError(record)
|
||||
|
||||
def rotate(self) -> None:
|
||||
"""
|
||||
rotate log records, removing older ones
|
||||
"""
|
||||
self.reporter.logs_rotate(self.keep_last_records)
|
||||
|
@ -26,6 +26,7 @@ from typing import Any
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.sign.gpg import GPG
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.core.utils import pretty_datetime, pretty_size, utcnow
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
from ahriman.models.result import Result
|
||||
@ -111,7 +112,7 @@ class JinjaTemplate:
|
||||
Returns:
|
||||
list[dict[str, str]]: sorted content according to comparator defined
|
||||
"""
|
||||
comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"]
|
||||
comparator: Callable[[dict[str, str]], Comparable] = lambda item: item["filename"]
|
||||
return sorted(content, key=comparator)
|
||||
|
||||
def make_html(self, result: Result, template_name: Path | str) -> str:
|
||||
|
@ -28,6 +28,7 @@ from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.report.jinja_template import JinjaTemplate
|
||||
from ahriman.core.report.report import Report
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.event import EventType
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.repository_id import RepositoryId
|
||||
@ -86,7 +87,7 @@ class RSS(Report, JinjaTemplate):
|
||||
Returns:
|
||||
list[dict[str, str]]: sorted content according to comparator defined
|
||||
"""
|
||||
comparator: Callable[[dict[str, str]], datetime.datetime] = \
|
||||
comparator: Callable[[dict[str, str]], Comparable] = \
|
||||
lambda item: parsedate_to_datetime(item["build_date"])
|
||||
return sorted(content, key=comparator, reverse=True)
|
||||
|
||||
|
@ -80,8 +80,7 @@ class Trigger(LazyLogging):
|
||||
return self.repository_id.architecture
|
||||
|
||||
@classmethod
|
||||
def configuration_schema(cls, repository_id: RepositoryId,
|
||||
configuration: Configuration | None) -> ConfigurationSchema:
|
||||
def configuration_schema(cls, configuration: Configuration | None) -> ConfigurationSchema:
|
||||
"""
|
||||
configuration schema based on supplied service configuration
|
||||
|
||||
@ -89,7 +88,6 @@ class Trigger(LazyLogging):
|
||||
Schema must be in cerberus format, for details and examples you can check built-in triggers.
|
||||
|
||||
Args:
|
||||
repository_id(str): repository unique identifier
|
||||
configuration(Configuration | None): configuration instance. If set to None, the default schema
|
||||
should be returned
|
||||
|
||||
@ -101,13 +99,15 @@ class Trigger(LazyLogging):
|
||||
|
||||
result: ConfigurationSchema = {}
|
||||
for target in cls.configuration_sections(configuration):
|
||||
if not configuration.has_section(target):
|
||||
continue
|
||||
section, schema_name = configuration.gettype(
|
||||
target, repository_id, fallback=cls.CONFIGURATION_SCHEMA_FALLBACK)
|
||||
if schema_name not in cls.CONFIGURATION_SCHEMA:
|
||||
continue
|
||||
result[section] = cls.CONFIGURATION_SCHEMA[schema_name]
|
||||
for section in configuration.sections():
|
||||
if not (section == target or section.startswith(f"{target}:")):
|
||||
# either repository specific or exact name
|
||||
continue
|
||||
schema_name = configuration.get(section, "type", fallback=section)
|
||||
|
||||
if schema_name not in cls.CONFIGURATION_SCHEMA:
|
||||
continue
|
||||
result[section] = cls.CONFIGURATION_SCHEMA[schema_name]
|
||||
|
||||
return result
|
||||
|
||||
|
@ -17,7 +17,15 @@
|
||||
# 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 typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class Comparable(Protocol):
|
||||
"""
|
||||
class which supports :func:`__lt__` operation`
|
||||
"""
|
||||
|
||||
def __lt__(self, other: Any) -> bool: ...
|
||||
|
||||
|
||||
class HasBool(Protocol):
|
||||
|
@ -21,6 +21,7 @@ from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.models.worker import Worker
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
@ -74,7 +75,7 @@ class WorkersView(BaseView):
|
||||
"""
|
||||
workers = self.workers.workers
|
||||
|
||||
comparator: Callable[[Worker], str] = lambda item: item.identifier
|
||||
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
|
||||
response = [worker.view() for worker in sorted(workers, key=comparator)]
|
||||
|
||||
return json_response(response)
|
||||
|
@ -23,6 +23,7 @@ from aiohttp.web import HTTPNoContent, Response, json_response
|
||||
from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.build_status import BuildStatus
|
||||
from ahriman.models.package import Package
|
||||
from ahriman.models.user_access import UserAccess
|
||||
@ -68,7 +69,7 @@ class PackagesView(StatusViewGuard, BaseView):
|
||||
repository_id = self.repository_id()
|
||||
packages = self.service(repository_id).packages
|
||||
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda items: items[0].base
|
||||
comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda items: items[0].base
|
||||
response = [
|
||||
{
|
||||
"package": package.view(),
|
||||
|
@ -22,6 +22,7 @@ from collections.abc import Callable
|
||||
from typing import ClassVar
|
||||
|
||||
from ahriman.core.alpm.remote import AUR
|
||||
from ahriman.core.types import Comparable
|
||||
from ahriman.models.aur_package import AURPackage
|
||||
from ahriman.models.user_access import UserAccess
|
||||
from ahriman.web.apispec.decorators import apidocs
|
||||
@ -70,10 +71,11 @@ class SearchView(BaseView):
|
||||
if not packages:
|
||||
raise HTTPNotFound(reason=f"No packages found for terms: {search}")
|
||||
|
||||
comparator: Callable[[AURPackage], tuple[bool, bool, str]] = lambda item: (
|
||||
item.package_base not in search, # inverted because False < True
|
||||
not any(item.package_base.startswith(term) for term in search), # same as above
|
||||
item.package_base,
|
||||
comparator: Callable[[AURPackage], Comparable] = \
|
||||
lambda item: (
|
||||
item.package_base not in search, # inverted because False < True
|
||||
not any(item.package_base.startswith(term) for term in search), # same as above
|
||||
item.package_base,
|
||||
)
|
||||
response = [
|
||||
{
|
||||
|
@ -2,6 +2,7 @@ import argparse
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from ahriman.application.handlers.validate import Validate
|
||||
@ -53,12 +54,50 @@ def test_run_skip(args: argparse.Namespace, configuration: Configuration, mocker
|
||||
print_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_run_default(args: argparse.Namespace, configuration: Configuration) -> None:
|
||||
"""
|
||||
must run on default configuration without errors
|
||||
"""
|
||||
args.exit_code = True
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
default = Configuration.from_path(Configuration.SYSTEM_CONFIGURATION_PATH, repository_id)
|
||||
# copy autogenerated values
|
||||
for section, key in (("build", "build_command"), ("repository", "root")):
|
||||
value = configuration.get(section, key)
|
||||
default.set_option(section, key, value)
|
||||
|
||||
Validate.run(args, repository_id, default, report=False)
|
||||
|
||||
|
||||
def test_run_repo_specific_triggers(args: argparse.Namespace, configuration: Configuration,
|
||||
resource_path_root: Path) -> None:
|
||||
"""
|
||||
must correctly insert repo specific triggers
|
||||
"""
|
||||
args.exit_code = True
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
# remove unused sections
|
||||
for section in ("customs3", "github:x86_64", "logs-rotation", "mirrorlist"):
|
||||
configuration.remove_section(section)
|
||||
|
||||
configuration.set_option("report", "target", "test")
|
||||
for section in ("test", "test:i686", "test:another-repo:x86_64"):
|
||||
configuration.set_option(section, "type", "html")
|
||||
configuration.set_option(section, "link_path", "http://link_path")
|
||||
configuration.set_option(section, "path", "path")
|
||||
configuration.set_option(section, "template", "template")
|
||||
configuration.set_option(section, "templates", str(resource_path_root))
|
||||
|
||||
Validate.run(args, repository_id, configuration, report=False)
|
||||
|
||||
|
||||
def test_schema(configuration: Configuration) -> None:
|
||||
"""
|
||||
must generate full schema correctly
|
||||
"""
|
||||
_, repository_id = configuration.check_loaded()
|
||||
schema = Validate.schema(repository_id, configuration)
|
||||
schema = Validate.schema(configuration)
|
||||
|
||||
# defaults
|
||||
assert schema.pop("console")
|
||||
@ -91,9 +130,7 @@ def test_schema_invalid_trigger(configuration: Configuration) -> None:
|
||||
"""
|
||||
configuration.set_option("build", "triggers", "some.invalid.trigger.path.Trigger")
|
||||
configuration.remove_option("build", "triggers_known")
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
assert Validate.schema(repository_id, configuration) == CONFIGURATION_SCHEMA
|
||||
assert Validate.schema(configuration) == CONFIGURATION_SCHEMA
|
||||
|
||||
|
||||
def test_schema_erase_required() -> None:
|
||||
|
@ -1,8 +1,8 @@
|
||||
import configparser
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
import os
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from pytest_mock import MockerFixture
|
||||
from unittest.mock import call as MockCall
|
||||
@ -20,6 +20,40 @@ def test_architecture(configuration: Configuration) -> None:
|
||||
assert configuration.architecture == "x86_64"
|
||||
|
||||
|
||||
def test_repository_id(configuration: Configuration, repository_id: RepositoryId) -> None:
|
||||
"""
|
||||
must return repository identifier
|
||||
"""
|
||||
assert configuration.repository_id == repository_id
|
||||
assert configuration.get("repository", "name") == repository_id.name
|
||||
assert configuration.get("repository", "architecture") == repository_id.architecture
|
||||
|
||||
|
||||
def test_repository_id_erase(configuration: Configuration) -> None:
|
||||
"""
|
||||
must remove repository identifier properties if empty identifier supplied
|
||||
"""
|
||||
configuration.repository_id = None
|
||||
assert configuration.get("repository", "name", fallback=None) is None
|
||||
assert configuration.get("repository", "architecture", fallback=None) is None
|
||||
|
||||
configuration.repository_id = RepositoryId("", "")
|
||||
assert configuration.get("repository", "name", fallback=None) is None
|
||||
assert configuration.get("repository", "architecture", fallback=None) is None
|
||||
|
||||
|
||||
def test_repository_id_update(configuration: Configuration, repository_id: RepositoryId) -> None:
|
||||
"""
|
||||
must update repository identifier and related configuration options
|
||||
"""
|
||||
repository_id = RepositoryId("i686", repository_id.name)
|
||||
|
||||
configuration.repository_id = repository_id
|
||||
assert configuration.repository_id == repository_id
|
||||
assert configuration.get("repository", "name") == repository_id.name
|
||||
assert configuration.get("repository", "architecture") == repository_id.architecture
|
||||
|
||||
|
||||
def test_repository_name(configuration: Configuration) -> None:
|
||||
"""
|
||||
must return valid repository name
|
||||
@ -42,12 +76,16 @@ def test_from_path(repository_id: RepositoryId, mocker: MockerFixture) -> None:
|
||||
mocker.patch("ahriman.core.configuration.Configuration.get", return_value="ahriman.ini.d")
|
||||
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
|
||||
load_includes_mock = mocker.patch("ahriman.core.configuration.Configuration.load_includes")
|
||||
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
|
||||
environment_mock = mocker.patch("ahriman.core.configuration.Configuration.load_environment")
|
||||
path = Path("path")
|
||||
|
||||
configuration = Configuration.from_path(path, repository_id)
|
||||
assert configuration.path == path
|
||||
read_mock.assert_called_once_with(path)
|
||||
load_includes_mock.assert_called_once_with()
|
||||
merge_mock.assert_called_once_with(repository_id)
|
||||
environment_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_from_path_file_missing(repository_id: RepositoryId, mocker: MockerFixture) -> None:
|
||||
@ -324,6 +362,18 @@ def test_gettype_from_section_no_section(configuration: Configuration) -> None:
|
||||
configuration.gettype("rsync:x86_64", configuration.repository_id)
|
||||
|
||||
|
||||
def test_load_environment(configuration: Configuration) -> None:
|
||||
"""
|
||||
must load environment variables
|
||||
"""
|
||||
os.environ["section:key"] = "value1"
|
||||
os.environ["section:identifier:key"] = "value2"
|
||||
|
||||
configuration.load_environment()
|
||||
assert configuration.get("section", "key") == "value1"
|
||||
assert configuration.get("section:identifier", "key") == "value2"
|
||||
|
||||
|
||||
def test_load_includes(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must load includes
|
||||
@ -444,10 +494,12 @@ def test_reload(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
load_mock = mocker.patch("ahriman.core.configuration.Configuration.load")
|
||||
merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections")
|
||||
environment_mock = mocker.patch("ahriman.core.configuration.Configuration.load_environment")
|
||||
|
||||
configuration.reload()
|
||||
load_mock.assert_called_once_with(configuration.path)
|
||||
merge_mock.assert_called_once_with(configuration.repository_id)
|
||||
environment_mock.assert_called_once_with()
|
||||
|
||||
|
||||
def test_reload_clear(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
|
19
tests/ahriman/core/housekeeping/conftest.py
Normal file
19
tests/ahriman/core/housekeeping/conftest.py
Normal file
@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.housekeeping import LogsRotationTrigger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logs_rotation_trigger(configuration: Configuration) -> LogsRotationTrigger:
|
||||
"""
|
||||
logs roration trigger fixture
|
||||
|
||||
Args:
|
||||
configuration(Configuration): configuration fixture
|
||||
|
||||
Returns:
|
||||
LogsRotationTrigger: logs rotation trigger test instance
|
||||
"""
|
||||
_, repository_id = configuration.check_loaded()
|
||||
return LogsRotationTrigger(repository_id, configuration)
|
@ -0,0 +1,26 @@
|
||||
from pytest_mock import MockerFixture
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from ahriman.core.configuration import Configuration
|
||||
from ahriman.core.housekeeping import LogsRotationTrigger
|
||||
from ahriman.core.status import Client
|
||||
from ahriman.models.result import Result
|
||||
|
||||
|
||||
def test_configuration_sections(configuration: Configuration) -> None:
|
||||
"""
|
||||
must correctly parse target list
|
||||
"""
|
||||
assert LogsRotationTrigger.configuration_sections(configuration) == ["logs-rotation"]
|
||||
|
||||
|
||||
def test_rotate(logs_rotation_trigger: LogsRotationTrigger, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must rotate logs
|
||||
"""
|
||||
client_mock = MagicMock()
|
||||
context_mock = mocker.patch("ahriman.core._Context.get", return_value=client_mock)
|
||||
|
||||
logs_rotation_trigger.on_result(Result(), [])
|
||||
context_mock.assert_called_once_with(Client)
|
||||
client_mock.logs_rotate.assert_called_once_with(logs_rotation_trigger.keep_last_records)
|
@ -20,14 +20,12 @@ def test_load(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
|
||||
add_mock = mocker.patch("logging.Logger.addHandler")
|
||||
load_mock = mocker.patch("ahriman.core.status.Client.load")
|
||||
atexit_mock = mocker.patch("atexit.register")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
handler = HttpLogHandler.load(repository_id, configuration, report=False)
|
||||
assert handler
|
||||
add_mock.assert_called_once_with(handler)
|
||||
load_mock.assert_called_once_with(repository_id, configuration, report=False)
|
||||
atexit_mock.assert_called_once_with(handler.rotate)
|
||||
|
||||
|
||||
def test_load_exist(configuration: Configuration) -> None:
|
||||
@ -96,16 +94,3 @@ def test_emit_skip(configuration: Configuration, log_record: logging.LogRecord,
|
||||
|
||||
handler.emit(log_record)
|
||||
log_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_rotate(configuration: Configuration, mocker: MockerFixture) -> None:
|
||||
"""
|
||||
must rotate logs
|
||||
"""
|
||||
rotate_mock = mocker.patch("ahriman.core.status.Client.logs_rotate")
|
||||
|
||||
_, repository_id = configuration.check_loaded()
|
||||
handler = HttpLogHandler(repository_id, configuration, report=False, suppress_errors=False)
|
||||
|
||||
handler.rotate()
|
||||
rotate_mock.assert_called_once_with(handler.keep_last_records)
|
||||
|
@ -19,10 +19,9 @@ def test_configuration_schema(configuration: Configuration) -> None:
|
||||
"""
|
||||
section = "console"
|
||||
configuration.set_option("report", "target", section)
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
expected = {section: ReportTrigger.CONFIGURATION_SCHEMA[section]}
|
||||
assert ReportTrigger.configuration_schema(repository_id, configuration) == expected
|
||||
assert ReportTrigger.configuration_schema(configuration) == expected
|
||||
|
||||
|
||||
def test_configuration_schema_no_section(configuration: Configuration) -> None:
|
||||
@ -31,9 +30,7 @@ def test_configuration_schema_no_section(configuration: Configuration) -> None:
|
||||
"""
|
||||
section = "abracadabra"
|
||||
configuration.set_option("report", "target", section)
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
assert ReportTrigger.configuration_schema(repository_id, configuration) == {}
|
||||
assert ReportTrigger.configuration_schema(configuration) == {}
|
||||
|
||||
|
||||
def test_configuration_schema_no_schema(configuration: Configuration) -> None:
|
||||
@ -43,17 +40,15 @@ def test_configuration_schema_no_schema(configuration: Configuration) -> None:
|
||||
section = "abracadabra"
|
||||
configuration.set_option("report", "target", section)
|
||||
configuration.set_option(section, "key", "value")
|
||||
_, repository_id = configuration.check_loaded()
|
||||
|
||||
assert ReportTrigger.configuration_schema(repository_id, configuration) == {}
|
||||
assert ReportTrigger.configuration_schema(configuration) == {}
|
||||
|
||||
|
||||
def test_configuration_schema_empty(configuration: Configuration) -> None:
|
||||
"""
|
||||
must return default schema if no configuration set
|
||||
"""
|
||||
_, repository_id = configuration.check_loaded()
|
||||
assert ReportTrigger.configuration_schema(repository_id, None) == ReportTrigger.CONFIGURATION_SCHEMA
|
||||
assert ReportTrigger.configuration_schema(None) == ReportTrigger.CONFIGURATION_SCHEMA
|
||||
|
||||
|
||||
def test_configuration_schema_variables() -> None:
|
||||
|
@ -37,6 +37,9 @@ target =
|
||||
[keyring]
|
||||
target = keyring
|
||||
|
||||
[logs-rotation]
|
||||
keep_last_logs = 5
|
||||
|
||||
[mirrorlist]
|
||||
target = mirrorlist
|
||||
servers = http://localhost
|
||||
|
Reference in New Issue
Block a user