#!/usr/bin/env python3
"""Dossier technique MacLustr — génère dossier.html (couverture + corps) à partir de :
- INVENTAIRE-MACLUSTR.md (tables mesurées : matériel, rôles, loués, OVH, tunnel, répartitions)
- data.json (inventaire du site www.maclustr.io : nœuds, sites géolocalisés, apps)
- live.json (instantané /api/live)
Rendu PDF : print.mjs (Playwright/Chromium) puis fusion couverture + corps (PyMuPDF)."""
import json, re, html, datetime, pathlib
HERE = pathlib.Path(__file__).parent
MD = (HERE.parent / "INVENTAIRE-MACLUSTR.md").read_text()
D = json.loads((HERE / "data.json").read_text())
L = json.loads((HERE / "live.json").read_text())
NOW = datetime.datetime.now()
DATE = NOW.strftime("%-d %B %Y").replace("September", "septembre").replace("October", "octobre")
MONTHS = {"January":"janvier","February":"février","March":"mars","April":"avril","May":"mai","June":"juin","July":"juillet","August":"août","September":"septembre","October":"octobre","November":"novembre","December":"décembre"}
for en, fr in MONTHS.items(): DATE = DATE.replace(en, fr)
TIME = NOW.strftime("%H h %M")
# ---------- markdown helpers ----------
def inline(s):
s = html.escape(s, quote=False)
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
s = re.sub(r"`([^`]+)`", r"\1", s)
s = s.replace("—", "—")
return s
def table_after(heading_regex):
"""Renvoie (header_cells, rows) de la première table après le titre correspondant."""
m = re.search(heading_regex, MD, re.M)
if not m: raise SystemExit(f"titre introuvable : {heading_regex}")
rest = MD[m.end():]
lines = rest.splitlines()
i = 0
while i < len(lines) and not lines[i].startswith("|"): i += 1
tbl = []
while i < len(lines) and lines[i].startswith("|"):
tbl.append(lines[i]); i += 1
cells = lambda ln: [c.strip() for c in ln.strip().strip("|").split("|")]
head = cells(tbl[0]); rows = [cells(r) for r in tbl[2:]]
return head, rows
def para_after(heading_regex, n=1):
m = re.search(heading_regex, MD, re.M); rest = MD[m.end():].strip().splitlines()
out = []
for ln in rest:
if ln.startswith("|") or ln.startswith("#"): break
if ln.strip(): out.append(inline(ln.strip()))
if len(out) >= n: break
return out
def html_table(head, rows, cls="", align_right=(), nowrap=(), widths=None):
th = "".join(f"
{inline(h)}
" for i, h in enumerate(head))
trs = []
for r in rows:
tds = []
for i, c in enumerate(r):
classes = []
if i in align_right: classes.append("r")
if i in nowrap: classes.append("nw")
if i == 0: classes.append("id")
attr = (' class="' + ' '.join(classes) + '"') if classes else ''
tds.append(f"
{inline(c)}
")
trs.append("
" + "".join(tds) + "
")
colg = "".join(f"
" for w in widths) if widths else ""
return f"
{colg}
{th}
{''.join(trs)}
"
# ---------- data ----------
nodes = D["nodes"]; sites = D["sites"]; apps = D["apps"]; groups = D["appGroups"]
by_site = {}
for n in nodes: by_site.setdefault(n["site"], []).append(n)
apps_by_node = {}
for a in apps: apps_by_node.setdefault(a["node"], []).append(a)
macs = [n for n in nodes if n["group"] != "ovh"]
tot = dict(nodes=len(nodes), macs=len(macs), ovh=len(nodes) - len(macs), cores=sum(n["cores"] for n in nodes),
gpu=sum(n.get("gpu") or 0 for n in macs), ram=sum(n["ram"] for n in nodes), apps=len(apps),
web=sum(1 for a in apps if a.get("domain")), sites=len(sites),
countries=len({s["region"].split(",")[-1].strip() for s in sites.values()}))
S = L.get("summary") or {}
lnodes = L.get("nodes", {}); lapps = L.get("apps", {}); ltun = L.get("tunnel", [])
fr = lambda n: f"{n:,}".replace(",", " ")
def ram_fmt(gb): return f"{gb/1024:.2f}".replace(".", ",") + " To" if gb >= 1000 else f"{gb} Go"
# ---------- pieces ----------
def kpi(v, l, s=""): return f"
{v}{l}{f'{s}' if s else ''}
"
def bar(p, cls=""):
p = 0 if p is None else max(0, min(100, p))
return f""
def status_cell(n):
if n["group"] == "ovh":
g = next((g for g in ltun if g["name"] == n["id"]), None)
if g: return "passerelle active" if g["ok"] else "injoignable"
return "en réserve"
l = lnodes.get(n["id"])
if not l or not l.get("online"): return "hors ligne"
return "en ligne"
# heatmap live
def heat_rows():
out = []
for n in sorted(macs, key=lambda x: -(x["cores"])):
l = lnodes.get(n["id"]) or {}
on = l.get("online")
cpu = l.get("cpu"); mem = l.get("memUsedGB"); memT = l.get("memTotalGB") or n["ram"]
disk = l.get("diskUsedGB"); diskT = l.get("diskTotalGB")
out.append(f"
{n['id']}
{n['chip']}
{n['cores']}
{n['ram']} Go
"
f"
{status_cell(n)}
"
f"
{bar(cpu if on else None)}{(str(round(cpu)) + ' %') if on and cpu is not None else '—'}
"
f"
{bar((mem / memT * 100) if on and mem is not None and memT else None)}{(f'{mem:.1f}'.replace('.', ',') + ' / ' + str(memT) + ' Go') if on and mem is not None else '—'}
"
f"
{bar((disk / diskT * 100) if on and disk and diskT else None)}{(fr(int(disk)) + ' / ' + fr(int(diskT)) + ' Go') if on and disk and diskT else '—'}
"
f"
{l.get('load1', '—') if on else '—'}
{l.get('os', '—') if on else '—'}
")
return "".join(out)
# répartition par site (barres)
def site_bars():
rows = []
mx_c = max(sum(n["cores"] for n in ns) for ns in by_site.values())
mx_r = max(sum(n["ram"] for n in ns) for ns in by_site.values())
for k, s in sorted(sites.items(), key=lambda kv: -len(by_site.get(kv[0], []))):
ns = by_site.get(k, []); c = sum(n["cores"] for n in ns); r = sum(n["ram"] for n in ns)
rows.append(f"
")
return "".join(rows)
def apps_section():
out = []
for g in groups:
ga = [a for a in apps if a["group"] == g["id"]]
rows = []
for a in ga:
l = lapps.get(a["id"]) or {}
st = "en ligne" if l.get("state") == "up" else ("" + html.escape(l.get("state", "—")) + "" if l else "—")
extra = []
if l.get("publicMs") is not None and l.get("publicOk"): extra.append(f"{l['publicMs']} ms")
if l.get("uptime24h") is not None: extra.append(f"{round(l['uptime24h'])} % / 24 h")
rows.append(f"
{html.escape(a['name'])}
{html.escape(a['domain'] or 'service interne')}
{l.get('node') or a['node']}:{a['port']}
{html.escape(a['desc'])}
{st} {' · '.join(extra)}
")
out.append(f"
{html.escape(g['name'])} {len(ga)}
{html.escape(g['blurb'])}
"
f"
Application
Domaine
Nœud
Description
État
{''.join(rows)}
")
return "".join(out)
# ---------- tables issues du markdown ----------
h_over, r_over = table_after(r"^## Vue d'ensemble")
h_1a, r_1a = table_after(r"^### 1a\.")
h_1b, r_1b = table_after(r"^### 1b\.")
h_2a, r_2a = table_after(r"^### 2a\.")
h_2b, r_2b = table_after(r"^### 2b\.")
h_2c, r_2c = table_after(r"^### 2c\.")
h_3a, r_3a = table_after(r"^### 3a\.")
h_4, r_4 = table_after(r"^## 4\.")
h_5a, r_5a = table_after(r"^### 5a\.")
h_5b, r_5b = table_after(r"^### 5b\.")
intro_over = para_after(r"^## Vue d'ensemble\n\n\|.*\n\|.*\n(\|.*\n)+", 3)
intro_1 = para_after(r"^## 1\. Nœuds internes.*$")
note_1a = para_after(r"^\*\*Sous-total LAN")
intro_1b = para_after(r"^### 1b\.")
intro_2 = para_after(r"^## 2\. Macs loués.*$")
intro_2a = para_after(r"^### 2a\.")
intro_2b = para_after(r"^### 2b\.")
intro_2c = para_after(r"^### 2c\.")
intro_3 = para_after(r"^## 3\. Serveurs.*$")
fiches_3b = [inline(l.strip()[2:]) for l in MD.split("### 3b. Fiches")[1].split("## 4.")[0].strip().splitlines() if l.strip().startswith("- ")]
intro_4 = para_after(r"^## 4\. Topologie.*$")
note_4 = [inline(l.strip()) for l in MD.split("## 4. Topologie publique : sites et tunnel")[1].split("## 5.")[0].strip().split("\n\n") if l.strip().startswith("Autres services")]
cap_5c = para_after(r"^### 5c\.")
sources = para_after(r"^## 6\. Sources.*$")
# lignes des tables 3a et 1a : retirer la colonne N° de série ? on garde tout (document détaillé)
# ---------- HTML ----------
CSS = (HERE / "dossier.css").read_text()
LOGO = ""
cover = f"""MacLustr — Dossier technique
{LOGO}MacLustr
www.maclustr.io
Dossier technique · inventaire complet
Un supercalculateur fait de Macs.
{tot['macs']} Macs Apple Silicon et {tot['ovh']} serveurs OVHcloud, répartis dans {tot['sites']} sites et {tot['countries']} pays, orchestrés comme une seule plateforme. Nœuds internes, Macs loués, passerelles, tunnel, applications hébergées : tout ce qui compose le cluster au {DATE}.
Simon-Pierre Boucher · Saint-Augustin-de-Desmaures, Québeccontact@spboucher.ai · état live du {DATE} à {TIME}
"""
def sec(num, title, lead=""):
return f"
{num}
{title}
{f'
{lead}
' if lead else ''}"
toc = [("1", "Vue d'ensemble"), ("2", "Nœuds internes — LAN Saint-Augustin"), ("3", "Macs dédiés loués hors site"), ("4", "Serveurs dédiés OVHcloud"),
("5", "Topologie publique : tunnel et sites"), ("6", "Emplacements et carte"), ("7", "Répartitions et capacité"), ("8", "Applications hébergées"), ("9", "Instantané live"), ("10", "Sources et méthode")]
body = f"""MacLustr — Dossier technique
{sec('1', "Vue d'ensemble")}
MacLustr est un cluster privé de {tot['macs']} Macs Apple Silicon et {tot['ovh']} serveurs Linux OVHcloud. Il totalise {fr(tot['cores'])} cœurs CPU, {fr(tot['gpu'])} cœurs GPU et {ram_fmt(tot['ram'])} de mémoire, et héberge {tot['apps']} applications en production, dont {tot['web']} sites publics servis en HTTPS par le MacLustr Tunnel.
{kpi(len(by_site['staug']), 'Macs sur le LAN', 'Saint-Augustin')}{kpi(sum(len(by_site.get(k, [])) for k in ('ist', 'atl', 'dub', 'lim')), 'Macs loués', 'Istanbul · Atlanta · Dublin · Limassol')}{kpi(len(by_site['bhs']) + len(by_site['gra']), 'serveurs OVHcloud', 'Beauharnois · Gravelines')}{kpi(len([n for n in nodes if 'gateway' in n['tags']]), 'passerelles', 'M1M32 · BHS64 · R9128')}{kpi(len(ltun), 'hubs WireGuard', f"{sum(len(g['peers']) for g in ltun)} pairs")}{kpi(sum(g.get('routes') or 0 for g in ltun), 'routes HTTPS', "Caddy · Let’s Encrypt")}
{sec('2', 'Nœuds internes — LAN Saint-Augustin-de-Desmaures', intro_1[0] if intro_1 else '')}
Caddytermine le TLS Let’s Encrypt et choisit l'upstream d'après l'hôte
WireGuardhub 10.67.0.1 → interface wg1 du nœud (10.67.0.x), MTU 1380, keepalive 25 s
Nœudprocessus PM2 ou launchd sur le port local ; healthcheck local puis public à chaque déploiement
Passerelles live
{''.join(f"
{g['name']}{'principale' if g.get('primary') else 'secondaire'} · {g.get('site', '')}{g.get('routes') or 0} routes · {sum(1 for p in g['peers'] if p['online'])}/{len(g['peers'])} pairs · {str(round(sum(p['rx'] for p in g['peers']) / 1e9, 1)).replace('.', ',')} Go reçus
{sec('6', 'Emplacements et carte', f"{tot['sites']} sites dans {tot['countries']} pays sur trois continents. Le LAN de Saint-Augustin porte le cœur du cluster ; Beauharnois et Gravelines publient les sites ; Istanbul, Atlanta, Dublin et Limassol étendent la capacité. Géolocalisation vérifiée par IP et latence.")}
Vue d'ensemble : épingles dimensionnées selon le nombre de nœuds, arcs WireGuard vers les passerelles OVHcloud. Capture du site www.maclustr.io/map.
{''.join(f"
{s['name']}{len(by_site.get(k, []))} nœud{'s' if len(by_site.get(k, [])) > 1 else ''}
{sec('8', 'Applications hébergées', f"{tot['apps']} applications, toutes décrites par un manifeste mld (dir, processus, runtimes, RAM, ports, placement, tunnel). Le registre M1M32:~/dispatch/registry.json est la seule source de vérité de « quelle app tourne où » ; état confirmé par maclustr-agentd au moment de la génération.")}
{apps_section()}
{sec('9', 'Instantané live', f"Mesures de maclustr-agentd le {DATE} à {TIME} : {S.get('nodesOnline', '—')}/{S.get('nodesTotal', '—')} Macs en ligne, CPU moyen {str(S.get('cpuAvg', '—')).replace('.', ',')} %, {fr(S.get('ramUsedGB', 0))} Go de mémoire utilisés sur {fr(int(S.get('ramOnlineGB', 0)))} Go en ligne, {fr(S.get('procs', 0))} processus, {(S.get('apps') or {}).get('up', '—')}/{(S.get('apps') or {}).get('total', '—')} applications saines.")}
Nœud
Puce
Cœurs
RAM
État
CPU
Mémoire
Disque
Load
macOS
{heat_rows()}
{sec('10', 'Sources et méthode')}
{sources[0] if sources else ''}
Les identifiants (mots de passe sudo, DeskIn, VNC, jetons) ne figurent pas dans ce dossier : voir NODES.md. Document généré par cluster-skill/dossier/build.py à partir de INVENTAIRE-MACLUSTR.md, de l'inventaire du site www.maclustr.io et de l'API live du cluster ; à régénérer après tout ajout ou retrait de machine.