Compare commits

...

128 Commits

Author SHA1 Message Date
9f3566a150 Release 1.2.4 2021-08-19 00:45:58 +03:00
16a6c4fdd7 include setup.cfg to tarball 2021-08-19 00:45:26 +03:00
91f66fdcee Release 1.2.3 2021-08-19 00:18:12 +03:00
bb45b1d868 split S3.sync to different methods 2021-08-18 23:59:18 +03:00
3d10fa472b guess mime type for local files 2021-08-18 05:04:26 +03:00
a90c93bbc4 add manpage generator 2021-08-17 04:05:18 +03:00
41a3c08d9f Release 1.2.2 2021-08-17 01:03:04 +03:00
cb328ad797 fix typo in log naming 2021-08-17 01:02:42 +03:00
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
63dc43366b Release 0.16.0 2021-03-28 15:38:12 +03:00
74a244f06c Add tests (#1) (#5)
* add models tests (#1)

also replace single quote to double one to confort PEP docstring
+ move _check_output to class properties to make it available for
mocking

* alpm tests implementation

* try to replace os with pathlib

* update tests for pathlib

* fix includes glob and trim version from dependencies

* build_tools package tests

* repository component tests

* add sign tests

* complete status tests

* handle exceptions in actual_version calls

* complete core tests

* move configuration to root conftest

* application tests

* complete application tests

* change copyright to more generic one

* base web tests

* complete web tests

* complete testkit

also add argument parsers test
2021-03-28 15:30:51 +03:00
69499b2d0a split functions to handles package (#3) 2021-03-21 16:33:04 +03:00
475afe4e08 add ability to import packages from diretory (#2) 2021-03-21 15:29:30 +03:00
2f2d6c2b70 imply unsafe option for some commands (#4) 2021-03-21 15:22:06 +03:00
15e3d2500c add status command 2021-03-20 22:20:47 +03:00
3d74b1485a Release 0.15.0 2021-03-20 18:05:36 +03:00
413d3b7509 web service improvements
* load and save web service state to cache file
* disable web reporting to self
* restore console handler settings
* allow to redirect logs to stderr
* verbose http error logging
* update package status by group, not by single package
* split Repository class to several traits
* move json generators/readers to dataclasses
2021-03-20 18:01:57 +03:00
3e2fb7b4e6 group package updates by bases for correct reporting 2021-03-20 16:13:13 +03:00
71196dc58b add watcher cache support 2021-03-20 05:42:33 +03:00
e7736e985f add pylint integration & fix some pylint warnings 2021-03-19 05:07:41 +03:00
f929a552e8 drop unused ignore lines 2021-03-19 01:02:56 +03:00
2c7ef3471e do not print upload progress 2021-03-17 19:12:31 +03:00
47bb22b1f4 do not set package to unknown for known packages 2021-03-17 19:08:36 +03:00
5a340146bb add get requests and change HTTP OK to HTTP No Content 2021-03-17 05:20:20 +03:00
0937a9a4b5 add check target 2021-03-17 04:39:25 +03:00
310eba694f Release 0.14.1 2021-03-17 03:35:38 +03:00
22d2057c3a replace script by makefile 2021-03-17 03:35:16 +03:00
47fc5bca57 Release 0.14.0 2021-03-16 20:11:56 +03:00
d4222eca25 add dump config option, change all timestamp objects to int, check git
directory
2021-03-16 05:24:07 +03:00
b5046b787c some improvements
* handle exceptions in multiprocessing
* readme update
* safe logger handler implementation (uses either stderr or
  rotatingfiles)
* user UID check
2021-03-16 04:25:58 +03:00
75c0cc970e Release 0.13.0 2021-03-16 01:40:48 +03:00
504d57b2f5 more package propertieis 2021-03-16 01:39:16 +03:00
4c20d0241a add clean subcommand 2021-03-15 23:34:50 +03:00
db0a6bf34e smart fetch & vcs cache 2021-03-15 23:28:08 +03:00
8f5af7965e Release 0.12.2 2021-03-15 22:51:07 +03:00
f35278e978 styling (again) 2021-03-15 22:50:58 +03:00
a288986450 allow to run single command for multiple architectures at the same time 2021-03-15 04:57:10 +03:00
2cef540cc0 status bar to build status page 2021-03-15 04:43:10 +03:00
7fd22e9f16 Release 0.12.1 2021-03-15 03:58:25 +03:00
e2608db991 add route =/ 2021-03-15 03:58:17 +03:00
2a0311b042 Release 0.12.0 2021-03-15 03:55:40 +03:00
0fcb46afca add information about installed size 2021-03-15 03:54:52 +03:00
374b3febc8 handle service status 2021-03-15 03:37:05 +03:00
3e0b3cdbaa even aggressive autopep8 2021-03-15 02:35:57 +03:00
663e822a92 autopep8 run 2021-03-15 02:27:42 +03:00
d3e79120cb docstrings everywhere 2021-03-15 02:21:41 +03:00
67b97a64ea Release 0.11.7 2021-03-14 19:28:58 +03:00
7ace74af44 handle makedepends as optional 2021-03-14 19:28:29 +03:00
b7d481858d Release 0.11.6 2021-03-13 19:24:23 +03:00
f753563804 soft colours 2021-03-13 19:24:08 +03:00
4727894349 drop architecture coz it is always same 2021-03-13 17:12:36 +03:00
4b98b21a70 strict typing, change colors a bit, architecture depending lock 2021-03-13 16:57:58 +03:00
9410c521a1 Release 0.11.5 2021-03-13 05:18:44 +03:00
dd42cd0cd6 sort package list 2021-03-13 05:18:27 +03:00
50b409cd3e Release 0.11.4 2021-03-13 05:13:33 +03:00
356cd35c5f better templating 2021-03-13 05:12:53 +03:00
3405105dce pretty status html 2021-03-13 03:57:27 +03:00
189 changed files with 11760 additions and 1291 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-argparse-manpage python-pip && \
pip install -e .[web] && \
pip install -e .[check] && \
pip install -e .[s3] && \
pip install -e .[test] && \
make check tests"

2
.gitignore vendored
View File

@ -94,3 +94,5 @@ ENV/
.venv/
*.tar.xz
man/

607
.pylintrc Normal file
View File

@ -0,0 +1,607 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-whitelist=
# Specify a score threshold to be exceeded before program exits with error.
fail-under=10.0
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=0
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=
# Pickle collected data for later comparisons.
persistent=yes
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=print-statement,
parameter-unpacking,
unpacking-in-except,
old-raise-syntax,
backtick,
long-suffix,
old-ne-operator,
old-octal-literal,
import-star-module-level,
non-ascii-bytes-literal,
raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
apply-builtin,
basestring-builtin,
buffer-builtin,
cmp-builtin,
coerce-builtin,
execfile-builtin,
file-builtin,
long-builtin,
raw_input-builtin,
reduce-builtin,
standarderror-builtin,
unicode-builtin,
xrange-builtin,
coerce-method,
delslice-method,
getslice-method,
setslice-method,
no-absolute-import,
old-division,
dict-iter-method,
dict-view-method,
next-method-called,
metaclass-assignment,
indexing-exception,
raising-string,
reload-builtin,
oct-method,
hex-method,
nonzero-method,
cmp-method,
input-builtin,
round-builtin,
intern-builtin,
unichr-builtin,
map-builtin-not-iterating,
zip-builtin-not-iterating,
range-builtin-not-iterating,
filter-builtin-not-iterating,
using-cmp-argument,
eq-without-hash,
div-method,
idiv-method,
rdiv-method,
exception-message-attribute,
invalid-str-codec,
sys-max-int,
bad-python3-import,
deprecated-string-function,
deprecated-str-translate-call,
deprecated-itertools-function,
deprecated-types-field,
next-method-defined,
dict-items-not-iterating,
dict-keys-not-iterating,
dict-values-not-iterating,
deprecated-operator-function,
deprecated-urllib-function,
xreadlines-attribute,
deprecated-sys-function,
exception-escape,
comprehension-escape,
missing-module-docstring,
line-too-long,
no-name-in-module,
import-outside-toplevel,
invalid-name,
raise-missing-from,
wrong-import-order,
too-few-public-methods,
too-many-instance-attributes,
broad-except,
too-many-ancestors,
fixme,
too-many-arguments,
duplicate-code,
cyclic-import
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'error', 'warning', 'refactor', and 'convention'
# which contain the number of messages in each category, as well as 'statement'
# which is the total number of statements analyzed. This score is used by the
# global evaluation report (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
#msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
output-format=text
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style.
#class-attribute-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style.
#variable-rgx=
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# List of decorators that change the signature of a decorated function.
signature-mutators=
[SIMILARITIES]
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
# Minimum lines number of a similarity.
min-similarity-lines=4
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
#notes-rgx=
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it work,
# install the python-enchant package.
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains the private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to the private dictionary (see the
# --spelling-private-dict-file option) instead of raising a message.
spelling-store-unknown-words=no
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=optparse,tkinter.tix
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled).
ext-import-graph=
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled).
import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[DESIGN]
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement (see R0916).
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "BaseException, Exception".
overgeneral-exceptions=BaseException,
Exception

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
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
@ -18,9 +18,9 @@ libalpm and AUR related configuration.
* `repositories` - list of pacman repositories, space separated list of strings, 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.
* `build_command` - default build command, string, required.
@ -35,9 +35,9 @@ Base repository settings.
* `name` - repository name, 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).
* `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.
* `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.
* `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`.
### `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.
### `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.
* `port` - port to bind, int, optional.

57
Makefile Normal file
View File

@ -0,0 +1,57 @@
.PHONY: archive archive_directory archlinux check clean directory push tests version
.DEFAULT_GOAL := archlinux
PROJECT := ahriman
FILES := AUTHORS COPYING CONFIGURING.md README.md package src setup.cfg setup.py
TARGET_FILES := $(addprefix $(PROJECT)/, $(FILES))
IGNORE_FILES := package/archlinux src/.mypy_cache
$(TARGET_FILES) : $(addprefix $(PROJECT), %) : $(addprefix ., %) directory version
@cp -rp $< $@
archive: archive_directory
tar cJf "$(PROJECT)-$(VERSION)-src.tar.xz" "$(PROJECT)"
rm -rf "$(PROJECT)"
archive_directory: $(TARGET_FILES)
rm -fr $(addprefix $(PROJECT)/, $(IGNORE_FILES))
find "$(PROJECT)" -type f -name "*.pyc" -delete
find "$(PROJECT)" -depth -type d -name "__pycache__" -execdir rm -rf {} +
find "$(PROJECT)" -depth -type d -name "*.egg-info" -execdir rm -rf {} +
archlinux: archive
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
check: clean mypy
autopep8 --exit-code --max-line-length 120 -aa -i -j 0 -r "src/$(PROJECT)" "tests/$(PROJECT)"
pylint --rcfile=.pylintrc "src/$(PROJECT)"
bandit -c .bandit.yml -r "src/$(PROJECT)"
bandit -c .bandit-test.yml -r "tests/$(PROJECT)"
clean:
find . -type f -name "$(PROJECT)-*-src.tar.xz" -delete
rm -rf "$(PROJECT)"
directory: clean
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
git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $(VERSION)"
git tag "$(VERSION)"
git push
git push --tags
tests: clean
python setup.py test
version:
ifndef VERSION
$(error VERSION is required, but not set)
endif
sed -i '/__version__ = "[0-9.]*/s/[^"][^)]*/__version__ = "$(VERSION)"/' src/ahriman/version.py

View File

@ -1,5 +1,8 @@
# 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)
[![CodeFactor](https://www.codefactor.io/repository/github/arcan1s/ahriman/badge)](https://www.codefactor.io/repository/github/arcan1s/ahriman)
Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts).
## Features
@ -15,13 +18,56 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
## Installation and run
* Install package as usual.
* Change settings if required, see `CONFIGURING.md` for more details.
* Create `/var/lib/ahriman/.makepkg.conf` with `makepkg.conf` overrides if required (at least you might want to set `PACKAGER`).
* Configure build tools (it might be required if your package will use any custom repositories):
* create build command, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/custom-x86_64-build` (you can choose any name for command);
* create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,custom}.conf`;
* Change settings if required, see [CONFIGURING](CONFIGURING.md) for more details.
* Create `/var/lib/ahriman/.makepkg.conf` with `makepkg.conf` overrides if required (at least you might want to set `PACKAGER`):
```shell
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):
* 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`);
* change configuration file, add your own repository, add multilib repository etc;
* set `build.build_command` setting to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow running command without password.
* Start and enable `ahriman.timer` via `systemctl`.
* Add packages by using `ahriman add {package}` command.
* set `build_command` option to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow running command without a password.
```shell
ln -s /usr/bin/archbuild /usr/local/bin/ahriman-x86_64-build
cp /usr/share/devtools/pacman-{extra,ahriman}.conf
echo '[multilib]' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'Include = /etc/pacman.d/mirrorlist' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo '[aur-clone]' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'SigLevel = Optional TrustAll' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'Server = file:///var/lib/ahriman/repository/$arch' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo '[build]' | tee -a /etc/ahriman.ini.d/build.ini
echo 'build_command = ahriman-x86_64-build' | tee -a /etc/ahriman.ini.d/build.ini
echo 'Cmnd_Alias CARCHBUILD_CMD = /usr/local/bin/ahriman-x86_64-build *' | tee -a /etc/sudoers.d/ahriman
echo 'ahriman ALL=(ALL) NOPASSWD: CARCHBUILD_CMD' | tee -a /etc/sudoers.d/ahriman
chmod 400 /etc/sudoers.d/ahriman
```
* Start and enable `ahriman@.timer` via `systemctl`:
```shell
systemctl enable --now ahriman@x86_64.timer
```
* Start and enable status page:
```shell
systemctl enable --now ahriman-web@x86_64
```
* Add packages by using `ahriman add {package}` command:
```shell
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,35 +0,0 @@
#!/bin/bash
set -e
VERSION="$1"
ARCHIVE="ahriman"
FILES="COPYING CONFIGURING.md README.md package src setup.py"
IGNORELIST="build .idea package/archlinux package/*src.tar.xz"
# set version
sed -i "/__version__ = '[0-9.]*/s/[^'][^)]*/__version__ = '$VERSION'/" src/ahriman/version.py
# create archive
[[ -e $ARCHIVE-$VERSION-src.tar.xz ]] && rm -f "$ARCHIVE-$VERSION-src.tar.xz"
[[ -d $ARCHIVE ]] && rm -rf "$ARCHIVE"
mkdir "$ARCHIVE"
for FILE in ${FILES[*]}; do cp -r "$FILE" "$ARCHIVE"; done
for FILE in ${IGNORELIST[*]}; do rm -rf "$ARCHIVE/$FILE"; done
tar cJf "$ARCHIVE-$VERSION-src.tar.xz" "$ARCHIVE"
rm -rf "$ARCHIVE"
# update checksums
SHA512SUMS=$(sha512sum $ARCHIVE-$VERSION-src.tar.xz | awk '{print $1}')
sed -i "/sha512sums=('[0-9A-Fa-f]*/s/[^'][^)]*/sha512sums=('$SHA512SUMS'/" package/archlinux/PKGBUILD
sed -i "s/pkgver=[0-9.]*/pkgver=$VERSION/" package/archlinux/PKGBUILD
# clear
find . -type f -name '*src.tar.xz' -not -name "*$VERSION-src.tar.xz" -exec rm -f {} \;
read -p "Publish release? [Ny] " -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $VERSION" && git push
git tag "$VERSION" && git push --tags
fi

View File

@ -1,31 +1,27 @@
# Maintainer: Evgeniy Alekseev
pkgname='ahriman'
pkgver=0.11.3
pkgver=1.2.4
pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager"
arch=('any')
url="https://github.com/arcan1s/ahriman"
license=('GPL3')
depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
makedepends=('python-pip')
optdepends=('aws-cli: sync to s3'
'breezy: -bzr packages support'
makedepends=('python-argparse-manpage' 'python-pip')
optdepends=('breezy: -bzr packages support'
'darcs: -darcs packages support'
'gnupg: package and repository sign'
'mercurial: -hg packages support'
'python-aiohttp: web server'
'python-aiohttp-jinja2: web server'
'python-boto3: sync to s3'
'python-jinja: html report generation'
'python-requests: web server'
'rsync: sync by using rsync'
'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sysusers'
'ahriman.tmpfiles')
sha512sums=('ef3c5bf73f95a87f226bc7ca90cee990abf8e7d7dacf56ce5ab346e689019a414a1ffe186a38c75bbf2c9fcc7e04822455e704c67fe2e125c5883dee8066f4bc'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini'
'etc/ahriman.ini.d/logging.ini')
@ -43,3 +39,7 @@ package() {
install -Dm644 "$srcdir/$pkgname.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgname.conf"
install -Dm644 "$srcdir/$pkgname.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/$pkgname.conf"
}
sha512sums=('6ab741bfb42f92ab00d1b6ecfc44426c00e5c433486e014efbdb585715d9a12dbbafc280e5a9f85b941c8681b13a9dad41327a3e3c44a9683ae30c1d6f017f50'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')

View File

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

View File

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

View File

@ -2,58 +2,68 @@
keys = root,builder,build_details,http
[handlers]
keys = console_handler,build_file_handler,file_handler,http_handler
keys = console_handler,build_file_handler,file_handler,http_handler,syslog_handler
[formatters]
keys = generic_format
keys = generic_format,syslog_format
[handler_console_handler]
class = StreamHandler
level = DEBUG
formatter = generic_format
args = (sys.stdout,)
args = (sys.stderr,)
[handler_file_handler]
class = logging.handlers.RotatingFileHandler
level = DEBUG
formatter = generic_format
args = ('/var/log/ahriman/ahriman.log', 'a', 20971520, 20)
args = ("/var/log/ahriman/ahriman.log", "a", 20971520, 20)
[handler_build_file_handler]
class = logging.handlers.RotatingFileHandler
level = DEBUG
formatter = generic_format
args = ('/var/log/ahriman/build.log', 'a', 20971520, 20)
args = ("/var/log/ahriman/build.log", "a", 20971520, 20)
[handler_http_handler]
class = logging.handlers.RotatingFileHandler
level = DEBUG
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.SysLogHandler
level = DEBUG
formatter = syslog_format
args = ("/dev/log",)
[formatter_generic_format]
format = %(asctime)s : %(levelname)s : %(funcName)s : %(message)s
format = [%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt =
[formatter_syslog_format]
format = [%(levelname)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s
datefmt =
[logger_root]
level = DEBUG
handlers = file_handler
handlers = syslog_handler
qualname = root
[logger_builder]
level = DEBUG
handlers = file_handler
handlers = syslog_handler
qualname = builder
propagate = 0
[logger_build_details]
level = DEBUG
handlers = build_file_handler
handlers = syslog_handler
qualname = build_details
propagate = 0
[logger_http]
level = DEBUG
handlers = http_handler
handlers = syslog_handler
qualname = http
propagate = 0

View File

@ -1,86 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
<title>{{ repository }}</title>
<style>
:root {
--color-building: 250, 255, 146;
--color-failed: 255, 94, 94;
--color-pending: 250, 255, 146;
--color-success: 121, 255, 94;
--color-unknown: 197, 197, 197;
}
{% include "style.jinja2" %}
@keyframes blink-building {
0% { background-color: rgba(var(--color-building), 1.0); }
10% { background-color: rgba(var(--color-building), 0.9); }
20% { background-color: rgba(var(--color-building), 0.8); }
30% { background-color: rgba(var(--color-building), 0.7); }
40% { background-color: rgba(var(--color-building), 0.6); }
50% { background-color: rgba(var(--color-building), 0.5); }
60% { background-color: rgba(var(--color-building), 0.4); }
70% { background-color: rgba(var(--color-building), 0.3); }
80% { background-color: rgba(var(--color-building), 0.2); }
90% { background-color: rgba(var(--color-building), 0.1); }
100% { background-color: rgba(var(--color-building), 0.0); }
}
table, th, td {
padding: 5px;
}
td.package {
font-weight: bolder;
}
td.package-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
td.package-pending {
background-color: rgba(var(--color-pending), 1.0);
}
td.package-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
td.package-failed {
background-color: rgba(var(--color-failed), 1.0);
}
td.package-success {
background-color: rgba(var(--color-success), 1.0);
}
</style>
<script src="https://www.kryogenix.org/code/browser/sorttable/sorttable.js"></script>
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<h1>ahriman {{ version|e }}</h1>
<div class="root">
<h1>ahriman
<img src="https://img.shields.io/badge/version-{{ version }}-informational" alt="{{ version }}">
<img src="https://img.shields.io/badge/architecture-{{ architecture }}-informational" alt="{{ architecture }}">
<img src="https://img.shields.io/badge/service%20status-{{ service.status }}-{{ service.status_color }}" alt="{{ service.status }}" title="{{ service.timestamp }}">
</h1>
<table class="sortable" id="builds">
<tr>
<th>package base</th>
<th>packages</th>
<th>version</th>
<th>architecture</th>
<th>timestamp</th>
<th>status</th>
</tr>
{% include "search-line.jinja2" %}
{% for package in packages %}
<tr>
<td class="package"><a href="{{ package.web_url|e }}" title="{{ package.base|e }}">{{ package.base|e }}</a></td>
<td>{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.version|e }}</td>
<td>{{ architecture|e }}</td>
<td>{{ package.timestamp|e }}</td>
<td class="package-{{ package.status|e }}">{{ package.status|e }}</td>
</tr>
{% endfor %}
</table>
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package base</th>
<th>packages</th>
<th>version</th>
<th>last update</th>
<th>status</th>
</tr>
{% for package in packages %}
<tr class="package">
<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>{{ package.version }}</td>
<td>{{ package.timestamp }}</td>
<td class="status package-{{ package.status }}">{{ package.status }}</td>
</tr>
{% endfor %}
</table>
</section>
<footer>
<ul class="navigation">
<li><a href="https://github.com/arcan1s/ahriman" title="sources">ahriman</a></li>
<li><a href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
</ul>
</footer>
</div>
</body>
</html>

View File

@ -1,32 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
<title>{{ repository }}</title>
{% include "style.jinja2" %}
{% if extended_report %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
{% endif %}
</head>
<body>
<h1>Archlinux custom repository</h1>
<div class="root">
{% if extended_report %}
<h1>Archlinux user repository</h1>
{% 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 }}" title="key search">{{ pgp_key|e }}</a>.</p>
{% endif %}
<section class="element">
{% if pgp_key is not none %}
<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 %}
<code>
$ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br>
Server = {{ link_path|e }}<br>
SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Package{% if has_package_signed %}Required{% else %}Never{% endif %} TrustedOnly
</code>
<code>
$ cat /etc/pacman.conf<br>
[{{ repository }}]<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
</code>
</section>
{% include "search-line.jinja2" %}
{% endif %}
<p>Packages:</p>
<ul>
{% for package, package_url in packages.items() %}
<li><a href="{{ package_url|e }}" title="{{ package|e }}">{{ package|e }}</a></li>
{% endfor %}
</ul>
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package</th>
<th>version</th>
<th>archive size</th>
<th>installed size</th>
<th>build date</th>
</tr>
{% if homepage is not none %}
<footer><a href="{{ homepage|e }}" title="homepage">Homepage</a></footer>
{% endif %}
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ link_path }}/{{ package.filename }}" title="{{ package.name }}">{{ package.name }}</a></td>
<td>{{ package.version }}</td>
<td>{{ package.archive_size }}</td>
<td>{{ package.installed_size }}</td>
<td>{{ package.build_date }}</td>
</tr>
{% endfor %}
</table>
</section>
{% if extended_report %}
<footer>
<ul class="navigation">
{% if homepage is not none %}
<li><a href="{{ homepage }}" title="homepage">Homepage</a></li>
{% endif %}
</ul>
</footer>
{% endif %}
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,136 @@
<style>
:root {
--color-building: 255, 255, 146;
--color-failed: 255, 94, 94;
--color-pending: 255, 255, 146;
--color-success: 94, 255, 94;
--color-unknown: 225, 225, 225;
--color-header: 200, 200, 255;
--color-hover: 255, 255, 225;
--color-line-blue: 235, 235, 255;
--color-line-white: 255, 255, 255;
}
@keyframes blink-building {
0% { background-color: rgba(var(--color-building), 1.0); }
10% { background-color: rgba(var(--color-building), 0.9); }
20% { background-color: rgba(var(--color-building), 0.8); }
30% { background-color: rgba(var(--color-building), 0.7); }
40% { background-color: rgba(var(--color-building), 0.6); }
50% { background-color: rgba(var(--color-building), 0.5); }
60% { background-color: rgba(var(--color-building), 0.4); }
70% { background-color: rgba(var(--color-building), 0.3); }
80% { background-color: rgba(var(--color-building), 0.2); }
90% { background-color: rgba(var(--color-building), 0.1); }
100% { background-color: rgba(var(--color-building), 0.0); }
}
div.root {
width: 70%;
padding: 15px 15% 0;
}
section.element, footer {
width: 100%;
padding: 10px 0;
}
code, input, table {
width: inherit;
}
th, td {
padding: 5px;
}
tr.package:nth-child(odd) {
background-color: rgba(var(--color-line-white), 1.0);
}
tr.package:nth-child(even) {
background-color: rgba(var(--color-line-blue), 1.0);
}
tr.package:hover {
background-color: rgba(var(--color-hover), 1.0);
}
tr.header{
background-color: rgba(var(--color-header), 1.0);
}
td.status {
text-align: center;
}
td.package-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
td.package-pending {
background-color: rgba(var(--color-pending), 1.0);
}
td.package-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
td.package-failed {
background-color: rgba(var(--color-failed), 1.0);
}
td.package-success {
background-color: rgba(var(--color-success), 1.0);
}
li.service-unknown {
background-color: rgba(var(--color-unknown), 1.0);
}
li.service-building {
background-color: rgba(var(--color-building), 1.0);
animation-name: blink-building;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-direction: alternate;
}
li.service-failed {
background-color: rgba(var(--color-failed), 1.0);
}
li.service-success {
background-color: rgba(var(--color-success), 1.0);
}
ul.navigation {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: rgba(var(--color-header), 1.0);
}
ul.navigation li {
float: left;
}
ul.navigation li.status {
display: block;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a {
display: block;
color: black;
text-align: center;
text-decoration: none;
padding: 14px 16px;
}
ul.navigation li a:hover {
background-color: rgba(var(--color-hover), 1.0);
}
</style>

8
setup.cfg Normal file
View File

@ -0,0 +1,8 @@
[aliases]
test = pytest
[tool:pytest]
addopts = --cov=ahriman --cov-report term-missing:skip-covered --pspec
[build_manpages]
manpages = man/ahriman.1:module=ahriman.application.ahriman:function=_parser

112
setup.py
View File

@ -1,68 +1,110 @@
from distutils.util import convert_path
from build_manpages.build_manpages import build_manpages, get_build_py_cmd, get_install_cmd
from pathlib import Path
from setuptools import setup, find_packages
from os import path
from setuptools.command.build_py import build_py
from setuptools.command.install import install
from typing import Any, Dict
metadata_path = Path(__file__).resolve().parent / "src/ahriman/version.py"
metadata: Dict[str, Any] = dict()
with metadata_path.open() as metadata_file:
exec(metadata_file.read(), metadata) # pylint: disable=exec-used
here = path.abspath(path.dirname(__file__))
metadata = dict()
with open(convert_path('src/ahriman/version.py')) as metadata_file:
exec(metadata_file.read(), metadata)
setup(
name='ahriman',
name="ahriman",
version=metadata['__version__'],
version=metadata["__version__"],
zip_safe=False,
description='ArcHlinux ReposItory MANager',
description="ArcHlinux ReposItory MANager",
author='arcanis',
author_email='',
url='https://github.com/arcan1s/ahriman',
author="arcanis",
author_email="",
url="https://github.com/arcan1s/ahriman",
license='GPL3',
license="GPL3",
packages=find_packages('src'),
package_dir={'': 'src'},
packages=find_packages("src"),
package_dir={"": "src"},
dependency_links=[
],
install_requires=[
'aur',
'pyalpm',
'srcinfo',
"aur",
"pyalpm",
"requests",
"srcinfo",
],
setup_requires=[
'pytest-runner',
"pytest-runner",
],
tests_require=[
'pytest',
"pytest",
"pytest-aiohttp",
"pytest-cov",
"pytest-helpers-namespace",
"pytest-mock",
"pytest-pspec",
"pytest-resource-path",
],
include_package_data=True,
scripts=[
'package/bin/ahriman',
"package/bin/ahriman",
],
data_files=[
('/etc', [
'package/etc/ahriman.ini',
("/etc", [
"package/etc/ahriman.ini",
]),
('/etc/ahriman.ini.d', [
'package/etc/ahriman.ini.d/logging.ini',
("/etc/ahriman.ini.d", [
"package/etc/ahriman.ini.d/logging.ini",
]),
('lib/systemd/system', [
'package/lib/systemd/system/ahriman@.service',
'package/lib/systemd/system/ahriman@.timer',
'package/lib/systemd/system/ahriman-web@.service',
("lib/systemd/system", [
"package/lib/systemd/system/ahriman@.service",
"package/lib/systemd/system/ahriman@.timer",
"package/lib/systemd/system/ahriman-web@.service",
]),
('share/ahriman', [
'package/share/ahriman/build-status.jinja2',
'package/share/ahriman/repo-index.jinja2',
("share/ahriman", [
"package/share/ahriman/build-status.jinja2",
"package/share/ahriman/repo-index.jinja2",
"package/share/ahriman/search.jinja2",
"package/share/ahriman/search-line.jinja2",
"package/share/ahriman/sorttable.jinja2",
"package/share/ahriman/style.jinja2",
]),
],
extras_require={
'html-templates': ['Jinja2'],
'test': ['coverage', 'pytest'],
'web': ['Jinja2', 'aiohttp', 'aiohttp_jinja2', 'requests'],
"check": [
"autopep8",
"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",
],
},
cmdclass={
"build_manpages": build_manpages,
"build_py": get_build_py_cmd(build_py),
"install": get_install_cmd(install),
}
)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -18,100 +18,330 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import sys
import tempfile
import ahriman.version as version
from pathlib import Path
from ahriman.application.application import Application
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration
from ahriman import version
from ahriman.application import handlers
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.sign_settings import SignSettings
def add(args: argparse.Namespace) -> None:
Application.from_args(args).add(args.package, args.without_dependencies)
# pylint thinks it is bad idea, but get the fuck off
SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access
def rebuild(args: argparse.Namespace) -> None:
app = Application.from_args(args)
packages = app.repository.packages()
app.update(packages)
def _parser() -> argparse.ArgumentParser:
"""
command line parser generator
:return: command line parser for the application
"""
parser = argparse.ArgumentParser(prog="ahriman", description="ArcHlinux ReposItory MANager",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-a", "--architecture", help="target architectures (can be used multiple times)",
action="append")
parser.add_argument("-c", "--configuration", help="configuration path", type=Path, default=Path("/etc/ahriman.ini"))
parser.add_argument("--force", help="force run, remove file lock", action="store_true")
parser.add_argument(
"-l",
"--lock",
help="lock file",
type=Path,
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-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("-v", "--version", action="version", version=version.__version__)
subparsers = parser.add_subparsers(title="command", help="command to run", dest="command", required=True)
_set_add_parser(subparsers)
_set_check_parser(subparsers)
_set_clean_parser(subparsers)
_set_config_parser(subparsers)
_set_init_parser(subparsers)
_set_key_import_parser(subparsers)
_set_rebuild_parser(subparsers)
_set_remove_parser(subparsers)
_set_report_parser(subparsers)
_set_search_parser(subparsers)
_set_setup_parser(subparsers)
_set_sign_parser(subparsers)
_set_status_parser(subparsers)
_set_status_update_parser(subparsers)
_set_sync_parser(subparsers)
_set_update_parser(subparsers)
_set_web_parser(subparsers)
return parser
def remove(args: argparse.Namespace) -> None:
Application.from_args(args).remove(args.package)
def _set_add_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
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
def report(args: argparse.Namespace) -> None:
Application.from_args(args).report(args.target)
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 sync(args: argparse.Namespace) -> None:
Application.from_args(args).sync(args.target)
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 update(args: argparse.Namespace) -> None:
app = Application.from_args(args)
log_fn = lambda line: print(line) if args.dry_run else app.logger.info(line)
packages = app.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, log_fn)
if args.dry_run:
return
app.update(packages)
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 web(args: argparse.Namespace) -> None:
from ahriman.web.web import run_server, setup_service
config = Configuration.from_path(args.config)
app = setup_service(args.architecture, config)
run_server(app, args.architecture)
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
if __name__ == '__main__':
parser = argparse.ArgumentParser(prog='ahriman', description='ArcHlinux ReposItory MANager')
parser.add_argument('-a', '--architecture', help='target architecture', 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('--lock', help='lock file', default='/tmp/ahriman.lock')
parser.add_argument('-v', '--version', action='version', version=version.__version__)
subparsers = parser.add_subparsers(title='command')
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
add_parser = subparsers.add_parser('add', description='add package')
add_parser.add_argument('package', help='package name or archive path', nargs='+')
add_parser.add_argument('--without-dependencies', help='do not add dependencies', action='store_true')
add_parser.set_defaults(fn=add)
check_parser = subparsers.add_parser('check', description='check for updates')
check_parser.add_argument('package', help='filter check by packages', nargs='*')
check_parser.set_defaults(fn=update, no_aur=False, no_manual=True, no_vcs=False, dry_run=True)
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
rebuild_parser = subparsers.add_parser('rebuild', description='rebuild whole repository')
rebuild_parser.set_defaults(fn=rebuild)
remove_parser = subparsers.add_parser('remove', description='remove package')
remove_parser.add_argument('package', help='package name', nargs='+')
remove_parser.set_defaults(fn=remove)
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
report_parser = subparsers.add_parser('report', description='generate report')
report_parser.add_argument('target', help='target to generate report', nargs='*')
report_parser.set_defaults(fn=report)
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(fn=sync)
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
update_parser = subparsers.add_parser('update', description='run updates')
update_parser.add_argument('package', help='filter check by packages', 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', 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(fn=update)
web_parser = subparsers.add_parser('web', description='start web server')
web_parser.set_defaults(fn=web, lock=None)
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
args = parser.parse_args()
if 'fn' not in args:
parser.print_help()
exit(1)
with Lock(args.lock, args.force):
args.fn(args)
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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,49 +17,72 @@
# 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
import argparse
import logging
import os
import shutil
from typing import Callable, Iterable, List, Optional, Set, Type
from pathlib import Path
from typing import Callable, Iterable, List, Set
from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.core.repository.repository import Repository
from ahriman.core.tree import Tree
from ahriman.core.util import package_like
from ahriman.models.package import Package
class Application:
"""
base application class
:ivar architecture: repository architecture
:ivar configuration: configuration instance
:ivar logger: application logger
:ivar repository: repository instance
"""
def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('root')
self.config = config
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.logger = logging.getLogger("root")
self.configuration = configuration
self.architecture = architecture
self.repository = Repository(architecture, config)
self.repository = Repository(architecture, configuration)
@classmethod
def from_args(cls: Type[Application], args: argparse.Namespace) -> Application:
config = Configuration.from_path(args.config)
return cls(args.architecture, config)
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]:
known_packages = set()
"""
load packages from repository and pacman repositories
:return: list of known packages
"""
known_packages: Set[str] = set()
# local set
for package in self.repository.packages():
known_packages.update(package.packages)
for base in self.repository.packages():
for package, properties in base.packages.items():
known_packages.add(package)
known_packages.update(properties.provides)
known_packages.update(self.repository.pacman.all_packages())
return known_packages
def _finalize(self) -> None:
self.report()
self.sync()
def get_updates(self, filter_packages: List[str], no_aur: bool, no_manual: bool, no_vcs: bool,
log_fn: Callable[[str], None]) -> List[Package]:
"""
get list of packages to run update process
:param filter_packages: do not check every package just specified in the list
:param no_aur: do not check for aur updates
:param no_manual: do not check for manual updates
:param no_vcs: do not check VCS packages
:param log_fn: logger function to log updates
:return: list of out-of-dated packages
"""
updates = []
if not no_aur:
@ -68,64 +91,139 @@ class Application:
updates.extend(self.repository.updates_manual())
for package in updates:
log_fn(f'{package.base} = {package.version}')
log_fn(f"{package.base} = {package.version}")
return updates
def add(self, names: Iterable[str], without_dependencies: bool) -> None:
"""
add packages for the next build
:param names: list of package bases to add
:param without_dependencies: if set, dependency check will be disabled
"""
known_packages = self._known_packages()
def add_manual(name: str) -> str:
package = Package.load(name, self.repository.pacman, self.config.get('alpm', 'aur_url'))
path = os.path.join(self.repository.paths.manual, package.base)
def add_directory(path: Path) -> None:
for full_path in filter(package_like, path.iterdir()):
add_archive(full_path)
def add_manual(src: str) -> Path:
package = Package.load(src, self.repository.pacman, self.configuration.get("alpm", "aur_url"))
path = self.repository.paths.manual / package.base
Task.fetch(path, package.git_url)
return path
def add_archive(src: str) -> None:
dst = os.path.join(self.repository.paths.packages, os.path.basename(src))
def add_archive(src: Path) -> None:
dst = self.repository.paths.packages / src.name
shutil.move(src, dst)
def process_dependencies(path: str) -> None:
def process_dependencies(path: Path) -> None:
if without_dependencies:
return
dependencies = Package.dependencies(path)
self.add(dependencies.difference(known_packages), without_dependencies)
def process_single(name: str) -> None:
if not os.path.isfile(name):
path = add_manual(name)
process_dependencies(path)
def process_single(src: str) -> None:
maybe_path = Path(src)
if maybe_path.is_dir():
add_directory(maybe_path)
elif maybe_path.is_file():
add_archive(maybe_path)
else:
add_archive(name)
path = add_manual(src)
process_dependencies(path)
for name in names:
process_single(name)
def clean(self, no_build: bool, no_cache: bool, no_chroot: bool, no_manual: bool, no_packages: bool) -> None:
"""
run all clean methods. Warning: some functions might not be available under non-root
:param no_build: do not clear directory with package sources
:param no_cache: do not clear directory with package caches
:param no_chroot: do not clear build chroot
:param no_manual: do not clear directory with manually added packages
:param no_packages: do not clear directory with built packages
"""
if not no_build:
self.repository.clear_build()
if not no_cache:
self.repository.clear_cache()
if not no_chroot:
self.repository.clear_chroot()
if not no_manual:
self.repository.clear_manual()
if not no_packages:
self.repository.clear_packages()
def remove(self, names: Iterable[str]) -> None:
"""
remove packages from repository
:param names: list of packages (either base or name) to remove
"""
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
: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
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
: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
self.repository.process_sync(targets)
self.repository.process_sync(targets, built_packages)
def update(self, updates: Iterable[Package]) -> None:
def process_update(paths: Iterable[str]) -> None:
"""
run package updates
:param updates: list of packages to update
"""
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._finalize()
self._finalize(updated)
# process built packages
packages = self.repository.packages_built()
process_update(packages)
# process manual packages
tree = Tree()
tree.load(updates)
tree = Tree.load(updates)
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)
process_update(packages)
process_update(packages)

View File

@ -0,0 +1,37 @@
#
# 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 ahriman.application.handlers.handler import Handler
from ahriman.application.handlers.add import Add
from ahriman.application.handlers.clean import Clean
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.remove import Remove
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_update import StatusUpdate
from ahriman.application.handlers.sync import Sync
from ahriman.application.handlers.update import Update
from ahriman.application.handlers.web import Web

View File

@ -0,0 +1,48 @@
#
# 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 Add(Handler):
"""
add 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 = 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

@ -0,0 +1,43 @@
#
# 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 Clean(Handler):
"""
clean caches 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).clean(args.no_build, args.no_cache, args.no_chroot,
args.no_manual, args.no_packages)

View File

@ -0,0 +1,48 @@
#
# 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.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Dump(Handler):
"""
dump configuration handler
"""
_print = print
@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
"""
dump = configuration.dump()
for section, values in sorted(dump.items()):
Dump._print(f"[{section}]")
for key, value in sorted(values.items()):
Dump._print(f"{key} = {value}")
Dump._print()

View File

@ -0,0 +1,98 @@
#
# 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
import argparse
import logging
from multiprocessing import Pool
from typing import Set, Type
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import MissingArchitecture
from ahriman.models.repository_paths import RepositoryPaths
class Handler:
"""
base handler class for command callbacks
"""
@classmethod
def _call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool:
"""
additional function to wrap all calls for multiprocessing library
:param args: command line args
:param architecture: repository architecture
:return: True on success, False otherwise
"""
try:
configuration = Configuration.from_path(args.configuration, architecture, not args.no_log)
with Lock(args, architecture, configuration):
cls.run(args, architecture, configuration)
return True
except Exception:
logging.getLogger("root").exception("process exception")
return False
@classmethod
def execute(cls: Type[Handler], args: argparse.Namespace) -> int:
"""
execute function for all aru
:param args: command line args
:return: 0 on success, 1 otherwise
"""
architectures = cls.extract_architectures(args)
with Pool(len(architectures)) as pool:
result = pool.starmap(
cls._call, [(args, architecture) for architecture in architectures])
return 0 if all(result) else 1
@classmethod
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
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
raise NotImplementedError

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 Init(Handler):
"""
repository init 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.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

@ -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 Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Rebuild(Handler):
"""
make world 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
"""
depends_on = set(args.depends_on) if args.depends_on else None
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)

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 Remove(Handler):
"""
remove 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).remove(args.package)

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 Report(Handler):
"""
generate report 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).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

@ -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
from typing import Iterable, Tuple, Type
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler
from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus
from ahriman.models.package import Package
class Status(Handler):
"""
package status 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 = Application(architecture, configuration)
if args.ahriman:
ahriman = application.repository.reporter.get_self()
print(ahriman.pretty_print())
print()
if args.package:
packages: Iterable[Tuple[Package, BuildStatus]] = sum(
[application.repository.reporter.get(base) for base in args.package],
start=[])
else:
packages = application.repository.reporter.get(None)
for package, package_status in sorted(packages, key=lambda item: item[0].base):
print(package.pretty_print())
print(f"\t{package.version}")
print(f"\t{package_status.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

@ -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 Sync(Handler):
"""
remove sync 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).sync(args.target, [])

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/>.
#
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 Update(Handler):
"""
package 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
"""
application = Application(architecture, configuration)
packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs,
Update.log_fn(application, args.dry_run))
if args.dry_run:
return
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

@ -0,0 +1,43 @@
#
# 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.handlers.handler import Handler
from ahriman.core.configuration import Configuration
class Web(Handler):
"""
web server 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
"""
from ahriman.web.web import run_server, setup_service
application = setup_service(architecture, configuration)
run_server(application)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,42 +17,114 @@
# 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
import argparse
import logging
import os
from typing import Optional
from pathlib import Path
from types import TracebackType
from typing import Literal, Optional, Type
from ahriman.core.exceptions import DuplicateRun
from ahriman import version
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.core.status.client import Client
from ahriman.models.build_status import BuildStatusEnum
class Lock:
"""
wrapper for application lock file
:ivar force: remove lock file on start if any
:ivar path: path to lock file if any
:ivar reporter: build status reporter instance
:ivar root: repository root (i.e. ahriman home)
:ivar unsafe: skip user check
"""
def __init__(self, path: Optional[str], force: bool) -> None:
self.path = path
self.force = force
def __init__(self, args: argparse.Namespace, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param args: command line args
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.path = Path(f"{args.lock}_{architecture}") if args.lock is not None else None
self.force = args.force
self.unsafe = args.unsafe
def __enter__(self):
if self.force:
self.remove()
self.check()
self.root = Path(configuration.get("repository", "root"))
self.reporter = Client() if args.no_report else Client.load(configuration)
def __enter__(self) -> Lock:
"""
default workflow is the following:
check user UID
check if there is lock file
check web status watcher status
create lock file
report to web if enabled
"""
self.check_user()
self.check_version()
self.create()
self.reporter.update_self(BuildStatusEnum.Building)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.remove()
def __exit__(self, exc_type: Optional[Type[Exception]], exc_val: Optional[Exception],
exc_tb: TracebackType) -> Literal[False]:
"""
remove lock file when done
:param exc_type: exception type name if any
:param exc_val: exception raised if any
:param exc_tb: exception traceback if any
:return: always False (do not suppress any exception)
"""
self.clear()
status = BuildStatusEnum.Success if exc_val is None else BuildStatusEnum.Failed
self.reporter.update_self(status)
return False
def check(self) -> None:
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:
"""
check if current user is actually owner of ahriman root
"""
if self.unsafe:
return
current_uid = os.getuid()
root_uid = self.root.stat().st_uid
if current_uid != root_uid:
raise UnsafeRun(current_uid, root_uid)
def clear(self) -> None:
"""
remove lock file
"""
if self.path is None:
return
if os.path.exists(self.path):
raise DuplicateRun()
self.path.unlink(missing_ok=True)
def create(self) -> None:
"""
create lock file
"""
if self.path is None:
return
open(self.path, 'w').close()
def remove(self) -> None:
if self.path is None:
return
if os.path.exists(self.path):
os.remove(self.path)
try:
self.path.touch(exist_ok=self.force)
except FileExistsError:
raise DuplicateRun()

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,24 +17,38 @@
# 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 pyalpm import Handle
from typing import List, Set
from pyalpm import Handle # type: ignore
from typing import Set
from ahriman.core.configuration import Configuration
class Pacman:
"""
alpm wrapper
:ivar handle: pyalpm root `Handle`
"""
def __init__(self, config: Configuration) -> None:
root = config.get('alpm', 'root')
pacman_root = config.get('alpm', 'database')
self.handle = Handle(root, pacman_root)
for repository in config.getlist('alpm', 'repositories'):
def __init__(self, configuration: Configuration) -> None:
"""
default constructor
:param configuration: configuration instance
"""
root = configuration.get("alpm", "root")
pacman_root = configuration.getpath("alpm", "database")
self.handle = Handle(root, str(pacman_root))
for repository in configuration.getlist("alpm", "repositories"):
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
:return: list of package names
"""
result: Set[str] = set()
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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -18,8 +18,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import logging
import os
from pathlib import Path
from typing import List
from ahriman.core.exceptions import BuildFailed
@ -28,30 +28,69 @@ from ahriman.models.repository_paths import RepositoryPaths
class Repo:
"""
repo-add and repo-remove wrapper
:ivar logger: class logger
:ivar name: repository name
:ivar paths: repository paths instance
:ivar sign_args: additional args which have to be used to sign repository archive
"""
_check_output = check_output
def __init__(self, name: str, paths: RepositoryPaths, sign_args: List[str]) -> None:
self.logger = logging.getLogger('build_details')
"""
default constructor
:param name: repository name
:param paths: repository paths instance
:param sign_args: additional args which have to be used to sign repository archive
"""
self.logger = logging.getLogger("build_details")
self.name = name
self.paths = paths
self.sign_args = sign_args
@property
def repo_path(self) -> str:
return os.path.join(self.paths.repository, f'{self.name}.db.tar.gz')
def repo_path(self) -> Path:
"""
:return: path to repository database
"""
return self.paths.repository / f"{self.name}.db.tar.gz"
def add(self, path: str) -> None:
check_output(
'repo-add', *self.sign_args, '-R', self.repo_path, path,
exception=BuildFailed(path),
def add(self, path: Path) -> None:
"""
add new package to repository
:param path: path to archive to add
"""
Repo._check_output(
"repo-add", *self.sign_args, "-R", str(self.repo_path), str(path),
exception=BuildFailed(path.name),
cwd=self.paths.repository,
logger=self.logger)
def remove(self, prefix: str, package: str) -> None:
for fn in filter(lambda f: f.startswith(prefix), os.listdir(self.paths.repository)):
full_path = os.path.join(self.paths.repository, fn)
os.remove(full_path)
check_output(
'repo-remove', *self.sign_args, self.repo_path, package,
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:
"""
remove package from repository
:param package: package name to remove
:param filename: package filename to remove
"""
# remove package and signature (if any) from filesystem
for full_path in self.paths.repository.glob(f"{filename}*"):
full_path.unlink()
# remove package from registry
Repo._check_output(
"repo-remove", *self.sign_args, str(self.repo_path), package,
exception=BuildFailed(package),
cwd=self.paths.repository,
logger=self.logger)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,10 +17,10 @@
# 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 os
import logging
import shutil
from pathlib import Path
from typing import List, Optional
from ahriman.core.configuration import Configuration
@ -31,46 +31,96 @@ from ahriman.models.repository_paths import RepositoryPaths
class Task:
"""
base package build task
:ivar build_logger: logger for build process
:ivar logger: class logger
:ivar package: package definitions
:ivar paths: repository paths instance
"""
def __init__(self, package: Package, architecture: str, config: Configuration, paths: RepositoryPaths) -> None:
self.logger = logging.getLogger('builder')
self.build_logger = logging.getLogger('build_details')
_check_output = check_output
def __init__(self, package: Package, configuration: Configuration, paths: RepositoryPaths) -> None:
"""
default constructor
:param package: package definitions
:param configuration: configuration instance
:param paths: repository paths instance
"""
self.logger = logging.getLogger("builder")
self.build_logger = logging.getLogger("build_details")
self.package = package
self.paths = paths
section = config.get_section_name('build', architecture)
self.archbuild_flags = config.getlist(section, 'archbuild_flags')
self.build_command = config.get(section, 'build_command')
self.makepkg_flags = config.getlist(section, 'makepkg_flags')
self.makechrootpkg_flags = config.getlist(section, 'makechrootpkg_flags')
self.archbuild_flags = configuration.getlist("build", "archbuild_flags")
self.build_command = configuration.get("build", "build_command")
self.makepkg_flags = configuration.getlist("build", "makepkg_flags")
self.makechrootpkg_flags = configuration.getlist("build", "makechrootpkg_flags")
@property
def git_path(self) -> str:
return os.path.join(self.paths.sources, self.package.base)
def cache_path(self) -> Path:
"""
:return: path to cached packages
"""
return self.paths.cache / self.package.base
@property
def git_path(self) -> Path:
"""
:return: path to clone package from git
"""
return self.paths.sources / self.package.base
@staticmethod
def fetch(local: str, remote: str) -> None:
shutil.rmtree(local, ignore_errors=True) # remove in case if file exists
check_output('git', 'clone', remote, local, exception=None)
def fetch(local: Path, remote: str, branch: str = "master") -> None:
"""
either clone repository or update it to origin/`branch`
:param local: local path to fetch
:param remote: remote target (from where to fetch)
:param branch: branch name to checkout, master by default
"""
logger = logging.getLogger("build_details")
# local directory exists and there is .git directory
if (local / ".git").is_dir():
Task._check_output("git", "fetch", "origin", branch, exception=None, cwd=local, logger=logger)
else:
Task._check_output("git", "clone", remote, str(local), exception=None, logger=logger)
# and now force reset to our branch
Task._check_output("git", "checkout", "--force", branch, exception=None, cwd=local, logger=logger)
Task._check_output("git", "reset", "--hard", f"origin/{branch}", exception=None, cwd=local, logger=logger)
def build(self) -> List[str]:
cmd = [self.build_command, '-r', self.paths.chroot]
cmd.extend(self.archbuild_flags)
cmd.extend(['--'] + self.makechrootpkg_flags)
cmd.extend(['--'] + self.makepkg_flags)
self.logger.info(f'using {cmd} for {self.package.base}')
def build(self) -> List[Path]:
"""
run package build
:return: paths of produced packages
"""
command = [self.build_command, "-r", str(self.paths.chroot)]
command.extend(self.archbuild_flags)
command.extend(["--"] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags)
self.logger.info("using %s for %s", command, self.package.base)
check_output(
*cmd,
Task._check_output(
*command,
exception=BuildFailed(self.package.base),
cwd=self.git_path,
logger=self.build_logger)
# well it is not actually correct, but we can deal with it
return check_output('makepkg', '--packagelist',
exception=BuildFailed(self.package.base),
cwd=self.git_path).splitlines()
packages = Task._check_output("makepkg", "--packagelist",
exception=BuildFailed(self.package.base),
cwd=self.git_path,
logger=self.build_logger).splitlines()
return [Path(package) for package in packages]
def clone(self, path: Optional[str] = None) -> None:
def init(self, path: Optional[Path] = None) -> None:
"""
fetch package from git
:param path: optional local path to fetch. If not set default path will be used
"""
git_path = path or self.git_path
return Task.fetch(git_path, self.package.git_url)
if self.cache_path.is_dir():
# no need to clone whole repository, just copy from cache first
shutil.copytree(self.cache_path, git_path)
return self.fetch(git_path, self.package.git_url)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -20,51 +20,168 @@
from __future__ import annotations
import configparser
import os
import logging
from logging.config import fileConfig
from typing import List, Optional, Type
from pathlib import Path
from typing import Dict, List, Optional, Type
# built-in configparser extension
class Configuration(configparser.RawConfigParser):
"""
extension for built-in configuration parser
:ivar path: path to root configuration file
: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_LEVEL: default log level (in case of fallback)
"""
DEFAULT_LOG_FORMAT = "[%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s"
DEFAULT_LOG_LEVEL = logging.DEBUG
ARCHITECTURE_SPECIFIC_SECTIONS = ["build", "html", "rsync", "s3", "sign", "web"]
def __init__(self) -> None:
"""
default constructor. In the most cases must not be called directly
"""
configparser.RawConfigParser.__init__(self, allow_no_value=True)
self.path: Optional[str] = None
self.path: Optional[Path] = None
@property
def include(self) -> str:
return self.get('settings', 'include')
def include(self) -> Path:
"""
:return: path to directory with configuration includes
"""
return self.getpath("settings", "include")
@property
def logging_path(self) -> Path:
"""
:return: path to logging configuration
"""
return self.getpath("settings", "logging")
@classmethod
def from_path(cls: Type[Configuration], path: str) -> Configuration:
def from_path(cls: Type[Configuration], path: Path, architecture: str, logfile: bool) -> Configuration:
"""
constructor with full object initialization
:param path: path to root configuration file
:param architecture: repository architecture
:param logfile: use log file to output messages
:return: configuration instance
"""
config = cls()
config.load(path)
config.load_logging()
config.merge_sections(architecture)
config.load_logging(logfile)
return config
@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
:return: configuration dump for specific architecture
"""
return {
section: dict(self[section])
for section in self.sections()
}
def getlist(self, section: str, key: str) -> List[str]:
"""
get space separated string list option
:param section: section name
:param key: key name
:return: list of string if option is set, empty list otherwise
"""
raw = self.get(section, key, fallback=None)
if not raw: # empty string or none
return []
return raw.split()
def get_section_name(self, prefix: str, suffix: str) -> str:
probe = f'{prefix}_{suffix}'
return probe if self.has_section(probe) else prefix
def getpath(self, section: str, key: str) -> Path:
"""
helper to generate absolute configuration path for relative settings value
:param section: section name
:param key: key name
:return: absolute path according to current path configuration
"""
value = Path(self.get(section, key))
if self.path is None or value.is_absolute():
return value
return self.path.parent / value
def load(self, path: str) -> None:
def load(self, path: Path) -> None:
"""
fully load configuration
:param path: path to root configuration file
"""
self.path = path
self.read(self.path)
self.load_includes()
def load_includes(self) -> None:
"""
load configuration includes
"""
try:
for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(self.include))):
self.read(os.path.join(self.include, conf))
except (FileNotFoundError, configparser.NoOptionError):
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)
except (FileNotFoundError, configparser.NoOptionError, configparser.NoSectionError):
pass
def load_logging(self) -> None:
fileConfig(self.get('settings', 'logging'))
def load_logging(self, logfile: bool) -> None:
"""
setup logging settings from configuration
:param logfile: use log file to output messages
"""
def file_logger() -> None:
try:
path = self.logging_path
fileConfig(path)
except (FileNotFoundError, PermissionError):
console_logger()
logging.exception("could not create logfile, fallback to stderr")
def console_logger() -> None:
logging.basicConfig(filename=None, format=self.DEFAULT_LOG_FORMAT,
level=self.DEFAULT_LOG_LEVEL)
if logfile:
file_logger()
else:
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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -21,35 +21,125 @@ from typing import Any
class BuildFailed(Exception):
"""
base exception for failed builds
"""
def __init__(self, package: str) -> None:
Exception.__init__(self, f'Package {package} build failed, check logs for details')
"""
default constructor
:param package: package base raised exception
"""
Exception.__init__(self, f"Package {package} build failed, check logs for details")
class DuplicateRun(Exception):
"""
exception which will be raised if there is another application instance
"""
def __init__(self) -> None:
Exception.__init__(self, 'Another application instance is run')
"""
default constructor
"""
Exception.__init__(self, "Another application instance is run")
class InitializeException(Exception):
"""
base service initialization exception
"""
def __init__(self) -> None:
Exception.__init__(self, 'Could not load service')
"""
default constructor
"""
Exception.__init__(self, "Could not load service")
class InvalidOption(Exception):
"""
exception which will be raised on configuration errors
"""
def __init__(self, value: Any) -> None:
Exception.__init__(self, f'Invalid or unknown option value `{value}`')
"""
default constructor
:param value: option value
"""
Exception.__init__(self, f"Invalid or unknown option value `{value}`")
class InvalidPackageInfo(Exception):
"""
exception which will be raised on package load errors
"""
def __init__(self, details: Any) -> None:
Exception.__init__(self, f'There are errors during reading package information: `{details}`')
"""
default constructor
:param details: error 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):
"""
report generation exception
"""
def __init__(self) -> None:
Exception.__init__(self, 'Report failed')
"""
default constructor
"""
Exception.__init__(self, "Report failed")
class SyncFailed(Exception):
"""
remote synchronization exception
"""
def __init__(self) -> None:
Exception.__init__(self, 'Sync failed')
"""
default constructor
"""
Exception.__init__(self, "Sync failed")
class UnknownPackage(Exception):
"""
exception for status watcher which will be thrown on unknown package
"""
def __init__(self, base: str) -> None:
Exception.__init__(self, f"Package base {base} is unknown")
class UnsafeRun(Exception):
"""
exception which will be raised in case if user is not owner of repository
"""
def __init__(self, current_uid: int, root_uid: int) -> None:
"""
default constructor
"""
Exception.__init__(
self,
f"""Current UID {current_uid} differs from root owner {root_uid}.
Note that for the most actions it is unsafe to run application as different user.
If you are 100% sure that it must be there try --unsafe option""")

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,54 +17,36 @@
# 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
import os
from typing import Dict
from typing import 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 package_like
from ahriman.models.sign_settings import SignSettings
from ahriman.models.package import Package
class HTML(Report):
class HTML(Report, JinjaTemplate):
"""
html report generator
:ivar report_path: output path to html report
"""
def __init__(self, architecture: str, config: Configuration) -> None:
Report.__init__(self, architecture, config)
section = config.get_section_name('html', architecture)
self.report_path = config.get(section, 'path')
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, "html", configuration)
self.link_path = config.get(section, 'link_path')
self.template_path = config.get(section, 'template_path')
self.report_path = configuration.getpath("html", "path")
# base template vars
self.sign_targets = [SignSettings.from_option(opt) for opt in config.getlist('sign', 'target')]
self.pgp_key = config.get('sign', 'key', fallback=None)
self.homepage = config.get(section, 'homepage', fallback=None)
self.repository = config.get('repository', 'name')
def generate(self, path: str) -> None:
# idea comes from https://stackoverflow.com/a/38642558
templates_dir, template_name = os.path.split(self.template_path)
loader = jinja2.FileSystemLoader(searchpath=templates_dir)
environment = jinja2.Environment(loader=loader)
template = environment.get_template(template_name)
packages: Dict[str, str] = {}
for fn in sorted(os.listdir(path)):
if not package_like(fn):
continue
packages[fn] = f'{self.link_path}/{fn}'
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=packages,
pgp_key=self.pgp_key,
repository=self.repository)
with open(self.report_path, 'w') as out:
out.write(html)
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
"""
html = self.make_html(packages, True)
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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,34 +17,69 @@
# 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
import logging
from typing import Iterable, Type
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import ReportFailed
from ahriman.models.package import Package
from ahriman.models.report_settings import ReportSettings
class Report:
"""
base report generator
:ivar architecture: repository architecture
:ivar configuration: configuration instance
:ivar logger: class logger
"""
def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('builder')
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.logger = logging.getLogger("builder")
self.architecture = architecture
self.config = config
self.configuration = configuration
@staticmethod
def run(architecture: str, config: Configuration, target: str, path: str) -> None:
@classmethod
def load(cls: Type[Report], architecture: str, configuration: Configuration, target: str) -> Report:
"""
load client from settings
:param architecture: repository architecture
:param configuration: configuration instance
:param target: target to generate report (e.g. html)
:return: client according to current settings
"""
provider = ReportSettings.from_option(target)
if provider == ReportSettings.HTML:
from ahriman.core.report.html import HTML
report: Report = HTML(architecture, config)
else:
report = Report(architecture, config)
return HTML(architecture, configuration)
if provider == ReportSettings.Email:
from ahriman.core.report.email import Email
return Email(architecture, configuration)
return cls(architecture, configuration) # should never happen
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
"""
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:
report.generate(path)
self.generate(packages, built_packages)
except Exception:
report.logger.exception('report generation failed', exc_info=True)
self.logger.exception("report generation failed")
raise ReportFailed()
def generate(self, path: str) -> None:
pass

View File

@ -1,198 +0,0 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
#
# 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 logging
import os
import shutil
from typing import Dict, Iterable, List, Optional
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.repo import Repo
from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration
from ahriman.core.report.report import Report
from ahriman.core.sign.gpg import GPG
from ahriman.core.upload.uploader import Uploader
from ahriman.core.util import package_like
from ahriman.core.watcher.client import Client
from ahriman.models.package import Package
from ahriman.models.repository_paths import RepositoryPaths
class Repository:
def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('builder')
self.architecture = architecture
self.config = config
self.aur_url = config.get('alpm', 'aur_url')
self.name = config.get('repository', 'name')
self.paths = RepositoryPaths(config.get('repository', 'root'), architecture)
self.paths.create_tree()
self.pacman = Pacman(config)
self.sign = GPG(architecture, config)
self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args)
self.web = Client.load(architecture, config)
def _clear_build(self) -> None:
for package in os.listdir(self.paths.sources):
shutil.rmtree(os.path.join(self.paths.sources, package))
def _clear_manual(self) -> None:
for package in os.listdir(self.paths.manual):
shutil.rmtree(os.path.join(self.paths.manual, package))
def _clear_packages(self) -> None:
for package in self.packages_built():
os.remove(package)
def packages(self) -> List[Package]:
result: Dict[str, Package] = {}
for fn in os.listdir(self.paths.repository):
if not package_like(fn):
continue
full_path = os.path.join(self.paths.repository, fn)
try:
local = Package.load(full_path, self.pacman, self.aur_url)
result.setdefault(local.base, local).packages.update(local.packages)
except Exception:
self.logger.exception(f'could not load package from {fn}', exc_info=True)
continue
return list(result.values())
def packages_built(self) -> List[str]:
return [
os.path.join(self.paths.packages, fn)
for fn in os.listdir(self.paths.packages)
]
def process_build(self, updates: Iterable[Package]) -> List[str]:
def build_single(package: Package) -> None:
self.web.set_building(package.base)
task = Task(package, self.architecture, self.config, self.paths)
task.clone()
built = task.build()
for src in built:
dst = os.path.join(self.paths.packages, os.path.basename(src))
shutil.move(src, dst)
for package in updates:
try:
build_single(package)
except Exception:
self.web.set_failed(package.base)
self.logger.exception(f'{package.base} ({self.architecture}) build exception', exc_info=True)
continue
self._clear_build()
return self.packages_built()
def process_remove(self, packages: Iterable[str]) -> str:
def remove_single(package: str) -> None:
try:
self.repo.remove(package, package)
except Exception:
self.logger.exception(f'could not remove {package}', exc_info=True)
for local in self.packages():
if local.base in packages:
to_remove = local.packages
elif local.packages.intersection(packages):
to_remove = local.packages.intersection(packages)
else:
to_remove = set()
self.web.remove(local.base, to_remove)
for package in to_remove:
remove_single(package)
return self.repo.repo_path
def process_report(self, targets: Optional[Iterable[str]]) -> None:
if targets is None:
targets = self.config.getlist('report', 'target')
for target in targets:
Report.run(self.architecture, self.config, target, self.paths.repository)
def process_sync(self, targets: Optional[Iterable[str]]) -> None:
if targets is None:
targets = self.config.getlist('upload', 'target')
for target in targets:
Uploader.run(self.architecture, self.config, target, self.paths.repository)
def process_update(self, packages: Iterable[str]) -> str:
for package in packages:
local = Package.load(package, self.pacman, self.aur_url) # we will use it for status reports
try:
files = self.sign.sign_package(package, local.base)
for src in files:
dst = os.path.join(self.paths.repository, os.path.basename(src))
shutil.move(src, dst)
package_fn = os.path.join(self.paths.repository, os.path.basename(package))
self.repo.add(package_fn)
self.web.set_success(local)
except Exception:
self.logger.exception(f'could not process {package}', exc_info=True)
self.web.set_failed(local.base)
self._clear_packages()
return self.repo.repo_path
def updates_aur(self, filter_packages: Iterable[str], no_vcs: bool) -> 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():
if local.base in ignore_list:
continue
if local.is_vcs and no_vcs:
continue
if filter_packages and local.base not in filter_packages:
continue
try:
remote = Package.load(local.base, self.pacman, self.aur_url)
if local.is_outdated(remote):
result.append(remote)
self.web.set_pending(local.base)
except Exception:
self.web.set_failed(local.base)
self.logger.exception(f'could not load remote package {local.base}', exc_info=True)
continue
return result
def updates_manual(self) -> List[Package]:
result: List[Package] = []
for fn in os.listdir(self.paths.manual):
try:
local = Package.load(os.path.join(self.paths.manual, fn), self.pacman, self.aur_url)
result.append(local)
self.web.set_unknown(local)
except Exception:
self.logger.exception(f'could not add package from {fn}', exc_info=True)
self._clear_manual()
return result

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -0,0 +1,78 @@
#
# 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 shutil
from pathlib import Path
from typing import List
from ahriman.core.repository.properties import Properties
class Cleaner(Properties):
"""
trait to clean common repository objects
"""
def packages_built(self) -> List[Path]:
"""
get list of files in built packages directory
:return: list of filenames from the directory
"""
raise NotImplementedError
def clear_build(self) -> None:
"""
clear sources directory
"""
self.logger.info("clear package sources directory")
for package in self.paths.sources.iterdir():
shutil.rmtree(package)
def clear_cache(self) -> None:
"""
clear cache directory
"""
self.logger.info("clear packages sources cache directory")
for package in self.paths.cache.iterdir():
shutil.rmtree(package)
def clear_chroot(self) -> None:
"""
clear cache directory. Warning: this method is architecture independent and will clear every chroot
"""
self.logger.info("clear build chroot directory")
for chroot in self.paths.chroot.iterdir():
shutil.rmtree(chroot)
def clear_manual(self) -> None:
"""
clear directory with manual package updates
"""
self.logger.info("clear manual packages")
for package in self.paths.manual.iterdir():
shutil.rmtree(package)
def clear_packages(self) -> None:
"""
clear directory with built packages (NOT repository itself)
"""
self.logger.info("clear built packages directory")
for package in self.packages_built():
package.unlink()

View File

@ -0,0 +1,165 @@
#
# 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 shutil
from pathlib import Path
from typing import Dict, Iterable, List, Optional
from ahriman.core.build_tools.task import Task
from ahriman.core.report.report import Report
from ahriman.core.repository.cleaner import Cleaner
from ahriman.core.upload.upload import Upload
from ahriman.models.package import Package
class Executor(Cleaner):
"""
trait for common repository update processes
"""
def packages(self) -> List[Package]:
"""
generate list of repository packages
:return: list of packages properties
"""
raise NotImplementedError
def process_build(self, updates: Iterable[Package]) -> List[Path]:
"""
build packages
:param updates: list of packages properties to build
:return: `packages_built`
"""
def build_single(package: Package) -> None:
self.reporter.set_building(package.base)
task = Task(package, self.configuration, self.paths)
task.init()
built = task.build()
for src in built:
dst = self.paths.packages / src.name
shutil.move(src, dst)
for single in updates:
try:
build_single(single)
except Exception:
self.reporter.set_failed(single.base)
self.logger.exception("%s (%s) build exception", single.base, self.architecture)
self.clear_build()
return self.packages_built()
def process_remove(self, packages: Iterable[str]) -> Path:
"""
remove packages from list
:param packages: list of package names or bases to remove
:return: path to repository database
"""
def remove_single(package: str, fn: Path) -> None:
try:
self.repo.remove(package, fn)
except Exception:
self.logger.exception("could not remove %s", package)
requested = set(packages)
for local in self.packages():
if local.base in packages or all(package in requested for package in local.packages):
to_remove = {
package: Path(properties.filename)
for package, properties in local.packages.items()
if properties.filename is not None
}
self.reporter.remove(local.base) # we only update status page in case of base removal
elif requested.intersection(local.packages.keys()):
to_remove = {
package: Path(properties.filename)
for package, properties in local.packages.items()
if package in requested and properties.filename is not None
}
else:
to_remove = dict()
for package, filename in to_remove.items():
remove_single(package, filename)
return self.repo.repo_path
def process_report(self, targets: Optional[Iterable[str]], built_packages: Iterable[Package]) -> None:
"""
generate reports
: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:
targets = self.configuration.getlist("report", "target")
for target in targets:
runner = Report.load(self.architecture, self.configuration, target)
runner.run(self.packages(), built_packages)
def process_sync(self, targets: Optional[Iterable[str]], built_packages: Iterable[Package]) -> None:
"""
process synchronization to remote servers
: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:
targets = self.configuration.getlist("upload", "target")
for target in targets:
runner = Upload.load(self.architecture, self.configuration, target)
runner.run(self.paths.repository, built_packages)
def process_update(self, packages: Iterable[Path]) -> Path:
"""
sign packages, add them to repository and update repository database
:param packages: list of filenames to run
:return: path to repository database
"""
def update_single(fn: Optional[str], base: str) -> None:
if fn is None:
self.logger.warning("received empty package name for base %s", base)
return # suppress type checking, it never can be none actually
# in theory it might be NOT packages directory, but we suppose it is
full_path = self.paths.packages / fn
files = self.sign.sign_package(full_path, base)
for src in files:
dst = self.paths.repository / src.name
shutil.move(src, dst)
package_path = self.paths.repository / fn
self.repo.add(package_path)
# we are iterating over bases, not single packages
updates: Dict[str, Package] = {}
for filename in packages:
try:
local = Package.load(filename, self.pacman, self.aur_url)
updates.setdefault(local.base, local).packages.update(local.packages)
except Exception:
self.logger.exception("could not load package from %s", filename)
for local in updates.values():
try:
for description in local.packages.values():
update_single(description.filename, local.base)
self.reporter.set_success(local)
except Exception:
self.reporter.set_failed(local.base)
self.logger.exception("could not process %s", local.base)
self.clear_packages()
return self.repo.repo_path

View File

@ -0,0 +1,61 @@
#
# 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 logging
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.repo import Repo
from ahriman.core.configuration import Configuration
from ahriman.core.sign.gpg import GPG
from ahriman.core.status.client import Client
from ahriman.models.repository_paths import RepositoryPaths
class Properties:
"""
repository internal objects holder
:ivar architecture: repository architecture
:ivar aur_url: base AUR url
:ivar configuration: configuration instance
:ivar ignore_list: package bases which will be ignored during auto updates
:ivar logger: class logger
:ivar name: repository name
:ivar pacman: alpm wrapper instance
:ivar paths: repository paths instance
:ivar repo: repo commands wrapper instance
:ivar reporter: build status reporter instance
:ivar sign: GPG wrapper instance
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
self.logger = logging.getLogger("builder")
self.architecture = architecture
self.configuration = configuration
self.aur_url = configuration.get("alpm", "aur_url")
self.name = configuration.get("repository", "name")
self.paths = RepositoryPaths(configuration.getpath("repository", "root"), architecture)
self.paths.create_tree()
self.ignore_list = configuration.getlist("build", "ignore_packages")
self.pacman = Pacman(configuration)
self.sign = GPG(architecture, configuration)
self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args)
self.reporter = Client.load(configuration)

View File

@ -0,0 +1,54 @@
#
# 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 pathlib import Path
from typing import Dict, List
from ahriman.core.repository.executor import Executor
from ahriman.core.repository.update_handler import UpdateHandler
from ahriman.core.util import package_like
from ahriman.models.package import Package
class Repository(Executor, UpdateHandler):
"""
base repository control class
"""
def packages(self) -> List[Package]:
"""
generate list of repository packages
:return: list of packages properties
"""
result: Dict[str, Package] = {}
for full_path in filter(package_like, self.paths.repository.iterdir()):
try:
local = Package.load(full_path, self.pacman, self.aur_url)
result.setdefault(local.base, local).packages.update(local.packages)
except Exception:
self.logger.exception("could not load package from %s", full_path)
continue
return list(result.values())
def packages_built(self) -> List[Path]:
"""
get list of files in built packages directory
:return: list of filenames from the directory
"""
return list(filter(package_like, self.paths.packages.iterdir()))

View File

@ -0,0 +1,87 @@
#
# 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 typing import Iterable, List
from ahriman.core.repository.cleaner import Cleaner
from ahriman.models.package import Package
class UpdateHandler(Cleaner):
"""
trait to get package update list
"""
def packages(self) -> List[Package]:
"""
generate list of repository packages
:return: list of packages properties
"""
raise NotImplementedError
def updates_aur(self, filter_packages: Iterable[str], no_vcs: bool) -> List[Package]:
"""
check AUR for updates
:param filter_packages: do not check every package just specified in the list
:param no_vcs: do not check VCS packages
:return: list of packages which are out-of-dated
"""
result: List[Package] = []
for local in self.packages():
if local.base in self.ignore_list:
continue
if local.is_vcs and no_vcs:
continue
if filter_packages and local.base not in filter_packages:
continue
try:
remote = Package.load(local.base, self.pacman, self.aur_url)
if local.is_outdated(remote, self.paths):
self.reporter.set_pending(local.base)
result.append(remote)
except Exception:
self.reporter.set_failed(local.base)
self.logger.exception("could not load remote package %s", local.base)
continue
return result
def updates_manual(self) -> List[Package]:
"""
check for packages for which manual update has been requested
:return: list of packages which are out-of-dated
"""
result: List[Package] = []
known_bases = {package.base for package in self.packages()}
for fn in self.paths.manual.iterdir():
try:
local = Package.load(fn, self.pacman, self.aur_url)
result.append(local)
if local.base not in known_bases:
self.reporter.set_unknown(local)
else:
self.reporter.set_pending(local.base)
except Exception:
self.logger.exception("could not add package from %s", fn)
self.clear_manual()
return result

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -18,52 +18,144 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import logging
import os
import requests
from typing import List
from pathlib import Path
from typing import List, Optional, Set, Tuple
from ahriman.core.configuration import Configuration
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
class GPG:
"""
gnupg wrapper
:ivar architecture: repository architecture
:ivar configuration: configuration instance
:ivar default_key: default PGP key ID to use
:ivar logger: class logger
:ivar targets: list of targets to sign (repository, package etc)
"""
def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('build_details')
self.config = config
self.section = config.get_section_name('sign', architecture)
self.target = [SignSettings.from_option(opt) for opt in config.getlist(self.section, 'target')]
self.default_key = config.get(self.section, 'key') if self.target else ''
_check_output = check_output
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.logger = logging.getLogger("build_details")
self.architecture = architecture
self.configuration = configuration
self.targets, self.default_key = self.sign_options(configuration)
@property
def repository_sign_args(self) -> List[str]:
if SignSettings.SignRepository not in self.target:
"""
:return: command line arguments for repo-add command to sign database
"""
if SignSettings.Repository not in self.targets:
return []
return ['--sign', '--key', self.default_key]
if self.default_key is None:
self.logger.error("no default key set, skip repository sign")
return []
return ["--sign", "--key", self.default_key]
def process(self, path: str, key: str) -> List[str]:
check_output(
*self.sign_cmd(path, key),
exception=BuildFailed(path),
cwd=os.path.dirname(path),
@staticmethod
def sign_command(path: Path, key: str) -> List[str]:
"""
gpg command to run
:param path: path to file to sign
:param key: PGP key ID
:return: gpg command with all required arguments
"""
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]:
"""
gpg command wrapper
:param path: path to file to sign
:param key: PGP key ID
:return: list of generated files including original file
"""
GPG._check_output(
*GPG.sign_command(path, key),
exception=BuildFailed(path.name),
logger=self.logger)
return [path, f'{path}.sig']
return [path, path.parent / f"{path.name}.sig"]
def sign_cmd(self, path: str, key: str) -> List[str]:
cmd = ['gpg']
cmd.extend(['-u', key])
cmd.extend(['-b', path])
return cmd
def sign_package(self, path: str, base: str) -> List[str]:
if SignSettings.SignPackages not in self.target:
def sign_package(self, path: Path, base: str) -> List[Path]:
"""
sign package if required by configuration
:param path: path to file to sign
:param base: package base required to check for key overrides
:return: list of generated files including original file
"""
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]
key = self.config.get(self.section, f'key_{base}', fallback=self.default_key)
return self.process(path, key)
def sign_repository(self, path: str) -> List[str]:
if SignSettings.SignRepository not in self.target:
def sign_repository(self, path: Path) -> List[Path]:
"""
sign repository if required by configuration
:note: more likely you just want to pass `repository_sign_args` to repo wrapper
:param path: path to repository database
:return: list of generated files including original file
"""
if SignSettings.Repository not in self.targets:
return [path]
return self.process(path, self.default_key)
if self.default_key is None:
self.logger.error("no default key set, skip repository sign")
return [path]
return self.process(path, self.default_key)

View File

@ -0,0 +1,19 @@
#
# 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/>.
#

View File

@ -0,0 +1,131 @@
#
# 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 typing import List, Optional, Tuple, Type
from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.internal_status import InternalStatus
from ahriman.models.package import Package
class 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:
"""
add new package with status
:param package: package properties
:param status: current package build status
"""
def get(self, base: Optional[str]) -> List[Tuple[Package, BuildStatus]]: # pylint: disable=no-self-use
"""
get package status
:param base: package base to get
:return: list of current package description and status if it has been found
"""
del base
return []
def get_internal(self) -> InternalStatus: # pylint: disable=no-self-use
"""
get internal service status
:return: current internal (web) service status
"""
return InternalStatus()
def get_self(self) -> BuildStatus: # pylint: disable=no-self-use
"""
get ahriman status itself
:return: current ahriman status
"""
return BuildStatus()
def remove(self, base: str) -> None:
"""
remove packages from watcher
:param base: package base to remove
"""
def update(self, base: str, status: BuildStatusEnum) -> None:
"""
update package build status. Unlike `add` it does not update package properties
:param base: package base to update
:param status: current package build status
"""
def update_self(self, status: BuildStatusEnum) -> None:
"""
update ahriman status itself
:param status: current ahriman status
"""
def set_building(self, base: str) -> None:
"""
set package status to building
:param base: package base to update
"""
return self.update(base, BuildStatusEnum.Building)
def set_failed(self, base: str) -> None:
"""
set package status to failed
:param base: package base to update
"""
return self.update(base, BuildStatusEnum.Failed)
def set_pending(self, base: str) -> None:
"""
set package status to pending
:param base: package base to update
"""
return self.update(base, BuildStatusEnum.Pending)
def set_success(self, package: Package) -> None:
"""
set package status to success
:param package: current package properties
"""
return self.add(package, BuildStatusEnum.Success)
def set_unknown(self, package: Package) -> None:
"""
set package status to unknown
:param package: current package properties
"""
return self.add(package, BuildStatusEnum.Unknown)

View File

@ -0,0 +1,166 @@
#
# 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 json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import UnknownPackage
from ahriman.core.repository.repository import Repository
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package
class Watcher:
"""
package status watcher
:ivar architecture: repository architecture
:ivar known: list of known packages. For the most cases `packages` should be used instead
:ivar logger: class logger
:ivar repository: repository object
:ivar status: daemon status
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.logger = logging.getLogger("http")
self.architecture = architecture
self.repository = Repository(architecture, configuration)
self.known: Dict[str, Tuple[Package, BuildStatus]] = {}
self.status = BuildStatus()
@property
def cache_path(self) -> Path:
"""
:return: path to dump with json cache
"""
return self.repository.paths.root / "status_cache.json"
@property
def packages(self) -> List[Tuple[Package, BuildStatus]]:
"""
:return: list of packages together with their statuses
"""
return list(self.known.values())
def _cache_load(self) -> None:
"""
update current state from cache
"""
def parse_single(properties: Dict[str, Any]) -> None:
package = Package.from_json(properties["package"])
status = BuildStatus.from_json(properties["status"])
if package.base in self.known:
self.known[package.base] = (package, status)
if not self.cache_path.is_file():
return
with self.cache_path.open() as cache:
try:
dump = json.load(cache)
except Exception:
self.logger.exception("cannot parse json from file")
dump = {}
for item in dump.get("packages", []):
try:
parse_single(item)
except Exception:
self.logger.exception("cannot parse item %s to package", item)
def _cache_save(self) -> None:
"""
dump current cache to filesystem
"""
dump = {
"packages": [
{
"package": package.view(),
"status": status.view()
} for package, status in self.packages
]
}
try:
with self.cache_path.open("w") as cache:
json.dump(dump, cache)
except Exception:
self.logger.exception("cannot dump cache")
def get(self, base: str) -> Tuple[Package, BuildStatus]:
"""
get current package base build status
:return: package and its status
"""
try:
return self.known[base]
except KeyError:
raise UnknownPackage(base)
def load(self) -> None:
"""
load packages from local repository. In case if last status is known, it will use it
"""
for package in self.repository.packages():
# get status of build or assign unknown
current = self.known.get(package.base)
if current is None:
status = BuildStatus()
else:
_, status = current
self.known[package.base] = (package, status)
self._cache_load()
def remove(self, base: str) -> None:
"""
remove package base from known list if any
:param base: package base
"""
self.known.pop(base, None)
self._cache_save()
def update(self, base: str, status: BuildStatusEnum, package: Optional[Package]) -> None:
"""
update package status and description
:param base: package base to update
:param status: new build status
:param package: optional new package description. In case if not set current properties will be used
"""
if package is None:
try:
package, _ = self.known[base]
except KeyError:
raise UnknownPackage(base)
full_status = BuildStatus(status)
self.known[base] = (package, full_status)
self._cache_save()
def update_self(self, status: BuildStatusEnum) -> None:
"""
update service status
:param status: new service status
"""
self.status = BuildStatus(status)

View File

@ -0,0 +1,188 @@
#
# 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 logging
import requests
from typing import List, Optional, Tuple
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.internal_status import InternalStatus
from ahriman.models.package import Package
class WebClient(Client):
"""
build status reporter web client
:ivar host: host of web service
:ivar logger: class logger
:ivar port: port of web service
"""
def __init__(self, host: str, port: int) -> None:
"""
default constructor
:param host: host of web service
:param port: port of web service
"""
self.logger = logging.getLogger("http")
self.host = host
self.port = port
def _ahriman_url(self) -> str:
"""
url generator
:return: full url for web service for ahriman service itself
"""
return f"http://{self.host}:{self.port}/api/v1/ahriman"
def _package_url(self, base: str = "") -> str:
"""
url generator
:param base: package base to generate url
:return: full url of web service for specific package 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:
"""
add new package with status
:param package: package properties
:param status: current package build status
"""
payload = {
"status": status.value,
"package": package.view()
}
try:
response = requests.post(self._package_url(package.base), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not add %s: %s", package.base, exception_response_text(e))
except Exception:
self.logger.exception("could not add %s", package.base)
def get(self, base: Optional[str]) -> List[Tuple[Package, BuildStatus]]:
"""
get package status
:param base: package base to get
:return: list of current package description and status if it has been found
"""
try:
response = requests.get(self._package_url(base or ""))
response.raise_for_status()
status_json = response.json()
return [
(Package.from_json(package["package"]), BuildStatus.from_json(package["status"]))
for package in status_json
]
except requests.exceptions.HTTPError as e:
self.logger.exception("could not get %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception("could not get %s", base)
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:
"""
get ahriman status itself
:return: current ahriman status
"""
try:
response = requests.get(self._ahriman_url())
response.raise_for_status()
status_json = response.json()
return BuildStatus.from_json(status_json)
except requests.exceptions.HTTPError as e:
self.logger.exception("could not get service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not get service status")
return BuildStatus()
def remove(self, base: str) -> None:
"""
remove packages from watcher
:param base: basename to remove
"""
try:
response = requests.delete(self._package_url(base))
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not delete %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception("could not delete %s", base)
def update(self, base: str, status: BuildStatusEnum) -> None:
"""
update package build status. Unlike `add` it does not update package properties
:param base: package base to update
:param status: current package build status
"""
payload = {"status": status.value}
try:
response = requests.post(self._package_url(base), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not update %s: %s", base, exception_response_text(e))
except Exception:
self.logger.exception("could not update %s", base)
def update_self(self, status: BuildStatusEnum) -> None:
"""
update ahriman status itself
:param status: current ahriman status
"""
payload = {"status": status.value}
try:
response = requests.post(self._ahriman_url(), json=payload)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
self.logger.exception("could not update service status: %s", exception_response_text(e))
except Exception:
self.logger.exception("could not update service status")

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -22,54 +22,95 @@ from __future__ import annotations
import shutil
import tempfile
from typing import Iterable, List, Set
from pathlib import Path
from typing import Iterable, List, Set, Type
from ahriman.core.build_tools.task import Task
from ahriman.models.package import Package
class Tree:
class Leaf:
"""
tree leaf implementation
:ivar dependencies: list of package dependencies
:ivar package: leaf package properties
"""
def __init__(self) -> None:
self.packages: List[Leaf] = []
def __init__(self, package: Package, dependencies: Set[str]) -> None:
"""
default constructor
:param package: package properties
:param dependencies: package dependencies
"""
self.package = package
self.dependencies = dependencies
@property
def items(self) -> Iterable[str]:
"""
:return: packages containing in this leaf
"""
return self.package.packages.keys()
@classmethod
def load(cls: Type[Leaf], package: Package) -> Leaf:
"""
load leaf from package with dependencies
:param package: package properties
:return: loaded class
"""
clone_dir = Path(tempfile.mkdtemp())
try:
Task.fetch(clone_dir, package.git_url)
dependencies = Package.dependencies(clone_dir)
finally:
shutil.rmtree(clone_dir, ignore_errors=True)
return cls(package, dependencies)
def is_root(self, packages: Iterable[Leaf]) -> bool:
"""
check if package depends on any other package from list of not
:param packages: list of known leaves
:return: True if any of packages is dependency of the leaf, False otherwise
"""
for leaf in packages:
if self.dependencies.intersection(leaf.items):
return False
return True
class Tree:
"""
dependency tree implementation
:ivar leaves: list of tree leaves
"""
def __init__(self, leaves: List[Leaf]) -> None:
"""
default constructor
:param leaves: leaves to build the tree
"""
self.leaves = leaves
@classmethod
def load(cls: Type[Tree], packages: Iterable[Package]) -> Tree:
"""
load tree from packages
:param packages: packages list
:return: loaded class
"""
return cls([Leaf.load(package) for package in packages])
def levels(self) -> List[List[Package]]:
"""
get build levels starting from the packages which do not require any other package to build
:return: list of packages lists
"""
result: List[List[Package]] = []
unprocessed = [leaf for leaf in self.packages]
unprocessed = self.leaves[:]
while unprocessed:
result.append([leaf.package for leaf in unprocessed if leaf.is_root(unprocessed)])
unprocessed = [leaf for leaf in unprocessed if not leaf.is_root(unprocessed)]
return result
def load(self, packages: Iterable[Package]) -> None:
for package in packages:
leaf = Leaf(package)
leaf.load_dependencies()
self.packages.append(leaf)
class Leaf:
def __init__(self, package: Package) -> None:
self.package = package
self.dependencies: Set[str] = set()
def is_root(self, packages: Iterable[Leaf]) -> bool:
'''
:param packages:
:return: true if any of packages is dependency of the leaf, false otherwise
'''
for package in packages:
if package.package.packages.intersection(self.dependencies):
return False
return True
def load_dependencies(self) -> None:
clone_dir = tempfile.mkdtemp()
try:
Task.fetch(clone_dir, self.package.git_url)
self.dependencies = Package.dependencies(clone_dir)
finally:
shutil.rmtree(clone_dir, ignore_errors=True)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,19 +17,38 @@
# 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 pathlib import Path
from typing import Iterable
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 Rsync(Uploader):
class Rsync(Upload):
"""
rsync wrapper
:ivar command: command arguments for sync
:ivar remote: remote address to sync
"""
def __init__(self, architecture: str, config: Configuration) -> None:
Uploader.__init__(self, architecture, config)
section = config.get_section_name('rsync', architecture)
self.remote = config.get(section, 'remote')
_check_output = check_output
def sync(self, path: str) -> None:
check_output('rsync', '--archive', '--verbose', '--compress', '--partial', '--progress', '--delete', path, self.remote,
exception=None,
logger=self.logger)
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
Upload.__init__(self, architecture, configuration)
self.command = configuration.getlist("rsync", "command")
self.remote = configuration.get("rsync", "remote")
def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
"""
sync data to remote server
:param path: local path to sync
:param built_packages: list of packages which has just been built
"""
Rsync._check_output(*self.command, str(path), self.remote, exception=None, logger=self.logger)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,20 +17,137 @@
# 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 boto3 # type: ignore
import hashlib
import mimetypes
from pathlib import Path
from typing import Any, Dict, Generator, Iterable
from ahriman.core.configuration import Configuration
from ahriman.core.upload.uploader import Uploader
from ahriman.core.util import check_output
from ahriman.core.upload.upload import Upload
from ahriman.models.package import Package
class S3(Uploader):
class S3(Upload):
"""
aws-cli wrapper
:ivar bucket: boto3 S3 bucket object
:ivar chunk_size: chunk size for calculating checksums
"""
def __init__(self, architecture: str, config: Configuration) -> None:
Uploader.__init__(self, architecture, config)
section = config.get_section_name('s3', architecture)
self.bucket = config.get(section, 'bucket')
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
Upload.__init__(self, architecture, configuration)
self.bucket = self.get_bucket(configuration)
self.chunk_size = configuration.getint("s3", "chunk_size", fallback=8 * 1024 * 1024)
def sync(self, path: str) -> None:
# TODO rewrite to boto, but it is bullshit
check_output('aws', 's3', 'sync', '--delete', path, self.bucket,
exception=None,
logger=self.logger)
@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"))
@staticmethod
def remove_files(local_files: Dict[Path, str], remote_objects: Dict[Path, Any]) -> None:
"""
remove files which have been removed locally
:param local_files: map of local path object to its checksum
:param remote_objects: map of remote path object to the remote s3 object
"""
for local_file, remote_object in remote_objects.items():
if local_file in local_files:
continue
remote_object.delete()
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
:param path: local path to sync
:param built_packages: list of packages which has just been built
"""
remote_objects = self.get_remote_objects()
local_files = self.get_local_files(path)
self.upload_files(path, local_files, remote_objects)
self.remove_files(local_files, remote_objects)
def upload_files(self, path: Path, local_files: Dict[Path, str], remote_objects: Dict[Path, Any]) -> None:
"""
upload changed files to s3
:param path: local path to sync
:param local_files: map of local path object to its checksum
:param remote_objects: map of remote path object to the remote s3 object
"""
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
local_path = path / local_file
remote_path = Path(self.architecture) / local_file
(mime, _) = mimetypes.guess_type(local_path)
extra_args = {"Content-Type": mime} if mime is not None else None
self.bucket.upload_file(Filename=str(local_path), Key=str(remote_path), ExtraArgs=extra_args)

View File

@ -0,0 +1,86 @@
#
# 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
import logging
from pathlib import Path
from typing import Iterable, Type
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import SyncFailed
from ahriman.models.package import Package
from ahriman.models.upload_settings import UploadSettings
class Upload:
"""
base remote sync class
:ivar architecture: repository architecture
:ivar configuration: configuration instance
:ivar logger: application logger
"""
def __init__(self, architecture: str, configuration: Configuration) -> None:
"""
default constructor
:param architecture: repository architecture
:param configuration: configuration instance
"""
self.logger = logging.getLogger("builder")
self.architecture = architecture
self.config = configuration
@classmethod
def load(cls: Type[Upload], architecture: str, configuration: Configuration, target: str) -> Upload:
"""
load client from settings
:param architecture: repository architecture
:param configuration: configuration instance
:param target: target to run sync (e.g. s3)
:return: client according to current settings
"""
provider = UploadSettings.from_option(target)
if provider == UploadSettings.Rsync:
from ahriman.core.upload.rsync import Rsync
return Rsync(architecture, configuration)
if provider == UploadSettings.S3:
from ahriman.core.upload.s3 import S3
return S3(architecture, configuration)
return cls(architecture, configuration) # should never happen
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:
self.sync(path, built_packages)
except Exception:
self.logger.exception("remote sync failed")
raise SyncFailed()
def sync(self, path: Path, built_packages: Iterable[Package]) -> None:
"""
sync data to remote server
:param path: local path to sync
:param built_packages: list of packages which has just been built
"""

View File

@ -1,53 +0,0 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
#
# 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 logging
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import SyncFailed
from ahriman.models.upload_settings import UploadSettings
class Uploader:
def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('builder')
self.architecture = architecture
self.config = config
@staticmethod
def run(architecture: str, config: Configuration, target: str, path: str) -> None:
provider = UploadSettings.from_option(target)
if provider == UploadSettings.Rsync:
from ahriman.core.upload.rsync import Rsync
uploader: Uploader = Rsync(architecture, config)
elif provider == UploadSettings.S3:
from ahriman.core.upload.s3 import S3
uploader = S3(architecture, config)
else:
uploader = Uploader(architecture, config)
try:
uploader.sync(path)
except Exception:
uploader.logger.exception('remote sync failed', exc_info=True)
raise SyncFailed()
def sync(self, path: str) -> None:
pass

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,27 +17,92 @@
# 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 subprocess
import requests
from logging import Logger
from typing import Optional
from pathlib import Path
from typing import Optional, Union
from ahriman.core.exceptions import InvalidOption
def check_output(*args: str, exception: Optional[Exception],
cwd = None, stderr: int = subprocess.STDOUT,
logger: Optional[Logger] = None) -> str:
def check_output(*args: str, exception: Optional[Exception], cwd: Optional[Path] = None,
input_data: Optional[str] = None, logger: Optional[Logger] = None) -> str:
"""
subprocess wrapper
:param args: command line arguments
:param exception: exception which has to be reraised instead of default subprocess exception
: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
:return: command output
"""
try:
result = subprocess.check_output(args, cwd=cwd, stderr=stderr).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:
for line in result.splitlines():
logger.debug(line)
except subprocess.CalledProcessError as e:
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)
raise exception or e
return result
def package_like(filename: str) -> bool:
return '.pkg.' in filename and not filename.endswith('.sig')
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:
"""
check if file looks like package
:param filename: name of file to check
:return: True in case if name contains `.pkg.` and not signature, False otherwise
"""
name = filename.name
return ".pkg." in name and not name.endswith(".sig")
def pretty_datetime(timestamp: Optional[Union[float, int]]) -> str:
"""
convert datetime object to string
:param timestamp: datetime to convert
:return: pretty printable datetime as string
"""
return "" if timestamp is None else datetime.datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def pretty_size(size: Optional[float], level: int = 0) -> str:
"""
convert size to string
:param size: size to convert
:param level: represents current units, 0 is B, 1 is KiB etc
:return: pretty printable size as string
"""
def str_level() -> str:
if level == 0:
return "B"
if level == 1:
return "KiB"
if level == 2:
return "MiB"
if level == 3:
return "GiB"
raise InvalidOption(level) # must never happen actually
if size is None:
return ""
if size < 1024 or level >= 3:
return f"{size:.1f} {str_level()}"
return pretty_size(size / 1024, level + 1)

View File

@ -1,116 +0,0 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
#
# 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
import logging
from typing import Any, Dict, Set
from ahriman.core.configuration import Configuration
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
class Client:
def add(self, package: Package, status: BuildStatusEnum) -> None:
pass
def remove(self, base: str, packages: Set[str]) -> None:
pass
def update(self, base: str, status: BuildStatusEnum) -> None:
pass
def set_building(self, base: str) -> None:
return self.update(base, BuildStatusEnum.Building)
def set_failed(self, base: str) -> None:
return self.update(base, BuildStatusEnum.Failed)
def set_pending(self, base: str) -> None:
return self.update(base, BuildStatusEnum.Pending)
def set_success(self, package: Package) -> None:
return self.add(package, BuildStatusEnum.Success)
def set_unknown(self, package: Package) -> None:
return self.add(package, BuildStatusEnum.Unknown)
@staticmethod
def load(architecture: str, config: Configuration) -> Client:
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()
return WebClient(host, port)
class WebClient(Client):
def __init__(self, host: str, port: int) -> None:
self.logger = logging.getLogger('http')
self.host = host
self.port = port
def _url(self, base: str) -> str:
return f'http://{self.host}:{self.port}/api/v1/packages/{base}'
def add(self, package: Package, status: BuildStatusEnum) -> None:
import requests
payload: Dict[str, Any] = {
'status': status.value,
'package': {
'base': package.base,
'packages': [p for p in package.packages],
'version': package.version,
'aur_url': package.aur_url
}
}
try:
response = requests.post(self._url(package.base), json=payload)
response.raise_for_status()
except:
self.logger.exception(f'could not add {package.base}', exc_info=True)
def remove(self, base: str, packages: Set[str]) -> None:
if not packages:
return
import requests
try:
response = requests.delete(self._url(base))
response.raise_for_status()
except:
self.logger.exception(f'could not delete {base}', exc_info=True)
def update(self, base: str, status: BuildStatusEnum) -> None:
import requests
payload: Dict[str, Any] = {'status': status.value}
try:
response = requests.post(self._url(base), json=payload)
response.raise_for_status()
except:
self.logger.exception(f'could not update {base}', exc_info=True)

View File

@ -1,57 +0,0 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
#
# 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 typing import Dict, List, Optional, Tuple
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package
class Watcher:
def __init__(self, architecture: str, config: Configuration) -> None:
self.architecture = architecture
self.repository = Repository(architecture, config)
self.known: Dict[str, Tuple[Package, BuildStatus]] = {}
@property
def packages(self) -> List[Tuple[Package, BuildStatus]]:
return [pair for pair in self.known.values()]
def load(self) -> None:
for package in self.repository.packages():
# get status of build or assign unknown
current = self.known.get(package.base)
if current is None:
status = BuildStatus()
else:
_, status = current
self.known[package.base] = (package, status)
def remove(self, base: str) -> None:
self.known.pop(base, None)
def update(self, base: str, status: BuildStatusEnum, package: Optional[Package]) -> None:
if package is None:
package, _ = self.known[base]
full_status = BuildStatus(status)
self.known[base] = (package, full_status)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,27 +17,104 @@
# 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
import datetime
from enum import Enum
from typing import Optional, Union
from typing import Any, Dict, Optional, Type, Union
from ahriman.core.util import pretty_datetime
class BuildStatusEnum(Enum):
Unknown = 'unknown'
Pending = 'pending'
Building = 'building'
Failed = 'failed'
Success = 'success'
"""
build status enumeration
:cvar Unknown: build status is unknown
:cvar Pending: package is out-of-dated and will be built soon
:cvar Building: package is building right now
:cvar Failed: package build failed
:cvar Success: package has been built without errors
"""
Unknown = "unknown"
Pending = "pending"
Building = "building"
Failed = "failed"
Success = "success"
def badges_color(self) -> str:
"""
convert itself to shield.io badges color
:return: shields.io color
"""
if self == BuildStatusEnum.Pending:
return "yellow"
if self == BuildStatusEnum.Building:
return "yellow"
if self == BuildStatusEnum.Failed:
return "critical"
if self == BuildStatusEnum.Success:
return "success"
return "inactive"
class BuildStatus:
"""
build status holder
:ivar status: build status
:ivar timestamp: build status update time
"""
def __init__(self, status: Union[BuildStatusEnum, str, None] = None,
timestamp: Optional[datetime.datetime] = None) -> None:
timestamp: Optional[int] = None) -> None:
"""
default constructor
:param status: current build status if known. `BuildStatusEnum.Unknown` will be used if not set
:param timestamp: build status timestamp. Current timestamp will be used if not set
"""
self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown
self._timestamp = timestamp or datetime.datetime.utcnow()
self.timestamp = timestamp or int(datetime.datetime.utcnow().timestamp())
@property
def timestamp(self) -> str:
return self._timestamp.strftime('%Y-%m-%d %H:%M:%S')
@classmethod
def from_json(cls: Type[BuildStatus], dump: Dict[str, Any]) -> BuildStatus:
"""
construct status properties from json dump
:param dump: json dump body
:return: status properties
"""
return cls(dump.get("status"), dump.get("timestamp"))
def pretty_print(self) -> str:
"""
generate pretty string representation
:return: print-friendly string
"""
return f"{self.status.value} ({pretty_datetime(self.timestamp)})"
def view(self) -> Dict[str, Any]:
"""
generate json status view
:return: json-friendly dictionary
"""
return {
"status": self.status.value,
"timestamp": self.timestamp
}
def __eq__(self, other: Any) -> bool:
"""
compare object to other
:param other: other object to compare
:return: True in case if objects are equal
"""
if not isinstance(other, BuildStatus):
return False
return self.status == other.status and self.timestamp == other.timestamp
def __repr__(self) -> str:
"""
generate string representation of object
:return: unique string representation
"""
return f"BuildStatus(status={self.status.value}, timestamp={self.timestamp})"

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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -19,123 +19,266 @@
#
from __future__ import annotations
import aur
import os
import shutil
import tempfile
import aur # type: ignore
import logging
from dataclasses import dataclass, field
from srcinfo.parse import parse_srcinfo
from typing import List, Optional, Set, Type
from dataclasses import asdict, dataclass
from pathlib import Path
from pyalpm import vercmp # type: ignore
from srcinfo.parse import parse_srcinfo # type: ignore
from typing import Any, Dict, List, Optional, Set, Type, Union
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.exceptions import InvalidPackageInfo
from ahriman.core.util import check_output
from ahriman.models.package_description import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths
@dataclass
class Package:
"""
package properties representation
:ivar aur_url: AUR root url
:ivar base: package base name
:ivar packages: map of package names to their properties. Filled only on load from archive
:ivar version: package full version
"""
base: str
version: str
aur_url: str
packages: Set[str] = field(default_factory=set)
packages: Dict[str, PackageDescription]
_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
def git_url(self) -> str:
return f'{self.aur_url}/{self.base}.git'
"""
:return: package git url to clone
"""
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
def is_single_package(self) -> bool:
"""
:return: true in case if this base has only one package with the same name
"""
return self.base in self.packages and len(self.packages) == 1
@property
def is_vcs(self) -> bool:
return self.base.endswith('-bzr') \
or self.base.endswith('-csv')\
or self.base.endswith('-darcs')\
or self.base.endswith('-git')\
or self.base.endswith('-hg')\
or self.base.endswith('-svn')
"""
:return: True in case if package base looks like VCS package and false otherwise
"""
return self.base.endswith("-bzr") \
or self.base.endswith("-csv")\
or self.base.endswith("-darcs")\
or self.base.endswith("-git")\
or self.base.endswith("-hg")\
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
def web_url(self) -> str:
return f'{self.aur_url}/packages/{self.base}'
# additional method to handle vcs versions
def actual_version(self) -> str:
if not self.is_vcs:
return self.version
from ahriman.core.build_tools.task import Task
clone_dir = tempfile.mkdtemp()
try:
Task.fetch(clone_dir, self.git_url)
# update pkgver first
check_output('makepkg', '--nodeps', '--nobuild',
exception=None, cwd=clone_dir)
# generate new .SRCINFO and put it to parser
src_info_source = check_output('makepkg', '--printsrcinfo',
exception=None, cwd=clone_dir)
src_info, errors = parse_srcinfo(src_info_source)
if errors:
raise InvalidPackageInfo(errors)
return self.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel'])
finally:
shutil.rmtree(clone_dir, ignore_errors=True)
"""
:return: package AUR url
"""
return f"{self.aur_url}/packages/{self.base}"
@classmethod
def from_archive(cls: Type[Package], path: str, pacman: Pacman, aur_url: str) -> Package:
package = pacman.handle.load_pkg(path)
return cls(package.base, package.version, aur_url, {package.name})
def from_archive(cls: Type[Package], path: Path, pacman: Pacman, aur_url: str) -> Package:
"""
construct package properties from package archive
:param path: path to package archive
:param pacman: alpm wrapper instance
:param aur_url: AUR root url
:return: package properties
"""
package = pacman.handle.load_pkg(str(path))
return cls(package.base, package.version, aur_url,
{package.name: PackageDescription.from_package(package, path)})
@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:
"""
construct package properties from AUR page
:param name: package name (either base or normal name)
:param aur_url: AUR root url
:return: package properties
"""
package = aur.info(name)
return cls(package.package_base, package.version, aur_url, {package.name})
return cls(package.package_base, package.version, aur_url, {package.name: PackageDescription()})
@classmethod
def from_build(cls: Type[Package], path: str, aur_url: str) -> Package:
with open(os.path.join(path, '.SRCINFO')) as fn:
src_info, errors = parse_srcinfo(fn.read())
def from_build(cls: Type[Package], path: Path, aur_url: str) -> Package:
"""
construct package properties from sources directory
:param path: path to package sources directory
:param aur_url: AUR root url
:return: package properties
"""
srcinfo, errors = parse_srcinfo((path / ".SRCINFO").read_text())
if errors:
raise InvalidPackageInfo(errors)
packages = set(src_info['packages'].keys())
version = cls.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel'])
packages = {key: PackageDescription() for key in srcinfo["packages"]}
version = cls.full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"])
return cls(src_info['pkgbase'], version, aur_url, packages)
return cls(srcinfo["pkgbase"], version, aur_url, packages)
@staticmethod
def dependencies(path: str) -> Set[str]:
with open(os.path.join(path, '.SRCINFO')) as fn:
src_info, errors = parse_srcinfo(fn.read())
if errors:
raise InvalidPackageInfo(errors)
makedepends = src_info['makedepends']
# sum over each package
depends: List[str] = src_info.get('depends', [])
for package in src_info['packages'].values():
depends.extend(package.get('depends', []))
# we are not interested in dependencies inside pkgbase
packages = set(src_info['packages'].keys())
return set(depends + makedepends) - packages
@classmethod
def from_json(cls: Type[Package], dump: Dict[str, Any]) -> Package:
"""
construct package properties from json dump
:param dump: json dump body
:return: package properties
"""
packages = {
key: PackageDescription.from_json(value)
for key, value in dump.get("packages", {}).items()
}
return Package(
base=dump["base"],
version=dump["version"],
aur_url=dump["aur_url"],
packages=packages)
@staticmethod
def full_version(epoch: Optional[str], pkgver: str, pkgrel: str) -> str:
prefix = f'{epoch}:' if epoch else ''
return f'{prefix}{pkgver}-{pkgrel}'
@staticmethod
def load(path: str, pacman: Pacman, aur_url: str) -> Package:
@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:
if os.path.isdir(path):
package: Package = Package.from_build(path, aur_url)
elif os.path.exists(path):
package = Package.from_archive(path, pacman, aur_url)
else:
package = Package.from_aur(path, aur_url)
return package
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))
def is_outdated(self, remote: Package) -> bool:
remote_version = remote.actual_version() # either normal version or updated VCS
result = check_output('vercmp', self.version, remote_version, exception=None)
return True if int(result) < 0 else False
@staticmethod
def dependencies(path: Path) -> Set[str]:
"""
load dependencies from package sources
:param path: path to package sources directory
:return: list of package dependencies including makedepends array, but excluding packages from this base
"""
# additional function to remove versions from dependencies
def trim_version(name: str) -> str:
for symbol in ("<", "=", ">"):
name = name.split(symbol)[0]
return name
srcinfo, errors = parse_srcinfo((path / ".SRCINFO").read_text())
if errors:
raise InvalidPackageInfo(errors)
makedepends = srcinfo.get("makedepends", [])
# sum over each package
depends: List[str] = srcinfo.get("depends", [])
for package in srcinfo["packages"].values():
depends.extend(package.get("depends", []))
# we are not interested in dependencies inside pkgbase
packages = set(srcinfo["packages"].keys())
full_list = set(depends + makedepends) - packages
return {trim_version(package_name) for package_name in full_list}
@staticmethod
def full_version(epoch: Optional[str], pkgver: str, pkgrel: str) -> str:
"""
generate full version from components
:param epoch: package epoch if any
:param pkgver: package version
:param pkgrel: package release version (archlinux specific)
:return: generated version
"""
prefix = f"{epoch}:" if epoch else ""
return f"{prefix}{pkgver}-{pkgrel}"
def actual_version(self, paths: RepositoryPaths) -> str:
"""
additional method to handle VCS package versions
:param paths: repository paths instance
:return: package version if package is not VCS and current version according to VCS otherwise
"""
if not self.is_vcs:
return self.version
from ahriman.core.build_tools.task import Task
clone_dir = paths.cache / self.base
logger = logging.getLogger("build_details")
Task.fetch(clone_dir, self.git_url)
try:
# update pkgver first
Package._check_output("makepkg", "--nodeps", "--nobuild", exception=None, cwd=clone_dir, logger=logger)
# generate new .SRCINFO and put it to parser
srcinfo_source = Package._check_output(
"makepkg",
"--printsrcinfo",
exception=None,
cwd=clone_dir,
logger=logger)
srcinfo, errors = parse_srcinfo(srcinfo_source)
if errors:
raise InvalidPackageInfo(errors)
return self.full_version(srcinfo.get("epoch"), srcinfo["pkgver"], srcinfo["pkgrel"])
except Exception:
logger.exception("cannot determine version of VCS package, make sure that you have VCS tools installed")
return self.version
def is_outdated(self, remote: Package, paths: RepositoryPaths) -> bool:
"""
check if package is out-of-dated
:param remote: package properties from remote source
:param paths: repository paths instance. Required for VCS packages cache
:return: True if the package is out-of-dated and False otherwise
"""
remote_version = remote.actual_version(paths) # either normal version or updated VCS
result: int = vercmp(self.version, remote_version)
return result < 0
def pretty_print(self) -> str:
"""
generate pretty string representation
:return: print-friendly string
"""
details = "" if self.is_single_package else f""" ({" ".join(sorted(self.packages.keys()))})"""
return f"{self.base}{details}"
def view(self) -> Dict[str, Any]:
"""
generate json package view
:return: json-friendly dictionary
"""
return asdict(self)

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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -20,15 +20,32 @@
from __future__ import annotations
from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption
class ReportSettings(Enum):
HTML = auto()
"""
report targets enumeration
:cvar Disabled: option which generates no report for testing purpose
:cvar HTML: html report generation
:cvar Email: email report generation
"""
@staticmethod
def from_option(value: str) -> ReportSettings:
if value.lower() in ('html',):
return ReportSettings.HTML
Disabled = auto() # for testing purpose
HTML = auto()
Email = auto()
@classmethod
def from_option(cls: Type[ReportSettings], value: str) -> ReportSettings:
"""
construct value from configuration
:param value: configuration value
:return: parsed value
"""
if value.lower() in ("html",):
return cls.HTML
if value.lower() in ("email",):
return cls.Email
raise InvalidOption(value)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,54 +17,88 @@
# 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 os
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Set, Type
@dataclass
class RepositoryPaths:
root: str
"""
repository paths holder. For the most operations with paths you want to use this object
:ivar root: repository root (i.e. ahriman home)
:ivar architecture: repository architecture
"""
root: Path
architecture: str
@property
def chroot(self) -> str:
'''
def cache(self) -> Path:
"""
:return: directory for packages cache (mainly used for VCS packages)
"""
return self.root / "cache"
@property
def chroot(self) -> Path:
"""
:return: directory for devtools chroot
'''
return os.path.join(self.root, 'chroot')
"""
# for the chroot directory devtools will create own tree and we don"t have to specify architecture here
return self.root / "chroot"
@property
def manual(self) -> str:
'''
def manual(self) -> Path:
"""
:return: directory for manual updates (i.e. from add command)
'''
return os.path.join(self.root, 'manual')
"""
return self.root / "manual" / self.architecture
@property
def packages(self) -> str:
'''
def packages(self) -> Path:
"""
:return: directory for built packages
'''
return os.path.join(self.root, 'packages')
"""
return self.root / "packages" / self.architecture
@property
def repository(self) -> str:
'''
def repository(self) -> Path:
"""
:return: repository directory
'''
return os.path.join(self.root, 'repository', self.architecture)
"""
return self.root / "repository" / self.architecture
@property
def sources(self) -> str:
'''
def sources(self) -> Path:
"""
:return: directory for downloaded PKGBUILDs for current build
'''
return os.path.join(self.root, 'sources')
"""
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:
os.makedirs(self.chroot, mode=0o755, exist_ok=True)
os.makedirs(self.manual, mode=0o755, exist_ok=True)
os.makedirs(self.packages, mode=0o755, exist_ok=True)
os.makedirs(self.repository, mode=0o755, exist_ok=True)
os.makedirs(self.sources, mode=0o755, exist_ok=True)
"""
create ahriman working tree
"""
self.cache.mkdir(mode=0o755, parents=True, exist_ok=True)
self.chroot.mkdir(mode=0o755, parents=True, exist_ok=True)
self.manual.mkdir(mode=0o755, parents=True, exist_ok=True)
self.packages.mkdir(mode=0o755, parents=True, exist_ok=True)
self.repository.mkdir(mode=0o755, parents=True, exist_ok=True)
self.sources.mkdir(mode=0o755, parents=True, exist_ok=True)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -20,18 +20,30 @@
from __future__ import annotations
from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption
class SignSettings(Enum):
SignPackages = auto()
SignRepository = auto()
"""
sign targets enumeration
:cvar Packages: sign each package
:cvar Repository: sign repository database file
"""
@staticmethod
def from_option(value: str) -> SignSettings:
if value.lower() in ('package', 'packages', 'sign-package'):
return SignSettings.SignPackages
elif value.lower() in ('repository', 'sign-repository'):
return SignSettings.SignRepository
raise InvalidOption(value)
Packages = auto()
Repository = auto()
@classmethod
def from_option(cls: Type[SignSettings], value: str) -> SignSettings:
"""
construct value from configuration
:param value: configuration value
:return: parsed value
"""
if value.lower() in ("package", "packages", "sign-package"):
return cls.Packages
if value.lower() in ("repository", "sign-repository"):
return cls.Repository
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

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -20,18 +20,32 @@
from __future__ import annotations
from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOption
class UploadSettings(Enum):
"""
remote synchronization targets enumeration
:cvar Disabled: no sync will be performed, required for testing purpose
:cvar Rsync: sync via rsync
:cvar S3: sync to Amazon S3
"""
Disabled = auto() # for testing purpose
Rsync = auto()
S3 = auto()
@staticmethod
def from_option(value: str) -> UploadSettings:
if value.lower() in ('rsync',):
return UploadSettings.Rsync
elif value.lower() in ('s3',):
return UploadSettings.S3
@classmethod
def from_option(cls: Type[UploadSettings], value: str) -> UploadSettings:
"""
construct value from configuration
:param value: configuration value
:return: parsed value
"""
if value.lower() in ("rsync",):
return cls.Rsync
if value.lower() in ("s3",):
return cls.S3
raise InvalidOption(value)

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = '0.11.3'
__version__ = "1.2.4"

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

View File

@ -1,7 +1,7 @@
#
# Copyright (c) 2021 Evgenii Alekseev.
# Copyright (c) 2021 ahriman team.
#
# This file is part of ahriman
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify

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