Compare commits

..

9 Commits

Author SHA1 Message Date
310eba694f Release 0.14.1 2021-03-17 03:35:38 +03:00
22d2057c3a replace script by makefile 2021-03-17 03:35:16 +03:00
47fc5bca57 Release 0.14.0 2021-03-16 20:11:56 +03:00
d4222eca25 add dump config option, change all timestamp objects to int, check git
directory
2021-03-16 05:24:07 +03:00
b5046b787c some improvements
* handle exceptions in multiprocessing
* readme update
* safe logger handler implementation (uses either stderr or
  rotatingfiles)
* user UID check
2021-03-16 04:25:58 +03:00
75c0cc970e Release 0.13.0 2021-03-16 01:40:48 +03:00
504d57b2f5 more package propertieis 2021-03-16 01:39:16 +03:00
4c20d0241a add clean subcommand 2021-03-15 23:34:50 +03:00
db0a6bf34e smart fetch & vcs cache 2021-03-15 23:28:08 +03:00
25 changed files with 412 additions and 163 deletions

46
Makefile Normal file
View File

@ -0,0 +1,46 @@
.PHONY: archive archive_directory archlinux clean directory push version
.DEFAULT_GOAL := archlinux
PROJECT := ahriman
FILES := COPYING CONFIGURING.md README.md package src setup.py
TARGET_FILES := $(addprefix $(PROJECT)/, $(FILES))
IGNORE_FILES := package/archlinux src/.mypy_cache
ifndef VERSION
$(error VERSION is not set)
endif
$(TARGET_FILES) : $(addprefix $(PROJECT), %) : $(addprefix ., %) directory version
@cp -rp $< $@
archive: archive_directory
tar cJf "$(PROJECT)-$(VERSION)-src.tar.xz" "$(PROJECT)"
rm -rf "$(PROJECT)"
archive_directory: $(TARGET_FILES)
rm -fr $(addprefix $(PROJECT)/, $(IGNORE_FILES))
find $(PROJECT) -type f -name '*.pyc' -delete
find $(PROJECT) -depth -type d -name '__pycache__' -execdir rm -rf {} +
find $(PROJECT) -depth -type d -name '*.egg-info' -execdir rm -rf {} +
archlinux: archive
sed -i "/sha512sums=('[0-9A-Fa-f]*/s/[^'][^)]*/sha512sums=('$$(sha512sum $(PROJECT)-$(VERSION)-src.tar.xz | awk '{print $$1}')'/" package/archlinux/PKGBUILD
sed -i "s/pkgver=[0-9.]*/pkgver=$(VERSION)/" package/archlinux/PKGBUILD
clean:
find . -type f -name '$(PROJECT)-*-src.tar.xz' -delete
rm -rf "$(PROJECT)"
directory: clean
mkdir "$(PROJECT)"
push: archlinux
git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $(VERSION)"
git push
git tag "$(VERSION)"
git push --tags
version:
sed -i "/__version__ = '[0-9.]*/s/[^'][^)]*/__version__ = '$(VERSION)'/" src/ahriman/version.py

View File

@ -15,13 +15,54 @@ Wrapper for managing custom repository inspired by [repo-scripts](https://github
## 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](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):
* create build command, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/custom-x86_64-build` (you can choose any name for command); ```shell
* create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,custom}.conf`; echo 'PACKAGES="John Doe <john@doe.com>"' | sudo -u ahriman tee -a /var/lib/ahriman/.makepkg.conf
* change configuration file, add your own repository, add multilib repository etc; ```
* set `build.build_command` setting to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow running command without password. * Configure build tools (it is required for correct dependency management system):
* Start and enable `ahriman.timer` via `systemctl`.
* Add packages by using `ahriman add {package}` command. * create build command, e.g. `ln -s /usr/bin/archbuild /usr/local/bin/ahriman-x86_64-build` (you can choose any name for command, basically it should be `{name}-{arch}-build`);
* create configuration file, e.g. `cp /usr/share/devtools/pacman-{extra,ahriman}.conf` (same as previous `pacman-{name}.conf`);
* change configuration file, add your own repository, add multilib repository etc. Hint: you can use `Include` option as well;
* set `build_command` option to point to your command;
* configure `/etc/sudoers.d/ahriman` to allow running command without a password.
```shell
ln -s /usr/bin/archbuild /usr/local/bin/ahriman-x86_64-build
cp /usr/share/devtools/pacman-{extra,ahriman}.conf
echo '[multilib]' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'Include = /etc/pacman.d/mirrorlist' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo '[aur-clone]' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'SigLevel = Optional TrustAll' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo 'Server = file:///var/lib/ahriman/repository/$arch' | tee -a /usr/share/devtools/pacman-ahriman.conf
echo '[build]' | tee -a /etc/ahriman.ini.d/build.ini
echo 'build_command = ahriman-x86_64-build' | tee -a /etc/ahriman.ini.d/build.ini
echo 'Cmnd_Alias CARCHBUILD_CMD = /usr/local/bin/ahriman-x86_64-build *' | tee -a /etc/sudoers.d/ahriman
echo 'ahriman ALL=(ALL) NOPASSWD: CARCHBUILD_CMD' | tee -a /etc/sudoers.d/ahriman
chmod 400 /etc/sudoers.d/ahriman
```
* Start and enable `ahriman@.timer` via `systemctl`:
```shell
systemctl enable --now ahriman@x86_64.timer
```
* Start and enable status page:
```shell
systemctl enable --now ahriman-web@x86_64
```
* Add packages by using `ahriman add {package}` command:
```shell
sudo -u ahriman ahriman -a x86_64 add yay
```

View File

@ -1,35 +0,0 @@
#!/bin/bash
set -e
VERSION="$1"
ARCHIVE="ahriman"
FILES="COPYING CONFIGURING.md README.md package src setup.py"
IGNORELIST="build .idea package/archlinux package/*src.tar.xz"
# set version
sed -i "/__version__ = '[0-9.]*/s/[^'][^)]*/__version__ = '$VERSION'/" src/ahriman/version.py
# create archive
[[ -e $ARCHIVE-$VERSION-src.tar.xz ]] && rm -f "$ARCHIVE-$VERSION-src.tar.xz"
[[ -d $ARCHIVE ]] && rm -rf "$ARCHIVE"
mkdir "$ARCHIVE"
for FILE in ${FILES[*]}; do cp -r "$FILE" "$ARCHIVE"; done
for FILE in ${IGNORELIST[*]}; do rm -rf "$ARCHIVE/$FILE"; done
tar cJf "$ARCHIVE-$VERSION-src.tar.xz" "$ARCHIVE"
rm -rf "$ARCHIVE"
# update checksums
SHA512SUMS=$(sha512sum $ARCHIVE-$VERSION-src.tar.xz | awk '{print $1}')
sed -i "/sha512sums=('[0-9A-Fa-f]*/s/[^'][^)]*/sha512sums=('$SHA512SUMS'/" package/archlinux/PKGBUILD
sed -i "s/pkgver=[0-9.]*/pkgver=$VERSION/" package/archlinux/PKGBUILD
# clear
find . -type f -name '*src.tar.xz' -not -name "*$VERSION-src.tar.xz" -exec rm -f {} \;
read -p "Publish release? [Ny] " -n 1 -r
if [[ $REPLY =~ ^[Yy]$ ]]; then
git add package/archlinux/PKGBUILD src/ahriman/version.py
git commit -m "Release $VERSION" && git push
git tag "$VERSION" && git push --tags
fi

View File

@ -1,7 +1,7 @@
# Maintainer: Evgeniy Alekseev # Maintainer: Evgeniy Alekseev
pkgname='ahriman' pkgname='ahriman'
pkgver=0.12.2 pkgver=0.14.1
pkgrel=1 pkgrel=1
pkgdesc="ArcHlinux ReposItory MANager" pkgdesc="ArcHlinux ReposItory MANager"
arch=('any') arch=('any')
@ -23,7 +23,7 @@ optdepends=('aws-cli: sync to s3'
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sysusers' 'ahriman.sysusers'
'ahriman.tmpfiles') 'ahriman.tmpfiles')
sha512sums=('8a140f11819a103b50bc8e8477f0d49a2c7468da4fbcaebc9b0519b29d964b9ca04224d171e3a6ecf00e371d09427c45d363e781d2df82cf0c6cbe9d9c829cfe' sha512sums=('54286cfd1c9b03e7adfa639b976ace233e4e3ea8d2a2cbd11c22fc43eda60906e1d3b795e1505b40e41171948ba95d6591a4f7c328146200f4622a8ed657e8a5'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075' '13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4') '55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini' backup=('etc/ahriman.ini'

View File

@ -2,17 +2,11 @@
keys = root,builder,build_details,http keys = root,builder,build_details,http
[handlers] [handlers]
keys = console_handler,build_file_handler,file_handler,http_handler keys = build_file_handler,file_handler,http_handler
[formatters] [formatters]
keys = generic_format keys = generic_format
[handler_console_handler]
class = StreamHandler
level = DEBUG
formatter = generic_format
args = (sys.stdout,)
[handler_file_handler] [handler_file_handler]
class = logging.handlers.RotatingFileHandler class = logging.handlers.RotatingFileHandler
level = DEBUG level = DEBUG

View File

@ -33,14 +33,18 @@
<tr class="header"> <tr class="header">
<th>package</th> <th>package</th>
<th>version</th> <th>version</th>
<th>archive size</th>
<th>installed size</th> <th>installed size</th>
<th>build date</th>
</tr> </tr>
{% for package in packages %} {% for package in packages %}
<tr class="package"> <tr class="package">
<td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td> <td class="include-search"><a href="{{ link_path|e }}/{{ package.filename|e }}" title="{{ package.name|e }}">{{ package.name|e }}</a></td>
<td>{{ package.version|e }}</td> <td>{{ package.version|e }}</td>
<td>{{ package.archive_size|e }}</td>
<td>{{ package.installed_size|e }}</td> <td>{{ package.installed_size|e }}</td>
<td>{{ package.build_date|e }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -18,6 +18,8 @@
# 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 logging
import sys
from multiprocessing import Pool from multiprocessing import Pool
@ -28,15 +30,21 @@ from ahriman.application.lock import Lock
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
def _call(args: argparse.Namespace, architecture: str, config: Configuration) -> None: def _call(args: argparse.Namespace, architecture: str, config: Configuration) -> bool:
''' '''
additional function to wrap all calls for multiprocessing library additional function to wrap all calls for multiprocessing library
:param args: command line args :param args: command line args
:param architecture: repository architecture :param architecture: repository architecture
:param config: configuration instance :param config: configuration instance
:return: True on success, False otherwise
''' '''
with Lock(args.lock, architecture, args.force, config): try:
with Lock(args.lock, architecture, args.force, args.unsafe, config):
args.fn(args, architecture, config) args.fn(args, architecture, config)
return True
except Exception:
logging.getLogger('root').exception('process exception', exc_info=True)
return False
def add(args: argparse.Namespace, architecture: str, config: Configuration) -> None: def add(args: argparse.Namespace, architecture: str, config: Configuration) -> None:
@ -49,6 +57,32 @@ def add(args: argparse.Namespace, architecture: str, config: Configuration) -> N
Application(architecture, config).add(args.package, args.without_dependencies) Application(architecture, config).add(args.package, args.without_dependencies)
def clean(args: argparse.Namespace, architecture: str, config: Configuration) -> None:
'''
clean caches callback
:param args: command line args
:param architecture: repository architecture
:param config: configuration instance
'''
Application(architecture, config).clean(args.no_build, args.no_cache, args.no_chroot,
args.no_manual, args.no_packages)
def dump_config(args: argparse.Namespace, architecture: str, config: Configuration) -> None:
'''
configuration dump callback
:param args: command line args
:param architecture: repository architecture
:param config: configuration instance
'''
result = config.dump(architecture)
for section, values in sorted(result.items()):
print(f'[{section}]')
for key, value in sorted(values.items()):
print(f'{key} = {value}')
print()
def rebuild(args: argparse.Namespace, architecture: str, config: Configuration) -> None: def rebuild(args: argparse.Namespace, architecture: str, config: Configuration) -> None:
''' '''
world rebuild callback world rebuild callback
@ -100,14 +134,14 @@ def update(args: argparse.Namespace, architecture: str, config: Configuration) -
''' '''
# typing workaround # typing workaround
def log_fn(line: str) -> None: def log_fn(line: str) -> None:
return print(line) if args.dry_run else app.logger.info(line) return print(line) if args.dry_run else application.logger.info(line)
app = Application(architecture, config) application = Application(architecture, config)
packages = app.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, log_fn) packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, log_fn)
if args.dry_run: if args.dry_run:
return return
app.update(packages) application.update(packages)
def web(args: argparse.Namespace, architecture: str, config: Configuration) -> None: def web(args: argparse.Namespace, architecture: str, config: Configuration) -> None:
@ -118,16 +152,21 @@ def web(args: argparse.Namespace, architecture: str, config: Configuration) -> N
:param config: configuration instance :param config: configuration instance
''' '''
from ahriman.web.web import run_server, setup_service from ahriman.web.web import run_server, setup_service
app = setup_service(architecture, config) application = setup_service(architecture, config)
run_server(app, architecture) run_server(application, 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 architectures', action='append') parser.add_argument(
'-a',
'--architecture',
help='target architectures (can be used multiple times)',
action='append')
parser.add_argument('-c', '--config', help='configuration path', default='/etc/ahriman.ini') parser.add_argument('-c', '--config', help='configuration path', default='/etc/ahriman.ini')
parser.add_argument('--force', help='force run, remove file lock', action='store_true') parser.add_argument('--force', help='force run, remove file lock', action='store_true')
parser.add_argument('--lock', help='lock file', default='/tmp/ahriman.lock') parser.add_argument('--lock', help='lock file', default='/tmp/ahriman.lock')
parser.add_argument('--unsafe', help='allow to run ahriman as non-ahriman user', action='store_true')
parser.add_argument('-v', '--version', action='version', version=version.__version__) parser.add_argument('-v', '--version', action='version', version=version.__version__)
subparsers = parser.add_subparsers(title='command') subparsers = parser.add_subparsers(title='command')
@ -141,6 +180,20 @@ if __name__ == '__main__':
check_parser.add_argument('--no-vcs', help='do not check VCS packages', action='store_true') check_parser.add_argument('--no-vcs', help='do not check VCS packages', action='store_true')
check_parser.set_defaults(fn=update, no_aur=False, no_manual=True, dry_run=True) check_parser.set_defaults(fn=update, no_aur=False, no_manual=True, dry_run=True)
clean_parser = subparsers.add_parser('clean', description='clear all local caches')
clean_parser.add_argument('--no-build', help='do not clear directory with package sources', action='store_true')
clean_parser.add_argument('--no-cache', help='do not clear directory with package caches', action='store_true')
clean_parser.add_argument('--no-chroot', help='do not clear build chroot', action='store_true')
clean_parser.add_argument(
'--no-manual',
help='do not clear directory with manually added packages',
action='store_true')
clean_parser.add_argument('--no-packages', help='do not clear directory with built packages', action='store_true')
clean_parser.set_defaults(fn=clean)
config_parser = subparsers.add_parser('config', description='dump configuration for specified architecture')
config_parser.set_defaults(fn=dump_config)
rebuild_parser = subparsers.add_parser('rebuild', description='rebuild whole repository') rebuild_parser = subparsers.add_parser('rebuild', description='rebuild whole repository')
rebuild_parser.set_defaults(fn=rebuild) rebuild_parser.set_defaults(fn=rebuild)
@ -175,4 +228,6 @@ if __name__ == '__main__':
config = Configuration.from_path(args.config) config = Configuration.from_path(args.config)
with Pool(len(args.architecture)) as pool: with Pool(len(args.architecture)) as pool:
pool.starmap(_call, [(args, architecture, config) for architecture in args.architecture]) result = pool.starmap(_call, [(args, architecture, config) for architecture in args.architecture])
sys.exit(0 if all(result) else 1)

View File

@ -17,13 +17,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from __future__ import annotations
import logging import logging
import os import os
import shutil import shutil
from typing import Callable, Iterable, List, Optional, Set, Type from typing import Callable, Iterable, List, Optional, Set
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@ -128,6 +126,26 @@ class Application:
for name in names: for name in names:
process_single(name) process_single(name)
def clean(self, no_build: bool, no_cache: bool, no_chroot: bool, no_manual: bool, no_packages: bool) -> None:
'''
run all clean methods. Warning: some functions might not be available under non-root
:param no_build: do not clear directory with package sources
:param no_cache: do not clear directory with package caches
:param no_chroot: do not clear build chroot
:param no_manual: do not clear directory with manually added packages
:param no_packages: do not clear directory with built packages
'''
if not no_build:
self.repository._clear_build()
if not no_cache:
self.repository._clear_cache()
if not no_chroot:
self.repository._clear_chroot()
if not no_manual:
self.repository._clear_manual()
if not no_packages:
self.repository._clear_packages()
def remove(self, names: Iterable[str]) -> None: def remove(self, names: Iterable[str]) -> None:
''' '''
remove packages from repository remove packages from repository

View File

@ -25,7 +25,7 @@ from types import TracebackType
from typing import Literal, Optional, Type from typing import Literal, Optional, Type
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import DuplicateRun from ahriman.core.exceptions import DuplicateRun, UnsafeRun
from ahriman.core.watcher.client import Client from ahriman.core.watcher.client import Client
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
@ -36,30 +36,38 @@ class Lock:
:ivar force: remove lock file on start if any :ivar force: remove lock file on start if any
:ivar path: path to lock file if any :ivar path: path to lock file if any
:ivar reporter: build status reporter instance :ivar reporter: build status reporter instance
:ivar root: repository root (i.e. ahriman home)
:ivar unsafe: skip user check
''' '''
def __init__(self, path: Optional[str], architecture: str, force: bool, config: Configuration) -> None: def __init__(self, path: Optional[str], architecture: str, force: bool, unsafe: bool,
config: Configuration) -> None:
''' '''
default constructor default constructor
:param path: optional path to lock file, if empty no file lock will be used :param path: optional path to lock file, if empty no file lock will be used
:param architecture: repository architecture :param architecture: repository architecture
:param force: remove lock file on start if any :param force: remove lock file on start if any
:param unsafe: skip user check
:param config: configuration instance :param config: configuration instance
''' '''
self.path = f'{path}_{architecture}' if path is not None else None self.path = f'{path}_{architecture}' if path is not None else None
self.force = force self.force = force
self.unsafe = unsafe
self.root = config.get('repository', 'root')
self.reporter = Client.load(architecture, config) self.reporter = Client.load(architecture, config)
def __enter__(self) -> Lock: def __enter__(self) -> Lock:
''' '''
default workflow is the following: default workflow is the following:
check user UID
remove lock file if force flag is set remove lock file if force flag is set
check if there is lock file check if there is lock file
create lock file create lock file
report to web if enabled report to web if enabled
''' '''
self.check_user()
if self.force: if self.force:
self.remove() self.remove()
self.check() self.check()
@ -90,6 +98,17 @@ class Lock:
if os.path.exists(self.path): if os.path.exists(self.path):
raise DuplicateRun() raise DuplicateRun()
def check_user(self) -> None:
'''
check if current user is actually owner of ahriman root
'''
if self.unsafe:
return
current_uid = os.getuid()
root_uid = os.stat(self.root).st_uid
if current_uid != root_uid:
raise UnsafeRun(current_uid, root_uid)
def create(self) -> None: def create(self) -> None:
''' '''
create lock file create lock file

View File

@ -58,6 +58,13 @@ class Task:
self.makepkg_flags = config.getlist(section, 'makepkg_flags') self.makepkg_flags = config.getlist(section, 'makepkg_flags')
self.makechrootpkg_flags = config.getlist(section, 'makechrootpkg_flags') self.makechrootpkg_flags = config.getlist(section, 'makechrootpkg_flags')
@property
def cache_path(self) -> str:
'''
:return: path to cached packages
'''
return os.path.join(self.paths.cache, self.package.base)
@property @property
def git_path(self) -> str: def git_path(self) -> str:
''' '''
@ -66,14 +73,21 @@ class Task:
return os.path.join(self.paths.sources, self.package.base) return os.path.join(self.paths.sources, self.package.base)
@staticmethod @staticmethod
def fetch(local: str, remote: str) -> None: def fetch(local: str, remote: str, branch: str = 'master') -> None:
''' '''
fetch package from git either clone repository or update it to origin/`branch`
:param local: local path to fetch :param local: local path to fetch
:param remote: remote target (from where to fetch) :param remote: remote target (from where to fetch)
:param branch: branch name to checkout, master by default
''' '''
shutil.rmtree(local, ignore_errors=True) # remove in case if file exists logger = logging.getLogger('build_details')
check_output('git', 'clone', remote, local, exception=None) # local directory exists and there is .git directory
if os.path.isdir(os.path.join(local, '.git')):
check_output('git', 'fetch', 'origin', branch, exception=None, cwd=local, logger=logger)
else:
check_output('git', 'clone', remote, local, exception=None, logger=logger)
# and now force reset to our branch
check_output('git', 'reset', '--hard', f'origin/{branch}', exception=None, cwd=local, logger=logger)
def build(self) -> List[str]: def build(self) -> List[str]:
''' '''
@ -95,12 +109,16 @@ class Task:
# well it is not actually correct, but we can deal with it # well it is not actually correct, but we can deal with it
return check_output('makepkg', '--packagelist', return check_output('makepkg', '--packagelist',
exception=BuildFailed(self.package.base), exception=BuildFailed(self.package.base),
cwd=self.git_path).splitlines() cwd=self.git_path,
logger=self.build_logger).splitlines()
def clone(self, path: Optional[str] = None) -> None: def init(self, path: Optional[str] = None) -> None:
''' '''
fetch package from git fetch package from git
:param path: optional local path to fetch. If not set default path will be used :param path: optional local path to fetch. If not set default path will be used
''' '''
git_path = path or self.git_path git_path = path or self.git_path
if os.path.isdir(self.cache_path):
# no need to clone whole repository, just copy from cache first
shutil.copytree(self.cache_path, git_path)
return Task.fetch(git_path, self.package.git_url) return Task.fetch(git_path, self.package.git_url)

View File

@ -20,18 +20,29 @@
from __future__ import annotations from __future__ import annotations
import configparser import configparser
import logging
import os import os
from logging.config import fileConfig from logging.config import fileConfig
from typing import List, Optional, Type from typing import Dict, List, Optional, Type
class Configuration(configparser.RawConfigParser): class Configuration(configparser.RawConfigParser):
''' '''
extension for built-in configuration parser extension for built-in configuration parser
:ivar path: path to root configuration file :ivar path: path to root configuration file
:cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump)
:cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback)
:cvar DEFAULT_LOG_LEVEL: default log level (in case of fallback)
:cvar STATIC_SECTIONS: known sections which are not architecture specific (required by dump)
''' '''
DEFAULT_LOG_FORMAT = '%(asctime)s : %(levelname)s : %(funcName)s : %(message)s'
DEFAULT_LOG_LEVEL = logging.DEBUG
STATIC_SECTIONS = ['alpm', 'report', 'repository', 'settings', 'upload']
ARCHITECTURE_SPECIFIC_SECTIONS = ['build', 'html', 'rsync', 's3', 'sign', 'web']
def __init__(self) -> None: def __init__(self) -> None:
''' '''
default constructor default constructor
@ -58,6 +69,25 @@ class Configuration(configparser.RawConfigParser):
config.load_logging() config.load_logging()
return config return config
def dump(self, architecture: str) -> Dict[str, Dict[str, str]]:
'''
dump configuration to dictionary
:param architecture: repository architecture
:return: configuration dump for specific architecture
'''
result: Dict[str, Dict[str, str]] = {}
for section in Configuration.STATIC_SECTIONS:
if not self.has_section(section):
continue
result[section] = dict(self[section])
for group in Configuration.ARCHITECTURE_SPECIFIC_SECTIONS:
section = self.get_section_name(group, architecture)
if not self.has_section(section):
continue
result[section] = dict(self[section])
return result
def getlist(self, section: str, key: str) -> List[str]: def getlist(self, section: str, key: str) -> List[str]:
''' '''
get space separated string list option get space separated string list option
@ -103,4 +133,9 @@ class Configuration(configparser.RawConfigParser):
''' '''
setup logging settings from configuration setup logging settings from configuration
''' '''
try:
fileConfig(self.get('settings', 'logging')) fileConfig(self.get('settings', 'logging'))
except PermissionError:
logging.basicConfig(filename=None, format=Configuration.DEFAULT_LOG_FORMAT,
level=Configuration.DEFAULT_LOG_LEVEL)
logging.error('could not create logfile, fallback to stderr', exc_info=True)

View File

@ -105,3 +105,19 @@ class SyncFailed(Exception):
default constructor default constructor
''' '''
Exception.__init__(self, 'Sync failed') Exception.__init__(self, 'Sync failed')
class UnsafeRun(Exception):
'''
exception which will be raised in case if user is not owner of repository
'''
def __init__(self, current_uid: int, root_uid: int) -> None:
'''
default constructor
'''
Exception.__init__(
self,
f'''Current UID {current_uid} differs from root owner {root_uid}.
Note that for the most actions it is unsafe to run application as different user.
If you are 100% sure that it must be there try --unsafe option''')

View File

@ -24,8 +24,8 @@ 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 pretty_size, pretty_datetime
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_desciption import PackageDescription
from ahriman.models.sign_settings import SignSettings from ahriman.models.sign_settings import SignSettings
@ -39,7 +39,7 @@ class HTML(Report):
link_path - prefix fo packages to download, string, required link_path - prefix fo packages to download, string, required
has_package_signed - True in case if package sign enabled, False otherwise, required has_package_signed - True in case if package sign enabled, False otherwise, required
has_repo_signed - True in case if repository database sign enabled, False otherwise, required has_repo_signed - True in case if repository database sign enabled, False otherwise, required
packages - sorted list of packages properties: filename, installed_size, name, version. Required packages - sorted list of packages properties: archive_size, build_date, filename, installed_size, name, version. Required
pgp_key - default PGP key ID, string, optional pgp_key - default PGP key ID, string, optional
repository - repository name, string, required repository - repository name, string, required
@ -85,8 +85,10 @@ class HTML(Report):
content = [ content = [
{ {
'archive_size': pretty_size(properties.archive_size),
'build_date': pretty_datetime(properties.build_date),
'filename': properties.filename, 'filename': properties.filename,
'installed_size': PackageDescription.size_to_str(properties.installed_size), 'installed_size': pretty_size(properties.installed_size),
'name': package, 'name': package,
'version': base.version 'version': base.version
} for base in packages for package, properties in base.packages.items() } for base in packages for package, properties in base.packages.items()

View File

@ -79,6 +79,20 @@ class Repository:
for package in os.listdir(self.paths.sources): for package in os.listdir(self.paths.sources):
shutil.rmtree(os.path.join(self.paths.sources, package)) shutil.rmtree(os.path.join(self.paths.sources, package))
def _clear_cache(self) -> None:
'''
clear cache directory
'''
for package in os.listdir(self.paths.cache):
shutil.rmtree(os.path.join(self.paths.cache, package))
def _clear_chroot(self) -> None:
'''
clear cache directory. Warning: this method is architecture independent and will clear every chroot
'''
for chroot in os.listdir(self.paths.chroot):
shutil.rmtree(os.path.join(self.paths.chroot, chroot))
def _clear_manual(self) -> None: def _clear_manual(self) -> None:
''' '''
clear directory with manual package updates clear directory with manual package updates
@ -130,7 +144,7 @@ class Repository:
def build_single(package: Package) -> None: def build_single(package: Package) -> None:
self.reporter.set_building(package.base) self.reporter.set_building(package.base)
task = Task(package, self.architecture, self.config, self.paths) task = Task(package, self.architecture, self.config, self.paths)
task.clone() task.init()
built = task.build() built = task.build()
for src in built: for src in built:
dst = os.path.join(self.paths.packages, os.path.basename(src)) dst = os.path.join(self.paths.packages, os.path.basename(src))
@ -238,7 +252,7 @@ class Repository:
try: try:
remote = Package.load(local.base, self.pacman, self.aur_url) remote = Package.load(local.base, self.pacman, self.aur_url)
if local.is_outdated(remote): if local.is_outdated(remote, self.paths):
result.append(remote) result.append(remote)
self.reporter.set_pending(local.base) self.reporter.set_pending(local.base)
except Exception: except Exception:

View File

@ -17,11 +17,14 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import datetime
import subprocess import subprocess
from logging import Logger from logging import Logger
from typing import Optional from typing import Optional
from ahriman.core.exceptions import InvalidOption
def check_output(*args: str, exception: Optional[Exception], def check_output(*args: str, exception: Optional[Exception],
cwd: Optional[str] = None, stderr: int = subprocess.STDOUT, cwd: Optional[str] = None, stderr: int = subprocess.STDOUT,
@ -55,3 +58,37 @@ def package_like(filename: str) -> bool:
:return: True in case if name contains `.pkg.` and not signature, False otherwise :return: True in case if name contains `.pkg.` and not signature, False otherwise
''' '''
return '.pkg.' in filename and not filename.endswith('.sig') return '.pkg.' in filename and not filename.endswith('.sig')
def pretty_datetime(timestamp: Optional[int]) -> str:
'''
convert datetime object to string
:param timestamp: datetime to convert
:return: pretty printable datetime as string
'''
return '' if timestamp is None else datetime.datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
def pretty_size(size: Optional[float], level: int = 0) -> str:
'''
convert size to string
:param size: size to convert
:param level: represents current units, 0 is B, 1 is KiB etc
:return: pretty printable size as string
'''
def str_level() -> str:
if level == 0:
return 'B'
elif level == 1:
return 'KiB'
elif level == 2:
return 'MiB'
elif level == 3:
return 'GiB'
raise InvalidOption(level) # I hope it will not be more than 1024 GiB
if size is None:
return ''
elif size < 1024:
return f'{round(size, 2)} {str_level()}'
return pretty_size(size / 1024, level + 1)

View File

@ -63,18 +63,11 @@ class BuildStatus:
''' '''
def __init__(self, status: Union[BuildStatusEnum, str, None] = None, def __init__(self, status: Union[BuildStatusEnum, str, None] = None,
timestamp: Optional[datetime.datetime] = None) -> None: timestamp: Optional[int] = None) -> None:
''' '''
default constructor default constructor
:param status: current build status if known. `BuildStatusEnum.Unknown` will be used if not set :param status: current build status if known. `BuildStatusEnum.Unknown` will be used if not set
:param timestamp: build status timestamp. Current timestamp will be used if not set :param timestamp: build status timestamp. Current timestamp will be used if not set
''' '''
self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown self.status = BuildStatusEnum(status) if status else BuildStatusEnum.Unknown
self._timestamp = timestamp or datetime.datetime.utcnow() self.timestamp = timestamp or int(datetime.datetime.utcnow().timestamp())
@property
def timestamp(self) -> str:
'''
:return: string representation of build status timestamp
'''
return self._timestamp.strftime('%Y-%m-%d %H:%M:%S')

View File

@ -19,10 +19,10 @@
# #
from __future__ import annotations from __future__ import annotations
import logging
import aur # type: ignore import aur # type: ignore
import os import os
import shutil
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pyalpm import vercmp # type: ignore from pyalpm import vercmp # type: ignore
@ -33,6 +33,7 @@ from ahriman.core.alpm.pacman import Pacman
from ahriman.core.exceptions import InvalidPackageInfo from ahriman.core.exceptions import InvalidPackageInfo
from ahriman.core.util import check_output from ahriman.core.util import check_output
from ahriman.models.package_desciption import PackageDescription from ahriman.models.package_desciption import PackageDescription
from ahriman.models.repository_paths import RepositoryPaths
@dataclass @dataclass
@ -76,30 +77,30 @@ class Package:
''' '''
return f'{self.aur_url}/packages/{self.base}' return f'{self.aur_url}/packages/{self.base}'
def actual_version(self) -> str: def actual_version(self, paths: RepositoryPaths) -> str:
''' '''
additional method to handle VCS package versions additional method to handle VCS package versions
:param paths: repository paths instance
:return: package version if package is not VCS and current version according to VCS otherwise :return: package version if package is not VCS and current version according to VCS otherwise
''' '''
if not self.is_vcs: if not self.is_vcs:
return self.version return self.version
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
clone_dir = tempfile.mkdtemp()
try: clone_dir = os.path.join(paths.cache, self.base)
logger = logging.getLogger('build_details')
Task.fetch(clone_dir, self.git_url) Task.fetch(clone_dir, self.git_url)
# update pkgver first # update pkgver first
check_output('makepkg', '--nodeps', '--nobuild', check_output('makepkg', '--nodeps', '--nobuild', exception=None, cwd=clone_dir, logger=logger)
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', exception=None, cwd=clone_dir, logger=logger)
exception=None, cwd=clone_dir)
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 self.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel']) return self.full_version(src_info.get('epoch'), src_info['pkgver'], src_info['pkgrel'])
finally:
shutil.rmtree(clone_dir, ignore_errors=True)
@classmethod @classmethod
def from_archive(cls: Type[Package], path: str, pacman: Pacman, aur_url: str) -> Package: def from_archive(cls: Type[Package], path: str, pacman: Pacman, aur_url: str) -> Package:
@ -111,7 +112,7 @@ class Package:
:return: package properties :return: package properties
''' '''
package = pacman.handle.load_pkg(path) package = pacman.handle.load_pkg(path)
properties = PackageDescription(os.path.basename(path), package.isize) properties = PackageDescription(package.size, package.builddate, os.path.basename(path), package.isize)
return cls(package.base, package.version, aur_url, {package.name: properties}) return cls(package.base, package.version, aur_url, {package.name: properties})
@classmethod @classmethod
@ -196,12 +197,13 @@ class Package:
except Exception as e: except Exception as e:
raise InvalidPackageInfo(str(e)) raise InvalidPackageInfo(str(e))
def is_outdated(self, remote: Package) -> bool: def is_outdated(self, remote: Package, paths: RepositoryPaths) -> bool:
''' '''
check if package is out-of-dated check if package is out-of-dated
:param remote: package properties from remote source :param remote: package properties from remote source
:param paths: repository paths instance. Required for VCS packages cache
:return: True if the package is out-of-dated and False otherwise :return: True if the package is out-of-dated and False otherwise
''' '''
remote_version = remote.actual_version() # either normal version or updated VCS remote_version = remote.actual_version(paths) # either normal version or updated VCS
result: int = vercmp(self.version, remote_version) result: int = vercmp(self.version, remote_version)
return result < 0 return result < 0

View File

@ -20,38 +20,18 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from ahriman.core.exceptions import InvalidOption
@dataclass @dataclass
class PackageDescription: class PackageDescription:
''' '''
package specific properties package specific properties
:ivar archive_size: package archive size
:ivar build_date: package build date
:ivar filename: package archive name
:ivar installed_size: package installed size
''' '''
archive_size: Optional[int] = None
build_date: Optional[int] = None
filename: Optional[str] = None filename: Optional[str] = None
installed_size: Optional[int] = None installed_size: Optional[int] = None
@staticmethod
def size_to_str(size: Optional[float], level: int = 0) -> str:
'''
convert size to string
:param size: size to convert
:param level: represents current units, 0 is B, 1 is KiB etc
:return: pretty printable size as string
'''
def str_level() -> str:
if level == 0:
return 'B'
elif level == 1:
return 'KiB'
elif level == 2:
return 'MiB'
elif level == 3:
return 'GiB'
raise InvalidOption(level)
if size is None:
return ''
elif size < 1024:
return f'{round(size, 2)} {str_level()}'
return PackageDescription.size_to_str(size / 1024, level + 1)

View File

@ -27,7 +27,7 @@ from ahriman.core.exceptions import InvalidOption
class ReportSettings(Enum): class ReportSettings(Enum):
''' '''
report targets enumeration report targets enumeration
:ivar HTML: html report generation :cvar HTML: html report generation
''' '''
HTML = auto() HTML = auto()

View File

@ -33,6 +33,13 @@ class RepositoryPaths:
root: str root: str
architecture: str architecture: str
@property
def cache(self) -> str:
'''
:return: directory for packages cache (mainly used for VCS packages)
'''
return os.path.join(self.root, 'cache')
@property @property
def chroot(self) -> str: def chroot(self) -> str:
''' '''
@ -73,6 +80,7 @@ class RepositoryPaths:
''' '''
create ahriman working tree create ahriman working tree
''' '''
os.makedirs(self.cache, mode=0o755, exist_ok=True)
os.makedirs(self.chroot, mode=0o755, exist_ok=True) os.makedirs(self.chroot, mode=0o755, exist_ok=True)
os.makedirs(self.manual, mode=0o755, exist_ok=True) os.makedirs(self.manual, mode=0o755, exist_ok=True)
os.makedirs(self.packages, mode=0o755, exist_ok=True) os.makedirs(self.packages, mode=0o755, exist_ok=True)

View File

@ -27,8 +27,8 @@ from ahriman.core.exceptions import InvalidOption
class SignSettings(Enum): class SignSettings(Enum):
''' '''
sign targets enumeration sign targets enumeration
:ivar SignPackages: sign each package :cvar SignPackages: sign each package
:ivar SignRepository: sign repository database file :cvar SignRepository: sign repository database file
''' '''
SignPackages = auto() SignPackages = auto()

View File

@ -27,8 +27,8 @@ from ahriman.core.exceptions import InvalidOption
class UploadSettings(Enum): class UploadSettings(Enum):
''' '''
remote synchronization targets enumeration remote synchronization targets enumeration
:ivar Rsync: sync via rsync :cvar Rsync: sync via rsync
:ivar S3: sync to Amazon S3 :cvar S3: sync to Amazon S3
''' '''
Rsync = auto() Rsync = auto()

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.12.2' __version__ = '0.14.1'

View File

@ -23,6 +23,7 @@ from typing import Any, Dict
import ahriman.version as version import ahriman.version as version
from ahriman.core.util import pretty_datetime
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -52,7 +53,7 @@ class IndexView(BaseView):
'base': package.base, 'base': package.base,
'packages': [p for p in sorted(package.packages)], 'packages': [p for p in sorted(package.packages)],
'status': status.status.value, 'status': status.status.value,
'timestamp': status.timestamp, 'timestamp': pretty_datetime(status.timestamp),
'version': package.version, 'version': package.version,
'web_url': package.web_url 'web_url': package.web_url
} for package, status in sorted(self.service.packages, key=lambda item: item[0].base) } for package, status in sorted(self.service.packages, key=lambda item: item[0].base)
@ -60,7 +61,7 @@ class IndexView(BaseView):
service = { service = {
'status': self.service.status.status.value, 'status': self.service.status.status.value,
'status_color': self.service.status.status.badges_color(), 'status_color': self.service.status.status.badges_color(),
'timestamp': self.service.status.timestamp 'timestamp': pretty_datetime(self.service.status.timestamp)
} }
return { return {

View File

@ -63,7 +63,8 @@ def run_server(application: web.Application, architecture: str) -> None:
host = application['config'].get(section, 'host') host = application['config'].get(section, 'host')
port = application['config'].getint(section, 'port') port = application['config'].getint(section, 'port')
web.run_app(application, host=host, port=port, handle_signals=False) web.run_app(application, host=host, port=port, handle_signals=False,
access_log=logging.getLogger('http'))
def setup_service(architecture: str, config: Configuration) -> web.Application: def setup_service(architecture: str, config: Configuration) -> web.Application:
@ -73,22 +74,22 @@ def setup_service(architecture: str, config: Configuration) -> web.Application:
:param config: configuration instance :param config: configuration instance
:return: web application instance :return: web application instance
''' '''
app = web.Application(logger=logging.getLogger('http')) application = web.Application(logger=logging.getLogger('http'))
app.on_shutdown.append(on_shutdown) application.on_shutdown.append(on_shutdown)
app.on_startup.append(on_startup) application.on_startup.append(on_startup)
app.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True)) application.middlewares.append(web.normalize_path_middleware(append_slash=False, remove_slash=True))
app.middlewares.append(exception_handler(app.logger)) application.middlewares.append(exception_handler(application.logger))
app.logger.info('setup routes') application.logger.info('setup routes')
setup_routes(app) setup_routes(application)
app.logger.info('setup templates') application.logger.info('setup templates')
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(config.get('web', 'templates'))) aiohttp_jinja2.setup(application, loader=jinja2.FileSystemLoader(config.get('web', 'templates')))
app.logger.info('setup configuration') application.logger.info('setup configuration')
app['config'] = config application['config'] = config
app.logger.info('setup watcher') application.logger.info('setup watcher')
app['watcher'] = Watcher(architecture, config) application['watcher'] = Watcher(architecture, config)
return app return application