Files
Enclume/enclume/model.py
T
hcornet b063d59525
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
2026-09-10 16:28:26 +02:00

425 lines
16 KiB
Python

"""Modele de donnees d'un projet.
Un projet est ce que l'utilisateur construit dans l'editeur : des commandes de
controle, des modeles de service, des modeles d'hote, et facultativement des
hotes. Les objets se referencent par leur nom, comme dans Centreon.
Le projet est serialisable en JSON : c'est ce que le navigateur conserve, ce
qu'on exporte, ce qu'on partage et ce que la CI rejoue.
"""
from __future__ import annotations
import re
from dataclasses import asdict, dataclass, field
from typing import Any
SCHEMA_VERSION = 1
# Versions de Centreon proposees dans l'editeur et ce qu'elles impliquent.
# Centreon nomme ses versions par annee et saison de livraison : XX.04 au
# printemps, XX.10 a l'automne. Les versions des annees paires sont supportees
# trois ans (LTS) depuis la 24.10, celles des annees impaires dix-huit mois.
CENTREON_VERSIONS: dict[str, dict[str, Any]] = {
"25.10": {"label": "Centreon 25.10", "macro_fields": 5},
"25.04": {"label": "Centreon 25.04", "macro_fields": 5},
"24.10": {"label": "Centreon 24.10 (LTS)", "macro_fields": 5},
"24.04": {"label": "Centreon 24.04", "macro_fields": 5},
"23.10": {"label": "Centreon 23.10", "macro_fields": 5},
"23.04": {"label": "Centreon 23.04", "macro_fields": 5},
"22.10": {"label": "Centreon 22.10", "macro_fields": 5},
"22.04": {"label": "Centreon 22.04", "macro_fields": 5},
"21.10": {"label": "Centreon 21.10", "macro_fields": 5},
"21.04": {"label": "Centreon 21.04", "macro_fields": 5},
"20.10": {"label": "Centreon 20.10", "macro_fields": 5},
"20.04": {"label": "Centreon 20.04", "macro_fields": 5},
"legacy": {"label": "Centreon anterieur a 20.04", "macro_fields": 3},
}
VERSION_PAR_DEFAUT = "25.10"
# Les premieres versions d'Enclume proposaient des familles ("24.x") plutot que
# des versions reelles. Les projets enregistres a cette epoque restent lisibles.
VERSIONS_HISTORIQUES = {
"24.x": "24.10",
"23.x": "23.10",
"22.x": "22.10",
"21.x": "21.10",
}
COMMAND_TYPES = {"check": "Controle", "notif": "Notification", "misc": "Divers", "discovery": "Decouverte"}
FORBIDDEN = ";"
def _clean(value: Any) -> str:
return "" if value is None else str(value).strip()
@dataclass
class Macro:
name: str = ""
value: str = ""
description: str = ""
is_password: bool = False
@classmethod
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")),
description=_clean(raw.get("description")),
is_password=bool(raw.get("is_password")),
)
@dataclass
class Command:
name: str = ""
type: str = "check"
line: str = ""
comment: str = ""
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Command":
return cls(
name=_clean(raw.get("name")),
type=_clean(raw.get("type")) or "check",
line=_clean(raw.get("line")),
comment=_clean(raw.get("comment")),
)
@dataclass
class ServiceTemplate:
name: str = ""
alias: str = ""
parent: str = "generic-active-service-custom"
command: str = ""
command_args: str = ""
check_interval: int = 5
retry_interval: int = 1
max_attempts: int = 3
active_checks: int = 1
passive_checks: int = 0
host_templates: list[str] = field(default_factory=list)
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "ServiceTemplate":
def as_int(key: str, default: int) -> int:
try:
return int(raw.get(key, default))
except (TypeError, ValueError):
return default
return cls(
name=_clean(raw.get("name")),
alias=_clean(raw.get("alias")),
parent=_clean(raw.get("parent")),
command=_clean(raw.get("command")),
command_args=_clean(raw.get("command_args")),
check_interval=as_int("check_interval", 5),
retry_interval=as_int("retry_interval", 1),
max_attempts=as_int("max_attempts", 3),
active_checks=as_int("active_checks", 1),
passive_checks=as_int("passive_checks", 0),
host_templates=[_clean(h) for h in raw.get("host_templates") or [] if _clean(h)],
macros=[Macro.from_dict(m) for m in raw.get("macros") or []],
)
@dataclass
class HostTemplate:
name: str = ""
alias: str = ""
parents: list[str] = field(default_factory=list)
check_command: str = ""
check_command_args: str = ""
snmp_version: str = ""
snmp_community: str = ""
timezone: str = ""
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "HostTemplate":
return cls(
name=_clean(raw.get("name")),
alias=_clean(raw.get("alias")),
parents=[_clean(p) for p in raw.get("parents") or [] if _clean(p)],
check_command=_clean(raw.get("check_command")),
check_command_args=_clean(raw.get("check_command_args")),
snmp_version=_clean(raw.get("snmp_version")),
snmp_community=_clean(raw.get("snmp_community")),
timezone=_clean(raw.get("timezone")),
macros=[Macro.from_dict(m) for m in raw.get("macros") or []],
)
@dataclass
class Host:
name: str = ""
alias: str = ""
address: str = ""
templates: list[str] = field(default_factory=list)
poller: str = "Central"
hostgroups: str = ""
macros: list[Macro] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Host":
return cls(
name=_clean(raw.get("name")),
alias=_clean(raw.get("alias")),
address=_clean(raw.get("address")),
templates=[_clean(t) for t in raw.get("templates") or [] if _clean(t)],
poller=_clean(raw.get("poller")) or "Central",
hostgroups=_clean(raw.get("hostgroups")),
macros=[Macro.from_dict(m) for m in raw.get("macros") or []],
)
@dataclass
class Prerequis:
"""Ce qu'il faut installer sur le collecteur pour que les sondes tournent.
Volontairement separe du CLAPI : ces commandes s'executent sur un serveur,
elles ne doivent jamais etre melangees au code de configuration ni jouees
sans relecture.
"""
paquets_debian: list[str] = field(default_factory=list)
paquets_rhel: list[str] = field(default_factory=list)
verifications: list[str] = field(default_factory=list)
notes: str = ""
@property
def vide(self) -> bool:
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":
raw = raw or {}
paquets = raw.get("paquets") or {}
return cls(
paquets_debian=[_clean(p) for p in paquets.get("debian") or [] if _clean(p)],
paquets_rhel=[_clean(p) for p in paquets.get("rhel") or [] if _clean(p)],
verifications=[_clean(v) for v in raw.get("verifications") or [] if _clean(v)],
notes=str(raw.get("notes") or "").strip(),
)
def fusionner(self, autre: "Prerequis") -> None:
for source, cible in (
(autre.paquets_debian, self.paquets_debian),
(autre.paquets_rhel, self.paquets_rhel),
(autre.verifications, self.verifications),
):
for valeur in source:
if valeur not in cible:
cible.append(valeur)
if autre.notes and autre.notes not in self.notes:
self.notes = f"{self.notes}\n\n{autre.notes}".strip()
# Un nom de paquet reste un nom de paquet : ni espace, ni separateur de
# commande. Le fichier genere est relu par un humain, mais autant qu'aucune
# soumission ne puisse y glisser autre chose.
MOTIF_PAQUET = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,80}$")
@dataclass
class Project:
name: str = "Nouveau projet"
centreon_version: str = VERSION_PAR_DEFAUT
prefix: str = ""
plugins_dir: str = "/usr/lib/centreon/plugins"
poller: str = "Central"
apply_config: bool = True
commands: list[Command] = field(default_factory=list)
service_templates: list[ServiceTemplate] = field(default_factory=list)
host_templates: list[HostTemplate] = field(default_factory=list)
hosts: list[Host] = field(default_factory=list)
prerequis: Prerequis = field(default_factory=Prerequis)
schema: int = SCHEMA_VERSION
@property
def macro_fields(self) -> int:
return CENTREON_VERSIONS.get(self.centreon_version, {}).get("macro_fields", 5)
@classmethod
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)
if version not in CENTREON_VERSIONS:
version = VERSION_PAR_DEFAUT
return cls(
name=_clean(raw.get("name")) or "Nouveau projet",
centreon_version=version,
prefix=_clean(raw.get("prefix")),
plugins_dir=_clean(raw.get("plugins_dir")) or "/usr/lib/centreon/plugins",
poller=_clean(raw.get("poller")) or "Central",
apply_config=bool(raw.get("apply_config", True)),
commands=[Command.from_dict(c) for c in raw.get("commands") or []],
service_templates=[ServiceTemplate.from_dict(s) for s in raw.get("service_templates") or []],
host_templates=[HostTemplate.from_dict(h) for h in raw.get("host_templates") or []],
hosts=[Host.from_dict(h) for h in raw.get("hosts") or []],
prerequis=Prerequis.from_dict(raw.get("prerequis")),
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def merge(self, other: "Project") -> list[str]:
"""Ajoute les objets d'un autre projet, en ignorant les noms deja pris."""
skipped: list[str] = []
def add(target: list[Any], items: list[Any], label: str) -> None:
existing = {obj.name for obj in target}
for item in items:
if item.name in existing:
skipped.append(f"{label} deja present, ignore : {item.name}")
continue
target.append(item)
existing.add(item.name)
add(self.commands, other.commands, "Commande")
add(self.service_templates, other.service_templates, "Modele de service")
add(self.host_templates, other.host_templates, "Modele d'hote")
add(self.hosts, other.hosts, "Hote")
self.prerequis.fusionner(other.prerequis)
return skipped
# --------------------------------------------------------------------------- #
# Validation
# --------------------------------------------------------------------------- #
def validate(project: Project) -> list[dict[str, str]]:
"""Retourne la liste des problemes, du bloquant au simple avertissement."""
issues: list[dict[str, str]] = []
def error(message: str) -> None:
issues.append({"level": "error", "message": message})
def warn(message: str) -> None:
issues.append({"level": "warning", "message": message})
def check_names(items: list[Any], label: str) -> None:
seen: set[str] = set()
for index, item in enumerate(items, start=1):
if not item.name:
error(f"{label} n{chr(176)}{index} : le nom est vide.")
continue
if item.name in seen:
error(f"{label} : nom en double, {item.name}")
seen.add(item.name)
if FORBIDDEN in item.name:
error(f"{label} {item.name} : le point-virgule est le separateur CLAPI.")
check_names(project.commands, "Commande")
check_names(project.service_templates, "Modele de service")
check_names(project.host_templates, "Modele d'hote")
check_names(project.hosts, "Hote")
command_names = {c.name for c in project.commands}
htpl_names = {h.name for h in project.host_templates}
for command in project.commands:
if not command.line:
error(f"Commande {command.name} : la ligne de commande est vide.")
elif FORBIDDEN in command.line:
error(
f"Commande {command.name} : la ligne contient un point-virgule, "
"ce qui casserait le fichier d'import."
)
for stpl in project.service_templates:
if not stpl.command:
warn(f"Modele de service {stpl.name} : aucune commande de controle associee.")
elif stpl.command not in command_names:
warn(
f"Modele de service {stpl.name} : la commande {stpl.command} n'est pas dans "
"le projet, elle devra exister deja sur le central."
)
if not stpl.host_templates:
warn(
f"Modele de service {stpl.name} : rattache a aucun modele d'hote, "
"il ne sera deploye sur aucun hote."
)
for htpl in stpl.host_templates:
if htpl not in htpl_names:
warn(f"Modele de service {stpl.name} : modele d'hote inconnu, {htpl}")
_check_macros(stpl.macros, f"Modele de service {stpl.name}", issues)
for htpl in project.host_templates:
_check_macros(htpl.macros, f"Modele d'hote {htpl.name}", issues)
for host in project.hosts:
if not host.address:
error(f"Hote {host.name} : l'adresse est vide.")
for template in host.templates:
if template not in htpl_names:
warn(f"Hote {host.name} : modele d'hote inconnu, {template}")
_check_macros(host.macros, f"Hote {host.name}", issues)
for paquet in (*project.prerequis.paquets_debian, *project.prerequis.paquets_rhel):
if not MOTIF_PAQUET.match(paquet):
error(f"Prerequis : nom de paquet invalide, {paquet}")
if not project.commands and not project.service_templates and not project.host_templates:
warn("Le projet est vide.")
return issues
def _check_macros(macros: list[Macro], label: str, issues: list[dict[str, str]]) -> None:
seen: set[str] = set()
for macro in macros:
if not macro.name:
issues.append({"level": "error", "message": f"{label} : une macro n'a pas de nom."})
continue
if macro.name in seen:
issues.append({"level": "error", "message": f"{label} : macro en double, {macro.name}"})
seen.add(macro.name)
if FORBIDDEN in macro.value or FORBIDDEN in macro.description:
issues.append(
{
"level": "error",
"message": f"{label}, macro {macro.name} : le point-virgule est interdit.",
}
)
if macro.is_password and macro.value:
issues.append(
{
"level": "warning",
"message": (
f"{label}, macro {macro.name} : un mot de passe est renseigne. "
"Il apparaitra en clair dans les fichiers generes."
),
}
)
def detect_macros(line: str) -> dict[str, list[str]]:
"""Repere les macros personnalisees utilisees dans une ligne de commande."""
import re
service = sorted(set(re.findall(r"\$_SERVICE([A-Z0-9_]+)\$", line)))
host = sorted(set(re.findall(r"\$_HOST([A-Z0-9_]+)\$", line)))
return {"service": service, "host": host}
def strip_secrets(project: Project) -> Project:
"""Vide toutes les macros marquees mot de passe.
Applique avant tout stockage cote serveur : partage, projet enregistre,
soumission de pack. Le site n'a aucune raison de detenir ces valeurs.
"""
for holder in (*project.service_templates, *project.host_templates, *project.hosts):
for macro in holder.macros:
if macro.is_password:
macro.value = ""
return project