mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-10 04:25:47 +00:00
Compare commits
10 Commits
2.18.0
...
027a3a8fb6
Author | SHA1 | Date | |
---|---|---|---|
027a3a8fb6 | |||
f41b69f42a | |||
930eccc55a | |||
32f99f7f36 | |||
e5d824b03f | |||
8d0d597473 | |||
995b396360 | |||
7f813cf0c3 | |||
d4eb55ef95 | |||
09350e88ab |
5
.github/workflows/regress.yml
vendored
5
.github/workflows/regress.yml
vendored
@ -1,6 +1,9 @@
|
|||||||
name: Regress
|
name: Regress
|
||||||
|
|
||||||
on: workflow_dispatch
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: 1 0 * * 0
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
1
.github/workflows/setup.yml
vendored
1
.github/workflows/setup.yml
vendored
@ -7,6 +7,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
1
.github/workflows/tests.yml
vendored
1
.github/workflows/tests.yml
vendored
@ -9,6 +9,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
schedule:
|
schedule:
|
||||||
- cron: 1 0 * * *
|
- cron: 1 0 * * *
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
@ -40,6 +40,7 @@ RUN pacman -S --noconfirm --asdeps \
|
|||||||
pacman -S --noconfirm --asdeps \
|
pacman -S --noconfirm --asdeps \
|
||||||
git \
|
git \
|
||||||
python-aiohttp \
|
python-aiohttp \
|
||||||
|
python-aiohttp-openmetrics \
|
||||||
python-boto3 \
|
python-boto3 \
|
||||||
python-cerberus \
|
python-cerberus \
|
||||||
python-cryptography \
|
python-cryptography \
|
||||||
@ -112,6 +113,7 @@ RUN pacman -S --noconfirm ahriman
|
|||||||
RUN pacman -S --noconfirm --asdeps \
|
RUN pacman -S --noconfirm --asdeps \
|
||||||
python-aioauth-client \
|
python-aioauth-client \
|
||||||
python-aiohttp-apispec-git \
|
python-aiohttp-apispec-git \
|
||||||
|
python-aiohttp-openmetrics \
|
||||||
python-aiohttp-security \
|
python-aiohttp-security \
|
||||||
python-aiohttp-session \
|
python-aiohttp-session \
|
||||||
python-boto3 \
|
python-boto3 \
|
||||||
|
@ -20,6 +20,14 @@ ahriman.web.middlewares.exception\_handler module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.middlewares.metrics\_handler module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.middlewares.metrics_handler
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
@ -4,6 +4,14 @@ ahriman.web.schemas package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
ahriman.web.schemas.any\_schema module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.schemas.any_schema
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.schemas.aur\_package\_schema module
|
ahriman.web.schemas.aur\_package\_schema module
|
||||||
-----------------------------------------------
|
-----------------------------------------------
|
||||||
|
|
||||||
|
@ -12,6 +12,14 @@ ahriman.web.views.v1.status.info module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
ahriman.web.views.v1.status.metrics module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: ahriman.web.views.v1.status.metrics
|
||||||
|
:members:
|
||||||
|
:no-undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.web.views.v1.status.repositories module
|
ahriman.web.views.v1.status.repositories module
|
||||||
-----------------------------------------------
|
-----------------------------------------------
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
pkgbase='ahriman'
|
pkgbase='ahriman'
|
||||||
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
|
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
|
||||||
pkgver=2.18.0
|
pkgver=2.18.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="ArcH linux ReposItory MANager"
|
pkgdesc="ArcH linux ReposItory MANager"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
@ -75,6 +75,7 @@ package_ahriman-web() {
|
|||||||
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2')
|
depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2')
|
||||||
optdepends=('python-aioauth-client: OAuth2 authorization support'
|
optdepends=('python-aioauth-client: OAuth2 authorization support'
|
||||||
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
|
'python-aiohttp-apispec>=3.0.0: autogenerated API documentation'
|
||||||
|
'python-aiohttp-openmetrics: HTTP metrics support'
|
||||||
'python-aiohttp-security: authorization support'
|
'python-aiohttp-security: authorization support'
|
||||||
'python-aiohttp-session: authorization support'
|
'python-aiohttp-session: authorization support'
|
||||||
'python-cryptography: authorization support')
|
'python-cryptography: authorization support')
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
.TH AHRIMAN "1" "2025\-06\-13" "ahriman" "Generated Python Manual"
|
.TH AHRIMAN "1" "2025\-06\-16" "ahriman" "Generated Python Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
ahriman
|
ahriman
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
@ -69,6 +69,10 @@ web_auth = [
|
|||||||
"aiohttp_security",
|
"aiohttp_security",
|
||||||
"cryptography",
|
"cryptography",
|
||||||
]
|
]
|
||||||
|
web_metrics = [
|
||||||
|
"ahriman[web]",
|
||||||
|
"aiohttp-openmetrics",
|
||||||
|
]
|
||||||
web_oauth2 = [
|
web_oauth2 = [
|
||||||
"ahriman[web_auth]",
|
"ahriman[web_auth]",
|
||||||
"aioauth-client",
|
"aioauth-client",
|
||||||
|
@ -17,4 +17,4 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "2.18.0"
|
__version__ = "2.18.2"
|
||||||
|
@ -35,7 +35,7 @@ class Remote(SyncHttpClient):
|
|||||||
>>> package = AUR.info("ahriman")
|
>>> package = AUR.info("ahriman")
|
||||||
>>> search_result = Official.multisearch("pacman", "manager", pacman=pacman)
|
>>> search_result = Official.multisearch("pacman", "manager", pacman=pacman)
|
||||||
|
|
||||||
Differnece between :func:`search()` and :func:`multisearch()` is that :func:`search()` passes all arguments to
|
Difference between :func:`search()` and :func:`multisearch()` is that :func:`search()` passes all arguments to
|
||||||
underlying wrapper directly, whereas :func:`multisearch()` splits search one by one and finds intersection
|
underlying wrapper directly, whereas :func:`multisearch()` splits search one by one and finds intersection
|
||||||
between search results.
|
between search results.
|
||||||
"""
|
"""
|
||||||
|
@ -153,10 +153,13 @@ class LogsOperations(Operations):
|
|||||||
"""
|
"""
|
||||||
delete from logs
|
delete from logs
|
||||||
where (package_base, repository, process_id) in (
|
where (package_base, repository, process_id) in (
|
||||||
select package_base, repository, process_id from logs
|
select package_base, repository, process_id from (
|
||||||
where repository = :repository
|
select package_base, repository, process_id, row_number() over (partition by package_base order by max(created) desc) as rn
|
||||||
group by package_base, repository, process_id
|
from logs
|
||||||
order by min(created) desc limit -1 offset :offset
|
where repository = :repository
|
||||||
|
group by package_base, repository, process_id
|
||||||
|
)
|
||||||
|
where rn > :offset
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import contextlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@ -87,10 +88,12 @@ class Operations(LazyLogging):
|
|||||||
Returns:
|
Returns:
|
||||||
T: result of the ``query`` call
|
T: result of the ``query`` call
|
||||||
"""
|
"""
|
||||||
with sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES) as connection:
|
with contextlib.closing(sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES)) as connection:
|
||||||
connection.set_trace_callback(self.logger.debug)
|
connection.set_trace_callback(self.logger.debug)
|
||||||
connection.row_factory = self.factory
|
connection.row_factory = self.factory
|
||||||
|
|
||||||
result = query(connection)
|
result = query(connection)
|
||||||
if commit:
|
if commit:
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -40,7 +40,7 @@ class JinjaTemplate:
|
|||||||
|
|
||||||
* homepage - link to homepage, string, optional
|
* homepage - link to homepage, string, optional
|
||||||
* last_update - report generation time, pretty printed datetime, required
|
* last_update - report generation time, pretty printed datetime, required
|
||||||
* link_path - prefix fo packages to download, string, required
|
* link_path - prefix of 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
|
||||||
@ -64,7 +64,7 @@ class JinjaTemplate:
|
|||||||
Attributes:
|
Attributes:
|
||||||
default_pgp_key(str | None): default PGP key
|
default_pgp_key(str | None): default PGP key
|
||||||
homepage(str | None): homepage link if any (for footer)
|
homepage(str | None): homepage link if any (for footer)
|
||||||
link_path(str): prefix fo packages to download
|
link_path(str): prefix of packages to download
|
||||||
name(str): repository name
|
name(str): repository name
|
||||||
rss_url(str | None): link to the RSS feed
|
rss_url(str | None): link to the RSS feed
|
||||||
sign_targets(set[SignSettings]): targets to sign enabled in configuration
|
sign_targets(set[SignSettings]): targets to sign enabled in configuration
|
||||||
|
@ -71,7 +71,7 @@ class EventLogger:
|
|||||||
>>> with self.in_event(package_base, EventType.PackageUpdated):
|
>>> with self.in_event(package_base, EventType.PackageUpdated):
|
||||||
>>> do_something()
|
>>> do_something()
|
||||||
|
|
||||||
Additional parameter ``failure`` can be set in order to emit an event on exception occured. If none set
|
Additional parameter ``failure`` can be set in order to emit an event on exception occurred. If none set
|
||||||
(default), then no event will be recorded on exception
|
(default), then no event will be recorded on exception
|
||||||
"""
|
"""
|
||||||
with MetricsTimer() as timer:
|
with MetricsTimer() as timer:
|
||||||
|
@ -69,7 +69,7 @@ class Package(LazyLogging):
|
|||||||
:attr:`ahriman.models.package_source.PackageSource.Archive`,
|
:attr:`ahriman.models.package_source.PackageSource.Archive`,
|
||||||
:attr:`ahriman.models.package_source.PackageSource.AUR`,
|
:attr:`ahriman.models.package_source.PackageSource.AUR`,
|
||||||
:attr:`ahriman.models.package_source.PackageSource.Local` and
|
:attr:`ahriman.models.package_source.PackageSource.Local` and
|
||||||
:attr:`ahriman.models.package_source.PackageSource.Repository` repsectively:
|
:attr:`ahriman.models.package_source.PackageSource.Repository` respectively:
|
||||||
|
|
||||||
>>> ahriman_package = Package.from_aur("ahriman")
|
>>> ahriman_package = Package.from_aur("ahriman")
|
||||||
>>> pacman_package = Package.from_official("pacman", pacman)
|
>>> pacman_package = Package.from_official("pacman", pacman)
|
||||||
|
69
src/ahriman/web/middlewares/metrics_handler.py
Normal file
69
src/ahriman/web/middlewares/metrics_handler.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
#
|
||||||
|
# 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/>.
|
||||||
|
#
|
||||||
|
try:
|
||||||
|
import aiohttp_openmetrics
|
||||||
|
except ImportError:
|
||||||
|
aiohttp_openmetrics = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from aiohttp.typedefs import Middleware
|
||||||
|
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware
|
||||||
|
|
||||||
|
from ahriman.web.middlewares import HandlerType
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"metrics",
|
||||||
|
"metrics_handler",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def metrics(request: Request) -> Response:
|
||||||
|
"""
|
||||||
|
handler for returning metrics
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request(Request): request object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: response object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPNotFound: endpoint is disabled
|
||||||
|
"""
|
||||||
|
if aiohttp_openmetrics is None:
|
||||||
|
raise HTTPNotFound
|
||||||
|
return await aiohttp_openmetrics.metrics(request)
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_handler() -> Middleware:
|
||||||
|
"""
|
||||||
|
middleware for metrics support
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Middleware: middleware function to handle server metrics
|
||||||
|
"""
|
||||||
|
if aiohttp_openmetrics is not None:
|
||||||
|
return aiohttp_openmetrics.metrics_middleware
|
||||||
|
|
||||||
|
@middleware
|
||||||
|
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
return handle
|
@ -17,6 +17,8 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import re
|
||||||
|
|
||||||
from aiohttp.web import Application, View
|
from aiohttp.web import Application, View
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
|
||||||
@ -45,6 +47,23 @@ def _dynamic_routes(configuration: Configuration) -> Generator[tuple[str, type[V
|
|||||||
yield route, view
|
yield route, view
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier(route: str) -> str:
|
||||||
|
"""
|
||||||
|
extract valid route identifier (aka name) for the route. This method replaces curly brackets by single colon
|
||||||
|
and replaces other special symbols (including slashes) by underscore
|
||||||
|
|
||||||
|
Args:
|
||||||
|
route(str): source route
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: route with special symbols being replaced
|
||||||
|
"""
|
||||||
|
# replace special symbols
|
||||||
|
alphanum = re.sub(r"[^A-Za-z\d\-{}]", "_", route)
|
||||||
|
# finally replace curly brackets
|
||||||
|
return alphanum.replace("{", ":").replace("}", "")
|
||||||
|
|
||||||
|
|
||||||
def setup_routes(application: Application, configuration: Configuration) -> None:
|
def setup_routes(application: Application, configuration: Configuration) -> None:
|
||||||
"""
|
"""
|
||||||
setup all defined routes
|
setup all defined routes
|
||||||
@ -53,7 +72,8 @@ def setup_routes(application: Application, configuration: Configuration) -> None
|
|||||||
application(Application): web application instance
|
application(Application): web application instance
|
||||||
configuration(Configuration): configuration instance
|
configuration(Configuration): configuration instance
|
||||||
"""
|
"""
|
||||||
application.router.add_static("/static", configuration.getpath("web", "static_path"), follow_symlinks=True)
|
application.router.add_static("/static", configuration.getpath("web", "static_path"), name="_static",
|
||||||
|
follow_symlinks=True)
|
||||||
|
|
||||||
for route, view in _dynamic_routes(configuration):
|
for route, view in _dynamic_routes(configuration):
|
||||||
application.router.add_view(route, view)
|
application.router.add_view(route, view, name=_identifier(route))
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
from ahriman.web.schemas.any_schema import AnySchema
|
||||||
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
|
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
|
||||||
from ahriman.web.schemas.auth_schema import AuthSchema
|
from ahriman.web.schemas.auth_schema import AuthSchema
|
||||||
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
||||||
|
26
src/ahriman/web/schemas/any_schema.py
Normal file
26
src/ahriman/web/schemas/any_schema.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2021-2025 ahriman team.
|
||||||
|
#
|
||||||
|
# This file is part of ahriman
|
||||||
|
# (see https://github.com/arcan1s/ahriman).
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from ahriman.web.apispec import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class AnySchema(Schema):
|
||||||
|
"""
|
||||||
|
response dummy schema
|
||||||
|
"""
|
@ -39,7 +39,7 @@ class RemoteSchema(Schema):
|
|||||||
"example": ".",
|
"example": ".",
|
||||||
})
|
})
|
||||||
source = fields.Enum(PackageSource, by_value=True, required=True, metadata={
|
source = fields.Enum(PackageSource, by_value=True, required=True, metadata={
|
||||||
"description": "Pacakge source",
|
"description": "Package source",
|
||||||
})
|
})
|
||||||
web_url = fields.String(metadata={
|
web_url = fields.String(metadata={
|
||||||
"description": "Package repository page",
|
"description": "Package repository page",
|
||||||
|
@ -167,6 +167,9 @@ class BaseView(View, CorsViewMixin):
|
|||||||
"""
|
"""
|
||||||
HEAD method implementation based on the result of GET method
|
HEAD method implementation based on the result of GET method
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamResponse: generated response for the request
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPMethodNotAllowed: in case if there is no GET method implemented
|
HTTPMethodNotAllowed: in case if there is no GET method implemented
|
||||||
"""
|
"""
|
||||||
|
@ -106,7 +106,7 @@ class PackageView(StatusViewGuard, BaseView):
|
|||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
summary="Update package",
|
summary="Update package",
|
||||||
description="Update package status and set its descriptior optionally",
|
description="Update package status and set its descriptor optionally",
|
||||||
permission=POST_PERMISSION,
|
permission=POST_PERMISSION,
|
||||||
error_400_enabled=True,
|
error_400_enabled=True,
|
||||||
error_404_description="Repository is unknown",
|
error_404_description="Repository is unknown",
|
||||||
|
@ -46,7 +46,7 @@ class PackagesView(StatusViewGuard, BaseView):
|
|||||||
ROUTES = ["/api/v1/packages"]
|
ROUTES = ["/api/v1/packages"]
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["packages"],
|
tags=["Packages"],
|
||||||
summary="Get packages list",
|
summary="Get packages list",
|
||||||
description="Retrieve packages and their descriptors",
|
description="Retrieve packages and their descriptors",
|
||||||
permission=GET_PERMISSION,
|
permission=GET_PERMISSION,
|
||||||
|
56
src/ahriman/web/views/v1/status/metrics.py
Normal file
56
src/ahriman/web/views/v1/status/metrics.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2021-2025 ahriman team.
|
||||||
|
#
|
||||||
|
# This file is part of ahriman
|
||||||
|
# (see https://github.com/arcan1s/ahriman).
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from aiohttp.web import Response
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
from ahriman.web.apispec.decorators import apidocs
|
||||||
|
from ahriman.web.middlewares.metrics_handler import metrics
|
||||||
|
from ahriman.web.schemas import AnySchema
|
||||||
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsView(BaseView):
|
||||||
|
"""
|
||||||
|
open metrics endpoints
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
||||||
|
"""
|
||||||
|
|
||||||
|
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
|
||||||
|
ROUTES = ["/api/v1/metrics"]
|
||||||
|
|
||||||
|
@apidocs(
|
||||||
|
tags=["Status"],
|
||||||
|
summary="OpenMetrics endpoint",
|
||||||
|
description="Get service metrics in OpenMetrics format",
|
||||||
|
permission=GET_PERMISSION,
|
||||||
|
error_404_description="Endpoint is disabled",
|
||||||
|
schema=AnySchema,
|
||||||
|
)
|
||||||
|
async def get(self) -> Response:
|
||||||
|
"""
|
||||||
|
get service HTTP metrics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: 200 with service metrics as generated by the library
|
||||||
|
"""
|
||||||
|
return await metrics(self.request)
|
@ -37,6 +37,7 @@ from ahriman.web.apispec.info import setup_apispec
|
|||||||
from ahriman.web.cors import setup_cors
|
from ahriman.web.cors import setup_cors
|
||||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
||||||
from ahriman.web.middlewares.exception_handler import exception_handler
|
from ahriman.web.middlewares.exception_handler import exception_handler
|
||||||
|
from ahriman.web.middlewares.metrics_handler import metrics_handler
|
||||||
from ahriman.web.routes import setup_routes
|
from ahriman.web.routes import setup_routes
|
||||||
|
|
||||||
|
|
||||||
@ -146,6 +147,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
|
|||||||
|
|
||||||
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
|
application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True))
|
||||||
application.middlewares.append(exception_handler(application.logger))
|
application.middlewares.append(exception_handler(application.logger))
|
||||||
|
application.middlewares.append(metrics_handler())
|
||||||
|
|
||||||
application.logger.info("setup routes")
|
application.logger.info("setup routes")
|
||||||
setup_routes(application, configuration)
|
setup_routes(application, configuration)
|
||||||
|
@ -53,7 +53,7 @@ def test_remote_git_url(remote: Remote) -> None:
|
|||||||
must raise NotImplemented for missing remote git url
|
must raise NotImplemented for missing remote git url
|
||||||
"""
|
"""
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
remote.remote_git_url("package", "repositorys")
|
remote.remote_git_url("package", "repositories")
|
||||||
|
|
||||||
|
|
||||||
def test_remote_web_url(remote: Remote) -> None:
|
def test_remote_web_url(remote: Remote) -> None:
|
||||||
|
@ -10,7 +10,7 @@ from ahriman.core.exceptions import PacmanError
|
|||||||
|
|
||||||
def test_copy(mocker: MockerFixture) -> None:
|
def test_copy(mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must copy loca database file
|
must copy local database file
|
||||||
"""
|
"""
|
||||||
copy_mock = mocker.patch("shutil.copy")
|
copy_mock = mocker.patch("shutil.copy")
|
||||||
PacmanDatabase.copy(Path("remote"), Path("local"))
|
PacmanDatabase.copy(Path("remote"), Path("local"))
|
||||||
|
@ -93,6 +93,27 @@ def test_logs_insert_get_multi(database: SQLite, package_ahriman: Package) -> No
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs_rotate_remove_older(database: SQLite, package_ahriman: Package,
|
||||||
|
package_python_schedule: Package) -> None:
|
||||||
|
"""
|
||||||
|
must correctly remove old records
|
||||||
|
"""
|
||||||
|
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 42.0, "message 1"))
|
||||||
|
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "1", "p1"), 43.0, "message 2"))
|
||||||
|
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2", "p2"), 44.0, "message 3"))
|
||||||
|
database.logs_insert(LogRecord(LogRecordId(package_ahriman.base, "2", "p2"), 45.0, "message 4"))
|
||||||
|
database.logs_insert(LogRecord(LogRecordId(package_python_schedule.base, "3", "p1"), 40.0, "message 5"))
|
||||||
|
|
||||||
|
database.logs_rotate(1)
|
||||||
|
assert database.logs_get(package_ahriman.base) == [
|
||||||
|
LogRecord(LogRecordId(package_ahriman.base, "2", "p2"), 44.0, "message 3"),
|
||||||
|
LogRecord(LogRecordId(package_ahriman.base, "2", "p2"), 45.0, "message 4"),
|
||||||
|
]
|
||||||
|
assert database.logs_get(package_python_schedule.base) == [
|
||||||
|
LogRecord(LogRecordId(package_python_schedule.base, "3", "p1"), 40.0, "message 5"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_logs_rotate_remove_all(database: SQLite, package_ahriman: Package) -> None:
|
def test_logs_rotate_remove_all(database: SQLite, package_ahriman: Package) -> None:
|
||||||
"""
|
"""
|
||||||
must remove all records when rotating with keep_last_records is 0
|
must remove all records when rotating with keep_last_records is 0
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
@ -24,15 +25,29 @@ def test_factory(database: SQLite) -> None:
|
|||||||
|
|
||||||
def test_with_connection(database: SQLite, mocker: MockerFixture) -> None:
|
def test_with_connection(database: SQLite, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must run query inside connection
|
must run query inside connection and close it at the end
|
||||||
"""
|
"""
|
||||||
connection_mock = MagicMock()
|
connection_mock = MagicMock()
|
||||||
connect_mock = mocker.patch("sqlite3.connect", return_value=connection_mock)
|
connect_mock = mocker.patch("sqlite3.connect", return_value=connection_mock)
|
||||||
|
|
||||||
database.with_connection(lambda conn: conn.execute("select 1"))
|
database.with_connection(lambda conn: conn.execute("select 1"))
|
||||||
connect_mock.assert_called_once_with(database.path, detect_types=sqlite3.PARSE_DECLTYPES)
|
connect_mock.assert_called_once_with(database.path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||||
connection_mock.__enter__().set_trace_callback.assert_called_once_with(database.logger.debug)
|
connection_mock.set_trace_callback.assert_called_once_with(database.logger.debug)
|
||||||
connection_mock.__enter__().commit.assert_not_called()
|
connection_mock.commit.assert_not_called()
|
||||||
|
connection_mock.close.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_connection_close(database: SQLite, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must close connection on errors
|
||||||
|
"""
|
||||||
|
connection_mock = MagicMock()
|
||||||
|
connection_mock.commit.side_effect = Exception
|
||||||
|
mocker.patch("sqlite3.connect", return_value=connection_mock)
|
||||||
|
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
database.with_connection(lambda conn: conn.execute("select 1"), commit=True)
|
||||||
|
connection_mock.close.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_with_connection_with_commit(database: SQLite, mocker: MockerFixture) -> None:
|
def test_with_connection_with_commit(database: SQLite, mocker: MockerFixture) -> None:
|
||||||
@ -44,4 +59,4 @@ def test_with_connection_with_commit(database: SQLite, mocker: MockerFixture) ->
|
|||||||
mocker.patch("sqlite3.connect", return_value=connection_mock)
|
mocker.patch("sqlite3.connect", return_value=connection_mock)
|
||||||
|
|
||||||
database.with_connection(lambda conn: conn.execute("select 1"), commit=True)
|
database.with_connection(lambda conn: conn.execute("select 1"), commit=True)
|
||||||
connection_mock.__enter__().commit.assert_called_once_with()
|
connection_mock.commit.assert_called_once_with()
|
||||||
|
@ -285,7 +285,7 @@ def test_set_unknown(client: Client, package_ahriman: Package, mocker: MockerFix
|
|||||||
|
|
||||||
def test_set_unknown_skip(client: Client, package_ahriman: Package, mocker: MockerFixture) -> None:
|
def test_set_unknown_skip(client: Client, package_ahriman: Package, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must skip unknown status update in case if pacakge is already known
|
must skip unknown status update in case if package is already known
|
||||||
"""
|
"""
|
||||||
mocker.patch("ahriman.core.status.Client.package_get", return_value=[(package_ahriman, None)])
|
mocker.patch("ahriman.core.status.Client.package_get", return_value=[(package_ahriman, None)])
|
||||||
update_mock = mocker.patch("ahriman.core.status.Client.package_update")
|
update_mock = mocker.patch("ahriman.core.status.Client.package_update")
|
||||||
|
@ -73,7 +73,7 @@ def test_configuration_sections(configuration: Configuration) -> None:
|
|||||||
|
|
||||||
def test_on_result(trigger: Trigger) -> None:
|
def test_on_result(trigger: Trigger) -> None:
|
||||||
"""
|
"""
|
||||||
must pass execution nto run method
|
must pass execution to run method
|
||||||
"""
|
"""
|
||||||
trigger.on_result(Result(), [])
|
trigger.on_result(Result(), [])
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ from ahriman.models.log_record_id import LogRecordId
|
|||||||
|
|
||||||
def test_init() -> None:
|
def test_init() -> None:
|
||||||
"""
|
"""
|
||||||
must correctly assign proces identifier if not set
|
must correctly assign process identifier if not set
|
||||||
"""
|
"""
|
||||||
assert LogRecordId("1", "2").process_id == LogRecordId.DEFAULT_PROCESS_ID
|
assert LogRecordId("1", "2").process_id == LogRecordId.DEFAULT_PROCESS_ID
|
||||||
assert LogRecordId("1", "2", "3").process_id == "3"
|
assert LogRecordId("1", "2", "3").process_id == "3"
|
||||||
|
59
tests/ahriman/web/middlewares/test_metrics_handler.py
Normal file
59
tests/ahriman/web/middlewares/test_metrics_handler.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import importlib
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiohttp.web import HTTPNotFound
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import ahriman.web.middlewares.metrics_handler as metrics_handler
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must return metrics methods if library is available
|
||||||
|
"""
|
||||||
|
metrics_mock = AsyncMock()
|
||||||
|
mocker.patch.object(metrics_handler, "aiohttp_openmetrics", metrics_mock)
|
||||||
|
|
||||||
|
await metrics_handler.metrics(42)
|
||||||
|
metrics_mock.metrics.assert_called_once_with(42)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must raise HTTPNotFound if no module found
|
||||||
|
"""
|
||||||
|
mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None)
|
||||||
|
with pytest.raises(HTTPNotFound):
|
||||||
|
await metrics_handler.metrics(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_handler() -> None:
|
||||||
|
"""
|
||||||
|
must return metrics handler if library is available
|
||||||
|
"""
|
||||||
|
assert metrics_handler.metrics_handler() == metrics_handler.aiohttp_openmetrics.metrics_middleware
|
||||||
|
|
||||||
|
|
||||||
|
async def test_metrics_handler_dummy(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must return dummy handler if no module found
|
||||||
|
"""
|
||||||
|
mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None)
|
||||||
|
handler = metrics_handler.metrics_handler()
|
||||||
|
|
||||||
|
async def handle(result: int) -> int:
|
||||||
|
return result
|
||||||
|
|
||||||
|
assert await handler(42, handle) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_openmetrics_missing(mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must correctly process missing module
|
||||||
|
"""
|
||||||
|
mocker.patch.dict(sys.modules, {"aiohttp_openmetrics": None})
|
||||||
|
importlib.reload(metrics_handler)
|
||||||
|
|
||||||
|
assert metrics_handler.aiohttp_openmetrics is None
|
1
tests/ahriman/web/schemas/test_any_schema.py
Normal file
1
tests/ahriman/web/schemas/test_any_schema.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# schema testing goes in view class tests
|
@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
from ahriman.core.utils import walk
|
from ahriman.core.utils import walk
|
||||||
from ahriman.web.routes import _dynamic_routes, setup_routes
|
from ahriman.web.routes import _dynamic_routes, _identifier, setup_routes
|
||||||
|
|
||||||
|
|
||||||
def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) -> None:
|
def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) -> None:
|
||||||
@ -22,9 +22,19 @@ def test_dynamic_routes(resource_path_root: Path, configuration: Configuration)
|
|||||||
assert len(set(routes.values())) == len(expected_views)
|
assert len(set(routes.values())) == len(expected_views)
|
||||||
|
|
||||||
|
|
||||||
|
def test_identifier() -> None:
|
||||||
|
"""
|
||||||
|
must correctly extract route identifiers
|
||||||
|
"""
|
||||||
|
assert _identifier("/") == "_"
|
||||||
|
assert _identifier("/api/v1/status") == "_api_v1_status"
|
||||||
|
assert _identifier("/api/v1/packages/{package}") == "_api_v1_packages_:package"
|
||||||
|
|
||||||
|
|
||||||
def test_setup_routes(application: Application, configuration: Configuration) -> None:
|
def test_setup_routes(application: Application, configuration: Configuration) -> None:
|
||||||
"""
|
"""
|
||||||
must generate non-empty list of routes
|
must generate non-empty list of routes
|
||||||
"""
|
"""
|
||||||
|
application.router._named_resources = {}
|
||||||
setup_routes(application, configuration)
|
setup_routes(application, configuration)
|
||||||
assert application.router.routes()
|
assert application.router.routes()
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from aiohttp.test_utils import TestClient
|
||||||
|
from aiohttp.web import Response
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from ahriman.models.user_access import UserAccess
|
||||||
|
from ahriman.web.views.v1.status.metrics import MetricsView
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_permission() -> None:
|
||||||
|
"""
|
||||||
|
must return correct permission for the request
|
||||||
|
"""
|
||||||
|
for method in ("GET",):
|
||||||
|
request = pytest.helpers.request("", "", method)
|
||||||
|
assert await MetricsView.get_permission(request) == UserAccess.Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes() -> None:
|
||||||
|
"""
|
||||||
|
must return correct routes
|
||||||
|
"""
|
||||||
|
assert MetricsView.ROUTES == ["/api/v1/metrics"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get(client: TestClient, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must return service metrics
|
||||||
|
"""
|
||||||
|
metrics_mock = mocker.patch("ahriman.web.views.v1.status.metrics.metrics", return_value=Response())
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/metrics")
|
||||||
|
assert response.ok
|
||||||
|
metrics_mock.assert_called_once_with(pytest.helpers.anyvar(int))
|
2
tox.ini
2
tox.ini
@ -3,7 +3,7 @@ envlist = check, tests
|
|||||||
isolated_build = true
|
isolated_build = true
|
||||||
labels =
|
labels =
|
||||||
release = version, docs, publish
|
release = version, docs, publish
|
||||||
dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2]
|
dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2,web_metrics]
|
||||||
project_name = ahriman
|
project_name = ahriman
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
|
Reference in New Issue
Block a user