Revue statique, conventions documentees, construction arm64 limitee aux versions et cache de couches
build / Garde-fou (pull_request) Successful in 9m21s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Successful in 9m20s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Successful in 9m44s
build / Images Harbor (web, Dockerfile) (push) Successful in 13m7s

This commit is contained in:
hcornet committed 2026-09-10 18:28:29 +02:00
1 parent 6d02ec0c18
commit acf72da7f8
16 files changed
+193 -46

No files matched your search

+22 -2
View File
@@ -27,7 +27,10 @@ jobs:
# evalue mal cette expression dans un bloc "with". La variable
# ci-dessus reste la reference lue par les humains.
python-version: "3.12"
- run: pip install -r requirements.txt pytest
cache: pip
- run: pip install -r requirements.txt pytest ruff
- name: Revue statique
run: python -m ruff check .
- run: python -m pytest -q
- name: Coherence du catalogue
run: python -m enclume.cli --list > /dev/null
@@ -48,7 +51,9 @@ jobs:
steps:
- uses: actions/checkout@v4
# QEMU n'est necessaire que pour l'arm64, donc uniquement sur les tags.
- name: Preparer QEMU
if: gitea.ref_type == 'tag'
uses: docker/setup-qemu-action@v3
- name: Preparer Buildx
@@ -72,13 +77,24 @@ jobs:
type=sha,format=long
type=ref,event=branch
- name: Choisir les architectures
id: arch
run: |
# L'arm64 passe par l'emulation QEMU et coute plusieurs dizaines de
# minutes. On ne le paie qu'au moment de publier une version.
if [ "${{ gitea.ref_type }}" = "tag" ]; then
echo "plateformes=linux/amd64,linux/arm64" >> "$GITHUB_OUTPUT"
else
echo "plateformes=linux/amd64" >> "$GITHUB_OUTPUT"
fi
- name: Construire et pousser
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.arch.outputs.plateformes }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -90,6 +106,10 @@ jobs:
# unknown/unknown que Harbor affiche mal et que son scanner ignore.
provenance: false
sbom: false
# Sans cache, chaque construction reinstalle toutes les dependances.
# Le cache vit dans Harbor, a cote des images.
cache-from: type=registry,ref=${{ vars.HARBOR_HOST }}/${{ vars.HARBOR_PROJECT }}/${{ matrix.composant }}:cache
cache-to: type=registry,ref=${{ vars.HARBOR_HOST }}/${{ vars.HARBOR_PROJECT }}/${{ matrix.composant }}:cache,mode=max
- name: Declencher le scan Harbor
env:
+5 -2
View File
@@ -6,9 +6,12 @@ FROM python:3.12-slim AS build
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /build
# requirements.txt seul dans cette couche : tant qu'il ne change pas, la
# reinstallation des dependances est reprise du cache, meme si le code a bouge.
COPY requirements.txt ./
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
python -m venv /opt/venv \
&& /opt/venv/bin/pip install -r requirements.txt
# ------------------------------------------------------------------- execution
FROM python:3.12-slim AS runtime
+6
View File
@@ -90,6 +90,12 @@ Un pack est un fichier YAML déposé dans `catalog/<catégorie>/`. Le format et
commenté sont dans `catalog/exemple-pack.yml.example`. Aucun code à modifier, aucune image à
reconstruire : `catalog-sync` le prend en charge au prochain cycle.
## Contribuer et livrer
Les conventions de nommage et la revue à passer avant chaque version sont dans
[docs/CONVENTIONS.md](docs/CONVENTIONS.md). En résumé : `ruff check`, la suite de tests, et
une installation en environnement vierge — dans cet ordre, avant tout tag.
## Structure
```
+80
View File
@@ -0,0 +1,80 @@
# Conventions et revue avant livraison
Ce fichier existe pour que le projet reste cohérent d'une version à l'autre, et pour qu'une
anomalie soit trouvée sur le poste de développement plutôt qu'après soixante minutes de
construction.
## Revue avant chaque livraison
Dans cet ordre, sans exception, y compris pour un correctif d'une ligne :
```bash
python -m ruff check . # analyse statique, zéro signalement toléré
python -m pytest -q # la suite complète
node --check enclume/static/*.js # syntaxe des scripts
sh -n docker/*.sh # syntaxe des scripts shell
python -c "import yaml,glob;[yaml.safe_load(open(f)) for f in glob.glob('catalog/**/*.yml',recursive=True)]"
docker compose -f compose.yaml config > /dev/null
```
Puis, dans un environnement vierge — c'est ce qui a manqué le jour où `requests` a cassé la
CI :
```bash
python -m venv /tmp/vierge && /tmp/vierge/bin/pip install -r requirements.txt pytest
/tmp/vierge/bin/python -m pytest -q
```
Et enfin la revue humaine, qui ne s'automatise pas :
- toute ressource référencée par un gabarit existe-t-elle réellement ? (un test le vérifie
désormais, parce que l'oubli a déjà eu lieu)
- une variable nouvelle est-elle présente dans `.env.example`, dans `compose.yaml` **et**
documentée ?
- une valeur venant de l'utilisateur peut-elle contenir un caractère qui casse le format où
on l'insère ? Point-virgule pour le CLAPI, `@` et `:` pour une URL de connexion, guillemet
simple pour le shell. Trois incidents sur ce seul motif.
- ce qui est écrit en base a-t-il traversé `strip_secrets` ?
## Nommage
| Objet | Règle | Exemple |
|---|---|---|
| Modules Python | français, minuscules, sans accent | `routes_donnees.py` |
| Fonctions et variables | français, `snake_case` | `utilisateur_courant`, `_repertoire_catalogue` |
| Classes | français, `PascalCase` | `Soumission`, `Prerequis` |
| Variables d'environnement | préfixe `ENCLUME_`, majuscules | `ENCLUME_DB_MOTDEPASSE` |
| Routes d'API | français, pluriel | `/api/soumissions`, `/api/partages` |
| Identifiants HTML | français, `kebab-case` | `btn-serveur-ouvrir`, `filtre-statut` |
| Fichiers statiques | français | `editeur.js`, `moderation.js` |
| Tables | français, pluriel | `utilisateurs`, `partages` |
| Packs du catalogue | `<famille>-<techno>[-<protocole>]` | `os-linux-snmp`, `hw-dell-idrac-snmp` |
| Modèles d'hôte générés | `<Famille>-<Techno>-custom` | `OS-Linux-SNMP-custom` |
| Modèles de service | `<Famille>-<Techno>-<Sonde>` | `OS-Linux-SNMP-Cpu` |
| Images | `<registre>/enclume/<composant>` | `.../enclume/catalog-sync` |
| Branches de soumission | `pack/<id>-<numéro>` | `pack/app-rabbitmq-7` |
Les commentaires et la documentation sont en français. Le code source reste sans accents,
par choix, pour éviter toute question d'encodage dans les conteneurs.
## Versions
Sémantique stricte, parce que la rétention Harbor et le retour arrière s'y appuient :
- **correctif** (`1.1.2`) : rien de nouveau, rien qui change de nom
- **mineure** (`1.2.0`) : fonctionnalité, nouvelle variable, migration de schéma
- **majeure** (`2.0.0`) : rupture pour qui héberge déjà — variable supprimée, format de
projet incompatible
Une migration de schéma n'ajoute que des colonnes et des tables. Une suppression attend la
version suivante, sinon revenir à l'image précédente casse l'application.
## Coût de construction
L'arm64 passe par l'émulation QEMU et coûte l'essentiel du temps de construction. Il n'est
donc produit **que sur un tag de version** ; un simple push sur `main` ne construit que
l'amd64. Le cache de couches vit dans Harbor, à côté des images : tant que
`requirements.txt` ne change pas, l'installation des dépendances est reprise du cache.
Conséquence pratique : itérer sur `main` est rapide, publier une version reste lent. Autant
grouper les corrections avant de taguer.
+4 -3
View File
@@ -11,7 +11,8 @@ identifiants de ces services, ni a etre redeployee quand on en ajoute un.
from __future__ import annotations
import functools
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for
@@ -100,7 +101,7 @@ def connexion():
def retour():
try:
jeton = oauth.fournisseur.authorize_access_token()
except Exception: # noqa: BLE001 - echec d'echange, on renvoie sans detail
except Exception:
current_app.logger.warning("echec de l'echange du jeton OIDC")
return redirect("/?connexion=echec")
@@ -108,7 +109,7 @@ def retour():
if not revendications:
try:
revendications = oauth.fournisseur.userinfo(token=jeton)
except Exception: # noqa: BLE001
except Exception:
revendications = {}
sujet = str(revendications.get("sub") or "").strip()
+5 -5
View File
@@ -31,7 +31,7 @@ class Macro:
is_password: bool = False
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Macro":
def from_dict(cls, raw: dict[str, Any]) -> Macro:
return cls(
name=str(raw["name"]).strip().upper(),
value="" if raw.get("value") is None else str(raw.get("value")),
@@ -53,7 +53,7 @@ class Service:
max_attempts: int | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Service":
def from_dict(cls, raw: dict[str, Any]) -> Service:
return cls(
name=str(raw["name"]).strip(),
alias=str(raw.get("alias") or raw["name"]).strip(),
@@ -77,7 +77,7 @@ class HostTemplate:
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "HostTemplate":
def from_dict(cls, raw: dict[str, Any]) -> HostTemplate:
return cls(
name=str(raw["name"]).strip(),
alias=str(raw.get("alias") or raw["name"]).strip(),
@@ -121,7 +121,7 @@ class Pack:
return None
@classmethod
def from_dict(cls, raw: dict[str, Any], source_file: str = "") -> "Pack":
def from_dict(cls, raw: dict[str, Any], source_file: str = "") -> Pack:
return cls(
id=str(raw["id"]).strip(),
name=str(raw["name"]).strip(),
@@ -155,7 +155,7 @@ class Catalog:
with open(path, encoding="utf-8") as handle:
raw = yaml.safe_load(handle)
pack = Pack.from_dict(raw, source_file=path)
except Exception as exc: # noqa: BLE001 - remonte tel quel a l'UI
except Exception as exc:
self.errors.append(f"{path} : {exc}")
continue
if pack.id in self.packs:
+12 -4
View File
@@ -25,14 +25,22 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--catalog", default=os.environ.get("ENCLUME_CATALOG", DEFAULT_CATALOG))
parser.add_argument("--list", action="store_true", help="liste les packs disponibles")
parser.add_argument("--pack", action="append", default=[], help="pack a importer (repetable)")
parser.add_argument("--tout", action="store_true", help="importer toutes les sondes, pas seulement celles par defaut")
parser.add_argument(
"--tout",
action="store_true",
help="importer toutes les sondes, pas seulement celles par defaut",
)
parser.add_argument("--projet", help="fichier de projet JSON exporte depuis le site")
parser.add_argument("--prefix", default="", help="prefixe applique aux objets crees")
parser.add_argument("--plugins-dir", default="/usr/lib/centreon/plugins")
parser.add_argument("--version-centreon", default="24.x")
parser.add_argument("--poller", default="Central")
parser.add_argument("--out", help="repertoire de sortie (defaut : stdout)")
parser.add_argument("--strict", action="store_true", help="code de retour non nul si le projet contient une erreur")
parser.add_argument(
"--strict",
action="store_true",
help="code de retour non nul si le projet contient une erreur",
)
args = parser.parse_args(argv)
catalog = Catalog(args.catalog)
@@ -93,5 +101,5 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__":
try:
raise SystemExit(main())
except BrokenPipeError:
raise SystemExit(0)
except BrokenPipeError: # sortie tronquee par un pipe, ex. | head
raise SystemExit(0) from None
+5 -5
View File
@@ -11,7 +11,7 @@ tard, quand le retour arriere n'est plus envisage.
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import (
@@ -35,7 +35,7 @@ db = SQLAlchemy(model_class=Base)
def maintenant() -> datetime:
return datetime.now(timezone.utc)
return datetime.now(UTC)
def _identifiant_court() -> str:
@@ -55,7 +55,7 @@ class Utilisateur(db.Model):
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
vu_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
projets: Mapped[list["Projet"]] = relationship(back_populates="proprietaire", cascade="all, delete-orphan")
projets: Mapped[list[Projet]] = relationship(back_populates="proprietaire", cascade="all, delete-orphan")
@property
def affichage(self) -> str:
@@ -96,7 +96,7 @@ class Partage(db.Model):
vues: Mapped[int] = mapped_column(Integer, default=0)
@classmethod
def creer(cls, contenu: str, nom: str, jours: int, utilisateur_id: int | None = None) -> "Partage":
def creer(cls, contenu: str, nom: str, jours: int, utilisateur_id: int | None = None) -> Partage:
return cls(
contenu=contenu,
nom=nom[:255],
@@ -108,7 +108,7 @@ class Partage(db.Model):
def expire(self) -> bool:
limite = self.expire_le
if limite.tzinfo is None: # SQLite rend des datetimes naifs
limite = limite.replace(tzinfo=timezone.utc)
limite = limite.replace(tzinfo=UTC)
return limite < maintenant()
+2 -2
View File
@@ -7,7 +7,7 @@ livres a l'utilisateur ne sont que des rendus differents de cette liste.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from .model import Macro, Project, validate
@@ -172,7 +172,7 @@ def build_plan(project: Project) -> Plan:
# --------------------------------------------------------------------------- #
def _header(title: str, plan: Plan) -> list[str]:
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
summary = ", ".join(f"{v} {k}" for k, v in plan.summary.items() if v)
return [
f"# {title} - {plan.project.name}",
+9 -9
View File
@@ -64,7 +64,7 @@ class Macro:
is_password: bool = False
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Macro":
def from_dict(cls, raw: dict[str, Any]) -> Macro:
return cls(
name=_clean(raw.get("name")).upper(),
value="" if raw.get("value") is None else str(raw.get("value")),
@@ -81,7 +81,7 @@ class Command:
comment: str = ""
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Command":
def from_dict(cls, raw: dict[str, Any]) -> Command:
return cls(
name=_clean(raw.get("name")),
type=_clean(raw.get("type")) or "check",
@@ -106,7 +106,7 @@ class ServiceTemplate:
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "ServiceTemplate":
def from_dict(cls, raw: dict[str, Any]) -> ServiceTemplate:
def as_int(key: str, default: int) -> int:
try:
return int(raw.get(key, default))
@@ -142,7 +142,7 @@ class HostTemplate:
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "HostTemplate":
def from_dict(cls, raw: dict[str, Any]) -> HostTemplate:
return cls(
name=_clean(raw.get("name")),
alias=_clean(raw.get("alias")),
@@ -167,7 +167,7 @@ class Host:
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Host":
def from_dict(cls, raw: dict[str, Any]) -> Host:
return cls(
name=_clean(raw.get("name")),
alias=_clean(raw.get("alias")),
@@ -198,7 +198,7 @@ class Prerequis:
return not (self.paquets_debian or self.paquets_rhel or self.verifications or self.notes)
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "Prerequis":
def from_dict(cls, raw: dict[str, Any] | None) -> Prerequis:
raw = raw or {}
paquets = raw.get("paquets") or {}
return cls(
@@ -208,7 +208,7 @@ class Prerequis:
notes=str(raw.get("notes") or "").strip(),
)
def fusionner(self, autre: "Prerequis") -> None:
def fusionner(self, autre: Prerequis) -> None:
for source, cible in (
(autre.paquets_debian, self.paquets_debian),
(autre.paquets_rhel, self.paquets_rhel),
@@ -247,7 +247,7 @@ class Project:
return CENTREON_VERSIONS.get(self.centreon_version, {}).get("macro_fields", 5)
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "Project":
def from_dict(cls, raw: dict[str, Any] | None) -> Project:
raw = raw or {}
version = _clean(raw.get("centreon_version")) or VERSION_PAR_DEFAUT
version = VERSIONS_HISTORIQUES.get(version, version)
@@ -270,7 +270,7 @@ class Project:
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def merge(self, other: "Project") -> list[str]:
def merge(self, other: Project) -> list[str]:
"""Ajoute les objets d'un autre projet, en ignorant les noms deja pris."""
skipped: list[str] = []
+5 -1
View File
@@ -19,7 +19,11 @@ def _macro(macro) -> Macro:
)
def pack_to_project(pack: Pack, only_defaults: bool = True, service_parent: str = "generic-active-service-custom") -> Project:
def pack_to_project(
pack: Pack,
only_defaults: bool = True,
service_parent: str = "generic-active-service-custom",
) -> Project:
"""Construit un projet ne contenant que les objets du pack."""
project = Project(name=pack.name)
+4 -4
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import json
import re
from datetime import timezone
from datetime import UTC
import yaml
from flask import Blueprint, jsonify, request
@@ -145,7 +145,7 @@ def creer_partage():
{
"id": partage.id,
"url": f"{base}/p/{partage.id}",
"expire_le": expire.replace(tzinfo=expire.tzinfo or timezone.utc).isoformat(),
"expire_le": expire.replace(tzinfo=expire.tzinfo or UTC).isoformat(),
"avertissement": "Les macros de type mot de passe ont ete videes avant l'enregistrement.",
}
)
@@ -194,8 +194,8 @@ def valider_pack(texte: str) -> tuple[dict | None, list[str]]:
try:
pack = Pack.from_dict(brut)
except Exception as erreur: # noqa: BLE001 - message rendu tel quel au contributeur
return None, problemes + [f"Structure invalide : {erreur}"]
except Exception as erreur:
return None, [*problemes, f"Structure invalide : {erreur}"]
if not pack.services:
problemes.append("Le pack ne declare aucune sonde.")
+1 -1
View File
@@ -14,7 +14,7 @@ from sqlalchemy import engine_from_config, pool
sys.path.insert(0, os.getcwd())
from enclume.db import Base # noqa: E402
from enclume.db import Base
config = context.config
config.set_main_option("sqlalchemy.url", os.environ.get("ENCLUME_DATABASE_URL", ""))
+21
View File
@@ -0,0 +1,21 @@
# Outillage de revue. La configuration vit ici pour que la CI et le poste de
# developpement appliquent exactement les memes regles.
[tool.ruff]
line-length = 110
target-version = "py312"
exclude = ["migrations/versions"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "ARG", "RUF"]
ignore = [
"RUF012", # les attributs de configuration sont volontairement mutables
"RUF001", # texte francais sans accents dans le code, par choix
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ARG005"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
+3 -4
View File
@@ -1,6 +1,5 @@
"""Garde-fous minimaux : ce qui casserait silencieusement le code genere."""
import json
from enclume.catalog import Catalog
from enclume.generator import build_plan, render_bundle
@@ -32,7 +31,7 @@ def test_prefixe_applique_aux_references():
projet = projet_exemple()
projet.prefix = "ACME-"
lignes = [o.import_line() for o in build_plan(projet).ops if o.obj != "#"]
liaison = [l for l in lignes if l.startswith("STPL;addhosttemplate")][0]
liaison = next(ligne for ligne in lignes if ligne.startswith("STPL;addhosttemplate"))
assert liaison.count("ACME-") == 2
@@ -46,13 +45,13 @@ def test_version_historique_convertie():
def test_macros_trois_champs_sur_ancienne_version():
projet = projet_exemple()
projet.centreon_version = "legacy"
macro = [o for o in build_plan(projet).ops if o.action == "setmacro"][0]
macro = next(o for o in build_plan(projet).ops if o.action == "setmacro")
assert len(macro.values) == 3
def test_echappement_shell_des_macros_centreon():
fichiers = render_bundle(build_plan(projet_exemple()))
ligne = [l for l in fichiers["deploy.sh"].splitlines() if "HOSTADDRESS" in l][0]
ligne = next(x for x in fichiers["deploy.sh"].splitlines() if "HOSTADDRESS" in x)
assert ligne.startswith("clapi -o CMD -a ADD -v '")
+9 -4
View File
@@ -7,7 +7,6 @@ ici, et la CI reste sans service externe.
from __future__ import annotations
import importlib
import json
import pytest
@@ -28,7 +27,9 @@ services:
- name: Queues
alias: Queues
description: Messages en attente
line: "$CENTREONPLUGINS$/centreon_rabbitmq_restapi.pl --plugin=apps::rabbitmq::restapi::plugin --mode=queues --hostname=$HOSTADDRESS$"
line: >-
$CENTREONPLUGINS$/centreon_rabbitmq_restapi.pl
--plugin=apps::rabbitmq::restapi::plugin --mode=queues --hostname=$HOSTADDRESS$
macros:
- {name: WARNING, value: "1000", description: "Seuil warning"}
"""
@@ -41,7 +42,11 @@ def app(tmp_path, monkeypatch):
monkeypatch.setenv("ENCLUME_SECRET_KEY", "cle-de-test")
monkeypatch.setenv("ENCLUME_COOKIE_SECURE", "0")
import enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp
import enclume.auth
import enclume.config
import enclume.db
import enclume.routes_donnees
import enclume.webapp
for module in (enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp):
importlib.reload(module)
@@ -276,7 +281,7 @@ def test_page_de_partage_sert_l_editeur(app):
def test_moderation_demande_la_connexion(app):
page = app.test_client().get("/moderation")
assert page.status_code == 200
assert "Connexion requise".encode() in page.data
assert b"Connexion requise" in page.data
def test_url_de_base_encode_les_caracteres_speciaux(monkeypatch):