Compare commits

...
Sign in to create a new pull request.

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
39 changed files with 1626 additions and 0 deletions

8
.gitignore vendored Normal file
View file

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

0
.gitmodules vendored Normal file
View file

29
Makefile Normal file
View file

@ -0,0 +1,29 @@
.PHONY: install test fmt docker-build up rebuild clean
install:
pip install -e .[dev]
test:
pytest -v
fmt:
black erminig tests
git add erminig tests
docker-build:
podman-compose -f docker-compose.yml build
up:
podman-compose -f docker-compose.yml run erminig
rebuild: docker-build up
clean:
sudo find . -name "*.pyc" -delete
sudo find . -name "__pycache__" -type d -exec rm -rf {} +
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

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"

68
schema.sql Normal file
View file

@ -0,0 +1,68 @@
CREATE TABLE IF NOT EXISTS upstreams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT CHECK(type IN ('http', 'github', 'sourceforge')) NOT NULL,
url TEXT, -- Base URL ou GitHub repo ou flux RSS
pattern TEXT DEFAULT NULL, -- Regex pour HTTP ou pattern GitHub si besoin
file TEXT, -- Optionnel : fichier final ex: gcc-${version}.tar.xz
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upstream_id INTEGER NOT NULL,
version TEXT,
url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (upstream_id) REFERENCES upstreams(id)
);
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
upstream_id INTEGER,
current_version_id INTEGER,
FOREIGN KEY (upstream_id) REFERENCES upstreams(id),
FOREIGN KEY (current_version_id) REFERENCES versions(id)
);
CREATE TABLE IF NOT EXISTS depends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
depends_on INTEGER NOT NULL,
type TEXT CHECK(type IN ('runtime', 'build', 'check')),
FOREIGN KEY (package_id) REFERENCES packages(id),
FOREIGN KEY (depends_on) REFERENCES packages(id)
);
CREATE TABLE IF NOT EXISTS builds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
version TEXT NOT NULL,
revision TEXT NOT NULL,
status TEXT CHECK(status IN ('success', 'fail', 'pending')),
built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
log_path TEXT,
FOREIGN KEY (package_id) REFERENCES packages(id)
);
CREATE TABLE IF NOT EXISTS repo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
version TEXT NOT NULL,
path TEXT,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (package_id) REFERENCES packages(id)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
version TEXT NOT NULL,
url TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- 'pending', 'sent', 'failed'
last_attempt TIMESTAMP, -- Pour savoir quand on a tenté la dernière fois
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_upstream_version ON versions(upstream_id, version);

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"