Refonte application-hub Phase 1 : liens explicites

L'application porte tous ses liens (groupe=capacite, hote=VM, port,
requiert, expose). L'exposition DNS quitte docs/domaines.yml et vit sur
l'application via 'expose' ; domaines.yml ne decrit plus que les zones.

- inventory_rules : expositions_des_applications + domaine_parent ;
  valider_applications valide requiert / expose / port.
- serveurs_nginx derive ses vhosts des applications (expose + edge),
  resolvant application -> hote -> IP (role + template reecrits).
- filter plugin : expose la nouvelle regle a Ansible.
- CLI : applications.py --port/--requiert/--expose ; domaines.py affiche
  les expositions derivees des applications.
- GUI : champs Port/Requiert/Expose sur la fiche Application (persistes ;
  corrige la perte de champs a la sauvegarde) ; l'API expose les domaines.

Le groupe n'est plus une cible de liaison, seulement une capacite.
Renommage nomenclature.domaines -> fonctions : Phase 1b. Generation de
l'inventaire depuis le plan : Phase 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-23 20:10:15 -04:00
parent abd6395a13
commit 27bb51d007
10 changed files with 163 additions and 84 deletions

View file

@ -2,6 +2,9 @@
## 2026-06-23
### Modifié
- **Refonte « application-hub », Phase 1 — les liens deviennent explicites (inventaire inchangé, zéro régression).** L'application porte désormais tous ses liens : `groupe` (capacité/rôle), `hote` (VM), `port`, **`requiert`** (autres applications dont elle dépend) et **`expose`** (FQDN publics qui la publient). L'**exposition DNS quitte `docs/domaines.yml`** (le bloc `exposition` est retiré) et vit sur l'application via `expose` ; `docs/domaines.yml` ne décrit plus que les zones (autorité, edge, secondaires, dnssec, mail). `serveurs_nginx` dérive ses vhosts des **applications** (`expose` + edge du domaine), résolvant `application → hôte → IP` (rôle + `expositions.conf.j2` réécrits). Nouvelle règle partagée `expositions_des_applications` (+ `domaine_parent`) dans `inventory_rules.py`, exposée à Ansible par le filter plugin ; `valider_applications` valide `requiert` (applis connues), `expose` (domaine parent déclaré) et `port`. CLI (`applications.py --port/--requiert/--expose`, `domaines.py` affiche les expositions dérivées) et **GUI** (champs Port/Requiert/Expose sur la fiche Application, persistés) suivent ; l'API expose aussi le registre des domaines. Le groupe n'est plus une cible de liaison, seulement une capacité. *(Renommage `nomenclature.domaines:``fonctions:` reporté en Phase 1b ; génération de l'inventaire depuis le plan en Phase 3.)*
### Ajouté
- **Application = entité de première classe** (une VM peut porter plusieurs applications). Nouveau registre `docs/applications.yml` (`application → groupe, hote`). Les bases se lient via `consommateur` + **`portee`** (`application` | `groupe` | `hote`, défaut `groupe`) dans `docs/bases-donnees.yml` : une application `A` (groupe `G`, hôte `H`) reçoit les bases où `(portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) OU (portee=hote ET consommateur=H)` — plusieurs DSN par application. Résolution + validation partagées dans `inventory_rules.py` (`charger_applications`, `valider_applications`, `applications_de_hote`, `bases_de_application`, `PORTEES_BD` ; `valider_bases` croise désormais `portee=application` avec le registre des applications). CLI `scripts/applications.py` (`lister`/`verifier`/`ajouter`/`retirer`) + `scripts/bases_donnees.py --portee` ; cibles `make applications` / `applications-verifier` ; validation intégrée dans `make inventaire-verifier`. GUI : vue **Applications** (CRUD), sélecteur de **portée** + consommateur dynamique (groupe/hôte/application) dans la vue Bases, et **vue Chaîne par application** (DSN résolus par hôte). Filter plugin `filter_plugins/registres.py` exposant la **même** règle de résolution à Ansible (source unique) ; les playbooks `serveurs_web_dorsaux` / `serveurs_web_frontaux` itèrent désormais sur les applications de l'hôte et résolvent leurs bases (scaffold ; le déploiement applicatif réel s'y insère). Les services en grappe (keycloak/icingadb/forgejo) restent en `portee: groupe` (inchangés).
- Vhosts `serveurs_nginx` **dérivés du registre `docs/domaines.yml`** (bloc `exposition`) : pour chaque exposition `web` ciblant cet edge, un vhost est généré (`expositions.conf.j2`) avec amont = `amont` explicite, sinon `http://<IP interne de la cible>:<port>` résolu depuis l'inventaire ; une exposition non résolvable (cible sans hôte actif, ou sans port) est listée mais non publiée. Variables `serveurs_nginx_groupe` (edge ciblé) et `serveurs_nginx_publier_expositions` (bascule). Les sites déclarés à la main continuent de coexister. Ajout du `port: 3000` à l'exposition `forge`.

View file

@ -1,23 +1,11 @@
# Registre des applications Set-OPS (application = entite pivot).
# Edite par make inventaire-ui ou scripts/applications.py.
# Une VM peut porter plusieurs applications ; une base se lie a une application.
---
# Registre des applications Set-OPS — l'application est l'entite PIVOT.
#
# Une VM (hote) peut porter PLUSIEURS applications. L'application, pas l'hote,
# est ce a quoi une base de donnees se lie (via son DSN). C'est aussi l'entite
# que l'exposition DNS pourra cibler a terme.
#
# Chaque application :
# <id> : identifiant unique (minuscule, court) ex. boutique
# groupe : groupe operationnel / role dont elle releve ex. serveurs_web_dorsaux
# hote : hote de l'inventaire ou elle s'execute ex. web-dorsal-01
#
# Une base se lie a une application via consommateur=<id> + portee=application
# dans docs/bases-donnees.yml. Une application recoit aussi les bases de portee
# 'groupe' de son groupe (bases partagees).
applications: {}
# boutique:
# groupe: serveurs_web_dorsaux
# hote: web-dorsal-01
# blog:
# groupe: serveurs_web_dorsaux
# hote: web-dorsal-01 # meme VM que boutique : 2 applications sur 1 hote
applications:
pg-principal:
groupe: serveurs_postgresql
hote: data-01
forge:
groupe: serveurs_forgejo
hote: forge-01

View file

@ -1,30 +1,24 @@
---
# Registre des domaines publics Set-OPS (publication externe des hostnames).
#
# Symetrique de docs/bases-donnees.yml : une source unique, plusieurs consommateurs.
#
# Modele d'autorite retenu : PRIMAIRE CACHE + SECONDAIRES.
# - Le primaire (PowerDNS, backend PostgreSQL + API) detient la zone faisant
# autorite mais N'EST PAS expose a Internet (NOTIFY + AXFR seulement).
# - Les "secondaires" sont la face publique : ce sont eux dans les NS + la glue
# chez le registraire. Ils tirent la zone du primaire (AXFR authentifie TSIG).
#
# Le bloc "exposition" est le binding nom-public <-> service interne, frere du
# binding appli<->base : nom public -> service interne (cible) -> edge.
# Il est lu par : (1) la generation de la zone publique (nom -> IP de l'edge),
# (2) la generation des vhosts serveurs_nginx, (3) ACME (noms a certifier).
# Ce registre ne decrit que les DOMAINES (zones). Le binding nom-public <-> service
# vit desormais sur l'APPLICATION : chaque application declare `expose: [FQDN...]`
# dans docs/applications.yml. nginx, la zone publique et ACME derivent les
# expositions depuis les applications (regle expositions_des_applications).
#
# Champs par domaine :
# autorite : primaire-cache | auto-heberge | delegue
# edge : groupe Ansible qui porte l'IP publique de facade (le reverse proxy)
# edge : groupe Ansible qui porte l'IP publique de facade (reverse proxy)
# secondaires : NS publics (FQDN) faisant face a Internet (a renseigner)
# dnssec : signature de la zone (jalon ulterieur ; false au depart)
# ttl : TTL par defaut de la zone
# mail : mx / spf / dmarc (lies a serveurs_sendmail) ; vides si pas de mail
# exposition : liste de { nom, cible, type }
# nom : etiquette publiee ("@" = apex du domaine) ex. forge
# cible : groupe interne servi ex. serveurs_forgejo
# type : web (vhost derriere l'edge) | direct | autre defaut: web
domaines_publics:
alliance-boreale.ca:
@ -37,8 +31,3 @@ domaines_publics:
mx: []
spf: ""
dmarc: ""
exposition:
- nom: forge # forge.alliance-boreale.ca
cible: serveurs_forgejo
type: web
port: 3000 # port interne de Forgejo (amont derive : http://<IP cible>:3000)

View file

@ -17,6 +17,7 @@ from inventory_rules import ( # noqa: E402
bases_de_application,
bases_du_groupe,
chaine_connexion,
expositions_des_applications,
)
@ -29,4 +30,5 @@ class FilterModule:
"applications_de_hote": applications_de_hote,
"bases_du_groupe": bases_du_groupe,
"chaine_connexion": chaine_connexion,
"expositions_des_applications": expositions_des_applications,
}

View file

@ -44,19 +44,20 @@
label: "{{ item.nom }}"
notify: Valider et recharger nginx
# --- Vhosts derives du registre partage docs/domaines.yml (bloc exposition) ---
- name: Charger le registre des domaines publics
# --- Vhosts derives des applications (champ expose) + domaines (edge) ---
- name: Charger les registres applications et domaines
ansible.builtin.include_vars:
file: "{{ role_path }}/../../docs/domaines.yml"
file: "{{ item }}"
loop:
- "{{ role_path }}/../../docs/applications.yml"
- "{{ role_path }}/../../docs/domaines.yml"
when: serveurs_nginx_publier_expositions | bool
- name: Lister les expositions destinees a cet edge
ansible.builtin.set_fact:
serveurs_nginx_expositions: >-
{{ (domaines_publics | default({})) | dict2items
| selectattr('value.edge', 'defined')
| selectattr('value.edge', 'equalto', serveurs_nginx_groupe)
| subelements('value.exposition', skip_missing=true) | list }}
{{ {'applications': applications | default({})}
| expositions_des_applications({'domaines_publics': domaines_publics | default({})}, serveurs_nginx_groupe) }}
when: serveurs_nginx_publier_expositions | bool
- name: Deployer les vhosts derives des expositions web

View file

@ -1,27 +1,24 @@
# Vhosts derives de docs/domaines.yml (bloc exposition) — gere par Set-OPS
# (role serveurs_nginx). Ne pas editer a la main.
# Vhosts derives des applications (champ 'expose' de docs/applications.yml,
# edge depuis docs/domaines.yml) — gere par Set-OPS (role serveurs_nginx).
# Ne pas editer a la main.
#
# Une exposition de type "web" produit un vhost si son amont est resolvable :
# entree "amont" explicite, sinon http://<IP interne de la cible>:<port>.
# Une exposition sans amont resolvable (cible sans hote actif, ou sans port)
# est listee ci-dessous mais NON publiee (binding declare, pas encore cablable).
{% for paire in serveurs_nginx_expositions | default([]) %}
{% set dom = paire.0.key %}
{% set expo = paire.1 %}
{% if (expo.type | default('web')) == 'web' %}
{% set fqdn = dom if expo.nom in ['@', dom] else (expo.nom ~ '.' ~ dom) %}
{% set ns = namespace(amont=(expo.amont | default('', true))) %}
{% if not ns.amont and (groups[expo.cible] | default([]) | length > 0) and (expo.port is defined) and (hostvars[groups[expo.cible] | first].ansible_host is defined) %}
{% set ns.amont = 'http://' ~ hostvars[groups[expo.cible] | first].ansible_host ~ ':' ~ expo.port %}
# Un FQDN expose produit un vhost si l'hote de l'application est resolvable :
# amont = http://<IP interne de l'hote de l'application>:<port>. Une exposition
# non resolvable (application sans hote actif, ou sans port) est listee mais
# NON publiee (binding declare, pas encore cablable).
{% for expo in serveurs_nginx_expositions | default([]) %}
{% set ns = namespace(amont='') %}
{% if expo.hote in hostvars and hostvars[expo.hote].ansible_host is defined and expo.port %}
{% set ns.amont = 'http://' ~ hostvars[expo.hote].ansible_host ~ ':' ~ expo.port %}
{% endif %}
{% if ns.amont %}
# {{ fqdn }} -> {{ expo.cible }} ({{ ns.amont }})
# {{ expo.fqdn }} -> application {{ expo.application }} sur {{ expo.hote }} ({{ ns.amont }})
{% if serveurs_nginx_redirection_http | bool %}
server {
listen 80;
listen [::]:80;
server_name {{ fqdn }};
server_name {{ expo.fqdn }};
return 301 https://$host$request_uri;
}
{% endif %}
@ -29,7 +26,7 @@ server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name {{ fqdn }};
server_name {{ expo.fqdn }};
ssl_certificate {{ serveurs_nginx_certificat }};
ssl_certificate_key {{ serveurs_nginx_cle }};
@ -44,7 +41,6 @@ server {
}
{% else %}
# (non publie) {{ fqdn }} -> {{ expo.cible }} : amont non resolvable (cible sans hote actif ou sans port).
{% endif %}
# (non publie) {{ expo.fqdn }} -> application {{ expo.application }} : amont non resolvable (hote sans IP active ou sans port).
{% endif %}
{% endfor %}

View file

@ -14,12 +14,18 @@ from inventory_rules import (
chaine_connexion,
charger_applications,
charger_bases_donnees,
charger_domaines,
valider_applications,
)
RACINE = Path(__file__).resolve().parents[1]
FICHIER = RACINE / "docs/applications.yml"
FICHIER_BASES = RACINE / "docs/bases-donnees.yml"
FICHIER_DOMAINES = RACINE / "docs/domaines.yml"
def _liste(valeur: str) -> list:
return [v.strip() for v in (valeur or "").split(",") if v.strip()]
def ecrire(registre: dict) -> None:
@ -41,7 +47,15 @@ def lister(registre: dict, bases: dict) -> None:
print("Aucune application declaree.")
return
for nom, app in apps.items():
print(f"{nom} [groupe {app.get('groupe', '?')}, hote {app.get('hote', '?')}]")
extras = []
if app.get("port"):
extras.append(f"port {app['port']}")
if app.get("requiert"):
extras.append(f"requiert {', '.join(app['requiert'])}")
if app.get("expose"):
extras.append(f"expose {', '.join(app['expose'])}")
suffixe = (" {" + " ; ".join(extras) + "}") if extras else ""
print(f"{nom} [groupe {app.get('groupe', '?')}, hote {app.get('hote', '?')}]{suffixe}")
for entree in bases_de_application(bases, application=nom,
groupe=app.get("groupe"), hote=app.get("hote")):
base = entree["base"]
@ -59,6 +73,9 @@ def main() -> int:
pa.add_argument("--id", required=True)
pa.add_argument("--groupe", required=True)
pa.add_argument("--hote", required=True)
pa.add_argument("--port", type=int)
pa.add_argument("--requiert", default="", help="Applications dont elle depend (separees par virgule).")
pa.add_argument("--expose", default="", help="FQDN publics qui la publient (separes par virgule).")
pr = sub.add_parser("retirer", help="Retire une application.")
pr.add_argument("--id", required=True)
@ -66,19 +83,27 @@ def main() -> int:
args = parser.parse_args()
try:
registre = charger_applications(FICHIER)
domaines = charger_domaines(FICHIER_DOMAINES)
if args.commande == "lister":
lister(registre, charger_bases_donnees(FICHIER_BASES))
elif args.commande == "verifier":
valider_applications(registre)
valider_applications(registre, domaines)
print("Registre des applications valide.")
elif args.commande == "ajouter":
registre["applications"][args.id] = {"groupe": args.groupe, "hote": args.hote}
valider_applications(registre)
app = {"groupe": args.groupe, "hote": args.hote}
if args.port is not None:
app["port"] = args.port
if _liste(args.requiert):
app["requiert"] = _liste(args.requiert)
if _liste(args.expose):
app["expose"] = _liste(args.expose)
registre["applications"][args.id] = app
valider_applications(registre, domaines)
ecrire(registre)
print(f"Application '{args.id}' enregistree.")
elif args.commande == "retirer":
registre["applications"].pop(args.id, None)
valider_applications(registre)
valider_applications(registre, domaines)
ecrire(registre)
print(f"Application '{args.id}' retiree.")
except Exception as exc:

View file

@ -7,38 +7,44 @@ import argparse
import sys
from pathlib import Path
from inventory_rules import charger_domaines, fqdn_exposition, valider_domaines
from inventory_rules import (
charger_applications,
charger_domaines,
expositions_des_applications,
valider_domaines,
)
RACINE = Path(__file__).resolve().parents[1]
FICHIER = RACINE / "docs/domaines.yml"
FICHIER_APPLICATIONS = RACINE / "docs/applications.yml"
def lister(registre: dict) -> None:
def lister(registre: dict, applications: dict) -> None:
domaines = registre.get("domaines_publics", {})
if not domaines:
print("Aucun domaine public declare.")
return
expos = expositions_des_applications(applications, registre)
for nom_domaine, conf in domaines.items():
secondaires = conf.get("secondaires") or []
dnssec = "oui" if conf.get("dnssec") else "non"
print(f"{nom_domaine} [autorite {conf.get('autorite', '?')}, edge {conf.get('edge', '-')}, dnssec {dnssec}]")
print(f" secondaires: {', '.join(secondaires) if secondaires else '(aucun)'}")
for entree in conf.get("exposition") or []:
fqdn = fqdn_exposition(nom_domaine, entree.get("nom", ""))
print(f" exposition: {fqdn} -> {entree.get('cible', '?')} ({entree.get('type', 'web')})")
for e in [x for x in expos if x["domaine"] == nom_domaine]:
print(f" exposition: {e['fqdn']} -> application {e['application']} (sur {e['hote']})")
def main() -> int:
parser = argparse.ArgumentParser(description="Registre des domaines publics Set-OPS.")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Affiche domaines, autorite et expositions.")
sub.add_parser("lister", help="Affiche domaines, autorite et expositions (depuis les applications).")
sub.add_parser("verifier", help="Valide la coherence du registre.")
args = parser.parse_args()
try:
registre = charger_domaines(FICHIER)
if args.commande == "lister":
lister(registre)
lister(registre, charger_applications(FICHIER_APPLICATIONS))
elif args.commande == "verifier":
valider_domaines(registre)
print("Registre des domaines valide.")

View file

@ -25,6 +25,7 @@ from inventory_rules import (
charger_applications,
charger_bases_donnees,
charger_dependances,
charger_domaines,
charger_nomenclature,
est_groupe_operationnel,
groupes_operationnels_connus,
@ -40,6 +41,7 @@ FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml"
FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml"
FICHIER_BASES = RACINE / "docs/bases-donnees.yml"
FICHIER_APPLICATIONS = RACINE / "docs/applications.yml"
FICHIER_DOMAINES = RACINE / "docs/domaines.yml"
# Garde-fous des executions (verifier / deployer) depuis l'interface.
JETON = secrets.token_urlsafe(18)
@ -179,6 +181,7 @@ def inventaire_api(path: Path) -> dict:
"chaine": roles_des_groupes(),
"bases": charger_bases_donnees(FICHIER_BASES),
"applications": charger_applications(FICHIER_APPLICATIONS),
"domaines": charger_domaines(FICHIER_DOMAINES),
"hotes": liste_hotes(data),
}
@ -882,7 +885,9 @@ HTML = r"""<!doctype html>
function chargerApplications(data) {
const a = (data.applications || {}).applications || {};
applications = Object.entries(a).map(([id, x]) => ({id, groupe: x.groupe || '', hote: x.hote || ''}));
applications = Object.entries(a).map(([id, x]) => ({
id, groupe: x.groupe || '', hote: x.hote || '', port: x.port || '',
requiert: (x.requiert || []).join(', '), expose: (x.expose || []).join(', ')}));
applicationsModifie = false;
}
@ -968,7 +973,7 @@ HTML = r"""<!doctype html>
if (b) { b.classList.add('attention'); b.textContent = 'Sauvegarder les applications •'; }
}
function definirApplication(i, champ, v) { applications[i][champ] = v; marquerApplicationsModifie(); if (champ === 'hote' || champ === 'groupe') dessinerApplications(); }
function ajouterApplication() { applications.push({id: '', groupe: (groupes[0] || ''), hote: ''}); marquerApplicationsModifie(); dessinerApplications(); }
function ajouterApplication() { applications.push({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: ''}); marquerApplicationsModifie(); dessinerApplications(); }
function retirerApplication(i) { applications.splice(i, 1); marquerApplicationsModifie(); dessinerApplications(); }
function dessinerApplications() {
@ -980,8 +985,11 @@ HTML = r"""<!doctype html>
const resume = dsns.length ? dsns.map(b => `<span class="mono">${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}</span>`).join('<br>') : '<span class="ch-stub">aucune base liée</span>';
return `<div class="base-ligne">
<label class="champ"><span>Identifiant</span><input value="${echapper(a.id)}" placeholder="boutique" oninput="definirApplication(${i}, 'id', this.value)"></label>
<label class="champ"><span>Groupe</span><select onchange="definirApplication(${i}, 'groupe', this.value)">${optGroupe(a.groupe)}</select></label>
<label class="champ"><span>Hôte</span><select onchange="definirApplication(${i}, 'hote', this.value)">${optHote(a.hote)}</select></label>
<label class="champ"><span>Groupe (rôle)</span><select onchange="definirApplication(${i}, 'groupe', this.value)">${optGroupe(a.groupe)}</select></label>
<label class="champ"><span>Hôte (VM)</span><select onchange="definirApplication(${i}, 'hote', this.value)">${optHote(a.hote)}</select></label>
<label class="champ"><span>Port</span><input type="number" value="${echapper(a.port)}" placeholder="3000" oninput="definirApplication(${i}, 'port', this.value)"></label>
<label class="champ"><span>Requiert (applis)</span><input value="${echapper(a.requiert)}" placeholder="pg-principal, …" oninput="definirApplication(${i}, 'requiert', this.value)"></label>
<label class="champ"><span>Expose (FQDN)</span><input value="${echapper(a.expose)}" placeholder="forge.alliance-boreale.ca" oninput="definirApplication(${i}, 'expose', this.value)"></label>
<button type="button" class="danger" title="Retirer" onclick="retirerApplication(${i})"></button>
<div class="dsn">${resume}</div>
</div>`;
@ -994,9 +1002,18 @@ HTML = r"""<!doctype html>
<div style="margin:4px 2px 20px"><button type="button" class="primaire ${applicationsModifie ? 'attention' : ''}" id="btn-sauver-applications" onclick="sauvegarderApplications()">Sauvegarder les applications${applicationsModifie ? '' : ''}</button></div>`;
}
function listeDepuisTexte(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
async function sauvegarderApplications() {
const apps = {};
applications.forEach(a => { if (String(a.id).trim()) apps[a.id] = {groupe: a.groupe, hote: a.hote}; });
applications.forEach(a => {
if (!String(a.id).trim()) return;
const o = {groupe: a.groupe, hote: a.hote};
if (String(a.port).trim()) o.port = parseInt(a.port, 10) || a.port;
const req = listeDepuisTexte(a.requiert); if (req.length) o.requiert = req;
const exp = listeDepuisTexte(a.expose); if (exp.length) o.expose = exp;
apps[a.id] = o;
});
const rep = await fetch('/api/applications', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({applications: apps})
});
@ -1431,7 +1448,7 @@ class Gestionnaire(BaseHTTPRequestHandler):
ecrire_bases(FICHIER_BASES, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/applications":
valider_applications(donnees)
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES))
ecrire_applications(FICHIER_APPLICATIONS, donnees)
self.repondre_json(200, inventaire_api(self.inventaire))
elif chemin == "/api/verifier":

View file

@ -127,9 +127,14 @@ def charger_applications(path: Path | None) -> dict:
return data
def valider_applications(registre: dict) -> None:
"""Valide la coherence du registre des applications."""
def valider_applications(registre: dict, domaines: dict | None = None) -> None:
"""Valide la coherence du registre des applications.
Si `domaines` (le registre des domaines publics) est fourni, chaque FQDN
de `expose` doit avoir un domaine parent declare.
"""
apps = registre.get("applications") or {}
doms = (domaines or {}).get("domaines_publics") or {}
if not isinstance(apps, dict):
raise ValueError("applications doit etre une table.")
for nom, app in apps.items():
@ -140,6 +145,21 @@ def valider_applications(registre: dict) -> None:
raise ValueError(f"Application '{nom}': champ '{champ}' requis.")
if not est_groupe_operationnel(app["groupe"]):
raise ValueError(f"Application '{nom}': groupe '{app['groupe']}' non operationnel.")
requiert = app.get("requiert") or []
if not isinstance(requiert, list):
raise ValueError(f"Application '{nom}': 'requiert' doit etre une liste.")
for dep in requiert:
if dep not in apps:
raise ValueError(f"Application '{nom}': requiert '{dep}' inconnue.")
expose = app.get("expose") or []
if not isinstance(expose, list):
raise ValueError(f"Application '{nom}': 'expose' doit etre une liste.")
for fqdn in expose:
if doms and domaine_parent(fqdn, doms) is None:
raise ValueError(f"Application '{nom}': FQDN expose '{fqdn}' sans domaine parent declare.")
port = app.get("port")
if port is not None and not str(port).strip().isdigit():
raise ValueError(f"Application '{nom}': 'port' doit etre un entier.")
def applications_de_hote(registre: dict, hote: str) -> list[dict]:
@ -151,6 +171,38 @@ def applications_de_hote(registre: dict, hote: str) -> list[dict]:
]
def domaine_parent(fqdn: str, domaines_publics: dict) -> str | None:
"""Domaine declare le plus long dont `fqdn` releve (apex inclus)."""
fqdn = str(fqdn).strip()
candidats = [d for d in domaines_publics if fqdn == d or fqdn.endswith("." + d)]
return max(candidats, key=len) if candidats else None
def expositions_des_applications(applications: dict, domaines: dict, edge: str | None = None) -> list[dict]:
"""Expositions publiques derivees des applications (champ 'expose').
Pour chaque FQDN qu'une application expose, on resout son domaine parent et
l'edge de ce domaine. Si `edge` est fourni, ne retient que les expositions
de cet edge. Le rendu de l'amont (IP interne) reste a la charge de l'appelant.
"""
apps = (applications or {}).get("applications") or {}
doms = (domaines or {}).get("domaines_publics") or {}
resultat = []
for app_id, app in apps.items():
for fqdn in (app.get("expose") or []):
dom = domaine_parent(fqdn, doms)
conf = doms.get(dom, {}) if dom else {}
edge_dom = conf.get("edge")
if edge is not None and edge_dom != edge:
continue
resultat.append({
"fqdn": fqdn, "application": app_id, "groupe": app.get("groupe"),
"hote": app.get("hote"), "port": app.get("port"),
"edge": edge_dom, "domaine": dom,
})
return resultat
def chaine_connexion(entree: dict, serveur: dict, secret: str = "***") -> str:
"""Derive une chaine de connexion (mot de passe masque par defaut)."""
type_bd = (serveur or {}).get("type", "postgres")