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:
parent
abd6395a13
commit
27bb51d007
10 changed files with 163 additions and 84 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Reference in a new issue