mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 11:03:37 +00:00
Compare commits
8 Commits
2.20.0rc5
...
875e9cf58a
| Author | SHA1 | Date | |
|---|---|---|---|
| 875e9cf58a | |||
| a809a4b67f | |||
| 437ae2c16c | |||
| 10661f3127 | |||
| 88ac3d82a0 | |||
| 386765ab4e | |||
| 78998182b8 | |||
| 2563391aaf |
@@ -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
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>;
|
/>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
108
src/ahriman/core/log/log_context.py
Normal file
108
src/ahriman/core/log/log_context.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
51
src/ahriman/web/middlewares/request_id_handler.py
Normal file
51
src/ahriman/web/middlewares/request_id_handler.py
Normal 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
|
||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
75
tests/ahriman/core/log/test_log_context.py
Normal file
75
tests/ahriman/core/log/test_log_context.py
Normal 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)
|
||||||
@@ -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__
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
43
tests/ahriman/web/middlewares/test_request_id_handler.py
Normal file
43
tests/ahriman/web/middlewares/test_request_id_handler.py
Normal 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
|
||||||
5
tox.toml
5
tox.toml
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user