From e6df656ce3318ee699b28ca17dc0fc691578ee3d Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Thu, 14 Aug 2025 13:32:55 +0300 Subject: [PATCH] add archive trigger --- .../settings/ahriman.ini.d/00-triggers.ini | 1 + src/ahriman/application/handlers/status.py | 2 +- src/ahriman/core/alpm/repo.py | 10 +- src/ahriman/core/archive/__init__.py | 1 + src/ahriman/core/archive/archive_tree.py | 127 ++++++++++++++++++ src/ahriman/core/archive/archive_trigger.py | 22 +-- src/ahriman/core/repository/executor.py | 4 +- subpackages.py | 1 + 8 files changed, 153 insertions(+), 15 deletions(-) create mode 100644 src/ahriman/core/archive/archive_tree.py diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-triggers.ini b/package/share/ahriman/settings/ahriman.ini.d/00-triggers.ini index ae29f15c..b097cc3a 100644 --- a/package/share/ahriman/settings/ahriman.ini.d/00-triggers.ini +++ b/package/share/ahriman/settings/ahriman.ini.d/00-triggers.ini @@ -1,5 +1,6 @@ [build] ; List of well-known triggers. Used only for configuration purposes. +triggers_known[] = ahriman.core.archive.ArchiveTrigger triggers_known[] = ahriman.core.distributed.WorkerLoaderTrigger triggers_known[] = ahriman.core.distributed.WorkerTrigger triggers_known[] = ahriman.core.support.KeyringTrigger diff --git a/src/ahriman/application/handlers/status.py b/src/ahriman/application/handlers/status.py index 235cca54..5273672f 100644 --- a/src/ahriman/application/handlers/status.py +++ b/src/ahriman/application/handlers/status.py @@ -66,7 +66,7 @@ class Status(Handler): Status.check_status(args.exit_code, packages) comparator: Callable[[tuple[Package, BuildStatus]], Comparable] = lambda item: item[0].base - filter_fn: Callable[[tuple[Package, BuildStatus]], bool] =\ + filter_fn: Callable[[tuple[Package, BuildStatus]], bool] = \ lambda item: args.status is None or item[1].status == args.status for package, package_status in sorted(filter(filter_fn, packages), key=comparator): PackagePrinter(package, package_status)(verbose=args.info) diff --git a/src/ahriman/core/alpm/repo.py b/src/ahriman/core/alpm/repo.py index 3d8c1839..380943e6 100644 --- a/src/ahriman/core/alpm/repo.py +++ b/src/ahriman/core/alpm/repo.py @@ -88,22 +88,24 @@ class Repo(LazyLogging): check_output("repo-add", *self.sign_args, str(self.repo_path), cwd=self.root, logger=self.logger, user=self.uid) - def remove(self, package: str, filename: Path) -> None: + def remove(self, package_name: str | None, filename: Path) -> None: """ remove package from repository Args: - package(str): package name to remove + package_name(str | None): package name to remove. If none set, it will be guessed from filename filename(Path): package filename to remove """ + package_name = package_name or filename.name.rsplit("-", maxsplit=3)[0] + # remove package and signature (if any) from filesystem for full_path in self.root.glob(f"**/{filename.name}*"): full_path.unlink() # remove package from registry check_output( - "repo-remove", *self.sign_args, str(self.repo_path), package, - exception=BuildError.from_process(package), + "repo-remove", *self.sign_args, str(self.repo_path), package_name, + exception=BuildError.from_process(package_name), cwd=self.root, logger=self.logger, user=self.uid, diff --git a/src/ahriman/core/archive/__init__.py b/src/ahriman/core/archive/__init__.py index 7413eea9..3862fe71 100644 --- a/src/ahriman/core/archive/__init__.py +++ b/src/ahriman/core/archive/__init__.py @@ -17,3 +17,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from ahriman.core.archive.archive_trigger import ArchiveTrigger diff --git a/src/ahriman/core/archive/archive_tree.py b/src/ahriman/core/archive/archive_tree.py new file mode 100644 index 00000000..b29c372e --- /dev/null +++ b/src/ahriman/core/archive/archive_tree.py @@ -0,0 +1,127 @@ +# +# Copyright (c) 2021-2025 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +import datetime + +from pathlib import Path + +from ahriman.core.alpm.repo import Repo +from ahriman.core.log import LazyLogging +from ahriman.core.utils import utcnow +from ahriman.models.package import Package +from ahriman.models.repository_paths import RepositoryPaths + + +class ArchiveTree(LazyLogging): + """ + wrapper around archive tree + + Attributes: + paths(RepositoryPaths): repository paths instance + repository_id(RepositoryId): repository unique identifier + sign_args(list[str]): additional args which have to be used to sign repository archive + """ + + def __init__(self, repository_path: RepositoryPaths, sign_args: list[str]) -> None: + """ + Args: + repository_path(RepositoryPaths): repository paths instance + sign_args(list[str]): additional args which have to be used to sign repository archive + """ + self.paths = repository_path + self.repository_id = repository_path.repository_id + self.sign_args = sign_args + + def repository_for(self, date: datetime.date | None = None) -> Path: + """ + get full path to repository at the specified date + + Args: + date(datetime.date | None, optional): date to generate path. If none supplied then today will be used + + Returns: + Path: path to the repository root + """ + date = date or utcnow().today() + return ( + self.paths.archive + / "repos" + / date.strftime("%Y") + / date.strftime("%m") + / date.strftime("%d") + / self.repository_id.name + / self.repository_id.architecture + ) + + def symlinks_create(self, packages: list[Package]) -> None: + """ + create symlinks for the specified packages in today's repository + + Args: + packages(list[Package]): list of packages to be updated + """ + root = self.repository_for() + repo = Repo(self.repository_id.name, self.paths, self.sign_args, root) + + for package in packages: + archive = self.paths.archive_for(package.base) + + for package_name, single in package.packages.items(): + if single.filename is None: + self.logger.warning("received empty package filename for %s", package_name) + continue + + has_file = False + for file in archive.glob(f"{single.filename}*"): + if not (symlink := root / file.name).exists(): + has_file |= True + symlink.symlink_to(file.relative_to(symlink.parent, walk_up=True)) + + if has_file: + repo.add(root / single.filename) + + def symlinks_fix(self) -> None: + """ + remove broken symlinks across all repositories + """ + for root, _, files in self.paths.archive.walk(): + *_, name, architecture = root.parts + if self.repository_id.name != name or self.repository_id.architecture != architecture: + continue # we only process same name repositories + + for file in files: + path = root / file + if not path.is_symlink(): + continue # find symlinks only + if path.exists(): + continue # filter out not broken symlinks + + repo = Repo(self.repository_id.name, self.paths, self.sign_args, root) + repo.remove(None, path) + + def tree_create(self) -> None: + """ + create repository tree for current repository + """ + path = self.repository_for() + if path.exists(): + return + + with self.paths.preserve_owner(self.paths.archive): + path.mkdir(0o755, parents=True) diff --git a/src/ahriman/core/archive/archive_trigger.py b/src/ahriman/core/archive/archive_trigger.py index 14e188ac..8bb1b040 100644 --- a/src/ahriman/core/archive/archive_trigger.py +++ b/src/ahriman/core/archive/archive_trigger.py @@ -17,16 +17,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from pathlib import Path - +from ahriman.core import context +from ahriman.core.archive.archive_tree import ArchiveTree from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG from ahriman.core.triggers import Trigger from ahriman.models.package import Package from ahriman.models.repository_id import RepositoryId from ahriman.models.result import Result -class ArchiveRotationTrigger(Trigger): +class ArchiveTrigger(Trigger): """ archive repository extension @@ -44,9 +45,8 @@ class ArchiveRotationTrigger(Trigger): self.paths = configuration.repository_paths - @property - def repos_path(self) -> Path: - return self.paths.archive / "repos" + ctx = context.get() + self.tree = ArchiveTree(self.paths, ctx.get(GPG).repository_sign_args) def on_result(self, result: Result, packages: list[Package]) -> None: """ @@ -56,10 +56,16 @@ class ArchiveRotationTrigger(Trigger): result(Result): build result packages(list[Package]): list of all available packages """ + self.tree.symlinks_create(packages) def on_start(self) -> None: """ trigger action which will be called at the start of the application """ - with self.paths.preserve_owner(self.repos_path): - self.repos_path.mkdir(mode=0o755, exist_ok=True) + self.tree.tree_create() + + def on_stop(self) -> None: + """ + trigger action which will be called before the stop of the application + """ + self.tree.symlinks_fix() diff --git a/src/ahriman/core/repository/executor.py b/src/ahriman/core/repository/executor.py index 390dace1..89a806cb 100644 --- a/src/ahriman/core/repository/executor.py +++ b/src/ahriman/core/repository/executor.py @@ -80,7 +80,7 @@ class Executor(PackageInfo, Cleaner): package_base(str): package base name """ if description.filename is None: - self.logger.warning("received empty package name for base %s", package_base) + self.logger.warning("received empty package filename for base %s", package_base) return # suppress type checking, it never can be none actually if (safe := safe_filename(description.filename)) != description.filename: @@ -161,7 +161,7 @@ class Executor(PackageInfo, Cleaner): packager_key(str | None): packager key identifier """ if filename is None: - self.logger.warning("received empty package name for base %s", package_base) + self.logger.warning("received empty package filename for base %s", package_base) return # suppress type checking, it never can be none actually # in theory, it might be NOT packages directory, but we suppose it is diff --git a/subpackages.py b/subpackages.py index 1e7659fb..ec871c39 100644 --- a/subpackages.py +++ b/subpackages.py @@ -37,6 +37,7 @@ SUBPACKAGES = { "ahriman-triggers": [ prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-triggers.ini", site_packages / "ahriman" / "application" / "handlers" / "triggers_support.py", + site_packages / "ahriman" / "core" / "archive", site_packages / "ahriman" / "core" / "distributed", site_packages / "ahriman" / "core" / "support", ],