Compare commits

..

8 commits
main ... dev

Author SHA1 Message Date
L0m1g
d801678376 Fix: Delete old License file 2025-03-03 11:57:21 +01:00
L0m1g
2060ad088d feat(db) update db when pakfile make changes 2025-03-03 11:49:17 +01:00
L0m1g
67c0c00fc3 feat(makefile): Makefile ind sql schema 2025-03-03 11:29:56 +01:00
L0m1g
e655fb4f71 feat(makefile): Makefile ind sql schema 2025-03-03 11:27:13 +01:00
L0m1g
48e191a705 feat(db) database connection 2025-03-03 11:25:08 +01:00
L0m1g
a7d708c8ca Fix: Readme history 2025-03-03 10:13:59 +01:00
L0m1g
b27f877f8b feat(common/pakfile): impl??mentation initiale de la classe Pakfile 2025-03-03 10:03:30 +01:00
L0m1g
b21bb94e8d Premier souffle d’Erminig.
Les fondations sont posées, la licence DOUARN flotte fièrement.
Le goéland nous surveille, l’hermine affûte ses griffes.
Archers en sucre, tremblez. Le vent de l’ouest se lève.
2025-03-02 11:35:18 +01:00
41 changed files with 307 additions and 1610 deletions

8
.gitignore vendored
View file

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

0
.gitmodules vendored
View file

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
[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.
-----

View file

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

View file

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

View file

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

View file

@ -1,98 +0,0 @@
#
# 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()

View file

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

25
src/erminig/common/db.py Normal file
View file

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

@ -0,0 +1,224 @@
#
# 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()

View file

@ -1,14 +0,0 @@
#
# 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")

View file

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