underlay : la frontiere entre les deux mondes, et l'instrument qui la mesure
Some checks are pending
verifier / verifier (push) Waiting to run
Some checks are pending
verifier / verifier (push) Waiting to run
Demande de l'exploitant : « il faut vraiment faire une distinction entre l'underlay et les tenants. Definis bien la frontiere entre les deux mondes. L'underlay et son tenant doivent avoir chacun sa voute. » LA DOCTRINE (docs/frontiere-physique-virtuel.md) : qui possede quoi, ou ca vit, qui l'administre. Et la regle qui la rend operante — UN TENANT NE DETIENT JAMAIS UN SECRET DU MONDE PHYSIQUE. Aujourd'hui chaque tenant porte le jeton d'API du cluster ; patient 0 a du le recopier pour exister. C'est la faute des neuf copies, appliquee aux secrets : une valeur qui vit a N endroits diverge, et on ne peut plus en revoquer une sans les autres. L'INSTRUMENT (`make underlay-plan`) confronte le fichier au reel : l'API du cluster pour les adresses REELLEMENT portees, une sonde TCP pour ce qui repond. N'ecrit rien. CE QU'IL A TROUVE, des le premier passage : management 10.0.0.0/24, stockage 10.0.1.0/24, ceph 10.0.2-3.0/24 -> PERSONNE transit 10.0.4.0/24, vxlan 10.0.5.0/24 -> occupes (3 noeuds) portes par les noeuds et declares NULLE PART : 192.168.11.x (la vraie gestion), 10.11.5-7.x, 192.168.50.x, 10.1.110.254 La frontiere avait deja migre vers 10.17.0.1 ; les hyperviseurs, non. Le fichier decrivait le monde d'avant — et c'est pour cela que la regle d'admin de patient 0 atterrissait sur `wan`, ou elle n'aurait jamais laisse passer personne. DEUX PRECAUTIONS ECRITES DANS L'INSTRUMENT, apprises en l'ecrivant : - l'AUTORITE DEPEND DU ROLE. L'API de Proxmox connait ses hyperviseurs, et eux seuls. Declarer un commutateur « porte par personne » parce que le cluster l'ignore, c'est accuser le monde de ce que l'instrument ne voit pas. - « pas joignable d'ici » n'est pas « absent ». Les reseaux de CHEMIN (transit, VXLAN, stockage) ne sont jamais joignables de l'exterieur, par construction (D-78). Le premier jet les declarait morts. Le devis a donc corrige DEUX FOIS sa propre facon de mesurer avant de rendre un verdict. RESTE, dans l'ordre : ecrire dans underlay.yml ce qui EST ; separer les voutes ; et alors seulement appliquer la frontiere. make verifier 41/41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5be3fbd5e1
commit
9f36f08db0
3 changed files with 319 additions and 0 deletions
3
Makefile
3
Makefile
|
|
@ -559,6 +559,9 @@ devis-opnsense-verifier: ## Verifie le devis de la frontiere nord/sud (aucune ec
|
||||||
underlay: ## Underlay (fabric physique cluster-global : mgmt/iSCSI/Ceph) : affiche + valide (P23)
|
underlay: ## Underlay (fabric physique cluster-global : mgmt/iSCSI/Ceph) : affiche + valide (P23)
|
||||||
python3 scripts/underlay.py
|
python3 scripts/underlay.py
|
||||||
|
|
||||||
|
underlay-plan: ## Confronte l'underlay DECLARE au reel (API du cluster + sonde TCP) — n'ecrit rien
|
||||||
|
python3 scripts/devis_underlay.py
|
||||||
|
|
||||||
flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml des roles
|
flux-verifier: ## Verifie que le registre des flux correspond aux meta/flux.yml des roles
|
||||||
python3 scripts/resoudre_flux.py verifier
|
python3 scripts/resoudre_flux.py verifier
|
||||||
|
|
||||||
|
|
|
||||||
112
docs/frontiere-physique-virtuel.md
Normal file
112
docs/frontiere-physique-virtuel.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# La frontière entre les deux mondes — physique et virtuel
|
||||||
|
|
||||||
|
> **Pour qui :** l'**exploitant** et le **mainteneur**. Ce document tranche une question qui
|
||||||
|
> revenait à chaque décision : *qui possède quoi, et où ça vit ?*
|
||||||
|
|
||||||
|
Set-OPS manipule deux mondes qui se ressemblent et n'obéissent pas aux mêmes règles. Les
|
||||||
|
confondre a coûté plusieurs soirées ; les séparer proprement rend la plupart des questions
|
||||||
|
suivantes évidentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les deux mondes
|
||||||
|
|
||||||
|
| | **Monde PHYSIQUE** — l'underlay | **Monde VIRTUEL** — le tenant |
|
||||||
|
|---|---|---|
|
||||||
|
| Ce que c'est | commutateurs, hyperviseurs, frontière, stockage, transport VXLAN | VM, applications, données |
|
||||||
|
| Possédé par | l'**hébergeur** | l'**organisation** |
|
||||||
|
| Adressage | bande basse du site : `10.<index>.0-15.x` | zones : `10.<index>.16+.x` |
|
||||||
|
| Décrit dans | `underlay.yml`, `proxmox-hebergeur.yml` | `plan/` |
|
||||||
|
| Monté par | le symlink `underlay.yml` | le symlink `instance` |
|
||||||
|
| Change quand | on touche au **matériel** | on touche au **service** |
|
||||||
|
| Ses secrets | jeton d'API Proxmox, clé d'API de la frontière, accès aux commutateurs | LDAP, forge, SSO, bases |
|
||||||
|
| Sa voûte | **la sienne**, chez l'hébergeur | `group_vars/all/vault.yml` |
|
||||||
|
|
||||||
|
**Les deux symlinks sont indépendants** (D-80) : `instance` dit *quel tenant*,
|
||||||
|
`underlay.yml` dit *sur quelle fabric*. Un tenant se déplace d'une fabric à l'autre sans
|
||||||
|
qu'on touche à son plan — c'est ce qui rend la portabilité possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## La règle qui rend la frontière opérante
|
||||||
|
|
||||||
|
> **Un tenant ne détient jamais un secret du monde physique.**
|
||||||
|
|
||||||
|
Aujourd'hui, chaque tenant porte dans sa voûte le jeton d'API du cluster. Patient 0 a dû le
|
||||||
|
recopier pour exister. C'est exactement la faute des **neuf copies** de la résolution
|
||||||
|
d'instance, appliquée aux secrets : une valeur qui vit à N endroits finit par diverger, et
|
||||||
|
on ne peut plus révoquer l'une sans révoquer les autres.
|
||||||
|
|
||||||
|
Conséquence : **l'underlay a sa propre voûte**, chez l'hébergeur, à côté d'`underlay.yml`.
|
||||||
|
Les opérations qui parlent au matériel — cloner une VM, poser une zone SDN, écrire sur la
|
||||||
|
frontière — l'y lisent. Les tenants n'y ont pas accès et n'en ont pas besoin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qui administre quoi, et depuis où
|
||||||
|
|
||||||
|
L'administration du monde physique se fait **depuis le tenant de l'hébergeur** : c'est lui
|
||||||
|
qui porte les outils, les clés et les traces. Le plan de gestion du site
|
||||||
|
(`10.<index>.0.0/24`) est la seule source autorisée à ouvrir SSH sur la flotte et à
|
||||||
|
franchir la frontière.
|
||||||
|
|
||||||
|
Ce n'est pas un détail de commodité. Une machine qui administre depuis l'extérieur des deux
|
||||||
|
mondes — un portable sur le réseau de la maison — n'est ni sauvegardée, ni reconstructible,
|
||||||
|
ni prouvée. Le jour où elle disparaît, l'écosystème est intact et personne ne peut plus y
|
||||||
|
entrer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce que la confusion a coûté
|
||||||
|
|
||||||
|
**Une règle qui ne peut jamais correspondre.** `devis_opnsense` dérive l'interface d'une
|
||||||
|
règle de l'**attachement réel** de sa source (D-61), et cet attachement se lit dans
|
||||||
|
`underlay.yml`. Un plan d'administration absent du fichier est classé « distant », et sa
|
||||||
|
règle atterrit sur `wan`. Le 22 août, on s'apprêtait à poser 89 objets sur la frontière
|
||||||
|
avec une règle d'admin qui n'aurait jamais laissé passer personne.
|
||||||
|
|
||||||
|
**Un fichier qui décrit un monde disparu.** Mesuré le même jour :
|
||||||
|
|
||||||
|
```
|
||||||
|
management 10.0.0.0/24 déclaré → PERSONNE
|
||||||
|
stockage 10.0.1.0/24 déclaré → PERSONNE
|
||||||
|
ceph 10.0.2-3.0/24 déclaré → PERSONNE
|
||||||
|
transit 10.0.4.0/24 déclaré → occupé (3 nœuds)
|
||||||
|
vxlan 10.0.5.0/24 déclaré → occupé (3 nœuds)
|
||||||
|
|
||||||
|
et, portés par les nœuds sans être déclarés nulle part :
|
||||||
|
192.168.11.x 10.11.5-7.x 192.168.50.x 10.1.110.254
|
||||||
|
```
|
||||||
|
|
||||||
|
La frontière avait déjà migré vers `10.17.0.1` ; les hyperviseurs, non. Le fichier était
|
||||||
|
resté au monde d'avant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L'instrument
|
||||||
|
|
||||||
|
```
|
||||||
|
make underlay-plan # confronte le fichier au réel, n'écrit rien
|
||||||
|
```
|
||||||
|
|
||||||
|
Il interroge l'API du cluster pour les adresses **réellement portées**, et sonde en TCP ce
|
||||||
|
qui répond. Deux précautions y sont inscrites, toutes deux apprises en l'écrivant :
|
||||||
|
|
||||||
|
- **l'autorité dépend du rôle.** L'API de Proxmox connaît ses hyperviseurs, et eux seuls.
|
||||||
|
Déclarer un commutateur « porté par personne » parce que le cluster l'ignore, c'est
|
||||||
|
accuser le monde de ce que l'instrument ne voit pas ;
|
||||||
|
- **« pas joignable d'ici » n'est pas « absent ».** Les réseaux de *chemin* (transit,
|
||||||
|
transport VXLAN, stockage) ne sont **jamais** joignables depuis l'extérieur, par
|
||||||
|
construction (D-78). Le premier jet du devis les déclarait morts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui reste à faire, dans l'ordre
|
||||||
|
|
||||||
|
- [ ] **Reconnaître** : `make underlay-plan`, puis écrire dans `underlay.yml` ce qui est —
|
||||||
|
pas ce qui était prévu. Chaque réseau porté et non déclaré est un réseau que le
|
||||||
|
moteur ne sait pas classer.
|
||||||
|
- [ ] **Séparer les voûtes** : créer celle de l'underlay, y déplacer le jeton Proxmox et la
|
||||||
|
clé de la frontière, les retirer des voûtes de tenants.
|
||||||
|
- [ ] **Alors seulement**, appliquer la frontière — ses règles dépendent des deux points
|
||||||
|
ci-dessus.
|
||||||
204
scripts/devis_underlay.py
Normal file
204
scripts/devis_underlay.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Devis de l'UNDERLAY — ce que le fichier declare, confronte a ce qui existe.
|
||||||
|
|
||||||
|
`underlay.yml` decrit le monde PHYSIQUE d'un site : commutateurs, hyperviseurs,
|
||||||
|
frontiere, reseaux de transport. Rien ne verifiait qu'il decrive encore quelque chose.
|
||||||
|
|
||||||
|
POURQUOI CE DEVIS EXISTE (mesure du 2026-08-22). Le fichier de Chezlepro declarait un
|
||||||
|
plan de gestion `10.0.0.0/24` avec les hyperviseurs en `10.0.0.41/43/47`. Mesure : ni
|
||||||
|
`10.0.0.1`, ni `10.0.0.41` ne repondent, et les hyperviseurs vivent en `192.168.11.x`. La
|
||||||
|
frontiere, elle, avait deja migre vers `10.17.0.1`. Le fichier decrivait un monde disparu.
|
||||||
|
|
||||||
|
CE QUE CA COUTAIT, et ce n'etait pas theorique : `devis_opnsense` derive l'interface d'une
|
||||||
|
regle de l'ATTACHEMENT REEL de sa source, et cet attachement se lit ici. Un plan
|
||||||
|
d'administration absent du fichier est classe « distant », et sa regle atterrit sur `wan`
|
||||||
|
ou elle ne peut JAMAIS correspondre. On aurait pose 89 objets sur la frontiere pour se
|
||||||
|
voir refuser quand meme.
|
||||||
|
|
||||||
|
CE QU'IL INTERROGE : l'API du cluster pour les adresses REELLES des noeuds, et une sonde
|
||||||
|
TCP pour ce qui repond. N'ECRIT RIEN — ni sur le cluster, ni dans le fichier.
|
||||||
|
|
||||||
|
python3 scripts/devis_underlay.py # tableau lisible
|
||||||
|
python3 scripts/devis_underlay.py --verifier # code de sortie (0 = le fichier dit vrai)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
|
|
||||||
|
import underlay as underlay_mod # noqa: E402
|
||||||
|
from proxmox_api import Cluster # noqa: E402
|
||||||
|
|
||||||
|
PORTS_SONDE = (22, 8006, 443, 80)
|
||||||
|
|
||||||
|
|
||||||
|
def repond(adresse: str, ports: tuple[int, ...] = PORTS_SONDE, delai: float = 1.5) -> int | None:
|
||||||
|
"""Le premier port qui accepte une connexion, ou None. Sonde TCP, jamais ICMP.
|
||||||
|
|
||||||
|
Un ping refuse ne prouve rien — beaucoup d'equipements le filtrent par defaut. Une
|
||||||
|
poignee TCP acceptee prouve qu'il y a quelqu'un.
|
||||||
|
"""
|
||||||
|
for port in ports:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((adresse, port), timeout=delai):
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def adresses_reelles(api: Cluster, noeuds: list[str]) -> dict[str, list[str]]:
|
||||||
|
"""Les adresses que PORTENT reellement les noeuds, vues par l'API du cluster."""
|
||||||
|
trouve: dict[str, list[str]] = {}
|
||||||
|
for n in noeuds:
|
||||||
|
rep = api(f"/nodes/{n}/network")
|
||||||
|
if not isinstance(rep, list):
|
||||||
|
continue
|
||||||
|
adresses = []
|
||||||
|
for i in rep:
|
||||||
|
cidr = i.get("cidr") or i.get("address")
|
||||||
|
if cidr and not str(cidr).startswith("127."):
|
||||||
|
adresses.append(str(cidr))
|
||||||
|
trouve[n] = sorted(adresses)
|
||||||
|
return trouve
|
||||||
|
|
||||||
|
|
||||||
|
def constater() -> tuple[list[dict], list[str]]:
|
||||||
|
"""Confronte chaque reseau et chaque hote declares a ce qui repond."""
|
||||||
|
u = underlay_mod.charger()
|
||||||
|
if u is None:
|
||||||
|
raise SystemExit("Aucun underlay monte (underlay.yml absent) — rien a confronter.")
|
||||||
|
|
||||||
|
lignes: list[dict] = []
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
# 2. Les noeuds du cluster : ou sont-ils VRAIMENT ? (l'AUTORITE)
|
||||||
|
try:
|
||||||
|
api, _ = Cluster.depuis_hebergeur()
|
||||||
|
rep = api("/nodes")
|
||||||
|
noeuds = sorted(n.get("node") for n in rep) if isinstance(rep, list) else []
|
||||||
|
reelles = adresses_reelles(api, noeuds)
|
||||||
|
except Exception as e: # noqa: BLE001 — l'API est un tiers ; on rend la cause lisible
|
||||||
|
notes.append(f"Cluster injoignable ({str(e)[:80]}) : adresses reelles non relevees.")
|
||||||
|
reelles = {}
|
||||||
|
|
||||||
|
portees = {a.split("/")[0] for ads in reelles.values() for a in ads}
|
||||||
|
|
||||||
|
# 1. Les hotes declares : le CLUSTER porte-t-il cette adresse ?
|
||||||
|
#
|
||||||
|
# L'autorite est l'API, pas ma sonde TCP. Le premier jet de ce devis declarait « MUET »
|
||||||
|
# tout ce qu'il ne joignait pas — et disait donc faux de `10.0.4.41`, une adresse bien
|
||||||
|
# reelle sur un reseau de CHEMIN, que rien ne joint jamais de l'exterieur par
|
||||||
|
# construction (D-78). Un instrument qui confond « absent » et « pas joignable d'ici »
|
||||||
|
# accuse le monde de ce qu'il ne sait pas voir. La sonde TCP reste, en second rang :
|
||||||
|
# elle dit « joignable d'ici », ce qui est une autre question, utile pour le plan
|
||||||
|
# d'administration.
|
||||||
|
for h in underlay_mod.hotes(u):
|
||||||
|
ip = str(h.get("ip") or "")
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
# L'AUTORITE DEPEND DU ROLE, et c'est la deuxieme fois que ce devis l'apprend :
|
||||||
|
# l'API de Proxmox connait ses hyperviseurs, et EUX SEULS. Un commutateur ou une
|
||||||
|
# frontiere n'y figurent pas — les declarer « portes par personne » parce que le
|
||||||
|
# cluster les ignore serait accuser le monde de ce que l'instrument ne voit pas.
|
||||||
|
role = str(h.get("role") or "")
|
||||||
|
port = repond(ip)
|
||||||
|
if role == "hyperviseur":
|
||||||
|
if not reelles:
|
||||||
|
verdict = "cluster injoignable — non verifie"
|
||||||
|
elif ip in portees:
|
||||||
|
verdict = f"portee par le cluster{f', joignable (tcp/{port})' if port else ''}"
|
||||||
|
else:
|
||||||
|
verdict = "PORTEE PAR AUCUN NOEUD"
|
||||||
|
else:
|
||||||
|
verdict = (f"repond (tcp/{port})" if port
|
||||||
|
else "non joignable d'ici (equipement hors cluster)")
|
||||||
|
lignes.append({"objet": f"{h.get('nom','?')} ({h.get('reseau','?')})",
|
||||||
|
"declare": ip, "verdict": verdict})
|
||||||
|
|
||||||
|
declarees = {str(h.get("ip")) for h in underlay_mod.hotes(u) if h.get("ip")}
|
||||||
|
for noeud, adresses in sorted(reelles.items()):
|
||||||
|
nues = [a.split("/")[0] for a in adresses]
|
||||||
|
connues = [a for a in nues if a in declarees]
|
||||||
|
lignes.append({"objet": f"{noeud} — adresses reelles",
|
||||||
|
"declare": ", ".join(a for a in nues if not a.startswith("169.254"))[:60],
|
||||||
|
"verdict": "declaree" if connues else "AUCUNE N'EST DECLAREE"})
|
||||||
|
|
||||||
|
# 3. Les reseaux declares : au moins une adresse reelle y tombe-t-elle ?
|
||||||
|
toutes_reelles = [a.split("/")[0] for ads in reelles.values() for a in ads]
|
||||||
|
for r in underlay_mod.reseaux(u):
|
||||||
|
try:
|
||||||
|
reseau = ipaddress.ip_network(str(r.get("sous_reseau")), strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
dedans = [a for a in toutes_reelles
|
||||||
|
if ipaddress.ip_address(a) in reseau] if toutes_reelles else []
|
||||||
|
if not toutes_reelles:
|
||||||
|
verdict = "cluster injoignable — non verifie"
|
||||||
|
elif dedans:
|
||||||
|
verdict = f"occupe ({len(dedans)} adresse(s) reelle(s))"
|
||||||
|
elif repond(str(r.get("passerelle") or "")) is not None:
|
||||||
|
verdict = "passerelle repond, aucun noeud dessus"
|
||||||
|
else:
|
||||||
|
verdict = "PERSONNE"
|
||||||
|
lignes.append({"objet": f"reseau {r.get('nom')}",
|
||||||
|
"declare": str(r.get("sous_reseau")), "verdict": verdict})
|
||||||
|
|
||||||
|
# 4. L'inverse, et c'est celui qu'on oublie : ce que les noeuds PORTENT et que
|
||||||
|
# l'underlay ne declare nulle part. Un reseau non declare est un reseau que le
|
||||||
|
# moteur ne sait pas classer — donc dont les regles atterrissent au mauvais endroit.
|
||||||
|
reseaux_declares = []
|
||||||
|
for r in underlay_mod.reseaux(u):
|
||||||
|
try:
|
||||||
|
reseaux_declares.append(ipaddress.ip_network(str(r.get("sous_reseau")), strict=False))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
inconnues = sorted({a for a in toutes_reelles
|
||||||
|
if not a.startswith(("127.", "169.254."))
|
||||||
|
and not any(ipaddress.ip_address(a) in r for r in reseaux_declares)})
|
||||||
|
for a in inconnues:
|
||||||
|
lignes.append({"objet": "porte par le cluster", "declare": a,
|
||||||
|
"verdict": "NON DECLARE dans underlay.yml"})
|
||||||
|
return lignes, notes
|
||||||
|
|
||||||
|
|
||||||
|
def afficher(lignes: list[dict], notes: list[str]) -> bool:
|
||||||
|
print(f"Devis de l'underlay — source : {underlay_mod.chemin()}\n")
|
||||||
|
print(f" {'OBJET':<34} {'DECLARE':<40} VERDICT")
|
||||||
|
faux = 0
|
||||||
|
for l in lignes:
|
||||||
|
alerte = l["verdict"].isupper() or l["verdict"] in ("MUET", "PERSONNE")
|
||||||
|
faux += 1 if alerte else 0
|
||||||
|
print(f" {l['objet']:<34} {l['declare']:<40} {l['verdict']}")
|
||||||
|
for n in notes:
|
||||||
|
print(f"\n /!\\ {n}")
|
||||||
|
print("\n" + (f"ECART : {faux} ligne(s) que le fichier declare et que rien n'occupe. "
|
||||||
|
f"L'underlay decrit un monde qui n'est plus la."
|
||||||
|
if faux else
|
||||||
|
"CONFORME : tout ce que l'underlay declare existe et repond."))
|
||||||
|
return faux == 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--verifier", action="store_true", help="code de sortie seulement")
|
||||||
|
a = ap.parse_args()
|
||||||
|
lignes, notes = constater()
|
||||||
|
if a.verifier:
|
||||||
|
faux = [l for l in lignes if l["verdict"].isupper()
|
||||||
|
or l["verdict"] in ("MUET", "PERSONNE")]
|
||||||
|
for l in faux:
|
||||||
|
print(f"underlay : {l['objet']} declare « {l['declare']} » — {l['verdict']}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1 if faux else 0
|
||||||
|
return 0 if afficher(lignes, notes) else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue