diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c3dcd..76e7893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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://:` 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`. diff --git a/docs/applications.yml b/docs/applications.yml index 1375c71..8613975 100644 --- a/docs/applications.yml +++ b/docs/applications.yml @@ -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 : -# : 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= + 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 diff --git a/docs/domaines.yml b/docs/domaines.yml index a700a01..8c23ee6 100644 --- a/docs/domaines.yml +++ b/docs/domaines.yml @@ -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://:3000) diff --git a/filter_plugins/registres.py b/filter_plugins/registres.py index 449a8ed..c92efc9 100644 --- a/filter_plugins/registres.py +++ b/filter_plugins/registres.py @@ -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, } diff --git a/roles/serveurs_nginx/tasks/main.yml b/roles/serveurs_nginx/tasks/main.yml index 97a6a4c..1e24ddf 100644 --- a/roles/serveurs_nginx/tasks/main.yml +++ b/roles/serveurs_nginx/tasks/main.yml @@ -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 diff --git a/roles/serveurs_nginx/templates/expositions.conf.j2 b/roles/serveurs_nginx/templates/expositions.conf.j2 index 86747ce..7854bf9 100644 --- a/roles/serveurs_nginx/templates/expositions.conf.j2 +++ b/roles/serveurs_nginx/templates/expositions.conf.j2 @@ -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://:. -# 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://:. 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 %} diff --git a/scripts/applications.py b/scripts/applications.py index a48d374..310b271 100644 --- a/scripts/applications.py +++ b/scripts/applications.py @@ -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: diff --git a/scripts/domaines.py b/scripts/domaines.py index a782a7b..0467b47 100644 --- a/scripts/domaines.py +++ b/scripts/domaines.py @@ -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.") diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index f119e0d..4126aa3 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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""" 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""" 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""" const resume = dsns.length ? dsns.map(b => `${echapper((b.portee || 'groupe'))}:${echapper(dsnBase(b))}`).join('
') : 'aucune base liée'; return `
- - + + + + +
${resume}
`; @@ -994,9 +1002,18 @@ HTML = r"""
`; } + 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": diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 31fa1ae..49705b4 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -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")