#!/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"{s['name']}
{s['region']} · {s['operator']}{len(ns)}" f"{bar(c / mx_c * 100)}{c} {'thr' if s['kind'] == 'ovh' else 'cœurs'}" f"{bar(r / mx_r * 100, 'alt')}{ram_fmt(r)}" f"{', '.join(n['id'] for n in ns)}") 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"{''.join(rows)}
ApplicationDomaineNœudDescriptionÉtat
") 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}.

{kpi(tot['nodes'], 'nœuds', f"{tot['macs']} Macs + {tot['ovh']} Linux")}{kpi(fr(tot['cores']), 'cœurs CPU', f"{S.get('coresOnline', '—')} actifs")}{kpi(fr(tot['gpu']), 'cœurs GPU', 'Apple Silicon')}{kpi(ram_fmt(tot['ram']), 'mémoire', f"{S.get('ramUsedGB', '—')} Go utilisés")}{kpi(tot['apps'], 'applications', f"{(S.get('apps') or {}).get('up', '—')} saines")}{kpi(tot['web'], 'sites HTTPS', 'MacLustr Tunnel')}
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.

{''.join(f'

{p}

' for p in intro_over)}

Sommaire

{''.join(f"
{n}{t}
" for n, t in toc)}

Par catégorie

{html_table(h_over, r_over, 'over', align_right=(1, 2, 3), nowrap=(1, 2, 3))}
{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 '')}

2a. Matériel et système {len(r_1a)} Macs

{html_table(h_1a, r_1a, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 9))}

{note_1a[0] if note_1a else ''}

2b. Rôle, runtimes et applications par nœud

{intro_1b[0] if intro_1b else ''}

{html_table(h_1b, r_1b, 'roles', widths=('7%', '21%', '18%', '26%', '28%'))}
{sec('3', 'Macs dédiés loués hors site', intro_2[0] if intro_2 else '')}

3a. Turquie — Macly {len(r_2a)} Macs

{intro_2a[0] if intro_2a else ''}

{html_table(h_2a, r_2a, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 8, 9, 10, 11, 12))}

3b. États-Unis et Irlande — MacStadium {len(r_2b)} Macs

{intro_2b[0] if intro_2b else ''}

{html_table(h_2b, r_2b, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 10, 11))}

3c. Chypre — rentamac {len(r_2c)} Mac

{intro_2c[0] if intro_2c else ''}

{html_table(h_2c, r_2c, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 9, 10))}
{sec('4', 'Serveurs dédiés OVHcloud (Linux)', intro_3[0] if intro_3 else '')} {html_table(h_3a, r_3a, 'ovh', align_right=(3,), nowrap=(3,))}

Fiches

{''.join(f'

{f}

' for f in fiches_3b)}
{sec('5', 'Topologie publique : tunnel et sites', intro_4[0] if intro_4 else '')} {html_table(h_4, r_4, 'tun', widths=('22%', '78%'))}

Chemin d'une requête

  1. DNSA → 51.161.112.61 (BHS64, Beauharnois) ; secours 51.255.75.61 (R9128, Gravelines)
  2. Caddytermine le TLS Let’s Encrypt et choisit l'upstream d'après l'hôte
  3. WireGuardhub 10.67.0.1 → interface wg1 du nœud (10.67.0.x), MTU 1380, keepalive 25 s
  4. 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
" for g in ltun)}

Services internes

{f"

{note_4[0].replace('Autres services internes : ', '')}

" if note_4 else ''}
{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.")}
Carte mondiale des nœuds
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 ''}
{s['region']} · {s['operator']} · {s['lat']:.2f}, {s['lon']:.2f}

{s['note']}

{''.join('' + n['id'] + '' for n in by_site.get(k, []))}
" for k, s in sorted(sites.items(), key=lambda kv: -len(by_site.get(kv[0], []))))}
{sec('7', 'Répartitions et capacité')}

7a. Par emplacement

{site_bars()}
EmplacementNœudsCœurs / threadsMémoireAlias

7b. Par modèle et par puce

{html_table(h_5b, r_5b, 'models', align_right=(1, 2, 3, 4), nowrap=(1, 2, 3, 4))}

7c. Capacité libre

{cap_5c[0] if cap_5c else ''}

{kpi('14', 'nœuds LAN libres', '150 cœurs · 332 Go')}{kpi('12', 'Macs loués libres', '114 cœurs · 240 Go')}{kpi('2', 'serveurs OVH libres', 'BHS64b 64 Go · BHS128 128 Go')}
{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.")} {heat_rows()}
NœudPuceCœursRAMÉtatCPUMémoireDisqueLoadmacOS
{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.

{LOGO}MacLustr · {DATE}
""" (HERE / "cover.html").write_text(cover) (HERE / "body.html").write_text(body) print("cover.html + body.html écrits —", len(body), "octets")