Compare commits

...

26 Commits

Author SHA1 Message Date
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
4445c8c871 Release 0.11.3 2021-03-13 02:27:38 +03:00
a3a66c7c9a count epoch 2021-03-13 02:27:27 +03:00
45b762e3d9 Release 0.11.2 2021-03-13 01:57:26 +03:00
c5db7e64ca process prepare call for vcs packages 2021-03-13 01:57:10 +03:00
0dd4d098f6 Release 0.11.1 2021-03-12 00:24:49 +03:00
4866548224 handle built packages during update 2021-03-12 00:24:26 +03:00
5d526e1bd8 Release 0.11.0 2021-03-12 00:15:21 +03:00
c66325ff38 fix interaction with web 2021-03-12 00:14:31 +03:00
371019f899 add depdendency manager and switch to pyalpm instead of expac 2021-03-12 00:04:37 +03:00
2d351fa94f allow to specify key overrides for packages 2021-03-11 04:06:20 +03:00
1770793e69 improvements
* multi-sign and multi-web configuration
* change default configuration to do not use architecture
* change units to be templated
* some refactoring
2021-03-11 03:57:23 +03:00
30ededb2cd Release 0.10.0 2021-03-11 01:59:05 +03:00
2fca108fa4 process null lock file 2021-03-11 01:58:33 +03:00
262d8d8647 multisign option 2021-03-11 01:39:45 +03:00
fd2049b334 web server support 2021-03-11 01:14:09 +03:00
56 changed files with 1400 additions and 333 deletions

View File

@ -1,19 +1,22 @@
# ahriman configuration # ahriman configuration
Some groups can be specified for each architecture separately with default values. 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. 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).
## `settings` group ## `settings` group
Base configuration settings: Base configuration settings.
* `include` - path to directory with configuration files overrides, string, required. * `include` - path to directory with configuration files overrides, string, required.
* `logging` - path to logging configuration, string, required. Check `logging.ini` for reference. * `logging` - path to logging configuration, string, required. Check `logging.ini` for reference.
## `aur` group ## `alpm` group
AUR related configuration: libalpm and AUR related configuration.
* `url` - base url for AUR, string, required. * `aur_url` - base url for AUR, string, required.
* `database` - path to pacman local database cache, string, required.
* `repositories` - list of pacman repositories, space separated list of strings, required.
* `root` - root for alpm library, string, required.
## `build_*` groups ## `build_*` groups
@ -27,25 +30,26 @@ Build related configuration. Group name must refer to architecture, e.g. it shou
## `repository` group ## `repository` group
Base repository settings: Base repository settings.
* `name` - repository name, string, required. * `name` - repository name, string, required.
* `root` - root path for application, string, required. * `root` - root path for application, string, required.
## `sign` group ## `sign_*` groups
Settings for signing packages or repository: Settings for signing packages or repository. Group name must refer to architecture, e.g. it should be `sign_x86_64` for x86_64 architecture.
* `enabled` - configuration flag to enable signing, string, required. Allowed values are `disabled`, `package` (sign each package separately), `repository` (sign repository database file). * `target` - configuration flag to enable signing, space separated list of strings, required. Allowed values are `package` (sign each package separately), `repository` (sign repository database file).
* `key` - PGP key, string, optional. * `key` - default PGP key, string, required. This key will also be used for database signing if enabled.
* `key_*` settings - PGP key which will be used for specific packages, string, optional. For example, if there is `key_yay` option the specified key will be used for yay package and default key for others.
## `report` group ## `report` group
Report generation settings: Report generation settings.
* `target` - list of reports to be generated, space separated list of strings, optional. Allowed values are `html`. * `target` - list of reports to be generated, space separated list of strings, optional. Allowed values are `html`.
### `html_*` group ### `html_*` 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 `html_x86_64` for x86_64 architecture.
@ -56,18 +60,26 @@ Group name must refer to architecture, e.g. it should be `html_x86_64` for x86_6
## `upload` group ## `upload` group
Remote synchronization settings: Remote synchronization settings.
* `target` - list of synchronizations to be used, space separated list of strings, optional. Allowed values are `rsync`, `s3`. * `target` - list of synchronizations to be used, space separated list of strings, optional. Allowed values are `rsync`, `s3`.
### `s3_*` group ### `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`.
* `remote` - remote server to rsync (e.g. `1.2.3.4:5678:path/to/sync`), string, required.
### `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. Requires `aws-cli` package to be installed. Do not forget to configure it for user `ahriman`.
* `bucket` - bucket name (e.g. `s3://bucket/path`), string, required. * `bucket` - bucket name (e.g. `s3://bucket/path`), string, required.
### `rsync_*` group ## `web_*` 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`. 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.
* `remote` - remote server to rsync (e.g. `1.2.3.4:5678:path/to/sync`), string, required. * `host` - host to bind, string, optional.
* `port` - port to bind, int, optional.
* `templates` - path to templates directory, string, required.

View File

@ -2,20 +2,26 @@
Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts). Wrapper for managing custom repository inspired by [repo-scripts](https://github.com/arcan1s/repo-scripts).
## Features
* Install-configure-forget manager for own repository
* Multi-architecture support
* VCS packages support
* Sign support with gpg (repository, package, per package settings)
* Synchronization to remote services (rsync, s3) and report generation (html)
* Dependency manager
* Repository status interface
## Installation and run ## Installation and run
* Install package as usual. * Install package as usual.
* Change settings if required, see `CONFIGURING.md` for more details. * 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`). * 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): * Configure build tools (it might be required if your package will use any custom repositories):
* create build command if required, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/custom-x86_64-build` (you can choose any name for command); * 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`; * create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,custom}.conf`;
* change configuration file: add your own repository, add multilib repository; * change configuration file, add your own repository, add multilib repository etc;
* set `build.build_command` to point to your command; * set `build.build_command` setting to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow to run command without password. * configure `/etc/sudoers.d/ahriman` to allow running command without password.
* Start and enable `ahriman.timer` via `systemctl`. * Start and enable `ahriman.timer` via `systemctl`.
* Add packages by using `ahriman add {package}` command. * Add packages by using `ahriman add {package}` command.
## Limitations
* It does not manage dependencies, so you have to add them before main package.

View File

@ -1,28 +1,29 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=0.9.1 pkgver=0.11.7
pkgrel=1 pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager" pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
url="https://github.com/arcan1s/ahriman" url="https://github.com/arcan1s/ahriman"
license=('GPL3') license=('GPL3')
depends=('devtools' 'expac' 'git' 'python-aur' 'python-srcinfo') depends=('devtools' 'git' 'pyalpm' 'python-aur' 'python-srcinfo')
makedepends=('python-pip') makedepends=('python-pip')
optdepends=('aws-cli: sync to s3' optdepends=('aws-cli: sync to s3'
'breezy: -bzr packages support' 'breezy: -bzr packages support'
'darcs: -darcs packages support' 'darcs: -darcs packages support'
'gnupg: package and repository sign' 'gnupg: package and repository sign'
'mercurial: -hg packages support' 'mercurial: -hg packages support'
'python-aiohttp: web server'
'python-aiohttp-jinja2: web server'
'python-jinja: html report generation' 'python-jinja: html report generation'
'python-requests: web server'
'rsync: sync by using rsync' 'rsync: sync by using rsync'
'subversion: -svn packages support') 'subversion: -svn packages support')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sudoers'
'ahriman.sysusers' 'ahriman.sysusers'
'ahriman.tmpfiles') 'ahriman.tmpfiles')
sha512sums=('82a8208554956f009db0334b0cb20891889d96617b4c9b9d2af7a007d668f6cbc6d46d0be8b5ee11ffb2b69d124b5d5d6db1a6f7d5a0a2c719c0d8e07dca24d8' sha512sums=('d35053c7a52e5cc2dd8a3dca6c9d9f18788296d0059683b16238a54318a74fb4c42544d0727460250e7d0f0ce1009aca96e88d3e52e6bdffab8d45e5e4901b7b'
'8c9b5b63ac3f7b4d9debaf801a1e9c060877c33d3ecafe18010fcca778e5fa2f2e46909d3d0ff1b229ff8aa978445d8243fd36e1fc104117ed678d5e21901167'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075' '13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4') '55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini' backup=('etc/ahriman.ini'
@ -39,7 +40,6 @@ package() {
python setup.py install --root="$pkgdir" python setup.py install --root="$pkgdir"
install -Dm400 "$srcdir/$pkgname.sudoers" "$pkgdir/etc/sudoers.d/$pkgname"
install -Dm644 "$srcdir/$pkgname.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgname.conf" install -Dm644 "$srcdir/$pkgname.sysusers" "$pkgdir/usr/lib/sysusers.d/$pkgname.conf"
install -Dm644 "$srcdir/$pkgname.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/$pkgname.conf" install -Dm644 "$srcdir/$pkgname.tmpfiles" "$pkgdir/usr/lib/tmpfiles.d/$pkgname.conf"
} }

View File

@ -1,4 +0,0 @@
# Used by ArcHlinux ReposItory MANager with default settings
Cmnd_Alias ARCHBUILD_CMD = /usr/bin/extra-x86_64-build *, /usr/bin/multilib-build *
ahriman ALL=(ALL) NOPASSWD: ARCHBUILD_CMD

View File

@ -2,10 +2,13 @@
include = /etc/ahriman.ini.d include = /etc/ahriman.ini.d
logging = /etc/ahriman.ini.d/logging.ini logging = /etc/ahriman.ini.d/logging.ini
[aur] [alpm]
url = https://aur.archlinux.org aur_url = https://aur.archlinux.org
database = /var/lib/pacman
repositories = core extra community multilib
root = /
[build_x86_64] [build]
archbuild_flags = archbuild_flags =
build_command = extra-x86_64-build build_command = extra-x86_64-build
ignore_packages = ignore_packages =
@ -17,23 +20,28 @@ name = aur-clone
root = /var/lib/ahriman root = /var/lib/ahriman
[sign] [sign]
enabled = disabled target =
key = key =
[report] [report]
target = target =
[html_x86_64] [html]
path = path =
homepage = homepage =
link_path = link_path =
template_path = /usr/share/ahriman/index.jinja2 template_path = /usr/share/ahriman/repo-index.jinja2
[upload] [upload]
target = target =
[s3_x86_64] [rsync]
remote =
[s3]
bucket = bucket =
[rsync_x86_64] [web]
remote = host =
port =
templates = /usr/share/ahriman

View File

@ -1,8 +1,8 @@
[loggers] [loggers]
keys = root,builder,build_details keys = root,builder,build_details,http
[handlers] [handlers]
keys = console_handler,build_file_handler,file_handler keys = console_handler,build_file_handler,file_handler,http_handler
[formatters] [formatters]
keys = generic_format keys = generic_format
@ -25,6 +25,12 @@ level = DEBUG
formatter = generic_format 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)
[formatter_generic_format] [formatter_generic_format]
format = %(asctime)s : %(levelname)s : %(funcName)s : %(message)s format = %(asctime)s : %(levelname)s : %(funcName)s : %(message)s
datefmt = datefmt =
@ -45,3 +51,9 @@ level = DEBUG
handlers = build_file_handler handlers = build_file_handler
qualname = build_details qualname = build_details
propagate = 0 propagate = 0
[logger_http]
level = DEBUG
handlers = http_handler
qualname = http
propagate = 0

View File

@ -0,0 +1,15 @@
[Unit]
Description=ArcHlinux ReposItory MANager web server (%I architecture)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/ahriman --architecture %i web
User=ahriman
Group=ahriman
KillSignal=SIGQUIT
SuccessExitStatus=SIGQUIT
[Install]
WantedBy=multi-user.target

View File

@ -1,7 +0,0 @@
[Unit]
Description=ArcHlinux ReposItory MANager
[Service]
ExecStart=/usr/bin/ahriman --architecture x86_64 update
User=ahriman
Group=ahriman

View File

@ -0,0 +1,7 @@
[Unit]
Description=ArcHlinux ReposItory MANager (%I architecture)
[Service]
ExecStart=/usr/bin/ahriman --architecture %i update
User=ahriman
Group=ahriman

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=ArcHlinux ReposItory MANager timer Description=ArcHlinux ReposItory MANager timer (%I architecture)
[Timer] [Timer]
OnCalendar=daily OnCalendar=daily

View File

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<div class="root">
<h1>ahriman {{ version|e }} ({{ architecture|e }})</h1>
{% include "search-line.jinja2" %}
<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|e }}" title="{{ package.base|e }}">{{ package.base|e }}</a></td>
<td class="include-search">{{ package.packages|join("<br>"|safe) }}</td>
<td>{{ package.version|e }}</td>
<td>{{ package.timestamp|e }}</td>
<td class="package-{{ package.status|e }}">{{ package.status|e }}</td>
</tr>
{% endfor %}
</table>
</section>
</div>
</body>
</html>

View File

@ -1,31 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
</head>
<body>
<h1>{{ repository|e }} ArchLinux custom repository</h1>
{% if pgp_key is not none %}
<p>All packages are signed with <a href="http://keys.gnupg.net/pks/lookup?search=0x{{ pgp_key|e }}" title="key search">{{ pgp_key|e }}</a>.</p>
{% endif %}
<code>
$ cat /etc/pacman.conf<br>
[{{ repository|e }}]<br>
Server = {{ link_path|e }}
</code>
<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>
{% if homepage is not none %}
<footer><a href="{{ homepage|e }}" title="homepage">Homepage</a></footer>
{% endif %}
</body>
</html>

View File

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<title>{{ repository|e }}</title>
{% include "style.jinja2" %}
{% include "sorttable.jinja2" %}
{% include "search.jinja2" %}
</head>
<body>
<div class="root">
<h1>Archlinux user repository</h1>
<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|e }}&fingerprint=on&op=index" title="key search">{{ pgp_key|e }}</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>
</section>
{% include "search-line.jinja2" %}
<section class="element">
<table class="sortable search-table">
<tr class="header">
<th>package</th>
<th>version</th>
</tr>
{% for package in packages %}
<tr class="package">
<td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td>
<td>{{ package.version|e }}</td>
</tr>
{% endfor %}
</table>
</section>
{% if homepage is not none %}
<footer><a href="{{ homepage|e }}" title="homepage">Homepage</a></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,25 @@
<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 tr = tables[i].getElementsByTagName("tr");
// from 1 coz of header
for (let i = 1; i < tr.length; i++) {
let td = tr[i].getElementsByClassName("include-search");
let display = "none";
for (let j = 0; j < td.length; j++) {
if (td[j].tagName.toLowerCase() === "td") {
if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) {
display = "";
break;
}
}
}
tr[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,78 @@
<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;
}
@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 {
width: 100%;
padding: 10px 0;
}
code, input, table {
width: inherit;
}
th, td {
padding: 5px;
}
tr.package:nth-child(odd) {
background-color: rgba(255, 255, 255, 1);
}
tr.package:nth-child(even) {
background-color: rgba(235, 235, 255, 1);
}
tr.package:hover {
background-color: rgba(255, 255, 225, 1);
}
tr.header{
background-color: rgba(200, 200, 255, 1);
}
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>

View File

@ -28,6 +28,7 @@ setup(
], ],
install_requires=[ install_requires=[
'aur', 'aur',
'pyalpm',
'srcinfo', 'srcinfo',
], ],
setup_requires=[ setup_requires=[
@ -42,17 +43,30 @@ setup(
'package/bin/ahriman', 'package/bin/ahriman',
], ],
data_files=[ data_files=[
('/etc', ['package/etc/ahriman.ini']), ('/etc', [
('/etc/ahriman.ini.d', ['package/etc/ahriman.ini.d/logging.ini']), 'package/etc/ahriman.ini',
('lib/systemd/system', [ ]),
'package/lib/systemd/system/ahriman.service', ('/etc/ahriman.ini.d', [
'package/lib/systemd/system/ahriman.timer' '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',
]),
('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',
]), ]),
('share/ahriman', ['package/share/ahriman/index.jinja2']),
], ],
extras_require={ extras_require={
'html-templates': ['Jinja2'], 'html-templates': ['Jinja2'],
'test': ['coverage', 'pytest'], 'test': ['coverage', 'pytest'],
'web': ['Jinja2', 'aiohttp', 'aiohttp_jinja2', 'requests'],
}, },
) )

View File

@ -18,62 +18,52 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import argparse import argparse
import os
import ahriman.version as version import ahriman.version as version
from ahriman.application.application import Application from ahriman.application.application import Application
from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _get_app(args: argparse.Namespace) -> Application:
config = _get_config(args.config)
return Application(args.architecture, config)
def _get_config(config_path: str) -> Configuration:
config = Configuration()
config.load(config_path)
config.load_logging()
return config
def _remove_lock(path: str) -> None:
if os.path.exists(path):
os.remove(path)
def add(args: argparse.Namespace) -> None: def add(args: argparse.Namespace) -> None:
_get_app(args).add(args.package) Application.from_args(args).add(args.package, args.without_dependencies)
def rebuild(args: argparse.Namespace) -> None: def rebuild(args: argparse.Namespace) -> None:
app = _get_app(args) app = Application.from_args(args)
packages = app.repository.packages() packages = app.repository.packages()
app.update(packages) app.update(packages)
def remove(args: argparse.Namespace) -> None: def remove(args: argparse.Namespace) -> None:
_get_app(args).remove(args.package) Application.from_args(args).remove(args.package)
def report(args: argparse.Namespace) -> None: def report(args: argparse.Namespace) -> None:
_get_app(args).report(args.target) Application.from_args(args).report(args.target)
def sync(args: argparse.Namespace) -> None: def sync(args: argparse.Namespace) -> None:
_get_app(args).sync(args.target) Application.from_args(args).sync(args.target)
def update(args: argparse.Namespace) -> None: def update(args: argparse.Namespace) -> None:
app = _get_app(args) app = Application.from_args(args)
log_fn = lambda line: print(line) if args.dry_run else app.logger.info(line) log_fn = lambda line: print(line) if args.dry_run else app.logger.info(line)
packages = app.get_updates(args.no_aur, args.no_manual, args.no_vcs, log_fn) packages = app.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, log_fn)
if args.dry_run: if args.dry_run:
return return
app.update(packages) app.update(packages)
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)
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(prog='ahriman', description='ArcHlinux ReposItory MANager') parser = argparse.ArgumentParser(prog='ahriman', description='ArcHlinux ReposItory MANager')
parser.add_argument('-a', '--architecture', help='target architecture', required=True) parser.add_argument('-a', '--architecture', help='target architecture', required=True)
@ -85,9 +75,11 @@ if __name__ == '__main__':
add_parser = subparsers.add_parser('add', description='add package') 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('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) add_parser.set_defaults(fn=add)
check_parser = subparsers.add_parser('check', description='check for updates') 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) check_parser.set_defaults(fn=update, no_aur=False, no_manual=True, no_vcs=False, dry_run=True)
rebuild_parser = subparsers.add_parser('rebuild', description='rebuild whole repository') rebuild_parser = subparsers.add_parser('rebuild', description='rebuild whole repository')
@ -106,26 +98,20 @@ if __name__ == '__main__':
sync_parser.set_defaults(fn=sync) sync_parser.set_defaults(fn=sync)
update_parser = subparsers.add_parser('update', description='run updates') 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('--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-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-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.add_argument('--no-vcs', help='do not check VCS packages', action='store_true')
update_parser.set_defaults(fn=update) update_parser.set_defaults(fn=update)
web_parser = subparsers.add_parser('web', description='start web server')
web_parser.set_defaults(fn=web, lock=None)
args = parser.parse_args() args = parser.parse_args()
if args.force:
_remove_lock(args.lock)
if os.path.exists(args.lock):
raise RuntimeError('Another application instance is run')
if 'fn' not in args: if 'fn' not in args:
parser.print_help() parser.print_help()
exit(1) exit(1)
try: with Lock(args.lock, args.architecture, args.force):
open(args.lock, 'w').close()
args.fn(args) args.fn(args)
finally:
_remove_lock(args.lock)

View File

@ -17,15 +17,19 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import argparse
import logging import logging
import os import os
import shutil import shutil
from typing import Callable, List, Optional from typing import Callable, Iterable, List, Optional, Set, Type
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
from ahriman.core.tree import Tree
from ahriman.models.package import Package from ahriman.models.package import Package
@ -37,16 +41,29 @@ class Application:
self.architecture = architecture self.architecture = architecture
self.repository = Repository(architecture, config) self.repository = Repository(architecture, config)
@classmethod
def from_args(cls: Type[Application], args: argparse.Namespace) -> Application:
config = Configuration.from_path(args.config)
return cls(args.architecture, config)
def _known_packages(self) -> Set[str]:
known_packages: Set[str] = set()
# local set
for package in self.repository.packages():
known_packages.update(package.packages.keys())
known_packages.update(self.repository.pacman.all_packages())
return known_packages
def _finalize(self) -> None: def _finalize(self) -> None:
self.report() self.report()
self.sync() self.sync()
def get_updates(self, no_aur: bool, no_manual: bool, no_vcs: bool, def get_updates(self, filter_packages: List[str], no_aur: bool, no_manual: bool, no_vcs: bool,
log_fn: Callable[[str], None]) -> List[Package]: log_fn: Callable[[str], None]) -> List[Package]:
updates = [] updates = []
if not no_aur: if not no_aur:
updates.extend(self.repository.updates_aur(no_vcs)) updates.extend(self.repository.updates_aur(filter_packages, no_vcs))
if not no_manual: if not no_manual:
updates.extend(self.repository.updates_manual()) updates.extend(self.repository.updates_manual())
@ -55,35 +72,60 @@ class Application:
return updates return updates
def add(self, names: List[str]) -> None: def add(self, names: Iterable[str], without_dependencies: bool) -> None:
def add_manual(name: str) -> None: known_packages = self._known_packages()
package = Package.load(name, self.config.get('aur', 'url'))
Task.fetch(os.path.join(self.repository.paths.manual, package.base), package.url) 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)
Task.fetch(path, package.git_url)
return path
def add_archive(src: str) -> None: def add_archive(src: str) -> None:
dst = os.path.join(self.repository.paths.packages, os.path.basename(src)) dst = os.path.join(self.repository.paths.packages, os.path.basename(src))
shutil.move(src, dst) shutil.move(src, dst)
for name in names: def process_dependencies(path: str) -> None:
if os.path.isfile(name): if without_dependencies:
add_archive(name) return
else: dependencies = Package.dependencies(path)
add_manual(name) self.add(dependencies.difference(known_packages), without_dependencies)
def remove(self, names: List[str]) -> None: def process_single(name: str) -> None:
if not os.path.isfile(name):
path = add_manual(name)
process_dependencies(path)
else:
add_archive(name)
for name in names:
process_single(name)
def remove(self, names: Iterable[str]) -> None:
self.repository.process_remove(names) self.repository.process_remove(names)
self._finalize() self._finalize()
def report(self, target: Optional[List[str]] = None) -> None: def report(self, target: Optional[Iterable[str]] = None) -> None:
targets = target or None targets = target or None
self.repository.process_report(targets) self.repository.process_report(targets)
def sync(self, target: Optional[List[str]] = None) -> None: def sync(self, target: Optional[Iterable[str]] = None) -> None:
targets = target or None targets = target or None
self.repository.process_sync(targets) self.repository.process_sync(targets)
def update(self, updates: List[Package]) -> None: def update(self, updates: Iterable[Package]) -> None:
packages = self.repository.process_build(updates) def process_update(paths: Iterable[str]) -> None:
self.repository.process_update(packages) self.repository.process_update(paths)
self._finalize() self._finalize()
# process built packages
packages = self.repository.packages_built()
process_update(packages)
# process manual packages
tree = Tree()
tree.load(updates)
for num, level in enumerate(tree.levels()):
self.logger.info(f'processing level #{num} {[package.base for package in level]}')
packages = self.repository.process_build(level)
process_update(packages)

View File

@ -0,0 +1,63 @@
#
# 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 os
from types import TracebackType
from typing import Literal, Optional, Type
from ahriman.core.exceptions import DuplicateRun
class Lock:
def __init__(self, path: Optional[str], architecture: str, force: bool) -> None:
self.path = f'{path}_{architecture}' if path is not None else None
self.force = force
def __enter__(self) -> Lock:
if self.force:
self.remove()
self.check()
self.create()
return self
def __exit__(self, exc_type: Optional[Type[Exception]], exc_val: Optional[Exception],
exc_tb: TracebackType) -> Literal[False]:
self.remove()
return False
def check(self) -> None:
if self.path is None:
return
if os.path.exists(self.path):
raise DuplicateRun()
def create(self) -> None:
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)

View File

@ -0,0 +1,40 @@
#
# 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 pyalpm import Handle # type: ignore
from typing import List, Set
from ahriman.core.configuration import Configuration
class Pacman:
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'):
self.handle.register_syncdb(repository, 0) # 0 is pgp_level
def all_packages(self) -> List[str]:
result: Set[str] = set()
for database in self.handle.get_syncdbs():
result.update({package.name for package in database.pkgcache})
return list(result)

View File

@ -27,7 +27,7 @@ from ahriman.core.util import check_output
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
class RepoWrapper: class Repo:
def __init__(self, name: str, paths: RepositoryPaths, sign_args: List[str]) -> None: def __init__(self, name: str, paths: RepositoryPaths, sign_args: List[str]) -> None:
self.logger = logging.getLogger('build_details') self.logger = logging.getLogger('build_details')

View File

@ -39,10 +39,10 @@ class Task:
self.paths = paths self.paths = paths
section = config.get_section_name('build', architecture) section = config.get_section_name('build', architecture)
self.archbuild_flags = config.get_list(section, 'archbuild_flags') self.archbuild_flags = config.getlist(section, 'archbuild_flags')
self.build_command = config.get(section, 'build_command') self.build_command = config.get(section, 'build_command')
self.makepkg_flags = config.get_list(section, 'makepkg_flags') self.makepkg_flags = config.getlist(section, 'makepkg_flags')
self.makechrootpkg_flags = config.get_list(section, 'makechrootpkg_flags') self.makechrootpkg_flags = config.getlist(section, 'makechrootpkg_flags')
@property @property
def git_path(self) -> str: def git_path(self) -> str:
@ -73,4 +73,4 @@ class Task:
def clone(self, path: Optional[str] = None) -> None: def clone(self, path: Optional[str] = None) -> None:
git_path = path or self.git_path git_path = path or self.git_path
return Task.fetch(git_path, self.package.url) return Task.fetch(git_path, self.package.git_url)

View File

@ -17,11 +17,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import configparser import configparser
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from typing import List, Optional from typing import List, Optional, Type
# built-in configparser extension # built-in configparser extension
@ -35,7 +37,14 @@ class Configuration(configparser.RawConfigParser):
def include(self) -> str: def include(self) -> str:
return self.get('settings', 'include') return self.get('settings', 'include')
def get_list(self, section: str, key: str) -> List[str]: @classmethod
def from_path(cls: Type[Configuration], path: str) -> Configuration:
config = cls()
config.load(path)
config.load_logging()
return config
def getlist(self, section: str, key: str) -> List[str]:
raw = self.get(section, key, fallback=None) raw = self.get(section, key, fallback=None)
if not raw: # empty string or none if not raw: # empty string or none
return [] return []
@ -52,8 +61,7 @@ class Configuration(configparser.RawConfigParser):
def load_includes(self) -> None: def load_includes(self) -> None:
try: try:
include_dir = self.include for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(self.include))):
for conf in filter(lambda p: p.endswith('.ini'), sorted(os.listdir(include_dir))):
self.read(os.path.join(self.include, conf)) self.read(os.path.join(self.include, conf))
except (FileNotFoundError, configparser.NoOptionError): except (FileNotFoundError, configparser.NoOptionError):
pass pass

View File

@ -25,7 +25,17 @@ class BuildFailed(Exception):
Exception.__init__(self, f'Package {package} build failed, check logs for details') Exception.__init__(self, f'Package {package} build failed, check logs for details')
class InvalidOptionException(Exception): class DuplicateRun(Exception):
def __init__(self) -> None:
Exception.__init__(self, 'Another application instance is run')
class InitializeException(Exception):
def __init__(self) -> None:
Exception.__init__(self, 'Could not load service')
class InvalidOption(Exception):
def __init__(self, value: Any) -> None: def __init__(self, value: Any) -> None:
Exception.__init__(self, f'Invalid or unknown option value `{value}`') Exception.__init__(self, f'Invalid or unknown option value `{value}`')
@ -35,16 +45,11 @@ class InvalidPackageInfo(Exception):
Exception.__init__(self, f'There are errors during reading package information: `{details}`') Exception.__init__(self, f'There are errors during reading package information: `{details}`')
class MissingConfiguration(Exception):
def __init__(self, name: str) -> None:
Exception.__init__(self, f'No section `{name}` found')
class ReportFailed(Exception): class ReportFailed(Exception):
def __init__(self, cause: Exception) -> None: def __init__(self) -> None:
Exception.__init__(self, f'Report failed with reason {cause}') Exception.__init__(self, 'Report failed')
class SyncFailed(Exception): class SyncFailed(Exception):
def __init__(self, cause: Exception) -> None: def __init__(self) -> None:
Exception.__init__(self, f'Sync failed with reason {cause}') Exception.__init__(self, 'Sync failed')

View File

@ -20,11 +20,11 @@
import jinja2 import jinja2
import os import os
from typing import Dict from typing import Callable, Dict, Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.report.report import Report from ahriman.core.report.report import Report
from ahriman.core.util import package_like from ahriman.models.package import Package
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -32,37 +32,41 @@ class HTML(Report):
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
Report.__init__(self, architecture, config) Report.__init__(self, architecture, config)
section = self.config.get_section_name('html', self.architecture) section = config.get_section_name('html', architecture)
self.report_path = config.get(section, 'path') self.report_path = config.get(section, 'path')
self.link_path = config.get(section, 'link_path') self.link_path = config.get(section, 'link_path')
self.template_path = config.get(section, 'template_path') self.template_path = config.get(section, 'template_path')
# base template vars # base template vars
if SignSettings.from_option(config.get('sign', 'enabled')) != SignSettings.Disabled:
self.pgp_key = config.get('sign', 'key', fallback=None)
else:
self.pgp_key = None
self.homepage = config.get(section, 'homepage', fallback=None) self.homepage = config.get(section, 'homepage', fallback=None)
self.repository = config.get('repository', 'name') self.repository = config.get('repository', 'name')
def generate(self, path: str) -> None: sign_section = config.get_section_name('sign', architecture)
self.sign_targets = [SignSettings.from_option(opt) for opt in config.getlist(sign_section, 'target')]
self.pgp_key = config.get(sign_section, 'key') if self.sign_targets else None
def generate(self, packages: Iterable[Package]) -> None:
# idea comes from https://stackoverflow.com/a/38642558 # idea comes from https://stackoverflow.com/a/38642558
templates_dir, template_name = os.path.split(self.template_path) templates_dir, template_name = os.path.split(self.template_path)
loader = jinja2.FileSystemLoader(searchpath=templates_dir) loader = jinja2.FileSystemLoader(searchpath=templates_dir)
environment = jinja2.Environment(loader=loader) environment = jinja2.Environment(loader=loader)
template = environment.get_template(template_name) template = environment.get_template(template_name)
packages: Dict[str, str] = {} content = [
for fn in sorted(os.listdir(path)): {
if not package_like(fn): 'filename': filename,
continue 'name': package,
packages[fn] = f'{self.link_path}/{fn}' 'version': base.version
} for base in packages for package, filename in base.packages.items()
]
comparator: Callable[[Dict[str, str]], str] = lambda item: item['filename']
html = template.render( html = template.render(
homepage=self.homepage, homepage=self.homepage,
link_path=self.link_path, link_path=self.link_path,
packages=packages, has_package_signed=SignSettings.SignPackages in self.sign_targets,
has_repo_signed=SignSettings.SignRepository in self.sign_targets,
packages=sorted(content, key=comparator),
pgp_key=self.pgp_key, pgp_key=self.pgp_key,
repository=self.repository) repository=self.repository)

View File

@ -19,32 +19,35 @@
# #
import logging import logging
from typing import Iterable
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import ReportFailed from ahriman.core.exceptions import ReportFailed
from ahriman.models.package import Package
from ahriman.models.report_settings import ReportSettings from ahriman.models.report_settings import ReportSettings
class Report: class Report:
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('builder')
self.architecture = architecture self.architecture = architecture
self.config = config self.config = config
self.logger = logging.getLogger('builder')
@staticmethod @staticmethod
def run(architecture: str, config: Configuration, target: str, path: str) -> None: def run(architecture: str, config: Configuration, target: str, packages: Iterable[Package]) -> None:
provider = ReportSettings.from_option(target) provider = ReportSettings.from_option(target)
if provider == ReportSettings.HTML: if provider == ReportSettings.HTML:
from ahriman.core.report.html import HTML from ahriman.core.report.html import HTML
report: Report = HTML(architecture, config) report: Report = HTML(architecture, config)
else: else:
from ahriman.core.report.dummy import Dummy report = Report(architecture, config)
report = Dummy(architecture, config)
try: try:
report.generate(path) report.generate(packages)
except Exception as e: except Exception:
raise ReportFailed(e) from e report.logger.exception('report generation failed', exc_info=True)
raise ReportFailed()
def generate(self, path: str) -> None: def generate(self, packages: Iterable[Package]) -> None:
raise NotImplementedError pass

View File

@ -21,15 +21,17 @@ import logging
import os import os
import shutil import shutil
from typing import Dict, List, Optional 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.build_tools.task import Task
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.repo.repo_wrapper import RepoWrapper
from ahriman.core.report.report import Report from ahriman.core.report.report import Report
from ahriman.core.sign.gpg_wrapper import GPGWrapper from ahriman.core.sign.gpg import GPG
from ahriman.core.upload.uploader import Uploader from ahriman.core.upload.uploader import Uploader
from ahriman.core.util import package_like from ahriman.core.util import package_like
from ahriman.core.watcher.client import Client
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.repository_paths import RepositoryPaths
@ -41,14 +43,16 @@ class Repository:
self.architecture = architecture self.architecture = architecture
self.config = config self.config = config
self.aur_url = config.get('aur', 'url') self.aur_url = config.get('alpm', 'aur_url')
self.name = config.get('repository', 'name') self.name = config.get('repository', 'name')
self.paths = RepositoryPaths(config.get('repository', 'root'), self.architecture) self.paths = RepositoryPaths(config.get('repository', 'root'), architecture)
self.paths.create_tree() self.paths.create_tree()
self.sign = GPGWrapper(config) self.pacman = Pacman(config)
self.wrapper = RepoWrapper(self.name, self.paths, self.sign.repository_sign_args) 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: def _clear_build(self) -> None:
for package in os.listdir(self.paths.sources): for package in os.listdir(self.paths.sources):
@ -59,8 +63,8 @@ class Repository:
shutil.rmtree(os.path.join(self.paths.manual, package)) shutil.rmtree(os.path.join(self.paths.manual, package))
def _clear_packages(self) -> None: def _clear_packages(self) -> None:
for package in os.listdir(self.paths.packages): for package in self.packages_built():
os.remove(os.path.join(self.paths.packages, package)) os.remove(package)
def packages(self) -> List[Package]: def packages(self) -> List[Package]:
result: Dict[str, Package] = {} result: Dict[str, Package] = {}
@ -69,15 +73,22 @@ class Repository:
continue continue
full_path = os.path.join(self.paths.repository, fn) full_path = os.path.join(self.paths.repository, fn)
try: try:
local = Package.load(full_path, self.aur_url) local = Package.load(full_path, self.pacman, self.aur_url)
result.setdefault(local.base, local).packages.update(local.packages) result.setdefault(local.base, local).packages.update(local.packages)
except Exception: except Exception:
self.logger.exception(f'could not load package from {fn}', exc_info=True) self.logger.exception(f'could not load package from {fn}', exc_info=True)
continue continue
return list(result.values()) return list(result.values())
def process_build(self, updates: List[Package]) -> List[str]: 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: def build_single(package: Package) -> None:
self.web.set_building(package.base)
task = Task(package, self.architecture, self.config, self.paths) task = Task(package, self.architecture, self.config, self.paths)
task.clone() task.clone()
built = task.build() built = task.build()
@ -89,74 +100,85 @@ class Repository:
try: try:
build_single(package) build_single(package)
except Exception: except Exception:
self.web.set_failed(package.base)
self.logger.exception(f'{package.base} ({self.architecture}) build exception', exc_info=True) self.logger.exception(f'{package.base} ({self.architecture}) build exception', exc_info=True)
continue continue
self._clear_build() self._clear_build()
return [ return self.packages_built()
os.path.join(self.paths.packages, fn)
for fn in os.listdir(self.paths.packages)
]
def process_remove(self, packages: List[str]) -> str: def process_remove(self, packages: Iterable[str]) -> str:
def remove_single(package: str) -> None: def remove_single(package: str) -> None:
try: try:
self.wrapper.remove(package, package) self.repo.remove(package, package)
except Exception: except Exception:
self.logger.exception(f'could not remove {package}', exc_info=True) self.logger.exception(f'could not remove {package}', exc_info=True)
requested = set(packages)
for local in self.packages(): for local in self.packages():
if local.base in packages: if local.base in packages:
to_remove = local.packages to_remove = set(local.packages.keys())
elif local.packages.intersection(packages): elif requested.intersection(local.packages.keys()):
to_remove = local.packages.intersection(packages) to_remove = requested.intersection(local.packages.keys())
else: else:
to_remove = set() to_remove = set()
self.web.remove(local.base, to_remove)
for package in to_remove: for package in to_remove:
remove_single(package) remove_single(package)
return self.wrapper.repo_path return self.repo.repo_path
def process_report(self, targets: Optional[List[str]]) -> None: def process_report(self, targets: Optional[Iterable[str]]) -> None:
if targets is None: if targets is None:
targets = self.config.get_list('report', 'target') targets = self.config.getlist('report', 'target')
for target in targets: for target in targets:
Report.run(self.architecture, self.config, target, self.paths.repository) Report.run(self.architecture, self.config, target, self.packages())
def process_sync(self, targets: Optional[List[str]]) -> None: def process_sync(self, targets: Optional[Iterable[str]]) -> None:
if targets is None: if targets is None:
targets = self.config.get_list('upload', 'target') targets = self.config.getlist('upload', 'target')
for target in targets: for target in targets:
Uploader.run(self.architecture, self.config, target, self.paths.repository) Uploader.run(self.architecture, self.config, target, self.paths.repository)
def process_update(self, packages: List[str]) -> str: def process_update(self, packages: Iterable[str]) -> str:
for package in packages: for package in packages:
files = self.sign.sign_package(package) 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: for src in files:
dst = os.path.join(self.paths.repository, os.path.basename(src)) dst = os.path.join(self.paths.repository, os.path.basename(src))
shutil.move(src, dst) shutil.move(src, dst)
package_fn = os.path.join(self.paths.repository, os.path.basename(package)) package_fn = os.path.join(self.paths.repository, os.path.basename(package))
self.wrapper.add(package_fn) 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() self._clear_packages()
return self.wrapper.repo_path return self.repo.repo_path
def updates_aur(self, no_vcs: bool) -> List[Package]: def updates_aur(self, filter_packages: Iterable[str], no_vcs: bool) -> List[Package]:
result: List[Package] = [] result: List[Package] = []
ignore_list = self.config.get_list(
self.config.get_section_name('build', self.architecture), 'ignore_packages') build_section = self.config.get_section_name('build', self.architecture)
ignore_list = self.config.getlist(build_section, 'ignore_packages')
for local in self.packages(): for local in self.packages():
if local.base in ignore_list: if local.base in ignore_list:
continue continue
if local.is_vcs and no_vcs: if local.is_vcs and no_vcs:
continue continue
if filter_packages and local.base not in filter_packages:
continue
try: try:
remote = Package.load(local.base, self.aur_url) remote = Package.load(local.base, self.pacman, self.aur_url)
if local.is_outdated(remote): if local.is_outdated(remote):
result.append(remote) result.append(remote)
self.web.set_pending(local.base)
except Exception: except Exception:
self.web.set_failed(local.base)
self.logger.exception(f'could not load remote package {local.base}', exc_info=True) self.logger.exception(f'could not load remote package {local.base}', exc_info=True)
continue continue
@ -166,8 +188,12 @@ class Repository:
result: List[Package] = [] result: List[Package] = []
for fn in os.listdir(self.paths.manual): for fn in os.listdir(self.paths.manual):
local = Package.load(os.path.join(self.paths.manual, fn), self.aur_url) try:
local = Package.load(os.path.join(self.paths.manual, fn), self.pacman, self.aur_url)
result.append(local) 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() self._clear_manual()
return result return result

View File

@ -28,41 +28,42 @@ from ahriman.core.util import check_output
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
class GPGWrapper: class GPG:
def __init__(self, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('build_details') self.logger = logging.getLogger('build_details')
self.config = config
self.key = config.get('sign', 'key', fallback=None) self.section = config.get_section_name('sign', architecture)
self.sign = SignSettings.from_option(config.get('sign', 'enabled')) 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 ''
@property @property
def repository_sign_args(self) -> List[str]: def repository_sign_args(self) -> List[str]:
if self.sign != SignSettings.SignRepository: if SignSettings.SignRepository not in self.target:
return [] return []
return ['--sign', '--key', self.key] if self.key else ['--sign'] return ['--sign', '--key', self.default_key]
def process(self, path: str) -> List[str]: def process(self, path: str, key: str) -> List[str]:
check_output( check_output(
*self.sign_cmd(path), *self.sign_cmd(path, key),
exception=BuildFailed(path), exception=BuildFailed(path),
cwd=os.path.dirname(path), cwd=os.path.dirname(path),
logger=self.logger) logger=self.logger)
return [path, f'{path}.sig'] return [path, f'{path}.sig']
def sign_cmd(self, path: str) -> List[str]: def sign_cmd(self, path: str, key: str) -> List[str]:
cmd = ['gpg'] cmd = ['gpg']
if self.key is not None: cmd.extend(['-u', key])
cmd.extend(['-u', self.key])
cmd.extend(['-b', path]) cmd.extend(['-b', path])
return cmd return cmd
def sign_package(self, path: str) -> List[str]: def sign_package(self, path: str, base: str) -> List[str]:
if self.sign != SignSettings.SignPackages: if SignSettings.SignPackages not in self.target:
return [path] return [path]
return self.process(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]: def sign_repository(self, path: str) -> List[str]:
if self.sign != SignSettings.SignRepository: if SignSettings.SignRepository not in self.target:
return [path] return [path]
return self.process(path) return self.process(path, self.default_key)

79
src/ahriman/core/tree.py Normal file
View File

@ -0,0 +1,79 @@
#
# 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 shutil
import tempfile
from typing import Iterable, List, Set
from ahriman.core.build_tools.task import Task
from ahriman.models.package import Package
class Tree:
def __init__(self) -> None:
self.packages: List[Leaf] = []
def levels(self) -> List[List[Package]]:
result: List[List[Package]] = []
unprocessed = [leaf for leaf in self.packages]
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()
@property
def items(self) -> Iterable[str]:
return self.package.packages.keys()
def is_root(self, packages: Iterable[Leaf]) -> bool:
'''
:param packages:
: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
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

@ -26,8 +26,8 @@ class Rsync(Uploader):
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
Uploader.__init__(self, architecture, config) Uploader.__init__(self, architecture, config)
section = self.config.get_section_name('rsync', self.architecture) section = config.get_section_name('rsync', architecture)
self.remote = self.config.get(section, 'remote') self.remote = config.get(section, 'remote')
def sync(self, path: str) -> None: def sync(self, path: str) -> None:
check_output('rsync', '--archive', '--verbose', '--compress', '--partial', '--progress', '--delete', path, self.remote, check_output('rsync', '--archive', '--verbose', '--compress', '--partial', '--progress', '--delete', path, self.remote,

View File

@ -26,8 +26,8 @@ class S3(Uploader):
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
Uploader.__init__(self, architecture, config) Uploader.__init__(self, architecture, config)
section = self.config.get_section_name('s3', self.architecture) section = config.get_section_name('s3', architecture)
self.bucket = self.config.get(section, 'bucket') self.bucket = config.get(section, 'bucket')
def sync(self, path: str) -> None: def sync(self, path: str) -> None:
# TODO rewrite to boto, but it is bullshit # TODO rewrite to boto, but it is bullshit

View File

@ -27,9 +27,9 @@ from ahriman.models.upload_settings import UploadSettings
class Uploader: class Uploader:
def __init__(self, architecture: str, config: Configuration) -> None: def __init__(self, architecture: str, config: Configuration) -> None:
self.logger = logging.getLogger('builder')
self.architecture = architecture self.architecture = architecture
self.config = config self.config = config
self.logger = logging.getLogger('builder')
@staticmethod @staticmethod
def run(architecture: str, config: Configuration, target: str, path: str) -> None: def run(architecture: str, config: Configuration, target: str, path: str) -> None:
@ -41,13 +41,13 @@ class Uploader:
from ahriman.core.upload.s3 import S3 from ahriman.core.upload.s3 import S3
uploader = S3(architecture, config) uploader = S3(architecture, config)
else: else:
from ahriman.core.upload.dummy import Dummy uploader = Uploader(architecture, config)
uploader = Dummy(architecture, config)
try: try:
uploader.sync(path) uploader.sync(path)
except Exception as e: except Exception:
raise SyncFailed(e) from e uploader.logger.exception('remote sync failed', exc_info=True)
raise SyncFailed()
def sync(self, path: str) -> None: def sync(self, path: str) -> None:
raise NotImplementedError pass

View File

@ -24,7 +24,7 @@ from typing import Optional
def check_output(*args: str, exception: Optional[Exception], def check_output(*args: str, exception: Optional[Exception],
cwd = None, stderr: int = subprocess.STDOUT, cwd: Optional[str] = None, stderr: int = subprocess.STDOUT,
logger: Optional[Logger] = None) -> str: logger: Optional[Logger] = None) -> str:
try: try:
result = subprocess.check_output(args, cwd=cwd, stderr=stderr).decode('utf8').strip() result = subprocess.check_output(args, cwd=cwd, stderr=stderr).decode('utf8').strip()

View File

@ -17,10 +17,3 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from ahriman.core.report.report import Report
class Dummy(Report):
def generate(self, path: str) -> None:
pass

View File

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

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

@ -0,0 +1,43 @@
#
# 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 datetime
from enum import Enum
from typing import Optional, Union
class BuildStatusEnum(Enum):
Unknown = 'unknown'
Pending = 'pending'
Building = 'building'
Failed = 'failed'
Success = 'success'
class BuildStatus:
def __init__(self, status: Union[BuildStatusEnum, str, None] = None,
timestamp: Optional[datetime.datetime] = None) -> None:
self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown
self._timestamp = timestamp or datetime.datetime.utcnow()
@property
def timestamp(self) -> str:
return self._timestamp.strftime('%Y-%m-%d %H:%M:%S')

View File

@ -19,17 +19,17 @@
# #
from __future__ import annotations from __future__ import annotations
import shutil import aur # type: ignore
import aur
import os import os
import shutil
import tempfile import tempfile
from configparser import RawConfigParser from dataclasses import dataclass
from dataclasses import dataclass, field from pyalpm import vercmp # type: ignore
from srcinfo.parse import parse_srcinfo from srcinfo.parse import parse_srcinfo # type: ignore
from typing import Set, Type from typing import Dict, List, Optional, Set, Type
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.exceptions import InvalidPackageInfo from ahriman.core.exceptions import InvalidPackageInfo
from ahriman.core.util import check_output from ahriman.core.util import check_output
@ -38,8 +38,12 @@ from ahriman.core.util import check_output
class Package: class Package:
base: str base: str
version: str version: str
url: str aur_url: str
packages: Set[str] = field(default_factory=set) packages: Dict[str, str] # map of package name to archive name
@property
def git_url(self) -> str:
return f'{self.aur_url}/{self.base}.git'
@property @property
def is_vcs(self) -> bool: def is_vcs(self) -> bool:
@ -50,6 +54,10 @@ class Package:
or self.base.endswith('-hg')\ or self.base.endswith('-hg')\
or self.base.endswith('-svn') or self.base.endswith('-svn')
@property
def web_url(self) -> str:
return f'{self.aur_url}/packages/{self.base}'
# additional method to handle vcs versions # additional method to handle vcs versions
def actual_version(self) -> str: def actual_version(self) -> str:
if not self.is_vcs: if not self.is_vcs:
@ -58,9 +66,9 @@ class Package:
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
clone_dir = tempfile.mkdtemp() clone_dir = tempfile.mkdtemp()
try: try:
Task.fetch(clone_dir, self.url) Task.fetch(clone_dir, self.git_url)
# update pkgver first # update pkgver first
check_output('makepkg', '--nodeps', '--noprepare', '--nobuild', check_output('makepkg', '--nodeps', '--nobuild',
exception=None, cwd=clone_dir) exception=None, cwd=clone_dir)
# generate new .SRCINFO and put it to parser # generate new .SRCINFO and put it to parser
src_info_source = check_output('makepkg', '--printsrcinfo', src_info_source = check_output('makepkg', '--printsrcinfo',
@ -68,44 +76,60 @@ class Package:
src_info, errors = parse_srcinfo(src_info_source) src_info, errors = parse_srcinfo(src_info_source)
if errors: if errors:
raise InvalidPackageInfo(errors) raise InvalidPackageInfo(errors)
return f'{src_info["pkgver"]}-{src_info["pkgrel"]}' return self.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel'])
finally: finally:
shutil.rmtree(clone_dir, ignore_errors=True) shutil.rmtree(clone_dir, ignore_errors=True)
@classmethod @classmethod
def from_archive(cls: Type[Package], path: str, aur_url: str) -> Package: def from_archive(cls: Type[Package], path: str, pacman: Pacman, aur_url: str) -> Package:
package, base, version = check_output('expac', '-p', '%n %e %v', path, exception=None).split() package = pacman.handle.load_pkg(path)
return cls(base, version, f'{aur_url}/{base}.git', packages={package}) return cls(package.base, package.version, aur_url, {package.name: os.path.basename(path)})
@classmethod @classmethod
def from_aur(cls: Type[Package], name: str, aur_url: str)-> Package: def from_aur(cls: Type[Package], name: str, aur_url: str)-> Package:
package = aur.info(name) package = aur.info(name)
return cls(package.package_base, package.version, f'{aur_url}/{package.package_base}.git', return cls(package.package_base, package.version, aur_url, {package.name: ''})
packages={package.name})
@classmethod @classmethod
def from_build(cls: Type[Package], path: str) -> Package: def from_build(cls: Type[Package], path: str, aur_url: str) -> Package:
git_config = RawConfigParser()
git_config.read(os.path.join(path, '.git', 'config'))
with open(os.path.join(path, '.SRCINFO')) as fn: with open(os.path.join(path, '.SRCINFO')) as fn:
src_info, errors = parse_srcinfo(fn.read()) src_info, errors = parse_srcinfo(fn.read())
if errors: if errors:
raise InvalidPackageInfo(errors) raise InvalidPackageInfo(errors)
packages = {key: '' for key in src_info['packages'].keys()}
version = cls.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel'])
return cls(src_info['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.get('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()) packages = set(src_info['packages'].keys())
return set(depends + makedepends) - packages
return cls(src_info['pkgbase'], f'{src_info["pkgver"]}-{src_info["pkgrel"]}', @staticmethod
git_config.get('remote "origin"', 'url'), packages-packages) def full_version(epoch: Optional[str], pkgver: str, pkgrel: str) -> str:
prefix = f'{epoch}:' if epoch else ''
return f'{prefix}{pkgver}-{pkgrel}'
@classmethod @staticmethod
def load(cls: Type[Package], path: str, aur_url: str) -> Package: def load(path: str, pacman: Pacman, aur_url: str) -> Package:
try: try:
if os.path.isdir(path): if os.path.isdir(path):
package: Package = cls.from_build(path) package: Package = Package.from_build(path, aur_url)
elif os.path.exists(path): elif os.path.exists(path):
package = cls.from_archive(path, aur_url) package = Package.from_archive(path, pacman, aur_url)
else: else:
package = cls.from_aur(path, aur_url) package = Package.from_aur(path, aur_url)
return package return package
except InvalidPackageInfo: except InvalidPackageInfo:
raise raise
@ -114,5 +138,5 @@ class Package:
def is_outdated(self, remote: Package) -> bool: def is_outdated(self, remote: Package) -> bool:
remote_version = remote.actual_version() # either normal version or updated VCS remote_version = remote.actual_version() # either normal version or updated VCS
result = check_output('vercmp', self.version, remote_version, exception=None) result: int = vercmp(self.version, remote_version)
return True if int(result) < 0 else False return result < 0

View File

@ -20,16 +20,15 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOptionException from ahriman.core.exceptions import InvalidOption
class ReportSettings(Enum): class ReportSettings(Enum):
HTML = auto() HTML = auto()
@classmethod @staticmethod
def from_option(cls: Type[ReportSettings], value: str) -> ReportSettings: def from_option(value: str) -> ReportSettings:
if value.lower() in ('html',): if value.lower() in ('html',):
return cls.HTML return ReportSettings.HTML
raise InvalidOptionException(value) raise InvalidOption(value)

View File

@ -29,22 +29,37 @@ class RepositoryPaths:
@property @property
def chroot(self) -> str: def chroot(self) -> str:
'''
:return: directory for devtools chroot
'''
return os.path.join(self.root, 'chroot') return os.path.join(self.root, 'chroot')
@property @property
def manual(self) -> str: def manual(self) -> str:
'''
:return: directory for manual updates (i.e. from add command)
'''
return os.path.join(self.root, 'manual') return os.path.join(self.root, 'manual')
@property @property
def packages(self) -> str: def packages(self) -> str:
'''
:return: directory for built packages
'''
return os.path.join(self.root, 'packages') return os.path.join(self.root, 'packages')
@property @property
def repository(self) -> str: def repository(self) -> str:
'''
:return: repository directory
'''
return os.path.join(self.root, 'repository', self.architecture) return os.path.join(self.root, 'repository', self.architecture)
@property @property
def sources(self) -> str: def sources(self) -> str:
'''
:return: directory for downloaded PKGBUILDs for current build
'''
return os.path.join(self.root, 'sources') return os.path.join(self.root, 'sources')
def create_tree(self) -> None: def create_tree(self) -> None:

View File

@ -20,22 +20,18 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOptionException from ahriman.core.exceptions import InvalidOption
class SignSettings(Enum): class SignSettings(Enum):
Disabled = auto()
SignPackages = auto() SignPackages = auto()
SignRepository = auto() SignRepository = auto()
@classmethod @staticmethod
def from_option(cls: Type[SignSettings], value: str) -> SignSettings: def from_option(value: str) -> SignSettings:
if value.lower() in ('no', 'disabled'): if value.lower() in ('package', 'packages', 'sign-package'):
return cls.Disabled return SignSettings.SignPackages
elif value.lower() in ('package', 'packages', 'sign-package'):
return cls.SignPackages
elif value.lower() in ('repository', 'sign-repository'): elif value.lower() in ('repository', 'sign-repository'):
return cls.SignRepository return SignSettings.SignRepository
raise InvalidOptionException(value) raise InvalidOption(value)

View File

@ -20,19 +20,18 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, auto from enum import Enum, auto
from typing import Type
from ahriman.core.exceptions import InvalidOptionException from ahriman.core.exceptions import InvalidOption
class UploadSettings(Enum): class UploadSettings(Enum):
Rsync = auto() Rsync = auto()
S3 = auto() S3 = auto()
@classmethod @staticmethod
def from_option(cls: Type[UploadSettings], value: str) -> UploadSettings: def from_option(value: str) -> UploadSettings:
if value.lower() in ('rsync',): if value.lower() in ('rsync',):
return cls.Rsync return UploadSettings.Rsync
elif value.lower() in ('s3',): elif value.lower() in ('s3',):
return cls.S3 return UploadSettings.S3
raise InvalidOptionException(value) raise InvalidOption(value)

View File

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

View File

@ -17,10 +17,3 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from ahriman.core.upload.uploader import Uploader
class Dummy(Uploader):
def sync(self, path: str) -> None:
pass

View File

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

View File

@ -0,0 +1,41 @@
#
# 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 aiohttp.web import middleware, Request
from aiohttp.web_exceptions import HTTPClientError
from aiohttp.web_response import StreamResponse
from logging import Logger
from typing import Awaitable, Callable
HandlerType = Callable[[Request], Awaitable[StreamResponse]]
def exception_handler(logger: Logger) -> Callable[[Request, HandlerType], Awaitable[StreamResponse]]:
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
try:
return await handler(request)
except HTTPClientError:
raise
except Exception:
logger.exception(f'exception during performing request to {request.path}', exc_info=True)
raise
return handle

34
src/ahriman/web/routes.py Normal file
View File

@ -0,0 +1,34 @@
#
# 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 aiohttp.web import Application
from ahriman.web.views.index import IndexView
from ahriman.web.views.package import PackageView
from ahriman.web.views.packages import PackagesView
def setup_routes(app: Application) -> None:
app.router.add_get('/', IndexView)
app.router.add_get('/index.html', IndexView)
app.router.add_post('/api/v1/packages', PackagesView)
app.router.add_delete('/api/v1/packages/{package}', PackageView)
app.router.add_post('/api/v1/packages/{package}', PackageView)

View File

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

View File

@ -0,0 +1,31 @@
#
# 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 aiohttp.web import View
from ahriman.core.watcher.watcher import Watcher
# special class to make it typed
class BaseView(View):
@property
def service(self) -> Watcher:
watcher: Watcher = self.request.app['watcher']
return watcher

View File

@ -0,0 +1,50 @@
#
# 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 aiohttp_jinja2 # type: ignore
from typing import Any, Dict
import ahriman.version as version
from ahriman.web.views.base import BaseView
class IndexView(BaseView):
@aiohttp_jinja2.template("build-status.jinja2") # type: ignore
async def get(self) -> Dict[str, Any]:
# some magic to make it jinja-friendly
packages = [
{
'base': package.base,
'packages': [p for p in sorted(package.packages)],
'status': status.status.value,
'timestamp': status.timestamp,
'version': package.version,
'web_url': package.web_url
} for package, status in sorted(self.service.packages, key=lambda item: item[0].base)
]
return {
'architecture': self.service.architecture,
'packages': packages,
'repository': self.service.repository.name,
'version': version.__version__,
}

View File

@ -0,0 +1,43 @@
#
# 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 aiohttp.web import HTTPOk, Response
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.web.views.base import BaseView
class PackageView(BaseView):
async def delete(self) -> Response:
base = self.request.match_info['package']
self.service.remove(base)
return HTTPOk()
async def post(self) -> Response:
base = self.request.match_info['package']
data = await self.request.json()
package = Package(**data['package']) if 'package' in data else None
status = BuildStatusEnum(data['status'])
self.service.update(base, status, package)
return HTTPOk()

View File

@ -0,0 +1,30 @@
#
# 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 aiohttp.web import HTTPOk, Response
from ahriman.web.views.base import BaseView
class PackagesView(BaseView):
async def post(self) -> Response:
self.service.load()
return HTTPOk()

75
src/ahriman/web/web.py Normal file
View File

@ -0,0 +1,75 @@
#
# 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 aiohttp_jinja2 # type: ignore
import jinja2
import logging
from aiohttp import web
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import InitializeException
from ahriman.core.watcher.watcher import Watcher
from ahriman.web.middlewares.exception_handler import exception_handler
from ahriman.web.routes import setup_routes
async def on_shutdown(app: web.Application) -> None:
app.logger.warning('server terminated')
async def on_startup(app: web.Application) -> None:
app.logger.info('server started')
try:
app['watcher'].load()
except Exception:
app.logger.exception('could not load packages', exc_info=True)
raise InitializeException()
def run_server(app: web.Application, architecture: str) -> None:
app.logger.info('start server')
section = app['config'].get_section_name('web', architecture)
host = app['config'].get(section, 'host')
port = app['config'].getint(section, 'port')
web.run_app(app, host=host, port=port, handle_signals=False)
def setup_service(architecture: str, config: Configuration) -> web.Application:
app = web.Application(logger=logging.getLogger('http'))
app.on_shutdown.append(on_shutdown)
app.on_startup.append(on_startup)
app.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True))
app.middlewares.append(exception_handler(app.logger))
app.logger.info('setup routes')
setup_routes(app)
app.logger.info('setup templates')
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(config.get('web', 'templates')))
app.logger.info('setup configuration')
app['config'] = config
app.logger.info('setup watcher')
app['watcher'] = Watcher(architecture, config)
return app