diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f81dd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.pytest_cache/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..508e3c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM fedora:42 + +RUN dnf -y update && \ + dnf -y install python3 python3-pip sqlite tar zstd git bash && \ + dnf clean all + +RUN mkdir -p /var/lib/erminig /var/cache/erminig /opt/erminig + +COPY . /opt/erminig + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4ec82b9 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: install test fmt docker-build up rebuild clean + +install: + pip install -e .[dev] + +test: + pytest -v + +fmt: + black erminig tests + +docker-build: + podman-compose -f docker-compose.yml build + +up: + podman-compose -f docker-compose.yml run erminig + +rebuild: docker-build up + +clean: + find . -name "*.pyc" -delete + find . -name "__pycache__" -type d -exec rm -rf {} + + find . -name "*.egg-info" -type d -exec rm -rf {} + + find . -name ".pytest_cache" -type d -exec rm -rf {} + + +build-all: fmt test docker-build + + diff --git a/data/config.yaml b/data/config.yaml new file mode 100644 index 0000000..9b836bf --- /dev/null +++ b/data/config.yaml @@ -0,0 +1,100 @@ +templates: + gnu: + url: "https://ftp.gnu.org/gnu/@NAME@/" + pattern: "@NAME@-[0-9]+\\.[0-9]+(?:\\.[0-9]+)?\\.tar\\.gz" + savannah: + url: "https://download.savannah.gnu.org/releases/@NAME@/" + pattern: '@NAME@-[0-9]+\.[0-9]+(?:\.[0-9]).tar.gz' + +projects: + - name: acl + template: savannah + - name: attr + template: savannah + - name: autoconf + template: gnu + - name: automake + template: gnu + + - name: bash + template: gnu + - name: bc + github: gavinhoward/bc + - name: binutils + template: gnu + - name: bison + template: gnu + - name: bzip2 + url: https://www.sourceware.org/pub/bzip2/ + pattern: bzip2-[0-9]+\.[0-9]+\.[0-9]+\.tar.gz + + - name: check + github: libcheck/check + - name: coreutils + template: gnu + + - name: dejagnu + template: gnu + - name: diffutils + template: gnu + + - name: e2fsprogs + url: https://www.kernel.org/pub/linux/kernel/people/tytso/e2fsprogs/ + pattern: v[0-9]+\.[0-9]+\.[0-9]/ + file: e2fsprogs-${version}.tar.gz + - name: elfutils + url: https://sourceware.org/pub/elfutils/ + pattern: "[0-9]+\\.[0-9]+/" + file: elfutils-${version}.tar.bz2 + - name: expat + github: libexpat/libexpat + - name: expect + sourceforge: https://sourceforge.net/projects/expect/rss?path=/Expect + + - name: file + url: https://astron.com/pub/file/ + pattern: file-[0-9]+\.[0-9]+\.tar.gz + - name: findutils + template: gnu + - name: flex + github: westes/flex + - name: flit + github: pypa/flit + + - name: gawk + template: gnu + - name: gcc + url: https://ftp.mpi-inf.mpg.de/mirrors/gnu/mirror/gcc.gnu.org/pub/gcc/releases/ + pattern: gcc-[0-9]+\.[0-9]+\.[0-9]/ + file: gcc-${version}.tar.gz + - name: gdbm + template: gnu + - name: gettext + template: gnu + - name: glibc + template: gnu + - name: gmp + template: gnu + - name: gperf + template: gnu + - name: grep + template: gnu + - name: groff + template : gnu + - name: grub + template: gnu + - name: gzip + template: gnu + + - name: iana-etc + github: Mic92/iana-etc + - name: inetutils + template: gnu + + - name: mpfr + url: https://www.mpfr.org/mpfr-current/ + pattern: mpfr-[0-9]+\.[0-9]+\.[0-9]+\.tar\.xz + + - name: linux-kernel + url: https://cdn.kernel.org/pub/linux/kernel/v6.x/ + pattern: linux-[0-9]+\.[0-9]+\.[0-9]+\.tar\.xz diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..9162fd8 --- /dev/null +++ b/dev.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "🌊 [Erminig] Rebuild and launch dev env..." +podman-compose -f docker-compose.yml build && \ +podman-compose -f docker-compose.yml run erminig \ No newline at end of file diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..5989d07 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,55 @@ +# Architecture Erminig + +--- + +## Vue Globale + +Erminig est une forge artisanale en 3 piliers : + +- **Evezh** : Veille logicielle, détection de nouvelles versions. +- **Govel** : Construction et maintenance des paquets à partir des fichiers Pakva. +- **Keo** : Gestion du dépôt de paquets et mise à disposition publique. + +Chaque module est indépendant, mais communique par base de données et sockets. + +--- + +## Détails + +| Module | Rôle | Langage | Communication | +|:-------|:-----|:--------|:---------------| +| Evezh | Check des versions upstream | Python 3.13 | SQLite | +| Govel | Build + révision + création Pakva | Python 3.13 | SQLite, fichiers système | +| Keo | Mirroir de paquets `.bzh` | À venir | SQLite, HTTP (futur) | + +--- + +## Dossiers critiques + +| Dossier | Contenu | +|:--------|:--------| +| `/var/lib/erminig` | Données persistantes (db, Pakva, builds, etc.) | +| `/var/cache/erminig` | Temporaire (compilations, archives, logs) | +| `/opt/erminig` | Sources du projet installées | + +--- + +## Principes + +- **KISS** : Keep it simple, stupid. +- **Séparation stricte** entre utilisateur système (`pak`) et root. +- **Aucune dépendance inutile.** +- **Logs clairs** pour tout ce qui est critique. +- **Architecture modulaire.** + +--- + +# Motto + +> **Un système simple. +> Une forge robuste. +> Un esprit libre.** + +--- + +# FIN diff --git a/doc/bugs.md b/doc/bugs.md new file mode 100644 index 0000000..fa845f8 --- /dev/null +++ b/doc/bugs.md @@ -0,0 +1,6 @@ +[gcc] Erreur HTTP : HTTPSConnectionPool(host='ftp.mpi-inf.mpg.de', port=443): Max retries exceeded with url: /mirrors/gnu/mirror/gcc.gnu.org/pub/gcc/releases/ (Caused by NameResolutionError(": Failed to resolve 'ftp.mpi-inf.mpg.de' ([Errno -5] No address associated with hostname)")) +[gcc] Aucune version détectée. +----- + + + diff --git a/doc/roadmap.md b/doc/roadmap.md new file mode 100644 index 0000000..cbe5f32 --- /dev/null +++ b/doc/roadmap.md @@ -0,0 +1,69 @@ +# Roadmap Erminig + +--- + +## 1. Stabilisation du Cœur + +- Finaliser `evezh` (veille versions) +- Finaliser `govel` (build) +- Finaliser `pakva` (formules) +- Finaliser `keo` (mirroir) +- Freeze code sauf bugfixes. + +--- + +## 2. Documentation Interne + +- `/doc/architecture.md` : Décrire les modules. +- `/doc/usage.md` : Exemples de commandes. +- `/doc/rules.md` : Bonnes pratiques de dev (ex : jamais builder en root). + +--- + +## 3. Automatisation + +- Script `evezh sync && govel build --all` +- Script `init-db.sh` pour première installation. + +--- + +## 4. Versionning + +- Commencer à versionner dès premier gel : + - `0.1.0` : Forge fonctionnelle (check + build ok) + - `0.2.0` : Ajout du mirroir Keo + - `0.3.0` : Ajout communication sockets entre modules + - `1.0.0` : Première release publique + +--- + +## 5. Tests Simples + +- Tests manuels à chaque changement critique. +- (Optionnel plus tard : sanity-checks automatiques sur `.Pakva`, db, build). + +--- + +## 6. Releases + +- Utiliser git tags : + - `v0.1.0`, `v0.2.0`, `v0.3.0`, `v1.0.0` +- Archiver les builds, backups réguliers de `/var/lib/erminig` et `/var/cache/erminig`. + +--- + +## 7. Écosystème Ouvert (Optionnel) + +- Documenter comment contribuer un `.Pakva` +- (Peut devenir communautaire si volonté future.) + +--- + +# Mantra + +> **Simple. Robuste. Artisan. Libre.** + +--- + +# Forge On. + diff --git a/doc/specs-pakva.md b/doc/specs-pakva.md new file mode 100644 index 0000000..972d10f --- /dev/null +++ b/doc/specs-pakva.md @@ -0,0 +1,78 @@ +# Specifications du format PAKVA v0.1 + +Le fichier **PAKVA** est un fichier qui définit les spécifications d'un logiciel. Il est utilisé par **evezh** pour +chercher les dernières versions disponibles pour un logiciel et par **govel** pour construire les paquets. +La syntaxe des fichiers **PAKVA** est prévue pour etre lisible en python et en bash. + +Ce document décrit les différentes clés, fonctions et macros disponibles. + +## Structure générale +Les lignes commençant par # sont des commentaires + +la syntaxe est de type clé=valeur (avec ou sans guillemets) +les tableaux sont en style bash clé=(val1 val2 val3) +les fonction sont en style bash fonction(){} + +### Clés disponibles + +**PAKVA** dispose de nombreuses clé disponibles : + +| Clé | Type | Obligatoire | Description | +| :---| :--- | :---------- | :---------- | +| `name` | string | oui | Nom du paquet principal | +| `basename` | string | non | Nom utilisé pour les archives/source/builddir (défaut = $name) | +| `description` | string | non | Description du paquet | +| `packager` | string | non | Packager du paquet | +| `version` | string | oui | Version du logiciel | +| `revision` | int | non | Révision locale | +| `url` | string | non | Page d'accueil du logiciel | +| `source` | array | oui | Sources du paquets | +| `license` | string | non | Licence du logiciel | +| `depends` | array | oui | Dépendances d'utilisation | +| `make_depends` | array | non | Dépendances de construction | +| `check_depends` | array | non | Dépendances de test | + + +### Fonctions disponibles + +| Fonction | Obligatoire | Instructions | +| :------- | :---------- | :---------- | +| `build()` | oui | Compilation | +| `check()` | non | Tests | +| `pak()` | oui | Packaging | +| `pre_install()` | non | Avant installation | +| `post_install()` | non | Après installation | +| `pre_upgrade()` | non | Avant mise à jour | +| `post_upgrade()` | non | Après mise à jour | +| `pre_remove()` | non | Avant suppression | +| `post_remove()` | non | Après suppression | + +### Multi Packaging + +On peut packager plus d'un paquet à partir d'un seul fichier **PAKVA**, et c'est assez simple. + +la syntaxe devient : + +`pak:()` +`pre_install:()` +`post_install:()` +`pre_upgrade:()` +`post_upgrade:()` +`pre_remove:()` +`post_remove:()` + +### Macros disponibles + +| Macro | Description | +| :---- | :---------- | +| `$PAK` | Dossier global d'installation | +| `$SRC` | Dossier d'extraction des sources | +| `$TMP` | Dossier temporaire pour chaque `pak:` | +| `name, $version, $revision, $basename` | Metadonnées du paquet principal | +| `pakname` | nom du paquet courant dans un `pak:` | + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afb26da --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.9' +services: + erminig: + build: + context: . + volumes: + - ./lib:/var/lib/erminig:z + - ./cache:/var/cache/erminig:z + working_dir: /opt/erminig + command: /bin/bash -c "pip install -e . && exec /bin/bash" diff --git a/erminig/__init__.py b/erminig/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/cli/__init__.py b/erminig/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/cli/evezh.py b/erminig/cli/evezh.py new file mode 100644 index 0000000..150014b --- /dev/null +++ b/erminig/cli/evezh.py @@ -0,0 +1,54 @@ +import argparse +import json +from erminig.controllers.evezh import check +from erminig.models.db import init_db + + +def main(): + parser = argparse.ArgumentParser(description="Evezh – Veille logicielle artisanale") + subparsers = parser.add_subparsers(dest="command") + + init_parser = subparsers.add_parser("init", help="Initialiser la base de données") + + check_parser = subparsers.add_parser("check") + check_parser.add_argument("--config") + check_parser.add_argument("--output") + check_parser.add_argument("--stdout", action="store_true") + + sync_parser = subparsers.add_parser("sync") + sync_parser.add_argument("--config", required=True) + sync_parser.add_argument("--db", required=True) + + args = parser.parse_args() + + if args.command == "init": + init_db() + + if args.command == "check": + state = check.load_state(args.output) if args.output else {} + results = check.check_versions(args.config, state) + new_state = {} + updated = [] + + for r in results: + name, version, url = r["name"], r["version"], r["url"] + if state.get(name, {}).get("version") != version: + updated.append(r) + new_state[name] = {"version": version, "url": url} + + if args.output: + check.save_state(args.output, new_state) + + if updated: + print(f"\n{len(updated)} mise(s) à jour détectée(s) :") + for r in updated: + print(f" - {r['name']} → {r['version']}") + else: + print("Aucun changement détecté.") + + if args.stdout: + print("\n--- Résultat complet ---") + print(json.dumps(results, indent=2)) + + elif args.command == "sync": + check.sync_db(args.config) diff --git a/erminig/cli/govel.py b/erminig/cli/govel.py new file mode 100644 index 0000000..6a2956a --- /dev/null +++ b/erminig/cli/govel.py @@ -0,0 +1,37 @@ +import argparse +from erminig.config import Config +from erminig.controllers.govel.pakva import Pakva +from erminig.controllers.govel.build import run_build_function + + +def main(): + parser = argparse.ArgumentParser(description="Govel – Build artisanal Erminig") + subparsers = parser.add_subparsers(dest="command") + + build_parser = subparsers.add_parser("build") + build_parser.add_argument("--name", help="Nom du paquet à builder") + + args = parser.parse_args() + + if args.command == "build": + if args.name: + pakva = Pakva(name=args.name, version=None, archive=None) + pakva.read() + else: + if not Config.PAKVA_DIR.exists(): + print("[GOVEL] Erreur : Aucun Pakva trouvé ici.") + return + pakva = Pakva(name="local", version=None, archive=None) + pakva.path = Config.PAKVA_DIR + pakva.read() + + build_success = run_build_function(pakva.path) + + if build_success: + print(f"[GOVEL] Build réussi pour {pakva.name}") + else: + print(f"[GOVEL] Build échoué pour {pakva.name}") + + +if __name__ == "__main__": + main() diff --git a/erminig/cli/init.py b/erminig/cli/init.py new file mode 100644 index 0000000..7111dac --- /dev/null +++ b/erminig/cli/init.py @@ -0,0 +1,56 @@ +import os +import subprocess +import sys +from pathlib import Path +from erminig.system.security import check_root, check_user_exists +from erminig.config import Config # Voilà la différence clé ! + +PAK_USER = Config.PAK_USER + + +def create_user_pak(): + """Crée l'utilisateur pak si nécessaire.""" + if check_user_exists(PAK_USER): + print(f"[INIT] Utilisateur '{PAK_USER}' existe déjà.") + return + + print(f"[INIT] Création de l'utilisateur '{PAK_USER}'...") + subprocess.run( + [ + "useradd", + "-r", + "-d", + str(Config.LIB_DIR), + "-s", + "/usr/sbin/nologin", + PAK_USER, + ], + check=True, + ) + print(f"[INIT] Utilisateur '{PAK_USER}' créé.") + + +def setup_directories(): + """Crée les dossiers nécessaires et assigne les permissions.""" + for directory in [Config.LIB_DIR, Config.CACHE_DIR]: + if not directory.exists(): + print(f"[INIT] Création du dossier {directory}...") + directory.mkdir(parents=True, exist_ok=True) + + print(f"[INIT] Attribution de {directory} à '{PAK_USER}'...") + subprocess.run( + ["chown", "-R", f"{PAK_USER}:{PAK_USER}", str(directory)], check=True + ) + + +def main(): + check_root() + + create_user_pak() + setup_directories() + + print("[INIT] Environnement Erminig initialisé avec succès.") + + +if __name__ == "__main__": + main() diff --git a/erminig/config.py b/erminig/config.py new file mode 100644 index 0000000..1644f41 --- /dev/null +++ b/erminig/config.py @@ -0,0 +1,18 @@ +import os +from pathlib import Path + + +class Config: + LIB_DIR = Path("/var/lib/erminig") + CACHE_DIR = Path("/var/cache/erminig") + BASE_DIR = Path("/opt/erminig") + DB_PATH = LIB_DIR / "erminig.db" + PAKVA_DIR = LIB_DIR / "pakva" + GOVEL_DIR = LIB_DIR / "govel" + REPO_DIR = LIB_DIR / "keo" + PAK_USER = "pak" + + GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + + RETRY_MAX_ATTEMPTS = 2 # Pour toutes les opérations réseau + RETRY_DELAY_SECONDS = 2 diff --git a/erminig/controllers/__init__.py b/erminig/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/controllers/evezh/__init__.py b/erminig/controllers/evezh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/controllers/evezh/abstract.py b/erminig/controllers/evezh/abstract.py new file mode 100644 index 0000000..e86a03b --- /dev/null +++ b/erminig/controllers/evezh/abstract.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + + +class UpstreamSource(ABC): + MAX_RETRIES = 2 + RETRY_DELAY = 2 # secondes + + def __init__(self, name, config): + self.name = name + self.config = config + + @abstractmethod + def get_latest(self): + pass diff --git a/erminig/controllers/evezh/check.py b/erminig/controllers/evezh/check.py new file mode 100644 index 0000000..b6ff1b8 --- /dev/null +++ b/erminig/controllers/evezh/check.py @@ -0,0 +1,122 @@ +import json +from pathlib import Path +import yaml + + +from erminig.controllers.evezh.parsers.github import GitHubSource +from erminig.controllers.evezh.parsers.http import HttpSource +from erminig.controllers.evezh.parsers.sourceforge import SourceForgeRSS +from erminig.handlers.versions import handle_new_version +from erminig.models.db import ErminigDB +from erminig.models import upstreams, versions + + +def load_state(path): + if Path(path).exists(): + return json.loads(Path(path).read_text()) + return {} + + +def save_state(path, state): + Path(path).write_text(json.dumps(state, indent=2)) + + +def sync_db(config_path): + config = get_config(config_path) + print(config) + + with ErminigDB() as db: + for proj in config: + name = proj["name"] + type_ = "http" + if "github" in proj: + type_ = "github" + url = proj["github"] + elif "sourceforge" in proj: + type_ = "sourceforge" + url = proj["sourceforge"] + else: + url = proj.get("url", "") + + pattern = proj.get("pattern", "") + file = proj.get("file", None) + upstreams.upsert_upstream(db, name, type_, url, pattern, file) + + print("Synchronisation terminée.") + + +def make_source(name, config): + if "github" in config: + return GitHubSource(name, config) + elif "sourceforge" in config: + return SourceForgeRSS(name, config) + else: + return HttpSource(name, config) + + +def apply_template(name, template_name, templates): + tpl = templates.get(template_name) + if not tpl: + raise ValueError(f"Template '{template_name}' non trouvé.") + return { + "url": tpl["url"].replace("@NAME@", name), + "pattern": tpl["pattern"].replace("@NAME@", name), + } + + +def get_config(path): + with open(path) as f: + full = yaml.safe_load(f) + + templates = full.get("templates", {}) + projects = full.get("projects", []) + + resolved = [] + + for proj in projects: + name = proj["name"] + new_proj = proj.copy() + + if "template" in proj: + tpl = apply_template(name, proj["template"], templates) + new_proj["url"] = tpl["url"] + new_proj["pattern"] = tpl["pattern"] + + resolved.append(new_proj) + + return resolved + + +def check_versions(config_path, state=None): + results = [] + with ErminigDB() as db: + for row in upstreams.get_all_upstreams(db): + proj = dict(row) + if proj["type"] == "github": + proj["github"] = proj["url"] + elif proj["type"] == "sourceforge": + proj["sourceforge"] = proj["url"] + else: + proj["url"] = proj["url"] + upstream_id = proj["id"] + name = proj["name"] + source = make_source(name, proj) + latest = source.get_latest() + if latest: + results.append(latest) + version = latest["version"] + url = latest["url"] + handle_new_version(db, upstream_id, name, version, url) + elif state and name in state: + print(f"[{name}] Serveur HS. On garde l’ancienne version.") + results.append( + { + "name": name, + "version": state[name]["version"], + "url": state[name]["url"], + } + ) + else: + print(f"[{name}] Aucune version détectée.") + + return results diff --git a/erminig/controllers/evezh/parsers/__init__.py b/erminig/controllers/evezh/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/controllers/evezh/parsers/github.py b/erminig/controllers/evezh/parsers/github.py new file mode 100644 index 0000000..29c66bd --- /dev/null +++ b/erminig/controllers/evezh/parsers/github.py @@ -0,0 +1,76 @@ +import re +import requests +from erminig.config import Config +from erminig.controllers.evezh.abstract import UpstreamSource +from erminig.system.retry import retry_on_failure + + +class GitHubSource(UpstreamSource): + + @retry_on_failure() + def get_latest(self): + repo = self.config["github"] + file_template = self.config.get("file") + + latest = self._get_latest_release(repo) + if not latest: + latest = self._get_latest_tag(repo) + if not latest: + print(f"[{self.name}] Aucune version détectée.") + return None + + version = self.normalize_version(latest["tag"]) + url = latest.get("url") + + if not url and file_template: + filename = file_template.replace("${version}", version) + url = f"https://github.com/{repo}/releases/download/{latest['tag']}/{filename}" + print(f"[{self.name}] Fallback URL : {url}") + + print(url) + return {"name": self.name, "version": version, "url": url or ""} + + def _github_headers(self): + headers = {} + if Config.GITHUB_TOKEN: + headers["Authorization"] = f"token {Config.GITHUB_TOKEN}" + return headers + + def _get_latest_release(self, repo): + r = requests.get( + f"https://api.github.com/repos/{repo}/releases", + headers=self._github_headers(), + ) + r.raise_for_status() + data = r.json() + if not data: + return None + + release = data[0] + for asset in release.get("assets", []): + if asset["browser_download_url"].endswith(".tar.gz"): + return { + "tag": release["tag_name"], + "url": asset["browser_download_url"], + } + return {"tag": release["tag_name"]} + + def _get_latest_tag(self, repo): + r = requests.get( + f"https://api.github.com/repos/{repo}/tags", headers=self._github_headers() + ) + r.raise_for_status() + tags = r.json() + if not tags: + return None + url = f"https://github.com/{repo}/archive/refs/tags/{tags[0]["name"]}.tar.gz" + return {"tag": tags[0]["name"], "url": url} + + def normalize_version(self, tag): + # Exemples : v2.7.1 → 2.7.1, R_2_7_1 → 2.7.1, expat-2.7.1 → 2.7.1 + tag = tag.strip() + if tag.lower().startswith(("v", "r_", "r")): + tag = re.sub(r"^[vVrR_]+", "", tag) + tag = tag.replace("_", ".") + match = re.search(r"(\d+\.\d+(?:\.\d+)?)", tag) + return match.group(1) if match else tag diff --git a/erminig/controllers/evezh/parsers/http.py b/erminig/controllers/evezh/parsers/http.py new file mode 100644 index 0000000..e7a85d5 --- /dev/null +++ b/erminig/controllers/evezh/parsers/http.py @@ -0,0 +1,45 @@ +import re +import requests +from erminig.controllers.evezh.abstract import UpstreamSource +from erminig.system.retry import retry_on_failure + + +class HttpSource(UpstreamSource): + + @retry_on_failure() + def get_latest(self): + base_url = self.config["url"] + pattern = self.config["pattern"] + file_pattern = self.config.get("file") + + response = requests.get( + base_url, timeout=10 + ) # timeout pour éviter de bloquer éternellement + response.raise_for_status() + html = response.text + + matches = re.findall(pattern, html) + if not matches: + print(f"[{self.name}] Aucun match avec pattern : {pattern}") + return None + + latest_dir = sorted(set(matches), key=self.version_key)[-1] + version = self.extract_version(latest_dir) + + if file_pattern: + filename = file_pattern.replace("${version}", version) + url = f"{base_url}{latest_dir}{filename}" + else: + url = f"{base_url}{latest_dir}" + + print(url) + + return {"name": self.name, "version": version, "url": url} + + def extract_version(self, path): + match = re.search(r"([0-9]+\.[0-9]+(?:\.[0-9]+)?)", path) + return match.group(1) if match else path + + def version_key(self, ver): + nums = re.findall(r"\d+", ver) + return list(map(int, nums)) diff --git a/erminig/controllers/evezh/parsers/sourceforge.py b/erminig/controllers/evezh/parsers/sourceforge.py new file mode 100644 index 0000000..c89f8f8 --- /dev/null +++ b/erminig/controllers/evezh/parsers/sourceforge.py @@ -0,0 +1,33 @@ +import re +import requests +import xml.etree.ElementTree as ET +from erminig.controllers.evezh.abstract import UpstreamSource +from erminig.system.retry import retry_on_failure + + +class SourceForgeRSS(UpstreamSource): + + @retry_on_failure() + def get_latest(self): + rss_url = self.config["sourceforge"] + r = requests.get(rss_url, timeout=10) + r.raise_for_status() + root = ET.fromstring(r.text) + items = root.findall(".//item") + + versions = [] + for item in items: + title = item.findtext("title") or "" + match = re.search(r"([0-9]+\.[0-9]+(?:\.[0-9]+)?)", title) + if match: + version = match.group(1) + link = item.findtext("link") + versions.append((version, link)) + + if not versions: + print(f"[{self.name}] Aucune version trouvée via RSS.") + return None + + latest = sorted(versions, key=lambda x: list(map(int, x[0].split("."))))[-1] + print(latest[1]) + return {"name": self.name, "version": latest[0], "url": latest[1]} diff --git a/erminig/controllers/govel/__init__.py b/erminig/controllers/govel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/controllers/govel/build.py b/erminig/controllers/govel/build.py new file mode 100644 index 0000000..2ba0b69 --- /dev/null +++ b/erminig/controllers/govel/build.py @@ -0,0 +1,33 @@ +import subprocess +from erminig.system.security import check_root, check_user_exists, run_as_user + +check_root +check_user_exists("pak") + + +@run_as_user("pak") +def run_build_function(pakva_path): + """ + Exécute la fonction build() du fichier Pakva donné. + """ + try: + result = subprocess.run( + f""" + set -e + source "{pakva_path}" + build + """, + shell=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + executable="/bin/bash", + text=True, + ) + print(f"[BUILD] Succès : {pakva_path.name}") + print(result.stdout) + return True + except subprocess.CalledProcessError as e: + print(f"[BUILD] Échec : {pakva_path.name}") + print(e.stderr) + return False diff --git a/erminig/controllers/govel/pakva.py b/erminig/controllers/govel/pakva.py new file mode 100644 index 0000000..a290711 --- /dev/null +++ b/erminig/controllers/govel/pakva.py @@ -0,0 +1,103 @@ +from pathlib import Path +from erminig.config import Config +from erminig.system.security import run_as_user + + +class Pakva: + + def __init__(self, name, version, archive): + self.name = name + self.version = version + self.archive = archive + self.path = Config.GOVEL_DIR / name[0] / name / "Pakva" + + @run_as_user("pak") + def save(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + with open(self.path, "w") as f: + f.write( + f"""\ +name="{self.name}" +version="{self.version}" +revision=1 +source=("{self.archive}") + +build() {{ + echo "Build function not implemented" +}} + +pak() {{ + echo "Packaging function not implemented" +}} +""" + ) + + @run_as_user("pak") + def update_version(self, version, archive, reset_revision=False): + if not self.path.exists(): + raise FileNotFoundError(f"Aucun fichier Pakva pour {self.name}") + + with open(self.path, "r") as f: + lines = f.readlines() + + new_lines = [] + updated_version = False + updated_source = False + + for line in lines: + if line.startswith("version=") and not updated_version: + new_lines.append(f'version="{version}"\n') + updated_version = True + elif line.startswith("source=") and not updated_source: + new_lines.append(f'source=("{archive}")\n') + updated_source = True + elif reset_revision and line.startswith("revision="): + new_lines.append("revision=1\n") + else: + new_lines.append(line) + + # Ajout si jamais les champs n'existaient pas + if not updated_version: + new_lines.insert(1, f'version="{version}"\n') + if not updated_source: + new_lines.append(f'source=("{archive}")\n') + if reset_revision and not any(line.startswith("revision=") for line in lines): + new_lines.insert(2, "revision=1\n") + + with open(self.path, "w") as f: + f.writelines(new_lines) + + self.version = version + self.archive = archive + print(f"[Pakva] Version mise à jour pour {self.name} -> {version}") + + @classmethod + def read(cls, path): + """ + Lit un fichier Pakva et retourne un objet Pakva. + """ + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Fichier Pakva introuvable : {path}") + + with path.open() as f: + lines = f.readlines() + + name = None + version = None + archive = None + + for line in lines: + if line.startswith("name="): + name = line.split("=")[1].strip().strip('"') + elif line.startswith("version="): + version = line.split("=")[1].strip().strip('"') + elif line.startswith("source=("): + archive = line.split("(")[1].split(")")[0].strip().strip('"') + + obj = cls(name, version, archive) + obj.path = path + return obj + + def __repr__(self): + return f"" diff --git a/erminig/handlers/__init__.py b/erminig/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/handlers/versions.py b/erminig/handlers/versions.py new file mode 100644 index 0000000..652a0ec --- /dev/null +++ b/erminig/handlers/versions.py @@ -0,0 +1,23 @@ +from erminig.models import versions +from erminig.controllers.govel.pakva import Pakva + + +def handle_new_version(db, upstream_id, name, version, url): + """ + Gère l'arrivée d'une nouvelle version : + - Insère en base si absente + - Crée ou met à jour le fichier Pakva correspondant + """ + inserted = versions.insert_version_if_new(db, upstream_id, version, url) + if not inserted: + return False + + pakva = Pakva(name, version, url) + if pakva.path.exists(): + pakva.update_version(version, url, reset_revision=True) + print(f"[PAKVA] Mis à jour pour {name}") + else: + pakva.save() + print(f"[PAKVA] Créé pour {name}") + + return True diff --git a/erminig/models/__init__.py b/erminig/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/models/db.py b/erminig/models/db.py new file mode 100644 index 0000000..02424d8 --- /dev/null +++ b/erminig/models/db.py @@ -0,0 +1,26 @@ +import sqlite3 +from erminig.config import Config + + +def init_db(): + if Config.DB_PATH.exists(): + return + + conn = sqlite3.connect(Config.DB_PATH) + with open(Config.BASE_DIR / "schema.sql", "r") as f: + conn.executescript(f.read()) + conn.commit() + conn.close() + print("Base erminig.db initialisée avec succès.") + + +class ErminigDB: + def __enter__(self): + self.conn = sqlite3.connect(Config.DB_PATH) + self.conn.row_factory = sqlite3.Row + self.cursor = self.conn.cursor() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.conn.commit() + self.conn.close() diff --git a/erminig/models/upstreams.py b/erminig/models/upstreams.py new file mode 100644 index 0000000..511bc5c --- /dev/null +++ b/erminig/models/upstreams.py @@ -0,0 +1,29 @@ +from erminig.models.db import ErminigDB + + +def upsert_upstream(db: ErminigDB, name, type_, url, pattern, file): + db.cursor.execute( + """ + INSERT INTO upstreams (name, type, url, pattern, file, created_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(name) DO UPDATE SET + type=excluded.type, + url=excluded.url, + pattern=excluded.pattern, + file=excluded.file + RETURNING id + """, + (name, type_, url, pattern, file), + ) + row = db.cursor.fetchone() + return row[0] if row else None + + +def get_all_upstreams(db: ErminigDB): + db.cursor.execute( + """ + SELECT id, name, type, url, pattern, file FROM upstreams + """ + ) + rows = db.cursor.fetchall() + return rows diff --git a/erminig/models/versions.py b/erminig/models/versions.py new file mode 100644 index 0000000..39ac451 --- /dev/null +++ b/erminig/models/versions.py @@ -0,0 +1,25 @@ +from erminig.models.db import ErminigDB + + +def insert_version_if_new(db, upstream_id, version, url): + db.cursor.execute( + """ + SELECT id FROM versions + WHERE upstream_id = ? AND version = ? + """, + (upstream_id, version), + ) + + if db.cursor.fetchone(): + return False # déjà en base + + db.cursor.execute( + """ + INSERT INTO versions (upstream_id, version, url) + VALUES (?, ?, ?) + """, + (upstream_id, version, url), + ) + + print(f"[DB] Nouvelle version insérée pour {upstream_id} : {version}") + return True diff --git a/erminig/system/__init__.py b/erminig/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/erminig/system/retry.py b/erminig/system/retry.py new file mode 100644 index 0000000..ba0f772 --- /dev/null +++ b/erminig/system/retry.py @@ -0,0 +1,34 @@ +import time +import requests +from erminig.config import Config + + +def retry_on_failure(): + def decorator(func): + def wrapper(*args, **kwargs): + attempt = 1 + while attempt <= Config.RETRY_MAX_ATTEMPTS + 1: + try: + return func(*args, **kwargs) + except requests.exceptions.RequestException as e: + name = getattr(args[0], "name", "Unknown") + print( + f"[{name}] Erreur réseau tentative {attempt}: {e.__class__.__name__}" + ) + if attempt <= Config.RETRY_MAX_ATTEMPTS: + print( + f"[{name}] Nouvelle tentative dans {Config.RETRY_DELAY_SECONDS}s..." + ) + time.sleep(Config.RETRY_DELAY_SECONDS) + attempt += 1 + else: + print(f"[{name}] Abandon après {attempt} tentatives.") + return None + except Exception as e: + name = getattr(args[0], "name", "Unknown") + print(f"[{name}] Erreur inconnue dans {func.__name__}: {e}") + return None + + return wrapper + + return decorator diff --git a/erminig/system/security.py b/erminig/system/security.py new file mode 100644 index 0000000..c015c04 --- /dev/null +++ b/erminig/system/security.py @@ -0,0 +1,55 @@ +import os +import pwd +import sys +import functools + + +def check_root(): + """Vérifie si on est root, sinon quitte.""" + if os.geteuid() != 0: + print("[SECURITY] Ce programme doit être exécuté en tant que root.") + sys.exit(1) + + +def check_user_exists(username): + """Vérifie si l'utilisateur spécifié existe.""" + try: + pwd.getpwnam(username) + return True + except KeyError: + print(f"[SECURITY] Utilisateur '{username}' introuvable.") + return False + + +def run_as_user(username): + """Décorateur : Fork et drop privileges pour exécuter une fonction sous un autre utilisateur.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + pid = os.fork() + if pid > 0: + # Parent + _, status = os.waitpid(pid, 0) + return os.WEXITSTATUS(status) + + # Child + pw_record = pwd.getpwnam(username) + user_uid = pw_record.pw_uid + user_gid = pw_record.pw_gid + + os.setgid(user_gid) + os.setuid(user_uid) + + # Exécuter la fonction sous l'utilisateur demandé + result = func(*args, **kwargs) + sys.exit(0 if result is None else int(bool(result))) + + except OSError as e: + print(f"[SECURITY] Fork échoué : {e}") + sys.exit(1) + + return wrapper + + return decorator diff --git a/lib/erminig.db b/lib/erminig.db new file mode 100644 index 0000000..25fdf49 Binary files /dev/null and b/lib/erminig.db differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bd115a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "erminig" +version = "0.0.1" +description = "Erminig: Une forge logicielle bretonne artisanale" +authors = [{ name = "Lomig", email = "lomig@example.com" }] +readme = "README.md" +license = "MIT" +requires-python = ">=3.13" +dependencies = ["requests", "PyYAML", "pytest"] + +[tool.setuptools] +packages = [ + "erminig", + "erminig.cli", + "erminig.controllers.evezh", + "erminig.controllers.evezh.parsers", + "erminig.models", +] + +[project.scripts] +erminit-init = "erminig.cli.init:main" +evezh = "erminig.cli.evezh:main" +govel = "erminig.cli.govel:main" diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..c745680 --- /dev/null +++ b/schema.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS upstreams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT CHECK(type IN ('http', 'github', 'sourceforge')) NOT NULL, + url TEXT, -- Base URL ou GitHub repo ou flux RSS + pattern TEXT DEFAULT NULL, -- Regex pour HTTP ou pattern GitHub si besoin + file TEXT, -- Optionnel : fichier final ex: gcc-${version}.tar.xz + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upstream_id INTEGER NOT NULL, + version TEXT, + url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (upstream_id) REFERENCES upstreams(id) +); + +CREATE TABLE IF NOT EXISTS packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + upstream_id INTEGER, + current_version_id INTEGER, + FOREIGN KEY (upstream_id) REFERENCES upstreams(id), + FOREIGN KEY (current_version_id) REFERENCES versions(id) +); + +CREATE TABLE IF NOT EXISTS depends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_id INTEGER NOT NULL, + depends_on INTEGER NOT NULL, + type TEXT CHECK(type IN ('runtime', 'build', 'check')), + FOREIGN KEY (package_id) REFERENCES packages(id), + FOREIGN KEY (depends_on) REFERENCES packages(id) +); + +CREATE TABLE IF NOT EXISTS builds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_id INTEGER NOT NULL, + version TEXT NOT NULL, + revision TEXT NOT NULL, + status TEXT CHECK(status IN ('success', 'fail', 'pending')), + built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + log_path TEXT, + FOREIGN KEY (package_id) REFERENCES packages(id) +); + +CREATE TABLE IF NOT EXISTS repo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_id INTEGER NOT NULL, + version TEXT NOT NULL, + path TEXT, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (package_id) REFERENCES packages(id) +); + +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + version TEXT NOT NULL, + url TEXT NOT NULL, + status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'failed' + last_attempt TIMESTAMP, -- Pour savoir quand on a tenté la dernière fois + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_upstream_version ON versions(upstream_id, version); \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..185d021 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,5 @@ +from erminig.config import Config + + +def test_db_path(): + assert str(Config.DB_PATH).endswith("erminig.db") diff --git a/tests/test_pakva.py b/tests/test_pakva.py new file mode 100644 index 0000000..6f87bb0 --- /dev/null +++ b/tests/test_pakva.py @@ -0,0 +1,20 @@ +import pytest +from pathlib import Path +from unittest.mock import patch + + +# PATCH directement le décorateur run_as_user pour les tests +@patch("erminig.system.security.run_as_user", lambda x=None: (lambda f: f)) +def test_pakva_save_and_read(tmp_path): + from erminig.controllers.govel.pakva import Pakva # Importer après patch ! + + pakva_path = tmp_path / "Pakva" + + pakva = Pakva("testpkg", "1.0.0", "http://example.com/testpkg.tar.gz") + pakva.path = pakva_path + pakva.save() + + loaded = Pakva.read(pakva_path) + assert loaded.name == "testpkg" + assert loaded.version == "1.0.0" + assert loaded.archive == "http://example.com/testpkg.tar.gz"