Compare commits

...

25 Commits

Author SHA1 Message Date
2e059023f3 Release 2.4.0 2022-12-05 00:25:53 +02:00
da5d97788a do not update database via pacman, use ahriman's own databses 2022-12-04 23:45:10 +02:00
0e839fbbf2 Some minor documentation related fixes
* Improve some wording (again)
* Change default type for refresh option to False (does not affect
  behavior)
* Update docstrings to reflect last changes
* Configuration.__convert_path has been replaced by shlex
* aiosecurity functions support kwargs now
2022-12-04 02:10:46 +02:00
262462d3c3 improve wording in documentation 2022-12-02 15:45:01 +02:00
7aa91f9e2e do not trigger update on sign command 2022-12-02 01:41:23 +02:00
01eda513cf improve setup command by --makeflags-jobs argument and fix repository sign on creation 2022-12-02 01:41:23 +02:00
0161617e36 implement support of unix socket for server
This feature can be used for unauthorized access to apis - e.g. for
reporting service if it is run on the same machine. Since now it becomes
recommended way for the interprocess communication, thus some options
(e.g. creating user with as-service flag) are no longer available now
2022-12-02 01:41:23 +02:00
4811dec759 do not invoke configuration write in case if no salt or user was written 2022-12-02 01:41:23 +02:00
09623c20d5 add rebuild implementation to interface 2022-12-02 01:41:23 +02:00
eba247b759 make package actions as dropdown 2022-12-02 01:41:23 +02:00
5073c80af1 add key-import button to interface 2022-12-02 01:41:23 +02:00
766081d212 add demos links 2022-11-24 02:38:33 +02:00
896cd0bd71 add security notes 2022-11-24 02:38:33 +02:00
ce1bd2f2db add curl examples to web views 2022-11-24 02:38:33 +02:00
6ba96d838d build docs together with web views 2022-11-24 02:38:33 +02:00
df9e03f495 change respone for service requests 2022-11-24 02:38:33 +02:00
89944eb2b6 add fallback for copying to clipboard 2022-11-24 02:38:33 +02:00
336784519b add show/hide password button 2022-11-24 02:38:33 +02:00
f3341ec7cd update web preview picture 2022-11-24 02:38:33 +02:00
d36e851a29 render httpunauthorized as html in plain http requests 2022-11-22 23:40:27 +02:00
28bd5f2095 allow scrolling with fixed copy button position 2022-11-22 22:20:21 +02:00
011b4e2e31 change logging module imports 2022-11-22 22:20:21 +02:00
15609ba044 change wording for package actions 2022-11-22 15:46:49 +02:00
6d4f9981f7 fix login and logout buttons decorations 2022-11-22 11:17:59 +02:00
bbb97d1cdd add more notes about docker 2022-11-22 10:49:46 +02:00
135 changed files with 5489 additions and 3823 deletions

View File

@ -0,0 +1,20 @@
---
name: Security report
about: Create a report related to security issues
title: ''
labels: security
assignees: ''
---
## Summary
A clear and concise description of what the issue is.
### Steps to reproduce
Steps to reproduce the behavior (commands, environment etc).
### Intended impact
Brief optional description of how this vulnerability can be used and which effects can be achieved.

View File

@ -1,6 +1,7 @@
version: 2
formats: all
formats:
- pdf
build:
os: ubuntu-20.04
@ -10,6 +11,7 @@ build:
sphinx:
builder: html
configuration: docs/conf.py
fail_on_warning: true
python:
install:
@ -17,4 +19,6 @@ python:
path: .
extra_requirements:
- docs
- s3
- web
system_packages: true

View File

@ -1,2 +1,2 @@
Current developers:
Evgenii Alekseev aka arcanis <esalexeev (at) gmail (dot) com>
Current maintainer:
Evgenii Alekseev <esalexeev (at) gmail (dot) com>

View File

@ -10,6 +10,7 @@ ENV AHRIMAN_PACKAGER="ahriman bot <ahriman@example.com>"
ENV AHRIMAN_PORT=""
ENV AHRIMAN_REPOSITORY="aur-clone"
ENV AHRIMAN_REPOSITORY_ROOT="/var/lib/ahriman/ahriman"
ENV AHRIMAN_UNIX_SOCKET=""
ENV AHRIMAN_USER="ahriman"
# install environment
@ -26,7 +27,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
## darcs is not installed by reasons, because it requires a lot haskell packages which dramatically increase image size
RUN pacman --noconfirm -Sy devtools git pyalpm python-inflection python-passlib python-requests python-setuptools python-srcinfo && \
pacman --noconfirm -Sy python-build python-installer python-wheel && \
pacman --noconfirm -Sy breezy mercurial python-aiohttp python-boto3 python-cryptography python-jinja rsync subversion && \
pacman --noconfirm -Sy breezy mercurial python-aiohttp python-boto3 python-cryptography python-jinja python-requests-unixsocket rsync subversion && \
runuser -u build -- install-aur-package python-aioauth-client python-aiohttp-jinja2 python-aiohttp-debugtoolbar \
python-aiohttp-session python-aiohttp-security
@ -50,4 +51,4 @@ VOLUME ["/var/lib/ahriman"]
COPY "docker/entrypoint.sh" "/usr/local/bin/entrypoint"
ENTRYPOINT ["entrypoint"]
# default command
CMD ["repo-update"]
CMD ["repo-update", "--refresh"]

View File

@ -3,7 +3,7 @@
PROJECT := ahriman
FILES := AUTHORS CONTRIBUTING.md COPYING README.md docs package src setup.py tox.ini web.png
FILES := AUTHORS CONTRIBUTING.md COPYING Makefile README.md SECURITY.md docs package src setup.py tox.ini web.png
TARGET_FILES := $(addprefix $(PROJECT)/, $(FILES))
IGNORE_FILES := package/archlinux src/.mypy_cache

View File

@ -33,3 +33,9 @@ Every available option is described in the [documentation](https://ahriman.readt
The application provides reasonable defaults which allow to use it out-of-box; however additional steps (like configuring build toolchain and sudoers) are recommended and can be easily achieved by following install instructions.
## [FAQ](https://ahriman.readthedocs.io/en/latest/faq.html)
## Live demos
* [Build status page](https://ahriman-demo.arcanis.me). You can log in as `demo` user by using `demo` password. However, you will not be able to run tasks.
* [Repository index](http://repo.arcanis.me/x86_64/index.html).
* [Telegram feed](https://t.me/arcanisrepo).

9
SECURITY.md Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
The project follows bleeding edge philosophy, thus only the latest version is supported with the exception for release candidates (i.e. tags which are marked with `rc` suffix).
## Reporting a Vulnerability
In the most cases you can report (suspected) security vulnerabilities directly on github by using ["Security report" template](https://github.com/arcan1s/ahriman/issues/new?assignees=&labels=security&template=02-security-report.md&title=). However, if your report could lead to data leak or break the system we kindly ask you to contact [current maintainer](AUTHORS) directly by email.

View File

@ -4,9 +4,17 @@ set -e
[ -n "$AHRIMAN_DEBUG" ] && set -x
# configuration tune
sed -i "s|root = /var/lib/ahriman|root = $AHRIMAN_REPOSITORY_ROOT|g" "/etc/ahriman.ini"
sed -i "s|database = /var/lib/ahriman/ahriman.db|database = $AHRIMAN_REPOSITORY_ROOT/ahriman.db|g" "/etc/ahriman.ini"
sed -i "s|host = 127.0.0.1|host = $AHRIMAN_HOST|g" "/etc/ahriman.ini"
cat <<EOF > "/etc/ahriman.ini.d/00-docker.ini"
[repository]
root = $AHRIMAN_REPOSITORY_ROOT
[settings]
database = $AHRIMAN_REPOSITORY_ROOT/ahriman.db
[web]
host = $AHRIMAN_HOST
EOF
sed -i "s|handlers = syslog_handler|handlers = ${AHRIMAN_OUTPUT}_handler|g" "/etc/ahriman.ini.d/logging.ini"
AHRIMAN_DEFAULT_ARGS=("--architecture" "$AHRIMAN_ARCHITECTURE")
@ -22,18 +30,23 @@ fi
[ -d "$AHRIMAN_REPOSITORY_ROOT" ] || mkdir "$AHRIMAN_REPOSITORY_ROOT"
chown "$AHRIMAN_USER":"$AHRIMAN_USER" "$AHRIMAN_REPOSITORY_ROOT"
# create .gnupg directory which is required for keys
AHRIMAN_GNUPG_HOME="$(getent passwd "$AHRIMAN_USER" | cut -d : -f 6)/.gnupg"
[ -d "$AHRIMAN_GNUPG_HOME" ] || mkdir -m700 "$AHRIMAN_GNUPG_HOME"
chown "$AHRIMAN_USER":"$AHRIMAN_USER" "$AHRIMAN_GNUPG_HOME"
# run built-in setup command
AHRIMAN_SETUP_ARGS=("--build-as-user" "$AHRIMAN_USER")
AHRIMAN_SETUP_ARGS+=("--packager" "$AHRIMAN_PACKAGER")
AHRIMAN_SETUP_ARGS+=("--repository" "$AHRIMAN_REPOSITORY")
if [ -n "$AHRIMAN_PORT" ]; then
# in addition it must be handled in docker run command
AHRIMAN_SETUP_ARGS+=("--web-port" "$AHRIMAN_PORT")
fi
if [ -n "$AHRIMAN_UNIX_SOCKET" ]; then
AHRIMAN_SETUP_ARGS+=("--web-unix-socket" "$AHRIMAN_UNIX_SOCKET")
fi
ahriman "${AHRIMAN_DEFAULT_ARGS[@]}" repo-setup "${AHRIMAN_SETUP_ARGS[@]}"
# refresh database
pacman -Syy &> /dev/null
# create machine-id which is required by build tools
systemd-machine-id-setup &> /dev/null

View File

@ -11,8 +11,8 @@ Depending on the goal the package can be used in different ways. Nevertheless, i
from ahriman.core.database import SQLite
architecture = "x86_64"
configuration = Configuration.from_path(Path("/etc/ahriman.ini"), architecture, quiet=False)
sqlite = SQLite.load(configuration)
configuration = Configuration.from_path(Path("/etc/ahriman.ini"), architecture)
database = SQLite.load(configuration)
At this point there are ``configuration`` and ``database`` instances which can be used later at any time anywhere, e.g.
@ -27,7 +27,7 @@ Almost all actions are wrapped by ``ahriman.core.repository.Repository`` class
from ahriman.core.repository import Repository
repository = Repository(architecture, configuration, database, no_report=False, unsafe=False)
repository = Repository(architecture, configuration, database, report=True, unsafe=False)
And the ``repository`` instance can be used to perform repository maintenance
@ -37,6 +37,6 @@ And the ``repository`` instance can be used to perform repository maintenance
built_packages = repository.packages_built()
update_result = repository.process_update(built_packages)
repository.process_triggers(update_result)
repository.triggers.on_result(update_result, repository.packages())
For the more info please refer to the classes documentation.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 613 KiB

After

Width:  |  Height:  |  Size: 621 KiB

View File

@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2022\-11\-16" "ahriman" "Generated Python Manual"
.TH AHRIMAN "1" "2022\-12\-05" "ahriman" "Generated Python Manual"
.SH NAME
ahriman
.SH SYNOPSIS
@ -10,7 +10,7 @@ ArcH linux ReposItory MANager
.SH OPTIONS
.TP
\fB\-a\fR \fI\,ARCHITECTURE\/\fR, \fB\-\-architecture\fR \fI\,ARCHITECTURE\/\fR
target architectures (can be used multiple times)
target architectures. For several subcommands it can be used multiple times
.TP
\fB\-c\fR \fI\,CONFIGURATION\/\fR, \fB\-\-configuration\fR \fI\,CONFIGURATION\/\fR
@ -128,7 +128,7 @@ run triggers
update packages
.TP
\fBahriman\fR \fI\,shell\/\fR
envoke python shell
invoke python shell
.TP
\fBahriman\fR \fI\,user\-add\/\fR
create or update user
@ -507,9 +507,10 @@ root path of the extracted files
.SH COMMAND \fI\,'ahriman repo\-setup'\/\fR
usage: ahriman repo\-setup [\-h] [\-\-build\-as\-user BUILD_AS_USER] [\-\-build\-command BUILD_COMMAND]
[\-\-from\-configuration FROM_CONFIGURATION] [\-\-multilib | \-\-no\-multilib] \-\-packager PACKAGER
\-\-repository REPOSITORY [\-\-sign\-key SIGN_KEY] [\-\-sign\-target {disabled,pacakges,repository}]
[\-\-web\-port WEB_PORT]
[\-\-from\-configuration FROM_CONFIGURATION] [\-\-makeflags\-jobs | \-\-no\-makeflags\-jobs]
[\-\-multilib | \-\-no\-multilib] \-\-packager PACKAGER \-\-repository REPOSITORY [\-\-sign\-key SIGN_KEY]
[\-\-sign\-target {disabled,packages,repository}] [\-\-web\-port WEB_PORT]
[\-\-web\-unix\-socket WEB_UNIX_SOCKET]
create initial service configuration, requires root
@ -526,6 +527,10 @@ build command prefix
\fB\-\-from\-configuration\fR \fI\,FROM_CONFIGURATION\/\fR
path to default devtools pacman configuration
.TP
\fB\-\-makeflags\-jobs\fR, \fB\-\-no\-makeflags\-jobs\fR
append MAKEFLAGS variable with parallelism set to number of cores (default: True)
.TP
\fB\-\-multilib\fR, \fB\-\-no\-multilib\fR
add or do not multilib repository (default: True)
@ -543,13 +548,17 @@ repository name
sign key id
.TP
\fB\-\-sign\-target\fR \fI\,{disabled,pacakges,repository}\/\fR
\fB\-\-sign\-target\fR \fI\,{disabled,packages,repository}\/\fR
sign options
.TP
\fB\-\-web\-port\fR \fI\,WEB_PORT\/\fR
port of the web service
.TP
\fB\-\-web\-unix\-socket\fR \fI\,WEB_UNIX_SOCKET\/\fR
path to unix socket used for interprocess communications
.SH COMMAND \fI\,'ahriman repo\-sign'\/\fR
usage: ahriman repo\-sign [\-h] [package ...]
@ -581,7 +590,7 @@ run triggers on empty build result as configured by settings
.TP
\fBtrigger\fR
instead of running all triggers as set by configuration, just process specified ones oin order of metion
instead of running all triggers as set by configuration, just process specified ones in order of mention
.SH COMMAND \fI\,'ahriman repo\-update'\/\fR
usage: ahriman repo\-update [\-h] [\-\-dry\-run] [\-e] [\-\-aur | \-\-no\-aur] [\-\-local | \-\-no\-local] [\-\-manual | \-\-no\-manual]
@ -633,7 +642,7 @@ drop into python shell while having created application
instead of dropping into shell, just execute the specified code
.SH COMMAND \fI\,'ahriman user\-add'\/\fR
usage: ahriman user\-add [\-h] [\-\-as\-service] [\-p PASSWORD] [\-r {unauthorized,read,reporter,full}] [\-s] username
usage: ahriman user\-add [\-h] [\-p PASSWORD] [\-r {unauthorized,read,reporter,full}] [\-s] username
update user for web services with the given password and role. In case if password was not entered it will be asked interactively
@ -642,10 +651,6 @@ update user for web services with the given password and role. In case if passwo
username for web service
.SH OPTIONS \fI\,'ahriman user\-add'\/\fR
.TP
\fB\-\-as\-service\fR
add user as service user
.TP
\fB\-p\fR \fI\,PASSWORD\/\fR, \fB\-\-password\fR \fI\,PASSWORD\/\fR
user password. Blank password will be treated as empty password, which is in particular must be used for OAuth2
@ -678,7 +683,7 @@ return non\-zero exit status if result is empty
filter users by role
.SH COMMAND \fI\,'ahriman user\-remove'\/\fR
usage: ahriman user\-remove [\-h] [\-s] username
usage: ahriman user\-remove [\-h] username
remove user from the user mapping and update the configuration
@ -686,11 +691,6 @@ remove user from the user mapping and update the configuration
\fBusername\fR
username for web service
.SH OPTIONS \fI\,'ahriman user\-remove'\/\fR
.TP
\fB\-s\fR, \fB\-\-secure\fR
set file permissions to user\-only
.SH COMMAND \fI\,'ahriman version'\/\fR
usage: ahriman version [\-h]

View File

@ -4,6 +4,14 @@ ahriman.core.log package
Submodules
----------
ahriman.core.log.filtered\_access\_logger module
------------------------------------------------
.. automodule:: ahriman.core.log.filtered_access_logger
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.log.http\_log\_handler module
------------------------------------------

View File

@ -12,6 +12,22 @@ ahriman.web.views.service.add module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.pgp module
------------------------------------
.. automodule:: ahriman.web.views.service.pgp
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.rebuild module
----------------------------------------
.. automodule:: ahriman.web.views.service.rebuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.remove module
---------------------------------------
@ -36,6 +52,14 @@ ahriman.web.views.service.search module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.service.update module
---------------------------------------
.. automodule:: ahriman.web.views.service.update
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -28,7 +28,7 @@ This package contains application (aka executable) related classes and everythin
``ahriman.core`` package
^^^^^^^^^^^^^^^^^^^^^^^^
This package contains everything which is required for any time of application run and separated into several packages:
This package contains everything required for the most of application actions and it is separated into several packages:
* ``ahriman.core.alpm`` package controls pacman related functions. It provides wrappers for ``pyalpm`` library and safe calls for repository tools (``repo-add`` and ``repo-remove``). Also this package contains ``ahriman.core.alpm.remote`` package which provides wrapper for remote sources (e.g. AUR RPC and official repositories RPC).
* ``ahriman.core.auth`` package provides classes for authorization methods used by web mostly. Base class is ``ahriman.core.auth.Auth`` which must be called by ``load`` method.
@ -36,6 +36,7 @@ This package contains everything which is required for any time of application r
* ``ahriman.core.database`` is everything including data and schema migrations for database.
* ``ahriman.core.formatters`` package provides ``Printer`` sub-classes for printing data (e.g. package properties) to stdout which are used by some handlers.
* ``ahriman.core.gitremote`` is a package with remote PKGBUILD triggers. Should not be called directly.
* ``ahriman.core.log`` is a log utils package. It includes logger loader class, custom HTTP based logger and access logger for HTTP services with additional filters.
* ``ahriman.core.report`` is a package with reporting triggers. Should not be called directly.
* ``ahriman.core.repository`` contains several traits and base repository (``ahriman.core.repository.Repository`` class) implementation.
* ``ahriman.core.sign`` package provides sign feature (only gpg calls are available).
@ -53,7 +54,7 @@ This package also provides some generic functions and classes which may be used
``ahriman.models`` package
^^^^^^^^^^^^^^^^^^^^^^^^^^
It provides models for any other part of application. Unlike ``ahriman.core`` package classes from here provides only conversion methods (e.g. create class from another or convert to). Mostly case classes and enumerations.
It provides models for any other part of application. Unlike ``ahriman.core`` package classes from here provide only conversion methods (e.g. create class from another or convert to). Mostly case classes and enumerations.
``ahriman.web`` package
^^^^^^^^^^^^^^^^^^^^^^^
@ -76,7 +77,7 @@ Application run
* Return result (success or failure) of each subprocess and exit from application.
* Some handlers may override their status and throw ``ExitCode`` exception. This exception is just silently suppressed and changes application exit code to ``1``.
In most cases handlers spawn god class ``ahriman.application.application.Application`` class and call required methods.
In the most cases handlers spawn god class ``ahriman.application.application.Application`` class and call required methods.
Application is designed to run from ``systemd`` services and provides parametrized by architecture timer and service file for that.
@ -196,14 +197,16 @@ means that there is user ``username`` with ``read`` access and password ``passwo
OAuth provider uses library definitions (``aioauth-client``) in order *authenticate* users. It still requires user permission to be set in database, thus it inherits mapping provider without any changes. Whereas we could override ``check_credentials`` (authentication method) by something custom, OAuth flow is a bit more complex than just forward request, thus we have to implement the flow in login form.
OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature is used by service reporting (aka robots).
OAuth's implementation also allows authenticating users via username + password (in the same way as mapping does) though it is not recommended for end-users and password must be left blank. In particular this feature can be used by service reporting (aka robots).
In addition, web service checks the source socket used. In case if it belongs to ``socket.AF_UNIX`` family, it will skip any furher checks considering the request to be performed in safe environment (e.g. on the same physical machine). This feature, in particular is being used by the reporter instances in case if socket address is set in configuration.
In order to configure users there are special commands.
Triggers
^^^^^^^^
Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit. The package provides two default extensions - one is report generation and another one is remote upload feature.
Triggers are extensions which can be used in order to perform any actions on application start, after the update process and, finally, before the application exit.
The main idea is to load classes by their full path (e.g. ``ahriman.core.upload.UploadTrigger``) by using ``importlib``: get the last part of the import and treat it as class name, join remain part by ``.`` and interpret as module path, import module and extract attribute from it.
@ -244,6 +247,7 @@ Web application requires the following python packages to be installed:
* In addition, ``aiohttp_debugtoolbar`` is required for debug panel. Please note that this option does not work together with authorization and basically must not be used in production.
* In addition, authorization feature requires ``aiohttp_security``, ``aiohttp_session`` and ``cryptography``.
* In addition to base authorization dependencies, OAuth2 also requires ``aioauth-client`` library.
* In addition if you would like to disable authorization for local access (recommended way in order to run the application itself with reporting support), the ``requests-unixsocket`` library is required.
Middlewares
^^^^^^^^^^^

View File

@ -25,20 +25,10 @@ sys.path.insert(0, str(basedir))
on_rtd = os.environ.get("READTHEDOCS", None) == "True"
for module in (
"aioauth_client",
"aiohttp",
"aiohttp.web",
"aiohttp.web_exceptions",
"aiohttp.web_response",
"aiohttp.web_urldispatcher",
"aiohttp_jinja2",
"aiohttp_security",
"aiohttp_session",
"aiohttp_session.cookie_storage",
"boto3",
"cryptography",
"pyalpm",
):
if module in sys.modules:
continue
sys.modules[module] = mock.Mock()
@ -89,7 +79,7 @@ html_theme = "default" if on_rtd else "alabaster"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
html_static_path = []
add_module_names = False

View File

@ -44,7 +44,7 @@ Base authorization settings. ``OAuth`` provider requires ``aioauth-client`` libr
* ``max_age`` - parameter which controls both cookie expiration and token expiration inside the service, integer, optional, default is 7 days.
* ``oauth_provider`` - OAuth2 provider class name as is in ``aioauth-client`` (e.g. ``GoogleClient``, ``GithubClient`` etc), string, required in case if ``oauth`` is used.
* ``oauth_scopes`` - scopes list for OAuth2 provider, which will allow retrieving user email (which is used for checking user permissions), e.g. ``https://www.googleapis.com/auth/userinfo.email`` for ``GoogleClient`` or ``user:email`` for ``GithubClient``, space separated list of strings, required in case if ``oauth`` is used.
* ``salt`` - password hash salt, string, required in case if authorization enabled (automatically generated by ``create-user`` subcommand).
* ``salt`` - password hash salt, string, required in case if authorization enabled (automatically generated by ``user-add`` subcommand).
Authorized users are stored inside internal database, if any of external provides are used the password field for non-service users must be empty.
@ -115,7 +115,7 @@ Report generation settings.
* ``target`` - list of reports to be generated, space separated list of strings, required. It must point to valid section (or to section with architecture), e.g. ``somerandomname`` must point to existing section, ``email`` must point to either ``email`` or ``email:x86_64`` (the one with architecture has higher priority).
Type will be read from several ways:
Type will be read from several sources:
* In case if ``type`` option set inside the section, it will be used.
* Otherwise, it will look for type from section name removing architecture name.
@ -180,7 +180,7 @@ Remote synchronization settings.
* ``target`` - list of synchronizations to be used, space separated list of strings, required. It must point to valid section (or to section with architecture), e.g. ``somerandomname`` must point to existing section, ``github`` must point to one of ``github`` of ``github:x86_64`` (with architecture it has higher priority).
Type will be read from several ways:
Type will be read from several sources:
* In case if ``type`` option set inside the section, it will be used.
* Otherwise, it will look for type from section name removing architecture name.
@ -240,4 +240,5 @@ Web server settings. If any of ``host``/``port`` is not set, web integration wil
* ``port`` - port to bind, int, optional.
* ``static_path`` - path to directory with static files, string, required.
* ``templates`` - path to templates directory, string, required.
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
* ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled.

View File

@ -169,13 +169,30 @@ Unlike ``RemotePullTrigger`` trigger, the ``RemotePushTrigger`` more likely will
How to change PKGBUILDs before build
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Well it is supported also. The recommended way is to patch specific function, e.g. by running ``sudo -u ahriman ahriman patch-add ahriman version``. This command will prompt for new value of the PKGBUILD variable ``version``. You can also write it to file and read from it ``sudo -u ahriman ahriman patch-add ahriman version version.patch``.
Well it is supported also. The recommended way is to patch specific function, e.g. by running
.. code-block:: shell
sudo -u ahriman ahriman patch-add ahriman version
This command will prompt for new value of the PKGBUILD variable ``version``. You can also write it to file and read from it:
.. code-block:: shell
sudo -u ahriman ahriman patch-add ahriman version version.patch
Alternatively you can create full-diff patches, which are calculated by using ``git diff`` from current PKGBUILD master branch:
#. Clone sources from AUR.
#. Make changes you would like to (e.g. edit ``PKGBUILD``, add external patches).
#. Run ``sudo -u ahriman ahriman patch-set-add /path/to/local/directory/with/PKGBUILD``.
#.
Clone sources from AUR.
#.
Make changes you would like to (e.g. edit ``PKGBUILD``, add external patches).
#.
Run command
.. code-block:: shell
sudo -u ahriman ahriman patch-set-add /path/to/local/directory/with/PKGBUILD
The last command will calculate diff from current tree to the ``HEAD`` and will store it locally. Patches will be applied on any package actions (e.g. it can be used for dependency management).
@ -219,6 +236,62 @@ Also, there is command ``repo-remove-unknown`` which checks packages in AUR and
Remove commands also remove any package files (patches, caches etc).
How to sign repository
^^^^^^^^^^^^^^^^^^^^^^
Repository sign feature is available in several configurations. The recommended way is just to sign repository database file by single key instead of trying to sign each package. However, the steps are pretty same, just configuration is a bit differ. For more details about options kindly refer to :doc:`configuration reference <configuration>`.
#.
First you would need to create the key on your local machine:
.. code-block:: shell
gpg --full-generate-key
This command will prompt you for several questions. Most of them may be left default, but you will need to fill real name and email address with some data. Because at the moment the service doesn't support passphrases, it must be left blank.
#.
The command above will generate key and print its hash, something like ``8BE91E5A773FB48AC05CC1EDBED105AED6246B39``. Copy it.
#.
Export your private key by using the hash above:
.. code-block:: shell
gpg --export-secret-keys -a 8BE91E5A773FB48AC05CC1EDBED105AED6246B39 > repository-key.gpg
#.
Copy the specified key to the build machine (i.e. where the service is running).
#.
Import the specified key to the service user:
.. code-block:: shell
sudo -u ahriman gpg --import repository-key.gpg
Don't forget to remove the key from filesystem after import.
#.
Change trust level to ``ultimate``:
.. code-block:: shell
sudo -u ahriman gpg --edit-key 8BE91E5A773FB48AC05CC1EDBED105AED6246B39
The command above will drop you into gpg shell, in which you will need to type ``trust``, choose ``5 = I trust ultimately``, confirm and exit ``quit``.
#.
Proceed with service configuration according to the :doc:`configuration <configuration>`:
.. code-block:: ini
[sign]
target = repository
key = 8BE91E5A773FB48AC05CC1EDBED105AED6246B39
How to rebuild packages after library update
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -290,17 +363,17 @@ The default action (in case if no arguments provided) is ``repo-update``. Basica
docker run --privileged -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
``--privileged`` flag is required to make mount possible inside container. In addition, you can pass own configuration overrides by using the same ``-v`` flag, e.g.:
``--privileged`` flag is required to make mount possible inside container. In order to make data available outside of container, you would need to mount local (parent) directory inside container by using ``-v /path/to/local/repo:/var/lib/ahriman`` argument, where ``/path/to/local/repo`` is a path to repository on local machine. In addition, you can pass own configuration overrides by using the same ``-v`` flag, e.g.:
.. code-block:: shell
docker run -v /path/to/local/repo:/var/lib/ahriman -v /etc/ahriman.ini:/etc/ahriman.ini.d/10-overrides.ini arcan1s/ahriman:latest
docker run --privileged -v /path/to/local/repo:/var/lib/ahriman -v /path/to/overrides/overrides.ini:/etc/ahriman.ini.d/10-overrides.ini arcan1s/ahriman:latest
The action can be specified during run, e.g.:
.. code-block:: shell
docker run arcan1s/ahriman:latest package-add ahriman --now
docker run --privileged -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest package-add ahriman --now
For more details please refer to docker FAQ.
@ -318,13 +391,25 @@ The following environment variables are supported:
* ``AHRIMAN_PORT`` - HTTP server port if any, default is empty.
* ``AHRIMAN_REPOSITORY`` - repository name, default is ``aur-clone``.
* ``AHRIMAN_REPOSITORY_ROOT`` - repository root. Because of filesystem rights it is required to override default repository root. By default, it uses ``ahriman`` directory inside ahriman's home, which can be passed as mount volume.
* ``AHRIMAN_UNIX_SOCKET`` - full path to unix socket which is used by web server, default is empty. Note that more likely you would like to put it inside ``AHRIMAN_REPOSITORY_ROOT`` directory (e.g. ``/var/lib/ahriman/ahriman/ahriman-web.sock``) or to ``/tmp``.
* ``AHRIMAN_USER`` - ahriman user, usually must not be overwritten, default is ``ahriman``.
You can pass any of these variables by using ``-e`` argument, e.g.:
.. code-block:: shell
docker run -e AHRIMAN_PORT=8080 arcan1s/ahriman:latest
docker run --privileged -e AHRIMAN_PORT=8080 -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
Daemon service
^^^^^^^^^^^^^^
There is special ``daemon`` subcommand which emulates systemd timer and will perform repository update periodically:
.. code-block:: shell
docker run --privileged -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest daemon
This command uses same rules as ``repo-update``, thus, e.g. requires ``--privileged`` flag.
Web service setup
^^^^^^^^^^^^^^^^^
@ -333,26 +418,23 @@ Well for that you would need to have web container instance running forever; it
.. code-block:: shell
docker run -p 8080:8080 -e AHRIMAN_PORT=8080 -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
docker run --privileged -p 8080:8080 -e AHRIMAN_PORT=8080 -e AHRIMAN_UNIX_SOCKET=/var/lib/ahriman/ahriman/ahriman-web.sock -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
Note about ``AHRIMAN_PORT`` environment variable which is required in order to enable web service. An additional port bind by ``-p 8080:8080`` is required to pass docker port outside of container.
For every next container run use arguments ``-e AHRIMAN_PORT=8080 --net=host``, e.g.:
The ``AHRIMAN_UNIX_SOCKET`` variable is not required, however, highly recommended as it can be used for interprocess communications. If you set this variable you would like to be sure that this path is available outside of container if you are going to use multiple docker instances.
If you are using ``AHRIMAN_UNIX_SOCKET`` variable, for every next container run it has to be passed also, e.g.:
.. code-block:: shell
docker run --privileged -e AHRIMAN_PORT=8080 --net=host -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
docker run --privileged -e AHRIMAN_UNIX_SOCKET=/var/lib/ahriman/ahriman/ahriman-web.sock -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
Daemon service
^^^^^^^^^^^^^^
There is special subcommand which emulates systemd timer and will perform repository update periodically:
Otherwise, you would need to pass ``AHRIMAN_PORT`` and mount container network to the host system (``--net=host``), e.g.:
.. code-block:: shell
docker run arcan1s/ahriman:latest daemon
This command uses same rules as ``repo-update``, thus, e.g. requires ``--privileged`` flag.
docker run --privileged --net=host -e AHRIMAN_PORT=8080 -v /path/to/local/repo:/var/lib/ahriman arcan1s/ahriman:latest
Remote synchronization
----------------------
@ -610,19 +692,41 @@ How to enable basic authorization
[auth]
target = configuration
#.
Create user for the service:
#.
In order to provide access for reporting from application instances you can (recommended way) use unix sockets by configuring the following (note, that it requires ``python-requests-unixsocket`` package to be installed):
.. code-block:: ini
[web]
unix_socket = /var/lib/ahriman/ahriman-web.sock
This socket path must be available for web service instance and must be available for application instances (e.g. in case if you are using docker container, see above, you need to be sure that the socket is passed to the root filesystem).
By the way, unix socket variable will be automatically set in case if ``--web-unix-socket`` argument is supplied to the ``setup`` subcommand.
Alternatively, you need to create user for the service:
.. code-block:: shell
sudo -u ahriman ahriman user-add --as-service -r write api
sudo -u ahriman ahriman user-add -r full api
This command will ask for the password, just type it in stdin; *do not* leave the field blank, user will not be able to authorize.
This command will ask for the password, just type it in stdin; *do not* leave the field blank, user will not be able to authorize, and finally configure the application:
#.
Create end-user ``sudo -u ahriman ahriman user-add -r write my-first-user`` with password.
.. code-block:: ini
#. Restart web service ``systemctl restart ahriman-web@x86_64``.
[web]
username = api
password = pa55w0rd
#.
Create end-user with password:
.. code-block:: shell
sudo -u ahriman ahriman user-add -r full my-first-user
#.
Restart web service ``systemctl restart ahriman-web@x86_64``.
How to enable OAuth authorization
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -656,12 +760,19 @@ How to enable OAuth authorization
.. code-block:: shell
sudo -u ahriman ahriman user-add --as-service -r write api
sudo -u ahriman ahriman user-add --as-service -r full api
#.
Create end-user ``sudo -u ahriman ahriman user-add -r write my-first-user``. When it will ask for the password leave it blank.
Create end-user:
#. Restart web service ``systemctl restart ahriman-web@x86_64``.
.. code-block:: shell
sudo -u ahriman ahriman user-add -r full my-first-user
When it will ask for the password leave it blank.
#.
Restart web service ``systemctl restart ahriman-web@x86_64``.
Backup and restore
------------------
@ -681,7 +792,7 @@ The service provides several commands aim to do easy repository backup and resto
Copy created archive from source server ``server1.example.com`` to target ``server2.example.com``.
#.
Install ahriman as usual on the target server ``server2.example.com`` if you didn't yet.
Install package as usual on the target server ``server2.example.com`` if you didn't yet.
#.
Extract archive e.g. by using subcommand:

View File

@ -16,6 +16,13 @@ Features
* Triggers for repository updates, e.g. synchronization to remote services (rsync, s3 and github) and report generation (email, html, telegram).
* Repository status interface with optional authorization and control options.
Live demos
----------
* `Build status page <https://ahriman-demo.arcanis.me>`_. You can login as ``demo`` user by using ``demo`` password. Note, however, you will not be able to run tasks.
* `Repository index <http://repo.arcanis.me/x86_64/index.html>`_.
* `Telegram feed <https://t.me/arcanisrepo>`_.
Contents
--------

View File

@ -19,7 +19,7 @@ For the configuration details and settings explanation kindly refer to the :doc:
This trigger will be called before any action (``on_start``) and pulls remote PKGBUILD repository locally; after that it copies found PKGBUILDs from the cloned repository to the local cache. It is useful in case if you have patched PGKBUILDs (or even missing in AUR) which you would like to use for package building and, technically, just simplifies the local package building.
In order to update those packages you would need to clone your repository separately, make changes in PKGBUILD (e.g. bump version and update checksums), commit them and push back. On the next ahriman's repository update, it will pull changes you commited and will perform package update.
In order to update those packages you would need to clone your repository separately, make changes in PKGBUILD (e.g. bump version and update checksums), commit them and push back. On the next ahriman's repository update, it will pull changes you committed and will perform package update.
``ahriman.core.gitremote.RemotePushTrigger``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev
pkgname='ahriman'
pkgver=2.3.0
pkgver=2.4.0
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')
@ -20,6 +20,7 @@ optdepends=('breezy: -bzr packages support'
'python-aiohttp-session: web server with authorization'
'python-boto3: sync to s3'
'python-cryptography: web server with authorization'
'python-requests-unixsocket: client report to web server by unix socket'
'python-jinja: html report generation'
'rsync: sync by using rsync'
'subversion: -svn packages support')

View File

@ -12,6 +12,8 @@
<body>
{% include "utils/bootstrap-scripts.jinja2" %}
<div class="container">
<h1>ahriman
<img id="badge-version" src="https://img.shields.io/badge/version-unknown-informational" alt="unknown">
@ -22,16 +24,35 @@
</div>
<div class="container">
<div id="toolbar">
<div id="toolbar" class="dropdown">
{% if not auth.enabled or auth.username is not none %}
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-form" hidden>
<i class="bi bi-plus"></i> add
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-box"></i> packages
</button>
<button id="update-btn" class="btn btn-secondary" onclick="updatePackages()" hidden>
<i class="bi bi-play"></i> update
</button>
<button id="remove-btn" class="btn btn-danger" onclick="removePackages()" disabled hidden>
<i class="bi bi-trash"></i> remove
<ul class="dropdown-menu">
<li>
<button id="package-add-btn" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal" hidden>
<i class="bi bi-plus"></i> add
</button>
</li>
<li>
<button id="package-update-btn" class="btn dropdown-item" onclick="updatePackages()" hidden>
<i class="bi bi-play"></i> update
</button>
</li>
<li>
<button id="package-rebuild-btn" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal" hidden>
<i class="bi bi-arrow-clockwise"></i> rebuild
</button>
</li>
<li>
<button id="package-remove-btn" class="btn dropdown-item" onclick="removePackages()" disabled hidden>
<i class="bi bi-trash"></i> remove
</button>
</li>
</ul>
<button id="key-import-btn" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal" hidden>
<i class="bi bi-key"></i> import key
</button>
{% endif %}
<button class="btn btn-secondary" onclick="reload()">
@ -87,13 +108,17 @@
{% endif %}
{% if auth.enabled %}
{% if auth.username is none %}
{{ auth.control|safe }}
{% else %}
<form action="/api/v1/logout" method="post">
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
</form>
{% endif %}
<ul class="nav">
{% if auth.username is none %}
<li>{{ auth.control|safe }}</li>
{% else %}
<li>
<form action="/api/v1/logout" method="post">
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
</form>
</li>
{% endif %}
</ul>
{% endif %}
</footer>
</div>
@ -102,12 +127,12 @@
{% include "build-status/login-modal.jinja2" %}
{% endif %}
{% include "utils/bootstrap-scripts.jinja2" %}
{% include "build-status/failed-modal.jinja2" %}
{% include "build-status/success-modal.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/package-rebuild-modal.jinja2" %}
{% include "build-status/key-import-modal.jinja2" %}
{% include "build-status/package-info-modal.jinja2" %}

View File

@ -1,13 +1,13 @@
<div id="failed-form" tabindex="-1" role="dialog" class="modal fade">
<div id="failed-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h4 id="error-title" class="modal-title"></h4>
<h4 id="failed-title" class="modal-title"></h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<p id="error-description"></p>
<p id="error-details"></p>
<p id="failed-description"></p>
<p id="failed-details"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
@ -17,16 +17,18 @@
</div>
<script>
const failedForm = $("#failed-form");
const errorDescription = $("#error-description");
const errorDetails = $("#error-details");
const errorTitle = $("#error-title");
failedForm.on("hidden.bs.modal", () => { reload(); });
const failedModal = $("#failed-modal");
failedModal.on("hidden.bs.modal", () => { reload(); });
const failedDescription = $("#failed-description");
const failedDetails = $("#failed-details");
const failedTitle = $("#failed-title");
function showFailure(title, description, details) {
errorTitle.text(title);
errorDescription.text(description);
errorDetails.text(details);
failedForm.modal("show");
failedTitle.text(title);
failedDescription.text(description);
failedDetails.text(details);
failedModal.modal("show");
}
</script>

View File

@ -0,0 +1,92 @@
<div id="key-import-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<form id="key-import-form" onsubmit="return false">
<div class="modal-header">
<h4 class="modal-title">Import key from PGP server</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="key-fingerprint-input" class="col-sm-2 col-form-label">fingerprint</label>
<div class="col-sm-10">
<input id="key-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required>
</div>
</div>
<div class="form-group row">
<label for="key-server-input" class="col-sm-2 col-form-label">key server</label>
<div class="col-sm-10">
<input id="key-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<pre class="language-less"><code id="key-body-input" class="pre-scrollable language-less"></code><button id="key-copy-btn" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="importPgpKey()"><i class="bi bi-play"></i> import</button>
<button type="submit" class="btn btn-success" onclick="fetchPgpKey()"><i class="bi bi-arrow-clockwise"></i> fetch</button>
</div>
</form>
</div>
</div>
</div>
<script>
const keyImportModal = $("#key-import-modal");
const keyImportForm = $("#key-import-form");
keyImportModal.on("hidden.bs.modal", () => {
keyBodyInput.text("");
keyImportForm.trigger("reset");
});
const keyBodyInput = $("#key-body-input");
const keyCopyButton = $("#key-copy-btn");
const keyFingerprintInput = $("#key-fingerprint-input");
const keyServerInput = $("#key-server-input");
async function copyPgpKey() {
const logs = keyBodyInput.text();
await copyToClipboard(logs, keyCopyButton);
}
function fetchPgpKey() {
const key = keyFingerprintInput.val();
const server = keyServerInput.val();
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: {"key": key, "server": server},
type: "GET",
dataType: "json",
success: response => { keyBodyInput.text(response.key); },
});
}
}
function importPgpKey() {
const key = keyFingerprintInput.val();
const server = keyServerInput.val();
if (key && server) {
$.ajax({
url: "/api/v1/service/pgp",
data: JSON.stringify({key: key, server: server}),
type: "POST",
contentType: "application/json",
success: _ => {
keyImportModal.modal("hide");
showSuccess("Success", `Key ${key} has been imported`, "");
},
error: (jqXHR, _, errorThrown) => {
showFailure("Action failed", `Could not import key ${key} from ${server}`, errorThrown);
},
});
}
}
</script>

View File

@ -1,4 +1,4 @@
<div id="loginForm" tabindex="-1" role="dialog" class="modal fade">
<div id="login-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form action="/api/v1/login" method="post">
@ -16,7 +16,12 @@
<div class="form-group row">
<label for="password" class="col-sm-2 col-form-label">password</label>
<div class="col-sm-10">
<input id="password" type="password" class="form-control" placeholder="enter password" name="password" required>
<div class="input-group">
<input id="password" type="password" class="form-control" placeholder="enter password" name="password" required>
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" onclick="showPassword()"><i id="show-hide-password-btn" class="bi bi-eye"></i></button>
</div>
</div>
</div>
</div>
</div>
@ -27,3 +32,20 @@
</div>
</div>
</div>
<script>
const passwordInput = $("#password");
const showHidePasswordButton = $("#show-hide-password-btn");
function showPassword() {
if (passwordInput.attr("type") === "password") {
passwordInput.attr("type", "text");
showHidePasswordButton.removeClass("bi-eye");
showHidePasswordButton.addClass("bi-eye-slash");
} else {
passwordInput.attr("type", "password");
showHidePasswordButton.removeClass("bi-eye-slash");
showHidePasswordButton.addClass("bi-eye");
}
}
</script>

View File

@ -1,61 +1,74 @@
<div id="add-form" tabindex="-1" role="dialog" class="modal fade">
<div id="package-add-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Add new packages</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package" class="col-sm-2 col-form-label">package</label>
<div class="col-sm-10">
<input id="package-form" type="text" list="known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="known-packages-dlist"></datalist>
<form id="package-add-form" onsubmit="return false">
<div class="modal-header">
<h4 class="modal-title">Add new packages</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="package-input" class="col-sm-2 col-form-label">package</label>
<div class="col-sm-10">
<input id="package-input" type="text" list="known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required>
<datalist id="known-packages-dlist"></datalist>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPackages()"><i class="bi bi-play"></i> add</button>
<button type="button" class="btn btn-success" data-bs-dismiss="modal" onclick="requestPackages()"><i class="bi bi-plus"></i> request</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button>
<button type="submit" class="btn btn-success" onclick="packagesRequest()"><i class="bi bi-plus"></i> request</button>
</div>
</form>
</div>
</div>
</div>
<script>
const packageInput = $("#package-form");
const knownPackages = $("#known-packages-dlist");
const packageAddModal = $("#package-add-modal");
const packageAddForm = $("#package-add-form");
packageAddModal.on("hidden.bs.modal", () => { packageAddForm.trigger("reset"); });
const packageInput = $("#package-input");
const knownPackagesList = $("#known-packages-dlist");
packageInput.keyup(() => {
clearTimeout(packageInput.data("timeout"));
packageInput.data("timeout", setTimeout($.proxy(() => {
const value = packageInput.val();
$.ajax({
url: "/api/v1/service/search",
data: {"for": value},
type: "GET",
dataType: "json",
success: response => {
const options = response.map(pkg => {
const option = document.createElement("option");
option.value = pkg.package;
option.innerText = `${pkg.package} (${pkg.description})`;
return option;
});
knownPackages.empty().append(options);
},
})
if (value.length >= 3) {
$.ajax({
url: "/api/v1/service/search",
data: {"for": value},
type: "GET",
dataType: "json",
success: response => {
const options = response.map(pkg => {
const option = document.createElement("option");
option.value = pkg.package;
option.innerText = `${pkg.package} (${pkg.description})`;
return option;
});
knownPackagesList.empty().append(options);
},
});
}
}, this), 500));
});
function addPackages() {
const packages = [packageInput.val()];
doPackageAction("/api/v1/service/add", packages);
function packagesAdd() {
const packages = packageInput.val();
if (packages) {
packageAddModal.modal("hide");
doPackageAction("/api/v1/service/add", [packages], "The following package has been added:", "Package addition failed:");
}
}
function requestPackages() {
const packages = [packageInput.val()];
doPackageAction("/api/v1/service/request", packages);
function packagesRequest() {
const packages = packageInput.val();
if (packages) {
packageAddModal.modal("hide");
doPackageAction("/api/v1/service/request", [packages], "The following package has been requested:", "Package request failed:");
}
}
</script>

View File

@ -1,4 +1,4 @@
<div id="package-info-form" tabindex="-1" role="dialog" class="modal fade">
<div id="package-info-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div id="package-info-modal-header" class="modal-header">
@ -6,7 +6,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<pre class="pre-scrollable language-logs"><code id="package-info-logs" class="language-logs"></code><button id="copy-btn" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
<pre class="language-logs"><code id="package-info-logs-input" class="pre-scrollable language-logs"></code><button id="logs-copy-btn" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="showLogs()"><i class="bi bi-arrow-clockwise"></i> reload</button>
@ -17,28 +17,24 @@
</div>
<script>
const packageInfoModal = $("#package-info-modal");
const packageInfoModalHeader = $("#package-info-modal-header");
const packageInfo = $("#package-info");
const packageInfoForm = $("#package-info-form");
const packageInfoHeader = $("#package-info-modal-header");
const packageInfoLogs = $("#package-info-logs");
const packageInfoLogsCopyButton = $("#copy-btn");
const packageInfoLogsInput = $("#package-info-logs-input");
const packageInfoLogsCopyButton = $("#logs-copy-btn");
async function copyLogs() {
const logs = packageInfoLogs.text();
await navigator.clipboard.writeText(logs);
packageInfoLogsCopyButton.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(()=> {
packageInfoLogsCopyButton.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
const logs = packageInfoLogsInput.text();
await copyToClipboard(logs, packageInfoLogsCopyButton);
}
function showLogs(package) {
const isPackageBaseSet = package !== undefined;
function showLogs(packageBase) {
const isPackageBaseSet = packageBase !== undefined;
if (isPackageBaseSet)
packageInfoForm.data("package", package); // set package base as currently used
packageInfoModal.data("package", packageBase); // set package base as currently used
else
package = packageInfoForm.data("package"); // read package base from the current window attribute
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
@ -49,22 +45,22 @@
};
$.ajax({
url: `/api/v1/packages/${package}/logs`,
url: `/api/v1/packages/${packageBase}/logs`,
type: "GET",
dataType: "json",
success: response => {
packageInfo.text(`${response.package_base} ${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`);
packageInfoLogs.text(response.logs);
packageInfoLogsInput.text(response.logs);
packageInfoHeader.removeClass();
packageInfoHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoHeader.addClass(clz));
packageInfoModalHeader.removeClass();
packageInfoModalHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoModalHeader.addClass(clz));
if (isPackageBaseSet) packageInfoForm.modal("show"); // we don't need to show window again
if (isPackageBaseSet) packageInfoModal.modal("show"); // we don't need to show window again
},
error: (jqXHR, _, errorThrown) => {
// show failed modal in case if first time loading
if (isPackageBaseSet) showFailure("Load failure", `Could not load package ${package} logs:`, errorThrown);
if (isPackageBaseSet) showFailure("Load failure", `Could not load package ${packageBase} logs:`, errorThrown);
},
});
}

View File

@ -0,0 +1,39 @@
<div id="package-rebuild-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form id="package-rebuild-form" onsubmit="return false">
<div class="modal-header">
<h4 class="modal-title">Rebuild depending packages</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div>
<div class="modal-body">
<div class="form-group row">
<label for="dependency-input" class="col-sm-4 col-form-label">dependency</label>
<div class="col-sm-8">
<input id="dependency-input" type="text" class="form-control" placeholder="packages dependency" name="package" required>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="packagesRebuild()"><i class="bi bi-play"></i> rebuild</button>
</div>
</form>
</div>
</div>
</div>
<script>
const packageRebuildModal = $("#package-rebuild-modal");
const packageRebuildForm = $("#package-rebuild-form");
packageRebuildModal.on("hidden.bs.modal", () => { packageRebuildForm.trigger("reset"); });
const dependencyInput = $("#dependency-input");
function packagesRebuild() {
const packages = dependencyInput.val();
if (packages) {
packageRebuildModal.modal("hide");
doPackageAction("/api/v1/service/rebuild", [packages], "Repository rebuild ran for the following dependencies:", "Repository rebuild failed:");
}
}
</script>

View File

@ -1,4 +1,4 @@
<div id="success-form" tabindex="-1" role="dialog" class="modal fade">
<div id="success-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-success text-white">
@ -17,16 +17,18 @@
</div>
<script>
const successForm = $("#success-form");
const successModal = $("#success-modal");
successModal.on("hidden.bs.modal", () => { reload(); });
const successDescription = $("#success-description");
const successDetails = $("#success-details");
const successTitle = $("#success-title");
successForm.on("hidden.bs.modal", () => { reload(); });
function showSuccess(title, description, details) {
successTitle.text(title);
successDescription.text(description);
successDetails.empty().append(details);
successForm.modal("show");
successModal.modal("show");
}
</script>

View File

@ -1,21 +1,27 @@
<script>
const addButton = $("#add-btn");
const removeButton = $("#remove-btn");
const updateButton = $("#update-btn");
const keyImportButton = $("#key-import-btn");
const packageAddButton = $("#package-add-btn");
const packageRebuildButton = $("#package-rebuild-btn");
const packageRemoveButton = $("#package-remove-btn");
const packageUpdateButton = $("#package-update-btn");
const table = $("#packages");
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table",
() => {
removeButton.prop("disabled", !table.bootstrapTable("getSelections").length);
});
table.on("click-row.bs.table", (_, row) => { showLogs(row.id); });
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", () => {
packageRemoveButton.prop("disabled", !table.bootstrapTable("getSelections").length);
});
table.on("click-row.bs.table", (self, data, row, cell) => {
if (0 === cell || "base" === cell) {
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
table.bootstrapTable(method, {field: "id", values: [data.id]});
} else showLogs(data.id);
});
const architectureBadge = $("#badge-architecture");
const repositoryBadge = $("#badge-repository");
const statusBadge = $("#badge-status");
const versionBadge = $("#badge-version");
function doPackageAction(uri, packages) {
function doPackageAction(uri, packages, successText, failureText) {
$.ajax({
url: uri,
data: JSON.stringify({packages: packages}),
@ -27,10 +33,10 @@
li.innerText = pkg;
return li;
});
showSuccess("Success", `Package action at ${uri} has been run on:`, details);
showSuccess("Success", successText, details);
},
error: (jqXHR, _, errorThrown) => {
showFailure("Action failed", `Package action request at ${uri} on ${packages} has failed:`, errorThrown);
showFailure("Action failed", failureText, errorThrown);
},
});
}
@ -39,18 +45,22 @@
return table.bootstrapTable("getSelections").map(row => { return row.id; });
}
function removePackages() { doPackageAction("/api/v1/service/remove", getSelection()); }
function removePackages() {
doPackageAction("/api/v1/service/remove", getSelection(), "The following packages have been removed:", "Packages removal failed:");
}
function updatePackages() {
const currentSelection = getSelection();
const url = currentSelection.length === 0 ? "/api/v1/service/update" : "/api/v1/service/add";
doPackageAction(url, getSelection());
doPackageAction(url, getSelection(), "Packages update has been run", "Packages update failed:");
}
function hideControls(hidden) {
addButton.attr("hidden", hidden);
removeButton.attr("hidden", hidden);
updateButton.attr("hidden", hidden);
keyImportButton.attr("hidden", hidden);
packageAddButton.attr("hidden", hidden);
packageRebuildButton.attr("hidden", hidden);
packageRemoveButton.attr("hidden", hidden);
packageUpdateButton.attr("hidden", hidden);
}
function reload() {

View File

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<title>Error</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/static/favicon.ico">
{% include "utils/style.jinja2" %}
</head>
<body>
{% include "utils/bootstrap-scripts.jinja2" %}
<div class="d-flex flex-row align-items-center">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-12 text-center">
<span class="display-1 d-block">{{ code }}</span>
<div class="mb-4 lead">{{ reason }}</div>
<a class="btn btn-link" style="text-decoration: none" href="/" title="home"><i class="bi bi-house"></i> home</a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -10,13 +10,15 @@
<body>
{% include "utils/bootstrap-scripts.jinja2" %}
<div class="container">
<h1>Arch linux user repository</h1>
</div>
<div class="container">
{% if pgp_key is not none %}
<p>This repository is signed with <a href="https://pgp.mit.edu/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
<p>This repository is signed with <a href="https://keyserver.ubuntu.com/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
{% endif %}
<p>In order to use this repository edit your <code>/etc/pacman.conf</code> as following:</p>
@ -93,20 +95,13 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
</footer>
</div>
{% include "utils/bootstrap-scripts.jinja2" %}
<script>
const pacmanConf = $("#pacman-conf");
const pacmanConfCopyButton = $("#copy-btn");
async function copyPacmanConf() {
const conf = pacmanConf.text();
await navigator.clipboard.writeText(conf);
pacmanConfCopyButton.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(() => {
pacmanConfCopyButton.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
await copyToClipboard(conf, pacmanConfCopyButton);
}
</script>

View File

@ -11,3 +11,23 @@
<script src="https://unpkg.com/bootstrap-table@1.21.1/dist/extensions/export/bootstrap-table-export.min.js"></script>
<script src="https://unpkg.com/bootstrap-table@1.21.1/dist/extensions/resizable/bootstrap-table-resizable.js"></script>
<script>
async function copyToClipboard(text, button) {
if (navigator.clipboard === undefined) {
const input = document.createElement("textarea");
input.innerHTML = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
} else {
await navigator.clipboard.writeText(text);
}
button.html("<i class=\"bi bi-clipboard-check\"></i> copied");
setTimeout(()=> {
button.html("<i class=\"bi bi-clipboard\"></i> copy");
}, 2000);
}
</script>

View File

@ -7,6 +7,7 @@
<style>
.pre-scrollable {
display: block;
max-height: 680px;
overflow-y: scroll;
}
@ -17,7 +18,7 @@
pre[class*="language-"] button{
position: absolute;
top: 0px;
top: 0;
right: 5px;
}
</style>

View File

@ -66,15 +66,18 @@ setup(
("share/ahriman/templates", [
"package/share/ahriman/templates/build-status.jinja2",
"package/share/ahriman/templates/email-index.jinja2",
"package/share/ahriman/templates/error.jinja2",
"package/share/ahriman/templates/repo-index.jinja2",
"package/share/ahriman/templates/shell",
"package/share/ahriman/templates/telegram-index.jinja2",
]),
("share/ahriman/templates/build-status", [
"package/share/ahriman/templates/build-status/failed-modal.jinja2",
"package/share/ahriman/templates/build-status/key-import-modal.jinja2",
"package/share/ahriman/templates/build-status/login-modal.jinja2",
"package/share/ahriman/templates/build-status/package-add-modal.jinja2",
"package/share/ahriman/templates/build-status/package-info-modal.jinja2",
"package/share/ahriman/templates/build-status/package-rebuild-modal.jinja2",
"package/share/ahriman/templates/build-status/success-modal.jinja2",
"package/share/ahriman/templates/build-status/table.jinja2",
]),
@ -131,6 +134,7 @@ setup(
"aiohttp_session",
"aiohttp_security",
"cryptography",
"requests-unixsocket", # required by unix socket support
],
},
)

View File

@ -68,8 +68,8 @@ def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="ahriman", description="ArcH linux ReposItory MANager",
epilog="Argument list can also be read from file by using @ prefix.",
fromfile_prefix_chars="@", formatter_class=_formatter)
parser.add_argument("-a", "--architecture", help="target architectures (can be used multiple times)",
action="append")
parser.add_argument("-a", "--architecture", help="target architectures. For several subcommands it can be used "
"multiple times", action="append")
parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini"))
parser.add_argument("--force", help="force run, remove file lock", action="store_true")
parser.add_argument("-l", "--lock", help="lock file", type=Path,
@ -169,7 +169,7 @@ def _set_daemon_parser(root: SubParserAction) -> argparse.ArgumentParser:
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=0)
action="count", default=False)
parser.set_defaults(handler=handlers.Daemon, dry_run=False, exit_code=False, package=[])
return parser
@ -229,7 +229,7 @@ def _set_key_import_parser(root: SubParserAction) -> argparse.ArgumentParser:
"fail in case if key is not known for build user. This subcommand can be used "
"in order to import the PGP key to user keychain.",
formatter_class=_formatter)
parser.add_argument("--key-server", help="key server for key import", default="pgp.mit.edu")
parser.add_argument("--key-server", help="key server for key import", default="keyserver.ubuntu.com")
parser.add_argument("key", help="PGP key to import from public server")
parser.set_defaults(handler=handlers.KeyImport, architecture=[""], lock=None, report=False)
return parser
@ -263,7 +263,7 @@ def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("-n", "--now", help="run update function after", action="store_true")
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=0)
action="count", default=False)
parser.add_argument("-s", "--source", help="explicitly specify the package source for this command",
type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto)
parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true")
@ -483,7 +483,7 @@ def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=0)
action="count", default=False)
parser.set_defaults(handler=handlers.Update, dry_run=True, aur=True, local=True, manual=False)
return parser
@ -632,6 +632,8 @@ def _set_repo_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--build-command", help="build command prefix", default="ahriman")
parser.add_argument("--from-configuration", help="path to default devtools pacman configuration",
type=Path, default=Path("/usr/share/devtools/pacman-extra.conf"))
parser.add_argument("--makeflags-jobs", help="append MAKEFLAGS variable with parallelism set to number of cores",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--multilib", help="add or do not multilib repository",
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--packager", help="packager name and email", required=True)
@ -640,6 +642,7 @@ def _set_repo_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser.add_argument("--sign-target", help="sign options", action="append",
type=SignSettings.from_option, choices=enum_values(SignSettings))
parser.add_argument("--web-port", help="port of the web service", type=int)
parser.add_argument("--web-unix-socket", help="path to unix socket used for interprocess communications", type=Path)
parser.set_defaults(handler=handlers.Setup, lock=None, report=False, quiet=True, unsafe=True)
return parser
@ -714,7 +717,7 @@ def _set_repo_triggers_parser(root: SubParserAction) -> argparse.ArgumentParser:
description="run triggers on empty build result as configured by settings",
formatter_class=_formatter)
parser.add_argument("trigger", help="instead of running all triggers as set by configuration, just process "
"specified ones oin order of metion", nargs="*")
"specified ones in order of mention", nargs="*")
parser.set_defaults(handler=handlers.Triggers)
return parser
@ -745,7 +748,7 @@ def _set_repo_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, "
"-yy to force refresh even if up to date",
action="count", default=0)
action="count", default=False)
parser.set_defaults(handler=handlers.Update)
return parser
@ -760,7 +763,7 @@ def _set_shell_parser(root: SubParserAction) -> argparse.ArgumentParser:
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("shell", help="envoke python shell",
parser = root.add_parser("shell", help="invoke python shell",
description="drop into python shell while having created application",
formatter_class=_formatter)
parser.add_argument("code", help="instead of dropping into shell, just execute the specified code", nargs="?")
@ -782,9 +785,10 @@ def _set_user_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
parser = root.add_parser("user-add", help="create or update user",
description="update user for web services with the given password and role. "
"In case if password was not entered it will be asked interactively",
epilog="In case of first run (i.e. if password salt is not set yet) this action requires "
"root privileges because it performs write to filesystem configuration.",
formatter_class=_formatter)
parser.add_argument("username", help="username for web service")
parser.add_argument("--as-service", help="add user as service user", action="store_true")
parser.add_argument("-p", "--password", help="user password. Blank password will be treated as empty password, "
"which is in particular must be used for OAuth2 authorization type.")
parser.add_argument("-r", "--role", help="user access level",
@ -830,7 +834,6 @@ def _set_user_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
description="remove user from the user mapping and update the configuration",
formatter_class=_formatter)
parser.add_argument("username", help="username for web service")
parser.add_argument("-s", "--secure", help="set file permissions to user-only", action="store_true")
parser.set_defaults(handler=handlers.Users, action=Action.Remove, architecture=[""], lock=None, report=False, # nosec
password="", quiet=True, unsafe=True)
return parser

View File

@ -17,8 +17,6 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import shutil
from pathlib import Path
from typing import Callable, Iterable, List
@ -54,7 +52,7 @@ class ApplicationRepository(ApplicationProperties):
Args:
cache(bool): clear directory with package caches
chroot(bool): clear build chroot
manual(bool): clear directory with manually added packages
manual(bool): clear directory with manually added packages' bases
packages(bool): clear directory with built packages
pacman(bool): clear directory with pacman databases
"""
@ -85,13 +83,10 @@ class ApplicationRepository(ApplicationProperties):
if archive.filepath is None:
self.logger.warning("filepath is empty for %s", package.base)
continue # avoid mypy warning
src = self.repository.paths.repository / archive.filepath
dst = self.repository.paths.packages / archive.filepath
shutil.copy(src, dst)
# run generic update function
self.update([])
self.repository.sign.process_sign_package(archive.filepath, package.base)
# sign repository database if set
self.repository.sign.process_sign_repository(self.repository.repo.repo_path)
# process triggers
self.on_result(Result())
def unknown(self) -> List[str]:

View File

@ -57,7 +57,7 @@ class Backup(Handler):
@staticmethod
def get_paths(configuration: Configuration) -> Set[Path]:
"""
extract paths to backup
extract paths to back up
Args:
configuration(Configuration): configuration instance

View File

@ -45,6 +45,7 @@ class Handler:
be called directly. The recommended way is to call ``execute`` class method, e.g.::
>>> from ahriman.application.handlers import Add
>>>
>>> Add.execute(args)
"""

View File

@ -125,7 +125,6 @@ class Patch(Handler):
package_base(Optional[str]): package base
variables(List[str]): extract patches only for specified PKGBUILD variables
exit_code(bool): exit with error on empty search result
:
"""
patches = application.database.patches_list(package_base, variables)
Patch.check_if_empty(exit_code, not patches)

View File

@ -76,5 +76,5 @@ class Rebuild(Handler):
List[Package]: list of packages which were stored in database
"""
if from_database:
return application.repository.packages()
return [package for (package, _) in application.database.packages_get()]
return [package for (package, _) in application.database.packages_get()]
return application.repository.packages()

View File

@ -64,7 +64,7 @@ class Setup(Handler):
application = Application(architecture, configuration, report=report, unsafe=unsafe)
Setup.configuration_create_makepkg(args.packager, application.repository.paths)
Setup.configuration_create_makepkg(args.packager, args.makeflags_jobs, application.repository.paths)
Setup.executable_create(application.repository.paths, args.build_command, architecture)
Setup.configuration_create_devtools(args.build_command, architecture, args.from_configuration,
args.multilib, args.repository, application.repository.paths)
@ -118,7 +118,11 @@ class Setup(Handler):
section = Configuration.section_name("web", architecture)
configuration.set_option(section, "port", str(args.web_port))
target = include_path / "setup-overrides.ini"
if args.web_unix_socket is not None:
section = Configuration.section_name("web", architecture)
configuration.set_option(section, "unix_socket", str(args.web_unix_socket))
target = include_path / "00-setup-overrides.ini"
with target.open("w") as ahriman_configuration:
configuration.write(ahriman_configuration)
@ -135,7 +139,7 @@ class Setup(Handler):
prefix(str): command prefix in {prefix}-{architecture}-build
architecture(str): repository architecture
source(Path): path to source configuration file
multilib(bool): add or do not multilib repository
multilib(bool): add or do not multilib repository to the configuration
repository(str): repository name
paths(RepositoryPaths): repository paths instance
"""
@ -166,17 +170,23 @@ class Setup(Handler):
configuration.write(devtools_configuration)
@staticmethod
def configuration_create_makepkg(packager: str, paths: RepositoryPaths) -> None:
def configuration_create_makepkg(packager: str, makeflags_jobs: bool, paths: RepositoryPaths) -> None:
"""
create configuration for makepkg
Args:
packager(str): packager identifier (e.g. name, email)
makeflags_jobs(bool): set MAKEFLAGS variable to number of cores
paths(RepositoryPaths): repository paths instance
"""
content = f"PACKAGER='{packager}'\n"
if makeflags_jobs:
content += """MAKEFLAGS="-j$(nproc)"\n"""
uid, _ = paths.root_owner
home_dir = Path(getpwuid(uid).pw_dir)
(home_dir / ".makepkg.conf").write_text(f"PACKAGER='{packager}'\n", encoding="utf8")
(home_dir / ".makepkg.conf").write_text(content, encoding="utf8")
@staticmethod
def configuration_create_sudo(paths: RepositoryPaths, prefix: str, architecture: str) -> None:

View File

@ -56,7 +56,8 @@ class Shell(Handler):
# licensed by https://creativecommons.org/licenses/by-sa/3.0
path = Path(sys.prefix) / "share" / "ahriman" / "templates" / "shell"
StringPrinter(path.read_text(encoding="utf8")).print(verbose=False)
# we only want to pass application isntance inside
if args.code is None:
code.interact(local=locals())
code.interact(local={"application": application})
else:
code.InteractiveConsole(locals=locals()).runcode(args.code)
code.InteractiveConsole(locals={"application": application}).runcode(args.code)

View File

@ -21,7 +21,7 @@ import argparse
import getpass
from pathlib import Path
from typing import Type
from typing import Optional, Tuple, Type
from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration
@ -55,12 +55,13 @@ class Users(Handler):
database = SQLite.load(configuration)
if args.action == Action.Update:
salt = Users.get_salt(configuration)
old_salt, salt = Users.get_salt(configuration)
user = Users.user_create(args)
auth_configuration = Users.configuration_get(configuration.include)
if old_salt is None:
auth_configuration = Users.configuration_get(configuration.include)
Users.configuration_create(auth_configuration, salt, args.secure)
Users.configuration_create(auth_configuration, user, salt, args.as_service, args.secure)
database.user_update(user.hash_password(salt))
elif args.action == Action.List:
users = database.user_list(args.username, args.role)
@ -71,22 +72,16 @@ class Users(Handler):
database.user_remove(args.username)
@staticmethod
def configuration_create(configuration: Configuration, user: User, salt: str,
as_service_user: bool, secure: bool) -> None:
def configuration_create(configuration: Configuration, salt: str, secure: bool) -> None:
"""
enable configuration if it has been disabled
Args:
configuration(Configuration): configuration instance
user(User): user descriptor
salt(str): password hash salt
as_service_user(bool): add user as service user, also set password and user to configuration
secure(bool): if true then set file permissions to 0o600
"""
configuration.set_option("auth", "salt", salt)
if as_service_user:
configuration.set_option("web", "username", user.username)
configuration.set_option("web", "password", user.password)
Users.configuration_write(configuration, secure)
@staticmethod
@ -100,7 +95,7 @@ class Users(Handler):
Returns:
Configuration: configuration instance. In case if there are local settings they will be loaded
"""
target = include_path / "auth.ini"
target = include_path / "00-auth.ini"
configuration = Configuration()
configuration.load(target)
@ -124,7 +119,7 @@ class Users(Handler):
path.chmod(0o600)
@staticmethod
def get_salt(configuration: Configuration, salt_length: int = 20) -> str:
def get_salt(configuration: Configuration, salt_length: int = 20) -> Tuple[Optional[str], str]:
"""
get salt from configuration or create new string
@ -133,11 +128,12 @@ class Users(Handler):
salt_length(int, optional): salt length (Default value = 20)
Returns:
str: current salt
Tuple[Optional[str], str]: tuple containing salt from configuration if any and actual salt which must be
used for password hash
"""
if salt := configuration.get("auth", "salt", fallback=None):
return salt
return User.generate_password(salt_length)
return salt, salt
return None, User.generate_password(salt_length)
@staticmethod
def user_create(args: argparse.Namespace) -> User:

View File

@ -68,7 +68,7 @@ class Pacman(LazyLogging):
Args:
database(DB): pacman database instance to be copied
pacman_root(Path): operating system pacman's root
pacman_root(Path): operating system pacman root
paths(RepositoryPaths): repository paths instance
use_ahriman_cache(bool): use local ahriman cache instead of system one
"""

View File

@ -55,14 +55,14 @@ class Auth(LazyLogging):
def auth_control(self) -> str:
"""
This workaround is required to make different behaviour for login interface.
In case of internal authentication it must provide an interface (modal form) to login with button sends POST
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
request to external resource
Returns:
str: login control as html code to insert
"""
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#loginForm" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>"""
return """<button type="button" class="btn btn-link" data-bs-toggle="modal" data-bs-target="#login-modal" style="text-decoration: none"><i class="bi bi-box-arrow-in-right"></i> login</button>"""
@classmethod
def load(cls: Type[Auth], configuration: Configuration, database: SQLite) -> Auth:

View File

@ -29,61 +29,65 @@ except ImportError:
__all__ = ["authorized_userid", "check_authorized", "forget", "remember"]
async def authorized_userid(*args: Any) -> Any:
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:
"""
handle aiohttp security methods
Args:
*args(Any): argument list as provided by authorized_userid function
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.authorized_userid(*args) # pylint: disable=no-value-for-parameter
return await aiohttp_security.authorized_userid(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
async def check_authorized(*args: Any) -> Any:
async def check_authorized(*args: Any, **kwargs: Any) -> Any:
"""
handle aiohttp security methods
Args:
*args(Any): argument list as provided by check_authorized function
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.check_authorized(*args) # pylint: disable=no-value-for-parameter
return await aiohttp_security.check_authorized(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
async def forget(*args: Any) -> Any:
async def forget(*args: Any, **kwargs: Any) -> Any:
"""
handle aiohttp security methods
Args:
*args(Any): argument list as provided by forget function
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.forget(*args) # pylint: disable=no-value-for-parameter
return await aiohttp_security.forget(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None
async def remember(*args: Any) -> Any:
async def remember(*args: Any, **kwargs: Any) -> Any:
"""
handle disabled auth
Args:
*args(Any): argument list as provided by remember function
**kwargs(Any): named argument list as provided by authorized_userid function
Returns:
Any: None in case if no aiohttp_security module found and function call otherwise
"""
if _has_aiohttp_security:
return await aiohttp_security.remember(*args) # pylint: disable=no-value-for-parameter
return await aiohttp_security.remember(*args, **kwargs) # pylint: disable=no-value-for-parameter
return None

View File

@ -32,7 +32,7 @@ class Mapping(Auth):
user authorization based on mapping from configuration file
Attributes:
salt(str): random generated string to salt passwords
salt(str): random generated string to salted password
database(SQLite): database instance
"""

View File

@ -30,7 +30,7 @@ from ahriman.models.auth_settings import AuthSettings
class OAuth(Mapping):
"""
OAuth user authorization.
OAuth's user authorization.
It is required to create application first and put application credentials.
Attributes:
@ -58,7 +58,7 @@ class OAuth(Mapping):
# thus we expect that address is set
self.redirect_uri = f"""{configuration.get("web", "address")}/api/v1/login"""
self.provider = self.get_provider(configuration.get("auth", "oauth_provider"))
# it is list but we will have to convert to string it anyway
# it is list, but we will have to convert to string it anyway
self.scopes = configuration.get("auth", "oauth_scopes")
@property

View File

@ -33,7 +33,7 @@ from ahriman.models.repository_paths import RepositoryPaths
class Sources(LazyLogging):
"""
helper to download package sources (PKGBUILD etc)
helper to download package sources (PKGBUILD etc...)
Attributes:
DEFAULT_BRANCH(str): (class attribute) default branch to process git repositories.

View File

@ -20,10 +20,11 @@
from __future__ import annotations
import configparser
import shlex
import sys
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional, Tuple, Type
from typing import Any, Dict, List, Optional, Tuple, Type
from ahriman.core.exceptions import InitializeError
from ahriman.models.repository_paths import RepositoryPaths
@ -72,7 +73,7 @@ class Configuration(configparser.RawConfigParser):
to ``True``, the keys without values will be allowed (Default value = False)
"""
configparser.RawConfigParser.__init__(self, allow_no_value=allow_no_value, converters={
"list": self.__convert_list,
"list": shlex.split,
"path": self.__convert_path,
})
self.architecture: Optional[str] = None
@ -126,39 +127,6 @@ class Configuration(configparser.RawConfigParser):
configuration.merge_sections(architecture)
return configuration
@staticmethod
def __convert_list(value: str) -> List[str]:
"""
convert string value to list of strings
Args:
value(str): string configuration value
Returns:
List[str]: list of string from the parsed string
Raises:
ValueError: in case if option value contains unclosed quotes
"""
def generator() -> Generator[str, None, None]:
quote_mark = None
word = ""
for char in value:
if char in ("'", "\"") and quote_mark is None: # quoted part started, store quote and do nothing
quote_mark = char
elif char == quote_mark: # quoted part ended, reset quotation
quote_mark = None
elif char == " " and quote_mark is None: # found space outside the quotation, yield the word
yield word
word = ""
else: # append character to the buffer
word += char
if quote_mark: # there is unmatched quote
raise ValueError(f"unmatched quote in {value}")
yield word # sequence done, return whatever we found
return [word for word in generator() if word]
@staticmethod
def section_name(section: str, suffix: str) -> str:
"""
@ -284,8 +252,8 @@ class Configuration(configparser.RawConfigParser):
# get overrides
specific = self.section_name(section, architecture)
if self.has_section(specific):
# if there is no such section it means that there is no overrides for this arch
# but we anyway will have to delete sections for others archs
# if there is no such section it means that there is no overrides for this arch,
# but we anyway will have to delete sections for others architectures
for key, value in self[specific].items():
self.set_option(section, key, value)
# remove any arch specific section

View File

@ -46,7 +46,7 @@ class LogsOperations(Operations):
for row in connection.execute(
"""
select created, record from logs where package_base = :package_base
order by created asc
order by created
""",
{"package_base": package_base})
]

View File

@ -19,8 +19,7 @@
#
import re
from aiohttp.abc import BaseRequest, StreamResponse
from aiohttp.web_log import AccessLogger
from aiohttp.web import AccessLogger, BaseRequest, StreamResponse
class FilteredAccessLogger(AccessLogger):

View File

@ -54,7 +54,7 @@ class Report(LazyLogging):
>>> except Exception as exception:
>>> handle_exceptions(exception)
>>>
>>> report.run([], Result())
>>> report.run(Result(), [])
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:

View File

@ -17,7 +17,7 @@
# 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 requests # technically we could use python-telegram-bot, but it is just a single request, cmon
import requests # technically we could use python-telegram-bot, but it is just a single request, c'mon
from typing import Iterable

View File

@ -46,8 +46,7 @@ class Repository(Executor, UpdateHandler):
>>> built_packages = repository.packages_built()
>>> update_result = repository.process_update(built_packages)
>>>
>>> repository.process_report(["email"], update_result)
>>> repository.process_sync(["s3"], update_result.success)
>>> repository.triggers.on_result(update_result, repository.packages())
"""
def load_archives(self, packages: Iterable[Path]) -> List[Package]:

View File

@ -118,7 +118,7 @@ class GPG(LazyLogging):
"""
key = key if key.startswith("0x") else f"0x{key}"
try:
response = requests.get(f"http://{server}/pks/lookup", params={
response = requests.get(f"https://{server}/pks/lookup", params={
"op": "get",
"options": "mr",
"search": key

View File

@ -24,7 +24,7 @@ import uuid
from multiprocessing import Process, Queue
from threading import Lock, Thread
from typing import Callable, Dict, Iterable, Tuple
from typing import Callable, Dict, Iterable, Optional, Tuple
from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
@ -78,6 +78,17 @@ class Spawn(Thread, LazyLogging):
result = callback(args, architecture)
queue.put((process_id, result))
def key_import(self, key: str, server: Optional[str]) -> None:
"""
import key to service cache
Args:
key(str): key to import
server(str): PGP key server
"""
kwargs = {} if server is None else {"key-server": server}
self.spawn_process("key-import", key, **kwargs)
def packages_add(self, packages: Iterable[str], *, now: bool) -> None:
"""
add packages
@ -86,12 +97,19 @@ class Spawn(Thread, LazyLogging):
packages(Iterable[str]): packages list to add
now(bool): build packages now
"""
if not packages:
return self.spawn_process("repo-update")
kwargs = {"source": PackageSource.AUR.value} # avoid abusing by building non-aur packages
if now:
kwargs["now"] = ""
return self.spawn_process("package-add", *packages, **kwargs)
self.spawn_process("package-add", *packages, **kwargs)
def packages_rebuild(self, depends_on: str) -> None:
"""
rebuild packages which depend on the specified package
Args:
depends_on(str): packages dependency
"""
self.spawn_process("repo-rebuild", **{"depends-on": depends_on})
def packages_remove(self, packages: Iterable[str]) -> None:
"""
@ -102,6 +120,12 @@ class Spawn(Thread, LazyLogging):
"""
self.spawn_process("package-remove", *packages)
def packages_update(self, ) -> None:
"""
run full repository update
"""
self.spawn_process("repo-update")
def spawn_process(self, command: str, *args: str, **kwargs: str) -> None:
"""
spawn external ahriman process with supplied arguments

View File

@ -52,8 +52,12 @@ class Client:
address = configuration.get("web", "address", fallback=None)
host = configuration.get("web", "host", fallback=None)
port = configuration.getint("web", "port", fallback=None)
socket = configuration.get("web", "unix_socket", fallback=None)
if address or (host and port):
# basically we just check if there is something we can use for interaction with remote server
# at the moment (end of 2022) I think it would be much better idea to introduce flag like `enabled`,
# but it will totally break used experience
if address or (host and port) or socket:
from ahriman.core.status.web_client import WebClient
return WebClient(configuration)
return cls()

View File

@ -21,6 +21,7 @@ import logging
import requests
from typing import List, Optional, Tuple
from urllib.parse import quote_plus as urlencode
from ahriman.core.configuration import Configuration
from ahriman.core.log import LazyLogging
@ -48,13 +49,12 @@ class WebClient(Client, LazyLogging):
Args:
configuration(Configuration): configuration instance
"""
self.address = self.parse_address(configuration)
self.address, use_unix_socket = self.parse_address(configuration)
self.user = User.from_option(
configuration.get("web", "username", fallback=None),
configuration.get("web", "password", fallback=None))
self.__session = requests.session()
self._login()
self.__session = self._create_session(use_unix_socket=use_unix_socket)
@property
def _login_url(self) -> str:
@ -62,7 +62,7 @@ class WebClient(Client, LazyLogging):
get url for the login api
Returns:
str: full url for web service to login
str: full url for web service to log in
"""
return f"{self.address}/api/v1/login"
@ -77,7 +77,7 @@ class WebClient(Client, LazyLogging):
return f"{self.address}/api/v1/status"
@staticmethod
def parse_address(configuration: Configuration) -> str:
def parse_address(configuration: Configuration) -> Tuple[str, bool]:
"""
parse address from configuration
@ -85,15 +85,38 @@ class WebClient(Client, LazyLogging):
configuration(Configuration): configuration instance
Returns:
str: valid http address
Tuple[str, bool]: tuple of server address and socket flag (True in case if unix socket must be used)
"""
if (unix_socket := configuration.get("web", "unix_socket", fallback=None)) is not None:
# special pseudo-protocol which is used for unix sockets
return f"http+unix://{urlencode(unix_socket)}", True
address = configuration.get("web", "address", fallback=None)
if not address:
# build address from host and port directly
host = configuration.get("web", "host")
port = configuration.getint("web", "port")
address = f"http://{host}:{port}"
return address
return address, False
def _create_session(self, *, use_unix_socket: bool) -> requests.Session:
"""
generate new request session
Args:
use_unix_socket(bool): if set to True then unix socket session will be generated instead of native requests
Returns:
requests.Session: generated session object
"""
if use_unix_socket:
import requests_unixsocket # type: ignore
session: requests.Session = requests_unixsocket.Session()
return session
session = requests.Session()
self._login()
return session
def _login(self) -> None:
"""

View File

@ -19,14 +19,15 @@
#
import datetime
import io
import logging
import os
import re
import requests
import subprocess
from enum import Enum
from logging import Logger
from pathlib import Path
from pwd import getpwuid
from typing import Any, Dict, Generator, IO, Iterable, List, Optional, Type, Union
from ahriman.core.exceptions import OptionError, UnsafeRunError
@ -38,7 +39,8 @@ __all__ = ["check_output", "check_user", "exception_response_text", "filter_json
def check_output(*args: str, exception: Optional[Exception] = None, cwd: Optional[Path] = None,
input_data: Optional[str] = None, logger: Optional[Logger] = None, user: Optional[int] = None) -> str:
input_data: Optional[str] = None, logger: Optional[logging.Logger] = None,
user: Optional[int] = None) -> str:
"""
subprocess wrapper
@ -48,7 +50,7 @@ def check_output(*args: str, exception: Optional[Exception] = None, cwd: Optiona
exception (Default value = None)
cwd(Optional[Path], optional): current working directory (Default value = None)
input_data(Optional[str], optional): data which will be written to command stdin (Default value = None)
logger(Optional[Logger], optional): logger to log command result if required (Default value = None)
logger(Optional[logging.Logger], optional): logger to log command result if required (Default value = None)
user(Optional[int], optional): run process as specified user (Default value = None)
Returns:
@ -83,10 +85,11 @@ def check_output(*args: str, exception: Optional[Exception] = None, cwd: Optiona
if logger is not None:
logger.debug(single)
environment = {"HOME": getpwuid(user).pw_dir} if user is not None else {}
# FIXME additional workaround for linter and type check which do not know that user arg is supported
# pylint: disable=unexpected-keyword-arg
with subprocess.Popen(args, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
user=user, text=True, encoding="utf8", bufsize=1) as process:
user=user, env=environment, text=True, encoding="utf8", bufsize=1) as process:
if input_data is not None:
input_channel = get_io(process, "stdin")
input_channel.write(input_data)
@ -151,7 +154,7 @@ def enum_values(enum: Type[Enum]) -> List[str]:
Returns:
List[str]: available enumeration values as string
"""
return [key.value for key in enum]
return [str(key.value) for key in enum] # explicit str conversion for typing
def exception_response_text(exception: requests.exceptions.HTTPError) -> str:
@ -285,7 +288,7 @@ def safe_filename(source: str) -> str:
# https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
# unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
# however we would like to allow some gen-delims characters in filename, because those characters are used
# as delimiter in other URI parts. The ones we allow are
# as delimiter in other URI parts. The ones we allow to are:
# ":" - used as separator in schema and userinfo
# "[" and "]" - used for host part
# "@" - used as separator between host and userinfo

View File

@ -42,7 +42,7 @@ class AURPackage:
description(str): package base description
url(Optional[str]): package upstream URL
num_votes(int): number of votes for the package
polularity(float): package popularity
popularity(float): package popularity
out_of_date(Optional[datetime.datetime]): package out of date timestamp if any
maintainer(Optional[str]): package maintainer
first_submitted(datetime.datetime): timestamp of the first package submission

View File

@ -36,7 +36,7 @@ class PackageSource(str, Enum):
AUR(PackageSource): (class attribute) source is an AUR package for which it should search
Directory(PackageSource): (class attribute) source is a directory which contains packages
Local(PackageSource): (class attribute) source is locally stored PKGBUILD
Remote(PackageSource): (class attribute) source is remote (http, ftp etc) link
Remote(PackageSource): (class attribute) source is remote (http, ftp etc...) link
Repository(PackageSource): (class attribute) source is official repository
Examples:

View File

@ -34,7 +34,7 @@ class SignSettings(str, Enum):
"""
Disabled = "disabled"
Packages = "pacakges"
Packages = "packages"
Repository = "repository"
@classmethod

View File

@ -41,11 +41,11 @@ class User:
Simply create user from database data and perform required validation::
>>> password = User.generate_password(24)
>>> user = User("ahriman", password, UserAccess.Write)
>>> user = User("ahriman", password, UserAccess.Full)
Since the password supplied may be plain text, the ``hash_password`` method can be used to hash the password::
>>> user.password = user.hash_password("salt")
>>> user = user.hash_password("salt")
Having the user instance and password, it can be validated::

View File

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

View File

@ -19,6 +19,7 @@
#
import aiohttp_security # type: ignore
import base64
import socket
import types
from aiohttp import web
@ -101,7 +102,11 @@ def auth_handler(allow_read_only: bool) -> MiddlewareType:
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
if (permission_method := getattr(handler, "get_permission", None)) is not None:
if (unix_socket := request.get_extra_info("socket")) is not None and unix_socket.family == socket.AF_UNIX:
# special case for unix sockets. We need to extract socket which is used for the request
# and check its address family
permission = UserAccess.Unauthorized
elif (permission_method := getattr(handler, "get_permission", None)) is not None:
permission = await permission_method(request)
elif isinstance(handler, types.MethodType): # additional wrapper for static resources
handler_instance = getattr(handler, "__self__", None)

View File

@ -17,10 +17,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import middleware, Request
from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError
from aiohttp.web_response import json_response, StreamResponse
from logging import Logger
import aiohttp_jinja2
import logging
from aiohttp.web import HTTPClientError, HTTPException, HTTPServerError, HTTPUnauthorized, Request, StreamResponse, \
json_response, middleware
from ahriman.web.middlewares import HandlerType, MiddlewareType
@ -28,12 +29,12 @@ from ahriman.web.middlewares import HandlerType, MiddlewareType
__all__ = ["exception_handler"]
def exception_handler(logger: Logger) -> MiddlewareType:
def exception_handler(logger: logging.Logger) -> MiddlewareType:
"""
exception handler middleware. Just log any exception (except for client ones)
Args:
logger(Logger): class logger
logger(logging.Logger): class logger
Returns:
MiddlewareType: built middleware
@ -42,6 +43,11 @@ def exception_handler(logger: Logger) -> MiddlewareType:
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
try:
return await handler(request)
except HTTPUnauthorized as e:
if is_templated_unauthorized(request):
context = {"code": e.status_code, "reason": e.reason}
return aiohttp_jinja2.render_template("error.jinja2", request, context, status=e.status_code)
return json_response(data={"error": e.reason}, status=e.status_code)
except HTTPClientError as e:
return json_response(data={"error": e.reason}, status=e.status_code)
except HTTPServerError as e:
@ -54,3 +60,17 @@ def exception_handler(logger: Logger) -> MiddlewareType:
return json_response(data={"error": str(e)}, status=500)
return handle
def is_templated_unauthorized(request: Request) -> bool:
"""
check if the request is eligible for rendering html template
Args:
request(Request): source request to check
Returns:
bool: True in case if response should be rendered as html and False otherwise
"""
return request.path in ("/api/v1/login", "/api/v1/logout") \
and "application/json" not in request.headers.getall("accept", [])

View File

@ -22,9 +22,12 @@ from pathlib import Path
from ahriman.web.views.index import IndexView
from ahriman.web.views.service.add import AddView
from ahriman.web.views.service.pgp import PGPView
from ahriman.web.views.service.rebuild import RebuildView
from ahriman.web.views.service.remove import RemoveView
from ahriman.web.views.service.request import RequestView
from ahriman.web.views.service.search import SearchView
from ahriman.web.views.service.update import UpdateView
from ahriman.web.views.status.logs import LogsView
from ahriman.web.views.status.package import PackageView
from ahriman.web.views.status.packages import PackagesView
@ -47,13 +50,18 @@ def setup_routes(application: Application, static_path: Path) -> None:
* ``POST /api/v1/service/add`` add new packages to repository
* ``GET /api/v1/service/pgp`` fetch PGP key from the keyserver
* ``POST /api/v1/service/pgp`` import PGP key from the keyserver
* ``POST /api/v1/service/rebuild`` rebuild packages based on their dependency list
* ``POST /api/v1/service/remove`` remove existing package from repository
* ``POST /api/v1/service/request`` request to add new packages to repository
* ``GET /api/v1/service/search`` search for substring in AUR
* ``POST /api/v1/service/update`` update packages in repository, actually it is just alias for add
* ``POST /api/v1/service/update`` update all packages in repository
* ``GET /api/v1/packages`` get all known packages
* ``POST /api/v1/packages`` force update every package from repository
@ -84,13 +92,18 @@ def setup_routes(application: Application, static_path: Path) -> None:
application.router.add_post("/api/v1/service/add", AddView)
application.router.add_get("/api/v1/service/pgp", PGPView, allow_head=True)
application.router.add_post("/api/v1/service/pgp", PGPView)
application.router.add_post("/api/v1/service/rebuild", RebuildView)
application.router.add_post("/api/v1/service/remove", RemoveView)
application.router.add_post("/api/v1/service/request", RequestView)
application.router.add_get("/api/v1/service/search", SearchView, allow_head=False)
application.router.add_post("/api/v1/service/update", AddView)
application.router.add_post("/api/v1/service/update", UpdateView)
application.router.add_get("/api/v1/packages", PackagesView, allow_head=True)
application.router.add_post("/api/v1/packages", PackagesView)

View File

@ -20,16 +20,18 @@
from __future__ import annotations
from aiohttp.web import Request, View
from typing import Any, Dict, List, Optional, Type
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
from ahriman.models.user_access import UserAccess
T = TypeVar("T", str, List[str])
class BaseView(View):
"""
base web view to make things typed
@ -46,17 +48,6 @@ class BaseView(View):
configuration: Configuration = self.request.app["configuration"]
return configuration
@property
def database(self) -> SQLite:
"""
get database instance
Returns:
SQLite: database instance
"""
database: SQLite = self.request.app["database"]
return database
@property
def service(self) -> Watcher:
"""
@ -104,6 +95,29 @@ class BaseView(View):
permission: UserAccess = getattr(cls, f"{request.method.upper()}_PERMISSION", UserAccess.Full)
return permission
@staticmethod
def get_non_empty(extractor: Callable[[str], Optional[T]], key: str) -> T:
"""
get non-empty value from request parameters
Args:
extractor(Callable[[str], T]): function to get value by the specified key
key(str): key to extract value
Returns:
T: extracted values if it is presented and not empty
Raises:
KeyError: in case if key was not found or value is empty
"""
try:
value = extractor(key)
if not value:
raise KeyError(key)
except Exception:
raise KeyError(f"Key {key} is missing or empty")
return value
async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]:
"""
extract json data from either json or form data

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -40,16 +40,34 @@ class AddView(BaseView):
JSON body must be supplied, the following model is used::
{
"packages": "ahriman" # either list of packages or package name as in AUR
"packages": ["ahriman"] # either list of packages or package name as in AUR
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPFound: in case of success response
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/add' -d '{"packages": ["ahriman"]}'
> POST /api/v1/service/add HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 25
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 18:44:21 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
data = await self.extract_data(["packages"])
packages = data.get("packages", [])
try:
data = await self.extract_data(["packages"])
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.packages_add(packages, now=True)
raise HTTPFound("/")
raise HTTPNoContent()

View File

@ -0,0 +1,121 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class PGPView(BaseView):
"""
pgp key management web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
HEAD_PERMISSION(UserAccess): (class attribute) head permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Full
GET_PERMISSION = HEAD_PERMISSION = UserAccess.Reporter
async def get(self) -> Response:
"""
retrieve key from the key server. It supports two query parameters: ``key`` - pgp key fingerprint and
``server`` which points to valid PGP key server
Returns:
Response: 200 with key body on success
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNotFound: if key wasn't found or service was unable to fetch it
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/service/pgp?key=0xE989490C&server=keyserver.ubuntu.com'
> GET /api/v1/service/pgp?key=0xE989490C&server=keyserver.ubuntu.com HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 3275
< Date: Fri, 25 Nov 2022 22:54:02 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
{"key": "key"}
"""
try:
key = self.get_non_empty(self.request.query.getone, "key")
server = self.get_non_empty(self.request.query.getone, "server")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
try:
key = self.service.repository.sign.key_download(server, key)
except Exception:
raise HTTPNotFound()
return json_response({"key": key})
async def post(self) -> None:
"""
store key to the local service environment
JSON body must be supplied, the following model is used::
{
"key": "0x8BE91E5A773FB48AC05CC1EDBED105AED6246B39", # key fingerprint to import
"server": "keyserver.ubuntu.com" # optional pgp server address
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/pgp' -d '{"key": "0xE989490C"}'
> POST /api/v1/service/pgp HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 21
>
< HTTP/1.1 204 No Content
< Date: Fri, 25 Nov 2022 22:55:56 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
data = await self.extract_data()
try:
key = self.get_non_empty(data.get, "key")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.key_import(key, data.get("server"))
raise HTTPNoContent()

View File

@ -0,0 +1,75 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class RebuildView(BaseView):
"""
rebuild packages web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Full
async def post(self) -> None:
"""
rebuild packages based on their dependency
JSON body must be supplied, the following model is used::
{
"packages": ["ahriman"] # either list of packages or package name of dependency
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/rebuild' -d '{"packages": ["python"]}'
> POST /api/v1/service/rebuild HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 24
>
< HTTP/1.1 204 No Content
< Date: Sun, 27 Nov 2022 00:22:26 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
try:
data = await self.extract_data(["packages"])
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
depends_on = next(package for package in packages)
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.packages_rebuild(depends_on)
raise HTTPNoContent()

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPFound
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -40,19 +40,35 @@ class RemoveView(BaseView):
JSON body must be supplied, the following model is used::
{
"packages": "ahriman", # either list of packages or package name
"packages": ["ahriman"] # either list of packages or package name
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPFound: in case of success response
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/remove' -d '{"packages": ["ahriman"]}'
> POST /api/v1/service/remove HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 25
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 18:57:56 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
try:
data = await self.extract_data(["packages"])
packages = data["packages"]
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.packages_remove(packages)
raise HTTPFound("/")
raise HTTPNoContent()

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPFound
from aiohttp.web import HTTPBadRequest, HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
@ -40,19 +40,35 @@ class RequestView(BaseView):
JSON body must be supplied, the following model is used::
{
"packages": "ahriman" # either list of packages or package name as in AUR
"packages": ["ahriman"] # either list of packages or package name as in AUR
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPFound: in case of success response
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/service/request' -d '{"packages": ["ahriman"]}'
> POST /api/v1/service/request HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 25
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 18:59:32 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
try:
data = await self.extract_data(["packages"])
packages = data["packages"]
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
except Exception as e:
raise HTTPBadRequest(reason=str(e))
self.spawner.packages_add(packages, now=False)
raise HTTPFound("/")
raise HTTPNoContent()

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPNotFound, Response, json_response
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
from typing import Callable, List
from ahriman.core.alpm.remote import AUR
@ -39,18 +39,38 @@ class SearchView(BaseView):
async def get(self) -> Response:
"""
search packages in AUR
search string (non empty) must be supplied as ``for`` parameter
search packages in AUR. Search string (non-empty) must be supplied as ``for`` parameter
Returns:
Response: 200 with found package bases and descriptions sorted by base
Raises:
HTTPBadRequest: in case if bad data is supplied
HTTPNotFound: if no packages found
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/service/search?for=ahriman'
> GET /api/v1/service/search?for=ahriman HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 148
< Date: Wed, 23 Nov 2022 19:07:13 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
[{"package": "ahriman", "description": "ArcH linux ReposItory MANager"}, {"package": "ahriman-git", "description": "ArcH Linux ReposItory MANager"}]
"""
search: List[str] = self.request.query.getall("for", default=[])
packages = AUR.multisearch(*search, pacman=self.service.repository.pacman)
try:
search: List[str] = self.get_non_empty(lambda key: self.request.query.getall(key, default=[]), "for")
packages = AUR.multisearch(*search, pacman=self.service.repository.pacman)
except Exception as e:
raise HTTPBadRequest(reason=str(e))
if not packages:
raise HTTPNotFound(reason=f"No packages found for terms: {search}")

View File

@ -0,0 +1,59 @@
#
# Copyright (c) 2021-2022 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPNoContent
from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView
class UpdateView(BaseView):
"""
update repository web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION = UserAccess.Full
async def post(self) -> None:
"""
run repository update. No parameters supported here
Raises:
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -XPOST 'http://example.com/api/v1/service/update'
> POST /api/v1/service/update HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Fri, 25 Nov 2022 22:57:56 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
self.spawner.packages_update()
raise HTTPNoContent()

View File

@ -17,8 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from aiohttp.web_exceptions import HTTPNotFound
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.log_record_id import LogRecordId
@ -46,6 +45,20 @@ class LogsView(BaseView):
Raises:
HTTPNoContent: on success response
Examples:
Example of command by using curl::
$ curl -v -XDELETE 'http://example.com/api/v1/packages/ahriman/logs'
> DELETE /api/v1/packages/ahriman/logs HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:26:40 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
package_base = self.request.match_info["package"]
self.service.remove_logs(package_base, None)
@ -58,6 +71,23 @@ class LogsView(BaseView):
Returns:
Response: 200 with package logs on success
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/packages/ahriman/logs'
> GET /api/v1/packages/ahriman/logs HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 100112
< Date: Wed, 23 Nov 2022 19:24:14 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
{"package_base": "ahriman", "status": {"status": "success", "timestamp": 1669231136}, "logs": "[2022-11-23 19:17:32] clone remote https://aur.archlinux.org/ahriman.git to /tmp/tmpy9j6fq9p using branch master"}
"""
package_base = self.request.match_info["package"]
@ -89,6 +119,22 @@ class LogsView(BaseView):
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/packages/ahriman/logs' -d '{"created": 1669231764.042444, "message": "my log message", "process_id": 1}'
> POST /api/v1/packages/ahriman/logs HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 76
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:30:45 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
package_base = self.request.match_info["package"]
data = await self.extract_data()

View File

@ -46,6 +46,20 @@ class PackageView(BaseView):
Raises:
HTTPNoContent: on success response
Examples:
Example of command by using curl::
$ curl -v -XDELETE 'http://example.com/api/v1/packages/ahriman'
> DELETE /api/v1/packages/ahriman HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:43:40 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
package_base = self.request.match_info["package"]
self.service.remove(package_base)
@ -61,6 +75,23 @@ class PackageView(BaseView):
Raises:
HTTPNotFound: if no package was found
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/packages/ahriman'
> GET /api/v1/packages/ahriman HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 743
< Date: Wed, 23 Nov 2022 19:41:01 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
[{"package": {"base": "ahriman", "version": "2.3.0-1", "remote": {"git_url": "https://aur.archlinux.org/ahriman.git", "web_url": "https://aur.archlinux.org/packages/ahriman", "path": ".", "branch": "master", "source": "aur"}, "packages": {"ahriman": {"architecture": "any", "archive_size": 247573, "build_date": 1669231069, "depends": ["devtools", "git", "pyalpm", "python-inflection", "python-passlib", "python-requests", "python-setuptools", "python-srcinfo"], "description": "ArcH linux ReposItory MANager", "filename": "ahriman-2.3.0-1-any.pkg.tar.zst", "groups": [], "installed_size": 1676153, "licenses": ["GPL3"], "provides": [], "url": "https://github.com/arcan1s/ahriman"}}}, "status": {"status": "success", "timestamp": 1669231136}}]
"""
package_base = self.request.match_info["package"]
@ -85,13 +116,29 @@ class PackageView(BaseView):
{
"status": "unknown", # package build status string, must be valid ``BuildStatusEnum``
"package": {} # package body (use ``dataclasses.asdict`` to generate one), optional.
# Must be supplied in case if package base is unknown
"package": {} # package body (use ``dataclasses.asdict`` to generate one), optional.
# Must be supplied in case if package base is unknown
}
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/packages/ahriman' -d '{"status": "success"}'
> POST /api/v1/packages/ahriman HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 21
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:42:49 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
package_base = self.request.match_info["package"]
data = await self.extract_data()

View File

@ -42,6 +42,23 @@ class PackagesView(BaseView):
Returns:
Response: 200 with package description on success
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/packages'
> GET /api/v1/packages HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 2687
< Date: Wed, 23 Nov 2022 19:35:24 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
[{"package": {"base": "ahriman", "version": "2.3.0-1", "remote": {"git_url": "https://aur.archlinux.org/ahriman.git", "web_url": "https://aur.archlinux.org/packages/ahriman", "path": ".", "branch": "master", "source": "aur"}, "packages": {"ahriman": {"architecture": "any", "archive_size": 247573, "build_date": 1669231069, "depends": ["devtools", "git", "pyalpm", "python-inflection", "python-passlib", "python-requests", "python-setuptools", "python-srcinfo"], "description": "ArcH linux ReposItory MANager", "filename": "ahriman-2.3.0-1-any.pkg.tar.zst", "groups": [], "installed_size": 1676153, "licenses": ["GPL3"], "provides": [], "url": "https://github.com/arcan1s/ahriman"}}}, "status": {"status": "success", "timestamp": 1669231136}}]
"""
response = [
{
@ -57,6 +74,20 @@ class PackagesView(BaseView):
Raises:
HTTPNoContent: on success response
Examples:
Example of command by using curl::
$ curl -v -XPOST 'http://example.com/api/v1/packages'
> POST /api/v1/packages HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:38:06 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
self.service.load()

View File

@ -46,6 +46,23 @@ class StatusView(BaseView):
Returns:
Response: 200 with service status object
Examples:
Example of command by using curl::
$ curl -v -H 'Accept: application/json' 'http://example.com/api/v1/status'
> GET /api/v1/status HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json; charset=utf-8
< Content-Length: 222
< Date: Wed, 23 Nov 2022 19:32:31 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
{"status": {"status": "success", "timestamp": 1669231237}, "architecture": "x86_64", "packages": {"total": 4, "unknown": 0, "pending": 0, "building": 0, "failed": 0, "success": 4}, "repository": "repo", "version": "2.3.0"}
"""
counters = Counters.from_packages(self.service.packages)
status = InternalStatus(
@ -70,6 +87,22 @@ class StatusView(BaseView):
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/status' -d '{"status": "success"}'
> POST /api/v1/status HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 21
>
< HTTP/1.1 204 No Content
< Date: Wed, 23 Nov 2022 19:33:57 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
"""
try:
data = await self.extract_data()

View File

@ -41,12 +41,17 @@ class LoginView(BaseView):
OAuth2 response handler
In case if code provided it will do a request to get user email. In case if no code provided it will redirect
to authorization url provided by OAuth client
to authorization url provided by OAuth client.
The authentication session will be passed in ``Set-Cookie`` header.
Raises:
HTTPFound: on success response
HTTPMethodNotAllowed: in case if method is used, but OAuth is disabled
HTTPUnauthorized: if case of authorization error
Examples:
This request must not be used directly.
"""
from ahriman.core.auth import OAuth
@ -78,9 +83,32 @@ class LoginView(BaseView):
"password": "pa55w0rd" # password to use for login
}
The authentication session will be passed in ``Set-Cookie`` header.
Raises:
HTTPFound: on success response
HTTPUnauthorized: if case of authorization error
Examples:
Example of command by using curl::
$ curl -v -H 'Content-Type: application/json' 'http://example.com/api/v1/login' -d '{"username": "test", "password": "test"}'
> POST /api/v1/login HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 40
>
< HTTP/1.1 302 Found
< Content-Type: text/plain; charset=utf-8
< Location: /
< Content-Length: 10
< Set-Cookie: ...
< Date: Wed, 23 Nov 2022 17:51:27 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
302: Found
"""
data = await self.extract_data()
username = data.get("username")

View File

@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from aiohttp.web import HTTPFound
from aiohttp.web import HTTPFound, HTTPUnauthorized
from ahriman.core.auth.helpers import check_authorized, forget
from ahriman.models.user_access import UserAccess
@ -36,12 +36,36 @@ class LogoutView(BaseView):
async def post(self) -> None:
"""
logout user from the service. No parameters supported here
logout user from the service. No parameters supported here.
The server will respond with ``Set-Cookie`` header, in which API session cookie will be nullified.
Raises:
HTTPFound: on success response
Examples:
Example of command by using curl::
$ curl -v -XPOST 'http://example.com/api/v1/logout'
> POST /api/v1/logout HTTP/1.1
> Host: example.com
> User-Agent: curl/7.86.0
> Accept: */*
>
< HTTP/1.1 302 Found
< Content-Type: text/plain; charset=utf-8
< Location: /
< Content-Length: 10
< Set-Cookie: ...
< Date: Wed, 23 Nov 2022 19:10:51 GMT
< Server: Python/3.10 aiohttp/3.8.3
<
302: Found
"""
await check_authorized(self.request)
try:
await check_authorized(self.request)
except HTTPUnauthorized:
raise HTTPUnauthorized(reason="I'm a teapot")
await forget(self.request, HTTPFound("/"))
raise HTTPFound("/")

View File

@ -78,8 +78,9 @@ def run_server(application: web.Application) -> None:
configuration: Configuration = application["configuration"]
host = configuration.get("web", "host")
port = configuration.getint("web", "port")
unix_socket = configuration.get("web", "unix_socket", fallback=None)
web.run_app(application, host=host, port=port, handle_signals=False,
web.run_app(application, host=host, port=port, path=unix_socket, handle_signals=False,
access_log=logging.getLogger("http"), access_log_class=FilteredAccessLogger)

View File

@ -69,18 +69,17 @@ def test_sign(application_repository: ApplicationRepository, package_ahriman: Pa
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
copy_mock = mocker.patch("shutil.copy")
update_mock = mocker.patch("ahriman.application.application.application_repository.ApplicationRepository.update")
sign_package_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_package")
sign_repository_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_repository")
on_result_mock = mocker.patch(
"ahriman.application.application.application_repository.ApplicationRepository.on_result")
application_repository.sign([])
copy_mock.assert_has_calls([
MockCall(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int)),
MockCall(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int))
sign_package_mock.assert_has_calls([
MockCall(pytest.helpers.anyvar(int), package_ahriman.base),
MockCall(pytest.helpers.anyvar(int), package_python_schedule.base),
MockCall(pytest.helpers.anyvar(int), package_python_schedule.base),
])
update_mock.assert_called_once_with([])
sign_repository_mock.assert_called_once_with(application_repository.repository.repo.repo_path)
on_result_mock.assert_called_once_with(Result())
@ -105,18 +104,14 @@ def test_sign_specific(application_repository: ApplicationRepository, package_ah
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
copy_mock = mocker.patch("shutil.copy")
update_mock = mocker.patch("ahriman.application.application.application_repository.ApplicationRepository.update")
sign_package_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_package")
sign_repository_mock = mocker.patch("ahriman.core.sign.gpg.GPG.process_sign_repository")
on_result_mock = mocker.patch(
"ahriman.application.application.application_repository.ApplicationRepository.on_result")
filename = package_ahriman.packages[package_ahriman.base].filepath
application_repository.sign([package_ahriman.base])
copy_mock.assert_called_once_with(
application_repository.repository.paths.repository / filename.name,
application_repository.repository.paths.packages / filename.name)
update_mock.assert_called_once_with([])
sign_package_mock.assert_called_once_with(filename, package_ahriman.base)
sign_repository_mock.assert_called_once_with(application_repository.repository.repo.repo_path)
on_result_mock.assert_called_once_with(Result())

View File

@ -1,17 +0,0 @@
import pytest
from collections import namedtuple
_passwd = namedtuple("passwd", ["pw_dir"])
@pytest.fixture
def passwd() -> _passwd:
"""
get passwd structure for the user
Returns:
_passwd: passwd structure test instance
"""
return _passwd("home")

View File

@ -17,7 +17,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
argparse.Namespace: generated arguments for these test cases
"""
args.key = "0xE989490C"
args.key_server = "pgp.mit.edu"
args.key_server = "keyserver.ubuntu.com"
return args

View File

@ -75,7 +75,7 @@ def test_run_dry_run(args: argparse.Namespace, configuration: Configuration,
args = _default_args(args)
args.dry_run = True
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on", return_value=[package_ahriman])
mocker.patch("ahriman.application.handlers.Rebuild.extract_packages", return_value=[package_ahriman])
application_mock = mocker.patch("ahriman.application.application.Application.update")
check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty")
@ -92,6 +92,7 @@ def test_run_filter(args: argparse.Namespace, configuration: Configuration, mock
args.depends_on = ["python-aur"]
mocker.patch("ahriman.application.application.Application.update")
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.application.handlers.Rebuild.extract_packages", return_value=[])
application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on")
Rebuild.run(args, "x86_64", configuration, report=False, unsafe=False)
@ -105,6 +106,7 @@ def test_run_without_filter(args: argparse.Namespace, configuration: Configurati
args = _default_args(args)
mocker.patch("ahriman.application.application.Application.update")
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.application.handlers.Rebuild.extract_packages", return_value=[])
application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on")
Rebuild.run(args, "x86_64", configuration, report=False, unsafe=False)
@ -120,6 +122,7 @@ def test_run_update_empty_exception(args: argparse.Namespace, configuration: Con
args.exit_code = True
args.dry_run = True
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.application.handlers.Rebuild.extract_packages")
mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty")
@ -135,6 +138,7 @@ def test_run_build_empty_exception(args: argparse.Namespace, configuration: Conf
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.tree_create")
mocker.patch("ahriman.application.handlers.Rebuild.extract_packages")
mocker.patch("ahriman.core.repository.repository.Repository.packages_depend_on", return_value=[package_ahriman])
mocker.patch("ahriman.application.application.Application.update", return_value=Result())
check_mock = mocker.patch("ahriman.application.handlers.Handler.check_if_empty")
@ -147,7 +151,7 @@ def test_extract_packages(application: Application, mocker: MockerFixture) -> No
"""
must extract packages from database
"""
packages_mock = mocker.patch("ahriman.core.database.SQLite.packages_get")
packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages")
Rebuild.extract_packages(application, from_database=False)
packages_mock.assert_called_once_with()
@ -156,6 +160,6 @@ def test_extract_packages_from_database(application: Application, mocker: Mocker
"""
must extract packages from database
"""
packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages")
packages_mock = mocker.patch("ahriman.core.database.SQLite.packages_get")
Rebuild.extract_packages(application, from_database=True)
packages_mock.assert_called_once_with()

View File

@ -25,12 +25,14 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
args.build_as_user = "ahriman"
args.build_command = "ahriman"
args.from_configuration = Path("/usr/share/devtools/pacman-extra.conf")
args.makeflags_jobs = True
args.multilib = True
args.packager = "John Doe <john@doe.com>"
args.repository = "aur-clone"
args.sign_key = "key"
args.sign_target = [SignSettings.Packages]
args.web_port = 8080
args.web_unix_socket = Path("/var/lib/ahriman/ahriman-web.sock")
return args
@ -53,10 +55,10 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository_
args, "x86_64", args.repository, configuration.include, repository_paths)
devtools_configuration_mock.assert_called_once_with(
args.build_command, "x86_64", args.from_configuration, args.multilib, args.repository, repository_paths)
makepkg_configuration_mock.assert_called_once_with(args.packager, repository_paths)
makepkg_configuration_mock.assert_called_once_with(args.packager, args.makeflags_jobs, repository_paths)
sudo_configuration_mock.assert_called_once_with(repository_paths, args.build_command, "x86_64")
executable_mock.assert_called_once_with(repository_paths, args.build_command, "x86_64")
init_mock.assert_called_once()
init_mock.assert_called_once_with()
def test_build_command(args: argparse.Namespace) -> None:
@ -91,6 +93,7 @@ def test_configuration_create_ahriman(args: argparse.Namespace, configuration: C
" ".join([target.name.lower() for target in args.sign_target])),
MockCall(Configuration.section_name("sign", "x86_64"), "key", args.sign_key),
MockCall(Configuration.section_name("web", "x86_64"), "port", str(args.web_port)),
MockCall(Configuration.section_name("web", "x86_64"), "unix_socket", str(args.web_unix_socket)),
])
write_mock.assert_called_once_with(pytest.helpers.anyvar(int))
@ -136,7 +139,7 @@ def test_configuration_create_makepkg(args: argparse.Namespace, repository_paths
mocker.patch("ahriman.application.handlers.setup.getpwuid", return_value=passwd)
write_text_mock = mocker.patch("pathlib.Path.write_text", autospec=True)
Setup.configuration_create_makepkg(args.packager, repository_paths)
Setup.configuration_create_makepkg(args.packager, args.makeflags_jobs, repository_paths)
write_text_mock.assert_called_once_with(
Path("home") / ".makepkg.conf", pytest.helpers.anyvar(str, True), encoding="utf8")

View File

@ -26,7 +26,6 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
args.username = "user"
args.action = Action.Update
args.as_service = False
args.exit_code = False
args.password = "pa55w0rd"
args.role = UserAccess.Reporter
@ -45,13 +44,36 @@ def test_run(args: argparse.Namespace, configuration: Configuration, database: S
get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.Users.configuration_get")
create_configuration_mock = mocker.patch("ahriman.application.handlers.Users.configuration_create")
create_user_mock = mocker.patch("ahriman.application.handlers.Users.user_create", return_value=user)
get_salt_mock = mocker.patch("ahriman.application.handlers.Users.get_salt", return_value="salt")
get_salt_mock = mocker.patch("ahriman.application.handlers.Users.get_salt", return_value=("salt", "salt"))
update_mock = mocker.patch("ahriman.core.database.SQLite.user_update")
Users.run(args, "x86_64", configuration, report=False, unsafe=False)
get_auth_configuration_mock.assert_not_called()
create_configuration_mock.assert_not_called()
create_user_mock.assert_called_once_with(args)
get_salt_mock.assert_called_once_with(configuration)
update_mock.assert_called_once_with(user)
def test_run_empty_salt(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create configuration if salt was not set
"""
args = _default_args(args)
user = User(username=args.username, password=args.password, access=args.role)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
mocker.patch("ahriman.models.user.User.hash_password", return_value=user)
get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.Users.configuration_get")
create_configuration_mock = mocker.patch("ahriman.application.handlers.Users.configuration_create")
create_user_mock = mocker.patch("ahriman.application.handlers.Users.user_create", return_value=user)
get_salt_mock = mocker.patch("ahriman.application.handlers.Users.get_salt", return_value=(None, "salt"))
update_mock = mocker.patch("ahriman.core.database.SQLite.user_update")
Users.run(args, "x86_64", configuration, report=False, unsafe=False)
get_auth_configuration_mock.assert_called_once_with(configuration.include)
create_configuration_mock.assert_called_once_with(pytest.helpers.anyvar(int), pytest.helpers.anyvar(int),
pytest.helpers.anyvar(int), args.as_service, args.secure)
create_configuration_mock.assert_called_once_with(
pytest.helpers.anyvar(int), pytest.helpers.anyvar(int), args.secure)
create_user_mock.assert_called_once_with(args)
get_salt_mock.assert_called_once_with(configuration)
update_mock.assert_called_once_with(user)
@ -103,7 +125,7 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, data
remove_mock.assert_called_once_with(args.username)
def test_configuration_create(configuration: Configuration, user: User, mocker: MockerFixture) -> None:
def test_configuration_create(configuration: Configuration, mocker: MockerFixture) -> None:
"""
must correctly create configuration file
"""
@ -111,28 +133,11 @@ def test_configuration_create(configuration: Configuration, user: User, mocker:
set_mock = mocker.patch("ahriman.core.configuration.Configuration.set_option")
write_mock = mocker.patch("ahriman.application.handlers.Users.configuration_write")
Users.configuration_create(configuration, user, "salt", False, False)
Users.configuration_create(configuration, "salt", False)
set_mock.assert_called_once_with("auth", "salt", pytest.helpers.anyvar(int))
write_mock.assert_called_once_with(configuration, False)
def test_configuration_create_with_plain_password(
configuration: Configuration,
user: User,
mocker: MockerFixture) -> None:
"""
must set plain text password and user for the service
"""
mocker.patch("pathlib.Path.open")
Users.configuration_create(configuration, user, "salt", True, False)
generated = User.from_option(user.username, user.password).hash_password("salt")
service = User.from_option(configuration.get("web", "username"), configuration.get("web", "password"))
assert generated.username == service.username
assert generated.check_credentials(service.password, configuration.get("auth", "salt"))
def test_configuration_get(mocker: MockerFixture) -> None:
"""
must load configuration from filesystem
@ -142,7 +147,7 @@ def test_configuration_get(mocker: MockerFixture) -> None:
read_mock = mocker.patch("ahriman.core.configuration.Configuration.read")
assert Users.configuration_get(Path("path"))
read_mock.assert_called_once_with(Path("path") / "auth.ini")
read_mock.assert_called_once_with(Path("path") / "00-auth.ini")
def test_configuration_write(configuration: Configuration, mocker: MockerFixture) -> None:
@ -185,7 +190,7 @@ def test_get_salt_read(configuration: Configuration) -> None:
"""
must read salt from configuration
"""
assert Users.get_salt(configuration) == "salt"
assert Users.get_salt(configuration) == ("salt", "salt")
def test_get_salt_generate(configuration: Configuration) -> None:
@ -194,8 +199,9 @@ def test_get_salt_generate(configuration: Configuration) -> None:
"""
configuration.remove_option("auth", "salt")
salt = Users.get_salt(configuration, 16)
old_salt, salt = Users.get_salt(configuration, 16)
assert salt
assert old_salt is None
assert len(salt) == 16

View File

@ -35,6 +35,7 @@ def anyvar(cls: Type[T], strict: bool = False) -> T:
any value helper for mocker calls check
Args:
cls(Type[T]): type of the variable to check
strict(bool, optional): if True then check type of supplied argument (Default value = False)
Returns:
@ -363,6 +364,19 @@ def pacman(configuration: Configuration) -> Pacman:
return Pacman("x86_64", configuration, refresh_database=0)
@pytest.fixture
def passwd() -> MagicMock:
"""
get passwd structure for the user
Returns:
MagicMock: passwd structure test instance
"""
passwd = MagicMock()
passwd.pw_dir = "home"
return passwd
@pytest.fixture
def remote_source() -> RemoteSource:
"""

View File

@ -10,7 +10,7 @@ def test_auth_control(auth: Auth) -> None:
must return a control for authorization
"""
assert auth.auth_control
assert "button" in auth.auth_control # I think it should be button
assert "button" in auth.auth_control # I think it should be a button
def test_load_dummy(configuration: Configuration, database: SQLite) -> None:

View File

@ -16,10 +16,10 @@ def test_extend_architectures(mocker: MockerFixture) -> None:
must update available architecture list
"""
mocker.patch("pathlib.Path.is_file", return_value=True)
archs_mock = mocker.patch("ahriman.models.package.Package.supported_architectures", return_value={"x86_64"})
architectures_mock = mocker.patch("ahriman.models.package.Package.supported_architectures", return_value={"x86_64"})
assert Sources.extend_architectures(Path("local"), "i686") == [PkgbuildPatch("arch", list({"x86_64", "i686"}))]
archs_mock.assert_called_once_with(Path("local"))
architectures_mock.assert_called_once_with(Path("local"))
def test_extend_architectures_any(mocker: MockerFixture) -> None:
@ -256,7 +256,7 @@ def test_add_intent_to_add(sources: Sources, mocker: MockerFixture) -> None:
def test_add_skip(sources: Sources, mocker: MockerFixture) -> None:
"""
must skip addition of files to index if no fiels found
must skip addition of files to index if no fields found
"""
mocker.patch("pathlib.Path.glob", return_value=[])
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.Sources._check_output")

View File

@ -63,7 +63,7 @@ def test_run(migrations: Migrations, mocker: MockerFixture) -> None:
def test_run_migration_exception(migrations: Migrations, mocker: MockerFixture) -> None:
"""
must rollback and close cursor on exception during migration
must roll back and close cursor on exception during migration
"""
cursor = MagicMock()
mocker.patch("logging.Logger.info", side_effect=Exception())

View File

@ -3,13 +3,13 @@ from ahriman.core.formatters import AurPrinter
def test_properties(aur_package_ahriman_printer: AurPrinter) -> None:
"""
must return non empty properties list
must return non-empty properties list
"""
assert aur_package_ahriman_printer.properties()
def test_title(aur_package_ahriman_printer: AurPrinter) -> None:
"""
must return non empty title
must return non-empty title
"""
assert aur_package_ahriman_printer.title() is not None

View File

@ -3,7 +3,7 @@ from ahriman.core.formatters import ConfigurationPrinter
def test_properties(configuration_printer: ConfigurationPrinter) -> None:
"""
must return non empty properties list
must return non-empty properties list
"""
assert configuration_printer.properties()
@ -17,6 +17,6 @@ def test_properties_required(configuration_printer: ConfigurationPrinter) -> Non
def test_title(configuration_printer: ConfigurationPrinter) -> None:
"""
must return non empty title
must return non-empty title
"""
assert configuration_printer.title() == "[section]"

View File

@ -3,13 +3,13 @@ from ahriman.core.formatters import PackagePrinter
def test_properties(package_ahriman_printer: PackagePrinter) -> None:
"""
must return non empty properties list
must return non-empty properties list
"""
assert package_ahriman_printer.properties()
def test_title(package_ahriman_printer: PackagePrinter) -> None:
"""
must return non empty title
must return non-empty title
"""
assert package_ahriman_printer.title() is not None

View File

@ -3,7 +3,7 @@ from ahriman.core.formatters import PatchPrinter
def test_properties(patch_printer: PatchPrinter) -> None:
"""
must return non empty properties list
must return non-empty properties list
"""
assert patch_printer.properties()
@ -17,6 +17,6 @@ def test_properties_required(patch_printer: PatchPrinter) -> None:
def test_title(patch_printer: PatchPrinter) -> None:
"""
must return non empty title
must return non-empty title
"""
assert patch_printer.title() == "ahriman"

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