Compare commits

...

8 Commits

42 changed files with 550 additions and 123 deletions

View File

@@ -28,6 +28,14 @@ ahriman.core.log.lazy\_logging module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.log.log\_context module
------------------------------------
.. automodule:: ahriman.core.log.log_context
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.log.log\_loader module ahriman.core.log.log\_loader module
----------------------------------- -----------------------------------

View File

@@ -28,6 +28,14 @@ ahriman.web.middlewares.metrics\_handler module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.web.middlewares.request\_id\_handler module
---------------------------------------------------
.. automodule:: ahriman.web.middlewares.request_id_handler
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -27,21 +27,26 @@ export default tseslint.config(
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports // imports
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error", "simple-import-sort/exports": "error",
"simple-import-sort/imports": "error",
// brackets // core
"curly": "error", "curly": "error",
"@stylistic/brace-style": ["error", "1tbs"], "eqeqeq": "error",
"no-console": "error",
"no-eval": "error",
// stylistic // stylistic
"@stylistic/array-bracket-spacing": ["error", "never"], "@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "as-needed"], "@stylistic/arrow-parens": ["error", "as-needed"],
"@stylistic/brace-style": ["error", "1tbs"],
"@stylistic/comma-dangle": ["error", "always-multiline"], "@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/comma-spacing": ["error", { before: false, after: true }], "@stylistic/comma-spacing": ["error", { before: false, after: true }],
"@stylistic/eol-last": ["error", "always"], "@stylistic/eol-last": ["error", "always"],
"@stylistic/indent": ["error", 4], "@stylistic/indent": ["error", 4],
"@stylistic/jsx-curly-brace-presence": ["error", { props: "never", children: "never" }],
"@stylistic/jsx-quotes": ["error", "prefer-double"], "@stylistic/jsx-quotes": ["error", "prefer-double"],
"@stylistic/jsx-self-closing-comp": ["error", { component: true, html: true }],
"@stylistic/max-len": ["error", { "@stylistic/max-len": ["error", {
code: 120, code: 120,
ignoreComments: true, ignoreComments: true,
@@ -49,6 +54,7 @@ export default tseslint.config(
ignoreTemplateLiterals: true, ignoreTemplateLiterals: true,
ignoreUrls: true, ignoreUrls: true,
}], }],
"@stylistic/member-delimiter-style": ["error", { multiline: { delimiter: "semi" }, singleline: { delimiter: "semi" } }],
"@stylistic/no-extra-parens": ["error", "all"], "@stylistic/no-extra-parens": ["error", "all"],
"@stylistic/no-multi-spaces": "error", "@stylistic/no-multi-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }], "@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
@@ -58,10 +64,14 @@ export default tseslint.config(
"@stylistic/semi": ["error", "always"], "@stylistic/semi": ["error", "always"],
// typescript // typescript
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }], "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error", "@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
); );

View File

@@ -2,7 +2,7 @@
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "2.20.0-rc4", "version": "2.20.0-rc6",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite", "dev": "vite",
@@ -11,30 +11,30 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.9",
"@mui/x-data-grid": "^8.27.3", "@mui/x-data-grid": "^8.27.4",
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.90.21",
"chart.js": "^4.5.0", "chart.js": "^4.5.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-syntax-highlighter": "^16.1.1" "react-syntax-highlighter": "^16.1.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0", "@stylistic/eslint-plugin": "^5.10.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.3.0", "typescript": "^5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1" "vite-tsconfig-paths": "^6.1.1"

View File

@@ -40,6 +40,7 @@ export class Client {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: "application/json", Accept: "application/json",
"X-Request-ID": crypto.randomUUID(),
}; };
if (json !== undefined) { if (json !== undefined) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
@@ -49,7 +50,7 @@ export class Client {
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: method || (json ? "POST" : "GET"), method: method ?? (json ? "POST" : "GET"),
headers, headers,
signal: controller.signal, signal: controller.signal,
}; };

View File

@@ -31,16 +31,16 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
data={{ data={{
labels: ["packages"], labels: ["packages"],
datasets: [ datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
{ {
label: "bases", label: "bases",
data: [stats.bases ?? 0], data: [stats.bases ?? 0],
backgroundColor: indigo[300], backgroundColor: indigo[300],
}, },
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
], ],
}} }}
options={{ options={{
@@ -48,7 +48,7 @@ export default function PackageCountBarChart({ stats }: PackageCountBarChartProp
responsive: true, responsive: true,
scales: { scales: {
x: { stacked: true }, x: { stacked: true },
y: { stacked: true }, y: { stacked: false },
}, },
}} }}
/>; />;

View File

@@ -86,12 +86,12 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300 }}> <Box sx={{ height: 300 }}>
<PackageCountBarChart stats={status.stats} /> <PackageCountBarChart stats={status.stats} />
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300, display: "flex", justifyContent: "center", alignItems: "center" }}> <Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>

View File

@@ -52,7 +52,7 @@ export default function AppLayout(): React.JSX.Element {
return <Container maxWidth="xl"> return <Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://github.com/arcan1s/ahriman" title="logo"> <a href="https://ahriman.readthedocs.io/" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" /> <img src="/static/logo.svg" width={30} height={30} alt="" />
</a> </a>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>

View File

@@ -1,22 +1,22 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path"; import path from "path";
import { defineConfig, type Plugin } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
function renameHtml(newName: string): Plugin { function rename(oldName: string, newName: string): Plugin {
return { return {
name: "rename-html", name: "rename",
enforce: "post", enforce: "post",
generateBundle(_, bundle) { generateBundle(_, bundle) {
if (bundle["index.html"]) { if (bundle[oldName]) {
bundle["index.html"].fileName = newName; bundle[oldName].fileName = newName;
} }
}, },
}; };
} }
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")], plugins: [react(), tsconfigPaths(), rename("index.html", "build-status.jinja2")],
base: "/", base: "/",
build: { build: {
chunkSizeWarningLimit: 10000, chunkSizeWarningLimit: 10000,

View File

@@ -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.20.0rc5 pkgver=2.20.0rc6
pkgrel=1 pkgrel=1
pkgdesc="ArcH linux ReposItory MANager" pkgdesc="ArcH linux ReposItory MANager"
arch=('any') arch=('any')
@@ -17,11 +17,10 @@ source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$
build() { build() {
cd "$pkgbase-$pkgver" cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation npm --prefix "frontend" install --cache "$srcdir/npm-cache"
npm --prefix "frontend" run build
cd "frontend" python -m build --wheel --no-isolation
npm install --cache "$srcdir/npm-cache"
npm run build
} }
package_ahriman() { package_ahriman() {

View File

@@ -26,10 +26,12 @@ formatter = syslog_format
args = ("/dev/log",) args = ("/dev/log",)
[formatter_generic_format] [formatter_generic_format]
format = [%(levelname)s %(asctime)s] [%(name)s]: %(message)s format = [{levelname} {asctime}] [{name}]: {message}
style = {
[formatter_syslog_format] [formatter_syslog_format]
format = [%(levelname)s] [%(name)s]: %(message)s format = [{levelname}] [{name}]: {message}
style = {
[logger_root] [logger_root]
level = DEBUG level = DEBUG

View File

@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-03\-06" "ahriman 2.20.0rc5" "ArcH linux ReposItory MANager" .TH AHRIMAN "1" "2026\-03\-06" "ahriman 2.20.0rc6" "ArcH linux ReposItory MANager"
.SH NAME .SH NAME
ahriman \- ArcH linux ReposItory MANager ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS .SH SYNOPSIS

View File

@@ -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.20.0rc5" __version__ = "2.20.0rc6"

View File

@@ -138,8 +138,14 @@ class PacmanDatabase(SyncHttpClient):
Args: Args:
force(bool): force database synchronization (same as ``pacman -Syy``) force(bool): force database synchronization (same as ``pacman -Syy``)
Raises:
PacmanError: on operation error (invalid scheme or incomplete configuration)
""" """
try:
server = next(iter(self.database.servers)) server = next(iter(self.database.servers))
except StopIteration:
raise PacmanError("No configured servers available for database") from None
filename = f"{self.database.name}.files.tar.gz" filename = f"{self.database.name}.files.tar.gz"
url = f"{server}/{filename}" url = f"{server}/{filename}"

View File

@@ -58,7 +58,7 @@ class Auth(LazyLogging):
Returns: Returns:
str: login control as html code to insert str: login control as html code to insert
""" """
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#login-modal" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>""" return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"
@property @property
def is_external(self) -> bool: def is_external(self) -> bool:

View File

@@ -116,6 +116,19 @@ class GitRemoteError(RuntimeError):
RuntimeError.__init__(self, "Git remote failed") RuntimeError.__init__(self, "Git remote failed")
class GPGError(RuntimeError):
"""
PGP/GPG related exception
"""
def __init__(self, details: str) -> None:
"""
Args:
details(str): details of the exception
"""
RuntimeError.__init__(self, f"GPG operation failed: {details}")
class InitializeError(RuntimeError): class InitializeError(RuntimeError):
""" """
base service initialization exception base service initialization exception

View File

@@ -86,6 +86,11 @@ class ArchiveRotationTrigger(Trigger):
package(Package): package which has been updated to check for older versions package(Package): package which has been updated to check for older versions
pacman(Pacman): alpm wrapper instance pacman(Pacman): alpm wrapper instance
""" """
# explicit guard to skip process in case if rotation is disabled
# this guard is supposed to speedup process
if self.keep_built_packages == 0:
return
packages: dict[tuple[str, str], Package] = {} packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions # we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()): for full_path in filter(package_like, self.paths.archive_for(package.base).iterdir()):
@@ -94,7 +99,7 @@ class ArchiveRotationTrigger(Trigger):
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
to_remove = sorted(packages.values(), key=cmp_to_key(comparator)) to_remove = sorted(packages.values(), key=cmp_to_key(comparator))
# 0 will implicitly be translated into [:0], meaning we keep all packages
for single in to_remove[:-self.keep_built_packages]: for single in to_remove[:-self.keep_built_packages]:
self.logger.info("removing version %s of package %s", single.version, single.base) self.logger.info("removing version %s of package %s", single.version, single.base)
for archive in single.packages.values(): for archive in single.packages.values():

View File

@@ -19,6 +19,7 @@
# #
import contextlib import contextlib
import requests import requests
import uuid
from requests.adapters import BaseAdapter from requests.adapters import BaseAdapter
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -60,6 +61,15 @@ class SyncAhrimanClient(SyncHttpClient):
return adapters return adapters
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return SyncHttpClient.headers(self) | {"X-Request-ID": str(uuid.uuid4())}
def on_session_creation(self, session: requests.Session) -> None: def on_session_creation(self, session: requests.Session) -> None:
""" """
method which will be called on session creation method which will be called on session creation

View File

@@ -144,6 +144,15 @@ class SyncHttpClient(LazyLogging):
"https://": HTTPAdapter(max_retries=self.retry), "https://": HTTPAdapter(max_retries=self.retry),
} }
def headers(self) -> dict[str, str]:
"""
additional request headers
Returns:
dict[str, str]: additional request headers defined by class
"""
return {}
def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *, def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
params: list[tuple[str, str]] | None = None, params: list[tuple[str, str]] | None = None,
@@ -178,6 +187,9 @@ class SyncHttpClient(LazyLogging):
if session is None: if session is None:
session = self.session session = self.session
if additional_headers := self.headers():
headers = additional_headers | (headers or {})
try: try:
response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json, response = session.request(method, url, params=params, data=data, headers=headers, files=files, json=json,
stream=stream, auth=self.auth, timeout=self.timeout) stream=stream, auth=self.auth, timeout=self.timeout)

View File

@@ -24,6 +24,7 @@ from collections.abc import Iterator
from functools import cached_property from functools import cached_property
from typing import Any from typing import Any
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
@@ -54,30 +55,20 @@ class LazyLogging:
prefix = "" if clazz.__module__ is None else f"{clazz.__module__}." prefix = "" if clazz.__module__ is None else f"{clazz.__module__}."
return f"{prefix}{clazz.__qualname__}" return f"{prefix}{clazz.__qualname__}"
@staticmethod @contextlib.contextmanager
def _package_logger_reset() -> None: def in_context(self, name: str, value: Any) -> Iterator[None]:
""" """
reset package logger to empty one execute function while setting log context. The context will be reset after the execution
"""
logging.setLogRecordFactory(logging.LogRecord)
@staticmethod
def _package_logger_set(package_base: str, version: str | None) -> None:
"""
set package base as extra info to the logger
Args: Args:
package_base(str): package base name(str): attribute name to set on log records
version(str | None): package version if available value(Any): current value of the context variable
""" """
current_factory = logging.getLogRecordFactory() token = LogContext.set(name, value)
try:
def package_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: yield
record = current_factory(*args, **kwargs) finally:
record.package_id = LogRecordId(package_base, version or "<unknown>") LogContext.reset(name, token)
return record
logging.setLogRecordFactory(package_record_factory)
@contextlib.contextmanager @contextlib.contextmanager
def in_package_context(self, package_base: str, version: str | None) -> Iterator[None]: def in_package_context(self, package_base: str, version: str | None) -> Iterator[None]:
@@ -94,8 +85,5 @@ class LazyLogging:
>>> with self.in_package_context(package.base, package.version): >>> with self.in_package_context(package.base, package.version):
>>> build_package(package) >>> build_package(package)
""" """
try: with self.in_context("package_id", LogRecordId(package_base, version or "<unknown>")):
self._package_logger_set(package_base, version)
yield yield
finally:
self._package_logger_reset()

View File

@@ -0,0 +1,108 @@
#
# Copyright (c) 2021-2026 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 contextvars
import logging
from typing import Any, ClassVar, TypeVar, cast
T = TypeVar("T")
class LogContext:
"""
logging context manager which provides context variables injection into log records
"""
_context: ClassVar[dict[str, contextvars.ContextVar[Any]]] = {}
@classmethod
def get(cls, name: str) -> T | None:
"""
get context variable if available
Args:
name(str): name of the context variable
Returns:
T | None: context variable if available and ``None`` otherwise
"""
if (variable := cls._context.get(name)) is not None:
return cast(T | None, variable.get())
return None
@classmethod
def log_record_factory(cls, *args: Any, **kwargs: Any) -> logging.LogRecord:
"""
log record factory which injects all registered context variables into log records
Args:
*args(Any): positional arguments for the log factory
**kwargs(Any): keyword arguments for the log factory
Returns:
logging.LogRecord: log record with context variables set as attributes
"""
record = logging.LogRecord(*args, **kwargs)
for name, variable in cls._context.items():
if (value := variable.get()) is not None:
setattr(record, name, value)
return record
@classmethod
def register(cls, name: str) -> contextvars.ContextVar[T]:
"""
(re)create context variable for log records
Args:
name(str): name of the context variable
Returns:
contextvars.ContextVar[T]: created context variable
"""
variable = cls._context[name] = contextvars.ContextVar(name, default=None)
return variable
@classmethod
def reset(cls, name: str, token: contextvars.Token[T]) -> None:
"""
reset context variable to its previous value
Args:
name(str): attribute name to reset on log records
token(contextvars.Token[T]): previously registered token
"""
cls._context[name].reset(token)
@classmethod
def set(cls, name: str, value: T) -> contextvars.Token[T]:
"""
set context variable for log records. This value will be automatically emitted with each log record
Args:
name(str): attribute name to set on log records
value(T): current value of the context variable
Returns:
contextvars.Token[T]: token created with this value
"""
return cls._context[name].set(value)

View File

@@ -21,10 +21,11 @@ import logging
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from typing import ClassVar from typing import ClassVar, Literal
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.http_log_handler import HttpLogHandler from ahriman.core.log.http_log_handler import HttpLogHandler
from ahriman.core.log.log_context import LogContext
from ahriman.models.log_handler import LogHandler from ahriman.models.log_handler import LogHandler
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@@ -36,11 +37,13 @@ class LogLoader:
Attributes: Attributes:
DEFAULT_LOG_FORMAT(str): (class attribute) default log format (in case of fallback) DEFAULT_LOG_FORMAT(str): (class attribute) default log format (in case of fallback)
DEFAULT_LOG_LEVEL(int): (class attribute) default log level (in case of fallback) DEFAULT_LOG_LEVEL(int): (class attribute) default log level (in case of fallback)
DEFAULT_LOG_STYLE(str): (class attribute) default log style (in case of fallback)
DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device DEFAULT_SYSLOG_DEVICE(Path): (class attribute) default path to syslog device
""" """
DEFAULT_LOG_FORMAT: ClassVar[str] = "[%(levelname)s %(asctime)s] [%(name)s]: %(message)s" DEFAULT_LOG_FORMAT: ClassVar[str] = "[{levelname} {asctime}] [{name}]: {message}"
DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG DEFAULT_LOG_LEVEL: ClassVar[int] = logging.DEBUG
DEFAULT_LOG_STYLE: ClassVar[Literal["%", "{", "$"]] = "{"
DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log" DEFAULT_SYSLOG_DEVICE: ClassVar[Path] = Path("/") / "dev" / "log"
@staticmethod @staticmethod
@@ -100,10 +103,22 @@ class LogLoader:
fileConfig(log_configuration, disable_existing_loggers=True) fileConfig(log_configuration, disable_existing_loggers=True)
logging.debug("using %s logger", default_handler) logging.debug("using %s logger", default_handler)
except Exception: except Exception:
logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT, level=LogLoader.DEFAULT_LOG_LEVEL) logging.basicConfig(filename=None, format=LogLoader.DEFAULT_LOG_FORMAT,
style=LogLoader.DEFAULT_LOG_STYLE, level=LogLoader.DEFAULT_LOG_LEVEL)
logging.exception("could not load logging from configuration, fallback to stderr") logging.exception("could not load logging from configuration, fallback to stderr")
HttpLogHandler.load(repository_id, configuration, report=report) HttpLogHandler.load(repository_id, configuration, report=report)
LogLoader.register_context()
if quiet: if quiet:
logging.disable(logging.WARNING) # only print errors here logging.disable(logging.WARNING) # only print errors here
@staticmethod
def register_context() -> None:
"""
register logging context
"""
# predefined context variables
for variable in ("package_id", "request_id"):
LogContext.register(variable)
logging.setLogRecordFactory(LogContext.log_record_factory)

View File

@@ -20,7 +20,7 @@
from pathlib import Path from pathlib import Path
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildError from ahriman.core.exceptions import BuildError, GPGError
from ahriman.core.http import SyncHttpClient from ahriman.core.http import SyncHttpClient
from ahriman.core.utils import check_output from ahriman.core.utils import check_output
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@@ -147,12 +147,19 @@ class GPG(SyncHttpClient):
Returns: Returns:
str: full PGP key fingerprint str: full PGP key fingerprint
Raises:
GPGError: if key is in wrong format
""" """
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
# fingerprint line will be like # fingerprint line will be like
# fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2: # fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:
metadata = check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger)
try:
fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines())) fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines()))
return fingerprint.split(":")[-2] return fingerprint.split(":")[-2]
except (IndexError, StopIteration):
raise GPGError(f"key {key} has invalid metadata") from None
def key_import(self, server: str, key: str) -> None: def key_import(self, server: str, key: str) -> None:
""" """

View File

@@ -88,8 +88,12 @@ class User:
""" """
if not self.password: if not self.password:
return None return None
try:
algo = next(segment for segment in self.password.split("$") if segment) algo = next(segment for segment in self.password.split("$") if segment)
return f"${algo}$" return f"${algo}$"
except StopIteration:
return None
@staticmethod @staticmethod
def generate_password(length: int) -> str: def generate_password(length: int) -> str:

View File

@@ -0,0 +1,51 @@
#
# Copyright (c) 2021-2026 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 uuid
from aiohttp.typedefs import Middleware
from aiohttp.web import Request, StreamResponse, middleware
from ahriman.core.log.log_context import LogContext
from ahriman.web.middlewares import HandlerType
__all__ = ["request_id_handler"]
def request_id_handler() -> Middleware:
"""
middleware to trace request id header
Returns:
Middleware: request id processing middleware
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
request_id = request.headers.getone("X-Request-ID", str(uuid.uuid4()))
token = LogContext.set("request_id", request_id)
try:
response = await handler(request)
response.headers["X-Request-ID"] = request_id
return response
finally:
LogContext.reset("request_id", token)
return handle

View File

@@ -25,8 +25,10 @@ class AuthInfoSchema(Schema):
authorization information schema authorization information schema
""" """
control = fields.String(required=True, metadata={ control = fields.String(
metadata={
"description": "HTML control for login interface", "description": "HTML control for login interface",
"example": "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>",
}) })
enabled = fields.Boolean(required=True, metadata={ enabled = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not", "description": "Whether authentication is enabled or not",
@@ -35,5 +37,5 @@ class AuthInfoSchema(Schema):
"description": "Whether authorization provider is external (e.g. OAuth)", "description": "Whether authorization provider is external (e.g. OAuth)",
}) })
username = fields.String(metadata={ username = fields.String(metadata={
"description": "Currently authenticated username if any", "description": "Currently authenticated username if available",
}) })

View File

@@ -27,10 +27,12 @@ class AutoRefreshIntervalSchema(Schema):
interval = fields.Integer(required=True, metadata={ interval = fields.Integer(required=True, metadata={
"description": "Auto refresh interval in milliseconds", "description": "Auto refresh interval in milliseconds",
"example": "60000",
}) })
is_active = fields.Boolean(required=True, metadata={ is_active = fields.Boolean(required=True, metadata={
"description": "Whether this interval is the default active one", "description": "Whether this interval is the default active one",
}) })
text = fields.String(required=True, metadata={ text = fields.String(required=True, metadata={
"description": "Human readable interval description", "description": "Human readable interval description",
"example": "1 minute",
}) })

View File

@@ -40,6 +40,7 @@ class InfoV2Schema(Schema):
}) })
index_url = fields.String(metadata={ index_url = fields.String(metadata={
"description": "URL to the repository index page", "description": "URL to the repository index page",
"example": "https://ahriman.readthedocs.io/",
}) })
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
"description": "List of loaded repositories", "description": "List of loaded repositories",

View File

@@ -31,7 +31,7 @@ class RepositoryIdSchema(Schema):
}) })
id = fields.String(metadata={ id = fields.String(metadata={
"description": "Unique repository identifier", "description": "Unique repository identifier",
"example": "aur-x86_64", "example": "x86_64-aur",
}) })
repository = fields.String(metadata={ repository = fields.String(metadata={
"description": "Repository name", "description": "Repository name",

View File

@@ -38,6 +38,7 @@ 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.middlewares.metrics_handler import metrics_handler
from ahriman.web.middlewares.request_id_handler import request_id_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.on_startup.append(_on_startup) application.on_startup.append(_on_startup)
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(request_id_handler())
application.middlewares.append(exception_handler(application.logger)) application.middlewares.append(exception_handler(application.logger))
application.middlewares.append(metrics_handler()) application.middlewares.append(metrics_handler())

View File

@@ -14,6 +14,7 @@ from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.database.migrations import Migrations from ahriman.core.database.migrations import Migrations
from ahriman.core.log.log_loader import LogLoader
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.core.spawn import Spawn from ahriman.core.spawn import Spawn
from ahriman.core.status import Client from ahriman.core.status import Client
@@ -124,6 +125,14 @@ def import_error(package: str, components: list[str], mocker: MockerFixture) ->
# generic fixtures # generic fixtures
@pytest.fixture(autouse=True)
def _register_log_context() -> None:
"""
register log context variables and factory
"""
LogLoader.register_context()
@pytest.fixture @pytest.fixture
def aur_package_ahriman() -> AURPackage: def aur_package_ahriman() -> AURPackage:
""" """

View File

@@ -183,6 +183,15 @@ def test_sync_files_local(pacman_database: PacmanDatabase, mocker: MockerFixture
copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int)) copy_mock.assert_called_once_with(Path("/var/core.files.tar.gz"), pytest.helpers.anyvar(int))
def test_sync_files_no_servers(pacman_database: PacmanDatabase) -> None:
"""
must raise PacmanError if no servers are configured
"""
pacman_database.database.servers = []
with pytest.raises(PacmanError):
pacman_database.sync_files(force=False)
def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None: def test_sync_files_unknown_source(pacman_database: PacmanDatabase) -> None:
""" """
must raise an exception in case if server scheme is unsupported must raise an exception in case if server scheme is unsupported

View File

@@ -30,6 +30,13 @@ def test_login_url(ahriman_client: SyncAhrimanClient) -> None:
assert ahriman_client._login_url().endswith("/api/v1/login") assert ahriman_client._login_url().endswith("/api/v1/login")
def test_headers(ahriman_client: SyncAhrimanClient) -> None:
"""
must inject request id header
"""
assert "X-Request-ID" in ahriman_client.headers()
def test_on_session_creation(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None: def test_on_session_creation(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
""" """
must log in user on start must log in user on start

View File

@@ -94,6 +94,13 @@ def test_adapters() -> None:
assert all(adapter.max_retries == client.retry for adapter in adapters.values()) assert all(adapter.max_retries == client.retry for adapter in adapters.values())
def test_headers() -> None:
"""
must return empty additional headers
"""
assert SyncHttpClient().headers() == {}
def test_make_request(mocker: MockerFixture) -> None: def test_make_request(mocker: MockerFixture) -> None:
""" """
must make HTTP request must make HTTP request
@@ -194,6 +201,20 @@ def test_make_request_session() -> None:
stream=None, auth=None, timeout=client.timeout) stream=None, auth=None, timeout=client.timeout)
def test_make_request_with_additional_headers(mocker: MockerFixture) -> None:
"""
must merge additional headers into request
"""
request_mock = mocker.patch("requests.Session.request")
mocker.patch("ahriman.core.http.sync_http_client.SyncHttpClient.headers", return_value={"X-Custom": "value"})
client = SyncHttpClient()
client.make_request("GET", "url")
request_mock.assert_called_once_with(
"GET", "url", params=None, data=None, headers={"X-Custom": "value"}, files=None, json=None,
stream=None, auth=None, timeout=client.timeout)
def test_on_session_creation() -> None: def test_on_session_creation() -> None:
""" """
must do nothing on start must do nothing on start

View File

@@ -1,8 +1,6 @@
import logging import logging
import pytest import pytest
from pytest_mock import MockerFixture
from ahriman.core.alpm.repo import Repo from ahriman.core.alpm.repo import Repo
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
@@ -30,59 +28,46 @@ def test_logger_name(database: SQLite, repo: Repo, task_ahriman: Task) -> None:
assert task_ahriman.logger_name == "ahriman.core.build_tools.task.Task" assert task_ahriman.logger_name == "ahriman.core.build_tools.task.Task"
def test_package_logger_set_reset(database: SQLite) -> None: def test_in_context(database: SQLite) -> None:
""" """
must set and reset package base attribute must set and reset generic log context
""" """
log_record_id = LogRecordId("base", "version") with database.in_context("package_id", "42"):
database._package_logger_set(log_record_id.package_base, log_record_id.version)
record = logging.makeLogRecord({}) record = logging.makeLogRecord({})
assert record.package_id == log_record_id assert record.package_id == "42"
database._package_logger_reset()
record = logging.makeLogRecord({}) record = logging.makeLogRecord({})
with pytest.raises(AttributeError): assert not hasattr(record, "package_id")
assert record.package_id
def test_in_package_context(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: def test_in_context_failed(database: SQLite) -> None:
"""
must reset context even if exception occurs
"""
with pytest.raises(ValueError):
with database.in_context("package_id", "42"):
raise ValueError()
record = logging.makeLogRecord({})
assert not hasattr(record, "package_id")
def test_in_package_context(database: SQLite, package_ahriman: Package) -> None:
""" """
must set package log context must set package log context
""" """
set_mock = mocker.patch("ahriman.core.log.LazyLogging._package_logger_set")
reset_mock = mocker.patch("ahriman.core.log.LazyLogging._package_logger_reset")
with database.in_package_context(package_ahriman.base, package_ahriman.version): with database.in_package_context(package_ahriman.base, package_ahriman.version):
pass record = logging.makeLogRecord({})
assert record.package_id == LogRecordId(package_ahriman.base, package_ahriman.version)
set_mock.assert_called_once_with(package_ahriman.base, package_ahriman.version) record = logging.makeLogRecord({})
reset_mock.assert_called_once_with() assert not hasattr(record, "package_id")
def test_in_package_context_empty_version(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None: def test_in_package_context_empty_version(database: SQLite, package_ahriman: Package) -> None:
""" """
must set package log context with empty version must set package log context with empty version
""" """
set_mock = mocker.patch("ahriman.core.log.LazyLogging._package_logger_set")
reset_mock = mocker.patch("ahriman.core.log.LazyLogging._package_logger_reset")
with database.in_package_context(package_ahriman.base, None): with database.in_package_context(package_ahriman.base, None):
pass record = logging.makeLogRecord({})
assert record.package_id == LogRecordId(package_ahriman.base, "<unknown>")
set_mock.assert_called_once_with(package_ahriman.base, None)
reset_mock.assert_called_once_with()
def test_in_package_context_failed(database: SQLite, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must reset package context even if exception occurs
"""
mocker.patch("ahriman.core.log.LazyLogging._package_logger_set")
reset_mock = mocker.patch("ahriman.core.log.LazyLogging._package_logger_reset")
with pytest.raises(ValueError):
with database.in_package_context(package_ahriman.base, ""):
raise ValueError()
reset_mock.assert_called_once_with()

View File

@@ -0,0 +1,75 @@
import logging
from ahriman.core.log.log_context import LogContext
def test_get() -> None:
"""
must get context variable value
"""
token = LogContext.set("package_id", "value")
assert LogContext.get("package_id") == "value"
LogContext.reset("package_id", token)
def test_get_empty() -> None:
"""
must return None when context variable is unknown or not set
"""
assert LogContext.get("package_id") is None
assert LogContext.get("random") is None
def test_log_record_factory() -> None:
"""
must inject all registered context variables into log records
"""
package_token = LogContext.set("package_id", "package")
record = logging.makeLogRecord({})
assert record.package_id == "package"
LogContext.reset("package_id", package_token)
def test_log_record_factory_empty() -> None:
"""
must not inject context variable when value is None
"""
record = logging.makeLogRecord({})
assert not hasattr(record, "package_id")
def test_register() -> None:
"""
must register a context variable
"""
variable = LogContext.register("random")
assert "random" in LogContext._context
assert LogContext._context["random"] is variable
del LogContext._context["random"]
def test_reset() -> None:
"""
must reset context variable so it is no longer injected
"""
token = LogContext.set("package_id", "value")
LogContext.reset("package_id", token)
record = logging.makeLogRecord({})
assert not hasattr(record, "package_id")
def test_set() -> None:
"""
must set context variable and inject it into log records
"""
token = LogContext.set("package_id", "value")
record = logging.makeLogRecord({})
assert record.package_id == "value"
LogContext.reset("package_id", token)

View File

@@ -7,6 +7,7 @@ from pytest_mock import MockerFixture
from systemd.journal import JournalHandler from systemd.journal import JournalHandler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.log.log_context import LogContext
from ahriman.core.log.log_loader import LogLoader from ahriman.core.log.log_loader import LogLoader
from ahriman.models.log_handler import LogHandler from ahriman.models.log_handler import LogHandler
@@ -75,3 +76,13 @@ def test_load_quiet(configuration: Configuration, mocker: MockerFixture) -> None
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
LogLoader.load(repository_id, configuration, LogHandler.Journald, quiet=True, report=False) LogLoader.load(repository_id, configuration, LogHandler.Journald, quiet=True, report=False)
disable_mock.assert_called_once_with(logging.WARNING) disable_mock.assert_called_once_with(logging.WARNING)
def test_register_context() -> None:
"""
must register predefined context variables and install log record factory
"""
LogLoader.register_context()
assert "package_id" in LogContext._context
assert "request_id" in LogContext._context
assert logging.getLogRecordFactory().__func__ is LogContext.log_record_factory.__func__

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import GPGError
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@@ -113,6 +114,15 @@ fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:""")
check_output_mock.assert_called_once_with("gpg", "--with-colons", "--fingerprint", key, logger=gpg.logger) check_output_mock.assert_called_once_with("gpg", "--with-colons", "--fingerprint", key, logger=gpg.logger)
def test_key_fingerprint_invalid(gpg: GPG, mocker: MockerFixture) -> None:
"""
must raise GPGError if no fingerprint found in output
"""
mocker.patch("ahriman.core.sign.gpg.check_output", return_value="no fingerprint here")
with pytest.raises(GPGError):
gpg.key_fingerprint("0xCE3C45C2")
def test_key_import(gpg: GPG, mocker: MockerFixture) -> None: def test_key_import(gpg: GPG, mocker: MockerFixture) -> None:
""" """
must import PGP key from the server must import PGP key from the server

View File

@@ -12,6 +12,8 @@ def test_algo() -> None:
""" """
assert User(username="user", password=None, access=UserAccess.Read).algo is None assert User(username="user", password=None, access=UserAccess.Read).algo is None
assert User(username="user", password="", access=UserAccess.Read).algo is None assert User(username="user", password="", access=UserAccess.Read).algo is None
assert User(username="user", password="$$$", access=UserAccess.Read).algo is None
assert User( assert User(
username="user", username="user",
password="$6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0", password="$6$rounds=656000$mWBiecMPrHAL1VgX$oU4Y5HH8HzlvMaxwkNEJjK13ozElyU1wAHBoO/WW5dAaE4YEfnB0X3FxbynKMl4FBdC3Ovap0jINz4LPkNADg0",

View File

@@ -0,0 +1,43 @@
import logging
import pytest
from unittest.mock import AsyncMock, MagicMock
from typing import Any
from ahriman.web.middlewares.request_id_handler import request_id_handler
async def test_request_id_handler() -> None:
"""
must use request id from request if available
"""
request = pytest.helpers.request("", "", "")
request.headers = MagicMock()
request.headers.getone.return_value = "request_id"
response = MagicMock()
response.headers = {}
async def check_handler(_: Any) -> MagicMock:
record = logging.makeLogRecord({})
assert record.request_id == "request_id"
return response
handler = request_id_handler()
await handler(request, check_handler)
assert response.headers["X-Request-ID"] == "request_id"
async def test_request_id_handler_generate() -> None:
"""
must generate request id and set it in response header
"""
request = pytest.helpers.request("", "", "")
response = MagicMock()
response.headers = {}
request_handler = AsyncMock(return_value=response)
handler = request_id_handler()
await handler(request, request_handler)
assert "X-Request-ID" in response.headers

View File

@@ -268,12 +268,13 @@ commands = [
[ [
"git", "git",
"add", "add",
"package/archlinux/PKGBUILD",
"src/ahriman/__init__.py",
"docs/_static/architecture.dot", "docs/_static/architecture.dot",
"frontend/package.json",
"package/archlinux/PKGBUILD",
"package/share/man/man1/ahriman.1", "package/share/man/man1/ahriman.1",
"package/share/bash-completion/completions/_ahriman", "package/share/bash-completion/completions/_ahriman",
"package/share/zsh/site-functions/_ahriman", "package/share/zsh/site-functions/_ahriman",
"src/ahriman/__init__.py",
], ],
[ [
"git", "git",

BIN
web.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 180 KiB