Compare commits

..

12 commits
dev ... main

Author SHA1 Message Date
L0m1g
56bd015690 Add: --force option to delete existing package 2025-05-09 11:47:48 +02:00
L0m1g
6a6d439aa2 Fix: run functions as pak user 2025-05-08 11:59:34 +02:00
L0m1g
7d39cae011 Fix: remove govel as submodule 2025-05-08 11:36:06 +02:00
L0m1g
d1bf50a840 Fix: remove container and venv for development 2025-05-08 11:32:06 +02:00
L0m1g
fb8c7f1b86 Fix: Load Pakva file if --name is used with govel 2025-05-05 16:27:52 +02:00
L0m1g
db9b597878 Add: First packages build 2025-05-05 16:16:24 +02:00
L0m1g
c26fcdc1db Add: Start govel build packages 2025-05-03 18:33:53 +02:00
L0m1g
865ec5def5 Add: erminig-pakeva as submodule 2025-05-03 16:29:04 +02:00
L0m1g
14a8f2b477 Fix: black error on pre-commit 2025-05-03 16:19:45 +02:00
L0m1g
63fc1ffd0e Fix: unlock database 2025-05-03 16:11:28 +02:00
L0m1g
f664d07c77 Add: Entete de licence dans les fichier .py 2025-04-29 17:35:21 +02:00
L0m1g
c63f62721b Les choses sérieuses commencent 2025-04-29 17:15:19 +02:00
41 changed files with 1617 additions and 314 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
.pytest_cache/
*.log
lib/erminig.db

View file

@ -1,37 +1,29 @@
DB_PATH := /var/lib/erminig/erminig.db .PHONY: install test fmt docker-build up rebuild clean
DB_DIR := /var/lib/erminig
PAK_USER := pak
all: prepare_env create_db create_pak_user install:
pip install -e .[dev]
prepare_env: test:
@echo "Création de l'arborescence pour Erminig..." pytest -v
@mkdir -p $(DB_DIR)
@mkdir -p /var/govel
@chown -R $(PAK_USER):$(PAK_USER) /var/govel || true
create_db: fmt:
@echo "Initialisation de la base SQLite Erminig..." black erminig tests
@if [ ! -f "$(DB_PATH)" ]; then \ git add erminig tests
sqlite3 $(DB_PATH) < schema.sql; \
chown $(PAK_USER):$(PAK_USER) $(DB_PATH); \
echo "Base de données créée à $(DB_PATH)"; \
else \
echo "La base existe déjà, on touche pas."; \
fi
create_pak_user: docker-build:
@echo "Création de l'utilisateur '$(PAK_USER)'..." podman-compose -f docker-compose.yml build
@if ! id -u $(PAK_USER) >/dev/null 2>&1; then \
useradd -r -m -d /var/govel -s /bin/bash $(PAK_USER); \ up:
echo "Utilisateur '$(PAK_USER)' créé."; \ podman-compose -f docker-compose.yml run erminig
else \
echo "L'utilisateur '$(PAK_USER)' existe déjà."; \ rebuild: docker-build up
fi
clean: clean:
@echo "Suppression de la base et de l'arborescence..." sudo find . -name "*.pyc" -delete
@rm -f $(DB_PATH) sudo find . -name "__pycache__" -type d -exec rm -rf {} +
@rm -rf /var/govel sudo find . -name "*.egg-info" -type d -exec rm -rf {} +
sudo find . -name ".pytest_cache" -type d -exec rm -rf {} +
build-all: fmt test docker-build
.PHONY: all prepare_env create_db create_pak_user clean

100
data/config.yaml Normal file
View file

@ -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

55
doc/architecture.md Normal file
View file

@ -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

6
doc/bugs.md Normal file
View file

@ -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("<urllib3.connection.HTTPSConnection object at 0x7fc45e207ed0>: Failed to resolve 'ftp.mpi-inf.mpg.de' ([Errno -5] No address associated with hostname)"))
[gcc] Aucune version détectée.
-----

69
doc/roadmap.md Normal file
View file

@ -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.

78
doc/specs-pakva.md Normal file
View file

@ -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:<name>()`
`pre_install:<name>()`
`post_install:<name>()`
`pre_upgrade:<name>()`
`post_upgrade:<name>()`
`pre_remove:<name>()`
`post_remove:<name>()`
### Macros disponibles
| Macro | Description |
| :---- | :---------- |
| `$PAK` | Dossier global d'installation |
| `$SRC` | Dossier d'extraction des sources |
| `$TMP` | Dossier temporaire pour chaque `pak:<name>` |
| `name, $version, $revision, $basename` | Metadonnées du paquet principal |
| `pakname` | nom du paquet courant dans un `pak:<name>` |

0
erminig/__init__.py Normal file
View file

0
erminig/cli/__init__.py Normal file
View file

63
erminig/cli/evezh.py Normal file
View file

@ -0,0 +1,63 @@
#
# Erminig - Analyse d'arguments pour evezh
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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)

98
erminig/cli/govel.py Normal file
View file

@ -0,0 +1,98 @@
#
# Erminig - Analyse d'arguments pour govel
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import argparse
from pathlib import Path
from erminig.core.config import Config
from erminig.controllers.govel.pakva import Pakva
from erminig.controllers.govel.build import run_build_function, run_pak_function
from erminig.core.package import Package
from erminig.core.security import run_as_user
import os
import subprocess
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")
build_parser.add_argument(
"--force",
action="store_true",
help="Force le build même si le dossier existe déjà",
)
new_parser = subparsers.add_parser("new")
new_parser.add_argument("--name", required=True, help="Nom du paquet")
edit_parser = subparsers.add_parser("edit")
edit_parser.add_argument("--name", required=True, help="Nom du paquet à éditer")
args = parser.parse_args()
if args.command == "build":
if args.name:
pakva = Pakva.load_from_name(args.name)
else:
pakva_path = Path.cwd() / "Pakva"
if not pakva_path.exists():
print("[GOVEL] Erreur : Aucun Pakva trouvé dans le dossier courant.")
return
pakva = Pakva.read(pakva_path)
pak_success = False
tmp_path = Path(f"{Config.BUILD_DIR}/{pakva.name}-{pakva.version}")
if tmp_path.exists():
if not args.force:
print(
f"[GOVEL] Erreur : {tmp_path} existe déjà. Utilisez --force pour écraser."
)
return
else:
print(f"[GOVEL] Build forcé activé. Suppression de {tmp_path}")
import shutil
shutil.rmtree(tmp_path)
build_success = run_build_function(pakva.path, pakva.name, pakva.version)
if build_success:
pak_success = run_pak_function(pakva.path, pakva.name, pakva.version)
if pak_success:
tmp_path = f"{Config.BUILD_DIR}/{pakva.name}-{pakva.version}"
pkg = Package(pakva.name, pakva.version, tmp_path)
pkg.generate_manifest()
pkg.write_manifest()
pkg.copy_pakva(pakva.path)
pkg.build_archive()
print(f"[GOVEL] Build réussi pour {pakva.name}")
else:
print(f"[GOVEL] Build échoué pour {pakva.name}")
elif args.command == "new":
if args.name:
pakva = Pakva(args.name)
pakva.save()
elif args.command == "edit":
pakva = Pakva.load_from_name(args.name)
open_editor(pakva.path)
@run_as_user("pak")
def open_editor(path):
editor = os.getenv("EDITOR", "nvim")
subprocess.run([editor, str(path)])
if __name__ == "__main__":
main()

69
erminig/cli/init.py Normal file
View file

@ -0,0 +1,69 @@
#
# Erminig - Initialisation des utilisateurs et répertoires
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import os
import subprocess
import sys
from pathlib import Path
from erminig.core.security import check_root, check_user_exists, run_as_user
from erminig.core.config import Config
from erminig.models.db import init_db
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, Config.BASE_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] Initialisation de la base de données...")
init_db()
print("[INIT] Environnement Erminig initialisé avec succès.")
if __name__ == "__main__":
main()

View file

View file

View file

@ -0,0 +1,23 @@
#
# Erminig - Classe abstraite pour la récupération des nouvelles version de softs.
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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

View file

@ -0,0 +1,133 @@
#
# Erminig - Classe globale pour la récupération des dernières versions de softs.
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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
from erminig.core.security import run_as_user
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))
@run_as_user("pak")
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
@run_as_user("pak")
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 lancienne version.")
results.append(
{
"name": name,
"version": state[name]["version"],
"url": state[name]["url"],
}
)
else:
print(f"[{name}] Aucune version détectée.")
return results

View file

@ -0,0 +1,85 @@
#
# Erminig - Récupération de la dernière version d'un soft sur Github via son API
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import re
import requests
from erminig.core.config import Config
from erminig.controllers.evezh.abstract import UpstreamSource
from erminig.core.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

View file

@ -0,0 +1,54 @@
#
# Erminig - Recupération de la dernière version d'un soft sur page html
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import re
import requests
from erminig.controllers.evezh.abstract import UpstreamSource
from erminig.core.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))

View file

@ -0,0 +1,42 @@
#
# Erminig - Recupération de la dernière version d'un soft sur page Sourceforge
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import re
import requests
import xml.etree.ElementTree as ET
from erminig.controllers.evezh.abstract import UpstreamSource
from erminig.core.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]}

View file

View file

@ -0,0 +1,63 @@
#
# Erminig - Lancement de la construction du paquet
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import os
import subprocess
from erminig.core.config import Config
from erminig.core.security import check_root, check_user_exists, run_as_user
check_root
check_user_exists("pak")
def run_pakva_function(pakva_path, name, version, func_name):
"""
Exécute une fonction dun fichier Pakva donné (ex: build, pak).
"""
build_root = Config.BUILD_DIR / f"{name}-{version}"
src_dir = build_root / "src"
tmp_dir = build_root / "tmp"
os.makedirs(src_dir, exist_ok=True)
os.makedirs(tmp_dir, exist_ok=True)
env = os.environ.copy()
env["SRC"] = str(src_dir)
env["TMP"] = str(tmp_dir)
try:
result = subprocess.run(
f"""
set -e
source "{pakva_path}"
{func_name}
""",
shell=True,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
executable="/bin/bash",
text=True,
env=env,
)
print(f"[{func_name.upper()}] Succès : {pakva_path.name}")
print(result.stdout)
return True
except subprocess.CalledProcessError as e:
print(f"[{func_name.upper()}] Échec : {pakva_path.name}")
print(e.stderr)
return False
@run_as_user("pak")
def run_build_function(pakva_path, name, version):
return run_pakva_function(pakva_path, name, version, "build")
def run_pak_function(pakva_path, name, version):
return run_pakva_function(pakva_path, name, version, "pak")

View file

@ -0,0 +1,120 @@
#
# Erminig - Création et mise à jour de la révision des fichiers pakva
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
from pathlib import Path
from erminig.core.config import Config
from erminig.core.security import run_as_user
class Pakva:
def __init__(self, name, version=None, archive=None):
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"<Pakva {self.name}-{self.version}>"
@classmethod
def load_from_name(cls, name):
"""
Charge un fichier Pakva depuis son nom uniquement.
"""
path = Config.GOVEL_DIR / name[0] / name / "Pakva"
return cls.read(path)

0
erminig/core/__init__.py Normal file
View file

28
erminig/core/config.py Normal file
View file

@ -0,0 +1,28 @@
#
# Erminig - Configuration de l'application
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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"
BUILD_DIR = Path("/tmp/erminig/build")
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

105
erminig/core/package.py Normal file
View file

@ -0,0 +1,105 @@
#
# Erminig - Fonctions de base pour les paquets
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import hashlib
import tarfile
import tempfile
import shutil
import pwd
import grp
from pathlib import Path
from erminig.core.config import Config
class Package:
def __init__(self, name, version, tmp_dir):
self.name = name
self.version = version
self.tmp_dir = Path(tmp_dir)
self.manifest = []
def generate_manifest(self):
install_root = self.tmp_dir / "tmp"
for path in install_root.rglob("*"):
rel_path = Path("/") / path.relative_to(install_root)
stat = path.lstat()
user = pwd.getpwuid(stat.st_uid).pw_name
group = grp.getgrgid(stat.st_gid).gr_name
if path.is_file():
md5 = hashlib.md5(path.read_bytes()).hexdigest()
self.manifest.append(
{
"path": str(rel_path),
"md5": md5,
"mode": oct(stat.st_mode & 0o777),
"user": user,
"group": group,
"type": "file",
}
)
elif path.is_dir():
self.manifest.append(
{
"path": str(rel_path),
"md5": "-", # pas de hash pour les dossiers
"mode": oct(stat.st_mode & 0o777),
"user": user,
"group": group,
"type": "dir",
}
)
def write_manifest(self):
manifest_path = self.tmp_dir / "MANIFEST"
with open(manifest_path, "w") as f:
for entry in self.manifest:
f.write(
f"{entry['type']} {entry['path']} {entry['md5']} {entry['mode']} {entry['user']} {entry['group']}\n"
)
def copy_pakva(self, pakva_path):
dest = self.tmp_dir / "Pakva"
content = Path(pakva_path).read_text()
dest.write_text(content)
# Dans ta classe Package
def build_archive(self):
temp_dir = tempfile.mkdtemp()
temp_path = Path(temp_dir)
# Copie les fichiers Pakva et MANIFEST dans le répertoire temporaire
shutil.copy(self.tmp_dir / "Pakva", temp_path / "Pakva")
shutil.copy(self.tmp_dir / "MANIFEST", temp_path / "MANIFEST")
# Crée le dossier Files et copie dedans UNIQUEMENT le contenu de $TMP
files_dir = temp_path / "Files"
files_dir.mkdir()
for item in (self.tmp_dir).iterdir():
if item.name in ["Pakva", "MANIFEST"]:
continue
install_dir = self.tmp_dir / "tmp"
if install_dir.exists():
for item in install_dir.iterdir():
dest = files_dir / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
# Crée l'archive .bzh sans inclure les dossiers de travail
archive_path = Config.BUILD_DIR / f"{self.name}-{self.version}.bzh"
with tarfile.open(archive_path, "w|xz") as tar:
tar.add(temp_path / "Pakva", arcname="Pakva")
tar.add(temp_path / "MANIFEST", arcname="MANIFEST")
tar.add(files_dir, arcname="Files")
shutil.rmtree(temp_path)
return archive_path

44
erminig/core/retry.py Normal file
View file

@ -0,0 +1,44 @@
#
# Erminig - Décorateur pour relancer un téléchargement
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import time
import requests
from erminig.core.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

69
erminig/core/security.py Normal file
View file

@ -0,0 +1,69 @@
#
# Erminig - Fonctions pour gérer les utilisateurs système.
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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 : attendre le child, ne pas exit, juste return proprement
_, status = os.waitpid(pid, 0)
return (
status >> 8
) # récupère le code retour du fils (comme exit code)
# 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)
os.environ["HOME"] = pw_record.pw_dir
os.environ["LOGNAME"] = pw_record.pw_name
os.environ["USER"] = pw_record.pw_name
result = func(*args, **kwargs)
os._exit(0 if result is None else int(bool(result)))
except OSError as e:
print(f"[SECURITY] Fork échoué : {e}")
os._exit(1)
return wrapper
return decorator

View file

View file

@ -0,0 +1,32 @@
#
# Erminig - Rentre la nouvelle version d'un soft dans la base de données
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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

View file

43
erminig/models/db.py Normal file
View file

@ -0,0 +1,43 @@
#
# Erminig - initialise la base sqlite
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import os
import pwd
import sqlite3
from erminig.core.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()
# Attribution au user pak
pak_uid = pwd.getpwnam("pak").pw_uid
pak_gid = pwd.getpwnam("pak").pw_gid
os.chown(Config.DB_PATH, pak_uid, pak_gid)
os.chmod(Config.DB_PATH, 0o664)
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()

View file

@ -0,0 +1,38 @@
#
# Erminig - Fonctions relatives à la table upstreams
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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

View file

@ -0,0 +1,34 @@
#
# Erminig - Fonctions relatives à la table versions
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
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

27
pyproject.toml Normal file
View file

@ -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"

View file

@ -1,37 +1,68 @@
-- schema.sql - Structure initiale de la base Erminig CREATE TABLE IF NOT EXISTS upstreams (
CREATE TABLE packages (
name TEXT PRIMARY KEY,
version TEXT NOT NULL,
revision INTEGER NOT NULL,
status TEXT NOT NULL, -- draft, built, failed, validated
blocked BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sources (
package_name TEXT,
url TEXT,
hash TEXT,
FOREIGN KEY (package_name) REFERENCES packages(name)
);
CREATE TABLE dependencies (
package_name TEXT,
dependency TEXT,
type TEXT, -- build, runtime, check
FOREIGN KEY (package_name) REFERENCES packages(name)
);
CREATE TABLE builds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
package_name TEXT, name TEXT NOT NULL UNIQUE,
version TEXT, type TEXT CHECK(type IN ('http', 'github', 'sourceforge')) NOT NULL,
revision INTEGER, url TEXT, -- Base URL ou GitHub repo ou flux RSS
start_time TEXT DEFAULT CURRENT_TIMESTAMP, pattern TEXT DEFAULT NULL, -- Regex pour HTTP ou pattern GitHub si besoin
end_time TEXT, file TEXT, -- Optionnel : fichier final ex: gcc-${version}.tar.xz
status TEXT, -- success, failed created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
log TEXT,
FOREIGN KEY (package_name) REFERENCES packages(name)
); );
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);

View file

@ -1,25 +0,0 @@
#
# Erminig - Interface SQLite
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import sqlite3
import os
DB_PATH = "/var/lib/erminig/erminig.db"
def get_db_connection():
"""
Ouvre une connexion à la base SQLite d'Erminig.
Si la base n'existe pas, déclenche une exception et laisse Make gérer sa création.
"""
if not os.path.exists(DB_PATH):
raise FileNotFoundError(f"La base de données Erminig est introuvable. Exécutez 'make init-db' avant de continuer.")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn

View file

@ -1,224 +0,0 @@
#
# Erminig - Librairie Pakfile
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import os
import toml
from .db import get_db_connection
class Pakfile:
"""
Représente un fichier de description de paquet (pakfile.toml)
dans le système de gestion de paquets Erminig.
Cette classe offre les opérations essentielles pour manipuler,
vérifier et modifier les pakfiles.
"""
BASE_PATH = "/var/govel"
def __init__(self, package: str):
"""
Initialise la gestion du pakfile pour un paquet donné.
:param package: Nom du paquet
"""
self.package = package
self.data = {}
def _load(self):
"""
Charge le contenu du pakfile en mémoire
Appelé automatiquement à l'initialisation si le fichier existe
"""
package_dir, pakfile_path = self._get_paths(package)
if not os.path.exists(pakfile_path):
raise FileNotFoundError(f"Pakfile introuvable pour {self.package} dans {pakfile_path}")
with open(pakfile_path, "r", encoding="utf-8") as f:
self.data = toml.load(f)
def _get_paths(self, package: str) -> tuple[str, str]:
"""
Retourne le chemin du dossier du paquet et du pakfile associé.
"""
package_dir = f"{self.BASE_PATH}/{package}"
pakfile_path = f"{package_dir}/pakfile.toml"
return package_dir, pakfile_path
def new(self, package: str):
"""
Crée un nouveau pakfile avec un squelette de base prêt à être édité.
Soulève une erreur si le pakfile existe déjà.
"""
package_dir, pakfile_path = self._get_paths(package)
if os.path.exists(pakfile_path):
raise FileExistsError(f"Le pakfile existe déjà pour {package}")
data = {
"header": {
"copyright": "Copyright (C) 2025 L0m1g",
"license": "Sous licence DOUARN - Voir le fichier LICENCE pour les détails",
"author": "L0m1g",
"maintainer": "L0m1g",
"description": "Description à compléter"
},
"name": package,
"ver": "",
"rev": 1,
"src": [],
"build": "# Ajouter les commandes de construction ici",
"check": "# Ajouter les commandes check ou équivalentes ici",
"install": "# Ajouter les commandes dinstallation ici"
}
os.makedirs(package_dir, exist_ok=True)
with open(pakfile_path, "w", encoding="utf-8") as f:
toml.dump(data, f)
self.update_db()
def set(self, key: str, value):
"""
Définit une valeur dans le pakfile et sauvegarde immédiatement
"""
self.data[key] = value
self._save()
self.update_db()
def _save(self):
"""
Ecrit le contenu actuel de self.data dans le pakfile correspondant
Créé le répertoire s'il n'existe pas.
"""
package_dir, pakfile_path = self._get_paths(self.package)
os.makedirs(package_dir, exist_ok=True)
with open(pakfile_path, "w", encoding="utf-8") as f:
toml.dump(self.data, f)
def get(self, key: str):
"""
Récupère la valeur d'une clé dans le pakfile
Redirige vers de getters spécifiques
:param key: Clé à récupérer
:return: Valeur associée ou None
"""
match key:
case "name" | "ver" | "rev":
return self._get(key)
case "src" | "deps" | "bdeps":
return self._get_list(key)
case _:
return None
def _get(self, key: str) -> str | None :
"""
Getter générique pour les chaines
"""
return self.data.get(key)
def _get_list(self, key: str) -> list[str]:
"""
Getter spécifique aux listes
"""
return self.data.get(key,[])
def delete(self, package: str) -> bool:
"""
Supprime un paquet non installé et son répertoire associé.
:param package: Nom du paquet à supprimer
:return: True si la suppression a réussi, False sinon
"""
package_dir, pakfile_path = self._get_paths(package)
if not os.path.exists(package_dir):
print(f"Le paquet '{package}' n'existe pas. Rien à supprimer.")
return False
try:
# On vire tout le répertoire et son contenu
for root, dirs, files in os.walk(package_dir, topdown=False):
for file in files:
os.remove(os.path.join(root, file))
for dir in dirs:
os.rmdir(os.path.join(root, dir))
os.rmdir(package_dir)
print(f"Paquet '{package}' supprimé avec succès.")
return True
except Exception as e:
print(f"Erreur lors de la suppression de '{package}': {e}")
return False
self.update_db()
def check(self):
"""
Vérifie la validité de la structure du pakfile.
Contrôle la présence et la cohérence des clés obligatoires.
Ne vérifie PAS la disponibilité des sources ou la qualité du code, juste la structure.
"""
required_keys = {"name", "ver", "rev", "src"}
optional_keys = {"deps", "bdeps", "build", "make", "install"}
all_keys = set(self.data.keys())
unknown_keys = all_keys - required_keys - optional_keys
missing_keys = required_keys - all_keys
if missing_keys:
print(f"Clés manquantes dans {self.package}: {', '.join(missing_keys)}")
return False
if unknown_keys:
print(f"Clés inconnues dans {self.package}: {', '.join(unknown_keys)}")
return False
return True
def update_db(self):
"""
Met à jour la base SQLite avec les informations actuelles du pakfile.
Si le paquet existe, il est mis à jour. Sinon, il est inséré.
"""
conn = get_db_connection()
cursor = conn.cursor()
# Récupère les données à jour
name = self.get("name")
version = self.get("ver")
revision = self.get("rev")
sources = self.get("src")
deps = self.get("deps")
bdeps = self.get("bdeps")
# Conversion des listes en string (on se complique pas la vie)
src_str = ",".join(sources) if sources else ""
deps_str = ",".join(deps) if deps else ""
bdeps_str = ",".join(bdeps) if bdeps else ""
cursor.execute("""
INSERT INTO paquets (name, version, revision, sources, deps, build_deps)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
version = excluded.version,
revision = excluded.revision,
sources = excluded.sources,
deps = excluded.deps,
build_deps = excluded.build_deps
""", (name, version, revision, src_str, deps_str, bdeps_str))
conn.commit()
conn.close()

14
tests/test_config.py Normal file
View file

@ -0,0 +1,14 @@
#
# Erminig - Tests relatifs à la bse de données
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
from erminig.core.config import Config
def test_db_path():
assert str(Config.DB_PATH).endswith("erminig.db")

29
tests/test_pakva.py Normal file
View file

@ -0,0 +1,29 @@
#
# Erminig - Tests relatifs aux fichiers Pakva
# Copyright (C) 2025 L0m1g
# Sous licence DOUARN - Voir le fichier LICENCE pour les détails
#
# Ce fichier fait partie du projet Erminig.
# Libre comme lair, stable comme un menhir, et salé comme le beurre.
#
import pytest
from pathlib import Path
from unittest.mock import patch
# PATCH directement le décorateur run_as_user pour les tests
@patch("erminig.core.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"