Compare commits

..

3 Commits

59 changed files with 994 additions and 84 deletions

View File

@ -59,7 +59,7 @@ class Handler:
repository_id(RepositoryId): repository unique identifier repository_id(RepositoryId): repository unique identifier
Returns: Returns:
bool: True on success, False otherwise bool: ``True`` on success, ``False`` otherwise
""" """
try: try:
configuration = Configuration.from_path(args.configuration, repository_id) configuration = Configuration.from_path(args.configuration, repository_id)
@ -129,7 +129,7 @@ class Handler:
check condition and flag and raise ExitCode exception in case if it is enabled and condition match check condition and flag and raise ExitCode exception in case if it is enabled and condition match
Args: Args:
enabled(bool): if False no check will be performed enabled(bool): if ``False`` no check will be performed
predicate(bool): indicates condition on which exception should be thrown predicate(bool): indicates condition on which exception should be thrown
Raises: Raises:

View File

@ -97,7 +97,7 @@ class Lock(LazyLogging):
fd(int): file descriptor: fd(int): file descriptor:
Returns: Returns:
bool: True in case if file is locked and False otherwise bool: ``True`` in case if file is locked and ``False`` otherwise
""" """
try: try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@ -119,7 +119,7 @@ class Lock(LazyLogging):
watch until lock disappear watch until lock disappear
Returns: Returns:
bool: True in case if file is locked and False otherwise bool: ``True`` in case if file is locked and ``False`` otherwise
""" """
# there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to # there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to
# race conditions because multiple processes will be notified at the same time. Secondly, it is good library, # race conditions because multiple processes will be notified at the same time. Secondly, it is good library,
@ -223,7 +223,7 @@ class Lock(LazyLogging):
exc_tb(TracebackType): exception traceback if any exc_tb(TracebackType): exception traceback if any
Returns: Returns:
Literal[False]: always False (do not suppress any exception) Literal[False]: always ``False`` (do not suppress any exception)
""" """
self.clear() self.clear()
status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed

View File

@ -101,7 +101,7 @@ class PacmanDatabase(SyncHttpClient):
local_path(Path): path to locally stored file local_path(Path): path to locally stored file
Returns: Returns:
bool: True in case if remote file is newer than local file bool: ``True`` in case if remote file is newer than local file
Raises: Raises:
PacmanError: in case if no last-modified header was found PacmanError: in case if no last-modified header was found

View File

@ -96,7 +96,7 @@ class Auth(LazyLogging):
password(str | None): entered password password(str | None): entered password
Returns: Returns:
bool: True in case if password matches, False otherwise bool: ``True`` in case if password matches, ``False`` otherwise
""" """
del username, password del username, password
return True return True
@ -109,7 +109,7 @@ class Auth(LazyLogging):
username(str): username username(str): username
Returns: Returns:
bool: True in case if user is known and can be authorized and False otherwise bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
""" """
del username del username
return True return True
@ -124,7 +124,7 @@ class Auth(LazyLogging):
context(str | None): URI request path context(str | None): URI request path
Returns: Returns:
bool: True in case if user is allowed to do this request and False otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
del username, required, context del username, required, context
return True return True

View File

@ -38,7 +38,7 @@ async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function **kwargs(Any): named argument list as provided by authorized_userid function
Returns: Returns:
Any: None in case if no aiohttp_security module found and function call otherwise Any: ``None`` in case if no aiohttp_security module found and function call otherwise
""" """
if _has_aiohttp_security: if _has_aiohttp_security:
return await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter return await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -54,7 +54,7 @@ async def check_authorized(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function **kwargs(Any): named argument list as provided by authorized_userid function
Returns: Returns:
Any: None in case if no aiohttp_security module found and function call otherwise Any: ``None`` in case if no aiohttp_security module found and function call otherwise
""" """
if _has_aiohttp_security: if _has_aiohttp_security:
return await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter return await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -70,7 +70,7 @@ async def forget(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function **kwargs(Any): named argument list as provided by authorized_userid function
Returns: Returns:
Any: None in case if no aiohttp_security module found and function call otherwise Any: ``None`` in case if no aiohttp_security module found and function call otherwise
""" """
if _has_aiohttp_security: if _has_aiohttp_security:
return await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter return await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter
@ -86,7 +86,7 @@ async def remember(*args: Any, **kwargs: Any) -> Any:
**kwargs(Any): named argument list as provided by authorized_userid function **kwargs(Any): named argument list as provided by authorized_userid function
Returns: Returns:
Any: None in case if no aiohttp_security module found and function call otherwise Any: ``None`` in case if no aiohttp_security module found and function call otherwise
""" """
if _has_aiohttp_security: if _has_aiohttp_security:
return await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter return await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter

View File

@ -57,7 +57,7 @@ class Mapping(Auth):
password(str | None): entered password password(str | None): entered password
Returns: Returns:
bool: True in case if password matches, False otherwise bool: ``True`` in case if password matches, ``False`` otherwise
""" """
if password is None: if password is None:
return False # invalid data supplied return False # invalid data supplied
@ -72,7 +72,7 @@ class Mapping(Auth):
username(str): username username(str): username
Returns: Returns:
User | None: user descriptor if username is known and None otherwise User | None: user descriptor if username is known and ``None`` otherwise
""" """
return self.database.user_get(username) return self.database.user_get(username)
@ -84,7 +84,7 @@ class Mapping(Auth):
username(str): username username(str): username
Returns: Returns:
bool: True in case if user is known and can be authorized and False otherwise bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
""" """
return username is not None and self.get_user(username) is not None return username is not None and self.get_user(username) is not None
@ -98,7 +98,7 @@ class Mapping(Auth):
context(str | None): URI request path context(str | None): URI request path
Returns: Returns:
bool: True in case if user is allowed to do this request and False otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
user = self.get_user(username) user = self.get_user(username)
return user is not None and user.verify_access(required) return user is not None and user.verify_access(required)

View File

@ -79,7 +79,7 @@ class PAM(Mapping):
password(str | None): entered password password(str | None): entered password
Returns: Returns:
bool: True in case if password matches, False otherwise bool: ``True`` in case if password matches, ``False`` otherwise
""" """
if password is None: if password is None:
return False # invalid data supplied return False # invalid data supplied
@ -101,7 +101,7 @@ class PAM(Mapping):
username(str): username username(str): username
Returns: Returns:
bool: True in case if user is known and can be authorized and False otherwise bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
""" """
try: try:
_ = getpwnam(username) _ = getpwnam(username)
@ -119,7 +119,7 @@ class PAM(Mapping):
context(str | None): URI request path context(str | None): URI request path
Returns: Returns:
bool: True in case if user is allowed to do this request and False otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
# this method is basically inverted, first we check overrides in database and then fallback to the PAM logic # this method is basically inverted, first we check overrides in database and then fallback to the PAM logic
if (user := self.get_user(username)) is not None: if (user := self.get_user(username)) is not None:

View File

@ -138,7 +138,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository sources_dir(Path): local path to git repository
Returns: Returns:
bool: True in case if there is any remote and false otherwise bool: ``True`` in case if there is any remote and false otherwise
""" """
instance = Sources() instance = Sources()
remotes = check_output(*instance.git(), "remote", cwd=sources_dir, logger=instance.logger) remotes = check_output(*instance.git(), "remote", cwd=sources_dir, logger=instance.logger)
@ -261,7 +261,7 @@ class Sources(LazyLogging):
commit_author(tuple[str, str] | None, optional): optional commit author if any (Default value = None) commit_author(tuple[str, str] | None, optional): optional commit author if any (Default value = None)
Returns: Returns:
bool: True in case if changes have been committed and False otherwise bool: ``True`` in case if changes have been committed and ``False`` otherwise
""" """
if not self.has_changes(sources_dir): if not self.has_changes(sources_dir):
return False # nothing to commit return False # nothing to commit
@ -351,7 +351,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository sources_dir(Path): local path to git repository
Returns: Returns:
bool: True if there are uncommitted changes and False otherwise bool: ``True`` if there are uncommitted changes and ``False`` otherwise
""" """
# there is --exit-code argument to diff, however, there might be other process errors # there is --exit-code argument to diff, however, there might be other process errors
changes = check_output(*self.git(), "diff", "--cached", "--name-only", cwd=sources_dir, logger=self.logger) changes = check_output(*self.git(), "diff", "--cached", "--name-only", cwd=sources_dir, logger=self.logger)

View File

@ -149,7 +149,7 @@ class Validator(RootValidator):
check if paths exists check if paths exists
Args: Args:
constraint(bool): True in case if path must exist and False otherwise constraint(bool): ``True`` in case if path must exist and ``False`` otherwise
field(str): field name to be checked field(str): field name to be checked
value(Path): value to be checked value(Path): value to be checked

View File

@ -0,0 +1,38 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""
create table auditlog (
created integer not null,
repository text not null,
event text not null,
object_id text not null,
message text,
data json
)
""",
"""
create index auditlog_created_repository_event_object_id
on auditlog (created, repository, event, object_id)
""",
]

View File

@ -21,6 +21,7 @@ from ahriman.core.database.operations.auth_operations import AuthOperations
from ahriman.core.database.operations.build_operations import BuildOperations from ahriman.core.database.operations.build_operations import BuildOperations
from ahriman.core.database.operations.changes_operations import ChangesOperations from ahriman.core.database.operations.changes_operations import ChangesOperations
from ahriman.core.database.operations.dependencies_operations import DependenciesOperations from ahriman.core.database.operations.dependencies_operations import DependenciesOperations
from ahriman.core.database.operations.event_operations import EventOperations
from ahriman.core.database.operations.logs_operations import LogsOperations from ahriman.core.database.operations.logs_operations import LogsOperations
from ahriman.core.database.operations.package_operations import PackageOperations from ahriman.core.database.operations.package_operations import PackageOperations
from ahriman.core.database.operations.patch_operations import PatchOperations from ahriman.core.database.operations.patch_operations import PatchOperations

View File

@ -39,7 +39,7 @@ class DependenciesOperations(Operations):
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None) repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
Returns: Returns:
Dependencies: changes for the package base if available Dependencies: dependencies for the package base if available
""" """
repository_id = repository_id or self._repository_id repository_id = repository_id or self._repository_id

View File

@ -0,0 +1,104 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from sqlite3 import Connection
from ahriman.core.database.operations.operations import Operations
from ahriman.models.event import Event, EventType
from ahriman.models.repository_id import RepositoryId
class EventOperations(Operations):
"""
operations for audit log table
"""
def event_get(self, event: str | EventType | None = None, object_id: str | None = None,
limit: int = -1, offset: int = 0, repository_id: RepositoryId | None = None) -> list[Event]:
"""
get list of events with filters applied
Args:
event(str | EventType | None, optional): filter by event type (Default value = None)
object_id(str | None, optional): filter by event object (Default value = None)
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
Returns:
list[Event]: list of audit log events
"""
repository_id = repository_id or self._repository_id
def run(connection: Connection) -> list[Event]:
return [
Event(
event=row["event"],
object_id=row["object_id"],
message=row["message"],
data=row["data"],
created=row["created"],
) for row in connection.execute(
"""
select created, event, object_id, message, data from auditlog
where (:event is null or event = :event)
and (:object_id is null or object_id = :object_id)
and repository = :repository
order by created limit :limit offset :offset
""",
{
"event": event,
"object_id": object_id,
"repository": repository_id.id,
"limit": limit,
"offset": offset,
}
)
]
return self.with_connection(run)
def event_insert(self, event: Event, repository_id: RepositoryId | None = None) -> None:
"""
insert audit log event
Args:
event(Event): event to insert
repository_id(RepositoryId, optional): repository unique identifier override (Default value = None)
"""
repository_id = repository_id or self._repository_id
def run(connection: Connection) -> None:
connection.execute(
"""
insert into auditlog
(created, repository, event, object_id, message, data)
values
(:created, :repository, :event, :object_id, :message, :data)
""",
{
"created": event.created,
"repository": repository_id.id,
"event": event.event,
"object_id": event.object_id,
"message": event.message,
"data": event.data,
})
return self.with_connection(run, commit=True)

View File

@ -74,7 +74,7 @@ class Operations(LazyLogging):
Args: Args:
query(Callable[[Connection], T]): function to be called with connection query(Callable[[Connection], T]): function to be called with connection
commit(bool, optional): if True commit() will be called on success (Default value = False) commit(bool, optional): if ``True`` commit() will be called on success (Default value = False)
Returns: Returns:
T: result of the ``query`` call T: result of the ``query`` call

View File

@ -26,7 +26,7 @@ from typing import Self
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \ from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \
DependenciesOperations, LogsOperations, PackageOperations, PatchOperations DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations
# pylint: disable=too-many-ancestors # pylint: disable=too-many-ancestors
@ -35,6 +35,7 @@ class SQLite(
BuildOperations, BuildOperations,
ChangesOperations, ChangesOperations,
DependenciesOperations, DependenciesOperations,
EventOperations,
LogsOperations, LogsOperations,
PackageOperations, PackageOperations,
PatchOperations): PatchOperations):

View File

@ -32,7 +32,7 @@ class BuildPrinter(StringPrinter):
Args: Args:
package(Package): built package package(Package): built package
is_success(bool): True in case if build has success status and False otherwise is_success(bool): ``True`` in case if build has success status and ``False`` otherwise
use_utf(bool): use utf instead of normal symbols use_utf(bool): use utf instead of normal symbols
""" """
StringPrinter.__init__(self, f"{self.sign(is_success, use_utf)} {package.base}") StringPrinter.__init__(self, f"{self.sign(is_success, use_utf)} {package.base}")
@ -43,7 +43,7 @@ class BuildPrinter(StringPrinter):
generate sign according to settings generate sign according to settings
Args: Args:
is_success(bool): True in case if build has success status and False otherwise is_success(bool): ``True`` in case if build has success status and ``False`` otherwise
use_utf(bool): use utf instead of normal symbols use_utf(bool): use utf instead of normal symbols
Returns: Returns:

View File

@ -57,7 +57,7 @@ class ChangesPrinter(Printer):
generate entry title from content generate entry title from content
Returns: Returns:
str | None: content title if it can be generated and None otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
if self.changes.is_empty: if self.changes.is_empty:
return None return None

View File

@ -63,7 +63,7 @@ class Printer:
generate entry title from content generate entry title from content
Returns: Returns:
str | None: content title if it can be generated and None otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
return None return None

View File

@ -42,6 +42,6 @@ class StringPrinter(Printer):
generate entry title from content generate entry title from content
Returns: Returns:
str | None: content title if it can be generated and None otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
return self.content return self.content

View File

@ -85,7 +85,7 @@ class SyncHttpClient(LazyLogging):
exception(requests.RequestException): exception raised exception(requests.RequestException): exception raised
Returns: Returns:
str: text of the response if it is not None and empty string otherwise str: text of the response if it is not ``None`` and empty string otherwise
""" """
result: str = exception.response.text if exception.response is not None else "" result: str = exception.response.text if exception.response is not None else ""
return result return result

View File

@ -38,8 +38,8 @@ class JinjaTemplate:
* homepage - link to homepage, string, optional * homepage - link to homepage, string, optional
* link_path - prefix fo packages to download, string, required * link_path - prefix fo packages to download, string, required
* has_package_signed - True in case if package sign enabled, False otherwise, required * has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, required
* has_repo_signed - True in case if repository database sign enabled, False otherwise, required * has_repo_signed - ``True`` in case if repository database sign enabled, ``False`` otherwise, required
* packages - sorted list of packages properties, required * packages - sorted list of packages properties, required
* architecture, string * architecture, string
* archive_size, pretty printed size, string * archive_size, pretty printed size, string

View File

@ -78,7 +78,7 @@ class RemoteCall(Report):
process_id(str): remote process id process_id(str): remote process id
Returns: Returns:
bool: True in case if remote process is alive and False otherwise bool: ``True`` in case if remote process is alive and ``False`` otherwise
""" """
try: try:
response = self.client.make_request("GET", f"{self.client.address}/api/v1/service/process/{process_id}") response = self.client.make_request("GET", f"{self.client.address}/api/v1/service/process/{process_id}")

View File

@ -72,7 +72,7 @@ class Spawn(Thread, LazyLogging):
value(bool): command line argument value value(bool): command line argument value
Returns: Returns:
str: if ``value`` is True, then returns positive flag and negative otherwise str: if ``value`` is ``True``, then returns positive flag and negative otherwise
""" """
return name if value else f"no-{name}" return name if value else f"no-{name}"
@ -153,7 +153,7 @@ class Spawn(Thread, LazyLogging):
process_id(str): process id to be checked as returned by :func:`_spawn_process()` process_id(str): process id to be checked as returned by :func:`_spawn_process()`
Returns: Returns:
bool: True in case if process still counts as active and False otherwise bool: ``True`` in case if process still counts as active and ``False`` otherwise
""" """
with self._lock: with self._lock:
return process_id in self.active return process_id in self.active

View File

@ -25,6 +25,7 @@ from ahriman.core.database import SQLite
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -79,6 +80,37 @@ class Client:
return make_local_client() return make_local_client()
def event_add(self, event: Event) -> None:
"""
create new event
Args:
event(Event): audit log event
Raises:
NotImplementedError: not implemented method
"""
raise NotImplementedError
def event_get(self, event: str | EventType | None, object_id: str | None,
limit: int = -1, offset: int = 0) -> list[Event]:
"""
retrieve list of events
Args:
event(str | EventType | None): filter by event type
object_id(str | None): filter by event object
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
Returns:
list[Event]: list of audit log events
Raises:
NotImplementedError: not implemented method
"""
raise NotImplementedError
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -184,7 +216,7 @@ class Client:
Args: Args:
package_base(str): package base package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
Raises: Raises:
NotImplementedError: not implemented method NotImplementedError: not implemented method
@ -213,7 +245,7 @@ class Client:
Args: Args:
package_base(str): package base to update package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed variable(str | None): patch name. If ``None`` is set, all patches will be removed
Raises: Raises:
NotImplementedError: not implemented method NotImplementedError: not implemented method

View File

@ -22,6 +22,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -48,6 +49,31 @@ class LocalClient(Client):
self.database = database self.database = database
self.repository_id = repository_id self.repository_id = repository_id
def event_add(self, event: Event) -> None:
"""
create new event
Args:
event(Event): audit log event
"""
self.database.event_insert(event, self.repository_id)
def event_get(self, event: str | EventType | None, object_id: str | None,
limit: int = -1, offset: int = 0) -> list[Event]:
"""
retrieve list of events
Args:
event(str | EventType | None): filter by event type
object_id(str | None): filter by event object
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
Returns:
list[Event]: list of audit log events
"""
return self.database.event_get(event, object_id, limit, offset, self.repository_id)
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -138,7 +164,7 @@ class LocalClient(Client):
Args: Args:
package_base(str): package base package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
""" """
self.database.logs_remove(package_base, version, self.repository_id) self.database.logs_remove(package_base, version, self.repository_id)
@ -162,7 +188,7 @@ class LocalClient(Client):
Args: Args:
package_base(str): package base to update package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed variable(str | None): patch name. If ``None`` is set, all patches will be removed
""" """
variables = [variable] if variable is not None else None variables = [variable] if variable is not None else None
self.database.patches_remove(package_base, variables) self.database.patches_remove(package_base, variables)

View File

@ -27,6 +27,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -68,6 +69,10 @@ class Watcher(LazyLogging):
with self._lock: with self._lock:
return list(self._known.values()) return list(self._known.values())
event_add: Callable[[Event], None]
event_get: Callable[[str | EventType | None, str | None, int, int], list[Event]]
def load(self) -> None: def load(self) -> None:
""" """
load packages from local database load packages from local database

View File

@ -27,6 +27,7 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -109,6 +110,15 @@ class WebClient(Client, SyncAhrimanClient):
""" """
return f"{self.address}/api/v1/packages/{urlencode(package_base)}/dependencies" return f"{self.address}/api/v1/packages/{urlencode(package_base)}/dependencies"
def _events_url(self) -> str:
"""
get url for the events api
Returns:
str: full url for web service for events
"""
return f"{self.address}/api/v1/events"
def _logs_url(self, package_base: str) -> str: def _logs_url(self, package_base: str) -> str:
""" """
get url for the logs api get url for the logs api
@ -157,6 +167,44 @@ class WebClient(Client, SyncAhrimanClient):
""" """
return f"{self.address}/api/v1/status" return f"{self.address}/api/v1/status"
def event_add(self, event: Event) -> None:
"""
create new event
Args:
event(Event): audit log event
"""
with contextlib.suppress(Exception):
self.make_request("POST", self._events_url(), params=self.repository_id.query(), json=event.view())
def event_get(self, event: str | EventType | None, object_id: str | None,
limit: int = -1, offset: int = 0) -> list[Event]:
"""
retrieve list of events
Args:
event(str | EventType | None): filter by event type
object_id(str | None): filter by event object
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
Returns:
list[Event]: list of audit log events
"""
query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
if event is not None:
query.append(("event", str(event)))
if object_id is not None:
query.append(("object_id", object_id))
with contextlib.suppress(Exception):
response = self.make_request("GET", self._events_url(), params=query)
response_json = response.json()
return [Event.from_json(event) for event in response_json]
return []
def package_changes_get(self, package_base: str) -> Changes: def package_changes_get(self, package_base: str) -> Changes:
""" """
get package changes get package changes
@ -274,8 +322,9 @@ class WebClient(Client, SyncAhrimanClient):
Returns: Returns:
list[tuple[float, str]]: package logs list[tuple[float, str]]: package logs
""" """
with contextlib.suppress(Exception):
query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))] query = self.repository_id.query() + [("limit", str(limit)), ("offset", str(offset))]
with contextlib.suppress(Exception):
response = self.make_request("GET", self._logs_url(package_base), params=query) response = self.make_request("GET", self._logs_url(package_base), params=query)
response_json = response.json() response_json = response.json()
@ -289,12 +338,13 @@ class WebClient(Client, SyncAhrimanClient):
Args: Args:
package_base(str): package base package_base(str): package base
version(str | None): package version to remove logs. If None set, all logs will be removed version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
""" """
with contextlib.suppress(Exception):
query = self.repository_id.query() query = self.repository_id.query()
if version is not None: if version is not None:
query += [("version", version)] query += [("version", version)]
with contextlib.suppress(Exception):
self.make_request("DELETE", self._logs_url(package_base), params=query) self.make_request("DELETE", self._logs_url(package_base), params=query)
def package_patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]: def package_patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]:
@ -323,7 +373,7 @@ class WebClient(Client, SyncAhrimanClient):
Args: Args:
package_base(str): package base to update package_base(str): package base to update
variable(str | None): patch name. If None set, all patches will be removed variable(str | None): patch name. If ``None`` is set, all patches will be removed
""" """
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.make_request("DELETE", self._patches_url(package_base, variable or "")) self.make_request("DELETE", self._patches_url(package_base, variable or ""))
@ -361,6 +411,7 @@ class WebClient(Client, SyncAhrimanClient):
NotImplementedError: not implemented method NotImplementedError: not implemented method
""" """
payload = {"status": status.value} payload = {"status": status.value}
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.make_request("POST", self._package_url(package_base), self.make_request("POST", self._package_url(package_base),
params=self.repository_id.query(), json=payload) params=self.repository_id.query(), json=payload)
@ -380,6 +431,7 @@ class WebClient(Client, SyncAhrimanClient):
"status": status.value, "status": status.value,
"package": package.view(), "package": package.view(),
} }
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.make_request("POST", self._package_url(package.base), self.make_request("POST", self._package_url(package.base),
params=self.repository_id.query(), json=payload) params=self.repository_id.query(), json=payload)
@ -407,5 +459,6 @@ class WebClient(Client, SyncAhrimanClient):
status(BuildStatusEnum): current ahriman status status(BuildStatusEnum): current ahriman status
""" """
payload = {"status": status.value} payload = {"status": status.value}
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.make_request("POST", self._status_url(), params=self.repository_id.query(), json=payload) self.make_request("POST", self._status_url(), params=self.repository_id.query(), json=payload)

View File

@ -64,7 +64,7 @@ class Leaf:
packages(Iterable[Leaf]): list of known leaves packages(Iterable[Leaf]): list of known leaves
Returns: Returns:
bool: True in case if package is dependency of others and False otherwise bool: ``True`` in case if package is dependency of others and ``False`` otherwise
""" """
for leaf in packages: for leaf in packages:
if leaf.dependencies.intersection(self.items): if leaf.dependencies.intersection(self.items):
@ -79,7 +79,7 @@ class Leaf:
packages(Iterable[Leaf]): list of known leaves packages(Iterable[Leaf]): list of known leaves
Returns: Returns:
bool: True if any of packages is dependency of the leaf, False otherwise bool: ``True`` if any of packages is dependency of the leaf, ``False`` otherwise
""" """
for leaf in packages: for leaf in packages:
if self.dependencies.intersection(leaf.items): if self.dependencies.intersection(leaf.items):

View File

@ -160,7 +160,7 @@ class GitHub(Upload, HttpUpload):
get release object if any get release object if any
Returns: Returns:
dict[str, Any] | None: GitHub API release object if release found and None otherwise dict[str, Any] | None: GitHub API release object if release found and ``None`` otherwise
""" """
url = f"https://api.github.com/repos/{self.github_owner}/{ url = f"https://api.github.com/repos/{self.github_owner}/{
self.github_repository}/releases/tags/{self.github_release_tag}" self.github_repository}/releases/tags/{self.github_release_tag}"

View File

@ -225,7 +225,7 @@ def extract_user() -> str | None:
extract user from system environment extract user from system environment
Returns: Returns:
str | None: SUDO_USER in case if set and USER otherwise. It can return None in case if environment has been str | None: SUDO_USER in case if set and USER otherwise. It can return ``None`` in case if environment has been
cleared before application start cleared before application start
""" """
return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER") return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER")
@ -295,7 +295,7 @@ def package_like(filename: Path) -> bool:
filename(Path): name of file to check filename(Path): name of file to check
Returns: Returns:
bool: True in case if name contains ``.pkg.`` and not signature, False otherwise bool: ``True`` in case if name contains ``.pkg.`` and not signature, ``False`` otherwise
""" """
name = filename.name name = filename.name
return not name.startswith(".") and ".pkg." in name and not name.endswith(".sig") return not name.startswith(".") and ".pkg." in name and not name.endswith(".sig")

View File

@ -44,7 +44,7 @@ class AuthSettings(StrEnum):
get enabled flag get enabled flag
Returns: Returns:
bool: False in case if authorization is disabled and True otherwise bool: ``False`` in case if authorization is disabled and ``True`` otherwise
""" """
return self != AuthSettings.Disabled return self != AuthSettings.Disabled

View File

@ -0,0 +1,92 @@
#
# Copyright (c) 2021-2024 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 dataclasses import dataclass, field, fields
from enum import StrEnum
from typing import Any, Self
from ahriman.core.utils import dataclass_view, filter_json, utcnow
class EventType(StrEnum):
"""
predefined event types
Attributes:
PackageOutdated(EventType): (class attribute) package has been marked as out-of-date
PackageRemoved(EventType): (class attribute) package has been removed
PackageUpdateFailed(EventType): (class attribute) package update has been failed
PackageUpdated(EventType): (class attribute) package has been updated
"""
PackageOutdated = "package-outdated"
PackageRemoved = "package-removed"
PackageUpdateFailed = "package-update-failed"
PackageUpdated = "package-updated"
@dataclass(frozen=True)
class Event:
"""
audit log event
Attributes:
created(int): event timestamp
data(dict[str, Any]): event metadata
event(str | EventType): event type
message(str | None): event message if available
object_id(str): object identifier
"""
event: str | EventType
object_id: str
message: str | None = None
data: dict[str, Any] = field(default_factory=dict)
created: int = field(default_factory=lambda: int(utcnow().timestamp()))
def __post_init__(self) -> None:
"""
convert event type to enum if it is a well-known event type
"""
if self.event in EventType:
object.__setattr__(self, "event", EventType(self.event))
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct event from the json dump
Args:
dump(dict[str, Any]): json dump body
Returns:
Self: dependencies object
"""
# filter to only known fields
known_fields = [pair.name for pair in fields(cls)]
return cls(**filter_json(dump, known_fields))
def view(self) -> dict[str, Any]:
"""
generate json event view
Returns:
dict[str, Any]: json-friendly dictionary
"""
return dataclass_view(self)

View File

@ -41,7 +41,7 @@ class MigrationResult:
check migration and check if there are pending migrations check migration and check if there are pending migrations
Returns: Returns:
bool: True in case if it requires migrations and False otherwise bool: ``True`` in case if it requires migrations and ``False`` otherwise
""" """
self.validate() self.validate()
return self.new_version > self.old_version return self.new_version > self.old_version

View File

@ -158,7 +158,7 @@ class Package(LazyLogging):
get VCS flag based on the package base get VCS flag based on the package base
Returns: Returns:
bool: True in case if package base looks like VCS package and False otherwise bool: ``True`` in case if package base looks like VCS package and ``False`` otherwise
""" """
return self.base.endswith("-bzr") \ return self.base.endswith("-bzr") \
or self.base.endswith("-csv")\ or self.base.endswith("-csv")\
@ -504,7 +504,7 @@ class Package(LazyLogging):
timestamp(float | int): timestamp to check build date against timestamp(float | int): timestamp to check build date against
Returns: Returns:
bool: True in case if package was built after the specified date and False otherwise. In case if build date bool: ``True`` in case if package was built after the specified date and ``False`` otherwise. In case if build date
is not set by any of packages, it returns False is not set by any of packages, it returns False
""" """
return any( return any(
@ -528,7 +528,7 @@ class Package(LazyLogging):
(Default value = True) (Default value = True)
Returns: Returns:
bool: True if the package is out-of-dated and False otherwise bool: ``True`` if the package is out-of-dated and ``False`` otherwise
""" """
min_vcs_build_date = utcnow().timestamp() - vcs_allowed_age min_vcs_build_date = utcnow().timestamp() - vcs_allowed_age
if calculate_version and not self.is_newer_than(min_vcs_build_date): if calculate_version and not self.is_newer_than(min_vcs_build_date):

View File

@ -55,7 +55,7 @@ class PkgbuildPatch:
parse key and define whether it function or not parse key and define whether it function or not
Returns: Returns:
bool: True in case if key ends with parentheses and False otherwise bool: ``True`` in case if key ends with parentheses and ``False`` otherwise
""" """
return self.key is not None and self.key.endswith("()") return self.key is not None and self.key.endswith("()")
@ -65,7 +65,7 @@ class PkgbuildPatch:
check if patch is full diff one or just single-variable patch check if patch is full diff one or just single-variable patch
Returns: Returns:
bool: True in case key set and False otherwise bool: ``True`` in case key set and ``False`` otherwise
""" """
return self.key is None return self.key is None

View File

@ -29,7 +29,7 @@ class Property:
Attributes: Attributes:
name(str): name of the property name(str): name of the property
value(Any): property value value(Any): property value
is_required(bool): if set to True then this property is required is_required(bool): if set to ``True`` then this property is required
indent(int): property indentation level indent(int): property indentation level
""" """

View File

@ -57,7 +57,7 @@ class RemoteSource:
check if source is remote check if source is remote
Returns: Returns:
bool: True in case if package is well-known remote source (e.g. AUR) and False otherwise bool: ``True`` in case if package is well-known remote source (e.g. AUR) and ``False`` otherwise
""" """
return self.source in (PackageSource.AUR, PackageSource.Repository) return self.source in (PackageSource.AUR, PackageSource.Repository)

View File

@ -52,7 +52,7 @@ class RepositoryId:
check if all data is supplied for the loading check if all data is supplied for the loading
Returns: Returns:
bool: True in case if architecture or name are not set and False otherwise bool: ``True`` in case if architecture or name are not set and ``False`` otherwise
""" """
return not self.architecture or not self.name return not self.architecture or not self.name
@ -85,7 +85,7 @@ class RepositoryId:
other(Any): other object to compare other(Any): other object to compare
Returns: Returns:
bool: True in case if this is less than other and False otherwise bool: ``True`` in case if this is less than other and ``False`` otherwise
Raises: Raises:
TypeError: if other is different from RepositoryId type TypeError: if other is different from RepositoryId type

View File

@ -82,7 +82,7 @@ class Result:
get if build result is empty or not get if build result is empty or not
Returns: Returns:
bool: True in case if success list is empty and False otherwise bool: ``True`` in case if success list is empty and ``False`` otherwise
""" """
return not self._added and not self._updated return not self._added and not self._updated
@ -191,7 +191,7 @@ class Result:
other(Any): other object instance other(Any): other object instance
Returns: Returns:
bool: True if the other object is the same and False otherwise bool: ``True`` if the other object is the same and ``False`` otherwise
""" """
if not isinstance(other, Result): if not isinstance(other, Result):
return False return False

View File

@ -98,7 +98,7 @@ class User:
salt(str): salt for hashed password salt(str): salt for hashed password
Returns: Returns:
bool: True in case if password matches, False otherwise bool: ``True`` in case if password matches, ``False`` otherwise
""" """
try: try:
verified: bool = self._HASHER.verify(password + salt, self.password) verified: bool = self._HASHER.verify(password + salt, self.password)
@ -131,7 +131,7 @@ class User:
required(UserAccess): required access level required(UserAccess): required access level
Returns: Returns:
bool: True in case if user is allowed to do this request and False otherwise bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
""" """
return self.access.permits(required) return self.access.permits(required)

View File

@ -47,7 +47,7 @@ class UserAccess(StrEnum):
other(UserAccess): other permission to compare other(UserAccess): other permission to compare
Returns: Returns:
bool: True in case if current permission allows the operation and False otherwise bool: ``True`` in case if current permission allows the operation and ``False`` otherwise
""" """
for member in UserAccess: for member in UserAccess:
if member == other: if member == other:

View File

@ -63,7 +63,7 @@ class _AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy):
identity(str): username identity(str): username
Returns: Returns:
str | None: user identity (username) in case if user exists and None otherwise str | None: user identity (username) in case if user exists and ``None`` otherwise
""" """
return identity if await self.validator.known_username(identity) else None return identity if await self.validator.known_username(identity) else None
@ -77,7 +77,7 @@ class _AuthorizationPolicy(aiohttp_security.AbstractAuthorizationPolicy):
context(str | None, optional): URI request path (Default value = None) context(str | None, optional): URI request path (Default value = None)
Returns: Returns:
bool: True in case if user is allowed to perform this request and False otherwise bool: ``True`` in case if user is allowed to perform this request and ``False`` otherwise
""" """
# some methods for type checking and parent class compatibility # some methods for type checking and parent class compatibility
if identity is None or not isinstance(permission, UserAccess): if identity is None or not isinstance(permission, UserAccess):

View File

@ -38,7 +38,7 @@ def _is_templated_unauthorized(request: Request) -> bool:
request(Request): source request to check request(Request): source request to check
Returns: Returns:
bool: True in case if response should be rendered as html and False otherwise bool: ``True`` in case if response should be rendered as html and ``False`` otherwise
""" """
return request.path in ("/api/v1/login", "/api/v1/logout") \ return request.path in ("/api/v1/login", "/api/v1/logout") \
and "application/json" not in request.headers.getall("accept", []) and "application/json" not in request.headers.getall("accept", [])

View File

@ -24,6 +24,8 @@ from ahriman.web.schemas.changes_schema import ChangesSchema
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
from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema from ahriman.web.schemas.info_schema import InfoSchema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema

View File

@ -0,0 +1,47 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import Schema, fields
from ahriman.models.event import EventType
class EventSchema(Schema):
"""
request/response event schema
"""
created = fields.Integer(required=True, metadata={
"description": "Event creation timestamp",
"example": 1680537091,
})
event = fields.String(required=True, metadata={
"description": "Event type",
"example": EventType.PackageUpdated,
})
object_id = fields.String(required=True, metadata={
"description": "Event object identifier",
"example": "ahriman",
})
message = fields.String(metadata={
"description": "Event message if available",
})
data = fields.Dict(keys=fields.String(), metadata={
"description": "Event metadata if available",
})

View File

@ -0,0 +1,38 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman.models.event import EventType
from ahriman.web.schemas.pagination_schema import PaginationSchema
class EventSearchSchema(PaginationSchema):
"""
request event search schema
"""
event = fields.String(metadata={
"description": "Event type",
"example": EventType.PackageUpdated,
})
object_id = fields.String(metadata={
"description": "Event object identifier",
"example": "ahriman",
})

View File

@ -247,7 +247,7 @@ class BaseView(View, CorsViewMixin):
extract username from request if any extract username from request if any
Returns: Returns:
str | None: authorized username if any and None otherwise (e.g. if authorization is disabled) str | None: authorized username if any and ``None`` otherwise (e.g. if authorization is disabled)
""" """
try: # try to read from payload try: # try to read from payload
data: dict[str, str] = await self.request.json() # technically it is not, but we only need str here data: dict[str, str] = await self.request.json() # technically it is not, but we only need str here

View File

@ -0,0 +1,19 @@
#
# Copyright (c) 2021-2024 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,104 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman.models.event import Event
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, EventSchema, EventSearchSchema
from ahriman.web.views.base import BaseView
class EventsView(BaseView):
"""
audit log 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
ROUTES = ["/api/v1/events"]
@aiohttp_apispec.docs(
tags=["Audit log"],
summary="Get events",
description="Retrieve events from audit log",
responses={
200: {"description": "Success response", "schema": EventSchema(many=True)},
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.querystring_schema(EventSearchSchema)
async def get(self) -> Response:
"""
get events list
Returns:
Response: 200 with workers list on success
"""
limit, offset = self.page()
event = self.request.query.get("event") or None
object_id = self.request.query.get("object_id") or None
events = self.service().event_get(event, object_id, limit, offset)
response = [event.view() for event in events]
return json_response(response)
@aiohttp_apispec.docs(
tags=["Audit log"],
summary="Create event",
description="Add new event to the audit log",
responses={
204: {"description": "Success response"},
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [POST_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.json_schema(EventSchema)
async def post(self) -> None:
"""
add new audit log event
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
"""
try:
data = await self.request.json()
event = Event.from_json(data)
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
self.service().event_add(event)
raise HTTPNoContent

View File

@ -0,0 +1,8 @@
from ahriman.core.database.migrations.m014_auditlog import steps
def test_migration_auditlog() -> None:
"""
migration must not be empty
"""
assert steps

View File

@ -0,0 +1,40 @@
from ahriman.core.database import SQLite
from ahriman.models.event import Event, EventType
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
def test_event_insert_get(database: SQLite, package_ahriman: Package) -> None:
"""
must insert and get event
"""
event = Event(EventType.PackageUpdated, package_ahriman.base, "Updated", {"key": "value"})
database.event_insert(event)
assert database.event_get() == [event]
event2 = Event("event", "object")
database.event_insert(event2, RepositoryId("i686", database._repository_id.name))
assert database.event_get() == [event]
assert database.event_get(repository_id=RepositoryId("i686", database._repository_id.name)) == [event2]
def test_event_insert_get_filter(database: SQLite) -> None:
"""
must insert and get events with filter
"""
database.event_insert(Event("event 1", "object 1", created=1))
database.event_insert(Event("event 2", "object 2"))
database.event_insert(Event(EventType.PackageUpdated, "package"))
assert database.event_get(event="event 1") == [Event("event 1", "object 1", created=1)]
assert database.event_get(object_id="object 1") == [Event("event 1", "object 1", created=1)]
assert all(event.event == EventType.PackageUpdated for event in database.event_get(event=EventType.PackageUpdated))
def test_event_insert_get_pagination(database: SQLite) -> None:
"""
must insert and get events with pagination
"""
database.event_insert(Event("1", "1"))
database.event_insert(Event("2", "2"))
assert all(event.event == "2" for event in database.event_get(limit=1, offset=1))

View File

@ -11,6 +11,7 @@ from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -94,6 +95,22 @@ 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_event_add(client: Client) -> None:
"""
must raise not implemented on event insertion
"""
with pytest.raises(NotImplementedError):
client.event_add(Event("", ""))
def test_event_get(client: Client) -> None:
"""
must raise not implemented on events request
"""
with pytest.raises(NotImplementedError):
client.event_get(None, None)
def test_package_changes_get(client: Client, package_ahriman: Package) -> None: def test_package_changes_get(client: Client, package_ahriman: Package) -> None:
""" """
must raise not implemented on package changes request must raise not implemented on package changes request

View File

@ -7,11 +7,32 @@ from ahriman.core.status.local_client import LocalClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
def test_event_add(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must add new event
"""
event_mock = mocker.patch("ahriman.core.database.SQLite.event_insert")
event = Event(EventType.PackageUpdated, package_ahriman.base)
local_client.event_add(event)
event_mock.assert_called_once_with(event, local_client.repository_id)
def test_event_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must retrieve events
"""
event_mock = mocker.patch("ahriman.core.database.SQLite.event_get")
local_client.event_get(EventType.PackageUpdated, package_ahriman.base, 1, 2)
event_mock.assert_called_once_with(EventType.PackageUpdated, package_ahriman.base, 1, 2, local_client.repository_id)
def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_changes_get(local_client: LocalClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must retrieve package changes must retrieve package changes

View File

@ -10,6 +10,7 @@ from ahriman.core.status.web_client import WebClient
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.internal_status import InternalStatus from ahriman.models.internal_status import InternalStatus
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
@ -54,18 +55,12 @@ def test_dependencies_url(web_client: WebClient, package_ahriman: Package) -> No
"/api/v1/packages/some%2Fpackage%25name/dependencies") "/api/v1/packages/some%2Fpackage%25name/dependencies")
def test__patches_url(web_client: WebClient, package_ahriman: Package) -> None: def test_event_url(web_client: WebClient) -> None:
""" """
must generate changes url correctly must generate audit log url correctly
""" """
assert web_client._patches_url(package_ahriman.base).startswith(web_client.address) assert web_client._events_url().startswith(web_client.address)
assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches") assert web_client._events_url().endswith("/api/v1/events")
assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches")
assert web_client._patches_url(package_ahriman.base, "var").endswith(
f"/api/v1/packages/{package_ahriman.base}/patches/var")
assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith(
f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name")
def test_logs_url(web_client: WebClient, package_ahriman: Package) -> None: def test_logs_url(web_client: WebClient, package_ahriman: Package) -> None:
@ -89,6 +84,20 @@ def test_package_url(web_client: WebClient, package_ahriman: Package) -> None:
assert web_client._package_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name") assert web_client._package_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name")
def test_patches_url(web_client: WebClient, package_ahriman: Package) -> None:
"""
must generate changes url correctly
"""
assert web_client._patches_url(package_ahriman.base).startswith(web_client.address)
assert web_client._patches_url(package_ahriman.base).endswith(f"/api/v1/packages/{package_ahriman.base}/patches")
assert web_client._patches_url("some/package%name").endswith("/api/v1/packages/some%2Fpackage%25name/patches")
assert web_client._patches_url(package_ahriman.base, "var").endswith(
f"/api/v1/packages/{package_ahriman.base}/patches/var")
assert web_client._patches_url(package_ahriman.base, "some/variable%name").endswith(
f"/api/v1/packages/{package_ahriman.base}/patches/some%2Fvariable%25name")
def test_status_url(web_client: WebClient) -> None: def test_status_url(web_client: WebClient) -> None:
""" """
must generate package status url correctly must generate package status url correctly
@ -97,6 +106,135 @@ 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_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must create event
"""
event = Event(EventType.PackageUpdated, package_ahriman.base)
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request")
web_client.event_add(event)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query(), json=event.view())
def test_event_add_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during events creation
"""
mocker.patch("requests.Session.request", side_effect=Exception())
web_client.event_add(Event("", ""))
def test_event_add_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during events creation
"""
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
web_client.event_add(Event("", ""))
def test_event_add_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during events creaton 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.event_add(Event("", ""))
logging_mock.assert_not_called()
def test_event_add_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during events creation 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.event_add(Event("", ""))
logging_mock.assert_not_called()
def test_event_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must get events
"""
event = Event(EventType.PackageUpdated, package_ahriman.base)
response_obj = requests.Response()
response_obj._content = json.dumps([event.view()]).encode("utf8")
response_obj.status_code = 200
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj)
result = web_client.event_get(None, None)
requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query() + [("limit", "-1"), ("offset", "0")])
assert result == [event]
def test_event_get_filter(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must get events with filter
"""
response_obj = requests.Response()
response_obj._content = json.dumps(Event("", "").view()).encode("utf8")
response_obj.status_code = 200
requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request", return_value=response_obj)
web_client.event_get("event", "object", 1, 2)
requests_mock.assert_called_once_with("GET", pytest.helpers.anyvar(str, True),
params=web_client.repository_id.query() + [
("limit", "1"),
("offset", "2"),
("event", "event"),
("object_id", "object"),
])
def test_event_get_failed(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during events fetch
"""
mocker.patch("requests.Session.request", side_effect=Exception())
web_client.event_get(None, None)
def test_event_get_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during events fetch
"""
mocker.patch("requests.Session.request", side_effect=requests.HTTPError())
web_client.event_get(None, None)
def test_event_get_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress any exception happened during events fetch 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.event_get(None, None)
logging_mock.assert_not_called()
def test_event_get_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None:
"""
must suppress HTTP exception happened during events fetch 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.event_get(None, None)
logging_mock.assert_not_called()
def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: def test_package_changes_get(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
""" """
must get changes must get changes

View File

@ -0,0 +1,17 @@
from ahriman.models.event import Event, EventType
def test_post_init() -> None:
"""
must replace event type for known types
"""
assert Event("random", "")
assert isinstance(Event(str(EventType.PackageUpdated), "").event, EventType)
def test_from_json_view() -> None:
"""
must construct and serialize event to json
"""
event = Event("event", "object", "message", {"key": "value"})
assert Event.from_json(event.view()) == event

View File

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

View File

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

View File

@ -0,0 +1,104 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.event import Event
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.auditlog.events import EventsView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET", "POST"):
request = pytest.helpers.request("", "", method)
assert await EventsView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert EventsView.ROUTES == ["/api/v1/events"]
async def test_get(client: TestClient) -> None:
"""
must return all events
"""
event1 = Event("event1", "object1", "message", {"key": "value"})
event2 = Event("event2", "object2")
await client.post("/api/v1/events", json=event1.view())
await client.post("/api/v1/events", json=event2.view())
response_schema = pytest.helpers.schema_response(EventsView.get)
response = await client.get("/api/v1/events")
assert response.ok
json = await response.json()
assert not response_schema.validate(json, many=True)
events = [Event.from_json(event) for event in json]
assert events == [event1, event2]
async def test_get_with_pagination(client: TestClient) -> None:
"""
must get events with pagination
"""
event1 = Event("event1", "object1", "message", {"key": "value"})
event2 = Event("event2", "object2")
await client.post("/api/v1/events", json=event1.view())
await client.post("/api/v1/events", json=event2.view())
request_schema = pytest.helpers.schema_request(EventsView.get, location="querystring")
response_schema = pytest.helpers.schema_response(EventsView.get)
payload = {"limit": 1, "offset": 1}
assert not request_schema.validate(payload)
response = await client.get("/api/v1/events", params=payload)
assert response.status == 200
json = await response.json()
assert not response_schema.validate(json, many=True)
assert [Event.from_json(event) for event in json] == [event2]
async def test_get_bad_request(client: TestClient) -> None:
"""
must return bad request for invalid query parameters
"""
response_schema = pytest.helpers.schema_response(EventsView.get, code=400)
response = await client.get("/api/v1/events", params={"limit": "limit"})
assert response.status == 400
assert not response_schema.validate(await response.json())
response = await client.get("/api/v1/events", params={"offset": "offset"})
assert response.status == 400
assert not response_schema.validate(await response.json())
async def test_post(client: TestClient) -> None:
"""
must create event
"""
event = Event("event1", "object1", "message", {"key": "value"})
request_schema = pytest.helpers.schema_request(EventsView.post)
payload = event.view()
assert not request_schema.validate(payload)
response = await client.post("/api/v1/events", json=payload)
assert response.status == 204
async def test_post_exception(client: TestClient) -> None:
"""
must raise exception on invalid payload
"""
response_schema = pytest.helpers.schema_response(EventsView.post, code=400)
response = await client.post("/api/v1/events", json={})
assert response.status == 400
assert not response_schema.validate(await response.json())

View File

@ -11,6 +11,7 @@ flags = --implicit-reexport --strict --allow-untyped-decorators --allow-subclass
[pytest] [pytest]
addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
asyncio_default_fixture_loop_scope = function
asyncio_mode = auto asyncio_mode = auto
spec_test_format = {result} {docstring_summary} spec_test_format = {result} {docstring_summary}