Compare commits

...

72 Commits

Author SHA1 Message Date
810091cde9 Release 1.2.1 2021-08-17 00:52:09 +03:00
fc0474fa8f logging rethink
* well lets replace f-strings by %s as it is originally recommended
* use syslog handler by default
2021-08-17 00:23:34 +03:00
b94179e071 use asyncmock from unittest library 2021-08-11 21:09:10 +03:00
9c5a9f5837 Release 1.2.0 2021-08-11 05:02:23 +03:00
83047d8270 cleanup and speedup runs 2021-08-11 04:59:45 +03:00
990d5dda81 use nosec instead of disabling mktemp rule 2021-08-11 02:51:29 +03:00
48e79ce39c add bandit integration and fix its warnings 2021-08-11 02:45:13 +03:00
375d7c55e5 web templates improvements
* enable jinja autoescape by default for jinja raw generator
* allow to search by multiple strings (OR)
* replace test templates by symlink
2021-08-11 02:12:14 +03:00
db52b8e844 move web server to loopback by default 2021-08-11 02:02:18 +03:00
50af309c80 add docstrings for every fixture and test methods
also add tests for missing components
2021-08-11 01:55:27 +03:00
581401d60f skip update process if no update supplied 2021-08-10 23:25:12 +03:00
c2685f4746 Native s3 sync (#23)
* Native S3 sync implementation

* fix imports

* fix paths reading

* install s3 components duriing test stage
2021-08-10 23:18:56 +03:00
952b55f707 Release 1.1.0 2021-07-05 22:11:14 +03:00
b9b012be53 handle provides list 2021-07-05 22:08:04 +03:00
b8036649ab install types for mypy 2021-06-28 02:54:20 +03:00
c90e20587e remove type: ignore for newest python 2021-06-28 02:32:54 +03:00
3e020ec141 Feature/all archs (#21)
* add init subcommand

* add also init command to repository object

* add ability to generate list of architectures

* check if architecture list is not empty
2021-05-23 16:40:40 +03:00
783b7d043d imply no-log for every unsafe parser (#20) 2021-05-19 23:30:59 +03:00
5c297d1c67 allow to specify list of package dependencies in rebuild target
also replace nargs= by action=append in non-positional args. It is
required to make arguments parsing result more predictable and
consistent
2021-04-18 13:34:27 +03:00
b0d1f3c091 Release 1.0.0 2021-04-10 01:38:55 +03:00
50e219fda5 import pgp key implementation (#17)
* import pgp key implementation

* do not ask confirmation for local sign. Also add argparser test

* superseed requests by python-aur package

* ...and drop --skippgpcheck makgepkg flag by default
2021-04-10 01:37:45 +03:00
75298d1b8a better naming for actions 2021-04-09 20:02:17 +03:00
8196dcc8a0 add search subparser (#15) 2021-04-09 11:57:06 +03:00
f634f1df58 Add web status route (#13)
* add status route

* typed status and get status at the start of application
2021-04-08 01:48:53 +03:00
32df4fc54f Move search line inside extended report option 2021-04-06 17:03:34 +03:00
11ae930c59 Release 0.22.1 2021-04-06 05:54:04 +03:00
9c332c23d2 format long line 2021-04-06 05:53:38 +03:00
4ed0a49a44 add ability to skip email report generation for empty update list 2021-04-06 05:51:50 +03:00
50f532a48a Release 0.22.0 2021-04-06 05:46:12 +03:00
c6ccf53768 Email report (#11)
* Demo email report implementation

* improved ssl mode

* correct default option spelling and more fields to be hidden for not
extended reports
2021-04-06 05:45:17 +03:00
ce0c07cbd9 Release 0.21.4 2021-04-05 02:28:38 +03:00
912a76d5cb drop changelog
the main reason is that it uses github to generate changelog. Thus it
will be updated AFTER release is created
2021-04-05 02:27:12 +03:00
76d0b0bc6d Release 0.21.3 2021-04-05 02:22:44 +03:00
27d018e721 update changelog at correct step
also fix commit filter and do not update sha anymore
2021-04-05 02:22:11 +03:00
a0e20ffb77 Release 0.21.2 2021-04-05 02:01:28 +03:00
96e4abc3c0 add changelog generator to both gh-actions and repository 2021-04-05 02:00:05 +03:00
6df60498aa Release 0.21.1 2021-04-05 00:45:12 +03:00
eb0a4b6b4a use globing instead 2021-04-05 00:44:39 +03:00
8f469e7eac Release 0.21.0 2021-04-05 00:38:23 +03:00
535e955814 try to make auto archive upload 2021-04-05 00:37:03 +03:00
0bd3ba626a implicit type conversion from command line 2021-04-04 23:53:30 +03:00
ffe6aec190 more options in setup command 2021-04-04 15:42:06 +03:00
56c600e5ac fix check errors 2021-04-04 14:00:42 +03:00
461883217d 100% coverage 2021-04-03 21:30:57 +03:00
62d55eff19 add ability to fitler by dependency list 2021-04-02 04:20:39 +03:00
534b5600b4 add ability to remove package from status page 2021-04-02 01:26:46 +03:00
32cbafd12b Release 0.20.0 2021-04-01 02:38:59 +03:00
880c70bd58 constistent classmethod and staticmethod usage
General idea is to use classmethod for every constructor and
statismethod otherwise.
Also use self and cls whenever it's possible to call static and class
methods
2021-03-31 04:29:08 +03:00
d449eb3c2e change arch specific section naming from section_arch to section:arch
Some archs can have _ in their name. Also in future we can use sections
with similar names
2021-03-31 02:31:14 +03:00
17b5cd0751 Release 0.19.0 2021-03-31 02:19:44 +03:00
2aef906fc8 add now argument to add command 2021-03-31 02:19:13 +03:00
e034327501 filter out every foreign arch in config 2021-03-31 01:55:39 +03:00
5d79fcca22 read sign targets from correct path 2021-03-31 00:11:05 +03:00
6e9dcca254 make configuration object arch-specific 2021-03-31 00:04:13 +03:00
fbf6748d4a more verbose variables 2021-03-30 05:29:13 +03:00
2260e52d5c merge settings groups instead of using whole group 2021-03-30 04:58:15 +03:00
bd2b61494f move rsync and s3 options to configuration 2021-03-30 02:38:18 +03:00
7280d30748 verbose help message 2021-03-30 02:25:23 +03:00
710274065d add status badge 2021-03-30 02:01:59 +03:00
e0b09cefad rename gpg test to correct naming 2021-03-30 01:53:23 +03:00
3b93510aad add more tests 2021-03-30 01:42:01 +03:00
5003cabeb5 Release 0.18.0 2021-03-29 11:48:54 +03:00
bc6af9256b more properties to be shown in status pages 2021-03-29 11:48:32 +03:00
1ac7c87317 architecture depending pacman.conf 2021-03-29 10:08:11 +03:00
803b7bee1e add status update subcommand
also satisfy pylint with too big method with too much variables
2021-03-29 04:17:10 +03:00
646190121a Release 0.17.0 2021-03-29 03:25:43 +03:00
10e4f3b629 Setup command (#9)
* block issues without templates

* add setup subcommand

* handle devtools config correctly
2021-03-29 03:24:58 +03:00
80a1f37c85 more templates 2021-03-29 00:13:20 +03:00
751676a07e Add issue templates 2021-03-28 23:50:41 +03:00
e1a7071ce5 try to integrate with github workflows 2021-03-28 23:13:42 +03:00
1605d185e2 remove unused import 2021-03-28 16:24:51 +03:00
2fdf910e78 add sign command (#7) (#8) 2021-03-28 16:24:00 +03:00
160 changed files with 5057 additions and 1124 deletions

1
.bandit-test.yml Normal file
View File

@ -0,0 +1 @@
skips: ['B101', 'B404']

1
.bandit.yml Normal file
View File

@ -0,0 +1 @@
skips: ['B404', 'B603']

24
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@ -0,0 +1,24 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
## Summary
A clear and concise description of what the bug is.
### Steps to Reproduce
Steps to reproduce the behavior (commands, environment etc)
### Expected behavior
A clear and concise description of what you expected to happen.
### Logs
Add logs to help explain your problem. Logs to stderr can be generated by using `--no-log` command line option.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

12
.github/ISSUE_TEMPLATE/discussion.md vendored Normal file
View File

@ -0,0 +1,12 @@
---
name: Question
about: Create an issue to get help with project
title: ''
labels: question
assignees: ''
---
## Describe your question below
A clear and concise description of your issue for which you would like to get help.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
## Feature summary
Brief description of the feature required
### Cause of the feature request
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
### Proposed changes and/or features
A clear and concise description of what you want to happen.

13
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,13 @@
## Summary
Brief description of the pull request. Try to provide clear explanation for major changes.
Please make sure that branch called either `feature/feature-name` for feature-related pull requests or `bug/bug-name` for bug-related ones.
Put `closes #ISSUE` in case if the pull requests solves one of the opened issues.
### Checklist
- [ ] Tests to cover new code
- [ ] `make check` passed
- [ ] `make tests` passed

37
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: release
on:
push:
tags:
- '*.*.*'
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: extract version
id: version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: create changelog
id: changelog
uses: jaywcjlove/changelog-generator@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
filter: 'Release \d+\.\d+\.\d+'
- name: create archive
run: make archive
env:
VERSION: ${{ steps.version.outputs.VERSION }}
- name: Release
uses: softprops/action-gh-release@v1
with:
body: |
${{ steps.changelog.outputs.compareurl }}
${{ steps.changelog.outputs.changelog }}
files: ahriman-*-src.tar.xz
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

26
.github/workflows/run-tests.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: run check and tests in archlinux container
run: |
docker run \
-v ${{ github.workspace }}:/build -w /build \
archlinux:latest \
/bin/bash -c "pacman --noconfirm -Syu base-devel python python-pip && \
pip install -e .[web] && \
pip install -e .[check] && \
pip install -e .[s3] && \
pip install -e .[test] && \
make check tests"

View File

@ -22,7 +22,7 @@ ignore-patterns=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use. # number of processors available to use.
jobs=1 jobs=0
# Control the amount of potential inferred values when inferring a single # Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or # object. This can help the performance when dealing with large functions or
@ -149,7 +149,6 @@ disable=print-statement,
too-few-public-methods, too-few-public-methods,
too-many-instance-attributes, too-many-instance-attributes,
broad-except, broad-except,
logging-fstring-interpolation,
too-many-ancestors, too-many-ancestors,
fixme, fixme,
too-many-arguments, too-many-arguments,

2
AUTHORS Normal file
View File

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

View File

@ -1,6 +1,6 @@
# ahriman configuration # ahriman configuration
Some groups can be specified for each architecture separately. E.g. if there are `build` and `build_x86_64` groups it will use the `build_x86_64` for the `x86_64` architecture and `build` for any other (architecture specific group has higher priority). Some groups can be specified for each architecture separately. E.g. if there are `build` and `build:x86_64` groups it will use the option from `build:x86_64` for the `x86_64` architecture and `build` for any other (architecture specific group has higher priority). In case if both groups are presented, architecture specific options will be merged into global ones overriding them.
## `settings` group ## `settings` group
@ -18,9 +18,9 @@ libalpm and AUR related configuration.
* `repositories` - list of pacman repositories, space separated list of strings, required. * `repositories` - list of pacman repositories, space separated list of strings, required.
* `root` - root for alpm library, string, required. * `root` - root for alpm library, string, required.
## `build_*` groups ## `build:*` groups
Build related configuration. Group name must refer to architecture, e.g. it should be `build_x86_64` for x86_64 architecture. Build related configuration. Group name must refer to architecture, e.g. it should be `build:x86_64` for x86_64 architecture.
* `archbuild_flags` - additional flags passed to `archbuild` command, space separated list of strings, optional. * `archbuild_flags` - additional flags passed to `archbuild` command, space separated list of strings, optional.
* `build_command` - default build command, string, required. * `build_command` - default build command, string, required.
@ -35,9 +35,9 @@ Base repository settings.
* `name` - repository name, string, required. * `name` - repository name, string, required.
* `root` - root path for application, string, required. * `root` - root path for application, string, required.
## `sign_*` groups ## `sign:*` groups
Settings for signing packages or repository. Group name must refer to architecture, e.g. it should be `sign_x86_64` for x86_64 architecture. Settings for signing packages or repository. Group name must refer to architecture, e.g. it should be `sign:x86_64` for x86_64 architecture.
* `target` - configuration flag to enable signing, space separated list of strings, required. Allowed values are `package` (sign each package separately), `repository` (sign repository database file). * `target` - configuration flag to enable signing, space separated list of strings, required. Allowed values are `package` (sign each package separately), `repository` (sign repository database file).
* `key` - default PGP key, string, required. This key will also be used for database signing if enabled. * `key` - default PGP key, string, required. This key will also be used for database signing if enabled.
@ -47,11 +47,27 @@ Settings for signing packages or repository. Group name must refer to architectu
Report generation settings. Report generation settings.
* `target` - list of reports to be generated, space separated list of strings, optional. Allowed values are `html`. * `target` - list of reports to be generated, space separated list of strings, optional. Allowed values are `html`, `email`.
### `html_*` groups ### `email:*` groups
Group name must refer to architecture, e.g. it should be `html_x86_64` for x86_64 architecture. Group name must refer to architecture, e.g. it should be `email:x86_64` for x86_64 architecture.
* `homepage` - link to homepage, string, optional.
* `host` - SMTP host for sending emails, string, required.
* `link_path` - prefix for HTML links, string, required.
* `no_empty_report` - skip report generation for empty packages list, boolean, optional, default `yes`.
* `password` - SMTP password to authenticate, string, optional.
* `port` - SMTP port for sending emails, int, required.
* `receivers` - SMTP receiver addresses, space separated list of strings, required.
* `sender` - SMTP sender address, string, required.
* `ssl` - SSL mode for SMTP connection, one of `ssl`, `starttls`, `disabled`, optional, default `disabled`.
* `template_path` - path to Jinja2 template, string, required.
* `user` - SMTP user to authenticate, string, optional.
### `html:*` groups
Group name must refer to architecture, e.g. it should be `html:x86_64` for x86_64 architecture.
* `path` - path to html report file, string, required. * `path` - path to html report file, string, required.
* `homepage` - link to homepage, string, optional. * `homepage` - link to homepage, string, optional.
@ -64,21 +80,26 @@ Remote synchronization settings.
* `target` - list of synchronizations to be used, space separated list of strings, optional. Allowed values are `rsync`, `s3`. * `target` - list of synchronizations to be used, space separated list of strings, optional. Allowed values are `rsync`, `s3`.
### `rsync_*` groups ### `rsync:*` groups
Group name must refer to architecture, e.g. it should be `rsync_x86_64` for x86_64 architecture. Requires `rsync` package to be installed. Do not forget to configure ssh for user `ahriman`. Group name must refer to architecture, e.g. it should be `rsync:x86_64` for x86_64 architecture. Requires `rsync` package to be installed. Do not forget to configure ssh for user `ahriman`.
* `command` - rsync command to run, space separated list of string, required.
* `remote` - remote server to rsync (e.g. `1.2.3.4:5678:path/to/sync`), string, required. * `remote` - remote server to rsync (e.g. `1.2.3.4:5678:path/to/sync`), string, required.
### `s3_*` groups ### `s3:*` groups
Group name must refer to architecture, e.g. it should be `s3_x86_64` for x86_64 architecture. Requires `aws-cli` package to be installed. Do not forget to configure it for user `ahriman`. Group name must refer to architecture, e.g. it should be `s3:x86_64` for x86_64 architecture.
* `bucket` - bucket name (e.g. `s3://bucket/path`), string, required. * `access_key` - AWS access key ID, string, required.
* `bucket` - bucket name (e.g. `bucket`), string, required.
* `chunk_size` - chunk size for calculating entity tags, int, optional, default 8 * 1024 * 1024.
* `region` - bucket region (e.g. `eu-central-1`), string, required.
* `secret_key` - AWS secret access key, string, required.
## `web_*` groups ## `web:*` groups
Web server settings. If any of `host`/`port` is not set, web integration will be disabled. Group name must refer to architecture, e.g. it should be `web_x86_64` for x86_64 architecture. Web server settings. If any of `host`/`port` is not set, web integration will be disabled. Group name must refer to architecture, e.g. it should be `web:x86_64` for x86_64 architecture.
* `host` - host to bind, string, optional. * `host` - host to bind, string, optional.
* `port` - port to bind, int, optional. * `port` - port to bind, int, optional.

View File

@ -3,7 +3,7 @@
PROJECT := ahriman PROJECT := ahriman
FILES := COPYING CONFIGURING.md README.md package src setup.py FILES := AUTHORS COPYING CONFIGURING.md README.md package src setup.py
TARGET_FILES := $(addprefix $(PROJECT)/, $(FILES)) TARGET_FILES := $(addprefix $(PROJECT)/, $(FILES))
IGNORE_FILES := package/archlinux src/.mypy_cache IGNORE_FILES := package/archlinux src/.mypy_cache
@ -21,13 +21,13 @@ archive_directory: $(TARGET_FILES)
find "$(PROJECT)" -depth -type d -name "*.egg-info" -execdir rm -rf {} + find "$(PROJECT)" -depth -type d -name "*.egg-info" -execdir rm -rf {} +
archlinux: archive archlinux: archive
sed -i "/sha512sums=('[0-9A-Fa-f]*/s/[^'][^)]*/sha512sums=('$$(sha512sum $(PROJECT)-$(VERSION)-src.tar.xz | awk '{print $$1}')'/" package/archlinux/PKGBUILD
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
check: check: clean mypy
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" autopep8 --exit-code --max-line-length 120 -aa -i -j 0 -r "src/$(PROJECT)" "tests/$(PROJECT)"
find "src/$(PROJECT)" tests -name "*.py" -execdir autopep8 --exit-code --max-line-length 120 -aa -i {} + pylint --rcfile=.pylintrc "src/$(PROJECT)"
cd src && pylint --rcfile=../.pylintrc "$(PROJECT)" bandit -c .bandit.yml -r "src/$(PROJECT)"
bandit -c .bandit-test.yml -r "tests/$(PROJECT)"
clean: clean:
find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete
@ -36,14 +36,18 @@ clean:
directory: clean directory: clean
mkdir "$(PROJECT)" mkdir "$(PROJECT)"
mypy:
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)" --install-types --non-interactive || true
cd src && mypy --implicit-reexport --strict -p "$(PROJECT)"
push: archlinux push: archlinux
git add package/archlinux/PKGBUILD src/ahriman/version.py git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $(VERSION)" git commit -m "Release $(VERSION)"
git push
git tag "$(VERSION)" git tag "$(VERSION)"
git push
git push --tags git push --tags
tests: tests: clean
python setup.py test python setup.py test
version: version:

View File

@ -1,5 +1,7 @@
# ArcHlinux ReposItory MANager # ArcHlinux ReposItory MANager
[![build status](https://github.com/arcan1s/ahriman/actions/workflows/run-tests.yml/badge.svg)](https://github.com/arcan1s/ahriman/actions/workflows/run-tests.yml)
Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts). Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts).
## Features ## Features
@ -19,14 +21,14 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
* Create `/var/lib/ahriman/.makepkg.conf` with `makepkg.conf` overrides if required (at least you might want to set `PACKAGER`): * Create `/var/lib/ahriman/.makepkg.conf` with `makepkg.conf` overrides if required (at least you might want to set `PACKAGER`):
```shell ```shell
echo 'PACKAGES="John Doe <john@doe.com>"' | sudo -u ahriman tee -a /var/lib/ahriman/.makepkg.conf echo 'PACKAGER="John Doe <john@doe.com>"' | sudo -u ahriman tee -a /var/lib/ahriman/.makepkg.conf
``` ```
* Configure build tools (it is required for correct dependency management system): * Configure build tools (it is required for correct dependency management system):
* create build command, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/ahriman-x86_64-build` (you can choose any name for command, basically it should be `{name}-{arch}-build`); * create build command, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/ahriman-x86_64-build` (you can choose any name for command, basically it should be `{name}-{arch}-build`);
* create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,ahriman}.conf` (same as previous `pacman-{name}.conf`); * create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,ahriman}.conf` (same as previous `pacman-{name}.conf`);
* change configuration file, add your own repository, add multilib repository etc. Hint: you can use `Include` option as well; * change configuration file, add your own repository, add multilib repository etc;
* set `build_command` option to point to your command; * set `build_command` option to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow running command without a password. * configure `/etc/sudoers.d/ahriman` to allow running command without a password.
@ -64,5 +66,7 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
* Add packages by using `ahriman add {package}` command: * Add packages by using `ahriman add {package}` command:
```shell ```shell
sudo -u ahriman ahriman -a x86_64 add yay sudo -u ahriman ahriman -a x86_64 add yay --now
``` ```
Note that initial service configuration can be done by running `ahriman setup` with specific arguments.

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=0.16.0 pkgver=1.2.1
pkgrel=1 pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager" pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
@ -9,21 +9,20 @@ url="https://github.com/arcan1s/ahriman"
license=('GPL3') license=('GPL3')
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo') depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
makedepends=('python-pip') makedepends=('python-pip')
optdepends=('aws-cli: sync to s3' optdepends=('breezy: -bzr packages support'
'breezy: -bzr packages support'
'darcs: -darcs packages support' 'darcs: -darcs packages support'
'gnupg: package and repository sign' 'gnupg: package and repository sign'
'mercurial: -hg packages support' 'mercurial: -hg packages support'
'python-aiohttp: web server' 'python-aiohttp: web server'
'python-aiohttp-jinja2: web server' 'python-aiohttp-jinja2: web server'
'python-boto3: sync to s3'
'python-jinja: html report generation' 'python-jinja: html report generation'
'python-requests: web server'
'rsync: sync by using rsync' 'rsync: sync by using rsync'
'subversion: -svn packages support') 'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sysusers' 'ahriman.sysusers'
'ahriman.tmpfiles') 'ahriman.tmpfiles')
sha512sums=('b337bd936d8bc768a703eaa519e4a178993454e15696135fc21cd4216fbd03bcf433c9887cc96b4cf96f8738488d338338601b5b04c5c3b099ab69c52305d8f6' sha512sums=('6ab741bfb42f92ab00d1b6ecfc44426c00e5c433486e014efbdb585715d9a12dbbafc280e5a9f85b941c8681b13a9dad41327a3e3c44a9683ae30c1d6f017f50'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075' '13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4') '55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini' backup=('etc/ahriman.ini'

View File

@ -1,2 +1 @@
d /var/lib/ahriman 0775 ahriman log
d /var/log/ahriman 0755 ahriman ahriman d /var/log/ahriman 0755 ahriman ahriman

View File

@ -13,7 +13,7 @@ archbuild_flags =
build_command = extra-x86_64-build build_command = extra-x86_64-build
ignore_packages = ignore_packages =
makechrootpkg_flags = makechrootpkg_flags =
makepkg_flags = --skippgpcheck makepkg_flags =
[repository] [repository]
name = aur-clone name = aur-clone
@ -21,25 +21,27 @@ root = /var/lib/ahriman
[sign] [sign]
target = target =
key =
[report] [report]
target = target =
[email]
no_empty_report = yes
template_path = /usr/share/ahriman/repo-index.jinja2
ssl = disabled
[html] [html]
path =
homepage =
link_path =
template_path = /usr/share/ahriman/repo-index.jinja2 template_path = /usr/share/ahriman/repo-index.jinja2
[upload] [upload]
target = target =
[rsync] [rsync]
remote = command = rsync --archive --compress --partial --delete
[s3] [s3]
bucket = chunk_size = 8388608
[web] [web]
host = 127.0.0.1
templates = /usr/share/ahriman templates = /usr/share/ahriman

View File

@ -2,10 +2,10 @@
keys = root,builder,build_details,http keys = root,builder,build_details,http
[handlers] [handlers]
keys = console_handler,build_file_handler,file_handler,http_handler keys = console_handler,build_file_handler,file_handler,http_handler,syslog_handler
[formatters] [formatters]
keys = generic_format keys = generic_format,syslog_format
[handler_console_handler] [handler_console_handler]
class = StreamHandler class = StreamHandler
@ -31,29 +31,39 @@ level = DEBUG
formatter = generic_format formatter = generic_format
args = ("/var/log/ahriman/http.log", "a", 20971520, 20) args = ("/var/log/ahriman/http.log", "a", 20971520, 20)
[handler_syslog_handler]
class = logging.handlers.SysLogFileHandler
level = DEBUG
formatter = syslog_format
args = ("/dev/log",)
[formatter_generic_format] [formatter_generic_format]
format = [%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s format = [%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt = datefmt =
[formatter_syslog_format]
format = [%(levelname)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt =
[logger_root] [logger_root]
level = DEBUG level = DEBUG
handlers = file_handler handlers = syslog_handler
qualname = root qualname = root
[logger_builder] [logger_builder]
level = DEBUG level = DEBUG
handlers = file_handler handlers = syslog_handler
qualname = builder qualname = builder
propagate = 0 propagate = 0
[logger_build_details] [logger_build_details]
level = DEBUG level = DEBUG
handlers = build_file_handler handlers = syslog_handler
qualname = build_details qualname = build_details
propagate = 0 propagate = 0
[logger_http] [logger_http]
level = DEBUG level = DEBUG
handlers = http_handler handlers = syslog_handler
qualname = http qualname = http
propagate = 0 propagate = 0

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ repository|e }}</title> <title>{{ repository }}</title>
{% include "style.jinja2" %} {% include "style.jinja2" %}
@ -12,9 +12,9 @@
<body> <body>
<div class="root"> <div class="root">
<h1>ahriman <h1>ahriman
<img src="https://img.shields.io/badge/version-{{ version|e }}-informational" alt="{{ version|e }}"> <img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture|e }}-informational" alt="{{ architecture|e }}"> <img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status|e }}-{{ service.status_color|e }}" alt="{{ service.status|e }}" title="{{ service.timestamp|e }}"> <img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
</h1> </h1>
{% include "search-line.jinja2" %} {% include "search-line.jinja2" %}
@ -31,11 +31,11 @@
{% for package in packages %} {% for package in packages %}
<tr class="package"> <tr class="package">
<td class="include-search"><a href="{{ package.web_url|e }}" title="{{ package.base|e }}">{{ package.base|e }}</a></td> <td class="include-search"><a href="{{ package.web_url }}" title="{{ package.base }}">{{ package.base }}</a></td>
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td> <td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.version|e }}</td> <td>{{ package.version }}</td>
<td>{{ package.timestamp|e }}</td> <td>{{ package.timestamp }}</td>
<td class="status package-{{ package.status|e }}">{{ package.status|e }}</td> <td class="status package-{{ package.status }}">{{ package.status }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -1,32 +1,36 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ repository|e }}</title> <title>{{ repository }}</title>
{% include "style.jinja2" %} {% include "style.jinja2" %}
{% include "sorttable.jinja2" %} {% if extended_report %}
{% include "search.jinja2" %} {% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
{% endif %}
</head> </head>
<body> <body>
<div class="root"> <div class="root">
<h1>Archlinux user repository</h1> {% if extended_report %}
<h1>Archlinux user repository</h1>
<section class="element"> <section class="element">
{% if pgp_key is not none %} {% if pgp_key is not none %}
<p>This repository is signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key|e }}&fingerprint=on&op=index" title="key search">{{ pgp_key|e }}</a> by default.</p> <p>This repository is signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key }}&fingerprint=on&op=index" title="key search">{{ pgp_key }}</a> by default.</p>
{% endif %} {% endif %}
<code> <code>
$ cat /etc/pacman.conf<br> $ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br> [{{ repository }}]<br>
Server = {{ link_path|e }}<br> Server = {{ link_path }}<br>
SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code> </code>
</section> </section>
{% include "search-line.jinja2" %} {% include "search-line.jinja2" %}
{% endif %}
<section class="element"> <section class="element">
<table class="sortable search-table"> <table class="sortable search-table">
@ -40,23 +44,25 @@
{% for package in packages %} {% for package in packages %}
<tr class="package"> <tr class="package">
<td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td> <td class="include-search"><a href="{{ link_path }}/{{ package.filename }}" title="{{ package.name }}">{{ package.name }}</a></td>
<td>{{ package.version|e }}</td> <td>{{ package.version }}</td>
<td>{{ package.archive_size|e }}</td> <td>{{ package.archive_size }}</td>
<td>{{ package.installed_size|e }}</td> <td>{{ package.installed_size }}</td>
<td>{{ package.build_date|e }}</td> <td>{{ package.build_date }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</section> </section>
<footer> {% if extended_report %}
<ul class="navigation"> <footer>
{% if homepage is not none %} <ul class="navigation">
<li><a href="{{ homepage|e }}" title="homepage">Homepage</a></li> {% if homepage is not none %}
{% endif %} <li><a href="{{ homepage }}" title="homepage">Homepage</a></li>
</ul> {% endif %}
</footer> </ul>
</footer>
{% endif %}
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,3 +1,3 @@
<section class="element"> <section class="element">
<input type="search" id="search" onkeyup="searchInTable()" placeholder="search for package" title="search for package"/> <input type="search" id="search" onkeyup="searchInTable()" placeholder="search for package" title="search for package"/>
</section> </section>

View File

@ -5,21 +5,22 @@
const tables = document.getElementsByClassName("search-table"); const tables = document.getElementsByClassName("search-table");
for (let i = 0; i < tables.length; i++) { for (let i = 0; i < tables.length; i++) {
const tr = tables[i].getElementsByTagName("tr"); const trs = tables[i].getElementsByTagName("tr");
// from 1 coz of header // from 1 coz of header
for (let i = 1; i < tr.length; i++) { for (let i = 1; i < trs.length; i++) {
let td = tr[i].getElementsByClassName("include-search"); let tr = trs[i].getElementsByClassName("include-search");
let display = "none"; let display = "none";
for (let j = 0; j < td.length; j++) { for (let j = 0; j < tr.length; j++) {
if (td[j].tagName.toLowerCase() === "td") { if (tr[j].tagName.toLowerCase() === "td") {
if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) { let contains = (element) => tr[j].innerHTML.toLowerCase().indexOf(element) > -1
if (filter.some(contains)) {
display = ""; display = "";
break; break;
} }
} }
} }
tr[i].style.display = display; trs[i].style.display = display;
} }
} }
} }
</script> </script>

View File

@ -1 +1 @@
<script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script> <script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script>

View File

@ -133,4 +133,4 @@
ul.navigation li a:hover { ul.navigation li a:hover {
background-color: rgba(var(--color-hover), 1.0); background-color: rgba(var(--color-hover), 1.0);
} }
</style> </style>

View File

@ -2,4 +2,4 @@
test = pytest test = pytest
[tool:pytest] [tool:pytest]
addopts = --cov=ahriman --pspec addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec

View File

@ -29,6 +29,7 @@ setup(
install_requires=[ install_requires=[
"aur", "aur",
"pyalpm", "pyalpm",
"requests",
"srcinfo", "srcinfo",
], ],
setup_requires=[ setup_requires=[
@ -71,8 +72,28 @@ setup(
], ],
extras_require={ extras_require={
"html-templates": ["Jinja2"], "check": [
"test": ["pytest", "pytest-cov", "pytest-helpers-namespace", "pytest-mock", "pytest-pspec", "pytest-resource-path"], "autopep8",
"web": ["Jinja2", "aiohttp", "aiohttp_jinja2", "requests"], "bandit",
"mypy",
"pylint",
],
"s3": [
"boto3",
],
"test": [
"pytest",
"pytest-aiohttp",
"pytest-cov",
"pytest-helpers-namespace",
"pytest-mock",
"pytest-pspec",
"pytest-resource-path",
],
"web": [
"Jinja2",
"aiohttp",
"aiohttp_jinja2",
],
}, },
) )

View File

@ -19,97 +19,330 @@
# #
import argparse import argparse
import sys import sys
import tempfile
import ahriman.application.handlers as handlers from pathlib import Path
import ahriman.version as version
from ahriman import version
from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings
# pylint thinks it is bad idea, but get the fuck off
# pylint: disable=protected-access
SubParserAction = argparse._SubParsersAction
# pylint: disable=too-many-statements
def _parser() -> argparse.ArgumentParser: def _parser() -> argparse.ArgumentParser:
""" """
command line parser generator command line parser generator
:return: command line parser for the application :return: command line parser for the application
""" """
parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager") parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager",
parser.add_argument( formatter_class=argparse.ArgumentDefaultsHelpFormatter)
"-a", parser.add_argument("-a", "--architecture", help="target architectures (can be used multiple times)",
"--architecture", action="append")
help="target architectures (can be used multiple times)", parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini"))
action="append",
required=True)
parser.add_argument("-c", "--config", help="configuration path", default="/etc/ahriman.ini")
parser.add_argument("--force", help="force run, remove file lock", action="store_true") parser.add_argument("--force", help="force run, remove file lock", action="store_true")
parser.add_argument("--lock", help="lock file", default="/tmp/ahriman.lock") parser.add_argument(
"-l",
"--lock",
help="lock file",
type=Path,
default=Path(
tempfile.gettempdir()) /
"ahriman.lock")
parser.add_argument("--no-log", help="redirect all log messages to stderr", action="store_true") parser.add_argument("--no-log", help="redirect all log messages to stderr", action="store_true")
parser.add_argument("--no-report", help="force disable reporting to web service", action="store_true") parser.add_argument("--no-report", help="force disable reporting to web service", action="store_true")
parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user", action="store_true") parser.add_argument("--unsafe", help="allow to run ahriman as non-ahriman user", action="store_true")
parser.add_argument("-v", "--version", action="version", version=version.__version__) parser.add_argument("-v", "--version", action="version", version=version.__version__)
subparsers = parser.add_subparsers(title="command", help="command to run", dest="command", required=True) subparsers = parser.add_subparsers(title="command", help="command to run", dest="command", required=True)
add_parser = subparsers.add_parser("add", description="add package") _set_add_parser(subparsers)
add_parser.add_argument("package", help="package base/name or archive path", nargs="+") _set_check_parser(subparsers)
add_parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true") _set_clean_parser(subparsers)
add_parser.set_defaults(handler=handlers.Add) _set_config_parser(subparsers)
_set_init_parser(subparsers)
check_parser = subparsers.add_parser("check", description="check for updates. Same as update --dry-run --no-manual") _set_key_import_parser(subparsers)
check_parser.add_argument("package", help="filter check by package base", nargs="*") _set_rebuild_parser(subparsers)
check_parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true") _set_remove_parser(subparsers)
check_parser.set_defaults(handler=handlers.Update, no_aur=False, no_manual=True, dry_run=True) _set_report_parser(subparsers)
_set_search_parser(subparsers)
clean_parser = subparsers.add_parser("clean", description="clear all local caches") _set_setup_parser(subparsers)
clean_parser.add_argument("--no-build", help="do not clear directory with package sources", action="store_true") _set_sign_parser(subparsers)
clean_parser.add_argument("--no-cache", help="do not clear directory with package caches", action="store_true") _set_status_parser(subparsers)
clean_parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true") _set_status_update_parser(subparsers)
clean_parser.add_argument( _set_sync_parser(subparsers)
"--no-manual", _set_update_parser(subparsers)
help="do not clear directory with manually added packages", _set_web_parser(subparsers)
action="store_true")
clean_parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true")
clean_parser.set_defaults(handler=handlers.Clean)
config_parser = subparsers.add_parser("config", description="dump configuration for specified architecture")
config_parser.set_defaults(handler=handlers.Dump, lock=None, no_report=True, unsafe=True)
rebuild_parser = subparsers.add_parser("rebuild", description="rebuild whole repository")
rebuild_parser.set_defaults(handler=handlers.Rebuild)
remove_parser = subparsers.add_parser("remove", description="remove package")
remove_parser.add_argument("package", help="package name or base", nargs="+")
remove_parser.set_defaults(handler=handlers.Remove)
report_parser = subparsers.add_parser("report", description="generate report")
report_parser.add_argument("target", help="target to generate report", nargs="*")
report_parser.set_defaults(handler=handlers.Report)
status_parser = subparsers.add_parser("status", description="request status of the package")
status_parser.add_argument("--ahriman", help="get service status itself", action="store_true")
status_parser.add_argument("package", help="filter status by package base", nargs="*")
status_parser.set_defaults(handler=handlers.Status, lock=None, no_report=True, unsafe=True)
sync_parser = subparsers.add_parser("sync", description="sync packages to remote server")
sync_parser.add_argument("target", help="target to sync", nargs="*")
sync_parser.set_defaults(handler=handlers.Sync)
update_parser = subparsers.add_parser("update", description="run updates")
update_parser.add_argument("package", help="filter check by package base", nargs="*")
update_parser.add_argument(
"--dry-run", help="just perform check for updates, same as check command", action="store_true")
update_parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true")
update_parser.add_argument("--no-manual", help="do not include manual updates", action="store_true")
update_parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
update_parser.set_defaults(handler=handlers.Update)
web_parser = subparsers.add_parser("web", description="start web server")
web_parser.set_defaults(handler=handlers.Web, lock=None, no_report=True)
return parser return parser
if __name__ == "__main__": def _set_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
arg_parser = _parser() """
args = arg_parser.parse_args() add parser for add subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("add", help="add package", description="add package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="package base/name or archive path", nargs="+")
parser.add_argument("--now", help="run update function after", action="store_true")
parser.add_argument("--without-dependencies", help="do not add dependencies", action="store_true")
parser.set_defaults(handler=handlers.Add, architecture=[])
return parser
handler: handlers.Handler = args.handler
status = handler.execute(args)
sys.exit(status) def _set_check_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for check subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("check", help="check for updates",
description="check for updates. Same as update --dry-run --no-manual",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="filter check by package base", nargs="*")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update, architecture=[], no_aur=False, no_manual=True, dry_run=True)
return parser
def _set_clean_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for clean subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("clean", help="clean local caches", description="clear local caches",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--no-build", help="do not clear directory with package sources", action="store_true")
parser.add_argument("--no-cache", help="do not clear directory with package caches", action="store_true")
parser.add_argument("--no-chroot", help="do not clear build chroot", action="store_true")
parser.add_argument("--no-manual", help="do not clear directory with manually added packages", action="store_true")
parser.add_argument("--no-packages", help="do not clear directory with built packages", action="store_true")
parser.set_defaults(handler=handlers.Clean, architecture=[], no_log=True, unsafe=True)
return parser
def _set_config_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for config subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("config", help="dump configuration",
description="dump configuration for specified architecture",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Dump, lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_init_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for init subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("init", help="create repository tree",
description="create empty repository tree. Optional command for auto architecture support",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Init, no_report=True)
return parser
def _set_key_import_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for key import subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("key-import", help="import PGP key",
description="import PGP key from public sources to repository user",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--key-server", help="key server for key import", default="keys.gnupg.net")
parser.add_argument("key", help="PGP key to import from public server")
parser.set_defaults(handler=handlers.KeyImport, architecture=[""], lock=None, no_report=True)
return parser
def _set_rebuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for rebuild subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("rebuild", help="rebuild repository", description="rebuild whole repository",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--depends-on", help="only rebuild packages that depend on specified package", action="append")
parser.set_defaults(handler=handlers.Rebuild, architecture=[])
return parser
def _set_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for remove subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("remove", help="remove package", description="remove package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="package name or base", nargs="+")
parser.set_defaults(handler=handlers.Remove, architecture=[])
return parser
def _set_report_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for report subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("report", help="generate report", description="generate report",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to generate report", nargs="*")
parser.set_defaults(handler=handlers.Report, architecture=[])
return parser
def _set_search_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for search subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("search", help="search for package", description="search for package in AUR using API")
parser.add_argument("search", help="search terms, can be specified multiple times", nargs="+")
parser.set_defaults(handler=handlers.Search, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_setup_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for setup subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("setup", help="initial service configuration",
description="create initial service configuration, requires root",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
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("--no-multilib", help="do not add multilib repository", action="store_true")
parser.add_argument("--packager", help="packager name and email", required=True)
parser.add_argument("--repository", help="repository name", required=True)
parser.add_argument("--sign-key", help="sign key id")
parser.add_argument("--sign-target", help="sign options", type=SignSettings.from_option,
choices=SignSettings, action="append")
parser.add_argument("--web-port", help="port of the web service", type=int)
parser.set_defaults(handler=handlers.Setup, lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_sign_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for sign subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("sign", help="sign packages", description="(re-)sign packages and repository database",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="sign only specified packages", nargs="*")
parser.set_defaults(handler=handlers.Sign, architecture=[])
return parser
def _set_status_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for status subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("status", help="get package status", description="request status of the package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--ahriman", help="get service status itself", action="store_true")
parser.add_argument("package", help="filter status by package base", nargs="*")
parser.set_defaults(handler=handlers.Status, lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_status_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for status update subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("status-update", help="update package status", description="request status of the package",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
"package",
help="set status for specified packages. If no packages supplied, service status will be updated",
nargs="*")
parser.add_argument("--status", help="new status", choices=BuildStatusEnum,
type=BuildStatusEnum, default=BuildStatusEnum.Success)
parser.add_argument("--remove", help="remove package status page", action="store_true")
parser.set_defaults(handler=handlers.StatusUpdate, lock=None, no_log=True, no_report=True, unsafe=True)
return parser
def _set_sync_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for sync subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("sync", help="sync repository", description="sync packages to remote server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("target", help="target to sync", nargs="*")
parser.set_defaults(handler=handlers.Sync, architecture=[])
return parser
def _set_update_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for update subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("update", help="update packages", description="run updates",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("package", help="filter check by package base", nargs="*")
parser.add_argument("--dry-run", help="just perform check for updates, same as check command", action="store_true")
parser.add_argument("--no-aur", help="do not check for AUR updates. Implies --no-vcs", action="store_true")
parser.add_argument("--no-manual", help="do not include manual updates", action="store_true")
parser.add_argument("--no-vcs", help="do not check VCS packages", action="store_true")
parser.set_defaults(handler=handlers.Update, architecture=[])
return parser
def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for web subcommand
:param root: subparsers for the commands
:return: created argument parser
"""
parser = root.add_parser("web", help="start web server", description="start web server",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.set_defaults(handler=handlers.Web, lock=None, no_report=True)
return parser
def run() -> None:
"""
run application instance
"""
if __name__ == "__main__":
args_parser = _parser()
args = args_parser.parse_args()
handler: handlers.Handler = args.handler
status = handler.execute(args)
sys.exit(status)
run()

View File

@ -21,7 +21,7 @@ import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable, List, Optional, Set from typing import Callable, Iterable, List, Set
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -35,21 +35,28 @@ class Application:
""" """
base application class base application class
:ivar architecture: repository architecture :ivar architecture: repository architecture
:ivar config: configuration instance :ivar configuration: configuration instance
:ivar logger: application logger :ivar logger: application logger
:ivar repository: repository instance :ivar repository: repository instance
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.logger = logging.getLogger("root") self.logger = logging.getLogger("root")
self.config = config self.configuration = configuration
self.architecture = architecture self.architecture = architecture
self.repository = Repository(architecture, config) self.repository = Repository(architecture, configuration)
def _finalize(self, built_packages: Iterable[Package]) -> None:
"""
generate report and sync to remote server
"""
self.report([], built_packages)
self.sync([], built_packages)
def _known_packages(self) -> Set[str]: def _known_packages(self) -> Set[str]:
""" """
@ -58,18 +65,13 @@ class Application:
""" """
known_packages: Set[str] = set() known_packages: Set[str] = set()
# local set # local set
for package in self.repository.packages(): for base in self.repository.packages():
known_packages.update(package.packages.keys()) for package, properties in base.packages.items():
known_packages.add(package)
known_packages.update(properties.provides)
known_packages.update(self.repository.pacman.all_packages()) known_packages.update(self.repository.pacman.all_packages())
return known_packages return known_packages
def _finalize(self) -> None:
"""
generate report and sync to remote server
"""
self.report()
self.sync()
def get_updates(self, filter_packages: List[str], no_aur: bool, no_manual: bool, no_vcs: bool, def get_updates(self, filter_packages: List[str], no_aur: bool, no_manual: bool, no_vcs: bool,
log_fn: Callable[[str], None]) -> List[Package]: log_fn: Callable[[str], None]) -> List[Package]:
""" """
@ -106,7 +108,7 @@ class Application:
add_archive(full_path) add_archive(full_path)
def add_manual(src: str) -> Path: def add_manual(src: str) -> Path:
package = Package.load(src, self.repository.pacman, self.config.get("alpm", "aur_url")) package = Package.load(src, self.repository.pacman, self.configuration.get("alpm", "aur_url"))
path = self.repository.paths.manual / package.base path = self.repository.paths.manual / package.base
Task.fetch(path, package.git_url) Task.fetch(path, package.git_url)
return path return path
@ -160,23 +162,48 @@ class Application:
:param names: list of packages (either base or name) to remove :param names: list of packages (either base or name) to remove
""" """
self.repository.process_remove(names) self.repository.process_remove(names)
self._finalize() self._finalize([])
def report(self, target: Optional[Iterable[str]] = None) -> None: def report(self, target: Iterable[str], built_packages: Iterable[Package]) -> None:
""" """
generate report generate report
:param target: list of targets to run (e.g. html) :param target: list of targets to run (e.g. html)
:param built_packages: list of packages which has just been built
""" """
targets = target or None targets = target or None
self.repository.process_report(targets) self.repository.process_report(targets, built_packages)
def sync(self, target: Optional[Iterable[str]] = None) -> None: def sign(self, packages: Iterable[str]) -> None:
"""
sign packages and repository
:param packages: only sign specified packages
"""
# copy to prebuilt directory
for package in self.repository.packages():
# no one requested this package
if packages and package.base not in packages:
continue
for archive in package.packages.values():
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([])
# sign repository database if set
self.repository.sign.sign_repository(self.repository.repo.repo_path)
self._finalize([])
def sync(self, target: Iterable[str], built_packages: Iterable[Package]) -> None:
""" """
sync to remote server sync to remote server
:param target: list of targets to run (e.g. s3) :param target: list of targets to run (e.g. s3)
:param built_packages: list of packages which has just been built
""" """
targets = target or None targets = target or None
self.repository.process_sync(targets) self.repository.process_sync(targets, built_packages)
def update(self, updates: Iterable[Package]) -> None: def update(self, updates: Iterable[Package]) -> None:
""" """
@ -184,8 +211,11 @@ class Application:
:param updates: list of packages to update :param updates: list of packages to update
""" """
def process_update(paths: Iterable[Path]) -> None: def process_update(paths: Iterable[Path]) -> None:
if not paths:
return # don't need to process if no update supplied
updated = [Package.load(path, self.repository.pacman, self.repository.aur_url) for path in paths]
self.repository.process_update(paths) self.repository.process_update(paths)
self._finalize() self._finalize(updated)
# process built packages # process built packages
packages = self.repository.packages_built() packages = self.repository.packages_built()
@ -194,6 +224,6 @@ class Application:
# process manual packages # process manual packages
tree = Tree.load(updates) tree = Tree.load(updates)
for num, level in enumerate(tree.levels()): for num, level in enumerate(tree.levels()):
self.logger.info(f"processing level #{num} {[package.base for package in level]}") self.logger.info("processing level #%i %s", num, [package.base for package in level])
packages = self.repository.process_build(level) packages = self.repository.process_build(level)
process_update(packages) process_update(packages)

View File

@ -22,10 +22,16 @@ from ahriman.application.handlers.handler import Handler
from ahriman.application.handlers.add import Add from ahriman.application.handlers.add import Add
from ahriman.application.handlers.clean import Clean from ahriman.application.handlers.clean import Clean
from ahriman.application.handlers.dump import Dump from ahriman.application.handlers.dump import Dump
from ahriman.application.handlers.init import Init
from ahriman.application.handlers.key_import import KeyImport
from ahriman.application.handlers.rebuild import Rebuild from ahriman.application.handlers.rebuild import Rebuild
from ahriman.application.handlers.remove import Remove from ahriman.application.handlers.remove import Remove
from ahriman.application.handlers.report import Report from ahriman.application.handlers.report import Report
from ahriman.application.handlers.search import Search
from ahriman.application.handlers.setup import Setup
from ahriman.application.handlers.sign import Sign
from ahriman.application.handlers.status import Status from ahriman.application.handlers.status import Status
from ahriman.application.handlers.status_update import StatusUpdate
from ahriman.application.handlers.sync import Sync from ahriman.application.handlers.sync import Sync
from ahriman.application.handlers.update import Update from ahriman.application.handlers.update import Update
from ahriman.application.handlers.web import Web from ahriman.application.handlers.web import Web

View File

@ -32,11 +32,17 @@ class Add(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Application(architecture, config).add(args.package, args.without_dependencies) application = Application(architecture, configuration)
application.add(args.package, args.without_dependencies)
if not args.now:
return
packages = application.get_updates(args.package, True, False, True, application.logger.info)
application.update(packages)

View File

@ -32,12 +32,12 @@ class Clean(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Application(architecture, config).clean(args.no_build, args.no_cache, args.no_chroot, Application(architecture, configuration).clean(args.no_build, args.no_cache, args.no_chroot,
args.no_manual, args.no_packages) args.no_manual, args.no_packages)

View File

@ -27,20 +27,22 @@ from ahriman.core.configuration import Configuration
class Dump(Handler): class Dump(Handler):
""" """
dump config handler dump configuration handler
""" """
_print = print
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
config_dump = config.dump(architecture) dump = configuration.dump()
for section, values in sorted(config_dump.items()): for section, values in sorted(dump.items()):
print(f"[{section}]") Dump._print(f"[{section}]")
for key, value in sorted(values.items()): for key, value in sorted(values.items()):
print(f"{key} = {value}") Dump._print(f"{key} = {value}")
print() Dump._print()

View File

@ -23,10 +23,12 @@ import argparse
import logging import logging
from multiprocessing import Pool from multiprocessing import Pool
from typing import Type from typing import Set, Type
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
from ahriman.models.repository_paths import RepositoryPaths
class Handler: class Handler:
@ -35,17 +37,17 @@ class Handler:
""" """
@classmethod @classmethod
def _call(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> bool: def _call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool:
""" """
additional function to wrap all calls for multiprocessing library additional function to wrap all calls for multiprocessing library
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance
:return: True on success, False otherwise :return: True on success, False otherwise
""" """
try: try:
with Lock(args, architecture, config): configuration = Configuration.from_path(args.configuration, architecture, not args.no_log)
cls.run(args, architecture, config) with Lock(args, architecture, configuration):
cls.run(args, architecture, configuration)
return True return True
except Exception: except Exception:
logging.getLogger("root").exception("process exception") logging.getLogger("root").exception("process exception")
@ -58,18 +60,39 @@ class Handler:
:param args: command line args :param args: command line args
:return: 0 on success, 1 otherwise :return: 0 on success, 1 otherwise
""" """
configuration = Configuration.from_path(args.config, not args.no_log) architectures = cls.extract_architectures(args)
with Pool(len(args.architecture)) as pool: with Pool(len(architectures)) as pool:
result = pool.starmap( result = pool.starmap(
cls._call, [(args, architecture, configuration) for architecture in args.architecture]) cls._call, [(args, architecture) for architecture in architectures])
return 0 if all(result) else 1 return 0 if all(result) else 1
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def extract_architectures(cls: Type[Handler], args: argparse.Namespace) -> Set[str]:
"""
get known architectures
:param args: command line args
:return: list of architectures for which tree is created
"""
if args.architecture is None:
raise MissingArchitecture(args.command)
if args.architecture:
return set(args.architecture)
config = Configuration()
config.load(args.configuration)
root = config.getpath("repository", "root")
architectures = RepositoryPaths.known_architectures(root)
if not architectures:
raise MissingArchitecture(args.command)
return architectures
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
raise NotImplementedError raise NotImplementedError

View File

@ -17,29 +17,26 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from dataclasses import dataclass import argparse
from pathlib import Path
from typing import Optional from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
@dataclass class Init(Handler):
class PackageDescription:
""" """
package specific properties repository init handler
:ivar archive_size: package archive size
:ivar build_date: package build date
:ivar filename: package archive name
:ivar installed_size: package installed size
""" """
archive_size: Optional[int] = None @classmethod
build_date: Optional[int] = None def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
filename: Optional[str] = None
installed_size: Optional[int] = None
@property
def filepath(self) -> Optional[Path]:
""" """
:return: path object for current filename callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
""" """
return Path(self.filename) if self.filename is not None else None Application(architecture, configuration).repository.repo.init()

View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class KeyImport(Handler):
"""
key import packages handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
Application(architecture, configuration).repository.sign.import_key(args.key_server, args.key)

View File

@ -32,13 +32,19 @@ class Rebuild(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
application = Application(architecture, config) depends_on = set(args.depends_on) if args.depends_on else None
packages = application.repository.packages()
application = Application(architecture, configuration)
packages = [
package
for package in application.repository.packages()
if depends_on is None or depends_on.intersection(package.depends)
] # we have to use explicit list here for testing purpose
application.update(packages) application.update(packages)

View File

@ -32,11 +32,11 @@ class Remove(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Application(architecture, config).remove(args.package) Application(architecture, configuration).remove(args.package)

View File

@ -32,11 +32,11 @@ class Report(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Application(architecture, config).report(args.target) Application(architecture, configuration).report(args.target, [])

View File

@ -0,0 +1,58 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import aur # type: ignore
from typing import Callable, Type
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Search(Handler):
"""
packages search handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
search = " ".join(args.search)
packages = aur.search(search)
# it actually always should return string
# explicit cast to string just to avoid mypy warning for untyped library
comparator: Callable[[aur.Package], str] = lambda item: str(item.package_base)
for package in sorted(packages, key=comparator):
Search.log_fn(package)
@staticmethod
def log_fn(package: aur.Package) -> None:
"""
log package information
:param package: package object as from AUR
"""
print(f"=> {package.package_base} {package.version}")
print(f" {package.description}")

View File

@ -0,0 +1,173 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import configparser
from pathlib import Path
from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
from ahriman.models.repository_paths import RepositoryPaths
class Setup(Handler):
"""
setup handler
:cvar ARCHBUILD_COMMAND_PATH: default devtools command
:cvar BIN_DIR_PATH: directory for custom binaries
:cvar MIRRORLIST_PATH: path to pacman default mirrorlist (used by multilib repository)
:cvar SUDOERS_PATH: path to sudoers.d include configuration
"""
ARCHBUILD_COMMAND_PATH = Path("/usr/bin/archbuild")
BIN_DIR_PATH = Path("/usr/local/bin")
MIRRORLIST_PATH = Path("/etc/pacman.d/mirrorlist")
SUDOERS_PATH = Path("/etc/sudoers.d/ahriman")
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
application = Application(architecture, configuration)
Setup.create_makepkg_configuration(args.packager, application.repository.paths)
Setup.create_executable(args.build_command, architecture)
Setup.create_devtools_configuration(args.build_command, architecture, args.from_configuration,
args.no_multilib, args.repository, application.repository.paths)
Setup.create_ahriman_configuration(args, architecture, args.repository, configuration.include)
Setup.create_sudo_configuration(args.build_command, architecture)
@staticmethod
def build_command(prefix: str, architecture: str) -> Path:
"""
generate build command name
:param prefix: command prefix in {prefix}-{architecture}-build
:param architecture: repository architecture
:return: valid devtools command name
"""
return Setup.BIN_DIR_PATH / f"{prefix}-{architecture}-build"
@staticmethod
def create_ahriman_configuration(args: argparse.Namespace, architecture: str, repository: str,
include_path: Path) -> None:
"""
create service specific configuration
:param args: command line args
:param architecture: repository architecture
:param repository: repository name
:param include_path: path to directory with configuration includes
"""
configuration = configparser.ConfigParser()
section = Configuration.section_name("build", architecture)
configuration.add_section(section)
configuration.set(section, "build_command", str(Setup.build_command(args.build_command, architecture)))
configuration.add_section("repository")
configuration.set("repository", "name", repository)
if args.sign_key is not None:
section = Configuration.section_name("sign", architecture)
configuration.add_section(section)
configuration.set(section, "target", " ".join([target.name.lower() for target in args.sign_target]))
configuration.set(section, "key", args.sign_key)
if args.web_port is not None:
section = Configuration.section_name("web", architecture)
configuration.add_section(section)
configuration.set(section, "port", str(args.web_port))
target = include_path / "setup-overrides.ini"
with target.open("w") as ahriman_configuration:
configuration.write(ahriman_configuration)
@staticmethod
def create_devtools_configuration(prefix: str, architecture: str, source: Path,
no_multilib: bool, repository: str, paths: RepositoryPaths) -> None:
"""
create configuration for devtools based on `source` configuration
:param prefix: command prefix in {prefix}-{architecture}-build
:param architecture: repository architecture
:param source: path to source configuration file
:param no_multilib: do not add multilib repository
:param repository: repository name
:param paths: repository paths instance
"""
configuration = configparser.ConfigParser()
# preserve case
# stupid mypy thinks that it is impossible
configuration.optionxform = lambda key: key # type: ignore
# load default configuration first
# we cannot use Include here because it will be copied to new chroot, thus no includes there
configuration.read(source)
# set our architecture now
configuration.set("options", "Architecture", architecture)
# add multilib
if not no_multilib:
configuration.add_section("multilib")
configuration.set("multilib", "Include", str(Setup.MIRRORLIST_PATH))
# add repository itself
configuration.add_section(repository)
configuration.set(repository, "SigLevel", "Optional TrustAll") # we don't care
configuration.set(repository, "Server", f"file://{paths.repository}")
target = source.parent / f"pacman-{prefix}-{architecture}.conf"
with target.open("w") as devtools_configuration:
configuration.write(devtools_configuration)
@staticmethod
def create_makepkg_configuration(packager: str, paths: RepositoryPaths) -> None:
"""
create configuration for makepkg
:param packager: packager identifier (e.g. name, email)
:param paths: repository paths instance
"""
(paths.root / ".makepkg.conf").write_text(f"PACKAGER='{packager}'\n")
@staticmethod
def create_sudo_configuration(prefix: str, architecture: str) -> None:
"""
create configuration to run build command with sudo without password
:param prefix: command prefix in {prefix}-{architecture}-build
:param architecture: repository architecture
"""
command = Setup.build_command(prefix, architecture)
Setup.SUDOERS_PATH.write_text(f"ahriman ALL=(ALL) NOPASSWD: {command} *\n")
Setup.SUDOERS_PATH.chmod(0o400) # security!
@staticmethod
def create_executable(prefix: str, architecture: str) -> None:
"""
create executable for the service
:param prefix: command prefix in {prefix}-{architecture}-build
:param architecture: repository architecture
"""
command = Setup.build_command(prefix, architecture)
command.unlink(missing_ok=True)
command.symlink_to(Setup.ARCHBUILD_COMMAND_PATH)

View File

@ -0,0 +1,42 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from typing import Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Sign(Handler):
"""
(re-)sign handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
Application(architecture, configuration).sign(args.package)

View File

@ -34,14 +34,14 @@ class Status(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
application = Application(architecture, config) application = Application(architecture, configuration)
if args.ahriman: if args.ahriman:
ahriman = application.repository.reporter.get_self() ahriman = application.repository.reporter.get_self()
print(ahriman.pretty_print()) print(ahriman.pretty_print())

View File

@ -0,0 +1,50 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from typing import Callable, Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class StatusUpdate(Handler):
"""
status update handler
"""
@classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
callback for command line
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
client = Application(architecture, configuration).repository.reporter
callback: Callable[[str], None] = lambda p: client.remove(p) if args.remove else client.update(p, args.status)
if args.package:
# update packages statuses
for package in args.package:
callback(package)
else:
# update service status
client.update_self(args.status)

View File

@ -32,11 +32,11 @@ class Sync(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Application(architecture, config).sync(args.target) Application(architecture, configuration).sync(args.target, [])

View File

@ -19,7 +19,7 @@
# #
import argparse import argparse
from typing import Type from typing import Callable, Type
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler from ahriman.application.handlers.handler import Handler
@ -32,20 +32,29 @@ class Update(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
# typing workaround application = Application(architecture, configuration)
def log_fn(line: str) -> None: packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs,
return print(line) if args.dry_run else application.logger.info(line) Update.log_fn(application, args.dry_run))
application = Application(architecture, config)
packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, log_fn)
if args.dry_run: if args.dry_run:
return return
application.update(packages) application.update(packages)
@staticmethod
def log_fn(application: Application, dry_run: bool) -> Callable[[str], None]:
"""
package updates log function
:param application: application instance
:param dry_run: do not perform update itself
:return: in case if dry_run is set it will return print, logger otherwise
"""
def inner(line: str) -> None:
return print(line) if dry_run else application.logger.info(line)
return inner

View File

@ -31,13 +31,13 @@ class Web(Handler):
""" """
@classmethod @classmethod
def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, config: Configuration) -> None: def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
callback for command line callback for command line
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
from ahriman.web.web import run_server, setup_service from ahriman.web.web import run_server, setup_service
application = setup_service(architecture, config) application = setup_service(architecture, configuration)
run_server(application) run_server(application)

View File

@ -20,12 +20,14 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging
import os import os
from pathlib import Path from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Literal, Optional, Type from typing import Literal, Optional, Type
from ahriman import version
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateRun, UnsafeRun from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
@ -42,31 +44,32 @@ class Lock:
:ivar unsafe: skip user check :ivar unsafe: skip user check
""" """
def __init__(self, args: argparse.Namespace, architecture: str, config: Configuration) -> None: def __init__(self, args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.path = Path(f"{args.lock}_{architecture}") if args.lock is not None else None self.path = Path(f"{args.lock}_{architecture}") if args.lock is not None else None
self.force = args.force self.force = args.force
self.unsafe = args.unsafe self.unsafe = args.unsafe
self.root = Path(config.get("repository", "root")) self.root = Path(configuration.get("repository", "root"))
self.reporter = Client() if args.no_report else Client.load(architecture, config) self.reporter = Client() if args.no_report else Client.load(configuration)
def __enter__(self) -> Lock: def __enter__(self) -> Lock:
""" """
default workflow is the following: default workflow is the following:
check user UID check user UID
remove lock file if force flag is set
check if there is lock file check if there is lock file
check web status watcher status
create lock file create lock file
report to web if enabled report to web if enabled
""" """
self.check_user() self.check_user()
self.check_version()
self.create() self.create()
self.reporter.update_self(BuildStatusEnum.Building) self.reporter.update_self(BuildStatusEnum.Building)
return self return self
@ -80,11 +83,22 @@ class Lock:
:param exc_tb: exception traceback if any :param exc_tb: exception traceback if any
:return: always False (do not suppress any exception) :return: always False (do not suppress any exception)
""" """
self.remove() self.clear()
status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed
self.reporter.update_self(status) self.reporter.update_self(status)
return False return False
def check_version(self) -> None:
"""
check web server version
"""
status = self.reporter.get_internal()
if status.version is not None and status.version != version.__version__:
logging.getLogger("root").warning(
"status watcher version mismatch, our %s, their %s",
version.__version__,
status.version)
def check_user(self) -> None: def check_user(self) -> None:
""" """
check if current user is actually owner of ahriman root check if current user is actually owner of ahriman root
@ -96,6 +110,14 @@ class Lock:
if current_uid != root_uid: if current_uid != root_uid:
raise UnsafeRun(current_uid, root_uid) raise UnsafeRun(current_uid, root_uid)
def clear(self) -> None:
"""
remove lock file
"""
if self.path is None:
return
self.path.unlink(missing_ok=True)
def create(self) -> None: def create(self) -> None:
""" """
create lock file create lock file
@ -106,11 +128,3 @@ class Lock:
self.path.touch(exist_ok=self.force) self.path.touch(exist_ok=self.force)
except FileExistsError: except FileExistsError:
raise DuplicateRun() raise DuplicateRun()
def remove(self) -> None:
"""
remove lock file
"""
if self.path is None:
return
self.path.unlink(missing_ok=True)

View File

@ -18,7 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from pyalpm import Handle # type: ignore from pyalpm import Handle # type: ignore
from typing import List, Set from typing import Set
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -29,24 +29,26 @@ class Pacman:
:ivar handle: pyalpm root `Handle` :ivar handle: pyalpm root `Handle`
""" """
def __init__(self, config: Configuration) -> None: def __init__(self, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param config: configuration instance :param configuration: configuration instance
""" """
root = config.get("alpm", "root") root = configuration.get("alpm", "root")
pacman_root = config.getpath("alpm", "database") pacman_root = configuration.getpath("alpm", "database")
self.handle = Handle(root, str(pacman_root)) self.handle = Handle(root, str(pacman_root))
for repository in config.getlist("alpm", "repositories"): for repository in configuration.getlist("alpm", "repositories"):
self.handle.register_syncdb(repository, 0) # 0 is pgp_level self.handle.register_syncdb(repository, 0) # 0 is pgp_level
def all_packages(self) -> List[str]: def all_packages(self) -> Set[str]:
""" """
get list of packages known for alpm get list of packages known for alpm
:return: list of package names :return: list of package names
""" """
result: Set[str] = set() result: Set[str] = set()
for database in self.handle.get_syncdbs(): for database in self.handle.get_syncdbs():
result.update({package.name for package in database.pkgcache}) for package in database.pkgcache:
result.add(package.name) # package itself
result.update(package.provides) # provides list for meta-packages
return list(result) return result

View File

@ -68,6 +68,16 @@ class Repo:
cwd=self.paths.repository, cwd=self.paths.repository,
logger=self.logger) logger=self.logger)
def init(self) -> None:
"""
create empty repository database
"""
Repo._check_output(
"repo-add", *self.sign_args, str(self.repo_path),
exception=None,
cwd=self.paths.repository,
logger=self.logger)
def remove(self, package: str, filename: Path) -> None: def remove(self, package: str, filename: Path) -> None:
""" """
remove package from repository remove package from repository

View File

@ -41,12 +41,11 @@ class Task:
_check_output = check_output _check_output = check_output
def __init__(self, package: Package, architecture: str, config: Configuration, paths: RepositoryPaths) -> None: def __init__(self, package: Package, configuration: Configuration, paths: RepositoryPaths) -> None:
""" """
default constructor default constructor
:param package: package definitions :param package: package definitions
:param architecture: repository architecture :param configuration: configuration instance
:param config: configuration instance
:param paths: repository paths instance :param paths: repository paths instance
""" """
self.logger = logging.getLogger("builder") self.logger = logging.getLogger("builder")
@ -54,11 +53,10 @@ class Task:
self.package = package self.package = package
self.paths = paths self.paths = paths
section = config.get_section_name("build", architecture) self.archbuild_flags = configuration.getlist("build", "archbuild_flags")
self.archbuild_flags = config.getlist(section, "archbuild_flags") self.build_command = configuration.get("build", "build_command")
self.build_command = config.get(section, "build_command") self.makepkg_flags = configuration.getlist("build", "makepkg_flags")
self.makepkg_flags = config.getlist(section, "makepkg_flags") self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags")
self.makechrootpkg_flags = config.getlist(section, "makechrootpkg_flags")
@property @property
def cache_path(self) -> Path: def cache_path(self) -> Path:
@ -97,14 +95,14 @@ class Task:
run package build run package build
:return: paths of produced packages :return: paths of produced packages
""" """
cmd = [self.build_command, "-r", str(self.paths.chroot)] command = [self.build_command, "-r", str(self.paths.chroot)]
cmd.extend(self.archbuild_flags) command.extend(self.archbuild_flags)
cmd.extend(["--"] + self.makechrootpkg_flags) command.extend(["--"] + self.makechrootpkg_flags)
cmd.extend(["--"] + self.makepkg_flags) command.extend(["--"] + self.makepkg_flags)
self.logger.info(f"using {cmd} for {self.package.base}") self.logger.info("using %s for %s", command, self.package.base)
Task._check_output( Task._check_output(
*cmd, *command,
exception=BuildFailed(self.package.base), exception=BuildFailed(self.package.base),
cwd=self.git_path, cwd=self.git_path,
logger=self.build_logger) logger=self.build_logger)
@ -125,4 +123,4 @@ class Task:
if self.cache_path.is_dir(): if self.cache_path.is_dir():
# no need to clone whole repository, just copy from cache first # no need to clone whole repository, just copy from cache first
shutil.copytree(self.cache_path, git_path) shutil.copytree(self.cache_path, git_path)
return Task.fetch(git_path, self.package.git_url) return self.fetch(git_path, self.package.git_url)

View File

@ -34,13 +34,11 @@ class Configuration(configparser.RawConfigParser):
:cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump) :cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump)
:cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback) :cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback)
:cvar DEFAULT_LOG_LEVEL: default log level (in case of fallback) :cvar DEFAULT_LOG_LEVEL: default log level (in case of fallback)
:cvar STATIC_SECTIONS: known sections which are not architecture specific (required by dump)
""" """
DEFAULT_LOG_FORMAT = "[%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s" DEFAULT_LOG_FORMAT = "[%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s"
DEFAULT_LOG_LEVEL = logging.DEBUG DEFAULT_LOG_LEVEL = logging.DEBUG
STATIC_SECTIONS = ["alpm", "report", "repository", "settings", "upload"]
ARCHITECTURE_SPECIFIC_SECTIONS = ["build", "html", "rsync", "s3", "sign", "web"] ARCHITECTURE_SPECIFIC_SECTIONS = ["build", "html", "rsync", "s3", "sign", "web"]
def __init__(self) -> None: def __init__(self) -> None:
@ -57,37 +55,47 @@ class Configuration(configparser.RawConfigParser):
""" """
return self.getpath("settings", "include") return self.getpath("settings", "include")
@property
def logging_path(self) -> Path:
"""
:return: path to logging configuration
"""
return self.getpath("settings", "logging")
@classmethod @classmethod
def from_path(cls: Type[Configuration], path: Path, logfile: bool) -> Configuration: def from_path(cls: Type[Configuration], path: Path, architecture: str, logfile: bool) -> Configuration:
""" """
constructor with full object initialization constructor with full object initialization
:param path: path to root configuration file :param path: path to root configuration file
:param architecture: repository architecture
:param logfile: use log file to output messages :param logfile: use log file to output messages
:return: configuration instance :return: configuration instance
""" """
config = cls() config = cls()
config.load(path) config.load(path)
config.merge_sections(architecture)
config.load_logging(logfile) config.load_logging(logfile)
return config return config
def dump(self, architecture: str) -> Dict[str, Dict[str, str]]: @staticmethod
def section_name(section: str, architecture: str) -> str:
"""
generate section name for architecture specific sections
:param section: section name
:param architecture: repository architecture
:return: correct section name for repository specific section
"""
return f"{section}:{architecture}"
def dump(self) -> Dict[str, Dict[str, str]]:
""" """
dump configuration to dictionary dump configuration to dictionary
:param architecture: repository architecture
:return: configuration dump for specific architecture :return: configuration dump for specific architecture
""" """
result: Dict[str, Dict[str, str]] = {} return {
for section in Configuration.STATIC_SECTIONS: section: dict(self[section])
if not self.has_section(section): for section in self.sections()
continue }
result[section] = dict(self[section])
for group in Configuration.ARCHITECTURE_SPECIFIC_SECTIONS:
section = self.get_section_name(group, architecture)
if not self.has_section(section):
continue
result[section] = dict(self[section])
return result
def getlist(self, section: str, key: str) -> List[str]: def getlist(self, section: str, key: str) -> List[str]:
""" """
@ -113,16 +121,6 @@ class Configuration(configparser.RawConfigParser):
return value return value
return self.path.parent / value return self.path.parent / value
def get_section_name(self, prefix: str, suffix: str) -> str:
"""
check if there is `prefix`_`suffix` section and return it on success. Return `prefix` otherwise
:param prefix: section name prefix
:param suffix: section name suffix (e.g. architecture name)
:return: found section name
"""
probe = f"{prefix}_{suffix}"
return probe if self.has_section(probe) else prefix
def load(self, path: Path) -> None: def load(self, path: Path) -> None:
""" """
fully load configuration fully load configuration
@ -138,8 +136,10 @@ class Configuration(configparser.RawConfigParser):
""" """
try: try:
for path in sorted(self.include.glob("*.ini")): for path in sorted(self.include.glob("*.ini")):
if path == self.logging_path:
continue # we don't want to load logging explicitly
self.read(path) self.read(path)
except (FileNotFoundError, configparser.NoOptionError): except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError):
pass pass
def load_logging(self, logfile: bool) -> None: def load_logging(self, logfile: bool) -> None:
@ -149,17 +149,39 @@ class Configuration(configparser.RawConfigParser):
""" """
def file_logger() -> None: def file_logger() -> None:
try: try:
config_path = self.getpath("settings", "logging") path = self.logging_path
fileConfig(config_path) fileConfig(path)
except (FileNotFoundError, PermissionError): except (FileNotFoundError, PermissionError):
console_logger() console_logger()
logging.exception("could not create logfile, fallback to stderr") logging.exception("could not create logfile, fallback to stderr")
def console_logger() -> None: def console_logger() -> None:
logging.basicConfig(filename=None, format=Configuration.DEFAULT_LOG_FORMAT, logging.basicConfig(filename=None, format=self.DEFAULT_LOG_FORMAT,
level=Configuration.DEFAULT_LOG_LEVEL) level=self.DEFAULT_LOG_LEVEL)
if logfile: if logfile:
file_logger() file_logger()
else: else:
console_logger() console_logger()
def merge_sections(self, architecture: str) -> None:
"""
merge architecture specific sections into main configuration
:param architecture: repository architecture
"""
for section in self.ARCHITECTURE_SPECIFIC_SECTIONS:
if not self.has_section(section):
self.add_section(section) # add section if not exists
# 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
for key, value in self[specific].items():
self.set(section, key, value)
# remove any arch specific section
for foreign in self.sections():
# we would like to use lambda filter here, but pylint is too dumb
if not foreign.startswith(f"{section}:"):
continue
self.remove_section(foreign)

View File

@ -83,6 +83,19 @@ class InvalidPackageInfo(Exception):
Exception.__init__(self, f"There are errors during reading package information: `{details}`") Exception.__init__(self, f"There are errors during reading package information: `{details}`")
class MissingArchitecture(Exception):
"""
exception which will be raised if architecture is required, but missing
"""
def __init__(self, command: str) -> None:
"""
default constructor
:param command: command name which throws exception
"""
Exception.__init__(self, f"Architecture required for subcommand {command}, but missing")
class ReportFailed(Exception): class ReportFailed(Exception):
""" """
report generation exception report generation exception

View File

@ -0,0 +1,105 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import datetime
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Dict, Iterable
from ahriman.core.configuration import Configuration
from ahriman.core.report.jinja_template import JinjaTemplate
from ahriman.core.report.report import Report
from ahriman.core.util import pretty_datetime
from ahriman.models.package import Package
from ahriman.models.smtp_ssl_settings import SmtpSSLSettings
class Email(Report, JinjaTemplate):
"""
email report generator
:ivar host: SMTP host to connect
:ivar no_empty_report: skip empty report generation
:ivar password: password to authenticate via SMTP
:ivar port: SMTP port to connect
:ivar receivers: list of receivers emails
:ivar sender: sender email address
:ivar ssl: SSL mode for SMTP connection
:ivar user: username to authenticate via SMTP
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
Report.__init__(self, architecture, configuration)
JinjaTemplate.__init__(self, "email", configuration)
# base smtp settings
self.host = configuration.get("email", "host")
self.no_empty_report = configuration.getboolean("email", "no_empty_report", fallback=True)
self.password = configuration.get("email", "password", fallback=None)
self.port = configuration.getint("email", "port")
self.receivers = configuration.getlist("email", "receivers")
self.sender = configuration.get("email", "sender")
self.ssl = SmtpSSLSettings.from_option(configuration.get("email", "ssl", fallback="disabled"))
self.user = configuration.get("email", "user", fallback=None)
def _send(self, text: str, attachment: Dict[str, str]) -> None:
"""
send email callback
:param text: email body text
:param attachment: map of attachment filename to attachment content
"""
message = MIMEMultipart()
message["From"] = self.sender
message["To"] = ", ".join(self.receivers)
message["Subject"] = f"{self.name} build report at {pretty_datetime(datetime.datetime.utcnow().timestamp())}"
message.attach(MIMEText(text, "html"))
for filename, content in attachment.items():
attach = MIMEText(content, "html")
attach.add_header("Content-Disposition", "attachment", filename=filename)
message.attach(attach)
if self.ssl != SmtpSSLSettings.SSL:
session = smtplib.SMTP(self.host, self.port)
if self.ssl == SmtpSSLSettings.STARTTLS:
session.starttls()
else:
session = smtplib.SMTP_SSL(self.host, self.port)
if self.user is not None and self.password is not None:
session.login(self.user, self.password)
session.sendmail(self.sender, self.receivers, message.as_string())
session.quit()
def generate(self, packages: Iterable[Package], built_packages: Iterable[Package]) -> None:
"""
generate report for the specified packages
:param packages: list of packages to generate report
:param built_packages: list of packages which has just been built
"""
if self.no_empty_report and not built_packages:
return
text = self.make_html(built_packages, False)
attachments = {"index.html": self.make_html(packages, True)}
self._send(text, attachments)

View File

@ -17,89 +17,36 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import jinja2 from typing import Iterable
from typing import Callable, Dict, Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.report.jinja_template import JinjaTemplate
from ahriman.core.report.report import Report from ahriman.core.report.report import Report
from ahriman.core.util import pretty_size, pretty_datetime
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.sign_settings import SignSettings
class HTML(Report): class HTML(Report, JinjaTemplate):
""" """
html report generator html report generator
It uses jinja2 templates for report generation, the following variables are allowed:
homepage - link to homepage, string, optional
link_path - prefix fo packages to download, string, required
has_package_signed - True in case if package sign enabled, False otherwise, required
has_repo_signed - True in case if repository database sign enabled, False otherwise, required
packages - sorted list of packages properties: archive_size, build_date, filename, installed_size, name, version. Required
pgp_key - default PGP key ID, string, optional
repository - repository name, string, required
:ivar homepage: homepage link if any (for footer)
:ivar link_path: prefix fo packages to download
:ivar name: repository name
:ivar pgp_key: default PGP key
:ivar report_path: output path to html report :ivar report_path: output path to html report
:ivar sign_targets: targets to sign enabled in configuration
:ivar tempate_path: path to directory with jinja templates
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Report.__init__(self, architecture, config) Report.__init__(self, architecture, configuration)
section = config.get_section_name("html", architecture) JinjaTemplate.__init__(self, "html", configuration)
self.report_path = config.getpath(section, "path")
self.link_path = config.get(section, "link_path")
self.template_path = config.getpath(section, "template_path")
# base template vars self.report_path = configuration.getpath("html", "path")
self.homepage = config.get(section, "homepage", fallback=None)
self.name = config.get("repository", "name")
sign_section = config.get_section_name("sign", architecture) def generate(self, packages: Iterable[Package], built_packages: Iterable[Package]) -> None:
self.sign_targets = [SignSettings.from_option(opt) for opt in config.getlist(sign_section, "target")]
self.pgp_key = config.get(sign_section, "key") if self.sign_targets else None
def generate(self, packages: Iterable[Package]) -> None:
""" """
generate report for the specified packages generate report for the specified packages
:param packages: list of packages to generate report :param packages: list of packages to generate report
:param built_packages: list of packages which has just been built
""" """
# idea comes from https://stackoverflow.com/a/38642558 html = self.make_html(packages, True)
loader = jinja2.FileSystemLoader(searchpath=self.template_path.parent)
environment = jinja2.Environment(loader=loader)
template = environment.get_template(self.template_path.name)
content = [
{
"archive_size": pretty_size(properties.archive_size),
"build_date": pretty_datetime(properties.build_date),
"filename": properties.filename,
"installed_size": pretty_size(properties.installed_size),
"name": package,
"version": base.version
} for base in packages for package, properties in base.packages.items()
]
comparator: Callable[[Dict[str, str]], str] = lambda item: item["filename"]
html = template.render(
homepage=self.homepage,
link_path=self.link_path,
has_package_signed=SignSettings.SignPackages in self.sign_targets,
has_repo_signed=SignSettings.SignRepository in self.sign_targets,
packages=sorted(content, key=comparator),
pgp_key=self.pgp_key,
repository=self.name)
self.report_path.write_text(html) self.report_path.write_text(html)

View File

@ -0,0 +1,117 @@
#
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import jinja2
from typing import Callable, Dict, Iterable
from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG
from ahriman.core.util import pretty_datetime, pretty_size
from ahriman.models.package import Package
from ahriman.models.sign_settings import SignSettings
class JinjaTemplate:
"""
jinja based report generator
It uses jinja2 templates for report generation, the following variables are allowed:
homepage - link to homepage, string, optional
link_path - prefix fo packages to download, string, required
has_package_signed - True in case if package sign enabled, False otherwise, required
has_repo_signed - True in case if repository database sign enabled, False otherwise, required
packages - sorted list of packages properties, required
* architecture, string
* archive_size, pretty printed size, string
* build_date, pretty printed datetime, string
* depends, sorted list of strings
* description, string
* filename, string,
* groups, sorted list of strings
* installed_size, pretty printed datetime, string
* licenses, sorted list of strings
* name, string
* url, string
* version, string
pgp_key - default PGP key ID, string, optional
repository - repository name, string, required
:ivar homepage: homepage link if any (for footer)
:ivar link_path: prefix fo packages to download
:ivar name: repository name
:ivar default_pgp_key: default PGP key
:ivar sign_targets: targets to sign enabled in configuration
:ivar template_path: path to directory with jinja templates
"""
def __init__(self, section: str, configuration: Configuration) -> None:
"""
default constructor
:param section: settings section name
:param configuration: configuration instance
"""
self.link_path = configuration.get(section, "link_path")
self.template_path = configuration.getpath(section, "template_path")
# base template vars
self.homepage = configuration.get(section, "homepage", fallback=None)
self.name = configuration.get("repository", "name")
self.sign_targets, self.default_pgp_key = GPG.sign_options(configuration)
def make_html(self, packages: Iterable[Package], extended_report: bool) -> str:
"""
generate report for the specified packages
:param packages: list of packages to generate report
:param extended_report: include additional blocks to the report
"""
# idea comes from https://stackoverflow.com/a/38642558
loader = jinja2.FileSystemLoader(searchpath=self.template_path.parent)
environment = jinja2.Environment(loader=loader, autoescape=True)
template = environment.get_template(self.template_path.name)
content = [
{
"architecture": properties.architecture or "",
"archive_size": pretty_size(properties.archive_size),
"build_date": pretty_datetime(properties.build_date),
"depends": properties.depends,
"description": properties.description or "",
"filename": properties.filename,
"groups": properties.groups,
"installed_size": pretty_size(properties.installed_size),
"licenses": properties.licenses,
"name": package,
"url": properties.url or "",
"version": base.version
} for base in packages for package, properties in base.packages.items()
]
comparator: Callable[[Dict[str, str]], str] = lambda item: item["filename"]
return template.render(
extended_report=extended_report,
homepage=self.homepage,
link_path=self.link_path,
has_package_signed=SignSettings.Packages in self.sign_targets,
has_repo_signed=SignSettings.Repository in self.sign_targets,
packages=sorted(content, key=comparator),
pgp_key=self.default_pgp_key,
repository=self.name)

View File

@ -17,9 +17,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import logging import logging
from typing import Iterable from typing import Iterable, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import ReportFailed from ahriman.core.exceptions import ReportFailed
@ -31,44 +33,53 @@ class Report:
""" """
base report generator base report generator
:ivar architecture: repository architecture :ivar architecture: repository architecture
:ivar config: configuration instance :ivar configuration: configuration instance
:ivar logger: class logger :ivar logger: class logger
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.logger = logging.getLogger("builder") self.logger = logging.getLogger("builder")
self.architecture = architecture self.architecture = architecture
self.config = config self.configuration = configuration
@staticmethod @classmethod
def run(architecture: str, config: Configuration, target: str, packages: Iterable[Package]) -> None: def load(cls: Type[Report], architecture: str, configuration: Configuration, target: str) -> Report:
""" """
run report generation load client from settings
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
:param target: target to generate report (e.g. html) :param target: target to generate report (e.g. html)
:param packages: list of packages to generate report :return: client according to current settings
""" """
provider = ReportSettings.from_option(target) provider = ReportSettings.from_option(target)
if provider == ReportSettings.HTML: if provider == ReportSettings.HTML:
from ahriman.core.report.html import HTML from ahriman.core.report.html import HTML
report: Report = HTML(architecture, config) return HTML(architecture, configuration)
else: if provider == ReportSettings.Email:
report = Report(architecture, config) from ahriman.core.report.email import Email
return Email(architecture, configuration)
return cls(architecture, configuration) # should never happen
try: def generate(self, packages: Iterable[Package], built_packages: Iterable[Package]) -> None:
report.generate(packages)
except Exception:
report.logger.exception(f"report generation failed for target {provider.name}")
raise ReportFailed()
def generate(self, packages: Iterable[Package]) -> None:
""" """
generate report for the specified packages generate report for the specified packages
:param packages: list of packages to generate report :param packages: list of packages to generate report
:param built_packages: list of packages which has just been built
""" """
def run(self, packages: Iterable[Package], built_packages: Iterable[Package]) -> None:
"""
run report generation
:param packages: list of packages to generate report
:param built_packages: list of packages which has just been built
"""
try:
self.generate(packages, built_packages)
except Exception:
self.logger.exception("report generation failed")
raise ReportFailed()

View File

@ -25,7 +25,7 @@ from typing import Dict, Iterable, List, Optional
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.report.report import Report from ahriman.core.report.report import Report
from ahriman.core.repository.cleaner import Cleaner from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.upload.uploader import Uploader from ahriman.core.upload.upload import Upload
from ahriman.models.package import Package from ahriman.models.package import Package
@ -49,7 +49,7 @@ class Executor(Cleaner):
""" """
def build_single(package: Package) -> None: def build_single(package: Package) -> None:
self.reporter.set_building(package.base) self.reporter.set_building(package.base)
task = Task(package, self.architecture, self.config, self.paths) task = Task(package, self.configuration, self.paths)
task.init() task.init()
built = task.build() built = task.build()
for src in built: for src in built:
@ -61,7 +61,7 @@ class Executor(Cleaner):
build_single(single) build_single(single)
except Exception: except Exception:
self.reporter.set_failed(single.base) self.reporter.set_failed(single.base)
self.logger.exception(f"{single.base} ({self.architecture}) build exception") self.logger.exception("%s (%s) build exception", single.base, self.architecture)
self.clear_build() self.clear_build()
return self.packages_built() return self.packages_built()
@ -76,7 +76,7 @@ class Executor(Cleaner):
try: try:
self.repo.remove(package, fn) self.repo.remove(package, fn)
except Exception: except Exception:
self.logger.exception(f"could not remove {package}") self.logger.exception("could not remove %s", package)
requested = set(packages) requested = set(packages)
for local in self.packages(): for local in self.packages():
@ -100,25 +100,29 @@ class Executor(Cleaner):
return self.repo.repo_path return self.repo.repo_path
def process_report(self, targets: Optional[Iterable[str]]) -> None: def process_report(self, targets: Optional[Iterable[str]], built_packages: Iterable[Package]) -> None:
""" """
generate reports generate reports
:param targets: list of targets to generate reports. Configuration option will be used if it is not set :param targets: list of targets to generate reports. Configuration option will be used if it is not set
:param built_packages: list of packages which has just been built
""" """
if targets is None: if targets is None:
targets = self.config.getlist("report", "target") targets = self.configuration.getlist("report", "target")
for target in targets: for target in targets:
Report.run(self.architecture, self.config, target, self.packages()) runner = Report.load(self.architecture, self.configuration, target)
runner.run(self.packages(), built_packages)
def process_sync(self, targets: Optional[Iterable[str]]) -> None: def process_sync(self, targets: Optional[Iterable[str]], built_packages: Iterable[Package]) -> None:
""" """
process synchronization to remote servers process synchronization to remote servers
:param targets: list of targets to sync. Configuration option will be used if it is not set :param targets: list of targets to sync. Configuration option will be used if it is not set
:param built_packages: list of packages which has just been built
""" """
if targets is None: if targets is None:
targets = self.config.getlist("upload", "target") targets = self.configuration.getlist("upload", "target")
for target in targets: for target in targets:
Uploader.run(self.architecture, self.config, target, self.paths.repository) runner = Upload.load(self.architecture, self.configuration, target)
runner.run(self.paths.repository, built_packages)
def process_update(self, packages: Iterable[Path]) -> Path: def process_update(self, packages: Iterable[Path]) -> Path:
""" """
@ -128,7 +132,7 @@ class Executor(Cleaner):
""" """
def update_single(fn: Optional[str], base: str) -> None: def update_single(fn: Optional[str], base: str) -> None:
if fn is None: if fn is None:
self.logger.warning(f"received empty package name for base {base}") self.logger.warning("received empty package name for base %s", base)
return # suppress type checking, it never can be none actually return # suppress type checking, it never can be none actually
# in theory it might be NOT packages directory, but we suppose it is # in theory it might be NOT packages directory, but we suppose it is
full_path = self.paths.packages / fn full_path = self.paths.packages / fn
@ -146,7 +150,7 @@ class Executor(Cleaner):
local = Package.load(filename, self.pacman, self.aur_url) local = Package.load(filename, self.pacman, self.aur_url)
updates.setdefault(local.base, local).packages.update(local.packages) updates.setdefault(local.base, local).packages.update(local.packages)
except Exception: except Exception:
self.logger.exception(f"could not load package from {filename}") self.logger.exception("could not load package from %s", filename)
for local in updates.values(): for local in updates.values():
try: try:
@ -155,7 +159,7 @@ class Executor(Cleaner):
self.reporter.set_success(local) self.reporter.set_success(local)
except Exception: except Exception:
self.reporter.set_failed(local.base) self.reporter.set_failed(local.base)
self.logger.exception(f"could not process {local.base}") self.logger.exception("could not process %s", local.base)
self.clear_packages() self.clear_packages()
return self.repo.repo_path return self.repo.repo_path

View File

@ -32,7 +32,8 @@ class Properties:
repository internal objects holder repository internal objects holder
:ivar architecture: repository architecture :ivar architecture: repository architecture
:ivar aur_url: base AUR url :ivar aur_url: base AUR url
:ivar config: configuration instance :ivar configuration: configuration instance
:ivar ignore_list: package bases which will be ignored during auto updates
:ivar logger: class logger :ivar logger: class logger
:ivar name: repository name :ivar name: repository name
:ivar pacman: alpm wrapper instance :ivar pacman: alpm wrapper instance
@ -42,18 +43,19 @@ class Properties:
:ivar sign: GPG wrapper instance :ivar sign: GPG wrapper instance
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
self.logger = logging.getLogger("builder") self.logger = logging.getLogger("builder")
self.architecture = architecture self.architecture = architecture
self.config = config self.configuration = configuration
self.aur_url = config.get("alpm", "aur_url") self.aur_url = configuration.get("alpm", "aur_url")
self.name = config.get("repository", "name") self.name = configuration.get("repository", "name")
self.paths = RepositoryPaths(config.getpath("repository", "root"), architecture) self.paths = RepositoryPaths(configuration.getpath("repository", "root"), architecture)
self.paths.create_tree() self.paths.create_tree()
self.pacman = Pacman(config) self.ignore_list = configuration.getlist("build", "ignore_packages")
self.sign = GPG(architecture, config) self.pacman = Pacman(configuration)
self.sign = GPG(architecture, configuration)
self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args)
self.reporter = Client.load(architecture, config) self.reporter = Client.load(configuration)

View File

@ -37,14 +37,12 @@ class Repository(Executor, UpdateHandler):
:return: list of packages properties :return: list of packages properties
""" """
result: Dict[str, Package] = {} result: Dict[str, Package] = {}
for full_path in self.paths.repository.iterdir(): for full_path in filter(package_like, self.paths.repository.iterdir()):
if not package_like(full_path):
continue
try: try:
local = Package.load(full_path, self.pacman, self.aur_url) local = Package.load(full_path, self.pacman, self.aur_url)
result.setdefault(local.base, local).packages.update(local.packages) result.setdefault(local.base, local).packages.update(local.packages)
except Exception: except Exception:
self.logger.exception(f"could not load package from {full_path}") self.logger.exception("could not load package from %s", full_path)
continue continue
return list(result.values()) return list(result.values())
@ -53,4 +51,4 @@ class Repository(Executor, UpdateHandler):
get list of files in built packages directory get list of files in built packages directory
:return: list of filenames from the directory :return: list of filenames from the directory
""" """
return list(self.paths.packages.iterdir()) return list(filter(package_like, self.paths.packages.iterdir()))

View File

@ -44,11 +44,8 @@ class UpdateHandler(Cleaner):
""" """
result: List[Package] = [] result: List[Package] = []
build_section = self.config.get_section_name("build", self.architecture)
ignore_list = self.config.getlist(build_section, "ignore_packages")
for local in self.packages(): for local in self.packages():
if local.base in ignore_list: if local.base in self.ignore_list:
continue continue
if local.is_vcs and no_vcs: if local.is_vcs and no_vcs:
continue continue
@ -62,7 +59,7 @@ class UpdateHandler(Cleaner):
result.append(remote) result.append(remote)
except Exception: except Exception:
self.reporter.set_failed(local.base) self.reporter.set_failed(local.base)
self.logger.exception(f"could not load remote package {local.base}") self.logger.exception("could not load remote package %s", local.base)
continue continue
return result return result
@ -84,7 +81,7 @@ class UpdateHandler(Cleaner):
else: else:
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
except Exception: except Exception:
self.logger.exception(f"could not add package from {fn}") self.logger.exception("could not add package from %s", fn)
self.clear_manual() self.clear_manual()
return result return result

View File

@ -18,13 +18,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import logging import logging
import requests
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional, Set, Tuple
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import BuildFailed from ahriman.core.exceptions import BuildFailed
from ahriman.core.util import check_output from ahriman.core.util import check_output, exception_response_text
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -32,37 +33,39 @@ class GPG:
""" """
gnupg wrapper gnupg wrapper
:ivar architecture: repository architecture :ivar architecture: repository architecture
:ivar config: configuration instance :ivar configuration: configuration instance
:ivar default_key: default PGP key ID to use :ivar default_key: default PGP key ID to use
:ivar logger: class logger :ivar logger: class logger
:ivar target: list of targets to sign (repository, package etc) :ivar targets: list of targets to sign (repository, package etc)
""" """
_check_output = check_output _check_output = check_output
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.logger = logging.getLogger("build_details") self.logger = logging.getLogger("build_details")
self.config = config self.architecture = architecture
self.section = config.get_section_name("sign", architecture) self.configuration = configuration
self.target = {SignSettings.from_option(opt) for opt in config.getlist(self.section, "target")} self.targets, self.default_key = self.sign_options(configuration)
self.default_key = config.get(self.section, "key") if self.target else ""
@property @property
def repository_sign_args(self) -> List[str]: def repository_sign_args(self) -> List[str]:
""" """
:return: command line arguments for repo-add command to sign database :return: command line arguments for repo-add command to sign database
""" """
if SignSettings.SignRepository not in self.target: if SignSettings.Repository not in self.targets:
return []
if self.default_key is None:
self.logger.error("no default key set, skip repository sign")
return [] return []
return ["--sign", "--key", self.default_key] return ["--sign", "--key", self.default_key]
@staticmethod @staticmethod
def sign_cmd(path: Path, key: str) -> List[str]: def sign_command(path: Path, key: str) -> List[str]:
""" """
gpg command to run gpg command to run
:param path: path to file to sign :param path: path to file to sign
@ -71,6 +74,50 @@ class GPG:
""" """
return ["gpg", "-u", key, "-b", str(path)] return ["gpg", "-u", key, "-b", str(path)]
@staticmethod
def sign_options(configuration: Configuration) -> Tuple[Set[SignSettings], Optional[str]]:
"""
extract default sign options from configuration
:param configuration: configuration instance
:return: tuple of sign targets and default PGP key
"""
targets = {
SignSettings.from_option(option)
for option in configuration.getlist("sign", "target")
}
default_key = configuration.get("sign", "key") if targets else None
return targets, default_key
def download_key(self, server: str, key: str) -> str:
"""
download key from public PGP server
:param server: public PGP server which will be used to download the key
:param key: key ID to download
:return: key as plain text
"""
key = key if key.startswith("0x") else f"0x{key}"
try:
response = requests.get(f"http://{server}/pks/lookup", params={
"op": "get",
"options": "mr",
"search": key
})
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not download key %s from %s: %s", key, server, exception_response_text(e))
raise
return response.text
def import_key(self, server: str, key: str) -> None:
"""
import key to current user and sign it locally
:param server: public PGP server which will be used to download the key
:param key: key ID to import
"""
key_body = self.download_key(server, key)
GPG._check_output("gpg", "--import", input_data=key_body, exception=None, logger=self.logger)
GPG._check_output("gpg", "--quick-lsign-key", key, exception=None, logger=self.logger)
def process(self, path: Path, key: str) -> List[Path]: def process(self, path: Path, key: str) -> List[Path]:
""" """
gpg command wrapper gpg command wrapper
@ -79,7 +126,7 @@ class GPG:
:return: list of generated files including original file :return: list of generated files including original file
""" """
GPG._check_output( GPG._check_output(
*GPG.sign_cmd(path, key), *GPG.sign_command(path, key),
exception=BuildFailed(path.name), exception=BuildFailed(path.name),
logger=self.logger) logger=self.logger)
return [path, path.parent / f"{path.name}.sig"] return [path, path.parent / f"{path.name}.sig"]
@ -91,9 +138,12 @@ class GPG:
:param base: package base required to check for key overrides :param base: package base required to check for key overrides
:return: list of generated files including original file :return: list of generated files including original file
""" """
if SignSettings.SignPackages not in self.target: if SignSettings.Packages not in self.targets:
return [path]
key = self.configuration.get("sign", f"key_{base}", fallback=self.default_key)
if key is None:
self.logger.error("no default key set, skip package %s sign", path)
return [path] return [path]
key = self.config.get(self.section, f"key_{base}", fallback=self.default_key)
return self.process(path, key) return self.process(path, key)
def sign_repository(self, path: Path) -> List[Path]: def sign_repository(self, path: Path) -> List[Path]:
@ -103,6 +153,9 @@ class GPG:
:param path: path to repository database :param path: path to repository database
:return: list of generated files including original file :return: list of generated files including original file
""" """
if SignSettings.SignRepository not in self.target: if SignSettings.Repository not in self.targets:
return [path]
if self.default_key is None:
self.logger.error("no default key set, skip repository sign")
return [path] return [path]
return self.process(path, self.default_key) return self.process(path, self.default_key)

View File

@ -19,10 +19,11 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import List, Optional, Tuple from typing import List, Optional, Tuple, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -31,6 +32,20 @@ class Client:
base build status reporter client base build status reporter client
""" """
@classmethod
def load(cls: Type[Client], configuration: Configuration) -> Client:
"""
load client from settings
:param configuration: configuration instance
:return: client according to current settings
"""
host = configuration.get("web", "host", fallback=None)
port = configuration.getint("web", "port", fallback=None)
if host is not None and port is not None:
from ahriman.core.status.web_client import WebClient
return WebClient(host, port)
return cls()
def add(self, package: Package, status: BuildStatusEnum) -> None: def add(self, package: Package, status: BuildStatusEnum) -> None:
""" """
add new package with status add new package with status
@ -48,6 +63,14 @@ class Client:
del base del base
return [] return []
# pylint: disable=no-self-use
def get_internal(self) -> InternalStatus:
"""
get internal service status
:return: current internal (web) service status
"""
return InternalStatus()
# pylint: disable=no-self-use # pylint: disable=no-self-use
def get_self(self) -> BuildStatus: def get_self(self) -> BuildStatus:
""" """
@ -109,20 +132,3 @@ class Client:
:param package: current package properties :param package: current package properties
""" """
return self.add(package, BuildStatusEnum.Unknown) return self.add(package, BuildStatusEnum.Unknown)
@staticmethod
def load(architecture: str, config: Configuration) -> Client:
"""
load client from settings
:param architecture: repository architecture
:param config: configuration instance
:return: client according to current settings
"""
section = config.get_section_name("web", architecture)
host = config.get(section, "host", fallback=None)
port = config.getint(section, "port", fallback=None)
if host is None or port is None:
return Client()
from ahriman.core.status.web_client import WebClient
return WebClient(host, port)

View File

@ -40,16 +40,16 @@ class Watcher:
:ivar status: daemon status :ivar status: daemon status
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.logger = logging.getLogger("http") self.logger = logging.getLogger("http")
self.architecture = architecture self.architecture = architecture
self.repository = Repository(architecture, config) self.repository = Repository(architecture, configuration)
self.known: Dict[str, Tuple[Package, BuildStatus]] = {} self.known: Dict[str, Tuple[Package, BuildStatus]] = {}
self.status = BuildStatus() self.status = BuildStatus()
@ -90,7 +90,7 @@ class Watcher:
try: try:
parse_single(item) parse_single(item)
except Exception: except Exception:
self.logger.exception(f"cannot parse item f{item} to package") self.logger.exception("cannot parse item %s to package", item)
def _cache_save(self) -> None: def _cache_save(self) -> None:
""" """

View File

@ -23,7 +23,9 @@ import requests
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from ahriman.core.status.client import Client from ahriman.core.status.client import Client
from ahriman.core.util import exception_response_text
from ahriman.models.build_status import BuildStatusEnum, BuildStatus from ahriman.models.build_status import BuildStatusEnum, BuildStatus
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package from ahriman.models.package import Package
@ -60,6 +62,13 @@ class WebClient(Client):
""" """
return f"http://{self.host}:{self.port}/api/v1/packages/{base}" return f"http://{self.host}:{self.port}/api/v1/packages/{base}"
def _status_url(self) -> str:
"""
url generator
:return: full url for web service for status
"""
return f"http://{self.host}:{self.port}/api/v1/status"
def add(self, package: Package, status: BuildStatusEnum) -> None: def add(self, package: Package, status: BuildStatusEnum) -> None:
""" """
add new package with status add new package with status
@ -75,9 +84,9 @@ class WebClient(Client):
response = requests.post(self._package_url(package.base), json=payload) response = requests.post(self._package_url(package.base), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not add {package.base}: {e.response.text}") self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
except Exception: except Exception:
self.logger.exception(f"could not add {package.base}") self.logger.exception("could not add %s", package.base)
def get(self, base: Optional[str]) -> List[Tuple[Package, BuildStatus]]: def get(self, base: Optional[str]) -> List[Tuple[Package, BuildStatus]]:
""" """
@ -95,11 +104,28 @@ class WebClient(Client):
for package in status_json for package in status_json
] ]
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get {base}: {e.response.text}") self.logger.exception("could not get %s: %s", base, exception_response_text(e))
except Exception: except Exception:
self.logger.exception(f"could not get {base}") self.logger.exception("could not get %s", base)
return [] return []
def get_internal(self) -> InternalStatus:
"""
get internal service status
:return: current internal (web) service status
"""
try:
response = requests.get(self._status_url())
response.raise_for_status()
status_json = response.json()
return InternalStatus.from_json(status_json)
except requests.exceptions.HTTPError as e:
self.logger.exception("could not get web service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get web service status")
return InternalStatus()
def get_self(self) -> BuildStatus: def get_self(self) -> BuildStatus:
""" """
get ahriman status itself get ahriman status itself
@ -112,7 +138,7 @@ class WebClient(Client):
status_json = response.json() status_json = response.json()
return BuildStatus.from_json(status_json) return BuildStatus.from_json(status_json)
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not get service status: {e.response.text}") self.logger.exception("could not get service status: %s", exception_response_text(e))
except Exception: except Exception:
self.logger.exception("could not get service status") self.logger.exception("could not get service status")
return BuildStatus() return BuildStatus()
@ -126,9 +152,9 @@ class WebClient(Client):
response = requests.delete(self._package_url(base)) response = requests.delete(self._package_url(base))
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not delete {base}: {e.response.text}") self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
except Exception: except Exception:
self.logger.exception(f"could not delete {base}") self.logger.exception("could not delete %s", base)
def update(self, base: str, status: BuildStatusEnum) -> None: def update(self, base: str, status: BuildStatusEnum) -> None:
""" """
@ -142,9 +168,9 @@ class WebClient(Client):
response = requests.post(self._package_url(base), json=payload) response = requests.post(self._package_url(base), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update {base}: {e.response.text}") self.logger.exception("could not update %s: %s", base, exception_response_text(e))
except Exception: except Exception:
self.logger.exception(f"could not update {base}") self.logger.exception("could not update %s", base)
def update_self(self, status: BuildStatusEnum) -> None: def update_self(self, status: BuildStatusEnum) -> None:
""" """
@ -157,6 +183,6 @@ class WebClient(Client):
response = requests.post(self._ahriman_url(), json=payload) response = requests.post(self._ahriman_url(), json=payload)
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self.logger.exception(f"could not update service status: {e.response.text}") self.logger.exception("could not update service status: %s", exception_response_text(e))
except Exception: except Exception:
self.logger.exception("could not update service status") self.logger.exception("could not update service status")

View File

@ -18,43 +18,37 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from pathlib import Path from pathlib import Path
from typing import Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.upload.uploader import Uploader from ahriman.core.upload.upload import Upload
from ahriman.core.util import check_output from ahriman.core.util import check_output
from ahriman.models.package import Package
class Rsync(Uploader): class Rsync(Upload):
""" """
rsync wrapper rsync wrapper
:ivar command: command arguments for sync
:ivar remote: remote address to sync :ivar remote: remote address to sync
""" """
_check_output = check_output _check_output = check_output
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Uploader.__init__(self, architecture, config) Upload.__init__(self, architecture, configuration)
section = config.get_section_name("rsync", architecture) self.command = configuration.getlist("rsync", "command")
self.remote = config.get(section, "remote") self.remote = configuration.get("rsync", "remote")
def sync(self, path: Path) -> None: def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
""" """
sync data to remote server sync data to remote server
:param path: local path to sync :param path: local path to sync
:param built_packages: list of packages which has just been built
""" """
Rsync._check_output( Rsync._check_output(*self.command, str(path), self.remote, exception=None, logger=self.logger)
"rsync",
"--archive",
"--verbose",
"--compress",
"--partial",
"--delete",
str(path),
self.remote,
exception=None,
logger=self.logger)

View File

@ -17,37 +17,116 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import boto3 # type: ignore
import hashlib
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Generator, Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.upload.uploader import Uploader from ahriman.core.upload.upload import Upload
from ahriman.core.util import check_output from ahriman.models.package import Package
class S3(Uploader): class S3(Upload):
""" """
aws-cli wrapper aws-cli wrapper
:ivar bucket: full bucket name :ivar bucket: boto3 S3 bucket object
:ivar chunk_size: chunk size for calculating checksums
""" """
_check_output = check_output def __init__(self, architecture: str, configuration: Configuration) -> None:
def __init__(self, architecture: str, config: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
Uploader.__init__(self, architecture, config) Upload.__init__(self, architecture, configuration)
section = config.get_section_name("s3", architecture) self.bucket = self.get_bucket(configuration)
self.bucket = config.get(section, "bucket") self.chunk_size = configuration.getint("s3", "chunk_size", fallback=8 * 1024 * 1024)
def sync(self, path: Path) -> None: @staticmethod
def calculate_etag(path: Path, chunk_size: int) -> str:
"""
calculate amazon s3 etag
credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
For this method we have to define nosec because it is out of any security context and provided by AWS
:param path: path to local file
:param chunk_size: read chunk size, which depends on client settings
:return: calculated entity tag for local file
"""
md5s = []
with path.open("rb") as local_file:
for chunk in iter(lambda: local_file.read(chunk_size), b""):
md5s.append(hashlib.md5(chunk)) # nosec
# in case if there is only one chunk it must be just this checksum
# and checksum of joined digest otherwise (including empty list)
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
# in case if there are more than one chunk it should be appended with amount of chunks
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
return f"{checksum.hexdigest()}{suffix}"
@staticmethod
def get_bucket(configuration: Configuration) -> Any:
"""
create resource client from configuration
:param configuration: configuration instance
:return: amazon client
"""
client = boto3.resource(service_name="s3",
region_name=configuration.get("s3", "region"),
aws_access_key_id=configuration.get("s3", "access_key"),
aws_secret_access_key=configuration.get("s3", "secret_key"))
return client.Bucket(configuration.get("s3", "bucket"))
def get_local_files(self, path: Path) -> Dict[Path, str]:
"""
get all local files and their calculated checksums
:param path: local path to sync
:return: map of path object to its checksum
"""
# credits to https://stackoverflow.com/a/64915960
def walk(directory_path: Path) -> Generator[Path, None, None]:
for element in directory_path.iterdir():
if element.is_dir():
yield from walk(element)
continue
yield element
return {
local_file.relative_to(path): self.calculate_etag(local_file, self.chunk_size)
for local_file in walk(path)
}
def get_remote_objects(self) -> Dict[Path, Any]:
"""
get all remote objects and their checksums
:return: map of path object to the remote s3 object
"""
objects = self.bucket.objects.filter(Prefix=self.architecture)
return {Path(item.key).relative_to(self.architecture): item for item in objects}
def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
""" """
sync data to remote server sync data to remote server
:param path: local path to sync :param path: local path to sync
:param built_packages: list of packages which has just been built
""" """
# TODO rewrite to boto, but it is bullshit remote_objects = self.get_remote_objects()
S3._check_output("aws", "s3", "sync", "--quiet", "--delete", str(path), self.bucket, local_files = self.get_local_files(path)
exception=None,
logger=self.logger) # sync to remotes first
for local_file, checksum in local_files.items():
remote_object = remote_objects.get(local_file)
# 0 and -1 elements are " (double quote)
remote_checksum = remote_object.e_tag[1:-1] if remote_object is not None else None
if remote_checksum == checksum:
continue
remote_path = Path(self.architecture) / local_file
self.bucket.upload_file(str(path / local_file), str(remote_path))
# remove files which were removed locally
for local_file, remote_object in remote_objects.items():
if local_file in local_files:
continue
remote_object.delete()

View File

@ -17,60 +17,70 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Iterable, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import SyncFailed from ahriman.core.exceptions import SyncFailed
from ahriman.models.package import Package
from ahriman.models.upload_settings import UploadSettings from ahriman.models.upload_settings import UploadSettings
class Uploader: class Upload:
""" """
base remote sync class base remote sync class
:ivar architecture: repository architecture :ivar architecture: repository architecture
:ivar config: configuration instance :ivar configuration: configuration instance
:ivar logger: application logger :ivar logger: application logger
""" """
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, configuration: Configuration) -> None:
""" """
default constructor default constructor
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
""" """
self.logger = logging.getLogger("builder") self.logger = logging.getLogger("builder")
self.architecture = architecture self.architecture = architecture
self.config = config self.config = configuration
@staticmethod @classmethod
def run(architecture: str, config: Configuration, target: str, path: Path) -> None: def load(cls: Type[Upload], architecture: str, configuration: Configuration, target: str) -> Upload:
""" """
run remote sync load client from settings
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
:param target: target to run sync (e.g. s3) :param target: target to run sync (e.g. s3)
:param path: local path to sync :return: client according to current settings
""" """
provider = UploadSettings.from_option(target) provider = UploadSettings.from_option(target)
if provider == UploadSettings.Rsync: if provider == UploadSettings.Rsync:
from ahriman.core.upload.rsync import Rsync from ahriman.core.upload.rsync import Rsync
uploader: Uploader = Rsync(architecture, config) return Rsync(architecture, configuration)
elif provider == UploadSettings.S3: if provider == UploadSettings.S3:
from ahriman.core.upload.s3 import S3 from ahriman.core.upload.s3 import S3
uploader = S3(architecture, config) return S3(architecture, configuration)
else: return cls(architecture, configuration) # should never happen
uploader = Uploader(architecture, config)
def run(self, path: Path, built_packages: Iterable[Package]) -> None:
"""
run remote sync
:param path: local path to sync
:param built_packages: list of packages which has just been built
"""
try: try:
uploader.sync(path) self.sync(path, built_packages)
except Exception: except Exception:
uploader.logger.exception(f"remote sync failed for {provider.name}") self.logger.exception("remote sync failed")
raise SyncFailed() raise SyncFailed()
def sync(self, path: Path) -> None: def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
""" """
sync data to remote server sync data to remote server
:param path: local path to sync :param path: local path to sync
:param built_packages: list of packages which has just been built
""" """

View File

@ -19,37 +19,51 @@
# #
import datetime import datetime
import subprocess import subprocess
import requests
from logging import Logger from logging import Logger
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Union
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
def check_output(*args: str, exception: Optional[Exception], def check_output(*args: str, exception: Optional[Exception], cwd: Optional[Path] = None,
cwd: Optional[Path] = None, logger: Optional[Logger] = None) -> str: input_data: Optional[str] = None, logger: Optional[Logger] = None) -> str:
""" """
subprocess wrapper subprocess wrapper
:param args: command line arguments :param args: command line arguments
:param exception: exception which has to be reraised instead of default subprocess exception :param exception: exception which has to be reraised instead of default subprocess exception
:param cwd: current working directory :param cwd: current working directory
:param input_data: data which will be written to command stdin
:param logger: logger to log command result if required :param logger: logger to log command result if required
:return: command output :return: command output
""" """
try: try:
result = subprocess.check_output(args, cwd=cwd, stderr=subprocess.STDOUT).decode("utf8").strip() # universal_newlines is required to read input from string
result: str = subprocess.check_output(args, cwd=cwd, input=input_data, stderr=subprocess.STDOUT,
universal_newlines=True).strip()
if logger is not None: if logger is not None:
for line in result.splitlines(): for line in result.splitlines():
logger.debug(line) logger.debug(line)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if e.output is not None and logger is not None: if e.output is not None and logger is not None:
for line in e.output.decode("utf8").splitlines(): for line in e.output.splitlines():
logger.debug(line) logger.debug(line)
raise exception or e raise exception or e
return result return result
def exception_response_text(exception: requests.exceptions.HTTPError) -> str:
"""
safe response exception text generation
:param exception: exception raised
:return: text of the response if it is not None and empty string otherwise
"""
result: str = exception.response.text if exception.response is not None else ""
return result
def package_like(filename: Path) -> bool: def package_like(filename: Path) -> bool:
""" """
check if file looks like package check if file looks like package
@ -60,7 +74,7 @@ def package_like(filename: Path) -> bool:
return ".pkg." in name and not name.endswith(".sig") return ".pkg." in name and not name.endswith(".sig")
def pretty_datetime(timestamp: Optional[int]) -> str: def pretty_datetime(timestamp: Optional[Union[float, int]]) -> str:
""" """
convert datetime object to string convert datetime object to string
:param timestamp: datetime to convert :param timestamp: datetime to convert
@ -89,6 +103,6 @@ def pretty_size(size: Optional[float], level: int = 0) -> str:
if size is None: if size is None:
return "" return ""
if size < 1024 or level == 3: if size < 1024 or level >= 3:
return f"{size:.1f} {str_level()}" return f"{size:.1f} {str_level()}"
return pretty_size(size / 1024, level + 1) return pretty_size(size / 1024, level + 1)

View File

@ -63,7 +63,7 @@ class BuildStatus:
""" """
build status holder build status holder
:ivar status: build status :ivar status: build status
:ivar _timestamp: build status update time :ivar timestamp: build status update time
""" """
def __init__(self, status: Union[BuildStatusEnum, str, None] = None, def __init__(self, status: Union[BuildStatusEnum, str, None] = None,

View File

@ -0,0 +1,71 @@
#
# Copyright (c) 2021 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 __future__ import annotations
from dataclasses import dataclass, fields
from typing import Any, Dict, List, Tuple, Type
from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package
@dataclass
class Counters:
"""
package counters
:ivar total: total packages count
:ivar unknown: packages in unknown status count
:ivar pending: packages in pending status count
:ivar building: packages in building status count
:ivar failed: packages in failed status count
:ivar success: packages in success status count
"""
total: int
unknown: int = 0
pending: int = 0
building: int = 0
failed: int = 0
success: int = 0
@classmethod
def from_json(cls: Type[Counters], dump: Dict[str, Any]) -> Counters:
"""
construct counters from json dump
:param dump: json dump body
:return: status counters
"""
# filter to only known fields
known_fields = [pair.name for pair in fields(cls)]
dump = {key: value for key, value in dump.items() if key in known_fields}
return cls(**dump)
@classmethod
def from_packages(cls: Type[Counters], packages: List[Tuple[Package, BuildStatus]]) -> Counters:
"""
construct counters from packages statuses
:param packages: list of package and their status as per watcher property
:return: status counters
"""
per_status = {"total": len(packages)}
for _, status in packages:
key = status.status.name.lower()
per_status.setdefault(key, 0)
per_status[key] += 1
return cls(**per_status)

View File

@ -0,0 +1,60 @@
#
# Copyright (c) 2021 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 __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, Optional, Type
from ahriman.models.counters import Counters
@dataclass
class InternalStatus:
"""
internal server status
:ivar architecture: repository architecture
:ivar packages: packages statuses counter object
:ivar repository: repository name
:ivar version: service version
"""
architecture: Optional[str] = None
packages: Counters = field(default=Counters(total=0))
repository: Optional[str] = None
version: Optional[str] = None
@classmethod
def from_json(cls: Type[InternalStatus], dump: Dict[str, Any]) -> InternalStatus:
"""
construct internal status from json dump
:param dump: json dump body
:return: internal status
"""
counters = Counters.from_json(dump["packages"]) if "packages" in dump else Counters(total=0)
return cls(architecture=dump.get("architecture"),
packages=counters,
repository=dump.get("repository"),
version=dump.get("version"))
def view(self) -> Dict[str, Any]:
"""
generate json status view
:return: json-friendly dictionary
"""
return asdict(self)

View File

@ -31,7 +31,7 @@ from typing import Any, Dict, List, Optional, Set, Type, Union
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.exceptions import InvalidPackageInfo from ahriman.core.exceptions import InvalidPackageInfo
from ahriman.core.util import check_output from ahriman.core.util import check_output
from ahriman.models.package_desciption import PackageDescription from ahriman.models.package_description import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -52,6 +52,13 @@ class Package:
_check_output = check_output _check_output = check_output
@property
def depends(self) -> List[str]:
"""
:return: sum of dependencies per arch package
"""
return sorted(set(sum([package.depends for package in self.packages.values()], start=[])))
@property @property
def git_url(self) -> str: def git_url(self) -> str:
""" """
@ -59,6 +66,13 @@ class Package:
""" """
return f"{self.aur_url}/{self.base}.git" return f"{self.aur_url}/{self.base}.git"
@property
def groups(self) -> List[str]:
"""
:return: sum of groups per each package
"""
return sorted(set(sum([package.groups for package in self.packages.values()], start=[])))
@property @property
def is_single_package(self) -> bool: def is_single_package(self) -> bool:
""" """
@ -78,6 +92,13 @@ class Package:
or self.base.endswith("-hg")\ or self.base.endswith("-hg")\
or self.base.endswith("-svn") or self.base.endswith("-svn")
@property
def licenses(self) -> List[str]:
"""
:return: sum of licenses per each package
"""
return sorted(set(sum([package.licenses for package in self.packages.values()], start=[])))
@property @property
def web_url(self) -> str: def web_url(self) -> str:
""" """
@ -95,8 +116,8 @@ class Package:
:return: package properties :return: package properties
""" """
package = pacman.handle.load_pkg(str(path)) package = pacman.handle.load_pkg(str(path))
properties = PackageDescription(package.size, package.builddate, path.name, package.isize) return cls(package.base, package.version, aur_url,
return cls(package.base, package.version, aur_url, {package.name: properties}) {package.name: PackageDescription.from_package(package, path)})
@classmethod @classmethod
def from_aur(cls: Type[Package], name: str, aur_url: str) -> Package: def from_aur(cls: Type[Package], name: str, aur_url: str) -> Package:
@ -133,7 +154,7 @@ class Package:
:return: package properties :return: package properties
""" """
packages = { packages = {
key: PackageDescription(**value) key: PackageDescription.from_json(value)
for key, value in dump.get("packages", {}).items() for key, value in dump.get("packages", {}).items()
} }
return Package( return Package(
@ -142,6 +163,27 @@ class Package:
aur_url=dump["aur_url"], aur_url=dump["aur_url"],
packages=packages) packages=packages)
@classmethod
def load(cls: Type[Package], path: Union[Path, str], pacman: Pacman, aur_url: str) -> Package:
"""
package constructor from available sources
:param path: one of path to sources directory, path to archive or package name/base
:param pacman: alpm wrapper instance (required to load from archive)
:param aur_url: AUR root url
:return: package properties
"""
try:
maybe_path = Path(path)
if maybe_path.is_dir():
return cls.from_build(maybe_path, aur_url)
if maybe_path.is_file():
return cls.from_archive(maybe_path, pacman, aur_url)
return cls.from_aur(str(path), aur_url)
except InvalidPackageInfo:
raise
except Exception as e:
raise InvalidPackageInfo(str(e))
@staticmethod @staticmethod
def dependencies(path: Path) -> Set[str]: def dependencies(path: Path) -> Set[str]:
""" """
@ -180,29 +222,6 @@ class Package:
prefix = f"{epoch}:" if epoch else "" prefix = f"{epoch}:" if epoch else ""
return f"{prefix}{pkgver}-{pkgrel}" return f"{prefix}{pkgver}-{pkgrel}"
@staticmethod
def load(path: Union[Path, str], pacman: Pacman, aur_url: str) -> Package:
"""
package constructor from available sources
:param path: one of path to sources directory, path to archive or package name/base
:param pacman: alpm wrapper instance (required to load from archive)
:param aur_url: AUR root url
:return: package properties
"""
try:
maybe_path = Path(path)
if maybe_path.is_dir():
package: Package = Package.from_build(maybe_path, aur_url)
elif maybe_path.is_file():
package = Package.from_archive(maybe_path, pacman, aur_url)
else:
package = Package.from_aur(str(path), aur_url)
return package
except InvalidPackageInfo:
raise
except Exception as e:
raise InvalidPackageInfo(str(e))
def actual_version(self, paths: RepositoryPaths) -> str: def actual_version(self, paths: RepositoryPaths) -> str:
""" """
additional method to handle VCS package versions additional method to handle VCS package versions

View File

@ -0,0 +1,95 @@
#
# Copyright (c) 2021 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 __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
from pyalpm import Package # type: ignore
from typing import Any, Dict, List, Optional, Type
@dataclass
class PackageDescription:
"""
package specific properties
:ivar architecture: package architecture
:ivar archive_size: package archive size
:ivar build_date: package build date
:ivar depends: package dependencies list
:ivar description: package description
:ivar filename: package archive name
:ivar groups: package groups
:ivar installed_size: package installed size
:ivar licenses: package licenses list
:ivar provides: list of provided packages
:ivar url: package url
"""
architecture: Optional[str] = None
archive_size: Optional[int] = None
build_date: Optional[int] = None
depends: List[str] = field(default_factory=list)
description: Optional[str] = None
filename: Optional[str] = None
groups: List[str] = field(default_factory=list)
installed_size: Optional[int] = None
licenses: List[str] = field(default_factory=list)
provides: List[str] = field(default_factory=list)
url: Optional[str] = None
@property
def filepath(self) -> Optional[Path]:
"""
:return: path object for current filename
"""
return Path(self.filename) if self.filename is not None else None
@classmethod
def from_json(cls: Type[PackageDescription], dump: Dict[str, Any]) -> PackageDescription:
"""
construct package properties from json dump
:param dump: json dump body
:return: package properties
"""
# filter to only known fields
known_fields = [pair.name for pair in fields(cls)]
dump = {key: value for key, value in dump.items() if key in known_fields}
return cls(**dump)
@classmethod
def from_package(cls: Type[PackageDescription], package: Package, path: Path) -> PackageDescription:
"""
construct class from alpm package class
:param package: alpm generated object
:param path: path to package archive
:return: package properties based on tarball
"""
return cls(
architecture=package.arch,
archive_size=package.size,
build_date=package.builddate,
depends=package.depends,
description=package.desc,
filename=path.name,
groups=package.groups,
installed_size=package.isize,
licenses=package.licenses,
provides=package.provides,
url=package.url)

View File

@ -20,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
@ -27,18 +28,24 @@ from ahriman.core.exceptions import InvalidOption
class ReportSettings(Enum): class ReportSettings(Enum):
""" """
report targets enumeration report targets enumeration
:cvar Disabled: option which generates no report for testing purpose
:cvar HTML: html report generation :cvar HTML: html report generation
:cvar Email: email report generation
""" """
Disabled = auto() # for testing purpose
HTML = auto() HTML = auto()
Email = auto()
@staticmethod @classmethod
def from_option(value: str) -> ReportSettings: def from_option(cls: Type[ReportSettings], value: str) -> ReportSettings:
""" """
construct value from configuration construct value from configuration
:param value: configuration value :param value: configuration value
:return: parsed value :return: parsed value
""" """
if value.lower() in ("html",): if value.lower() in ("html",):
return ReportSettings.HTML return cls.HTML
if value.lower() in ("email",):
return cls.Email
raise InvalidOption(value) raise InvalidOption(value)

View File

@ -17,9 +17,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from pathlib import Path from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Set, Type
@dataclass @dataclass
@ -76,6 +78,20 @@ class RepositoryPaths:
""" """
return self.root / "sources" / self.architecture return self.root / "sources" / self.architecture
@classmethod
def known_architectures(cls: Type[RepositoryPaths], root: Path) -> Set[str]:
"""
get known architectures
:param root: repository root
:return: list of architectures for which tree is created
"""
paths = cls(root, "")
return {
path.name
for path in paths.repository.iterdir()
if path.is_dir()
}
def create_tree(self) -> None: def create_tree(self) -> None:
""" """
create ahriman working tree create ahriman working tree

View File

@ -20,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
@ -27,22 +28,22 @@ from ahriman.core.exceptions import InvalidOption
class SignSettings(Enum): class SignSettings(Enum):
""" """
sign targets enumeration sign targets enumeration
:cvar SignPackages: sign each package :cvar Packages: sign each package
:cvar SignRepository: sign repository database file :cvar Repository: sign repository database file
""" """
SignPackages = auto() Packages = auto()
SignRepository = auto() Repository = auto()
@staticmethod @classmethod
def from_option(value: str) -> SignSettings: def from_option(cls: Type[SignSettings], value: str) -> SignSettings:
""" """
construct value from configuration construct value from configuration
:param value: configuration value :param value: configuration value
:return: parsed value :return: parsed value
""" """
if value.lower() in ("package", "packages", "sign-package"): if value.lower() in ("package", "packages", "sign-package"):
return SignSettings.SignPackages return cls.Packages
if value.lower() in ("repository", "sign-repository"): if value.lower() in ("repository", "sign-repository"):
return SignSettings.SignRepository return cls.Repository
raise InvalidOption(value) raise InvalidOption(value)

View File

@ -0,0 +1,49 @@
#
# Copyright (c) 2021 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 __future__ import annotations
from enum import Enum, auto
from typing import Type
class SmtpSSLSettings(Enum):
"""
SMTP SSL mode enumeration
:cvar Disabled: no SSL enabled
:cvar SSL: use SMTP_SSL instead of normal SMTP client
:cvar STARTTLS: use STARTTLS in normal SMTP client
"""
Disabled = auto()
SSL = auto()
STARTTLS = auto()
@classmethod
def from_option(cls: Type[SmtpSSLSettings], value: str) -> SmtpSSLSettings:
"""
construct value from configuration
:param value: configuration value
:return: parsed value
"""
if value.lower() in ("ssl", "ssl/tls"):
return cls.SSL
if value.lower() in ("starttls",):
return cls.STARTTLS
return cls.Disabled

View File

@ -20,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption from ahriman.core.exceptions import InvalidOption
@ -27,22 +28,24 @@ from ahriman.core.exceptions import InvalidOption
class UploadSettings(Enum): class UploadSettings(Enum):
""" """
remote synchronization targets enumeration remote synchronization targets enumeration
:cvar Disabled: no sync will be performed, required for testing purpose
:cvar Rsync: sync via rsync :cvar Rsync: sync via rsync
:cvar S3: sync to Amazon S3 :cvar S3: sync to Amazon S3
""" """
Disabled = auto() # for testing purpose
Rsync = auto() Rsync = auto()
S3 = auto() S3 = auto()
@staticmethod @classmethod
def from_option(value: str) -> UploadSettings: def from_option(cls: Type[UploadSettings], value: str) -> UploadSettings:
""" """
construct value from configuration construct value from configuration
:param value: configuration value :param value: configuration value
:return: parsed value :return: parsed value
""" """
if value.lower() in ("rsync",): if value.lower() in ("rsync",):
return UploadSettings.Rsync return cls.Rsync
if value.lower() in ("s3",): if value.lower() in ("s3",):
return UploadSettings.S3 return cls.S3
raise InvalidOption(value) raise InvalidOption(value)

View File

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

View File

@ -23,6 +23,7 @@ from ahriman.web.views.ahriman import AhrimanView
from ahriman.web.views.index import IndexView from ahriman.web.views.index import IndexView
from ahriman.web.views.package import PackageView from ahriman.web.views.package import PackageView
from ahriman.web.views.packages import PackagesView from ahriman.web.views.packages import PackagesView
from ahriman.web.views.status import StatusView
def setup_routes(application: Application) -> None: def setup_routes(application: Application) -> None:
@ -44,6 +45,8 @@ def setup_routes(application: Application) -> None:
GET /api/v1/package/:base get package base status GET /api/v1/package/:base get package base status
POST /api/v1/package/:base update package base status POST /api/v1/package/:base update package base status
GET /api/v1/status get web service status itself
:param application: web application instance :param application: web application instance
""" """
application.router.add_get("/", IndexView) application.router.add_get("/", IndexView)
@ -58,3 +61,5 @@ def setup_routes(application: Application) -> None:
application.router.add_delete("/api/v1/packages/{package}", PackageView) application.router.add_delete("/api/v1/packages/{package}", PackageView)
application.router.add_get("/api/v1/packages/{package}", PackageView) application.router.add_get("/api/v1/packages/{package}", PackageView)
application.router.add_post("/api/v1/packages/{package}", PackageView) application.router.add_post("/api/v1/packages/{package}", PackageView)
application.router.add_get("/api/v1/status", StatusView)

View File

@ -21,8 +21,7 @@ import aiohttp_jinja2
from typing import Any, Dict from typing import Any, Dict
import ahriman.version as version from ahriman import version
from ahriman.core.util import pretty_datetime from ahriman.core.util import pretty_datetime
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -34,10 +33,21 @@ class IndexView(BaseView):
It uses jinja2 templates for report generation, the following variables are allowed: It uses jinja2 templates for report generation, the following variables are allowed:
architecture - repository architecture, string, required architecture - repository architecture, string, required
packages - sorted list of packages properties: base, packages (sorted list), status, packages - sorted list of packages properties, required
timestamp, version, web_url. Required * base, string
* depends, sorted list of strings
* groups, sorted list of strings
* licenses, sorted list of strings
* packages, sorted list of strings
* status, string based on enum value
* timestamp, pretty printed datetime, string
* version, string
* web_url, string
repository - repository name, string, required repository - repository name, string, required
service - service status properties: status, status_color, timestamp. Required service - service status properties, required
* status, string based on enum value
* status_color, string based on enum value
* timestamp, pretty printed datetime, string
version - ahriman version, string, required version - ahriman version, string, required
""" """
@ -51,6 +61,9 @@ class IndexView(BaseView):
packages = [ packages = [
{ {
"base": package.base, "base": package.base,
"depends": package.depends,
"groups": package.groups,
"licenses": package.licenses,
"packages": list(sorted(package.packages)), "packages": list(sorted(package.packages)),
"status": status.status.value, "status": status.status.value,
"timestamp": pretty_datetime(status.timestamp), "timestamp": pretty_datetime(status.timestamp),

View File

@ -0,0 +1,45 @@
#
# Copyright (c) 2021 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 Response, json_response
from ahriman import version
from ahriman.models.counters import Counters
from ahriman.models.internal_status import InternalStatus
from ahriman.web.views.base import BaseView
class StatusView(BaseView):
"""
web service status web view
"""
async def get(self) -> Response:
"""
get current service status
:return: 200 with service status object
"""
counters = Counters.from_packages(self.service.packages)
status = InternalStatus(
architecture=self.service.architecture,
packages=counters,
repository=self.service.repository.name,
version=version.__version__)
return json_response(status.view())

View File

@ -58,19 +58,19 @@ def run_server(application: web.Application) -> None:
""" """
application.logger.info("start server") application.logger.info("start server")
section = application["config"].get_section_name("web", application["architecture"]) configuration: Configuration = application["configuration"]
host = application["config"].get(section, "host") host = configuration.get("web", "host")
port = application["config"].getint(section, "port") port = configuration.getint("web", "port")
web.run_app(application, host=host, port=port, handle_signals=False, web.run_app(application, host=host, port=port, handle_signals=False,
access_log=logging.getLogger("http")) access_log=logging.getLogger("http"))
def setup_service(architecture: str, config: Configuration) -> web.Application: def setup_service(architecture: str, configuration: Configuration) -> web.Application:
""" """
create web application create web application
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param configuration: configuration instance
:return: web application instance :return: web application instance
""" """
application = web.Application(logger=logging.getLogger("http")) application = web.Application(logger=logging.getLogger("http"))
@ -84,13 +84,12 @@ def setup_service(architecture: str, config: Configuration) -> web.Application:
setup_routes(application) setup_routes(application)
application.logger.info("setup templates") application.logger.info("setup templates")
aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(config.getpath("web", "templates"))) aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(configuration.getpath("web", "templates")))
application.logger.info("setup configuration") application.logger.info("setup configuration")
application["config"] = config application["configuration"] = configuration
application["architecture"] = architecture
application.logger.info("setup watcher") application.logger.info("setup watcher")
application["watcher"] = Watcher(architecture, config) application["watcher"] = Watcher(architecture, configuration)
return application return application

View File

@ -1,4 +1,5 @@
import argparse import argparse
import aur
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@ -7,24 +8,70 @@ from ahriman.application.ahriman import _parser
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.lock import Lock from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
@pytest.fixture @pytest.fixture
def application(configuration: Configuration, mocker: MockerFixture) -> Application: def application(configuration: Configuration, mocker: MockerFixture) -> Application:
"""
fixture for application
:param configuration: configuration fixture
:param mocker: mocker object
:return: application test instance
"""
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
return Application("x86_64", configuration) return Application("x86_64", configuration)
@pytest.fixture @pytest.fixture
def args() -> argparse.Namespace: def args() -> argparse.Namespace:
"""
fixture for command line arguments
:return: command line arguments test instance
"""
return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True) return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True)
@pytest.fixture
def aur_package_ahriman(package_ahriman: Package) -> aur.Package:
"""
fixture for AUR package
:param package_ahriman: package fixture
:return: AUR package test instance
"""
return aur.Package(
num_votes=None,
description=package_ahriman.packages[package_ahriman.base].description,
url_path=package_ahriman.web_url,
last_modified=None,
name=package_ahriman.base,
out_of_date=None,
id=None,
first_submitted=None,
maintainer=None,
version=package_ahriman.version,
license=package_ahriman.packages[package_ahriman.base].licenses,
url=None,
package_base=package_ahriman.base,
package_base_id=None,
category_id=None)
@pytest.fixture @pytest.fixture
def lock(args: argparse.Namespace, configuration: Configuration) -> Lock: def lock(args: argparse.Namespace, configuration: Configuration) -> Lock:
"""
fixture for file lock
:param args: command line arguments fixture
:param configuration: configuration fixture
:return: file lock test instance
"""
return Lock(args, "x86_64", configuration) return Lock(args, "x86_64", configuration)
@pytest.fixture @pytest.fixture
def parser() -> argparse.ArgumentParser: def parser() -> argparse.ArgumentParser:
"""
fixture for command line arguments parser
:return: command line arguments parser test instance
"""
return _parser() return _parser()

View File

@ -1,27 +1,97 @@
import argparse import argparse
import pytest
from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.application.handlers import Handler from ahriman.application.handlers import Handler
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
def test_call(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_call(args: argparse.Namespace, mocker: MockerFixture) -> None:
""" """
must call inside lock must call inside lock
""" """
args.configuration = Path("")
args.no_log = False
mocker.patch("ahriman.application.handlers.Handler.run") mocker.patch("ahriman.application.handlers.Handler.run")
mocker.patch("ahriman.core.configuration.Configuration.from_path")
enter_mock = mocker.patch("ahriman.application.lock.Lock.__enter__") enter_mock = mocker.patch("ahriman.application.lock.Lock.__enter__")
exit_mock = mocker.patch("ahriman.application.lock.Lock.__exit__") exit_mock = mocker.patch("ahriman.application.lock.Lock.__exit__")
assert Handler._call(args, "x86_64", configuration) assert Handler._call(args, "x86_64")
enter_mock.assert_called_once() enter_mock.assert_called_once()
exit_mock.assert_called_once() exit_mock.assert_called_once()
def test_call_exception(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_call_exception(args: argparse.Namespace, mocker: MockerFixture) -> None:
""" """
must process exception must process exception
""" """
mocker.patch("ahriman.application.lock.Lock.__enter__", side_effect=Exception()) mocker.patch("ahriman.application.lock.Lock.__enter__", side_effect=Exception())
assert not Handler._call(args, "x86_64", configuration) assert not Handler._call(args, "x86_64")
def test_execute(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must run execution in multiple processes
"""
args.architecture = ["i686", "x86_64"]
starmap_mock = mocker.patch("multiprocessing.pool.Pool.starmap")
Handler.execute(args)
starmap_mock.assert_called_once()
def test_extract_architectures(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must generate list of available architectures
"""
args.architecture = []
args.configuration = Path("")
mocker.patch("ahriman.core.configuration.Configuration.getpath")
known_architectures_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures")
Handler.extract_architectures(args)
known_architectures_mock.assert_called_once()
def test_extract_architectures_empty(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must raise exception if no available architectures found
"""
args.architecture = []
args.command = "config"
args.configuration = Path("")
mocker.patch("ahriman.core.configuration.Configuration.getpath")
mocker.patch("ahriman.models.repository_paths.RepositoryPaths.known_architectures", return_value=set())
with pytest.raises(MissingArchitecture):
Handler.extract_architectures(args)
def test_extract_architectures_exception(args: argparse.Namespace) -> None:
"""
must raise exception on missing architectures
"""
args.command = "config"
args.architecture = None
with pytest.raises(MissingArchitecture):
Handler.extract_architectures(args)
def test_extract_architectures_specified(args: argparse.Namespace) -> None:
"""
must return architecture list if it has been specified
"""
architectures = args.architecture = ["i686", "x86_64"]
assert Handler.extract_architectures(args) == set(architectures)
def test_run(args: argparse.Namespace, configuration: Configuration) -> None:
"""
must raise NotImplemented for missing method
"""
with pytest.raises(NotImplementedError):
Handler.run(args, "x86_64", configuration)

View File

@ -6,14 +6,41 @@ from ahriman.application.handlers import Add
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
args.now = False
args.without_dependencies = False
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must run command must run command
""" """
args.package = [] args = _default_args(args)
args.without_dependencies = False
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.add") application_mock = mocker.patch("ahriman.application.application.Application.add")
Add.run(args, "x86_64", configuration) Add.run(args, "x86_64", configuration)
application_mock.assert_called_once() application_mock.assert_called_once()
def test_run_with_updates(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command with updates after
"""
args = _default_args(args)
args.now = True
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.application.application.Application.add")
application_mock = mocker.patch("ahriman.application.application.Application.update")
updates_mock = mocker.patch("ahriman.application.application.Application.get_updates")
Add.run(args, "x86_64", configuration)
application_mock.assert_called_once()
updates_mock.assert_called_once()

View File

@ -6,15 +6,25 @@ from ahriman.application.handlers import Clean
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
""" """
must run command default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
""" """
args.no_build = False args.no_build = False
args.no_cache = False args.no_cache = False
args.no_chroot = False args.no_chroot = False
args.no_manual = False args.no_manual = False
args.no_packages = False args.no_packages = False
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.clean") application_mock = mocker.patch("ahriman.application.application.Application.clean")

View File

@ -11,7 +11,10 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
must run command must run command
""" """
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.core.configuration.Configuration.dump") print_mock = mocker.patch("ahriman.application.handlers.dump.Dump._print")
application_mock = mocker.patch("ahriman.core.configuration.Configuration.dump",
return_value=configuration.dump())
Dump.run(args, "x86_64", configuration) Dump.run(args, "x86_64", configuration)
application_mock.assert_called_once() application_mock.assert_called_once()
print_mock.assert_called()

View File

@ -0,0 +1,18 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import Init
from ahriman.core.configuration import Configuration
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
create_tree_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree")
init_mock = mocker.patch("ahriman.core.alpm.repo.Repo.init")
Init.run(args, "x86_64", configuration)
create_tree_mock.assert_called_once()
init_mock.assert_called_once()

View File

@ -0,0 +1,29 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import KeyImport
from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.key = "0xE989490C"
args.key_server = "keys.gnupg.net"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.core.sign.gpg.GPG.import_key")
KeyImport.run(args, "x86_64", configuration)
application_mock.assert_called_once()

View File

@ -4,12 +4,24 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Rebuild from ahriman.application.handlers import Rebuild
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.depends_on = []
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must run command must run command
""" """
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages") application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages")
application_mock = mocker.patch("ahriman.application.application.Application.update") application_mock = mocker.patch("ahriman.application.application.Application.update")
@ -17,3 +29,36 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc
Rebuild.run(args, "x86_64", configuration) Rebuild.run(args, "x86_64", configuration)
application_packages_mock.assert_called_once() application_packages_mock.assert_called_once()
application_mock.assert_called_once() application_mock.assert_called_once()
def test_run_filter(args: argparse.Namespace, configuration: Configuration,
package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must run command with depends filter
"""
args = _default_args(args)
args.depends_on = ["python-aur"]
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
application_mock = mocker.patch("ahriman.application.application.Application.update")
Rebuild.run(args, "x86_64", configuration)
application_mock.assert_called_with([package_ahriman])
def test_run_without_filter(args: argparse.Namespace, configuration: Configuration,
package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must run command for all packages if no filter supplied
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
application_mock = mocker.patch("ahriman.application.application.Application.update")
Rebuild.run(args, "x86_64", configuration)
application_mock.assert_called_with([package_ahriman, package_python_schedule])

View File

@ -6,11 +6,21 @@ from ahriman.application.handlers import Remove
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must run command must run command
""" """
args.package = [] args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.remove") application_mock = mocker.patch("ahriman.application.application.Application.remove")

View File

@ -6,11 +6,21 @@ from ahriman.application.handlers import Report
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.target = []
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must run command must run command
""" """
args.target = [] args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.report") application_mock = mocker.patch("ahriman.application.application.Application.report")

View File

@ -0,0 +1,55 @@
import argparse
import aur
from pytest_mock import MockerFixture
from ahriman.application.handlers import Search
from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.search = ["ahriman"]
return args
def test_run(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman])
log_mock = mocker.patch("ahriman.application.handlers.search.Search.log_fn")
Search.run(args, "x86_64", configuration)
log_mock.assert_called_once()
def test_run_multiple_search(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command with multiple search arguments
"""
args = _default_args(args)
args.search = ["ahriman", "is", "cool"]
search_mock = mocker.patch("aur.search")
Search.run(args, "x86_64", configuration)
search_mock.assert_called_with(" ".join(args.search))
def test_log_fn(args: argparse.Namespace, configuration: Configuration, aur_package_ahriman: aur.Package,
mocker: MockerFixture) -> None:
"""
log function must call print built-in
"""
args = _default_args(args)
mocker.patch("aur.search", return_value=[aur_package_ahriman])
print_mock = mocker.patch("builtins.print")
Search.run(args, "x86_64", configuration)
print_mock.assert_called() # we don't really care about call details tbh

View File

@ -0,0 +1,160 @@
import argparse
from pathlib import Path
from pytest_mock import MockerFixture
from unittest import mock
from ahriman.application.handlers import Setup
from ahriman.core.configuration import Configuration
from ahriman.models.repository_paths import RepositoryPaths
from ahriman.models.sign_settings import SignSettings
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.build_command = "ahriman"
args.from_configuration = Path("/usr/share/devtools/pacman-extra.conf")
args.no_multilib = False
args.packager = "John Doe <john@doe.com>"
args.repository = "aur-clone"
args.sign_key = "key"
args.sign_target = [SignSettings.Packages]
args.web_port = 8080
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
ahriman_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_ahriman_configuration")
devtools_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_devtools_configuration")
makepkg_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_makepkg_configuration")
sudo_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_sudo_configuration")
executable_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_executable")
Setup.run(args, "x86_64", configuration)
ahriman_configuration_mock.assert_called_once()
devtools_configuration_mock.assert_called_once()
makepkg_configuration_mock.assert_called_once()
sudo_configuration_mock.assert_called_once()
executable_mock.assert_called_once()
def test_build_command(args: argparse.Namespace) -> None:
"""
must generate correct build command name
"""
args = _default_args(args)
assert Setup.build_command(args.build_command, "x86_64").name == f"{args.build_command}-x86_64-build"
def test_create_ahriman_configuration(args: argparse.Namespace, configuration: Configuration,
mocker: MockerFixture) -> None:
"""
must create configuration for the service
"""
args = _default_args(args)
mocker.patch("pathlib.Path.open")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
set_mock = mocker.patch("configparser.RawConfigParser.set")
write_mock = mocker.patch("configparser.RawConfigParser.write")
command = Setup.build_command(args.build_command, "x86_64")
Setup.create_ahriman_configuration(args, "x86_64", args.repository, configuration.include)
add_section_mock.assert_has_calls([
mock.call(Configuration.section_name("build", "x86_64")),
mock.call("repository"),
mock.call(Configuration.section_name("sign", "x86_64")),
mock.call(Configuration.section_name("web", "x86_64")),
])
set_mock.assert_has_calls([
mock.call(Configuration.section_name("build", "x86_64"), "build_command", str(command)),
mock.call("repository", "name", args.repository),
mock.call(Configuration.section_name("sign", "x86_64"), "target",
" ".join([target.name.lower() for target in args.sign_target])),
mock.call(Configuration.section_name("sign", "x86_64"), "key", args.sign_key),
mock.call(Configuration.section_name("web", "x86_64"), "port", str(args.web_port)),
])
write_mock.assert_called_once()
def test_create_devtools_configuration(args: argparse.Namespace, repository_paths: RepositoryPaths,
mocker: MockerFixture) -> None:
"""
must create configuration for the devtools
"""
args = _default_args(args)
mocker.patch("pathlib.Path.open")
mocker.patch("configparser.RawConfigParser.set")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
write_mock = mocker.patch("configparser.RawConfigParser.write")
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
args.no_multilib, args.repository, repository_paths)
add_section_mock.assert_has_calls([
mock.call("multilib"),
mock.call(args.repository)
])
write_mock.assert_called_once()
def test_create_devtools_configuration_no_multilib(args: argparse.Namespace, repository_paths: RepositoryPaths,
mocker: MockerFixture) -> None:
"""
must create configuration for the devtools without multilib
"""
args = _default_args(args)
mocker.patch("pathlib.Path.open")
mocker.patch("configparser.RawConfigParser.set")
add_section_mock = mocker.patch("configparser.RawConfigParser.add_section")
write_mock = mocker.patch("configparser.RawConfigParser.write")
Setup.create_devtools_configuration(args.build_command, "x86_64", args.from_configuration,
True, args.repository, repository_paths)
add_section_mock.assert_called_once()
write_mock.assert_called_once()
def test_create_makepkg_configuration(args: argparse.Namespace, repository_paths: RepositoryPaths,
mocker: MockerFixture) -> None:
"""
must create makepkg configuration
"""
args = _default_args(args)
write_text_mock = mocker.patch("pathlib.Path.write_text")
Setup.create_makepkg_configuration(args.packager, repository_paths)
write_text_mock.assert_called_once()
def test_create_sudo_configuration(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must create sudo configuration
"""
args = _default_args(args)
chmod_text_mock = mocker.patch("pathlib.Path.chmod")
write_text_mock = mocker.patch("pathlib.Path.write_text")
Setup.create_sudo_configuration(args.build_command, "x86_64")
chmod_text_mock.assert_called_with(0o400)
write_text_mock.assert_called_once()
def test_create_executable(args: argparse.Namespace, mocker: MockerFixture) -> None:
"""
must create executable
"""
args = _default_args(args)
symlink_text_mock = mocker.patch("pathlib.Path.symlink_to")
unlink_text_mock = mocker.patch("pathlib.Path.unlink")
Setup.create_executable(args.build_command, "x86_64")
symlink_text_mock.assert_called_once()
unlink_text_mock.assert_called_once()

View File

@ -0,0 +1,28 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import Sign
from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.package = []
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.sign")
Sign.run(args, "x86_64", configuration)
application_mock.assert_called_once()

View File

@ -4,19 +4,47 @@ from pytest_mock import MockerFixture
from ahriman.application.handlers import Status from ahriman.application.handlers import Status
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
""" """
must run command default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
""" """
args.ahriman = True args.ahriman = True
args.package = [] args.package = []
args.without_dependencies = False return args
def test_run(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.core.status.client.Client.get_self") application_mock = mocker.patch("ahriman.core.status.client.Client.get_self")
packages_mock = mocker.patch("ahriman.core.status.client.Client.get") packages_mock = mocker.patch("ahriman.core.status.client.Client.get",
return_value=[(package_ahriman, BuildStatus())])
Status.run(args, "x86_64", configuration) Status.run(args, "x86_64", configuration)
application_mock.assert_called_once() application_mock.assert_called_once()
packages_mock.assert_called_once() packages_mock.assert_called_once()
def test_run_with_package_filter(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
args.package = [package_ahriman.base]
mocker.patch("pathlib.Path.mkdir")
packages_mock = mocker.patch("ahriman.core.status.client.Client.get",
return_value=[(package_ahriman, BuildStatus())])
Status.run(args, "x86_64", configuration)
packages_mock.assert_called_once()

View File

@ -0,0 +1,61 @@
import argparse
from pytest_mock import MockerFixture
from ahriman.application.handlers import StatusUpdate
from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.status = BuildStatusEnum.Success
args.package = None
args.remove = False
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir")
update_self_mock = mocker.patch("ahriman.core.status.client.Client.update_self")
StatusUpdate.run(args, "x86_64", configuration)
update_self_mock.assert_called_once()
def test_run_packages(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must run command with specified packages
"""
args = _default_args(args)
args.package = [package_ahriman.base]
mocker.patch("pathlib.Path.mkdir")
update_mock = mocker.patch("ahriman.core.status.client.Client.update")
StatusUpdate.run(args, "x86_64", configuration)
update_mock.assert_called_once()
def test_run_remove(args: argparse.Namespace, configuration: Configuration, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must remove package from status page
"""
args = _default_args(args)
args.package = [package_ahriman.base]
args.remove = True
mocker.patch("pathlib.Path.mkdir")
update_mock = mocker.patch("ahriman.core.status.client.Client.remove")
StatusUpdate.run(args, "x86_64", configuration)
update_mock.assert_called_once()

View File

@ -6,11 +6,21 @@ from ahriman.application.handlers import Sync
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
"""
args.target = []
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
""" """
must run command must run command
""" """
args.target = [] args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.sync") application_mock = mocker.patch("ahriman.application.application.Application.sync")

View File

@ -2,19 +2,30 @@ import argparse
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from ahriman.application.application import Application
from ahriman.application.handlers import Update from ahriman.application.handlers import Update
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: def _default_args(args: argparse.Namespace) -> argparse.Namespace:
""" """
must run command default arguments for these test cases
:param args: command line arguments fixture
:return: generated arguments for these test cases
""" """
args.package = [] args.package = []
args.dry_run = False args.dry_run = False
args.no_aur = False args.no_aur = False
args.no_manual = False args.no_manual = False
args.no_vcs = False args.no_vcs = False
return args
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
application_mock = mocker.patch("ahriman.application.application.Application.update") application_mock = mocker.patch("ahriman.application.application.Application.update")
updates_mock = mocker.patch("ahriman.application.application.Application.get_updates") updates_mock = mocker.patch("ahriman.application.application.Application.get_updates")
@ -28,13 +39,19 @@ def test_run_dry_run(args: argparse.Namespace, configuration: Configuration, moc
""" """
must run simplified command must run simplified command
""" """
args.package = [] args = _default_args(args)
args.dry_run = True args.dry_run = True
args.no_aur = False
args.no_manual = False
args.no_vcs = False
mocker.patch("pathlib.Path.mkdir") mocker.patch("pathlib.Path.mkdir")
updates_mock = mocker.patch("ahriman.application.application.Application.get_updates") updates_mock = mocker.patch("ahriman.application.application.Application.get_updates")
Update.run(args, "x86_64", configuration) Update.run(args, "x86_64", configuration)
updates_mock.assert_called_once() updates_mock.assert_called_once()
def test_log_fn(application: Application, mocker: MockerFixture) -> None:
"""
must print package updates
"""
logger_mock = mocker.patch("logging.Logger.info")
Update.log_fn(application, False)("hello")
logger_mock.assert_called_once()

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