Compare commits

..

40 Commits

Author SHA1 Message Date
3d7c25b52c Merge a2c755556a into 3bdd2b618f 2024-09-19 00:58:22 +00:00
a2c755556a add moroe tests 2024-09-19 03:58:16 +03:00
aa9798973f docs update 2024-09-18 16:10:12 +03:00
bb587bcb17 allow packages without package function 2024-09-18 16:10:12 +03:00
12f6acc76c expand bash 2024-09-18 16:10:12 +03:00
54f905a537 handle quoted control sequences correctly 2024-09-18 16:10:12 +03:00
458c932fa7 tests update 2024-09-18 16:10:12 +03:00
4197de66e4 docs update 2024-09-18 16:10:12 +03:00
57afa87451 add support of array expansion 2024-09-18 16:10:12 +03:00
f46eea30a9 udpate tests 2024-09-18 16:10:12 +03:00
9c75ca09ec never raise keyerror instead return empty string 2024-09-18 16:10:12 +03:00
3c4ba46b51 docs and recipes updatte 2024-09-18 16:10:12 +03:00
a67a291b6e try to improve parser 2024-09-18 16:10:12 +03:00
3bf5e94098 simplify typed get 2024-09-18 16:10:12 +03:00
50d9cf5a6b completely remove makepkg calls 2024-09-18 16:10:12 +03:00
e293d4d354 pkgbuild parser impl 2024-09-18 16:10:12 +03:00
4afaeb7a9e generate filenames without using makepkg 2024-09-18 16:10:12 +03:00
3bdd2b618f bug: limit amount of fetches used for changes
The issue appears in case if - somehow - unknown commit sha has been
stored. In this scenario it would try to fetch infinitely
2024-09-18 16:08:59 +03:00
a075606330 feat: calculate changes on package addition as well 2024-09-18 14:03:52 +03:00
547357a705 bug: do not treat cached vcs packages as local 2024-09-17 18:02:32 +03:00
50cd71b954 feat: calculate and store changes for each update 2024-09-17 15:01:45 +03:00
f81ebe6c3c docs: improve class init docs 2024-09-15 15:13:54 +03:00
1d85a61cc4 feat: get rid of jquery (#133) 2024-09-05 02:26:52 +03:00
689de82139 build: make cerberus dependency optional 2024-09-04 22:28:25 +03:00
5b9f35220f feat: implement stats subcommand (#132) 2024-09-04 22:28:25 +03:00
8fc4d7b4a5 feat: allow filter events by timestamp 2024-09-04 22:28:25 +03:00
cedf18ac7a chore: add rss generation to samples 2024-09-04 22:28:25 +03:00
164b6d7956 feat: add event log and update chart to package info modal 2024-09-04 22:28:25 +03:00
27e595cdf4 feat: remove duplicates from the toast 2024-09-04 22:28:25 +03:00
020560d341 refactor: simplify Validator class 2024-09-04 22:28:25 +03:00
cdef67986b feat: allow cross reference in the configuration (#131) 2024-09-04 22:28:25 +03:00
dddcd0bfce feat: implement rss generation (#130) 2024-09-04 22:28:25 +03:00
a0784b7af1 feat: add ability to log sql statements 2024-09-04 22:28:25 +03:00
4c4c9b2bfd feat: serve logs and events from the newest to oldest, but keep the
ordering

So basically initial implementation, with limit=1, would emit the oldest
record in series. New implementation will return the most recent one
instead

The response is still sorted by ascension
2024-09-04 22:28:25 +03:00
5c34c051cb feat: log package update events 2024-09-04 22:28:25 +03:00
4fa44b0532 refactor: allow event to receive keyword arguments
This change also replaces the dataclass implementation of the class to
custom one
2024-09-04 22:28:25 +03:00
f167ce7d3b feat: add timer for metrics purposes 2024-09-04 22:28:25 +03:00
950b9e4289 docs: update booleans in docs 2024-09-04 22:28:25 +03:00
264aeb7150 feat: implement audit log tables and methods (#129) 2024-09-04 22:28:25 +03:00
be7169c5df feat: replace scan paths options to single one
It has been found that previous system didn't allow to configure
specific cases (e.g. a whitelisted directory inside /usr/lib/cmake). The
current solution replaces two options to single one, which also allows a
regular expressions

Also PackageArchive class has been moved to core package, because it is
more about service rather than model
2024-09-04 22:25:54 +03:00
240 changed files with 6185 additions and 1478 deletions

View File

@ -8,19 +8,17 @@ set -ex
# install dependencies
echo -e '[arcanisrepo]\nServer = https://repo.arcanis.me/$arch\nSigLevel = Never' | tee -a /etc/pacman.conf
# refresh the image
pacman -Syu --noconfirm
pacman -Syyu --noconfirm
# main dependencies
pacman -Sy --noconfirm devtools git pyalpm python-cerberus python-inflection python-passlib python-pyelftools python-requests python-srcinfo python-systemd sudo
pacman -S --noconfirm devtools git pyalpm python-inflection python-passlib python-pyelftools python-requests python-systemd sudo
# make dependencies
pacman -Sy --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel
pacman -S --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel
# optional dependencies
if [[ -z $MINIMAL_INSTALL ]]; then
# VCS support
pacman -Sy --noconfirm breezy darcs mercurial subversion
# web server
pacman -Sy --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-cryptography python-jinja
pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-cryptography python-jinja
# additional features
pacman -Sy --noconfirm gnupg python-boto3 rsync
pacman -S --noconfirm gnupg python-boto3 python-cerberus python-matplotlib rsync
fi
# FIXME since 1.0.4 devtools requires dbus to be run, which doesn't work now in container
cp "docker/systemd-nspawn.sh" "/usr/local/bin/systemd-nspawn"
@ -42,12 +40,12 @@ pacman -Qdtq | pacman -Rscn --noconfirm -
# initial setup command as root
[[ -z $MINIMAL_INSTALL ]] && WEB_ARGS=("--web-port" "8080")
ahriman -a x86_64 -r "github" service-setup --packager "ahriman bot <ahriman@example.com>" "${WEB_ARGS[@]}"
# validate configuration
ahriman service-config-validate --exit-code
# enable services
systemctl enable ahriman-web
systemctl enable ahriman@x86_64-github.timer
if [[ -z $MINIMAL_INSTALL ]]; then
# validate configuration
ahriman service-config-validate --exit-code
# run web service (detached)
sudo -u ahriman -- ahriman web &
WEB_PID=$!

View File

@ -4,7 +4,7 @@
set -ex
# install dependencies
pacman --noconfirm -Syu base-devel python-tox
pacman --noconfirm -Syyu base-devel python-tox
# run test and check targets
tox

View File

@ -12,6 +12,7 @@ python:
extra_requirements:
- docs
- s3
- validator
- web
formats:

View File

@ -36,6 +36,7 @@ Again, the most checks can be performed by `tox` command, though some additional
Notes:
Very important note about this function
Probably multi-line
Args:
argument(str): an argument. This argument has
@ -70,6 +71,7 @@ Again, the most checks can be performed by `tox` command, though some additional
Attributes:
CLAZZ_ATTRIBUTE(int): (class attribute) a brand-new class attribute
instance_attribute(str): an instance attribute
with the long description
Examples:
Very informative class usage example, e.g.::
@ -82,8 +84,6 @@ Again, the most checks can be performed by `tox` command, though some additional
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
default constructor
Args:
*args(Any): positional arguments
**kwargs(Any): keyword arguments
@ -91,8 +91,10 @@ Again, the most checks can be performed by `tox` command, though some additional
self.instance_attribute = ""
```
Note missing comment for the `__init__` method, which is the special case.
* Type annotations are the must, even for local functions. For the function argument `self` (for instance methods) and `cls` (for class methods) should not be annotated.
* For collection types built-in classes must be used if possible (e.g. `dict` instead of `typing.Dict`, `tuple` instead of `typing.Tuple`). In case if built-in type is not available, but `collections.abc` provides interface, it must be used (e.g. `collections.abc.Awaitable` instead of `typing.Awaitable`, `collections.abc.Iterable` instead of `typing.Iterable`). For union classes, the bar operator (`|`) must be used (e.g. `float | int` instead of `typing.Union[float, int]`), which also includes `typinng.Optional` (e.g. `str | None` instead of `Optional[str]`).
* For collection types built-in classes must be used if possible (e.g. `dict` instead of `typing.Dict`, `tuple` instead of `typing.Tuple`). In case if built-in type is not available, but `collections.abc` provides interface, it must be used (e.g. `collections.abc.Awaitable` instead of `typing.Awaitable`, `collections.abc.Iterable` instead of `typing.Iterable`). For union classes, the bar operator (`|`) must be used (e.g. `float | int` instead of `typing.Union[float, int]`), which also includes `typing.Optional` (e.g. `str | None` instead of `Optional[str]`).
* `classmethod` should (almost) always return `Self`. In case of mypy warning (e.g. if there is a branch in which function doesn't return the instance of `cls`) consider using `staticmethod` instead.
* Recommended order of function definitions in class:

View File

@ -31,12 +31,42 @@ RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build && \
echo "build ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/build"
COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
## install package dependencies
## darcs is not installed by reasons, because it requires a lot haskell packages which dramatically increase image size
RUN pacman -Sy --noconfirm --asdeps devtools git pyalpm python-cerberus python-inflection python-passlib python-pyelftools python-requests python-srcinfo && \
pacman -Sy --noconfirm --asdeps base-devel python-build python-flit python-installer python-wheel && \
pacman -Sy --noconfirm --asdeps breezy git mercurial python-aiohttp python-boto3 python-cryptography python-jinja python-systemd rsync subversion && \
runuser -u build -- install-aur-package python-aioauth-client python-webargs python-aiohttp-apispec-git python-aiohttp-cors \
python-aiohttp-jinja2 python-aiohttp-session python-aiohttp-security python-requests-unixsocket2
RUN pacman -Sy --noconfirm --asdeps \
devtools \
git \
pyalpm \
python-inflection \
python-passlib \
python-pyelftools \
python-requests \
&& \
pacman -Sy --noconfirm --asdeps \
base-devel \
python-build \
python-flit \
python-installer \
python-wheel \
&& \
pacman -Sy --noconfirm --asdeps \
git \
python-aiohttp \
python-boto3 \
python-cerberus \
python-cryptography \
python-jinja \
python-matplotlib \
python-systemd \
rsync \
&& \
runuser -u build -- install-aur-package \
python-aioauth-client \
python-webargs \
python-aiohttp-apispec-git \
python-aiohttp-cors \
python-aiohttp-jinja2 \
python-aiohttp-session \
python-aiohttp-security \
python-requests-unixsocket2
## FIXME since 1.0.4 devtools requires dbus to be run, which doesn't work now in container
COPY "docker/systemd-nspawn.sh" "/usr/local/bin/systemd-nspawn"

View File

@ -2,7 +2,7 @@
[![tests status](https://github.com/arcan1s/ahriman/actions/workflows/tests.yml/badge.svg)](https://github.com/arcan1s/ahriman/actions/workflows/tests.yml)
[![setup status](https://github.com/arcan1s/ahriman/actions/workflows/setup.yml/badge.svg)](https://github.com/arcan1s/ahriman/actions/workflows/setup.yml)
[![Docker Image Version (latest semver)](https://img.shields.io/docker/v/arcan1s/ahriman?label=Docker%20image)](https://hub.docker.com/r/arcan1s/ahriman)
[![Docker Image Version (latest semver)](https://img.shields.io/docker/v/arcan1s/ahriman?label=Docker%20image&sort=semver)](https://hub.docker.com/r/arcan1s/ahriman)
[![CodeFactor](https://www.codefactor.io/repository/github/arcan1s/ahriman/badge)](https://www.codefactor.io/repository/github/arcan1s/ahriman)
[![Documentation Status](https://readthedocs.org/projects/ahriman/badge/?version=latest)](https://ahriman.readthedocs.io)

View File

@ -8,9 +8,6 @@ cat <<EOF > "/etc/ahriman.ini.d/00-docker.ini"
[repository]
root = $AHRIMAN_REPOSITORY_ROOT
[settings]
database = $AHRIMAN_REPOSITORY_ROOT/ahriman.db
[web]
host = $AHRIMAN_HOST

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 12.1.1 (0)
<!-- Generated by graphviz version 12.1.0 (0)
-->
<!-- Title: G Pages: 1 -->
<svg width="28485pt" height="4761pt"

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -172,6 +172,14 @@ ahriman.application.handlers.sign module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.statistics module
----------------------------------------------
.. automodule:: ahriman.application.handlers.statistics
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.status module
------------------------------------------

View File

@ -28,6 +28,14 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module
-----------------------------------------
.. automodule:: ahriman.core.alpm.pkgbuild_parser
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.repo module
-----------------------------

View File

@ -4,6 +4,14 @@ ahriman.core.build\_tools package
Submodules
----------
ahriman.core.build\_tools.package\_archive module
-------------------------------------------------
.. automodule:: ahriman.core.build_tools.package_archive
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.build\_tools.sources module
----------------------------------------

View File

@ -28,6 +28,14 @@ ahriman.core.configuration.shell\_interpolator module
:no-undoc-members:
:show-inheritance:
ahriman.core.configuration.shell\_template module
-------------------------------------------------
.. automodule:: ahriman.core.configuration.shell_template
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.configuration.validator module
-------------------------------------------

View File

@ -116,6 +116,14 @@ ahriman.core.database.migrations.m013\_dependencies module
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m014\_auditlog module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m014_auditlog
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -36,6 +36,14 @@ ahriman.core.database.operations.dependencies\_operations module
:no-undoc-members:
:show-inheritance:
ahriman.core.database.operations.event\_operations module
---------------------------------------------------------
.. automodule:: ahriman.core.database.operations.event_operations
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.database.operations.logs\_operations module
--------------------------------------------------------

View File

@ -44,6 +44,14 @@ ahriman.core.formatters.configuration\_printer module
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.event\_stats\_printer module
----------------------------------------------------
.. automodule:: ahriman.core.formatters.event_stats_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.package\_printer module
-----------------------------------------------
@ -52,6 +60,14 @@ ahriman.core.formatters.package\_printer module
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.package\_stats\_printer module
------------------------------------------------------
.. automodule:: ahriman.core.formatters.package_stats_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.patch\_printer module
---------------------------------------------

View File

@ -60,6 +60,14 @@ ahriman.core.report.report\_trigger module
:no-undoc-members:
:show-inheritance:
ahriman.core.report.rss module
------------------------------
.. automodule:: ahriman.core.report.rss
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.report.telegram module
-----------------------------------

View File

@ -12,6 +12,14 @@ ahriman.core.repository.cleaner module
:no-undoc-members:
:show-inheritance:
ahriman.core.repository.event\_logger module
--------------------------------------------
.. automodule:: ahriman.core.repository.event_logger
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.repository.executor module
---------------------------------------

View File

@ -68,6 +68,14 @@ ahriman.models.dependencies module
:no-undoc-members:
:show-inheritance:
ahriman.models.event module
---------------------------
.. automodule:: ahriman.models.event
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.filesystem\_package module
-----------------------------------------
@ -100,6 +108,14 @@ ahriman.models.log\_record\_id module
:no-undoc-members:
:show-inheritance:
ahriman.models.metrics\_timer module
------------------------------------
.. automodule:: ahriman.models.metrics_timer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.migration module
-------------------------------
@ -124,14 +140,6 @@ ahriman.models.package module
:no-undoc-members:
:show-inheritance:
ahriman.models.package\_archive module
--------------------------------------
.. automodule:: ahriman.models.package_archive
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.package\_description module
------------------------------------------
@ -164,6 +172,14 @@ ahriman.models.pacman\_synchronization module
:no-undoc-members:
:show-inheritance:
ahriman.models.pkgbuild module
------------------------------
.. automodule:: ahriman.models.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.models.pkgbuild\_patch module
-------------------------------------

View File

@ -60,6 +60,22 @@ ahriman.web.schemas.error\_schema module
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.event\_schema module
----------------------------------------
.. automodule:: ahriman.web.schemas.event_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.event\_search\_schema module
------------------------------------------------
.. automodule:: ahriman.web.schemas.event_search_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.file\_schema module
---------------------------------------

View File

@ -0,0 +1,21 @@
ahriman.web.views.v1.auditlog package
=====================================
Submodules
----------
ahriman.web.views.v1.auditlog.events module
-------------------------------------------
.. automodule:: ahriman.web.views.v1.auditlog.events
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: ahriman.web.views.v1.auditlog
:members:
:no-undoc-members:
:show-inheritance:

View File

@ -7,6 +7,7 @@ Subpackages
.. toctree::
:maxdepth: 4
ahriman.web.views.v1.auditlog
ahriman.web.views.v1.distributed
ahriman.web.views.v1.packages
ahriman.web.views.v1.service

View File

@ -32,7 +32,7 @@ This package contains application (aka executable) related classes and everythin
This package contains everything required for the most of application actions and it is separated into several packages:
* ``ahriman.core.alpm`` package controls pacman related functions. It provides wrappers for ``pyalpm`` library and safe calls for repository tools (``repo-add`` and ``repo-remove``). Also this package contains ``ahriman.core.alpm.remote`` package which provides wrapper for remote sources (e.g. AUR RPC and official repositories RPC).
* ``ahriman.core.alpm`` package controls pacman related functions. It provides wrappers for ``pyalpm`` library and safe calls for repository tools (``repo-add`` and ``repo-remove``). Also this package contains ``ahriman.core.alpm.remote`` package which provides wrapper for remote sources (e.g. AUR RPC and official repositories RPC) and some other helpers.
* ``ahriman.core.auth`` package provides classes for authorization methods used by web mostly. Base class is ``ahriman.core.auth.Auth`` which must be instantiated by ``load`` method.
* ``ahriman.core.build_tools`` is a package which provides wrapper for ``devtools`` commands.
* ``ahriman.core.configuration`` contains extension for standard ``configparser`` library and some validation related classes.
@ -375,6 +375,24 @@ There are several supported synchronization providers, currently they are ``rsyn
``github`` provider authenticates through basic auth, API key with repository write permissions is required. There will be created a release with the name of the architecture in case if it does not exist; files will be uploaded to the release assets. It also stores array of files and their MD5 checksums in release body in order to upload only changed ones. According to the GitHub API in case if there is already uploaded asset with the same name (e.g. database files), asset will be removed first.
PKGBUILD parsing
^^^^^^^^^^^^^^^^
The application provides a house-made shell parser ``ahriman.core.alpm.pkgbuild_parser.PkgbuildParser`` to process PKGBUILDs and extract package data from them. It relies on the ``shlex.shlex`` parser with some configuration tweaks and adds some token post-processing.
#. During the parser process, firstly, it extract next token from the source file (basically, the word) and tries to match it to the variable assignment. If so, then just processes accordingly.
#. If it is not an assignment, the parser checks if the token was quoted.
#. If it wasn't then the parser tries to match the array starts (two consecutive tokens like ``array=`` and ``(``) or it is function (``function``, ``()`` and ``{``).
#. The arrays are processed until the next closing bracket ``)``. After extraction, the parser tries to expand an array according to bash rules (``prefix{first,second}suffix`` constructions).
#. The functions are just read until the closing bracket ``}`` and then reread whole text from the input string without a tokenization.
All extracted fields are packed as ``ahriman.models.pkgbuild_patch.PkgbuildPatch`` and then can be used as ``ahriman.models.pkgbuild.Pkgbuild`` instance.
The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multipackages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
Additional features
^^^^^^^^^^^^^^^^^^^

View File

@ -17,14 +17,33 @@ There are two variable types which have been added to default ones, they are pat
Path values, except for casting to ``pathlib.Path`` type, will be also expanded to absolute paths relative to the configuration path. E.g. if path is set to ``ahriman.ini.d/logging.ini`` and root configuration path is ``/etc/ahriman.ini``, the value will be expanded to ``/etc/ahriman.ini.d/logging.ini``. In order to disable path expand, use the full path, e.g. ``/etc/ahriman.ini.d/logging.ini``.
Configuration allows string interpolation from environment variables, e.g.:
Configuration allows string interpolation from the same configuration file, e.g.:
.. code-block:: ini
[section]
key = ${anoher_key}
another_key = value
will read value for the ``section.key`` option from ``section.another_key``. In case if the cross-section reference is required, the ``${section:another_key}`` notation must be used. It also allows string interpolation from environment variables, e.g.:
.. code-block:: ini
[section]
key = $SECRET
will try to read value from ``SECRET`` environment variable. In case if the required environment variable wasn't found, it will keep original value (i.e. ``$SECRET`` in the example). Dollar sign can be set as ``$$``.
will try to read value from ``SECRET`` environment variable. In case if the required environment variable wasn't found, it will keep original value (i.e. ``$SECRET`` in the example). Dollar sign can be set as ``$$``. All those interpolations will be applied in succession and - expected to be - recursively, e.g. the following configuration:
.. code-block:: ini
[section1]
key = ${section2:key}
[section2]
key = ${home}
home = $HOME
will eventually lead ``section1.key`` option to be set to the value of ``HOME`` environment variable (if available).
There is also additional subcommand which will allow to validate configuration and print found errors. In order to do so, run ``service-config-validate`` subcommand, e.g.:
@ -81,14 +100,13 @@ Authorized users are stored inside internal database, if any of external provide
Build related configuration. Group name can refer to architecture, e.g. ``build:x86_64`` can be used for x86_64 architecture specific settings.
* ``allowed_scan_paths`` - paths to be used for implicit dependencies scan, scape separated list of paths, optional.
* ``archbuild_flags`` - additional flags passed to ``archbuild`` command, space separated list of strings, optional.
* ``blacklisted_scan_paths`` - paths to be excluded for implicit dependencies scan, scape separated list of paths, optional. Normally all elements of this option must be child paths of any of ``allowed_scan_paths`` element.
* ``build_command`` - default build command, string, required.
* ``ignore_packages`` - list packages to ignore during a regular update (manual update will still work), space separated list of strings, optional.
* ``include_debug_packages`` - distribute debug packages, boolean, optional, default ``yes``.
* ``makepkg_flags`` - additional flags passed to ``makepkg`` command, space separated list of strings, optional.
* ``makechrootpkg_flags`` - additional flags passed to ``makechrootpkg`` command, space separated list of strings, optional.
* ``scan_paths`` - paths to be used for implicit dependencies scan, space separated list of strings, optional. If any of those paths is matched against the path, it will be added to the allowed list.
* ``triggers`` - list of ``ahriman.core.triggers.Trigger`` class implementation (e.g. ``ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger``) which will be loaded and run at the end of processing, space separated list of strings, optional. You can also specify triggers by their paths, e.g. ``/usr/lib/python3.10/site-packages/ahriman/core/report/report.py.ReportTrigger``. Triggers are run in the order of definition.
* ``triggers_known`` - optional list of ``ahriman.core.triggers.Trigger`` class implementations which are not run automatically and used only for trigger discovery and configuration validation.
* ``vcs_allowed_age`` - maximal age in seconds of the VCS packages before their version will be updated with its remote source, integer, optional, default is 7 days.
@ -252,6 +270,7 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e
* ``password`` - SMTP password to authenticate, string, optional.
* ``port`` - SMTP port for sending emails, integer, required.
* ``receivers`` - SMTP receiver addresses, space separated list of strings, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``sender`` - SMTP sender address, string, required.
* ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``.
* ``template`` - Jinja2 template name, string, required.
@ -267,7 +286,8 @@ Section name must be either ``html`` (plus optional architecture name, e.g. ``ht
* ``type`` - type of the report, string, optional, must be set to ``html`` if exists.
* ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required.
* ``path`` - path to html report file, string, required.
* ``path`` - path to HTML report file, string, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required.
* ``templates`` - path to templates directories, space separated list of paths, required.
@ -282,6 +302,20 @@ Section name must be either ``remote-call`` (plus optional architecture name, e.
* ``manual`` - update manually built packages, boolean, optional, default ``no``.
* ``wait_timeout`` - maximum amount of time in seconds to be waited before remote process will be terminated, integer, optional, default ``-1``.
``rss`` type
^^^^^^^^^^^^
Section name must be either ``rss`` (plus optional architecture name, e.g. ``rss:x86_64``) or random name with ``type`` set.
* ``type`` - type of the report, string, optional, must be set to ``rss`` if exists.
* ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required.
* ``max_entries`` - maximal amount of entries to be included to the report, negative means no limit, integer, optional, default ``-1``.
* ``path`` - path to generated RSS file, string, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required.
* ``templates`` - path to templates directories, space separated list of paths, required.
``telegram`` type
^^^^^^^^^^^^^^^^^
@ -292,6 +326,7 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g.
* ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required.
* ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required.
* ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``.
* ``templates`` - path to templates directories, space separated list of paths, required.

View File

@ -292,7 +292,7 @@ Worker nodes (applicable for all workers) config (``worker.ini``) as:
Command to run worker nodes (considering there will be two workers, one is on ``8081`` port and other is on ``8082``):
.. code-block:: ini
.. code-block:: shell
docker run --privileged -p 8081:8081 -e AHRIMAN_PORT=8081 -v worker.ini:/etc/ahriman.ini.d/overrides.ini arcan1s/ahriman:latest web
docker run --privileged -p 8082:8082 -e AHRIMAN_PORT=8082 -v worker.ini:/etc/ahriman.ini.d/overrides.ini arcan1s/ahriman:latest web

View File

@ -143,7 +143,7 @@ TL;DR
sudo -u ahriman ahriman package-add /path/to/local/directory/with/PKGBUILD --now
Before using this command you will need to create local directory, put ``PKGBUILD`` there and generate ``.SRCINFO`` by using ``makepkg --printsrcinfo > .SRCINFO`` command. These packages will be stored locally and *will be ignored* during automatic update; in order to update the package you will need to run ``package-add`` command again.
Before using this command you will need to create local directory and put ``PKGBUILD`` there. These packages will be stored locally and *will be ignored* during automatic update; in order to update the package you will need to run ``package-add`` command again.
How to copy package from another repository
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -265,11 +265,7 @@ TL;DR
How to update VCS packages
^^^^^^^^^^^^^^^^^^^^^^^^^^
Normally the service handles VCS packages correctly, however it requires additional dependencies:
.. code-block:: shell
pacman -S breezy darcs mercurial subversion
Normally the service handles VCS packages correctly. The version is updated in clean chroot, no additional actions are required.
How to review changes before build
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -379,7 +375,7 @@ After the success build the application extracts all linked libraries and used d
In order to disable this check completely, the ``--no-check-files`` flag can be used.
In addition, there is possibility to control paths which will be used for checking, by using options ``build.allowed_scan_paths`` and ``build.blacklisted_scan_paths``. Leaving ``build.allowed_scan_paths`` blank will effectively disable any check too.
In addition, there is possibility to control paths which will be used for checking, by using option ``build.scan_paths``, which supports regular expressions. Leaving ``build.scan_paths`` blank will effectively disable any check too.
How to install built packages
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -28,8 +28,8 @@ How to report by email
sender = me@example.com
user = me@example.com
How to generate index page for S3
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
How to generate index page
^^^^^^^^^^^^^^^^^^^^^^^^^^
#.
Install dependencies:
@ -47,10 +47,30 @@ How to generate index page for S3
target = html
[html]
path = /var/lib/ahriman/repository/aur-clone/x86_64/index.html
path = ${repository:root}/repository/aur-clone/x86_64/index.html
link_path = http://example.com/aur-clone/x86_64
After these steps ``index.html`` file will be automatically synced to S3.
Having this configuration, the generated ``index.html`` will be also automatically synced to remote services (e.g. S3).
How to generate RSS feed for index page
"""""""""""""""""""""""""""""""""""""""
In addition to previous steps, the following configuration is required:
.. code-block:: ini
[report]
target = html rss
[html]
rss_url = ${html:link_path}/rss.xml
[rss]
link_path = ${html:link_path}
path = ${repository:root}/repository/ahriman-demo/x86_64/rss.xml
rss_url = ${html:link_path}/rss.xml
With the appended configuration, the service fill also generate ``rss.xml``, link it to generated ``index.html`` and put it together.
How to post build report to telegram
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -1,18 +1,15 @@
# Maintainer: Evgeniy Alekseev
pkgname='ahriman'
pkgver=2.14.2
pkgver=2.14.1
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
url="https://github.com/arcan1s/ahriman"
license=('GPL3')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-cerberus' 'python-inflection' 'python-passlib' 'python-pyelftools' 'python-requests' 'python-srcinfo')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-inflection' 'python-passlib' 'python-pyelftools' 'python-requests')
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'
'mercurial: -hg packages support'
'python-aioauth-client: web server with OAuth2 authorization'
optdepends=('python-aioauth-client: web server with OAuth2 authorization'
'python-aiohttp: web server'
'python-aiohttp-apispec>=3.0.0: web server'
'python-aiohttp-cors: web server'
@ -20,12 +17,13 @@ optdepends=('breezy: -bzr packages support'
'python-aiohttp-security: web server with authorization'
'python-aiohttp-session: web server with authorization'
'python-boto3: sync to s3'
'python-cerberus: configuration validator'
'python-cryptography: web server with authorization'
'python-matplotlib: usage statistics chart'
'python-requests-unixsocket2: client report to web server by unix socket'
'python-jinja: html report generation'
'python-systemd: journal support'
'rsync: sync by using rsync'
'subversion: -svn packages support')
'rsync: sync by using rsync')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver.tar.gz"
'ahriman.sysusers'
'ahriman.tmpfiles')

View File

@ -6,7 +6,7 @@ logging = ahriman.ini.d/logging.ini
; Perform database migrations on the application start. Do not touch this option unless you know what are you doing.
;apply_migrations = yes
; Path to the application SQLite database.
database = /var/lib/ahriman/ahriman.db
database = ${repository:root}/ahriman.db
[alpm]
; Path to pacman system database cache.
@ -17,7 +17,7 @@ mirror = https://geo.mirror.pkgbuild.com/$repo/os/$arch
repositories = core extra multilib
; Pacman's root directory. In the most cases it must point to the system root.
root = /
; Sync files databases too, which is required by deep dependencies check
; Sync files databases too, which is required by deep dependencies check.
sync_files_database = yes
; Use local packages cache. If this option is enabled, the service will be able to synchronize databases (available
; as additional option for some subcommands). If set to no, databases must be synchronized manually.
@ -50,22 +50,20 @@ allow_read_only = yes
;salt =
[build]
; List of paths to be used for implicit dependency scan
allowed_scan_paths = /usr/lib
; List of additional flags passed to archbuild command.
;archbuild_flags =
; List of paths to be excluded for implicit dependency scan. Usually they should be subpaths of allowed_scan_paths
blacklisted_scan_paths = /usr/lib/cmake
; Path to build command
; Path to build command.
;build_command =
; List of packages to be ignored during automatic updates.
;ignore_packages =
; Include debug packages
; Include debug packages.
;include_debug_packages = yes
; List of additional flags passed to makechrootpkg command.
;makechrootpkg_flags =
; List of additional flags passed to makepkg command.
makepkg_flags = --nocolor --ignorearch
; List of paths to be used for implicit dependency scan. Regular expressions are supported.
scan_paths = ^usr/lib(?!/cmake).*$
; List of enabled triggers in the order of calls.
triggers = ahriman.core.gitremote.RemotePullTrigger ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger ahriman.core.gitremote.RemotePushTrigger
; List of well-known triggers. Used only for configuration purposes.
@ -121,9 +119,9 @@ host = 127.0.0.1
; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes.
;service_only = no
; Path to directory with static files.
static_path = /usr/share/ahriman/templates/static
static_path = ${templates}/static
; List of directories with templates.
templates = /usr/share/ahriman/templates
templates = ${prefix}/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled.
;unix_socket =
; Allow unix socket to be world readable.
@ -214,14 +212,14 @@ target = console
; Console reporting trigger configuration sample.
[console]
; Trigger type name
; Trigger type name.
;type = console
; Use utf8 symbols in output.
use_utf = yes
; Email reporting trigger configuration sample.
[email]
; Trigger type name
; Trigger type name.
;type = email
; Optional URL to the repository homepage.
;homepage=
@ -237,6 +235,8 @@ use_utf = yes
;port =
; List of emails to receive the reports.
;receivers =
; Optional link to the RSS feed.
;rss_url =
; Sender email.
;sender =
; SMTP server SSL mode, one of ssl, starttls, disabled.
@ -246,13 +246,13 @@ template = email-index.jinja2
; Template name to be used for full packages list generation (same as HTML report).
;template_full =
; List of directories with templates.
templates = /usr/share/ahriman/templates
templates = ${prefix}/share/ahriman/templates
; SMTP user.
;user =
; HTML reporting trigger configuration sample.
[html]
; Trigger type name
; Trigger type name.
;type = html
; Optional URL to the repository homepage.
;homepage=
@ -260,14 +260,16 @@ templates = /usr/share/ahriman/templates
;link_path =
; Output path for the HTML report.
;path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used.
template = repo-index.jinja2
; List of directories with templates.
templates = /usr/share/ahriman/templates
templates = ${prefix}/share/ahriman/templates
; Remote service callback trigger configuration sample.
[remote-call]
; Trigger type name
; Trigger type name.
;type = remote-call
; Call for AUR packages update.
;aur = no
@ -278,9 +280,26 @@ templates = /usr/share/ahriman/templates
; Wait until remote process will be terminated in seconds.
;wait_timeout = -1
; RSS reporting trigger configuration sample.
[rss]
; Trigger type name.
;type = rss
; Optional URL to the repository homepage.
;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename.
;link_path =
; Output path for the RSS report.
;path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used.
template = rss.jinja2
; List of directories with templates.
templates = ${prefix}/share/ahriman/templates
; Telegram reporting trigger configuration sample.
[telegram]
; Trigger type name
; Trigger type name.
;type = telegram
; Telegram bot API key.
;api_key =
@ -290,12 +309,14 @@ templates = /usr/share/ahriman/templates
;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename.
;link_path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used.
template = telegram-index.jinja2
; Telegram specific template mode, one of MarkdownV2, HTML or Markdown.
;template_type = HTML
; List of directories with templates.
templates = /usr/share/ahriman/templates
templates = ${prefix}/share/ahriman/templates
; HTTP request timeout in seconds.
;timeout = 30
@ -306,7 +327,7 @@ target =
; GitHub upload trigger configuration sample.
[github]
; Trigger type name
; Trigger type name.
;type = github
; GitHub repository owner username.
;owner =
@ -323,14 +344,14 @@ target =
; Remote instance upload trigger configuration sample.
[remote-service]
; Trigger type name
; Trigger type name.
;type = remote-service
; HTTP request timeout in seconds.
;timeout = 30
; rsync upload trigger configuration sample.
[rsync]
; Trigger type name
; Trigger type name.
;type = rsync
; rsync command to run.
command = rsync --archive --compress --partial --delete
@ -340,7 +361,7 @@ command = rsync --archive --compress --partial --delete
; S3 upload trigger configuration sample.
[s3]
; Trigger type name
; Trigger type name.
;type = s3
; AWS services access key.
;access_key =

View File

@ -1,5 +1,5 @@
[loggers]
keys = root,http,stderr,boto3,botocore,nose,s3transfer
keys = root,http,stderr,boto3,botocore,nose,s3transfer,sql
[handlers]
keys = console_handler,journald_handler,syslog_handler
@ -64,3 +64,8 @@ propagate = 0
level = INFO
qualname = s3transfer
propagate = 0
[logger_sql]
level = INFO
qualname = sql
propagate = 0

View File

@ -44,28 +44,28 @@
</button>
<ul class="dropdown-menu">
<li>
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal" hidden>
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal">
<i class="bi bi-plus"></i> add
</button>
</li>
<li>
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()" hidden>
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()">
<i class="bi bi-play"></i> update
</button>
</li>
<li>
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal" hidden>
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal">
<i class="bi bi-arrow-clockwise"></i> rebuild
</button>
</li>
<li>
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled hidden>
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled>
<i class="bi bi-trash"></i> remove
</button>
</li>
</ul>
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal" hidden>
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal">
<i class="bi bi-key"></i><span class="d-none d-sm-inline"> import key</span>
</button>
{% endif %}

View File

@ -1,8 +1,12 @@
<script>
const alertPlaceholder = $("#alert-placeholder");
const alertPlaceholder = document.getElementById("alert-placeholder");
function createAlert(title, message, clz, action, id) {
id ??= md5(title + message); // MD5 id from the content
if (alertPlaceholder.querySelector(`#alert-${id}`)) return; // check if there are duplicates
function createAlert(title, message, clz, action) {
const wrapper = document.createElement("div");
wrapper.id = `alert-${id}`;
wrapper.classList.add("toast", clz);
wrapper.role = "alert";
wrapper.ariaLive = "assertive";
@ -19,7 +23,7 @@
body.innerText = message;
wrapper.appendChild(body);
alertPlaceholder.append(wrapper);
alertPlaceholder.appendChild(wrapper);
const toast = new bootstrap.Toast(wrapper);
wrapper.addEventListener("hidden.bs.toast", _ => {
wrapper.remove(); // bootstrap doesn't remove elements
@ -28,12 +32,12 @@
toast.show();
}
function showFailure(title, description, jqXHR, errorThrown) {
function showFailure(title, description, error) {
let details;
try {
details = $.parseJSON(jqXHR.responseText).error; // execution handler json error response
details = JSON.parse(error.text).error; // execution handler json error response
} catch (_) {
details = errorThrown;
details = error.text ?? error.message ?? error;
}
createAlert(title, description(details), "text-bg-danger");
}

View File

@ -36,61 +36,69 @@
</div>
<script>
const keyImportModal = $("#key-import-modal");
const keyImportForm = $("#key-import-form");
const keyImportModal = document.getElementById("key-import-modal");
const keyImportForm = document.getElementById("key-import-form");
const keyImportBodyInput = $("#key-import-body-input");
const keyImportCopyButton = $("#key-import-copy-button");
const keyImportBodyInput = document.getElementById("key-import-body-input");
const keyImportCopyButton = document.getElementById("key-import-copy-button");
const keyImportFingerprintInput = $("#key-import-fingerprint-input");
const keyImportServerInput = $("#key-import-server-input");
const keyImportFingerprintInput = document.getElementById("key-import-fingerprint-input");
const keyImportServerInput = document.getElementById("key-import-server-input");
async function copyPgpKey() {
const logs = keyImportBodyInput.text();
await copyToClipboard(logs, keyImportCopyButton);
const key = keyImportBodyInput.textContent;
await copyToClipboard(key, keyImportCopyButton);
}
function fetchPgpKey() {
const key = keyImportFingerprintInput.val();
const server = keyImportServerInput.val();
const key = keyImportFingerprintInput.value;
const server = keyImportServerInput.value;
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: {"key": key, "server": server},
type: "GET",
dataType: "json",
success: response => { keyImportBodyInput.text(response.key); },
});
makeRequest(
"/api/v1/service/pgp",
{
query: {
key: key,
server: server,
},
convert: response => response.json(),
},
data => { keyImportBodyInput.textContent = data.key; },
);
}
}
function importPgpKey() {
const key = keyImportFingerprintInput.val();
const server = keyImportServerInput.val();
const key = keyImportFingerprintInput.value;
const server = keyImportServerInput.value;
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: JSON.stringify({key: key, server: server}),
type: "POST",
contentType: "application/json",
success: _ => {
keyImportModal.modal("hide");
makeRequest(
"/api/v1/service/pgp",
{
method: "POST",
json: {
key: key,
server: server,
},
},
_ => {
bootstrap.Modal.getOrCreateInstance(keyImportModal).hide();
showSuccess("Success", `Key ${key} has been imported`);
},
error: (jqXHR, _, errorThrown) => {
error => {
const message = _ => `Could not import key ${key} from ${server}`;
showFailure("Action failed", message, jqXHR, errorThrown);
showFailure("Action failed", message, error);
},
});
);
}
}
$(_ => {
keyImportModal.on("hidden.bs.modal", _ => {
keyImportBodyInput.text("");
keyImportForm.trigger("reset");
ready(_ => {
keyImportModal.addEventListener("hidden.bs.modal", _ => {
keyImportBodyInput.textContent = "";
keyImportForm.reset();
});
});
</script>

View File

@ -34,53 +34,57 @@
</div>
<script>
const loginModal = $("#login-modal");
const loginForm = $("#login-form");
const loginModal = document.getElementById("login-modal");
const loginForm = document.getElementById("login-form");
const loginPasswordInput = $("#login-password");
const loginUsernameInput = $("#login-username");
const showHidePasswordButton = $("#login-show-hide-password-button");
const loginPasswordInput = document.getElementById("login-password");
const loginUsernameInput = document.getElementById("login-username");
const showHidePasswordButton = document.getElementById("login-show-hide-password-button");
function login() {
const password = loginPasswordInput.val();
const username = loginUsernameInput.val();
const password = loginPasswordInput.value;
const username = loginUsernameInput.value;
if (username && password) {
$.ajax({
url: "/api/v1/login",
data: JSON.stringify({username: username, password: password}),
type: "POST",
contentType: "application/json",
success: _ => {
loginModal.modal("hide");
makeRequest(
"/api/v1/login",
{
method: "POST",
json: {
username: username,
password: password,
},
},
_ => {
bootstrap.Modal.getOrCreateInstance(loginModal).hide();
showSuccess("Logged in", `Successfully logged in as ${username}`, _ => location.href = "/");
},
error: (jqXHR, _, errorThrown) => {
error => {
const message = _ =>
username === "admin" && password === "admin"
? "You've entered a password for user \"root\", did you make a typo in username?"
: `Could not login as ${username}`;
showFailure("Login error", message, jqXHR, errorThrown);
showFailure("Login error", message, error);
},
});
);
}
}
function showPassword() {
if (loginPasswordInput.attr("type") === "password") {
loginPasswordInput.attr("type", "text");
showHidePasswordButton.removeClass("bi-eye");
showHidePasswordButton.addClass("bi-eye-slash");
if (loginPasswordInput.getAttribute("type") === "password") {
loginPasswordInput.setAttribute("type", "text");
showHidePasswordButton.classList.remove("bi-eye");
showHidePasswordButton.classList.add("bi-eye-slash");
} else {
loginPasswordInput.attr("type", "password");
showHidePasswordButton.removeClass("bi-eye-slash");
showHidePasswordButton.addClass("bi-eye");
loginPasswordInput.setAttribute("type", "password");
showHidePasswordButton.classList.remove("bi-eye-slash");
showHidePasswordButton.classList.add("bi-eye");
}
}
$(_ => {
loginModal.on("hidden.bs.modal", _ => {
loginForm.trigger("reset");
ready(_ => {
loginModal.addEventListener("hidden.bs.modal", _ => {
loginForm.reset();
});
});
</script>

View File

@ -41,14 +41,14 @@
</div>
<script>
const packageAddModal = $("#package-add-modal");
const packageAddForm = $("#package-add-form");
const packageAddModal = document.getElementById("package-add-modal");
const packageAddForm = document.getElementById("package-add-form");
const packageAddInput = $("#package-add-input");
const packageAddRepositoryInput = $("#package-add-repository-input");
const packageAddKnownPackagesList = $("#package-add-known-packages-dlist");
const packageAddInput = document.getElementById("package-add-input");
const packageAddRepositoryInput = document.getElementById("package-add-repository-input");
const packageAddKnownPackagesList = document.getElementById("package-add-known-packages-dlist");
const packageAddVariablesDiv = $("#package-add-variables-div");
const packageAddVariablesDiv = document.getElementById("package-add-variables-div");
function packageAddVariableInputCreate() {
const variableInput = document.createElement("div");
@ -78,7 +78,7 @@
variableButtonRemove.classList.add("btn");
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => { return variableInput.remove(); };
variableButtonRemove.onclick = _ => { variableInput.remove(); };
// bring them together
variableInput.appendChild(variableNameInput);
@ -86,27 +86,26 @@
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageAddVariablesDiv.append(variableInput);
packageAddVariablesDiv.appendChild(variableInput);
}
function patchesParse() {
const patches = packageAddVariablesDiv.find(".package-add-variable").map((_, element) => {
const richElement = $(element);
const patches = Array.from(packageAddVariablesDiv.getElementsByClassName("package-add-variable")).map(element => {
return {
key: richElement.find(".package-add-variable-name").val(),
value: richElement.find(".package-add-variable-value").val(),
key: element.querySelector(".package-add-variable-name").value,
value: element.querySelector(".package-add-variable-value").value,
};
}).filter((_, patch) => patch.key).get();
}).filter(patch => patch.key);
return {patches: patches};
}
function packagesAdd(packages, patches, repository) {
packages = packages ?? packageAddInput.val();
packages = packages ?? packageAddInput.value;
patches = patches ?? patchesParse();
repository = repository ?? getRepositorySelector(packageAddRepositoryInput);
if (packages) {
packageAddModal.modal("hide");
bootstrap.Modal.getOrCreateInstance(packageAddModal).hide();
const onSuccess = update => `Packages ${update} have been added`;
const onFailure = error => `Package addition failed: ${error}`;
doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure, patches);
@ -114,50 +113,54 @@
}
function packagesRequest(packages, patches) {
packages = packages ?? packageAddInput.val();
packages = packages ?? packageAddInput.value;
patches = patches ?? patchesParse();
const repository = getRepositorySelector(packageAddRepositoryInput);
if (packages) {
packageAddModal.modal("hide");
bootstrap.Modal.getOrCreateInstance(packageAddModal).hide();
const onSuccess = update => `Packages ${update} have been requested`;
const onFailure = error => `Package request failed: ${error}`;
doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure, patches);
}
}
$(_ => {
packageAddModal.on("shown.bs.modal", _ => {
$(`#package-add-repository-input option[value="${repository.architecture}-${repository.repository}"]`).prop("selected", true);
ready(_ => {
packageAddModal.addEventListener("shown.bs.modal", _ => {
const option = packageAddRepositoryInput.querySelector(`option[value="${repository.architecture}-${repository.repository}"]`);
option.selected = "selected";
});
packageAddModal.on("hidden.bs.modal", _ => {
packageAddVariablesDiv.empty();
packageAddForm.trigger("reset");
packageAddModal.addEventListener("hidden.bs.modal", _ => {
packageAddVariablesDiv.replaceChildren();
packageAddForm.reset();
});
packageAddInput.keyup(_ => {
clearTimeout(packageAddInput.data("timeout"));
packageAddInput.data("timeout", setTimeout($.proxy(_ => {
const value = packageAddInput.val();
packageAddInput.addEventListener("keyup", _ => {
clearTimeout(packageAddInput.requestTimeout);
packageAddInput.requestTimeout = setTimeout(_ => {
const value = packageAddInput.value;
if (value.length >= 3) {
$.ajax({
url: "/api/v1/service/search",
data: {"for": value},
type: "GET",
dataType: "json",
success: response => {
const options = response.map(pkg => {
makeRequest(
"/api/v1/service/search",
{
query: {
for: value,
},
convert: response => response.json(),
},
data => {
const options = data.map(pkg => {
const option = document.createElement("option");
option.value = pkg.package;
option.innerText = `${pkg.package} (${pkg.description})`;
return option;
});
packageAddKnownPackagesList.empty().append(options);
packageAddKnownPackagesList.replaceChildren(...options);
},
});
);
}
}, this), 500));
}, 500);
});
});
</script>

View File

@ -45,8 +45,9 @@
<nav>
<div class="nav nav-tabs" role="tablist">
<button id="package-info-logs-button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#package-info-logs" type="button" role="tab" aria-controls="package-info-logs" aria-selected="true"><h3>Build logs</h3></button>
<button id="package-info-changes-button" class="nav-link" data-bs-toggle="tab" data-bs-target="#package-info-changes" type="button" role="tab" aria-controls="package-info-changes" aria-selected="false"><h3>Changes</h3></button>
<button id="package-info-logs-button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#package-info-logs" type="button" role="tab" aria-controls="package-info-logs" aria-selected="true">Build logs</button>
<button id="package-info-changes-button" class="nav-link" data-bs-toggle="tab" data-bs-target="#package-info-changes" type="button" role="tab" aria-controls="package-info-changes" aria-selected="false">Changes</button>
<button id="package-info-events-button" class="nav-link" data-bs-toggle="tab" data-bs-target="#package-info-events" type="button" role="tab" aria-controls="package-info-events" aria-selected="false">Events</button>
</div>
</nav>
<div class="tab-content" id="nav-tabContent">
@ -56,11 +57,30 @@
<div id="package-info-changes" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-changes-button" tabindex="0">
<pre class="language-diff"><code id="package-info-changes-input" class="pre-scrollable language-diff"></code><button id="package-info-changes-copy-button" type="button" class="btn language-diff" onclick="copyChanges()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
<div id="package-info-events" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-events-button" tabindex="0">
<canvas id="package-info-events-update-chart" hidden></canvas>
<table id="package-info-events-table"
data-classes="table table-hover"
data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-toggle="table">
<thead class="table-primary">
<tr>
<th data-align="right" data-field="timestamp">date</th>
<th data-field="event">event</th>
<th data-field="message">description</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button id="package-info-update-button" type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal" hidden><i class="bi bi-play"></i><span class="d-none d-sm-inline"> update</span></button>
<button id="package-info-remove-button" type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal" hidden><i class="bi bi-trash"></i><span class="d-none d-sm-inline"> remove</span></button>
{% if not auth.enabled or auth.username is not none %}
<button id="package-info-update-button" type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal"><i class="bi bi-play"></i><span class="d-none d-sm-inline"> update</span></button>
<button id="package-info-remove-button" type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i><span class="d-none d-sm-inline"> remove</span></button>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span></button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button>
</div>
@ -69,41 +89,54 @@
</div>
<script>
const packageInfoModal = $("#package-info-modal");
const packageInfoModalHeader = $("#package-info-modal-header");
const packageInfo = $("#package-info");
const packageInfoModal = document.getElementById("package-info-modal");
const packageInfoModalHeader = document.getElementById("package-info-modal-header");
const packageInfo = document.getElementById("package-info");
const packageInfoLogsInput = $("#package-info-logs-input");
const packageInfoLogsCopyButton = $("#package-info-logs-copy-button");
const packageInfoLogsInput = document.getElementById("package-info-logs-input");
const packageInfoLogsCopyButton = document.getElementById("package-info-logs-copy-button");
const packageInfoChangesInput = $("#package-info-changes-input");
const packageInfoChangesCopyButton = $("#package-info-changes-copy-button");
const packageInfoChangesInput = document.getElementById("package-info-changes-input");
const packageInfoChangesCopyButton = document.getElementById("package-info-changes-copy-button");
const packageInfoAurUrl = $("#package-info-aur-url");
const packageInfoDepends = $("#package-info-depends");
const packageInfoGroups = $("#package-info-groups");
const packageInfoLicenses = $("#package-info-licenses");
const packageInfoPackager = $("#package-info-packager");
const packageInfoPackages = $("#package-info-packages");
const packageInfoUpstreamUrl = $("#package-info-upstream-url");
const packageInfoVersion = $("#package-info-version");
// so far bootstrap-table only operates with jquery elements
const packageInfoEventsTable = $(document.getElementById("package-info-events-table"));
const packageInfoEventsUpdateChartCanvas = document.getElementById("package-info-events-update-chart");
let packageInfoEventsUpdateChart = null;
const packageInfoVariablesBlock = $("#package-info-variables-block");
const packageInfoVariablesDiv = $("#package-info-variables-div");
const packageInfoAurUrl = document.getElementById("package-info-aur-url");
const packageInfoDepends = document.getElementById("package-info-depends");
const packageInfoGroups = document.getElementById("package-info-groups");
const packageInfoLicenses = document.getElementById("package-info-licenses");
const packageInfoPackager = document.getElementById("package-info-packager");
const packageInfoPackages = document.getElementById("package-info-packages");
const packageInfoUpstreamUrl = document.getElementById("package-info-upstream-url");
const packageInfoVersion = document.getElementById("package-info-version");
const packageInfoVariablesBlock = document.getElementById("package-info-variables-block");
const packageInfoVariablesDiv = document.getElementById("package-info-variables-div");
function clearChart() {
packageInfoEventsUpdateChartCanvas.hidden = true;
if (packageInfoEventsUpdateChart) {
packageInfoEventsUpdateChart.data = {};
packageInfoEventsUpdateChart.update();
}
}
async function copyChanges() {
const changes = packageInfoChangesInput.text();
const changes = packageInfoChangesInput.textContent;
await copyToClipboard(changes, packageInfoChangesCopyButton);
}
async function copyLogs() {
const logs = packageInfoLogsInput.text();
const logs = packageInfoLogsInput.textContent;
await copyToClipboard(logs, packageInfoLogsCopyButton);
}
function hideInfoControls(hidden) {
packageInfoRemoveButton.attr("hidden", hidden);
packageInfoUpdateButton.attr("hidden", hidden);
function highlight(element) {
delete element.dataset.highlighted;
hljs.highlightElement(element);
}
function insertVariable(packageBase, variable) {
@ -130,12 +163,13 @@
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches/${variable.key}`,
type: "DELETE",
dataType: "json",
success: _ => variableInput.remove(),
});
makeRequest(
`/api/v1/packages/${packageBase}/patches/${variable.key}`,
{
method: "DELETE",
},
_ => variableInput.remove(),
);
};
// bring them together
@ -144,45 +178,93 @@
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageInfoVariablesDiv.append(variableInput);
packageInfoVariablesDiv.appendChild(variableInput);
}
function loadChanges(packageBase, onFailure) {
$.ajax({
url: `/api/v1/packages/${packageBase}/changes`,
data: {
architecture: repository.architecture,
repository: repository.repository,
makeRequest(
`/api/v1/packages/${packageBase}/changes`,
{
query: {
architecture: repository.architecture,
repository: repository.repository,
},
convert: response => response.json(),
},
type: "GET",
dataType: "json",
success: response => {
const changes = response.changes;
packageInfoChangesInput.text(changes || "");
packageInfoChangesInput.map((_, el) => hljs.highlightElement(el));
data => {
const changes = data.changes;
packageInfoChangesInput.textContent = changes ?? "";
highlight(packageInfoChangesInput);
},
error: onFailure,
});
onFailure,
);
}
function loadEvents(packageBase, onFailure) {
packageInfoEventsTable.bootstrapTable("showLoading");
clearChart();
makeRequest(
"/api/v1/events",
{
query: {
architecture: repository.architecture,
repository: repository.repository,
object_id: packageBase,
limit: 30,
},
convert: response => response.json(),
},
data => {
const events = data.map(event => {
return {
timestamp: new Date(1000 * event.created).toISOStringShort(),
event: event.event,
message: event.message || "",
};
});
const chart = data.filter(event => event.event === "package-updated");
packageInfoEventsTable.bootstrapTable("load", events);
packageInfoEventsTable.bootstrapTable("hideLoading");
if (packageInfoEventsUpdateChart) {
packageInfoEventsUpdateChart.config.data = {
labels: chart.map(event => new Date(1000 * event.created).toISOStringShort()),
datasets: [{
label: "update duration, s",
data: chart.map(event => event.data.took),
cubicInterpolationMode: "monotone",
tension: 0.4,
}],
};
packageInfoEventsUpdateChart.update();
}
packageInfoEventsUpdateChartCanvas.hidden = !chart.length;
},
onFailure,
);
}
function loadLogs(packageBase, onFailure) {
$.ajax({
url: `/api/v2/packages/${packageBase}/logs`,
data: {
architecture: repository.architecture,
repository: repository.repository,
makeRequest(
`/api/v2/packages/${packageBase}/logs`,
{
query: {
architecture: repository.architecture,
repository: repository.repository,
},
convert: response => response.json(),
},
type: "GET",
dataType: "json",
success: response => {
const logs = response.map(log_record => {
data => {
const logs = data.map(log_record => {
return `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`;
});
packageInfoLogsInput.text(logs.join("\n"));
packageInfoLogsInput.map((_, el) => hljs.highlightElement(el));
packageInfoLogsInput.textContent = logs.join("\n");
highlight(packageInfoLogsInput);
},
error: onFailure,
});
onFailure,
);
}
function loadPackage(packageBase, onFailure) {
@ -194,16 +276,17 @@
return ["bg-secondary", "text-white"];
};
$.ajax({
url: `/api/v1/packages/${packageBase}`,
data: {
architecture: repository.architecture,
repository: repository.repository,
makeRequest(
`/api/v1/packages/${packageBase}`,
{
query: {
architecture: repository.architecture,
repository: repository.repository,
},
convert: response => response.json(),
},
type: "GET",
dataType: "json",
success: response => {
const description = response.find(Boolean);
data => {
const description = data.find(Boolean);
const packages = Object.keys(description.package.packages);
const aurUrl = description.package.remote.web_url;
const upstreamUrls = Array.from(
@ -213,103 +296,111 @@
)
).sort();
packageInfo.text(`${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`);
packageInfo.textContent = `${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`;
packageInfoModalHeader.removeClass();
packageInfoModalHeader.addClass("modal-header");
headerClass(description.status.status).forEach(clz => packageInfoModalHeader.addClass(clz));
packageInfoModalHeader.classList.remove(...packageInfoModalHeader.classList);
packageInfoModalHeader.classList.add("modal-header");
headerClass(description.status.status).forEach(clz => packageInfoModalHeader.classList.add(clz));
packageInfoAurUrl.html(aurUrl ? safeLink(aurUrl, aurUrl, "AUR link").outerHTML : "");
packageInfoDepends.html(listToTable(
packageInfoAurUrl.innerHTML = aurUrl ? safeLink(aurUrl, aurUrl, "AUR link").outerHTML : "";
packageInfoDepends.innerHTML = listToTable(
Object.values(description.package.packages)
.reduce((accumulator, currentValue) => {
return accumulator.concat(currentValue.depends.filter(v => packages.indexOf(v) === -1))
.concat(currentValue.make_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (make)`))
.concat(currentValue.opt_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (optional)`));
}, [])
));
packageInfoGroups.html(listToTable(extractListProperties(description.package, "groups")));
packageInfoLicenses.html(listToTable(extractListProperties(description.package, "licenses")));
packageInfoPackager.text(description.package.packager);
packageInfoPackages.html(listToTable(packages));
packageInfoUpstreamUrl.html(upstreamUrls.map(url => safeLink(url, url, "upstream link").outerHTML).join("<br>"));
packageInfoVersion.text(description.package.version);
hideInfoControls(false);
);
packageInfoGroups.innerHTML = listToTable(extractListProperties(description.package, "groups"));
packageInfoLicenses.innerHTML = listToTable(extractListProperties(description.package, "licenses"));
packageInfoPackager.textContent = description.package.packager;
packageInfoPackages.innerHTML = listToTable(packages);
packageInfoUpstreamUrl.innerHTML = upstreamUrls.map(url => safeLink(url, url, "upstream link").outerHTML).join("<br>");
packageInfoVersion.textContent = description.package.version;
},
error: (jqXHR, _, errorThrown) => {
hideInfoControls(true);
onFailure(jqXHR, null, errorThrown);
},
});
onFailure,
);
}
function loadPatches(packageBase, onFailure) {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches`,
type: "GET",
dataType: "json",
success: response => {
packageInfoVariablesDiv.empty();
response.map(patch => insertVariable(packageBase, patch));
packageInfoVariablesBlock.attr("hidden", response.length === 0);
makeRequest(
`/api/v1/packages/${packageBase}/patches`,
{
convert: response => response.json(),
},
error: onFailure,
});
data => {
packageInfoVariablesDiv.replaceChildren();
data.map(patch => insertVariable(packageBase, patch));
packageInfoVariablesBlock.hidden = !data.length;
},
onFailure,
);
}
function packageInfoRemove() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesRemove([packageBase]);
const packageBase = packageInfoModal.package;
packagesRemove([packageBase]);
}
function packageInfoUpdate() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesAdd(packageBase, [], repository);
const packageBase = packageInfoModal.package;
packagesAdd(packageBase, [], repository);
}
function showPackageInfo(packageBase) {
const isPackageBaseSet = packageBase !== undefined;
if (isPackageBaseSet)
packageInfoModal.data("package", packageBase); // set package base as currently used
else
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
if (isPackageBaseSet) {
// set package base as currently used
packageInfoModal.package = packageBase;
} else {
// read package base from the current window attribute
packageBase = packageInfoModal.package;
}
const onFailure = (jqXHR, _, errorThrown) => {
const onFailure = error => {
if (isPackageBaseSet) {
const message = error => `Could not load package ${packageBase} info: ${error}`;
showFailure("Load failure", message, jqXHR, errorThrown);
const message = details => `Could not load package ${packageBase} info: ${details}`;
showFailure("Load failure", message, error);
}
};
loadPackage(packageBase, onFailure);
loadPatches(packageBase, onFailure);
loadLogs(packageBase, onFailure);
loadChanges(packageBase, onFailure)
loadChanges(packageBase, onFailure);
loadEvents(packageBase, onFailure);
if (isPackageBaseSet) packageInfoModal.modal("show");
if (isPackageBaseSet) {
bootstrap.Modal.getOrCreateInstance(packageInfoModal).show();
}
}
$(_ => {
packageInfoModal.on("hidden.bs.modal", _ => {
packageInfoAurUrl.empty();
packageInfoDepends.empty();
packageInfoGroups.empty();
packageInfoLicenses.empty();
packageInfoPackager.empty();
packageInfoPackages.empty();
packageInfoUpstreamUrl.empty();
packageInfoVersion.empty();
ready(_ => {
packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, {
type: "line",
data: {},
options: {
responsive: true,
},
});
packageInfoVariablesBlock.attr("hidden", true);
packageInfoVariablesDiv.empty();
packageInfoModal.addEventListener("hidden.bs.modal", _ => {
packageInfoAurUrl.textContent = "";
packageInfoDepends.textContent = "";
packageInfoGroups.textContent = "";
packageInfoLicenses.textContent = "";
packageInfoPackager.textContent = "";
packageInfoPackages.textContent = "";
packageInfoUpstreamUrl.textContent = "";
packageInfoVersion.textContent = "";
packageInfoLogsInput.empty();
packageInfoChangesInput.empty();
packageInfoVariablesBlock.hidden = true;
packageInfoVariablesDiv.replaceChildren();
packageInfoModal.trigger("reset");
hideInfoControls(true);
packageInfoLogsInput.textContent = "";
packageInfoChangesInput.textContent = "";
packageInfoEventsTable.bootstrapTable("load", []);
clearChart();
});
});
</script>

View File

@ -33,28 +33,31 @@
</div>
<script>
const packageRebuildModal = $("#package-rebuild-modal");
const packageRebuildForm = $("#package-rebuild-form");
const packageRebuildModal = document.getElementById("package-rebuild-modal");
const packageRebuildForm = document.getElementById("package-rebuild-form");
const packageRebuildDependencyInput = $("#package-rebuild-dependency-input");
const packageRebuildRepositoryInput = $("#package-rebuild-repository-input");
const packageRebuildDependencyInput = document.getElementById("package-rebuild-dependency-input");
const packageRebuildRepositoryInput = document.getElementById("package-rebuild-repository-input");
function packagesRebuild() {
const packages = packageRebuildDependencyInput.val();
const packages = packageRebuildDependencyInput.value;
const repository = getRepositorySelector(packageRebuildRepositoryInput);
if (packages) {
packageRebuildModal.modal("hide");
bootstrap.Modal.getOrCreateInstance(packageRebuildModal).hide();
const onSuccess = update => `Repository rebuild has been run for packages which depend on ${update}`;
const onFailure = error => `Repository rebuild failed: ${error}`;
doPackageAction("/api/v1/service/rebuild", [packages], repository, onSuccess, onFailure);
}
}
$(_ => {
packageRebuildModal.on("shown.bs.modal", _ => {
$(`#package-rebuild-repository-input option[value="${repository.architecture}-${repository.repository}"]`).prop("selected", true);
ready(_ => {
packageRebuildModal.addEventListener("shown.bs.modal", _ => {
const option = packageRebuildRepositoryInput.querySelector(`option[value="${repository.architecture}-${repository.repository}"]`);
option.selected = "selected";
});
packageRebuildModal.on("hidden.bs.modal", _ => { packageRebuildForm.trigger("reset"); });
packageRebuildModal.addEventListener("hidden.bs.modal", _ => {
packageRebuildForm.reset();
});
});
</script>

View File

@ -1,39 +1,34 @@
<script>
const keyImportButton = $("#key-import-button");
const packageAddButton = $("#package-add-button");
const packageRebuildButton = $("#package-rebuild-button");
const packageRemoveButton = $("#package-remove-button");
const packageUpdateButton = $("#package-update-button");
const packageInfoRemoveButton = $("#package-info-remove-button");
const packageInfoUpdateButton = $("#package-info-update-button");
const packageRemoveButton = document.getElementById("package-remove-button");
const packageUpdateButton = document.getElementById("package-update-button");
let repository = null;
const table = $("#packages");
// so far bootstrap-table only operates with jquery elements
const table = $(document.getElementById("packages"));
const statusBadge = $("#badge-status");
const versionBadge = $("#badge-version");
const statusBadge = document.getElementById("badge-status");
const versionBadge = document.getElementById("badge-version");
function doPackageAction(uri, packages, repository, successText, failureText, data) {
const queryParams = $.param({
architecture: repository.architecture,
repository: repository.repository,
}); // it will never be empty btw
$.ajax({
url: `${uri}?${queryParams}`,
data: JSON.stringify(Object.assign({}, {packages: packages}, data || {})),
type: "POST",
contentType: "application/json",
success: _ => {
makeRequest(
uri,
{
method: "POST",
query: {
architecture: repository.architecture,
repository: repository.repository,
},
json: Object.assign({}, {packages: packages}, data || {}),
},
_ => {
const message = successText(packages.join(", "));
showSuccess("Success", message);
},
error: (jqXHR, _, errorThrown) => {
showFailure("Action failed", failureText, jqXHR, errorThrown);
error => {
showFailure("Action failed", failureText, error);
},
});
);
}
function filterListGroups() {
@ -49,10 +44,10 @@
}
function getRepositorySelector(selector) {
const selected = selector.find(":selected");
const selected = selector.options[selector.selectedIndex];
return {
architecture: selected.data("architecture"),
repository: selected.data("repository"),
architecture: selected.getAttribute("data-architecture"),
repository: selected.getAttribute("data-repository"),
};
}
@ -60,14 +55,6 @@
return table.bootstrapTable("getSelections").map(row => row.id);
}
function hideControls(hidden) {
keyImportButton.attr("hidden", hidden);
packageAddButton.attr("hidden", hidden);
packageRebuildButton.attr("hidden", hidden);
packageRemoveButton.attr("hidden", hidden);
packageUpdateButton.attr("hidden", hidden);
}
function packagesRemove(packages) {
packages = packages ?? getSelection();
const onSuccess = update => `Packages ${update} have been removed`;
@ -97,16 +84,17 @@
return "btn-outline-secondary";
};
$.ajax({
url: "/api/v1/packages",
data: {
architecture: repository.architecture,
repository: repository.repository,
makeRequest(
"/api/v1/packages",
{
query: {
architecture: repository.architecture,
repository: repository.repository,
},
convert: response => response.json(),
},
type: "GET",
dataType: "json",
success: response => {
const payload = response.map(description => {
data => {
const payload = data.map(description => {
const package_base = description.package.base;
const web_url = description.package.remote.web_url;
return {
@ -125,10 +113,9 @@
table.bootstrapTable("load", payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading");
hideControls(false);
},
error: (jqXHR, _, errorThrown) => {
if ((jqXHR.status === 401) || (jqXHR.status === 403)) {
error => {
if ((error.status === 401) || (error.status === 403)) {
// authorization error
const text = "In order to see statuses you must login first.";
table.find("tr.unauthorized").remove();
@ -136,39 +123,39 @@
table.bootstrapTable("hideLoading");
} else {
// other errors
const message = error => `Could not load list of packages: ${error}`;
showFailure("Load failure", message, jqXHR, errorThrown);
const message = details => `Could not load list of packages: ${details}`;
showFailure("Load failure", message, error);
}
hideControls(true);
},
});
);
$.ajax({
url: "/api/v1/status",
data: {
architecture: repository.architecture,
repository: repository.repository,
makeRequest(
"/api/v1/status",
{
query: {
architecture: repository.architecture,
repository: repository.repository,
},
convert: response => response.json(),
},
type: "GET",
dataType: "json",
success: response => {
versionBadge.html(`<i class="bi bi-github"></i> ahriman ${safe(response.version)}`);
data => {
versionBadge.innerHTML = `<i class="bi bi-github"></i> ahriman ${safe(data.version)}`;
statusBadge
.popover("dispose")
.attr("data-bs-content", `${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOStringShort()}`)
.popover();
statusBadge.removeClass();
statusBadge.addClass("btn");
statusBadge.addClass(badgeClass(response.status.status));
statusBadge.classList.remove(...statusBadge.classList);
statusBadge.classList.add("btn");
statusBadge.classList.add(badgeClass(data.status.status));
const popover = bootstrap.Popover.getOrCreateInstance(statusBadge);
popover.dispose();
statusBadge.dataset.bsContent = `${data.status.status} at ${new Date(1000 * data.status.timestamp).toISOStringShort()}`;
bootstrap.Popover.getOrCreateInstance(statusBadge);
},
});
);
}
function selectRepository() {
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
const element = $(`#${fragment}-link`);
element.click();
document.getElementById(`${fragment}-link`).click();
}
function statusFormat(value) {
@ -182,20 +169,25 @@
return {classes: cellClass(value)};
}
$(_ => {
$("#repositories a").on("click", event => {
const element = event.target;
repository = {
architecture: element.dataset.architecture,
repository: element.dataset.repository,
ready(_ => {
document.querySelectorAll("#repositories a").forEach(element => {
element.onclick = _ => {
repository = {
architecture: element.dataset.architecture,
repository: element.dataset.repository,
};
if (packageUpdateButton) {
packageUpdateButton.innerHTML = `<i class="bi bi-play"></i> update<span class="d-none d-sm-inline"> ${safe(repository.repository)} (${safe(repository.architecture)})</span>`;
}
bootstrap.Tab.getOrCreateInstance(document.getElementById(element.id)).show();
reload();
};
packageUpdateButton.html(`<i class="bi bi-play"></i> update<span class="d-none d-sm-inline"> ${safe(repository.repository)} (${safe(repository.architecture)})</span>`);
$(`#${element.id}`).tab("show");
reload();
});
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", _ => {
packageRemoveButton.prop("disabled", !table.bootstrapTable("getSelections").length);
if (packageRemoveButton) {
packageRemoveButton.disabled = !table.bootstrapTable("getSelections").length;
}
});
table.on("click-row.bs.table", (self, data, row, cell) => {
if (0 === cell || "base" === cell) {
@ -204,26 +196,38 @@
} else showPackageInfo(data.id);
});
table.on("created-controls.bs.table", _ => {
const pickerInput = $(".bootstrap-table-filter-control-timestamp");
pickerInput.daterangepicker({
autoUpdateInput: false,
new easepick.create({
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
css: [
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
],
grid: 2,
calendars: 2,
autoApply: false,
locale: {
cancelLabel: "Clear",
cancel: "Clear",
},
RangePlugin: {
tooltip: false,
},
plugins: [
"RangePlugin",
],
setup: picker => {
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
// replace "Cancel" behaviour to "Clear"
picker.onClickCancelButton = element => {
if (picker.isCancelButton(element)) {
picker.clear();
picker.hide();
table.bootstrapTable("triggerSearch");
}
};
},
});
pickerInput.on("apply.daterangepicker", (event, picker) => {
pickerInput.val(`${picker.startDate.format("YYYY-MM-DD")} - ${picker.endDate.format("YYYY-MM-DD")}`);
table.bootstrapTable("triggerSearch");
});
pickerInput.on("cancel.daterangepicker", _ => {
pickerInput.val("");
table.bootstrapTable("triggerSearch");
});
});
statusBadge.popover();
bootstrap.Popover.getOrCreateInstance(statusBadge);
selectRepository();
});
</script>

View File

@ -7,6 +7,10 @@
{% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %}
{% if rss_url is not none %}
<link rel="alternate" href="{{ rss_url }}" type="application/rss+xml">
{% endif %}
</head>
<body>
@ -101,13 +105,13 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
</div>
<script>
const table = $("#packages");
const table = $(document.getElementById("packages"));
const pacmanConf = $("#pacman-conf");
const pacmanConfCopyButton = $("#copy-btn");
const pacmanConf = document.getElementById("pacman-conf");
const pacmanConfCopyButton = document.getElementById("copy-btn");
async function copyPacmanConf() {
const conf = pacmanConf.text();
const conf = pacmanConf.textContent;
await copyToClipboard(conf, pacmanConfCopyButton);
}
@ -123,24 +127,36 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
return extractDataList(table.bootstrapTable("getData"), "licenses");
}
$(_ => {
ready(_ => {
table.on("created-controls.bs.table", _ => {
const pickerInput = $(".bootstrap-table-filter-control-timestamp");
pickerInput.daterangepicker({
autoUpdateInput: false,
new easepick.create({
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
css: [
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
],
grid: 2,
calendars: 2,
autoApply: false,
locale: {
cancelLabel: "Clear",
cancel: "Clear",
},
RangePlugin: {
tooltip: false,
},
plugins: [
"RangePlugin",
],
setup: picker => {
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
// replace "Cancel" behaviour to "Clear"
picker.onClickCancelButton = element => {
if (picker.isCancelButton(element)) {
picker.clear();
picker.hide();
table.bootstrapTable("triggerSearch");
}
};
},
});
pickerInput.on("apply.daterangepicker", (event, picker) => {
pickerInput.val(`${picker.startDate.format("YYYY-MM-DD")} - ${picker.endDate.format("YYYY-MM-DD")}`);
table.bootstrapTable("triggerSearch");
});
pickerInput.on("cancel.daterangepicker", _ => {
pickerInput.val("");
table.bootstrapTable("triggerSearch");
});
});
});

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ repository }}: Recent package updates</title>
{% if homepage is not none %}
<link>{{ homepage }}</link>
{% endif %}
<description>Recently updated packages in the {{ repository }}.</description>
{% if rss_url is not none %}
<atom:link href="{{ rss_url }}" rel="self"/>
{% endif %}
<language>en-us</language>
<lastBuildDate>{{ last_update }}</lastBuildDate>
{% for package in packages %}
<item>
<title>{{ package.name }} {{ package.version }} {{ package.architecture }}</title>
<link>{{ link_path }}/{{ package.filename }}</link>
<description>{{ package.description }}</description>
<pubDate>{{ package.build_date }}</pubDate>
<guid isPermaLink="false">{{ package.tag }}</guid>
<category>{{ repository }}</category>
<category>{{ package.architecture }}</category>
</item>
{% endfor %}
</channel>
</rss>

View File

@ -1,38 +1,30 @@
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/daterangepicker@3.1.0/daterangepicker.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.28.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.30.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script>
async function copyToClipboard(text, button) {
if (navigator.clipboard === undefined) {
const input = document.createElement("textarea");
input.innerHTML = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
} else {
await navigator.clipboard.writeText(text);
}
button.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(()=> {
button.html("<i class=\"bi bi-clipboard\"></i> copy");
await navigator.clipboard.writeText(text);
button.innerHTML = "<i class=\"bi bi-clipboard-check\"></i> copied";
setTimeout(_ => {
button.innerHTML = "<i class=\"bi bi-clipboard\"></i> copy";
}, 2000);
}
@ -73,6 +65,47 @@
.join("<br>");
}
function makeRequest(url, params, onSuccess, onFailure) {
const requestParams = {
method: params.method,
body: params.json ? JSON.stringify(params.json) : params.json,
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
};
if (params.query) {
const query = new URLSearchParams(params.query);
url += `?${query.toString()}`;
}
const convert = params.convert ?? (response => response.text());
return fetch(url, requestParams)
.then(response => {
if (response.ok) {
return convert(response);
} else {
const error = new Error("Network request error");
error.status = response.status;
error.statusText = response.statusText;
return response.text().then(text => {
error.text = text;
throw error;
});
}
})
.then(data => onSuccess && onSuccess(data))
.catch(error => onFailure && onFailure(error));
}
function ready(fn) {
if (document.readyState === "complete" || document.readyState === "interactive") {
setTimeout(fn, 1);
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
@ -86,7 +119,9 @@
const element = document.createElement("a");
element.href = url;
element.innerText = text;
if (title) element.title = title;
if (title) {
element.title = title;
}
return element;
}

View File

@ -1,17 +1,15 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.22.1/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.2/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/daterangepicker@3.1.0/daterangepicker.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/github.min.css" crossorigin="anonymous" type="text/css">
<style>
.pre-scrollable {

View File

@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2024\-09\-19" "ahriman" "Generated Python Manual"
.TH AHRIMAN "1" "2024\-09\-04" "ahriman" "Generated Python Manual"
.SH NAME
ahriman
.SH SYNOPSIS

View File

@ -17,12 +17,10 @@ authors = [
]
dependencies = [
"cerberus",
"inflection",
"passlib",
"pyelftools",
"requests",
"srcinfo",
]
dynamic = ["version"]
@ -62,6 +60,9 @@ pacman = [
s3 = [
"boto3",
]
stats = [
"matplotlib",
]
tests = [
"pytest",
"pytest-aiohttp",
@ -71,6 +72,9 @@ tests = [
"pytest-resource-path",
"pytest-spec",
]
validator = [
"cerberus",
]
web = [
"Jinja2",
"aioauth-client",

View File

@ -1,6 +1,7 @@
# Index
1. Setup repository named `ahriman-demo` with architecture `x86_64`.
2. Generate index page.
2. Generate index page and RSS feed.
3. Repository is available at `http://localhost:8080/repo`.
4. Index page is available at `http://localhost:8080/repo/ahriman-demo/x86_64/index.html`
5. Index page is available at `http://localhost:8080/repo/ahriman-demo/x86_64/rss.xml`

View File

@ -1,6 +1,12 @@
[report]
target = html
target = html rss
[html]
path = /var/lib/ahriman/ahriman/repository/ahriman-demo/x86_64/index.html
path = ${repository:root}/repository/ahriman-demo/x86_64/index.html
link_path = http://localhost:8080/repo/ahriman-demo/x86_64
rss_url = ${html:link_path}/rss.xml
[rss]
link_path = ${html:link_path}
path = ${repository:root}/repository/ahriman-demo/x86_64/rss.xml
rss_url = ${html:link_path}/rss.xml

View File

@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "2.14.2"
__version__ = "2.14.1"

View File

@ -28,6 +28,7 @@ from ahriman.application import handlers
from ahriman.core.utils import enum_values, extract_user
from ahriman.models.action import Action
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.event import EventType
from ahriman.models.log_handler import LogHandler
from ahriman.models.package_source import PackageSource
from ahriman.models.sign_settings import SignSettings
@ -119,6 +120,7 @@ def _parser() -> argparse.ArgumentParser:
_set_repo_report_parser(subparsers)
_set_repo_restore_parser(subparsers)
_set_repo_sign_parser(subparsers)
_set_repo_statistics_parser(subparsers)
_set_repo_status_update_parser(subparsers)
_set_repo_sync_parser(subparsers)
_set_repo_tree_parser(subparsers)
@ -264,6 +266,8 @@ def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
"5) and finally you can add package from AUR.",
formatter_class=_formatter)
parser.add_argument("package", help="package source (base name, path to local files, remote URL)", nargs="+")
parser.add_argument("--changes", help="calculate changes from the latest known commit if available",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--dependencies", help="process missing package dependencies",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty", action="store_true")
@ -532,8 +536,7 @@ def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
description="check for packages updates. Same as repo-update --dry-run --no-manual",
formatter_class=_formatter)
parser.add_argument("package", help="filter check by package base", nargs="*")
parser.add_argument("--changes", help="calculate changes from the latest known commit if available. "
"Only applicable in dry run mode",
parser.add_argument("--changes", help="calculate changes from the latest known commit if available",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--check-files", help="enable or disable checking of broken dependencies "
"(e.g. dynamically linked libraries or modules directories)",
@ -735,6 +738,30 @@ def _set_repo_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
return parser
def _set_repo_statistics_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for repository statistics subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("repo-statistics", help="repository statistics",
description="fetch repository statistics", formatter_class=_formatter)
parser.add_argument("package", help="fetch only events for the specified package", nargs="?")
parser.add_argument("--chart", help="create updates chart and save it to the specified path", type=Path)
parser.add_argument("-e", "--event", help="event type filter",
type=EventType, choices=enum_values(EventType), default=EventType.PackageUpdated)
parser.add_argument("--from-date", help="only fetch events which are newer than the date")
parser.add_argument("--limit", help="limit response by specified amount of events", type=int, default=-1)
parser.add_argument("--offset", help="skip specified amount of events", type=int, default=0)
parser.add_argument("--to-date", help="only fetch events which are older than the date")
parser.set_defaults(handler=handlers.Statistics, lock=None, quiet=True, report=False, unsafe=True)
return parser
def _set_repo_status_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for repository status update subcommand

View File

@ -120,8 +120,7 @@ class Application(ApplicationPackages, ApplicationRepository):
process_dependencies(bool): if no set, dependencies will not be processed
Returns:
list[Package]: updated packages list. Packager for dependencies will be copied from
original package
list[Package]: updated packages list. Packager for dependencies will be copied from the original package
Examples:
In the most cases, in order to avoid build failure, it is required to add missing packages, which can be

View File

@ -40,8 +40,6 @@ class ApplicationProperties(LazyLogging):
def __init__(self, repository_id: RepositoryId, configuration: Configuration, *, report: bool,
refresh_pacman_database: PacmanSynchronization = PacmanSynchronization.Disabled) -> None:
"""
default constructor
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance

View File

@ -49,8 +49,6 @@ class UpdatesIterator(Iterator[list[str] | None]):
def __init__(self, application: Application, interval: int) -> None:
"""
default constructor
Args:
application(Application): application instance
interval(int): predefined interval for updates

View File

@ -37,8 +37,6 @@ class LocalUpdater(Updater):
def __init__(self, repository: Repository) -> None:
"""
default constructor
Args:
repository(Repository): repository instance
"""

View File

@ -43,8 +43,6 @@ class RemoteUpdater(Updater):
def __init__(self, workers: list[Worker], repository_id: RepositoryId, configuration: Configuration) -> None:
"""
default constructor
Args:
workers(list[Worker]): worker identifiers
repository_id(RepositoryId): repository unique identifier

View File

@ -38,6 +38,7 @@ from ahriman.application.handlers.service_updates import ServiceUpdates
from ahriman.application.handlers.setup import Setup
from ahriman.application.handlers.shell import Shell
from ahriman.application.handlers.sign import Sign
from ahriman.application.handlers.statistics import Statistics
from ahriman.application.handlers.status import Status
from ahriman.application.handlers.status_update import StatusUpdate
from ahriman.application.handlers.structure import Structure

View File

@ -57,6 +57,9 @@ class Add(Handler):
return
packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False)
if args.changes: # generate changes if requested
application.changes(packages)
packages = application.with_dependencies(packages, process_dependencies=args.dependencies)
packagers = Packagers(args.username, {package.base: package.packager for package in packages})

View File

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

View File

@ -47,7 +47,7 @@ class ServiceUpdates(Handler):
report(bool): force enable or disable reporting
"""
remote = Package.from_aur("ahriman", None)
_, release = remote.version.rsplit("-", 1) # we don't store pkgrel locally, so we just append it
_, release = remote.version.rsplit("-", maxsplit=1) # we don't store pkgrel locally, so we just append it
local_version = f"{__version__}-{release}"
# technically we would like to compare versions, but it is fine to raise an exception in case if locally

View File

@ -0,0 +1,170 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import datetime
import itertools
from collections.abc import Callable
from pathlib import Path
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import EventStatsPrinter, PackageStatsPrinter
from ahriman.core.utils import pretty_datetime
from ahriman.models.event import Event
from ahriman.models.repository_id import RepositoryId
class Statistics(Handler):
"""
repository statistics handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
from_date = to_date = None
if (value := args.from_date) is not None:
from_date = datetime.datetime.fromisoformat(value).timestamp()
if (value := args.to_date) is not None:
to_date = datetime.datetime.fromisoformat(value).timestamp()
events = application.reporter.event_get(args.event, args.package, from_date, to_date, args.limit, args.offset)
match args.package:
case None:
Statistics.stats_per_package(args.event, events, args.chart)
case _:
Statistics.stats_for_package(args.event, events, args.chart)
@staticmethod
def event_stats(event_type: str, events: list[Event]) -> None:
"""
calculate event stats
Args:
event_type(str): event type
events(list[Event]): list of events
"""
times = [event.get("took") for event in events if event.get("took") is not None]
EventStatsPrinter(f"{event_type} duration, s", times)(verbose=True)
@staticmethod
def plot_packages(event_type: str, events: dict[str, int], path: Path) -> None:
"""
plot packages frequency
Args:
event_type(str): event type
events(dict[str, int]): list of events
path(Path): path to save plot
"""
from matplotlib import pyplot as plt
x, y = list(events.keys()), list(events.values())
plt.bar(x, y)
plt.xlabel("Package base")
plt.ylabel("Frequency")
plt.title(f"Frequency of the {event_type} event per package")
plt.savefig(path)
@staticmethod
def plot_times(event_type: str, events: list[Event], path: Path) -> None:
"""
plot events timeline
Args:
event_type(str): event type
events(list[Event]): list of events
path(Path): path to save plot
"""
from matplotlib import pyplot as plt
figure = plt.figure()
x, y = zip(*[(pretty_datetime(event.created), event.get("took")) for event in events])
plt.plot(x, y)
plt.xlabel("Event timestamp")
plt.ylabel("Duration, s")
plt.title(f"Duration of the {event_type} event")
figure.autofmt_xdate()
plt.savefig(path)
@staticmethod
def stats_for_package(event_type: str, events: list[Event], chart_path: Path | None) -> None:
"""
calculate statistics for a package
Args:
event_type(str): event type
events(list[Event]): list of events
chart_path(Path): path to save plot if any
"""
# event statistics
Statistics.event_stats(event_type, events)
# chart if enabled
if chart_path is not None:
Statistics.plot_times(event_type, events, chart_path)
@staticmethod
def stats_per_package(event_type: str, events: list[Event], chart_path: Path | None) -> None:
"""
calculate overall statistics
Args:
event_type(str): event type
events(list[Event]): list of events
chart_path(Path): path to save plot if any
"""
key: Callable[[Event], str] = lambda event: event.object_id
by_object_id = {
object_id: len(list(related))
for object_id, related in itertools.groupby(sorted(events, key=key), key=key)
}
# distribution per package
PackageStatsPrinter(by_object_id)(verbose=True)
EventStatsPrinter(f"{event_type} frequency", list(by_object_id.values()))(verbose=True)
# event statistics
Statistics.event_stats(event_type, events)
# chart if enabled
if chart_path is not None:
Statistics.plot_packages(event_type, by_object_id, chart_path)

View File

@ -50,10 +50,10 @@ class Update(Handler):
packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs,
check_files=args.check_files)
if args.dry_run: # some check specific actions
if args.changes: # generate changes if requested
application.changes(packages)
if args.changes: # generate changes if requested
application.changes(packages)
if args.dry_run: # exit from application if no build requested
Update.check_if_empty(args.exit_code, not packages) # status code check
return

View File

@ -25,7 +25,6 @@ from typing import Any
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
from ahriman.core.configuration.schema import CONFIGURATION_SCHEMA, ConfigurationSchema
from ahriman.core.configuration.validator import Validator
from ahriman.core.exceptions import ExtensionError
from ahriman.core.formatters import ValidationPrinter
from ahriman.core.triggers import TriggerLoader
@ -51,6 +50,8 @@ class Validate(Handler):
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
from ahriman.core.configuration.validator import Validator
schema = Validate.schema(repository_id, configuration)
validator = Validator(configuration=configuration, schema=schema)

View File

@ -59,15 +59,13 @@ class Lock(LazyLogging):
>>> configuration = Configuration()
>>> try:
>>> with Lock(args, RepositoryId("x86_64", "aur-clone"), configuration):
>>> perform_actions()
>>> do_something()
>>> except Exception as exception:
>>> handle_exceptions(exception)
"""
def __init__(self, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
default constructor
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
@ -99,7 +97,7 @@ class Lock(LazyLogging):
fd(int): file descriptor:
Returns:
bool: True in case if file is locked and False otherwise
bool: ``True`` in case if file is locked and ``False`` otherwise
"""
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@ -121,7 +119,7 @@ class Lock(LazyLogging):
watch until lock disappear
Returns:
bool: True in case if file is locked and False otherwise
bool: ``True`` in case if file is locked and ``False`` otherwise
"""
# there are reasons why we are not using inotify here. First of all, if we would use it, it would bring to
# race conditions because multiple processes will be notified at the same time. Secondly, it is good library,
@ -225,7 +223,7 @@ class Lock(LazyLogging):
exc_tb(TracebackType): exception traceback if any
Returns:
Literal[False]: always False (do not suppress any exception)
Literal[False]: always ``False`` (do not suppress any exception)
"""
self.clear()
status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed

View File

@ -33,9 +33,7 @@ class _Context:
"""
def __init__(self) -> None:
"""
default constructor. Must not be used directly
"""
""""""
self._content: dict[str, Any] = {}
def get(self, key: ContextKey[T] | type[T]) -> T:

View File

@ -49,8 +49,6 @@ class Pacman(LazyLogging):
def __init__(self, repository_id: RepositoryId, configuration: Configuration, *,
refresh_database: PacmanSynchronization) -> None:
"""
default constructor
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance

View File

@ -45,8 +45,6 @@ class PacmanDatabase(SyncHttpClient):
def __init__(self, database: DB, configuration: Configuration) -> None:
"""
default constructor
Args:
database(DB): pyalpm database object
configuration(Configuration): configuration instance
@ -102,7 +100,7 @@ class PacmanDatabase(SyncHttpClient):
local_path(Path): path to locally stored file
Returns:
bool: True in case if remote file is newer than local file
bool: ``True`` in case if remote file is newer than local file
Raises:
PacmanError: in case if no last-modified header was found

View File

@ -0,0 +1,316 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import itertools
import re
import shlex
from collections.abc import Generator
from enum import StrEnum
from typing import IO
from ahriman.core.exceptions import PkgbuildParserError
from ahriman.models.pkgbuild_patch import PkgbuildPatch
class PkgbuildToken(StrEnum):
"""
well-known tokens dictionary
Attributes:
ArrayEnds(PkgbuildToken): (class attribute) array ends token
ArrayStarts(PkgbuildToken): (class attribute) array starts token
Comma(PkgbuildToken): (class attribute) comma token
Comment(PkgbuildToken): (class attribute) comment token
FunctionDeclaration(PkgbuildToken): (class attribute) function declaration token
FunctionEnds(PkgbuildToken): (class attribute) function ends token
FunctionStarts(PkgbuildToken): (class attribute) function starts token
"""
ArrayStarts = "("
ArrayEnds = ")"
Comma = ","
Comment = "#"
FunctionDeclaration = "()"
FunctionStarts = "{"
FunctionEnds = "}"
class PkgbuildParser(shlex.shlex):
"""
simple pkgbuild reader implementation in pure python, because others suck.
What is it:
#. Simple PKGBUILD parser written in python.
#. No shell execution, so it is free from random shell attacks.
#. Able to parse simple constructions (assignments, comments, functions, arrays).
What it is not:
#. Fully functional shell parser.
#. Shell executor.
#. No parameter expansion.
For more details what does it support, please, consult with the test cases.
Examples:
This class is heavily based on :mod:`shlex` parser, but instead of strings operates with the
:class:`ahriman.models.pkgbuild_patch.PkgbuildPatch` objects. The main way to use it is to call :func:`parse()`
function and collect parsed objects, e.g.::
>>> parser = PkgbuildParser(StringIO("input string"))
>>> for patch in parser.parse():
>>> print(f"{patch.key} = {patch.value}")
It doesn't store the state of the fields (but operates with the :mod:`shlex` parser state), so no shell
post-processing is performed (e.g. variable substitution).
"""
_ARRAY_ASSIGNMENT = re.compile(r"^(?P<key>\w+)=$")
# in addition to usual assignment, functions can have dash
_FUNCTION_DECLARATION = re.compile(r"^(?P<key>[\w-]+)$")
_STRING_ASSIGNMENT = re.compile(r"^(?P<key>\w+)=(?P<value>.+)$")
def __init__(self, stream: IO[str]) -> None:
"""
Args:
stream(IO[str]): input stream containing PKGBUILD content
"""
shlex.shlex.__init__(self, stream, posix=True, punctuation_chars=True)
self._io = stream # direct access without type casting
# ignore substitution and extend bash symbols
self.wordchars += "${}#:+-@!"
# in case of default behaviour, it will ignore, for example, segment part of url outside of quotes
self.commenters = ""
@staticmethod
def _expand_array(array: list[str]) -> list[str]:
"""
bash array expansion simulator. It takes raw array and tries to expand constructions like
``(first prefix-{mid1,mid2}-suffix last)`` into ``(first, prefix-mid1-suffix prefix-mid2-suffix last)``
Args:
array(list[str]): input array
Returns:
list[str]: either source array or expanded array if possible
Raises:
PkgbuildParserError: if there are errors in parser
"""
# we are using comma as marker for expansion (if any)
if PkgbuildToken.Comma not in array:
return array
# again sanity check, for expansion there are at least 3 elements (first, last and comma)
if len(array) < 3:
return array
result = []
buffer, prefix = [], None
for index, (first, second) in enumerate(itertools.pairwise(array)):
match (first, second):
# in this case we check if expansion should be started
# this condition matches "prefix{first", ","
case (_, PkgbuildToken.Comma) if PkgbuildToken.FunctionStarts in first:
prefix, part = first.rsplit(PkgbuildToken.FunctionStarts, maxsplit=1)
buffer.append(f"{prefix}{part}")
# the last element case, it matches either ",", "last}" or ",", "last}suffix"
# in case if there is suffix, it must be appended to all list elements
case (PkgbuildToken.Comma, _) if prefix is not None and PkgbuildToken.FunctionEnds in second:
part, suffix = second.rsplit(PkgbuildToken.FunctionEnds, maxsplit=1)
buffer.append(f"{prefix}{part}")
result.extend([f"{part}{suffix}" for part in buffer])
# reset state
buffer, prefix = [], None
# we have already prefix string, so we are in progress of expansion
# we always operate the last element, so this matches ",", "next"
case (PkgbuildToken.Comma, _) if prefix is not None:
buffer.append(f"{prefix}{second}")
# exactly first element of the list
case (_, _) if prefix is None and index == 0:
result.append(first)
# any next normal element
case (_, _) if prefix is None:
result.append(second)
# small sanity check
if prefix is not None:
raise PkgbuildParserError("error in array expansion", array)
return result
def _is_quoted(self) -> bool:
"""
check if the last element was quoted. ``shlex.shlex`` parser doesn't provide information about was the token
quoted or not, thus there is no difference between "'#'" (diez in quotes) and "#" (diez without quotes). This
method simply rolls back to the last non-space character and check if it is a quotation mark
Returns:
bool: ``True`` if the previous element of the stream is a quote and ``False`` otherwise
"""
current_position = self._io.tell()
last_char = None
for index in range(current_position - 1, -1, -1):
self._io.seek(index)
last_char = self._io.read(1)
if not last_char.isspace():
break
self._io.seek(current_position) # reset position of the stream
return last_char is not None and last_char in self.quotes
def _parse_array(self) -> list[str]:
"""
parse array from the PKGBUILD. This method will extract tokens from parser until it matches closing array,
modifying source parser state
Returns:
list[str]: extracted arrays elements
Raises:
PkgbuildParserError: if array is not closed
"""
def extract() -> Generator[str, None, None]:
while token := self.get_token():
match token:
case _ if self._is_quoted():
pass
case PkgbuildToken.ArrayEnds:
break
case PkgbuildToken.Comment:
self.instream.readline()
continue
yield token
if token != PkgbuildToken.ArrayEnds:
raise PkgbuildParserError("no closing array bracket found")
return self._expand_array(list(extract()))
def _parse_function(self) -> str:
"""
parse function from the PKGBUILD. This method will extract tokens from parser until it matches closing function,
modifying source parser state. Instead of trying to combine tokens together, it uses positions of the file
and reads content again in this range
Returns:
str: function body
Raises:
PkgbuildParserError: if function body wasn't found or parser input stream doesn't support position reading
"""
# find start and end positions
start_position = end_position = -1
counter = 0 # simple processing of the inner "{" and "}"
while token := self.get_token():
match token:
case _ if self._is_quoted():
continue
case PkgbuildToken.FunctionStarts:
if counter == 0:
start_position = self._io.tell() - 1
counter += 1
case PkgbuildToken.FunctionEnds:
end_position = self._io.tell()
counter -= 1
if counter == 0:
break
if not 0 < start_position < end_position:
raise PkgbuildParserError("function body wasn't found")
# read the specified interval from source stream
self._io.seek(start_position - 1) # start from the previous symbol
content = self._io.read(end_position - start_position)
# special case of the end of file
if self.state == self.eof: # type: ignore[attr-defined]
content += self._io.read(1)
# reset position (because the last position was before the next token starts)
self._io.seek(end_position)
return content
def _parse_token(self, token: str) -> Generator[PkgbuildPatch, None, None]:
"""
parse single token to the PKGBUILD field
Args:
token(str): current token
Yields:
PkgbuildPatch: extracted a PKGBUILD node
"""
# simple assignment rule
if m := self._STRING_ASSIGNMENT.match(token):
key = m.group("key")
value = m.group("value")
yield PkgbuildPatch(key, value)
return
if token == PkgbuildToken.Comment:
self.instream.readline()
return
match self.get_token():
# array processing. Arrays will be sent as "key=", "(", values, ")"
case PkgbuildToken.ArrayStarts if m := self._ARRAY_ASSIGNMENT.match(token):
key = m.group("key")
value = self._parse_array()
yield PkgbuildPatch(key, value)
# functions processing. Function will be sent as "name", "()", "{", body, "}"
case PkgbuildToken.FunctionDeclaration if self._FUNCTION_DECLARATION.match(token):
key = f"{token}{PkgbuildToken.FunctionDeclaration}"
value = self._parse_function()
yield PkgbuildPatch(key, value) # this is not mistake, assign to token without ()
# special function case, where "(" and ")" are separated tokens, e.g. "pkgver ( )"
case PkgbuildToken.ArrayStarts if self._FUNCTION_DECLARATION.match(token):
next_token = self.get_token()
if next_token == PkgbuildToken.ArrayEnds: # replace closing bracket with "()"
next_token = PkgbuildToken.FunctionDeclaration
self.push_token(next_token) # type: ignore[arg-type]
yield from self._parse_token(token)
# some random token received without continuation, lets guess it is empty assignment (i.e. key=)
case other if other is not None:
yield from self._parse_token(other)
def parse(self) -> Generator[PkgbuildPatch, None, None]:
"""
parse source stream and yield parsed entries
Yields:
PkgbuildPatch: extracted a PKGBUILD node
"""
for token in self:
yield from self._parse_token(token)

View File

@ -38,8 +38,6 @@ class Repo(LazyLogging):
def __init__(self, name: str, paths: RepositoryPaths, sign_args: list[str]) -> None:
"""
default constructor
Args:
name(str): repository name
paths(RepositoryPaths): repository paths instance
@ -68,7 +66,7 @@ class Repo(LazyLogging):
path(Path): path to archive to add
"""
check_output(
"repo-add", *self.sign_args, "-R", str(self.repo_path), str(path),
"repo-add", *self.sign_args, "--remove", str(self.repo_path), str(path),
exception=BuildError.from_process(path.name),
cwd=self.paths.repository,
logger=self.logger,
@ -78,8 +76,13 @@ class Repo(LazyLogging):
"""
create empty repository database. It just calls add with empty arguments
"""
check_output("repo-add", *self.sign_args, str(self.repo_path),
cwd=self.paths.repository, logger=self.logger, user=self.uid)
# since pacman-6.1.0 repo-add doesn't create empty database in case if no packages supplied
# this code creates empty files instead
if self.repo_path.exists():
return # database is already created, skip this part
self.repo_path.touch(exist_ok=True)
(self.paths.repository / f"{self.name}.db").symlink_to(self.repo_path)
def remove(self, package: str, filename: Path) -> None:
"""

View File

@ -38,8 +38,6 @@ class Auth(LazyLogging):
def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance
provider(AuthSettings, optional): authorization type definition (Default value = AuthSettings.Disabled)
@ -96,7 +94,7 @@ class Auth(LazyLogging):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
del username, password
return True
@ -109,7 +107,7 @@ class Auth(LazyLogging):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
del username
return True
@ -124,7 +122,7 @@ class Auth(LazyLogging):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
del username, required, context
return True

View File

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

View File

@ -37,8 +37,6 @@ class Mapping(Auth):
def __init__(self, configuration: Configuration, database: SQLite,
provider: AuthSettings = AuthSettings.Configuration) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance
database(SQLite): database instance
@ -57,7 +55,7 @@ class Mapping(Auth):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
if password is None:
return False # invalid data supplied
@ -72,7 +70,7 @@ class Mapping(Auth):
username(str): username
Returns:
User | None: user descriptor if username is known and None otherwise
User | None: user descriptor if username is known and ``None`` otherwise
"""
return self.database.user_get(username)
@ -84,7 +82,7 @@ class Mapping(Auth):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
return username is not None and self.get_user(username) is not None
@ -98,7 +96,7 @@ class Mapping(Auth):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
user = self.get_user(username)
return user is not None and user.verify_access(required)

View File

@ -43,8 +43,6 @@ class OAuth(Mapping):
def __init__(self, configuration: Configuration, database: SQLite,
provider: AuthSettings = AuthSettings.OAuth) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance
database(SQLite): database instance

View File

@ -41,8 +41,6 @@ class PAM(Mapping):
def __init__(self, configuration: Configuration, database: SQLite,
provider: AuthSettings = AuthSettings.PAM) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance
database(SQLite): database instance
@ -79,7 +77,7 @@ class PAM(Mapping):
password(str | None): entered password
Returns:
bool: True in case if password matches, False otherwise
bool: ``True`` in case if password matches, ``False`` otherwise
"""
if password is None:
return False # invalid data supplied
@ -101,7 +99,7 @@ class PAM(Mapping):
username(str): username
Returns:
bool: True in case if user is known and can be authorized and False otherwise
bool: ``True`` in case if user is known and can be authorized and ``False`` otherwise
"""
try:
_ = getpwnam(username)
@ -119,7 +117,7 @@ class PAM(Mapping):
context(str | None): URI request path
Returns:
bool: True in case if user is allowed to do this request and False otherwise
bool: ``True`` in case if user is allowed to do this request and ``False`` otherwise
"""
# this method is basically inverted, first we check overrides in database and then fallback to the PAM logic
if (user := self.get_user(username)) is not None:

View File

@ -17,7 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from dataclasses import dataclass
from elftools.elf.dynamic import DynamicSection
from elftools.elf.elffile import ELFFile
from pathlib import Path
@ -33,7 +32,6 @@ from ahriman.models.package import Package
from ahriman.models.scan_paths import ScanPaths
@dataclass
class PackageArchive:
"""
helper for package archives
@ -45,10 +43,18 @@ class PackageArchive:
scan_paths(ScanPaths): scan paths holder
"""
root: Path
package: Package
pacman: Pacman
scan_paths: ScanPaths
def __init__(self, root: Path, package: Package, pacman: Pacman, scan_paths: ScanPaths) -> None:
"""
Args:
root(Path): path to root filesystem
package(Package): package descriptor
pacman(Pacman): alpm wrapper instance
scan_paths(ScanPaths): scan paths holder
"""
self.root = root
self.package = package
self.pacman = pacman
self.scan_paths = scan_paths
@staticmethod
def dynamic_needed(binary_path: Path) -> list[str]:
@ -109,7 +115,7 @@ class PackageArchive:
Returns:
FilesystemPackage: generated pacman package model with empty paths
"""
package_name, *_ = path.parent.name.rsplit("-", 2)
package_name, *_ = path.parent.name.rsplit("-", maxsplit=2)
try:
pacman_package = OfficialSyncdb.info(package_name, pacman=self.pacman)
return FilesystemPackage(
@ -163,7 +169,7 @@ class PackageArchive:
result: dict[Path, list[FilesystemPackage]] = {}
# sort items from children directories to root
for path, packages in reversed(sorted(source.items())):
for path, packages in sorted(source.items(), reverse=True):
# skip if this path belongs to the one of the base packages
if any(package.package_name in base_packages for package in packages):
continue
@ -228,7 +234,7 @@ class PackageArchive:
extract list of the installed packages and their content
Returns:
dict[str, FilesystemPackage]; map of package name to list of directories and files contained
dict[str, FilesystemPackage]: map of package name to list of directories and files contained
by this package
"""
result = {}

View File

@ -64,8 +64,9 @@ class Sources(LazyLogging):
return None # no previous reference found
instance = Sources()
instance.fetch_until(source_dir, commit_sha=last_commit_sha)
return instance.diff(source_dir, last_commit_sha)
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
return instance.diff(source_dir, last_commit_sha)
return None
@staticmethod
def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
@ -138,7 +139,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository
Returns:
bool: True in case if there is any remote and false otherwise
bool: ``True`` in case if there is any remote and false otherwise
"""
instance = Sources()
remotes = check_output(*instance.git(), "remote", cwd=sources_dir, logger=instance.logger)
@ -261,7 +262,7 @@ class Sources(LazyLogging):
commit_author(tuple[str, str] | None, optional): optional commit author if any (Default value = None)
Returns:
bool: True in case if changes have been committed and False otherwise
bool: ``True`` in case if changes have been committed and ``False`` otherwise
"""
if not self.has_changes(sources_dir):
return False # nothing to commit
@ -298,7 +299,8 @@ class Sources(LazyLogging):
args.append(sha)
return check_output(*self.git(), "diff", *args, cwd=sources_dir, logger=self.logger)
def fetch_until(self, sources_dir: Path, *, branch: str | None = None, commit_sha: str | None = None) -> None:
def fetch_until(self, sources_dir: Path, *, branch: str | None = None, commit_sha: str | None = None,
max_depth: int = 10) -> str | None:
"""
fetch repository until commit sha
@ -307,11 +309,16 @@ class Sources(LazyLogging):
branch(str | None, optional): use specified branch (Default value = None)
commit_sha(str | None, optional): commit hash to fetch. If none set, only one will be fetched
(Default value = None)
max_depth(int, optional): maximal amount of commits to fetch if ``commit_sha`` is set (Default value = 10)
Returns:
str | None: fetched ``commit_sha`` (if set) and ``None`` in case if commit wasn't found or
``commit_sha`` is not set
"""
commit_sha = commit_sha or "HEAD" # if none set we just fetch the last commit
commits_count = 1
while commit_sha is not None:
while commits_count <= max_depth:
command = self.git() + ["fetch", "--quiet", "--depth", str(commits_count)]
if branch is not None:
command += ["origin", branch]
@ -320,10 +327,13 @@ class Sources(LazyLogging):
try:
# check if there is an object in current git directory
check_output(*self.git(), "cat-file", "-e", commit_sha, cwd=sources_dir, logger=self.logger)
commit_sha = None # reset search
return commit_sha # found the required commit
except CalledProcessError:
commits_count += 1 # increase depth
# no commits found at the requested depth
return None
def git(self, gitconfig: dict[str, str] | None = None) -> list[str]:
"""
git command prefix
@ -351,7 +361,7 @@ class Sources(LazyLogging):
sources_dir(Path): local path to git repository
Returns:
bool: True if there are uncommitted changes and False otherwise
bool: ``True`` if there are uncommitted changes and ``False`` otherwise
"""
# there is --exit-code argument to diff, however, there might be other process errors
changes = check_output(*self.git(), "diff", "--cached", "--name-only", cwd=sources_dir, logger=self.logger)

View File

@ -17,13 +17,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from collections.abc import Generator
from pathlib import Path
from ahriman.core.build_tools.sources import Sources
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildError
from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output
from ahriman.core.utils import check_output, package_like
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_paths import RepositoryPaths
@ -48,8 +49,6 @@ class Task(LazyLogging):
def __init__(self, package: Package, configuration: Configuration, architecture: str,
paths: RepositoryPaths) -> None:
"""
default constructor
Args:
package(Package): package definitions
configuration(Configuration): configuration instance
@ -67,12 +66,43 @@ class Task(LazyLogging):
self.makepkg_flags = configuration.getlist("build", "makepkg_flags", fallback=[])
self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags", fallback=[])
def build(self, sources_dir: Path, **kwargs: str | None) -> list[Path]:
def _package_archives(self, sources_dir: Path, source_files: list[Path]) -> list[Path]:
"""
extract package archives from the directory
Args:
sources_dir(Path): path to where sources are
source_files(list[Path]): list of files which were initially in the directory
Returns:
list[Path]: list of file paths which looks like freshly generated archives
"""
def files() -> Generator[Path, None, None]:
for filepath in sources_dir.iterdir():
if filepath in source_files:
continue # skip files which were already there
if filepath.suffix == ".log":
continue # skip log files
if not package_like(filepath):
continue # path doesn't look like a package
yield filepath
# debug packages are always formed as package.base-debug
# see /usr/share/makepkg/util/pkgbuild.sh for more details
debug_package_prefix = f"{self.package.base}-debug-"
return [
package
for package in files()
if self.include_debug_packages or not package.name.startswith(debug_package_prefix)
]
def build(self, sources_dir: Path, *, dry_run: bool = False, **kwargs: str | None) -> list[Path]:
"""
run package build
Args:
sources_dir(Path): path to where sources are
dry_run(bool, optional): do not perform build itself (Default value = False)
**kwargs(str | None): environment variables to be passed to build processes
Returns:
@ -82,6 +112,8 @@ class Task(LazyLogging):
command.extend(self.archbuild_flags)
command.extend(["--"] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags)
if dry_run:
command.extend(["--nobuild"])
self.logger.info("using %s for %s", command, self.package.base)
environment: dict[str, str] = {
@ -91,6 +123,7 @@ class Task(LazyLogging):
}
self.logger.info("using environment variables %s", environment)
source_files = list(sources_dir.iterdir())
check_output(
*command,
exception=BuildError.from_process(self.package.base),
@ -100,20 +133,7 @@ class Task(LazyLogging):
environment=environment,
)
package_list_command = ["makepkg", "--packagelist"]
if not self.include_debug_packages:
package_list_command.append("OPTIONS=(!debug)") # disable debug flag manually
packages = check_output(
*package_list_command,
exception=BuildError.from_process(self.package.base),
cwd=sources_dir,
logger=self.logger,
environment=environment,
).splitlines()
# some dirty magic here
# the filter is applied in order to make sure that result will only contain packages which were actually built
# e.g. in some cases packagelist command produces debug packages which were not actually built
return list(filter(lambda path: path.is_file(), map(Path, packages)))
return self._package_archives(sources_dir, source_files)
def init(self, sources_dir: Path, patches: list[PkgbuildPatch], local_version: str | None) -> str | None:
"""

View File

@ -46,7 +46,8 @@ class Configuration(configparser.RawConfigParser):
Examples:
Configuration class provides additional method in order to handle application configuration. Since this class is
derived from built-in :class:`configparser.RawConfigParser` class, the same flow is applicable here.
Nevertheless, it is recommended to use :func:`from_path` class method which also calls initialization methods::
Nevertheless, it is recommended to use :func:`from_path()` class method which also calls initialization
methods::
>>> from pathlib import Path
>>>
@ -57,7 +58,7 @@ class Configuration(configparser.RawConfigParser):
The configuration instance loaded in this way will contain only sections which are defined for the specified
architecture according to the merge rules. Moreover, the architecture names will be removed from section names.
In order to get current settings, the :func:`check_loaded` method can be used. This method will raise an
In order to get current settings, the :func:`check_loaded()` method can be used. This method will raise an
:exc:`ahriman.core.exceptions.InitializeError` in case if configuration was not yet loaded::
>>> path, repository_id = configuration.check_loaded()
@ -70,8 +71,6 @@ class Configuration(configparser.RawConfigParser):
def __init__(self, allow_no_value: bool = False) -> None:
"""
default constructor. In the most cases must not be called directly
Args:
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behaviour. In case if it is set
to ``True``, the keys without values will be allowed (Default value = False)
@ -344,7 +343,8 @@ class Configuration(configparser.RawConfigParser):
def set_option(self, section: str, option: str, value: str) -> None:
"""
set option. Unlike default :func:`configparser.RawConfigParser.set` it also creates section if it does not exist
set option. Unlike default :func:`configparser.RawConfigParser.set()` it also creates section if
it does not exist
Args:
section(str): section name

View File

@ -169,14 +169,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"build": {
"type": "dict",
"schema": {
"allowed_scan_paths": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
},
},
"archbuild_flags": {
"type": "list",
"coerce": "list",
@ -185,14 +177,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"empty": False,
},
},
"blacklisted_scan_paths": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
},
},
"build_command": {
"type": "string",
"required": True,
@ -226,6 +210,14 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"empty": False,
},
},
"scan_paths": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"triggers": {
"type": "list",
"coerce": "list",

View File

@ -19,21 +19,84 @@
#
import configparser
import os
import sys
from collections.abc import Mapping, MutableMapping
from collections.abc import Generator, Mapping, MutableMapping
from string import Template
from ahriman.core.configuration.shell_template import ShellTemplate
class ShellInterpolator(configparser.Interpolation):
"""
custom string interpolator, because we cannot use defaults argument due to config validation
"""
DATA_LINK_ESCAPE = "\x10"
@staticmethod
def _extract_variables(parser: MutableMapping[str, Mapping[str, str]], value: str,
defaults: Mapping[str, str]) -> Generator[tuple[str, str], None, None]:
"""
extract keys and values (if available) from the configuration. In case if a key is not available, it will be
silently skipped from the result
Args:
parser(MutableMapping[str, Mapping[str, str]]): option parser
value(str): source (not-converted) value
defaults(Mapping[str, str]): default values
Yields:
tuple[str, str]: variable name used for substitution and its value
"""
def identifiers() -> Generator[tuple[str | None, str], None, None]:
# extract all found identifiers and parse them
for identifier in ShellTemplate(value).get_identifiers():
match identifier.split(":"):
case [lookup_option]: # single option from the same section
yield None, lookup_option
case [lookup_section, lookup_option]: # reference to another section
yield lookup_section, lookup_option
for section, option in identifiers():
# key to be substituted
key = f"{section}:{option}" if section else option
if section is not None: # foreign section case
if section not in parser:
continue # section was not found, silently skip it
values = parser[section]
else: # same section
values = defaults
if (raw := values.get(option)) is not None:
yield key, raw
@staticmethod
def environment() -> dict[str, str]:
"""
extract environment variables
Returns:
dict[str, str]: environment variables and some custom variables
"""
return os.environ | {
"prefix": sys.prefix,
}
def before_get(self, parser: MutableMapping[str, Mapping[str, str]], section: str, option: str, value: str,
defaults: Mapping[str, str]) -> str:
"""
interpolate option value
Notes:
This method is using :class:`string.Template` class in order to render both cross-references and
environment variables, because it seems that it is the most legit way to handle it. In addition,
we are using shell-like variables in some cases (see :attr:`alpm.mirror` option), thus we would like
to keep them alive.
First this method resolves substitution from the configuration and then renders environment variables
Args:
parser(MutableMapping[str, Mapping[str, str]]): option parser
section(str): section name
@ -44,8 +107,15 @@ class ShellInterpolator(configparser.Interpolation):
Returns:
str: substituted value
"""
# At the moment it seems that it is the most legit way to handle environment variables
# Template behaviour is literally the same as shell
# In addition, we are using shell-like variables in some cases (see :attr:`alpm.mirror` option),
# thus we would like to keep them alive
return Template(value).safe_substitute(os.environ)
# because any substitution effectively replace escaped $ ($$) in result, we have to escape it manually
escaped = value.replace("$$", self.DATA_LINK_ESCAPE)
# resolve internal references
variables = dict(self._extract_variables(parser, value, defaults))
internal = ShellTemplate(escaped).safe_substitute(variables)
# resolve enriched environment variables by using default Template class
environment = Template(internal).safe_substitute(self.environment())
# replace escaped values back
return environment.replace(self.DATA_LINK_ESCAPE, "$")

View File

@ -0,0 +1,158 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import fnmatch
import re
from collections.abc import Generator, Mapping
from string import Template
class ShellTemplate(Template):
"""
extension to the default :class:`Template` class, which also adds additional tokens to braced regex and enables
bash expansion
Attributes:
braceidpattern(str): regular expression to match every character except for closing bracket
"""
braceidpattern = r"(?a:[_a-z0-9][^}]*)"
_REMOVE_BACK = re.compile(r"^(?P<key>\w+)%(?P<pattern>.+)$")
_REMOVE_FRONT = re.compile(r"^(?P<key>\w+)#(?P<pattern>.+)$")
_REPLACE = re.compile(r"^(?P<key>\w+)/(?P<pattern>.+)/(?P<replacement>.+)$")
@staticmethod
def _remove_back(source: str, pattern: str, *, greedy: bool) -> str:
"""
resolve "${var%(%)pattern}" constructions
Args:
source(str): source string to match the pattern inside
pattern(str): shell expression to match
greedy(bool): match as much as possible or not
Returns:
str: result after removal ``pattern`` from the end of the string
"""
regex = fnmatch.translate(pattern)
compiled = re.compile(regex)
result = source
start_pos = 0
while m := compiled.search(source, start_pos):
result = source[:m.start()]
start_pos += m.start() + 1
if greedy:
break
return result
@staticmethod
def _remove_front(source: str, pattern: str, *, greedy: bool) -> str:
"""
resolve "${var#(#)pattern}" constructions
Args:
source(str): source string to match the pattern inside
pattern(str): shell expression to match
greedy(bool): match as much as possible or not
Returns:
str: result after removal ``pattern`` from the start of the string
"""
regex = fnmatch.translate(pattern)[:-2] # remove \Z at the end of the regex
if not greedy:
regex = regex.replace("*", "*?")
compiled = re.compile(regex)
m = compiled.match(source)
if m is None:
return source
return source[m.end():]
@staticmethod
def _replace(source: str, pattern: str, replacement: str, *, greedy: bool) -> str:
"""
resolve "${var/(/)pattern/replacement}" constructions
Args:
source(str): source string to match the pattern inside
pattern(str): shell expression to match
replacement(str): new substring
greedy(bool): replace as much as possible or not
Returns:
str: result after replacing ``pattern`` by ``replacement``
"""
match pattern:
case from_back if from_back.startswith("%"):
removed = ShellTemplate._remove_back(source, from_back[1:], greedy=False)
return removed if removed == source else removed + replacement
case from_front if from_front.startswith("#"):
removed = ShellTemplate._remove_front(source, from_front[1:], greedy=False)
return removed if removed == source else replacement + removed
case regular:
regex = fnmatch.translate(regular)[:-2] # remove \Z at the end of the regex
compiled = re.compile(regex)
return compiled.sub(replacement, source, count=not greedy)
def shell_substitute(self, mapping: Mapping[str, str], /, **kwargs: str) -> str:
"""
this method behaves the same as :func:`safe_substitute`, however also expands bash string operations
Args:
mapping(Mapping[str, str]): key-value dictionary of variables
**kwargs(str): key-value dictionary of variables passed as kwargs
Returns:
str: string with replaced values
"""
substitutions = (
(self._REMOVE_BACK, self._remove_back, "%"),
(self._REMOVE_FRONT, self._remove_front, "#"),
(self._REPLACE, self._replace, "/"),
)
def generator(variables: dict[str, str]) -> Generator[tuple[str, str], None, None]:
for identifier in self.get_identifiers():
for regex, function, greediness in substitutions:
if m := regex.match(identifier):
source = variables.get(m.group("key"))
if source is None:
continue
# replace pattern with non-greedy
pattern = m.group("pattern").removeprefix(greediness)
greedy = m.group("pattern").startswith(greediness)
# gather all additional args
args = {key: value for key, value in m.groupdict().items() if key not in ("key", "pattern")}
yield identifier, function(source, pattern, **args, greedy=greedy)
break
kwargs.update(mapping)
substituted = dict(generator(kwargs))
return self.safe_substitute(kwargs | substituted)

View File

@ -35,13 +35,12 @@ class Validator(RootValidator):
configuration(Configuration): configuration instance
"""
types_mapping = RootValidator.types_mapping.copy()
types_mapping["path"] = TypeDefinition("path", (Path,), ())
types_mapping = RootValidator.types_mapping | {
"path": TypeDefinition("path", (Path,), ()),
}
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance used for extraction
*args(Any): positional arguments to be passed to base validator
@ -149,7 +148,7 @@ class Validator(RootValidator):
check if paths exists
Args:
constraint(bool): True in case if path must exist and False otherwise
constraint(bool): ``True`` in case if path must exist and ``False`` otherwise
field(str): field name to be checked
value(Path): value to be checked

View File

@ -41,8 +41,6 @@ class Migrations(LazyLogging):
def __init__(self, connection: Connection, configuration: Configuration) -> None:
"""
default constructor
Args:
connection(Connection): database connection
configuration(Configuration): configuration instance

View File

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

View File

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

View File

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

View File

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

View File

@ -50,9 +50,11 @@ class LogsOperations(Operations):
(row["created"], row["record"])
for row in connection.execute(
"""
select created, record from logs
where package_base = :package_base and repository = :repository
order by created limit :limit offset :offset
select created, record from (
select * from logs
where package_base = :package_base and repository = :repository
order by created desc limit :limit offset :offset
) order by created asc
""",
{
"package_base": package_base,

View File

@ -41,16 +41,25 @@ class Operations(LazyLogging):
def __init__(self, path: Path, repository_id: RepositoryId, repository_paths: RepositoryPaths) -> None:
"""
default constructor
Args:
path(Path): path to the database file
repository_id(RepositoryId): repository unique identifier
repository_paths(RepositoryPaths): repository paths
"""
self.path = path
self._repository_id = repository_id
self._repository_paths = repository_paths
@property
def logger_name(self) -> str:
"""
extract logger name for the class
Returns:
str: logger name override
"""
return "sql"
@staticmethod
def factory(cursor: sqlite3.Cursor, row: tuple[Any, ...]) -> dict[str, Any]:
"""
@ -74,12 +83,13 @@ class Operations(LazyLogging):
Args:
query(Callable[[Connection], T]): function to be called with connection
commit(bool, optional): if True commit() will be called on success (Default value = False)
commit(bool, optional): if ``True`` commit() will be called on success (Default value = False)
Returns:
T: result of the ``query`` call
"""
with sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES) as connection:
connection.set_trace_callback(self.logger.debug)
connection.row_factory = self.factory
result = query(connection)
if commit:

View File

@ -26,7 +26,7 @@ from typing import Self
from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations import Migrations
from ahriman.core.database.operations import AuthOperations, BuildOperations, ChangesOperations, \
DependenciesOperations, LogsOperations, PackageOperations, PatchOperations
DependenciesOperations, EventOperations, LogsOperations, PackageOperations, PatchOperations
from ahriman.models.repository_id import RepositoryId
@ -36,6 +36,7 @@ class SQLite(
BuildOperations,
ChangesOperations,
DependenciesOperations,
EventOperations,
LogsOperations,
PackageOperations,
PatchOperations):

View File

@ -59,8 +59,6 @@ class DistributedSystem(Trigger, WebClient):
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
default constructor
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance

View File

@ -34,8 +34,6 @@ class WorkerTrigger(DistributedSystem):
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:
"""
default constructor
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance

View File

@ -36,8 +36,6 @@ class WorkersCache(LazyLogging):
def __init__(self, configuration: Configuration) -> None:
"""
default constructor
Args:
configuration(Configuration): configuration instance
"""

View File

@ -33,8 +33,6 @@ class BuildError(RuntimeError):
def __init__(self, package_base: str, stderr: str | None = None) -> None:
"""
default constructor
Args:
package_base(str): package base raised exception
stderr(str | None, optional): stderr of the process if available (Default value = None)
@ -67,8 +65,6 @@ class CalledProcessError(subprocess.CalledProcessError):
def __init__(self, status_code: int, process: list[str], stderr: str) -> None:
"""
default constructor
Args:
status_code(int): process return code
process(list[str]): process argument list
@ -94,9 +90,7 @@ class DuplicateRunError(RuntimeError):
"""
def __init__(self) -> None:
"""
default constructor
"""
""""""
RuntimeError.__init__(
self, "Another application instance is run. This error can be suppressed by using --force flag.")
@ -119,9 +113,7 @@ class GitRemoteError(RuntimeError):
"""
def __init__(self) -> None:
"""
default constructor
"""
""""""
RuntimeError.__init__(self, "Git remote failed")
@ -132,8 +124,6 @@ class InitializeError(RuntimeError):
def __init__(self, details: str) -> None:
"""
default constructor
Args:
details(str): details of the exception
"""
@ -147,8 +137,6 @@ class MigrationError(RuntimeError):
def __init__(self, details: str) -> None:
"""
default constructor
Args:
details(str): error details
"""
@ -162,8 +150,6 @@ class MissingArchitectureError(ValueError):
def __init__(self, command: str) -> None:
"""
default constructor
Args:
command(str): command name which throws exception
"""
@ -177,8 +163,6 @@ class MultipleArchitecturesError(ValueError):
def __init__(self, command: str, repositories: list[RepositoryId] | None = None) -> None:
"""
default constructor
Args:
command(str): command name which throws exception
repositories(list[RepositoryId] | None, optional): found repository list (Default value = None)
@ -196,8 +180,6 @@ class OptionError(ValueError):
def __init__(self, value: Any) -> None:
"""
default constructor
Args:
value(Any): option value
"""
@ -211,8 +193,6 @@ class PackageInfoError(RuntimeError):
def __init__(self, details: Any) -> None:
"""
default constructor
Args:
details(Any): error details
"""
@ -226,14 +206,29 @@ class PacmanError(RuntimeError):
def __init__(self, details: Any) -> None:
"""
default constructor
Args:
details(Any): error details
"""
RuntimeError.__init__(self, f"Could not perform operation with pacman: `{details}`")
class PkgbuildParserError(ValueError):
"""
exception raises in case of PKGBUILD parser errors
"""
def __init__(self, reason: str, source: Any = None) -> None:
"""
Args:
reason(str): parser error reason
source(Any, optional): source line if available (Default value = None)
"""
message = f"Could not parse PKGBUILD: {reason}"
if source is not None:
message += f", source: `{source}`"
ValueError.__init__(self, message)
class PathError(ValueError):
"""
exception which will be raised on path which is not belong to root directory
@ -241,8 +236,6 @@ class PathError(ValueError):
def __init__(self, path: Path, root: Path) -> None:
"""
default constructor
Args:
path(Path): path which raised an exception
root(Path): repository root (i.e. ahriman home)
@ -257,8 +250,6 @@ class PasswordError(ValueError):
def __init__(self, details: Any) -> None:
"""
default constructor
Args:
details(Any); error details
"""
@ -272,8 +263,6 @@ class PartitionError(RuntimeError):
def __init__(self, count: int) -> None:
"""
default constructor
Args:
count(int): count of partitions
"""
@ -286,9 +275,7 @@ class PkgbuildGeneratorError(RuntimeError):
"""
def __init__(self) -> None:
"""
default constructor
"""
""""""
RuntimeError.__init__(self, "Could not generate package")
@ -298,9 +285,7 @@ class ReportError(RuntimeError):
"""
def __init__(self) -> None:
"""
default constructor
"""
""""""
RuntimeError.__init__(self, "Report failed")
@ -310,9 +295,7 @@ class SynchronizationError(RuntimeError):
"""
def __init__(self) -> None:
"""
default constructor
"""
""""""
RuntimeError.__init__(self, "Sync failed")
@ -323,8 +306,6 @@ class UnknownPackageError(ValueError):
def __init__(self, package_base: str) -> None:
"""
default constructor
Args:
package_base(str): package base name
"""
@ -338,8 +319,6 @@ class UnsafeRunError(RuntimeError):
def __init__(self, current_uid: int, root_uid: int) -> None:
"""
default constructor
Args:
current_uid(int): current user ID
root_uid(int): ID of the owner of root directory

View File

@ -22,7 +22,9 @@ from ahriman.core.formatters.build_printer import BuildPrinter
from ahriman.core.formatters.changes_printer import ChangesPrinter
from ahriman.core.formatters.configuration_paths_printer import ConfigurationPathsPrinter
from ahriman.core.formatters.configuration_printer import ConfigurationPrinter
from ahriman.core.formatters.event_stats_printer import EventStatsPrinter
from ahriman.core.formatters.package_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
from ahriman.core.formatters.patch_printer import PatchPrinter
from ahriman.core.formatters.printer import Printer
from ahriman.core.formatters.repository_printer import RepositoryPrinter

View File

@ -33,8 +33,6 @@ class AurPrinter(StringPrinter):
def __init__(self, package: AURPackage) -> None:
"""
default constructor
Args:
package(AURPackage): AUR package description
"""

View File

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

View File

@ -32,8 +32,6 @@ class ChangesPrinter(Printer):
def __init__(self, changes: Changes) -> None:
"""
default constructor
Args:
changes(Changes): package changes
"""
@ -57,7 +55,7 @@ class ChangesPrinter(Printer):
generate entry title from content
Returns:
str | None: content title if it can be generated and None otherwise
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.is_empty:
return None

View File

@ -33,8 +33,6 @@ class ConfigurationPathsPrinter(StringPrinter):
def __init__(self, root: Path, includes: list[Path]) -> None:
"""
default constructor
Args:
root(Path): path to root configuration file
includes(list[Path]): list of include files

View File

@ -42,8 +42,6 @@ class ConfigurationPrinter(StringPrinter):
def __init__(self, section: str, values: dict[str, str]) -> None:
"""
default constructor
Args:
section(str): section name
values(dict[str, str]): configuration values dictionary

View File

@ -0,0 +1,72 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import statistics
from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.core.utils import minmax
from ahriman.models.property import Property
class EventStatsPrinter(StringPrinter):
"""
print event statistics
Attributes:
events(list[float | int]): event values to build statistics
"""
def __init__(self, event_type: str, events: list[float | int]) -> None:
"""
Args:
event_type(str): event type used for this statistics
events(list[float | int]): event values to build statistics
"""
StringPrinter.__init__(self, event_type)
self.events = events
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
properties = [
Property("total", len(self.events)),
]
# time statistics
if self.events:
min_time, max_time = minmax(self.events)
mean = statistics.mean(self.events)
if len(self.events) > 1:
stdev = statistics.stdev(self.events)
average = f"{mean:.3f} ± {stdev:.3f}"
else:
average = f"{mean:.3f}"
properties.extend([
Property("min", min_time),
Property("average", average),
Property("max", max_time),
])
return properties

View File

@ -34,8 +34,6 @@ class PackagePrinter(StringPrinter):
def __init__(self, package: Package, status: BuildStatus) -> None:
"""
default constructor
Args:
package(Package): package description
status(BuildStatus): build status

View File

@ -0,0 +1,56 @@
#
# Copyright (c) 2021-2024 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core.formatters.string_printer import StringPrinter
from ahriman.models.property import Property
class PackageStatsPrinter(StringPrinter):
"""
print packages statistics
Attributes:
events(dict[str, int]): map of package to its event frequency
"""
MAX_COUNT = 10
def __init__(self, events: dict[str, int]) -> None:
"""
Args:
events(dict[str, int]): map of package to its event frequency
"""
StringPrinter.__init__(self, "The most frequent packages")
self.events = events
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
if not self.events:
return [] # no events found, discard any stats
properties = []
for object_id, count in sorted(self.events.items(), key=lambda pair: pair[1], reverse=True)[:self.MAX_COUNT]:
properties.append(Property(object_id, count))
return properties

View File

@ -32,8 +32,6 @@ class PatchPrinter(StringPrinter):
def __init__(self, package_base: str, patches: list[PkgbuildPatch]) -> None:
"""
default constructor
Args:
package_base(str): package base
patches(list[PkgbuildPatch]): PKGBUILD patch object

Some files were not shown because too many files have changed in this diff Show More