Compare commits

..

2 Commits

Author SHA1 Message Date
3b82b8d49f Release 2.14.2 2024-09-20 02:00:14 +03:00
bb404d43d2 Revert "fix: update Repo.init to the latest pacman release"
This reverts commit d30d512eb6.
2024-09-20 01:53:22 +03:00
183 changed files with 1247 additions and 4992 deletions

View File

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

View File

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

View File

@ -36,7 +36,6 @@ Again, the most checks can be performed by `tox` command, though some additional
Notes: Notes:
Very important note about this function Very important note about this function
Probably multi-line
Args: Args:
argument(str): an argument. This argument has argument(str): an argument. This argument has
@ -71,7 +70,6 @@ Again, the most checks can be performed by `tox` command, though some additional
Attributes: Attributes:
CLAZZ_ATTRIBUTE(int): (class attribute) a brand-new class attribute CLAZZ_ATTRIBUTE(int): (class attribute) a brand-new class attribute
instance_attribute(str): an instance attribute instance_attribute(str): an instance attribute
with the long description
Examples: Examples:
Very informative class usage example, e.g.:: Very informative class usage example, e.g.::
@ -94,7 +92,7 @@ Again, the most checks can be performed by `tox` command, though some additional
``` ```
* 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. * 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 `typing.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 `typinng.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. * `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: * Recommended order of function definitions in class:

View File

@ -31,42 +31,12 @@ RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build && \
echo "build ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/build" echo "build ALL=(ALL) NOPASSWD: ALL" > "/etc/sudoers.d/build"
COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package" COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
## install package dependencies ## install package dependencies
RUN pacman -Sy --noconfirm --asdeps \ ## darcs is not installed by reasons, because it requires a lot haskell packages which dramatically increase image size
devtools \ RUN pacman -Sy --noconfirm --asdeps devtools git pyalpm python-cerberus python-inflection python-passlib python-pyelftools python-requests python-srcinfo && \
git \ pacman -Sy --noconfirm --asdeps base-devel python-build python-flit python-installer python-wheel && \
pyalpm \ pacman -Sy --noconfirm --asdeps breezy git mercurial python-aiohttp python-boto3 python-cryptography python-jinja python-systemd rsync subversion && \
python-inflection \ runuser -u build -- install-aur-package python-aioauth-client python-webargs python-aiohttp-apispec-git python-aiohttp-cors \
python-passlib \ python-aiohttp-jinja2 python-aiohttp-session python-aiohttp-security python-requests-unixsocket2
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 ## 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" 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) [![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) [![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&sort=semver)](https://hub.docker.com/r/arcan1s/ahriman) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/arcan1s/ahriman?label=Docker%20image)](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) [![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) [![Documentation Status](https://readthedocs.org/projects/ahriman/badge/?version=latest)](https://ahriman.readthedocs.io)

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

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

View File

@ -28,14 +28,6 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.core.alpm.repo module
----------------------------- -----------------------------

View File

@ -4,14 +4,6 @@ ahriman.core.build\_tools package
Submodules 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 ahriman.core.build\_tools.sources module
---------------------------------------- ----------------------------------------

View File

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

View File

@ -36,14 +36,6 @@ ahriman.core.database.operations.dependencies\_operations module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.core.database.operations.logs\_operations module
-------------------------------------------------------- --------------------------------------------------------

View File

@ -44,14 +44,6 @@ ahriman.core.formatters.configuration\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.core.formatters.package\_printer module
----------------------------------------------- -----------------------------------------------
@ -60,14 +52,6 @@ ahriman.core.formatters.package\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.core.formatters.patch\_printer module
--------------------------------------------- ---------------------------------------------

View File

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

View File

@ -12,14 +12,6 @@ ahriman.core.repository.cleaner module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.core.repository.executor module
--------------------------------------- ---------------------------------------

View File

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

View File

@ -60,22 +60,6 @@ ahriman.web.schemas.error\_schema module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :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 ahriman.web.schemas.file\_schema module
--------------------------------------- ---------------------------------------

View File

@ -1,21 +0,0 @@
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,7 +7,6 @@ Subpackages
.. toctree:: .. toctree::
:maxdepth: 4 :maxdepth: 4
ahriman.web.views.v1.auditlog
ahriman.web.views.v1.distributed ahriman.web.views.v1.distributed
ahriman.web.views.v1.packages ahriman.web.views.v1.packages
ahriman.web.views.v1.service ahriman.web.views.v1.service

View File

@ -17,33 +17,14 @@ 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``. 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 the same configuration file, e.g.: Configuration allows string interpolation from environment variables, 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 .. code-block:: ini
[section] [section]
key = $SECRET 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 ``$$``. All those interpolations will be applied in succession and - expected to be - recursively, e.g. the following configuration: 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 ``$$``.
.. 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.: 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.:
@ -100,13 +81,14 @@ 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. 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. * ``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. * ``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. * ``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``. * ``include_debug_packages`` - distribute debug packages, boolean, optional, default ``yes``.
* ``makepkg_flags`` - additional flags passed to ``makepkg`` command, space separated list of strings, optional. * ``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. * ``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`` - 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. * ``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. * ``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.
@ -270,7 +252,6 @@ Section name must be either ``email`` (plus optional architecture name, e.g. ``e
* ``password`` - SMTP password to authenticate, string, optional. * ``password`` - SMTP password to authenticate, string, optional.
* ``port`` - SMTP port for sending emails, integer, required. * ``port`` - SMTP port for sending emails, integer, required.
* ``receivers`` - SMTP receiver addresses, space separated list of strings, 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. * ``sender`` - SMTP sender address, string, required.
* ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``. * ``ssl`` - SSL mode for SMTP connection, one of ``ssl``, ``starttls``, ``disabled``, optional, default ``disabled``.
* ``template`` - Jinja2 template name, string, required. * ``template`` - Jinja2 template name, string, required.
@ -286,8 +267,7 @@ 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. * ``type`` - type of the report, string, optional, must be set to ``html`` if exists.
* ``homepage`` - link to homepage, string, optional. * ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required. * ``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. * ``template`` - Jinja2 template name, string, required.
* ``templates`` - path to templates directories, space separated list of paths, required. * ``templates`` - path to templates directories, space separated list of paths, required.
@ -302,20 +282,6 @@ Section name must be either ``remote-call`` (plus optional architecture name, e.
* ``manual`` - update manually built packages, boolean, optional, default ``no``. * ``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``. * ``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 ``telegram`` type
^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
@ -326,7 +292,6 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g.
* ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required. * ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required.
* ``homepage`` - link to homepage, string, optional. * ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required. * ``link_path`` - prefix for HTML links, string, required.
* ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required. * ``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``. * ``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. * ``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``): Command to run worker nodes (considering there will be two workers, one is on ``8081`` port and other is on ``8082``):
.. code-block:: shell .. code-block:: ini
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 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 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

@ -265,7 +265,11 @@ TL;DR
How to update VCS packages How to update VCS packages
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^
Normally the service handles VCS packages correctly. The version is updated in clean chroot, no additional actions are required. Normally the service handles VCS packages correctly, however it requires additional dependencies:
.. code-block:: shell
pacman -S breezy darcs mercurial subversion
How to review changes before build How to review changes before build
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -375,7 +379,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 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 option ``build.scan_paths``, which supports regular expressions. Leaving ``build.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 options ``build.allowed_scan_paths`` and ``build.blacklisted_scan_paths``. Leaving ``build.allowed_scan_paths`` blank will effectively disable any check too.
How to install built packages How to install built packages
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -28,8 +28,8 @@ How to report by email
sender = me@example.com sender = me@example.com
user = me@example.com user = me@example.com
How to generate index page How to generate index page for S3
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#. #.
Install dependencies: Install dependencies:
@ -47,30 +47,10 @@ How to generate index page
target = html target = html
[html] [html]
path = ${repository:root}/repository/aur-clone/x86_64/index.html path = /var/lib/ahriman/repository/aur-clone/x86_64/index.html
link_path = http://example.com/aur-clone/x86_64 link_path = http://example.com/aur-clone/x86_64
Having this configuration, the generated ``index.html`` will be also automatically synced to remote services (e.g. S3). After these steps ``index.html`` file will be automatically synced to 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 How to post build report to telegram
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -1,15 +1,18 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=2.14.1 pkgver=2.14.2
pkgrel=1 pkgrel=1
pkgdesc="ArcH linux ReposItory MANager" pkgdesc="ArcH linux ReposItory MANager"
arch=('any') arch=('any')
url="https://github.com/arcan1s/ahriman" url="https://github.com/arcan1s/ahriman"
license=('GPL3') license=('GPL3')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-inflection' 'python-passlib' 'python-pyelftools' 'python-requests') depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-cerberus' 'python-inflection' 'python-passlib' 'python-pyelftools' 'python-requests' 'python-srcinfo')
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel') makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
optdepends=('python-aioauth-client: web server with OAuth2 authorization' optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'
'mercurial: -hg packages support'
'python-aioauth-client: web server with OAuth2 authorization'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-apispec>=3.0.0: web server' 'python-aiohttp-apispec>=3.0.0: web server'
'python-aiohttp-cors: web server' 'python-aiohttp-cors: web server'
@ -17,13 +20,12 @@ optdepends=('python-aioauth-client: web server with OAuth2 authorization'
'python-aiohttp-security: web server with authorization' 'python-aiohttp-security: web server with authorization'
'python-aiohttp-session: web server with authorization' 'python-aiohttp-session: web server with authorization'
'python-boto3: sync to s3' 'python-boto3: sync to s3'
'python-cerberus: configuration validator'
'python-cryptography: web server with authorization' 'python-cryptography: web server with authorization'
'python-matplotlib: usage statistics chart'
'python-requests-unixsocket2: client report to web server by unix socket' 'python-requests-unixsocket2: client report to web server by unix socket'
'python-jinja: html report generation' 'python-jinja: html report generation'
'python-systemd: journal support' 'python-systemd: journal support'
'rsync: sync by using rsync') 'rsync: sync by using rsync'
'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver.tar.gz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver.tar.gz"
'ahriman.sysusers' 'ahriman.sysusers'
'ahriman.tmpfiles') '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. ; Perform database migrations on the application start. Do not touch this option unless you know what are you doing.
;apply_migrations = yes ;apply_migrations = yes
; Path to the application SQLite database. ; Path to the application SQLite database.
database = ${repository:root}/ahriman.db database = /var/lib/ahriman/ahriman.db
[alpm] [alpm]
; Path to pacman system database cache. ; Path to pacman system database cache.
@ -17,7 +17,7 @@ mirror = https://geo.mirror.pkgbuild.com/$repo/os/$arch
repositories = core extra multilib repositories = core extra multilib
; Pacman's root directory. In the most cases it must point to the system root. ; Pacman's root directory. In the most cases it must point to the system root.
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 sync_files_database = yes
; Use local packages cache. If this option is enabled, the service will be able to synchronize databases (available ; 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. ; as additional option for some subcommands). If set to no, databases must be synchronized manually.
@ -50,20 +50,22 @@ allow_read_only = yes
;salt = ;salt =
[build] [build]
; List of paths to be used for implicit dependency scan
allowed_scan_paths = /usr/lib
; List of additional flags passed to archbuild command. ; List of additional flags passed to archbuild command.
;archbuild_flags = ;archbuild_flags =
; Path to build command. ; 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
;build_command = ;build_command =
; List of packages to be ignored during automatic updates. ; List of packages to be ignored during automatic updates.
;ignore_packages = ;ignore_packages =
; Include debug packages. ; Include debug packages
;include_debug_packages = yes ;include_debug_packages = yes
; List of additional flags passed to makechrootpkg command. ; List of additional flags passed to makechrootpkg command.
;makechrootpkg_flags = ;makechrootpkg_flags =
; List of additional flags passed to makepkg command. ; List of additional flags passed to makepkg command.
makepkg_flags = --nocolor --ignorearch 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. ; 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 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. ; List of well-known triggers. Used only for configuration purposes.
@ -119,9 +121,9 @@ host = 127.0.0.1
; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes. ; Disable status (e.g. package status, logs, etc) endpoints. Useful for build only modes.
;service_only = no ;service_only = no
; Path to directory with static files. ; Path to directory with static files.
static_path = ${templates}/static static_path = /usr/share/ahriman/templates/static
; List of directories with templates. ; List of directories with templates.
templates = ${prefix}/share/ahriman/templates templates = /usr/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled. ; Path to unix socket. If none set, unix socket will be disabled.
;unix_socket = ;unix_socket =
; Allow unix socket to be world readable. ; Allow unix socket to be world readable.
@ -212,14 +214,14 @@ target = console
; Console reporting trigger configuration sample. ; Console reporting trigger configuration sample.
[console] [console]
; Trigger type name. ; Trigger type name
;type = console ;type = console
; Use utf8 symbols in output. ; Use utf8 symbols in output.
use_utf = yes use_utf = yes
; Email reporting trigger configuration sample. ; Email reporting trigger configuration sample.
[email] [email]
; Trigger type name. ; Trigger type name
;type = email ;type = email
; Optional URL to the repository homepage. ; Optional URL to the repository homepage.
;homepage= ;homepage=
@ -235,8 +237,6 @@ use_utf = yes
;port = ;port =
; List of emails to receive the reports. ; List of emails to receive the reports.
;receivers = ;receivers =
; Optional link to the RSS feed.
;rss_url =
; Sender email. ; Sender email.
;sender = ;sender =
; SMTP server SSL mode, one of ssl, starttls, disabled. ; 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 name to be used for full packages list generation (same as HTML report).
;template_full = ;template_full =
; List of directories with templates. ; List of directories with templates.
templates = ${prefix}/share/ahriman/templates templates = /usr/share/ahriman/templates
; SMTP user. ; SMTP user.
;user = ;user =
; HTML reporting trigger configuration sample. ; HTML reporting trigger configuration sample.
[html] [html]
; Trigger type name. ; Trigger type name
;type = html ;type = html
; Optional URL to the repository homepage. ; Optional URL to the repository homepage.
;homepage= ;homepage=
@ -260,16 +260,14 @@ templates = ${prefix}/share/ahriman/templates
;link_path = ;link_path =
; Output path for the HTML report. ; Output path for the HTML report.
;path = ;path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used. ; Template name to be used.
template = repo-index.jinja2 template = repo-index.jinja2
; List of directories with templates. ; List of directories with templates.
templates = ${prefix}/share/ahriman/templates templates = /usr/share/ahriman/templates
; Remote service callback trigger configuration sample. ; Remote service callback trigger configuration sample.
[remote-call] [remote-call]
; Trigger type name. ; Trigger type name
;type = remote-call ;type = remote-call
; Call for AUR packages update. ; Call for AUR packages update.
;aur = no ;aur = no
@ -280,26 +278,9 @@ templates = ${prefix}/share/ahriman/templates
; Wait until remote process will be terminated in seconds. ; Wait until remote process will be terminated in seconds.
;wait_timeout = -1 ;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 reporting trigger configuration sample.
[telegram] [telegram]
; Trigger type name. ; Trigger type name
;type = telegram ;type = telegram
; Telegram bot API key. ; Telegram bot API key.
;api_key = ;api_key =
@ -309,14 +290,12 @@ templates = ${prefix}/share/ahriman/templates
;homepage= ;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename. ; Prefix for packages links. Link to a package will be formed as link_path / filename.
;link_path = ;link_path =
; Optional link to the RSS feed.
;rss_url =
; Template name to be used. ; Template name to be used.
template = telegram-index.jinja2 template = telegram-index.jinja2
; Telegram specific template mode, one of MarkdownV2, HTML or Markdown. ; Telegram specific template mode, one of MarkdownV2, HTML or Markdown.
;template_type = HTML ;template_type = HTML
; List of directories with templates. ; List of directories with templates.
templates = ${prefix}/share/ahriman/templates templates = /usr/share/ahriman/templates
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
;timeout = 30 ;timeout = 30
@ -327,7 +306,7 @@ target =
; GitHub upload trigger configuration sample. ; GitHub upload trigger configuration sample.
[github] [github]
; Trigger type name. ; Trigger type name
;type = github ;type = github
; GitHub repository owner username. ; GitHub repository owner username.
;owner = ;owner =
@ -344,14 +323,14 @@ target =
; Remote instance upload trigger configuration sample. ; Remote instance upload trigger configuration sample.
[remote-service] [remote-service]
; Trigger type name. ; Trigger type name
;type = remote-service ;type = remote-service
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
;timeout = 30 ;timeout = 30
; rsync upload trigger configuration sample. ; rsync upload trigger configuration sample.
[rsync] [rsync]
; Trigger type name. ; Trigger type name
;type = rsync ;type = rsync
; rsync command to run. ; rsync command to run.
command = rsync --archive --compress --partial --delete command = rsync --archive --compress --partial --delete
@ -361,7 +340,7 @@ command = rsync --archive --compress --partial --delete
; S3 upload trigger configuration sample. ; S3 upload trigger configuration sample.
[s3] [s3]
; Trigger type name. ; Trigger type name
;type = s3 ;type = s3
; AWS services access key. ; AWS services access key.
;access_key = ;access_key =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,6 @@
{% include "utils/style.jinja2" %} {% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %} {% include "user-style.jinja2" ignore missing %}
{% if rss_url is not none %}
<link rel="alternate" href="{{ rss_url }}" type="application/rss+xml">
{% endif %}
</head> </head>
<body> <body>
@ -105,13 +101,13 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
</div> </div>
<script> <script>
const table = $(document.getElementById("packages")); const table = $("#packages");
const pacmanConf = document.getElementById("pacman-conf"); const pacmanConf = $("#pacman-conf");
const pacmanConfCopyButton = document.getElementById("copy-btn"); const pacmanConfCopyButton = $("#copy-btn");
async function copyPacmanConf() { async function copyPacmanConf() {
const conf = pacmanConf.textContent; const conf = pacmanConf.text();
await copyToClipboard(conf, pacmanConfCopyButton); await copyToClipboard(conf, pacmanConfCopyButton);
} }
@ -127,37 +123,25 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
return extractDataList(table.bootstrapTable("getData"), "licenses"); return extractDataList(table.bootstrapTable("getData"), "licenses");
} }
ready(_ => { $(_ => {
table.on("created-controls.bs.table", _ => { table.on("created-controls.bs.table", _ => {
new easepick.create({ const pickerInput = $(".bootstrap-table-filter-control-timestamp");
element: document.querySelector(".bootstrap-table-filter-control-timestamp"), pickerInput.daterangepicker({
css: [ autoUpdateInput: false,
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
],
grid: 2,
calendars: 2,
autoApply: false,
locale: { locale: {
cancel: "Clear", cancelLabel: "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");
});
}); });
}); });
</script> </script>

View File

@ -1,27 +0,0 @@
<?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,30 +1,38 @@
<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/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/tableexport.jquery.plugin@1.30.0/tableExport.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/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.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/@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.3/dist/js/bootstrap.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.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/bootstrap-table.min.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.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.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.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.23.2/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.22.1/dist/extensions/filter-control/bootstrap-table-filter-control.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.9.0/build/highlight.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> <script>
async function copyToClipboard(text, button) { async function copyToClipboard(text, button) {
await navigator.clipboard.writeText(text); if (navigator.clipboard === undefined) {
button.innerHTML = "<i class=\"bi bi-clipboard-check\"></i> copied"; const input = document.createElement("textarea");
setTimeout(_ => { input.innerHTML = text;
button.innerHTML = "<i class=\"bi bi-clipboard\"></i> copy"; 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");
}, 2000); }, 2000);
} }
@ -65,47 +73,6 @@
.join("<br>"); .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) { function safe(string) {
return String(string) return String(string)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -119,9 +86,7 @@
const element = document.createElement("a"); const element = document.createElement("a");
element.href = url; element.href = url;
element.innerText = text; element.innerText = text;
if (title) { if (title) element.title = title;
element.title = title;
}
return element; return element;
} }

View File

@ -1,15 +1,17 @@
<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@5.3.2/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-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.23.2/dist/bootstrap-table.min.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/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.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.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/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/bootswatch@5.3.3/dist/cosmo/bootstrap.min.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/gh/highlightjs/cdn-release@11.10.0/build/styles/github.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">
<style> <style>
.pre-scrollable { .pre-scrollable {

View File

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

View File

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

View File

@ -1,7 +1,6 @@
# Index # Index
1. Setup repository named `ahriman-demo` with architecture `x86_64`. 1. Setup repository named `ahriman-demo` with architecture `x86_64`.
2. Generate index page and RSS feed. 2. Generate index page.
3. Repository is available at `http://localhost:8080/repo`. 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` 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,12 +1,6 @@
[report] [report]
target = html rss target = html
[html] [html]
path = ${repository:root}/repository/ahriman-demo/x86_64/index.html path = /var/lib/ahriman/ahriman/repository/ahriman-demo/x86_64/index.html
link_path = http://localhost:8080/repo/ahriman-demo/x86_64 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 # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
__version__ = "2.14.1" __version__ = "2.14.2"

View File

@ -28,7 +28,6 @@ from ahriman.application import handlers
from ahriman.core.utils import enum_values, extract_user from ahriman.core.utils import enum_values, extract_user
from ahriman.models.action import Action from ahriman.models.action import Action
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.event import EventType
from ahriman.models.log_handler import LogHandler from ahriman.models.log_handler import LogHandler
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -120,7 +119,6 @@ def _parser() -> argparse.ArgumentParser:
_set_repo_report_parser(subparsers) _set_repo_report_parser(subparsers)
_set_repo_restore_parser(subparsers) _set_repo_restore_parser(subparsers)
_set_repo_sign_parser(subparsers) _set_repo_sign_parser(subparsers)
_set_repo_statistics_parser(subparsers)
_set_repo_status_update_parser(subparsers) _set_repo_status_update_parser(subparsers)
_set_repo_sync_parser(subparsers) _set_repo_sync_parser(subparsers)
_set_repo_tree_parser(subparsers) _set_repo_tree_parser(subparsers)
@ -737,30 +735,6 @@ def _set_repo_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
return parser 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: def _set_repo_status_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
""" """
add parser for repository status update subcommand add parser for repository status update subcommand

View File

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

View File

@ -38,7 +38,6 @@ from ahriman.application.handlers.service_updates import ServiceUpdates
from ahriman.application.handlers.setup import Setup from ahriman.application.handlers.setup import Setup
from ahriman.application.handlers.shell import Shell from ahriman.application.handlers.shell import Shell
from ahriman.application.handlers.sign import Sign 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 import Status
from ahriman.application.handlers.status_update import StatusUpdate from ahriman.application.handlers.status_update import StatusUpdate
from ahriman.application.handlers.structure import Structure from ahriman.application.handlers.structure import Structure

View File

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

View File

@ -1,170 +0,0 @@
#
# 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

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

View File

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

View File

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

View File

@ -1,253 +0,0 @@
#
# 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.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
"""
_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:
"""
default constructor
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 parsed 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:
ValueError: 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 ValueError(f"Could not expand `{array}` as array")
return result
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:
ValueError: if array is not closed
"""
def extract() -> Generator[str, None, None]:
while token := self.get_token():
if token == PkgbuildToken.ArrayEnds:
break
if token == PkgbuildToken.Comment:
self.instream.readline()
continue
yield token
if token != PkgbuildToken.ArrayEnds:
raise ValueError("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 read content again in this range
Returns:
str: function body
Raises:
ValueError: 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, -1
while token := self.get_token():
match token:
case PkgbuildToken.FunctionStarts:
start_position = self._io.tell() - 1
case PkgbuildToken.FunctionEnds:
end_position = self._io.tell()
break
if not 0 < start_position < end_position:
raise ValueError("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)
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 (match := self._STRING_ASSIGNMENT.match(token)) is not None:
key = match.group("key")
value = match.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 (match := self._ARRAY_ASSIGNMENT.match(token)) is not None:
key = match.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

@ -68,7 +68,7 @@ class Repo(LazyLogging):
path(Path): path to archive to add path(Path): path to archive to add
""" """
check_output( check_output(
"repo-add", *self.sign_args, "--remove", str(self.repo_path), str(path), "repo-add", *self.sign_args, "-R", str(self.repo_path), str(path),
exception=BuildError.from_process(path.name), exception=BuildError.from_process(path.name),
cwd=self.paths.repository, cwd=self.paths.repository,
logger=self.logger, logger=self.logger,
@ -78,13 +78,8 @@ class Repo(LazyLogging):
""" """
create empty repository database. It just calls add with empty arguments create empty repository database. It just calls add with empty arguments
""" """
# since pacman-6.1.0 repo-add doesn't create empty database in case if no packages supplied check_output("repo-add", *self.sign_args, str(self.repo_path),
# this code creates empty files instead cwd=self.paths.repository, logger=self.logger, user=self.uid)
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: def remove(self, package: str, filename: Path) -> None:
""" """

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,14 +17,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from collections.abc import Generator
from pathlib import Path from pathlib import Path
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildError from ahriman.core.exceptions import BuildError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, package_like from ahriman.core.utils import check_output
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -68,43 +67,12 @@ class Task(LazyLogging):
self.makepkg_flags = configuration.getlist("build", "makepkg_flags", fallback=[]) self.makepkg_flags = configuration.getlist("build", "makepkg_flags", fallback=[])
self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags", fallback=[]) self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags", fallback=[])
def _package_archives(self, sources_dir: Path, source_files: list[Path]) -> list[Path]: def build(self, sources_dir: Path, **kwargs: str | None) -> 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 run package build
Args: Args:
sources_dir(Path): path to where sources are 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 **kwargs(str | None): environment variables to be passed to build processes
Returns: Returns:
@ -114,8 +82,6 @@ class Task(LazyLogging):
command.extend(self.archbuild_flags) command.extend(self.archbuild_flags)
command.extend(["--"] + self.makechrootpkg_flags) command.extend(["--"] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags) command.extend(["--"] + self.makepkg_flags)
if dry_run:
command.extend(["--nobuild"])
self.logger.info("using %s for %s", command, self.package.base) self.logger.info("using %s for %s", command, self.package.base)
environment: dict[str, str] = { environment: dict[str, str] = {
@ -125,7 +91,6 @@ class Task(LazyLogging):
} }
self.logger.info("using environment variables %s", environment) self.logger.info("using environment variables %s", environment)
source_files = list(sources_dir.iterdir())
check_output( check_output(
*command, *command,
exception=BuildError.from_process(self.package.base), exception=BuildError.from_process(self.package.base),
@ -135,7 +100,20 @@ class Task(LazyLogging):
environment=environment, environment=environment,
) )
return self._package_archives(sources_dir, source_files) 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)))
def init(self, sources_dir: Path, patches: list[PkgbuildPatch], local_version: str | None) -> str | None: def init(self, sources_dir: Path, patches: list[PkgbuildPatch], local_version: str | None) -> str | None:
""" """

View File

@ -46,8 +46,7 @@ class Configuration(configparser.RawConfigParser):
Examples: Examples:
Configuration class provides additional method in order to handle application configuration. Since this class is 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. 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 Nevertheless, it is recommended to use :func:`from_path` class method which also calls initialization methods::
methods::
>>> from pathlib import Path >>> from pathlib import Path
>>> >>>
@ -58,7 +57,7 @@ class Configuration(configparser.RawConfigParser):
The configuration instance loaded in this way will contain only sections which are defined for the specified 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. 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:: :exc:`ahriman.core.exceptions.InitializeError` in case if configuration was not yet loaded::
>>> path, repository_id = configuration.check_loaded() >>> path, repository_id = configuration.check_loaded()
@ -345,8 +344,7 @@ class Configuration(configparser.RawConfigParser):
def set_option(self, section: str, option: str, value: str) -> None: def set_option(self, section: str, option: str, value: str) -> None:
""" """
set option. Unlike default :func:`configparser.RawConfigParser.set()` it also creates section if set option. Unlike default :func:`configparser.RawConfigParser.set` it also creates section if it does not exist
it does not exist
Args: Args:
section(str): section name section(str): section name

View File

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

View File

@ -19,93 +19,21 @@
# #
import configparser import configparser
import os import os
import sys
from collections.abc import Generator, Mapping, MutableMapping from collections.abc import Mapping, MutableMapping
from string import Template from string import Template
class ExtendedTemplate(Template):
"""
extension to the default :class:`Template` class, which also enabled braces regex to lookup in sections
Attributes:
braceidpattern(str): regular expression to match a colon inside braces
"""
braceidpattern = r"(?a:[_a-z0-9][_a-z0-9:]*)"
class ShellInterpolator(configparser.Interpolation): class ShellInterpolator(configparser.Interpolation):
""" """
custom string interpolator, because we cannot use defaults argument due to config validation 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 ExtendedTemplate(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, def before_get(self, parser: MutableMapping[str, Mapping[str, str]], section: str, option: str, value: str,
defaults: Mapping[str, str]) -> str: defaults: Mapping[str, str]) -> str:
""" """
interpolate option value 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: Args:
parser(MutableMapping[str, Mapping[str, str]]): option parser parser(MutableMapping[str, Mapping[str, str]]): option parser
section(str): section name section(str): section name
@ -116,15 +44,8 @@ class ShellInterpolator(configparser.Interpolation):
Returns: Returns:
str: substituted value str: substituted value
""" """
# because any substitution effectively replace escaped $ ($$) in result, we have to escape it manually # At the moment it seems that it is the most legit way to handle environment variables
escaped = value.replace("$$", self.DATA_LINK_ESCAPE) # Template behaviour is literally the same as shell
# In addition, we are using shell-like variables in some cases (see :attr:`alpm.mirror` option),
# resolve internal references # thus we would like to keep them alive
variables = dict(self._extract_variables(parser, value, defaults)) return Template(value).safe_substitute(os.environ)
internal = ExtendedTemplate(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

@ -35,9 +35,8 @@ class Validator(RootValidator):
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
""" """
types_mapping = RootValidator.types_mapping | { types_mapping = RootValidator.types_mapping.copy()
"path": TypeDefinition("path", (Path,), ()), types_mapping["path"] = TypeDefinition("path", (Path,), ())
}
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
""" """
@ -150,7 +149,7 @@ class Validator(RootValidator):
check if paths exists check if paths exists
Args: Args:
constraint(bool): ``True`` in case if path must exist and ``False`` otherwise constraint(bool): True in case if path must exist and False otherwise
field(str): field name to be checked field(str): field name to be checked
value(Path): value to be checked value(Path): value to be checked

View File

@ -1,38 +0,0 @@
#
# 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,7 +21,6 @@ from ahriman.core.database.operations.auth_operations import AuthOperations
from ahriman.core.database.operations.build_operations import BuildOperations from ahriman.core.database.operations.build_operations import BuildOperations
from ahriman.core.database.operations.changes_operations import ChangesOperations from ahriman.core.database.operations.changes_operations import ChangesOperations
from ahriman.core.database.operations.dependencies_operations import DependenciesOperations from ahriman.core.database.operations.dependencies_operations import DependenciesOperations
from ahriman.core.database.operations.event_operations import EventOperations
from ahriman.core.database.operations.logs_operations import LogsOperations from ahriman.core.database.operations.logs_operations import LogsOperations
from ahriman.core.database.operations.package_operations import PackageOperations from ahriman.core.database.operations.package_operations import PackageOperations
from ahriman.core.database.operations.patch_operations import PatchOperations from ahriman.core.database.operations.patch_operations import PatchOperations

View File

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

View File

@ -1,108 +0,0 @@
#
# 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,11 +50,9 @@ class LogsOperations(Operations):
(row["created"], row["record"]) (row["created"], row["record"])
for row in connection.execute( for row in connection.execute(
""" """
select created, record from ( select created, record from logs
select * from logs where package_base = :package_base and repository = :repository
where package_base = :package_base and repository = :repository order by created limit :limit offset :offset
order by created desc limit :limit offset :offset
) order by created asc
""", """,
{ {
"package_base": package_base, "package_base": package_base,

View File

@ -46,22 +46,11 @@ class Operations(LazyLogging):
Args: Args:
path(Path): path to the database file path(Path): path to the database file
repository_id(RepositoryId): repository unique identifier repository_id(RepositoryId): repository unique identifier
repository_paths(RepositoryPaths): repository paths
""" """
self.path = path self.path = path
self._repository_id = repository_id self._repository_id = repository_id
self._repository_paths = repository_paths 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 @staticmethod
def factory(cursor: sqlite3.Cursor, row: tuple[Any, ...]) -> dict[str, Any]: def factory(cursor: sqlite3.Cursor, row: tuple[Any, ...]) -> dict[str, Any]:
""" """
@ -85,13 +74,12 @@ class Operations(LazyLogging):
Args: Args:
query(Callable[[Connection], T]): function to be called with connection query(Callable[[Connection], T]): function to be called with connection
commit(bool, optional): if ``True`` commit() will be called on success (Default value = False) commit(bool, optional): if True commit() will be called on success (Default value = False)
Returns: Returns:
T: result of the ``query`` call T: result of the ``query`` call
""" """
with sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES) as connection: with sqlite3.connect(self.path, detect_types=sqlite3.PARSE_DECLTYPES) as connection:
connection.set_trace_callback(self.logger.debug)
connection.row_factory = self.factory connection.row_factory = self.factory
result = query(connection) result = query(connection)
if commit: if commit:

View File

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

View File

@ -22,9 +22,7 @@ from ahriman.core.formatters.build_printer import BuildPrinter
from ahriman.core.formatters.changes_printer import ChangesPrinter from ahriman.core.formatters.changes_printer import ChangesPrinter
from ahriman.core.formatters.configuration_paths_printer import ConfigurationPathsPrinter from ahriman.core.formatters.configuration_paths_printer import ConfigurationPathsPrinter
from ahriman.core.formatters.configuration_printer import ConfigurationPrinter 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_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
from ahriman.core.formatters.patch_printer import PatchPrinter from ahriman.core.formatters.patch_printer import PatchPrinter
from ahriman.core.formatters.printer import Printer from ahriman.core.formatters.printer import Printer
from ahriman.core.formatters.repository_printer import RepositoryPrinter from ahriman.core.formatters.repository_printer import RepositoryPrinter

View File

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

View File

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

View File

@ -1,74 +0,0 @@
#
# 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:
"""
default constructor
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

@ -1,58 +0,0 @@
#
# 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:
"""
default constructor
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

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

View File

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

View File

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

View File

@ -29,8 +29,7 @@ from ahriman.models.repository_id import RepositoryId
class HttpLogHandler(logging.Handler): class HttpLogHandler(logging.Handler):
""" """
handler for the http logging. Because default :class:`logging.handlers.HTTPHandler` does not support cookies handler for the http logging. Because default :class:`logging.handlers.HTTPHandler` does not support cookies
authorization, we have to implement own handler which overrides the :func:`logging.handlers.HTTPHandler.emit()` authorization, we have to implement own handler which overrides the :func:`logging.handlers.HTTPHandler.emit` method
method
Attributes: Attributes:
reporter(Client): build status reporter instance reporter(Client): build status reporter instance

View File

@ -17,16 +17,14 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import datetime
import jinja2 import jinja2
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.utils import pretty_datetime, pretty_size, utcnow from ahriman.core.utils import pretty_datetime, pretty_size
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result from ahriman.models.result import Result
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -39,34 +37,30 @@ class JinjaTemplate:
It uses jinja2 templates for report generation, the following variables are allowed: It uses jinja2 templates for report generation, the following variables are allowed:
* homepage - link to homepage, string, optional * homepage - link to homepage, string, optional
* last_update - report generation time, pretty printed datetime, required
* link_path - prefix fo packages to download, string, required * link_path - prefix fo packages to download, string, required
* has_package_signed - ``True`` in case if package sign enabled, ``False`` otherwise, required * has_package_signed - True in case if package sign enabled, False otherwise, required
* has_repo_signed - ``True`` in case if repository database sign enabled, ``False`` otherwise, required * has_repo_signed - True in case if repository database sign enabled, False otherwise, required
* packages - sorted list of packages properties, required * packages - sorted list of packages properties, required
* architecture, string * architecture, string
* archive_size, pretty printed size, string * archive_size, pretty printed size, string
* build_date, pretty printed datetime, string * build_date, pretty printed datetime, string
* depends, sorted list of strings * depends, sorted list of strings
* description, string * description, string
* filename, string * filename, string,
* groups, sorted list of strings * groups, sorted list of strings
* installed_size, pretty printed size, string * installed_size, pretty printed datetime, string
* licenses, sorted list of strings * licenses, sorted list of strings
* name, string * name, string
* tag, string
* url, string * url, string
* version, string * version, string
* pgp_key - default PGP key ID, string, optional * pgp_key - default PGP key ID, string, optional
* repository - repository name, string, required * repository - repository name, string, required
* rss_url - optional link to the RSS feed, string, optional
Attributes: Attributes:
default_pgp_key(str | None): default PGP key default_pgp_key(str | None): default PGP key
homepage(str | None): homepage link if any (for footer) homepage(str | None): homepage link if any (for footer)
link_path(str): prefix fo packages to download link_path(str): prefix fo packages to download
name(str): repository name name(str): repository name
rss_url(str | None): link to the RSS feed
sign_targets(set[SignSettings]): targets to sign enabled in configuration sign_targets(set[SignSettings]): targets to sign enabled in configuration
templates(list[Path]): list of directories with templates templates(list[Path]): list of directories with templates
""" """
@ -86,36 +80,8 @@ class JinjaTemplate:
self.homepage = configuration.get(section, "homepage", fallback=None) self.homepage = configuration.get(section, "homepage", fallback=None)
self.link_path = configuration.get(section, "link_path") self.link_path = configuration.get(section, "link_path")
self.name = repository_id.name self.name = repository_id.name
self.rss_url = configuration.get(section, "rss_url", fallback=None)
self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration) self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration)
@staticmethod
def format_datetime(timestamp: datetime.datetime | float | int | None) -> str:
"""
convert datetime object to string
Args:
timestamp(datetime.datetime | float | int | None): datetime to convert
Returns:
str: datetime as string representation
"""
return pretty_datetime(timestamp)
@staticmethod
def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
sort content before rendering
Args:
content(list[dict[str, str]]): content of the template
Returns:
list[dict[str, str]]: sorted content according to comparator defined
"""
comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"]
return sorted(content, key=comparator)
def make_html(self, result: Result, template_name: Path | str) -> str: def make_html(self, result: Result, template_name: Path | str) -> str:
""" """
generate report for the specified packages generate report for the specified packages
@ -138,7 +104,7 @@ class JinjaTemplate:
{ {
"architecture": properties.architecture or "", "architecture": properties.architecture or "",
"archive_size": pretty_size(properties.archive_size), "archive_size": pretty_size(properties.archive_size),
"build_date": self.format_datetime(properties.build_date), "build_date": pretty_datetime(properties.build_date),
"depends": properties.depends, "depends": properties.depends,
"description": properties.description or "", "description": properties.description or "",
"filename": properties.filename, "filename": properties.filename,
@ -146,20 +112,17 @@ class JinjaTemplate:
"installed_size": pretty_size(properties.installed_size), "installed_size": pretty_size(properties.installed_size),
"licenses": properties.licenses, "licenses": properties.licenses,
"name": package, "name": package,
"tag": f"tag:{self.name}:{properties.architecture}:{package}:{base.version}:{properties.build_date}",
"url": properties.url or "", "url": properties.url or "",
"version": base.version, "version": base.version
} for base in result.success for package, properties in base.packages.items() } for base in result.success for package, properties in base.packages.items()
] ]
comparator: Callable[[dict[str, str]], str] = lambda item: item["filename"]
return template.render( return template.render(
homepage=self.homepage, homepage=self.homepage,
last_update=self.format_datetime(utcnow()),
link_path=self.link_path, link_path=self.link_path,
has_package_signed=SignSettings.Packages in self.sign_targets, has_package_signed=SignSettings.Packages in self.sign_targets,
has_repo_signed=SignSettings.Repository in self.sign_targets, has_repo_signed=SignSettings.Repository in self.sign_targets,
packages=self.sort_content(content), packages=sorted(content, key=comparator),
pgp_key=self.default_pgp_key, pgp_key=self.default_pgp_key,
repository=self.name, repository=self.name)
rss_url=self.rss_url,
)

View File

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

View File

@ -66,7 +66,7 @@ class Report(LazyLogging):
self.configuration = configuration self.configuration = configuration
@staticmethod @staticmethod
def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report: # pylint: disable=too-many-return-statements def load(repository_id: RepositoryId, configuration: Configuration, target: str) -> Report:
""" """
load client from settings load client from settings
@ -92,9 +92,6 @@ class Report(LazyLogging):
case ReportSettings.Telegram: case ReportSettings.Telegram:
from ahriman.core.report.telegram import Telegram from ahriman.core.report.telegram import Telegram
return Telegram(repository_id, configuration, section) return Telegram(repository_id, configuration, section)
case ReportSettings.RSS:
from ahriman.core.report.rss import RSS
return RSS(repository_id, configuration, section)
case ReportSettings.RemoteCall: case ReportSettings.RemoteCall:
from ahriman.core.report.remote_call import RemoteCall from ahriman.core.report.remote_call import RemoteCall
return RemoteCall(repository_id, configuration, section) return RemoteCall(repository_id, configuration, section)

View File

@ -116,11 +116,6 @@ class ReportTrigger(Trigger):
"required": True, "required": True,
"empty": False, "empty": False,
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"sender": { "sender": {
"type": "string", "type": "string",
"required": True, "required": True,
@ -192,11 +187,6 @@ class ReportTrigger(Trigger):
"coerce": "absolute_path", "coerce": "absolute_path",
"required": True, "required": True,
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"template": { "template": {
"type": "string", "type": "string",
"excludes": ["template_path"], "excludes": ["template_path"],
@ -253,11 +243,6 @@ class ReportTrigger(Trigger):
"empty": False, "empty": False,
"is_url": [], "is_url": [],
}, },
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"template": { "template": {
"type": "string", "type": "string",
"excludes": ["template_path"], "excludes": ["template_path"],
@ -319,67 +304,7 @@ class ReportTrigger(Trigger):
"coerce": "integer", "coerce": "integer",
}, },
}, },
}, }
"rss": {
"type": "dict",
"schema": {
"type": {
"type": "string",
"allowed": ["rss"],
},
"homepage": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"link_path": {
"type": "string",
"required": True,
"empty": False,
"is_url": [],
},
"max_entries": {
"type": "integer",
"coerce": "integer",
},
"path": {
"type": "path",
"coerce": "absolute_path",
"required": True,
},
"rss_url": {
"type": "string",
"empty": False,
"is_url": ["http", "https"],
},
"template": {
"type": "string",
"excludes": ["template_path"],
"dependencies": ["templates"],
"required": True,
"empty": False,
},
"template_path": {
"type": "path",
"coerce": "absolute_path",
"excludes": ["template"],
"required": True,
"path_exists": True,
"path_type": "file",
},
"templates": {
"type": "list",
"coerce": "list",
"schema": {
"type": "path",
"coerce": "absolute_path",
"path_exists": True,
"path_type": "dir",
},
"empty": False,
},
},
},
} }
def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None: def __init__(self, repository_id: RepositoryId, configuration: Configuration) -> None:

View File

@ -1,130 +0,0 @@
#
# 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 datetime
from collections.abc import Callable
from email.utils import format_datetime, parsedate_to_datetime
from typing import Any
from ahriman.core import context
from ahriman.core.configuration import Configuration
from ahriman.core.report.jinja_template import JinjaTemplate
from ahriman.core.report.report import Report
from ahriman.core.status import Client
from ahriman.models.event import EventType
from ahriman.models.package import Package
from ahriman.models.repository_id import RepositoryId
from ahriman.models.result import Result
class RSS(Report, JinjaTemplate):
"""
RSS report generator
Attributes:
max_entries(int): the maximal amount of entries in RSS
report_path(Path): output path to RSS report
template(str): name of the template
"""
def __init__(self, repository_id: RepositoryId, configuration: Configuration, section: str) -> None:
"""
default constructor
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
section(str): settings section name
"""
Report.__init__(self, repository_id, configuration)
JinjaTemplate.__init__(self, repository_id, configuration, section)
self.max_entries = configuration.getint(section, "max_entries", fallback=-1)
self.report_path = configuration.getpath(section, "path")
self.template = configuration.get(section, "template")
@staticmethod
def format_datetime(timestamp: datetime.datetime | float | int | None) -> str:
"""
convert datetime object to string
Args:
timestamp(datetime.datetime | float | int | None): datetime to convert
Returns:
str: datetime as string representation
"""
if timestamp is None:
return ""
if isinstance(timestamp, (int, float)):
timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
return format_datetime(timestamp)
@staticmethod
def sort_content(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
sort content before rendering
Args:
content(list[dict[str, str]]): content of the template
Returns:
list[dict[str, str]]: sorted content according to comparator defined
"""
comparator: Callable[[dict[str, str]], datetime.datetime] = \
lambda item: parsedate_to_datetime(item["build_date"])
return sorted(content, key=comparator, reverse=True)
def content(self, packages: list[Package]) -> Result:
"""
extract result to be written to template
Args:
packages(list[Package]): list of packages to generate report
Returns:
Result: result descriptor
"""
ctx = context.get()
reporter = ctx.get(Client)
events = reporter.event_get(EventType.PackageUpdated, None, limit=self.max_entries)
known_packages = {package.base: package for package in packages}
result = Result()
for event in events:
package = known_packages.get(event.object_id)
if package is None:
continue # package not found
result.add_updated(package)
return result
def generate(self, packages: list[Package], result: Result) -> None:
"""
generate report for the specified packages
Args:
packages(list[Package]): list of packages to generate report
result(Result): build result
"""
result = self.content(packages)
rss = self.make_html(result, self.template)
self.report_path.write_text(rss, encoding="utf8")

View File

@ -1,84 +0,0 @@
#
# 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 contextlib
from typing import Generator
from ahriman.core.status import Client
from ahriman.models.event import Event, EventType
from ahriman.models.metrics_timer import MetricsTimer
class EventLogger:
"""
wrapper for logging events
Attributes:
reporter(Client): build status reporter instance
"""
reporter: Client
def event(self, package_base: str, event: EventType, message: str | None = None) -> None:
"""
log single event. For timed events use context manager :func:`in_event()` instead
Args:
package_base(str): package base name
event(EventType): event type to be logged on success action
message(str | None, optional): optional message describing the action (Default value = None)
Examples:
This method must be used as simple wrapper for :class:`ahriman.core.status.Client` methods, e.g.::
>>> do_something()
>>> self.event(package_base, EventType.PackageUpdated)
"""
self.reporter.event_add(Event(event, package_base, message))
@contextlib.contextmanager
def in_event(self, package_base: str, event: EventType, message: str | None = None,
failure: EventType | None = None) -> Generator[None, None, None]:
"""
perform action in package context and log event with time elapsed
Args:
package_base(str): package base name
event(EventType): event type to be logged on success action
message(str | None, optional): optional message describing the action (Default value = None)
failure(EventType | None, optional): event type to be logged on exception (Default value = None)
Examples:
This method must be used to perform action in context with time measurement::
>>> with self.in_event(package_base, EventType.PackageUpdated):
>>> do_something()
Additional parameter ``failure`` can be set in order to emit an event on exception occured. If none set
(default), then no event will be recorded on exception
"""
with MetricsTimer() as timer:
try:
yield
self.reporter.event_add(Event(event, package_base, message, took=timer.elapsed))
except Exception:
if failure is not None:
self.reporter.event_add(Event(failure, package_base, took=timer.elapsed))
raise

View File

@ -23,14 +23,13 @@ from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from ahriman.core.build_tools.package_archive import PackageArchive
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.package_info import PackageInfo from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.utils import safe_filename from ahriman.core.utils import safe_filename
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_archive import PackageArchive
from ahriman.models.package_description import PackageDescription from ahriman.models.package_description import PackageDescription
from ahriman.models.packagers import Packagers from ahriman.models.packagers import Packagers
from ahriman.models.result import Result from ahriman.models.result import Result
@ -76,17 +75,16 @@ class Executor(PackageInfo, Cleaner):
with self.in_package_context(single.base, local_versions.get(single.base)), \ with self.in_package_context(single.base, local_versions.get(single.base)), \
TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
try: try:
with self.in_event(single.base, EventType.PackageUpdated, failure=EventType.PackageUpdateFailed): packager = self.packager(packagers, single.base)
packager = self.packager(packagers, single.base) last_commit_sha = build_single(single, Path(dir_name), packager.packager_id)
last_commit_sha = build_single(single, Path(dir_name), packager.packager_id) # clear changes and update commit hash
# clear changes and update commit hash self.reporter.package_changes_update(single.base, Changes(last_commit_sha))
self.reporter.package_changes_update(single.base, Changes(last_commit_sha)) # update dependencies list
# update dependencies list package_archive = PackageArchive(self.paths.build_directory, single, self.pacman, self.scan_paths)
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths) dependencies = package_archive.depends_on()
dependencies = package_archive.depends_on() self.reporter.package_dependencies_update(single.base, dependencies)
self.reporter.package_dependencies_update(single.base, dependencies) # update result set
# update result set result.add_updated(single)
result.add_updated(single)
except Exception: except Exception:
self.reporter.set_failed(single.base) self.reporter.set_failed(single.base)
result.add_failed(single) result.add_failed(single)
@ -106,8 +104,7 @@ class Executor(PackageInfo, Cleaner):
""" """
def remove_base(package_base: str) -> None: def remove_base(package_base: str) -> None:
try: try:
with self.in_event(package_base, EventType.PackageRemoved): self.reporter.package_remove(package_base)
self.reporter.package_remove(package_base)
except Exception: except Exception:
self.logger.exception("could not remove base %s", package_base) self.logger.exception("could not remove base %s", package_base)

View File

@ -58,7 +58,7 @@ class PackageInfo(RepositoryProperties):
# force version to max of them # force version to max of them
self.logger.warning("version of %s differs, found %s and %s", self.logger.warning("version of %s differs, found %s and %s",
current.base, current.version, local.version) current.base, current.version, local.version)
if current.is_outdated(local, self.configuration, calculate_version=False): if current.is_outdated(local, self.paths, calculate_version=False):
current.version = local.version current.version = local.version
current.packages.update(local.packages) current.packages.update(local.packages)
except Exception: except Exception:

View File

@ -22,7 +22,6 @@ from ahriman.core.alpm.repo import Repo
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.repository.event_logger import EventLogger
from ahriman.core.sign.gpg import GPG from ahriman.core.sign.gpg import GPG
from ahriman.core.status import Client from ahriman.core.status import Client
from ahriman.core.triggers import TriggerLoader from ahriman.core.triggers import TriggerLoader
@ -35,7 +34,7 @@ from ahriman.models.user import User
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
class RepositoryProperties(EventLogger, LazyLogging): class RepositoryProperties(LazyLogging):
""" """
repository internal objects holder repository internal objects holder
@ -51,6 +50,7 @@ class RepositoryProperties(EventLogger, LazyLogging):
scan_paths(ScanPaths): scan paths for the implicit dependencies scan_paths(ScanPaths): scan paths for the implicit dependencies
sign(GPG): GPG wrapper instance sign(GPG): GPG wrapper instance
triggers(TriggerLoader): triggers holder triggers(TriggerLoader): triggers holder
vcs_allowed_age(int): maximal age of the VCS packages before they will be checked
""" """
def __init__(self, repository_id: RepositoryId, configuration: Configuration, database: SQLite, *, report: bool, def __init__(self, repository_id: RepositoryId, configuration: Configuration, database: SQLite, *, report: bool,
@ -69,6 +69,8 @@ class RepositoryProperties(EventLogger, LazyLogging):
self.configuration = configuration self.configuration = configuration
self.database = database self.database = database
self.vcs_allowed_age = configuration.getint("build", "vcs_allowed_age", fallback=0)
self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing self.paths: RepositoryPaths = configuration.repository_paths # additional workaround for pycharm typing
self.ignore_list = configuration.getlist("build", "ignore_packages", fallback=[]) self.ignore_list = configuration.getlist("build", "ignore_packages", fallback=[])
@ -78,7 +80,10 @@ class RepositoryProperties(EventLogger, LazyLogging):
self.reporter = Client.load(repository_id, configuration, database, report=report) self.reporter = Client.load(repository_id, configuration, database, report=report)
self.triggers = TriggerLoader.load(repository_id, configuration) self.triggers = TriggerLoader.load(repository_id, configuration)
self.scan_paths = ScanPaths(configuration.getlist("build", "scan_paths", fallback=[])) self.scan_paths = ScanPaths(
allowed_paths=configuration.getpathlist("build", "allowed_scan_paths", fallback=[]),
blacklisted_paths=configuration.getpathlist("build", "blacklisted_scan_paths", fallback=[]),
)
@property @property
def architecture(self) -> str: def architecture(self) -> str:

View File

@ -23,7 +23,6 @@ from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.repository.package_info import PackageInfo from ahriman.core.repository.package_info import PackageInfo
from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.remote_source import RemoteSource from ahriman.models.remote_source import RemoteSource
@ -67,9 +66,11 @@ class UpdateHandler(PackageInfo, Cleaner):
try: try:
remote = load_remote(local) remote = load_remote(local)
if local.is_outdated(remote, self.configuration, calculate_version=vcs): if local.is_outdated(
remote, self.paths,
vcs_allowed_age=self.vcs_allowed_age,
calculate_version=vcs):
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
self.event(local.base, EventType.PackageOutdated, "Remote version is newer than local")
result.append(remote) result.append(remote)
except Exception: except Exception:
self.reporter.set_failed(local.base) self.reporter.set_failed(local.base)
@ -97,8 +98,8 @@ class UpdateHandler(PackageInfo, Cleaner):
return files return files
result: list[Package] = [] result: list[Package] = []
for local in self.packages(filter_packages): for package in self.packages(filter_packages):
dependencies = self.reporter.package_dependencies_get(local.base) dependencies = self.reporter.package_dependencies_get(package.base)
if not dependencies.paths: if not dependencies.paths:
continue # skip check if no package dependencies found continue # skip check if no package dependencies found
@ -111,10 +112,7 @@ class UpdateHandler(PackageInfo, Cleaner):
continue continue
# there are no packages found in filesystem with the same paths # there are no packages found in filesystem with the same paths
self.reporter.set_pending(local.base) result.append(package)
self.event(local.base, EventType.PackageOutdated, "Implicit dependencies are broken")
result.append(local)
break break
return result return result
@ -151,9 +149,10 @@ class UpdateHandler(PackageInfo, Cleaner):
if local is None: if local is None:
continue # we don't add packages automatically continue # we don't add packages automatically
if local.is_outdated(remote, self.configuration, calculate_version=vcs): if local.is_outdated(remote, self.paths,
vcs_allowed_age=self.vcs_allowed_age,
calculate_version=vcs):
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
self.event(local.base, EventType.PackageOutdated, "Locally pulled sources are outdated")
result.append(remote) result.append(remote)
except Exception: except Exception:
self.logger.exception("could not process package at %s", cache_dir) self.logger.exception("could not process package at %s", cache_dir)
@ -177,7 +176,6 @@ class UpdateHandler(PackageInfo, Cleaner):
self.reporter.set_unknown(local) self.reporter.set_unknown(local)
else: else:
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
self.event(local.base, EventType.PackageOutdated, "Manual update is requested")
except Exception: except Exception:
self.logger.exception("could not load packages from database") self.logger.exception("could not load packages from database")
self.clear_queue() self.clear_queue()

View File

@ -20,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import time
import uuid import uuid
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
@ -27,7 +28,6 @@ from multiprocessing import Process, Queue
from threading import Lock, Thread from threading import Lock, Thread
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.models.metrics_timer import MetricsTimer
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.process_status import ProcessStatus from ahriman.models.process_status import ProcessStatus
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -72,7 +72,7 @@ class Spawn(Thread, LazyLogging):
value(bool): command line argument value value(bool): command line argument value
Returns: Returns:
str: if ``value`` is ``True``, then returns positive flag and negative otherwise str: if ``value`` is True, then returns positive flag and negative otherwise
""" """
return name if value else f"no-{name}" return name if value else f"no-{name}"
@ -90,9 +90,11 @@ class Spawn(Thread, LazyLogging):
process_id(str): process unique identifier process_id(str): process unique identifier
queue(Queue[ProcessStatus | None]): output queue queue(Queue[ProcessStatus | None]): output queue
""" """
with MetricsTimer() as timer: start_time = time.monotonic()
result = callback(args, repository_id) result = callback(args, repository_id)
consumed_time = timer.elapsed stop_time = time.monotonic()
consumed_time = int(1000 * (stop_time - start_time))
queue.put(ProcessStatus(process_id, result, consumed_time)) queue.put(ProcessStatus(process_id, result, consumed_time))
@ -151,7 +153,7 @@ class Spawn(Thread, LazyLogging):
process_id(str): process id to be checked as returned by :func:`_spawn_process()` process_id(str): process id to be checked as returned by :func:`_spawn_process()`
Returns: Returns:
bool: ``True`` in case if process still counts as active and ``False`` otherwise bool: True in case if process still counts as active and False otherwise
""" """
with self._lock: with self._lock:
return process_id in self.active return process_id in self.active
@ -269,7 +271,7 @@ class Spawn(Thread, LazyLogging):
""" """
for terminated in iter(self.queue.get, None): for terminated in iter(self.queue.get, None):
self.logger.info("process %s has been terminated with status %s, consumed time %ss", self.logger.info("process %s has been terminated with status %s, consumed time %ss",
terminated.process_id, terminated.status, terminated.consumed_time) terminated.process_id, terminated.status, terminated.consumed_time / 1000)
with self._lock: with self._lock:
process = self.active.pop(terminated.process_id, None) process = self.active.pop(terminated.process_id, None)

View File

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

View File

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

View File

@ -27,7 +27,6 @@ from ahriman.core.status import Client
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -69,10 +68,6 @@ class Watcher(LazyLogging):
with self._lock: with self._lock:
return list(self._known.values()) return list(self._known.values())
event_add: Callable[[Event], None]
event_get: Callable[[str | EventType | None, str | None, int | None, int | None, int, int], list[Event]]
def load(self) -> None: def load(self) -> None:
""" """
load packages from local database load packages from local database
@ -199,7 +194,7 @@ class Watcher(LazyLogging):
proxy methods for reporter client proxy methods for reporter client
Args: Args:
item(str): property name item(str): property name:
Returns: Returns:
Any: attribute by its name Any: attribute by its name

View File

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

View File

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

View File

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

View File

@ -39,15 +39,14 @@ class Upload(LazyLogging):
Examples: Examples:
These classes provide the way to upload packages to remote sources as it is described in their implementations. These classes provide the way to upload packages to remote sources as it is described in their implementations.
Basic flow includes class instantiating by using the :func:`load()` method and then calling the :func:`run()` Basic flow includes class instantiating by using the :func:`load` method and then calling the :func:`run` method
method which wraps any internal exceptions into the :exc:`ahriman.core.exceptions.SynchronizationError` which wraps any internal exceptions into the :exc:`ahriman.core.exceptions.SynchronizationError` exception::
exception::
>>> configuration = Configuration() >>> configuration = Configuration()
>>> upload = Upload.load(RepositoryId("x86_64", "aur-clone"), configuration, "s3") >>> upload = Upload.load(RepositoryId("x86_64", "aur-clone"), configuration, "s3")
>>> upload.run(configuration.repository_paths.repository, []) >>> upload.run(configuration.repository_paths.repository, [])
Or in case if direct access to exception is required, the :func:`sync()` method can be used:: Or in case if direct access to exception is required, the :func:`sync` method can be used::
>>> try: >>> try:
>>> upload.sync(configuration.repository_paths.repository, []) >>> upload.sync(configuration.repository_paths.repository, [])

View File

@ -27,7 +27,7 @@ import re
import selectors import selectors
import subprocess import subprocess
from collections.abc import Callable, Generator, Iterable, Mapping from collections.abc import Callable, Generator, Iterable
from dataclasses import asdict from dataclasses import asdict
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@ -225,8 +225,8 @@ def extract_user() -> str | None:
extract user from system environment extract user from system environment
Returns: Returns:
str | None: SUDO_USER in case if set and USER otherwise. It can return ``None`` in case if environment has been str | None: SUDO_USER in case if set and USER otherwise. It can return None in case if environment has been
cleared before application start cleared before application start
""" """
return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER") return os.getenv("SUDO_USER") or os.getenv("DOAS_USER") or os.getenv("USER")
@ -295,7 +295,7 @@ def package_like(filename: Path) -> bool:
filename(Path): name of file to check filename(Path): name of file to check
Returns: Returns:
bool: ``True`` in case if name contains ``.pkg.`` and not signature, ``False`` otherwise bool: True in case if name contains ``.pkg.`` and not signature, False otherwise
""" """
name = filename.name name = filename.name
return not name.startswith(".") and ".pkg." in name and not name.endswith(".sig") return not name.startswith(".") and ".pkg." in name and not name.endswith(".sig")
@ -407,7 +407,7 @@ def safe_filename(source: str) -> str:
return re.sub(r"[^A-Za-z\d\-._~:\[\]@]", "-", source) return re.sub(r"[^A-Za-z\d\-._~:\[\]@]", "-", source)
def srcinfo_property(key: str, srcinfo: Mapping[str, Any], package_srcinfo: Mapping[str, Any], *, def srcinfo_property(key: str, srcinfo: dict[str, Any], package_srcinfo: dict[str, Any], *,
default: Any = None) -> Any: default: Any = None) -> Any:
""" """
extract property from SRCINFO. This method extracts property from package if this property is presented in extract property from SRCINFO. This method extracts property from package if this property is presented in
@ -416,8 +416,8 @@ def srcinfo_property(key: str, srcinfo: Mapping[str, Any], package_srcinfo: Mapp
Args: Args:
key(str): key to extract key(str): key to extract
srcinfo(Mapping[str, Any]): root structure of SRCINFO srcinfo(dict[str, Any]): root structure of SRCINFO
package_srcinfo(Mapping[str, Any]): package specific SRCINFO package_srcinfo(dict[str, Any]): package specific SRCINFO
default(Any, optional): the default value for the specified key (Default value = None) default(Any, optional): the default value for the specified key (Default value = None)
Returns: Returns:
@ -426,7 +426,7 @@ def srcinfo_property(key: str, srcinfo: Mapping[str, Any], package_srcinfo: Mapp
return package_srcinfo.get(key) or srcinfo.get(key) or default return package_srcinfo.get(key) or srcinfo.get(key) or default
def srcinfo_property_list(key: str, srcinfo: Mapping[str, Any], package_srcinfo: Mapping[str, Any], *, def srcinfo_property_list(key: str, srcinfo: dict[str, Any], package_srcinfo: dict[str, Any], *,
architecture: str | None = None) -> list[Any]: architecture: str | None = None) -> list[Any]:
""" """
extract list property from SRCINFO. Unlike :func:`srcinfo_property()` it supposes that default return value is extract list property from SRCINFO. Unlike :func:`srcinfo_property()` it supposes that default return value is
@ -435,8 +435,8 @@ def srcinfo_property_list(key: str, srcinfo: Mapping[str, Any], package_srcinfo:
Args: Args:
key(str): key to extract key(str): key to extract
srcinfo(Mapping[str, Any]): root structure of SRCINFO srcinfo(dict[str, Any]): root structure of SRCINFO
package_srcinfo(Mapping[str, Any]): package specific SRCINFO package_srcinfo(dict[str, Any]): package specific SRCINFO
architecture(str | None, optional): package architecture if set (Default value = None) architecture(str | None, optional): package architecture if set (Default value = None)
Returns: Returns:

View File

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

View File

@ -1,140 +0,0 @@
#
# 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 enum import StrEnum
from typing import Any, Self
from ahriman.core.utils import utcnow
class EventType(StrEnum):
"""
predefined event types
Attributes:
PackageOutdated(EventType): (class attribute) package has been marked as out-of-date
PackageRemoved(EventType): (class attribute) package has been removed
PackageUpdateFailed(EventType): (class attribute) package update has been failed
PackageUpdated(EventType): (class attribute) package has been updated
"""
PackageOutdated = "package-outdated"
PackageRemoved = "package-removed"
PackageUpdateFailed = "package-update-failed"
PackageUpdated = "package-updated"
class Event:
"""
audit log event
Attributes:
created(int): event timestamp
data(dict[str, Any]): event metadata
event(str | EventType): event type
message(str | None): event message if available
object_id(str): object identifier
"""
def __init__(self, event: str | EventType, object_id: str, message: str | None = None, created: int | None = None,
**kwargs: Any):
"""
default constructor
Args:
event(str | EventType): event type
object_id(str): object identifier
message(str | None): event message if available
created(int | None, optional): event timestamp (Default value = None)
**kwargs(Any): event metadata
"""
self.event = EventType(event) if event in EventType else event
self.object_id = object_id
self.created = created or int(utcnow().timestamp())
self.message = message
self.data = kwargs
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""
construct event from the json dump
Args:
dump(dict[str, Any]): json dump body
Returns:
Self: dependencies object
"""
return cls(
event=dump["event"],
object_id=dump["object_id"],
message=dump.get("message"),
created=dump.get("created"),
**dump.get("data", {}),
)
def get(self, key: str) -> Any:
"""
get a property
Args:
key(str): key to lookup in data
Returns:
Any: metadata property if available or ``None`` otherwise
"""
return self.data.get(key)
def view(self) -> dict[str, Any]:
"""
generate json event view
Returns:
dict[str, Any]: json-friendly dictionary
"""
dump = {
"event": self.event,
"object_id": self.object_id,
"created": self.created,
}
if self.message is not None:
dump["message"] = self.message
if self.data:
dump["data"] = self.data
return dump
def __eq__(self, other: Any) -> bool:
"""
check if other is the same object
Args:
other(Any): other object instance
Returns:
bool: ``True`` if the other object is the same and ``False`` otherwise
"""
if not isinstance(other, Event):
return False
return self.event == other.event \
and self.object_id == other.object_id \
and self.message == other.message \
and self.created == other.created \
and self.data == other.data

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