SPB Git forge
3commits 1branches 0releases
2.9 MBsize
maindefault branch
1 h agolast push
HTML 57.2% Shell 19.1% Python 11.2% CSS 7.4% JavaScript 5.1%
20.3 KB · 285 lines python
Raw Blame History
1#!/usr/bin/env python32"""Dossier technique MacLustr — génère dossier.html (couverture + corps) à partir de :3   - INVENTAIRE-MACLUSTR.md (tables mesurées : matériel, rôles, loués, OVH, tunnel, répartitions)4   - data.json (inventaire du site www.maclustr.io : nœuds, sites géolocalisés, apps)5   - live.json (instantané /api/live)6   Rendu PDF : print.mjs (Playwright/Chromium) puis fusion couverture + corps (PyMuPDF)."""7import json, re, html, datetime, pathlib89HERE = pathlib.Path(__file__).parent10MD = (HERE.parent / "INVENTAIRE-MACLUSTR.md").read_text()11D = json.loads((HERE / "data.json").read_text())12L = json.loads((HERE / "live.json").read_text())13NOW = datetime.datetime.now()14DATE = NOW.strftime("%-d %B %Y").replace("September", "septembre").replace("October", "octobre")15MONTHS = {"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"}16for en, fr in MONTHS.items(): DATE = DATE.replace(en, fr)17TIME = NOW.strftime("%H h %M")1819# ---------- markdown helpers ----------20def inline(s):21    s = html.escape(s, quote=False)22    s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)23    s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)24    s = s.replace("—", "—")25    return s2627def table_after(heading_regex):28    """Renvoie (header_cells, rows) de la première table après le titre correspondant."""29    m = re.search(heading_regex, MD, re.M)30    if not m: raise SystemExit(f"titre introuvable : {heading_regex}")31    rest = MD[m.end():]32    lines = rest.splitlines()33    i = 034    while i < len(lines) and not lines[i].startswith("|"): i += 135    tbl = []36    while i < len(lines) and lines[i].startswith("|"):37        tbl.append(lines[i]); i += 138    cells = lambda ln: [c.strip() for c in ln.strip().strip("|").split("|")]39    head = cells(tbl[0]); rows = [cells(r) for r in tbl[2:]]40    return head, rows4142def para_after(heading_regex, n=1):43    m = re.search(heading_regex, MD, re.M); rest = MD[m.end():].strip().splitlines()44    out = []45    for ln in rest:46        if ln.startswith("|") or ln.startswith("#"): break47        if ln.strip(): out.append(inline(ln.strip()))48        if len(out) >= n: break49    return out5051def html_table(head, rows, cls="", align_right=(), nowrap=(), widths=None):52    th = "".join(f"<th{' class=r' if i in align_right else ''}>{inline(h)}</th>" for i, h in enumerate(head))53    trs = []54    for r in rows:55        tds = []56        for i, c in enumerate(r):57            classes = []58            if i in align_right: classes.append("r")59            if i in nowrap: classes.append("nw")60            if i == 0: classes.append("id")61            attr = (' class="' + ' '.join(classes) + '"') if classes else ''62            tds.append(f"<td{attr}>{inline(c)}</td>")63        trs.append("<tr>" + "".join(tds) + "</tr>")64    colg = "".join(f"<col style='width:{w}'>" for w in widths) if widths else ""65    return f"<table class='t {cls}'>{colg}<thead><tr>{th}</tr></thead><tbody>{''.join(trs)}</tbody></table>"6667# ---------- data ----------68nodes = D["nodes"]; sites = D["sites"]; apps = D["apps"]; groups = D["appGroups"]69by_site = {}70for n in nodes: by_site.setdefault(n["site"], []).append(n)71apps_by_node = {}72for a in apps: apps_by_node.setdefault(a["node"], []).append(a)73macs = [n for n in nodes if n["group"] != "ovh"]74tot = dict(nodes=len(nodes), macs=len(macs), ovh=len(nodes) - len(macs), cores=sum(n["cores"] for n in nodes),75           gpu=sum(n.get("gpu") or 0 for n in macs), ram=sum(n["ram"] for n in nodes), apps=len(apps),76           web=sum(1 for a in apps if a.get("domain")), sites=len(sites),77           countries=len({s["region"].split(",")[-1].strip() for s in sites.values()}))78S = L.get("summary") or {}79lnodes = L.get("nodes", {}); lapps = L.get("apps", {}); ltun = L.get("tunnel", [])80fr = lambda n: f"{n:,}".replace(",", " ")81def ram_fmt(gb): return f"{gb/1024:.2f}".replace(".", ",") + " To" if gb >= 1000 else f"{gb} Go"8283# ---------- pieces ----------84def kpi(v, l, s=""): return f"<div class='kpi'><b>{v}</b><span>{l}</span>{f'<small>{s}</small>' if s else ''}</div>"8586def bar(p, cls=""):87    p = 0 if p is None else max(0, min(100, p))88    return f"<span class='bar {cls}'><i style='width:{p}%'></i></span>"8990def status_cell(n):91    if n["group"] == "ovh":92        g = next((g for g in ltun if g["name"] == n["id"]), None)93        if g: return "<span class='st on'>passerelle active</span>" if g["ok"] else "<span class='st bad'>injoignable</span>"94        return "<span class='st on'>en réserve</span>"95    l = lnodes.get(n["id"])96    if not l or not l.get("online"): return "<span class='st off'>hors ligne</span>"97    return "<span class='st on'>en ligne</span>"9899# heatmap live100def heat_rows():101    out = []102    for n in sorted(macs, key=lambda x: -(x["cores"])):103        l = lnodes.get(n["id"]) or {}104        on = l.get("online")105        cpu = l.get("cpu"); mem = l.get("memUsedGB"); memT = l.get("memTotalGB") or n["ram"]106        disk = l.get("diskUsedGB"); diskT = l.get("diskTotalGB")107        out.append(f"<tr><td class='id'>{n['id']}</td><td>{n['chip']}</td><td class='r'>{n['cores']}</td><td class='r'>{n['ram']} Go</td>"108                   f"<td>{status_cell(n)}</td>"109                   f"<td class='meter'>{bar(cpu if on else None)}<span class='v'>{(str(round(cpu)) + ' %') if on and cpu is not None else '—'}</span></td>"110                   f"<td class='meter'>{bar((mem / memT * 100) if on and mem is not None and memT else None)}<span class='v'>{(f'{mem:.1f}'.replace('.', ',') + ' / ' + str(memT) + ' Go') if on and mem is not None else '—'}</span></td>"111                   f"<td class='meter'>{bar((disk / diskT * 100) if on and disk and diskT else None)}<span class='v'>{(fr(int(disk)) + ' / ' + fr(int(diskT)) + ' Go') if on and disk and diskT else '—'}</span></td>"112                   f"<td class='r mono'>{l.get('load1', '—') if on else '—'}</td><td class='mono'>{l.get('os', '—') if on else '—'}</td></tr>")113    return "".join(out)114115# répartition par site (barres)116def site_bars():117    rows = []118    mx_c = max(sum(n["cores"] for n in ns) for ns in by_site.values())119    mx_r = max(sum(n["ram"] for n in ns) for ns in by_site.values())120    for k, s in sorted(sites.items(), key=lambda kv: -len(by_site.get(kv[0], []))):121        ns = by_site.get(k, []); c = sum(n["cores"] for n in ns); r = sum(n["ram"] for n in ns)122        rows.append(f"<tr><td class='id'>{s['name']}<br><small>{s['region']} · {s['operator']}</small></td><td class='r'>{len(ns)}</td>"123                    f"<td class='meter wide'>{bar(c / mx_c * 100)}<span class='v'>{c} {'thr' if s['kind'] == 'ovh' else 'cœurs'}</span></td>"124                    f"<td class='meter wide'>{bar(r / mx_r * 100, 'alt')}<span class='v'>{ram_fmt(r)}</span></td>"125                    f"<td class='mono small'>{', '.join(n['id'] for n in ns)}</td></tr>")126    return "".join(rows)127128def apps_section():129    out = []130    for g in groups:131        ga = [a for a in apps if a["group"] == g["id"]]132        rows = []133        for a in ga:134            l = lapps.get(a["id"]) or {}135            st = "<span class='st on'>en ligne</span>" if l.get("state") == "up" else ("<span class='st bad'>" + html.escape(l.get("state", "—")) + "</span>" if l else "<span class='st off'>—</span>")136            extra = []137            if l.get("publicMs") is not None and l.get("publicOk"): extra.append(f"{l['publicMs']} ms")138            if l.get("uptime24h") is not None: extra.append(f"{round(l['uptime24h'])} % / 24 h")139            rows.append(f"<tr><td class='id'>{html.escape(a['name'])}</td><td class='mono'>{html.escape(a['domain'] or 'service interne')}</td><td class='mono nw'>{l.get('node') or a['node']}<span class='faint'>:{a['port']}</span></td><td>{html.escape(a['desc'])}</td><td class='nw'>{st} <span class='faint mono small'>{' · '.join(extra)}</span></td></tr>")140        out.append(f"<div class='app-group{' keep' if len(ga) <= 6 else ''}'><h3>{html.escape(g['name'])} <span class='cnt'>{len(ga)}</span></h3><p class='lead'>{html.escape(g['blurb'])}</p>"141                   f"<table class='t apps'><col style='width:15%'><col style='width:19%'><col style='width:11%'><col><col style='width:15%'><thead><tr><th>Application</th><th>Domaine</th><th>Nœud</th><th>Description</th><th>État</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div>")142    return "".join(out)143144# ---------- tables issues du markdown ----------145h_over, r_over = table_after(r"^## Vue d'ensemble")146h_1a, r_1a = table_after(r"^### 1a\.")147h_1b, r_1b = table_after(r"^### 1b\.")148h_2a, r_2a = table_after(r"^### 2a\.")149h_2b, r_2b = table_after(r"^### 2b\.")150h_2c, r_2c = table_after(r"^### 2c\.")151h_3a, r_3a = table_after(r"^### 3a\.")152h_4, r_4 = table_after(r"^## 4\.")153h_5a, r_5a = table_after(r"^### 5a\.")154h_5b, r_5b = table_after(r"^### 5b\.")155intro_over = para_after(r"^## Vue d'ensemble\n\n\|.*\n\|.*\n(\|.*\n)+", 3)156intro_1 = para_after(r"^## 1\. Nœuds internes.*$")157note_1a = para_after(r"^\*\*Sous-total LAN")158intro_1b = para_after(r"^### 1b\.")159intro_2 = para_after(r"^## 2\. Macs loués.*$")160intro_2a = para_after(r"^### 2a\.")161intro_2b = para_after(r"^### 2b\.")162intro_2c = para_after(r"^### 2c\.")163intro_3 = para_after(r"^## 3\. Serveurs.*$")164fiches_3b = [inline(l.strip()[2:]) for l in MD.split("### 3b. Fiches")[1].split("## 4.")[0].strip().splitlines() if l.strip().startswith("- ")]165intro_4 = para_after(r"^## 4\. Topologie.*$")166note_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")]167cap_5c = para_after(r"^### 5c\.")168sources = para_after(r"^## 6\. Sources.*$")169170# lignes des tables 3a et 1a : retirer la colonne N° de série ? on garde tout (document détaillé)171# ---------- HTML ----------172CSS = (HERE / "dossier.css").read_text()173LOGO = "<svg class='logo' viewBox='0 0 32 32'><rect x='3' y='3' width='11' height='11' rx='2.5' fill='currentColor'/><rect x='18' y='3' width='11' height='11' rx='2.5' fill='currentColor' opacity='.55'/><rect x='3' y='18' width='11' height='11' rx='2.5' fill='currentColor' opacity='.55'/><rect x='18' y='18' width='11' height='11' rx='2.5' fill='currentColor' opacity='.3'/></svg>"174175cover = f"""<!doctype html><html lang='fr'><head><meta charset='utf-8'><title>MacLustr — Dossier technique</title><style>{CSS} @page {{ size: 297mm 210mm; margin: 0; }}</style></head>176<body class='cover'>177<div class='cv'>178  <div class='cv-top'><div class='brand'>{LOGO}<span>MacLustr</span></div><span class='mono'>www.maclustr.io</span></div>179  <div class='cv-mid'>180    <p class='eyebrow'>Dossier technique · inventaire complet</p>181    <h1>Un supercalculateur<br><span>fait de Macs.</span></h1>182    <p class='sub'>{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}.</p>183  </div>184  <div class='cv-facts'>185    {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')}186  </div>187  <div class='cv-bot'><span>Simon-Pierre Boucher · Saint-Augustin-de-Desmaures, Québec</span><span class='mono'>contact@spboucher.ai · état live du {DATE} à {TIME}</span></div>188</div>189</body></html>"""190191def sec(num, title, lead=""):192    return f"<section class='sec'><div class='sec-h'><span class='num'>{num}</span><h2>{title}</h2></div>{f'<p class=lead>{lead}</p>' if lead else ''}"193194toc = [("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"),195       ("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")]196197body = f"""<!doctype html><html lang='fr'><head><meta charset='utf-8'><title>MacLustr — Dossier technique</title><style>{CSS}</style></head>198<body>199{sec('1', "Vue d'ensemble")}200<div class='two'>201  <div>202    <p class='lead big'>MacLustr est un cluster privé de {tot['macs']} Macs Apple Silicon et {tot['ovh']} serveurs Linux OVHcloud. Il totalise <strong>{fr(tot['cores'])} cœurs CPU</strong>, <strong>{fr(tot['gpu'])} cœurs GPU</strong> et <strong>{ram_fmt(tot['ram'])} de mémoire</strong>, et héberge <strong>{tot['apps']} applications</strong> en production, dont {tot['web']} sites publics servis en HTTPS par le MacLustr Tunnel.</p>203    {''.join(f'<p>{p}</p>' for p in intro_over)}204  </div>205  <div>206    <div class='toc'><h4>Sommaire</h4>{''.join(f"<div><span class='num'>{n}</span>{t}</div>" for n, t in toc)}</div>207  </div>208</div>209<h3>Par catégorie</h3>210{html_table(h_over, r_over, 'over', align_right=(1, 2, 3), nowrap=(1, 2, 3))}211<div class='kpis'>{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")}</div>212</section>213214{sec('2', 'Nœuds internes — LAN Saint-Augustin-de-Desmaures', intro_1[0] if intro_1 else '')}215<h3>2a. Matériel et système <span class='cnt'>{len(r_1a)} Macs</span></h3>216{html_table(h_1a, r_1a, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 9))}217<p class='note'>{note_1a[0] if note_1a else ''}</p>218<div class='roles-page'><h3>2b. Rôle, runtimes et applications par nœud</h3>219<p class='lead'>{intro_1b[0] if intro_1b else ''}</p>220{html_table(h_1b, r_1b, 'roles', widths=('7%', '21%', '18%', '26%', '28%'))}</div>221</section>222223{sec('3', 'Macs dédiés loués hors site', intro_2[0] if intro_2 else '')}224<h3>3a. Turquie — Macly <span class='cnt'>{len(r_2a)} Macs</span></h3>225<p class='lead'>{intro_2a[0] if intro_2a else ''}</p>226{html_table(h_2a, r_2a, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 8, 9, 10, 11, 12))}227<div class='keep'><h3>3b. États-Unis et Irlande — MacStadium <span class='cnt'>{len(r_2b)} Macs</span></h3>228<p class='lead'>{intro_2b[0] if intro_2b else ''}</p>229{html_table(h_2b, r_2b, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 10, 11))}</div>230<div class='keep'><h3>3c. Chypre — rentamac <span class='cnt'>{len(r_2c)} Mac</span></h3>231<p class='lead'>{intro_2c[0] if intro_2c else ''}</p>232{html_table(h_2c, r_2c, 'hw', align_right=(3, 4), nowrap=(0, 3, 4, 5, 7, 8, 9, 10))}</div>233</section>234235{sec('4', 'Serveurs dédiés OVHcloud (Linux)', intro_3[0] if intro_3 else '')}236{html_table(h_3a, r_3a, 'ovh', align_right=(3,), nowrap=(3,))}237<h3>Fiches</h3>238<div class='fiches'>{''.join(f'<p>{f}</p>' for f in fiches_3b)}</div>239</section>240241{sec('5', 'Topologie publique : tunnel et sites', intro_4[0] if intro_4 else '')}242{html_table(h_4, r_4, 'tun', widths=('22%', '78%'))}243<div class='three-col'>244  <div class='panel'><h4>Chemin d'une requête</h4>245    <ol class='flow'><li><div><b>DNS</b><span>A → 51.161.112.61 (BHS64, Beauharnois) ; secours 51.255.75.61 (R9128, Gravelines)</span></div></li><li><div><b>Caddy</b><span>termine le TLS Let’s Encrypt et choisit l'upstream d'après l'hôte</span></div></li><li><div><b>WireGuard</b><span>hub 10.67.0.1 → interface wg1 du nœud (10.67.0.x), MTU 1380, keepalive 25 s</span></div></li><li><div><b>Nœud</b><span>processus PM2 ou launchd sur le port local ; healthcheck local puis public à chaque déploiement</span></div></li></ol>246  </div>247  <div class='panel'><h4>Passerelles live</h4>248    {''.join(f"<div class='gw'><b>{g['name']}</b><span>{'principale' if g.get('primary') else 'secondaire'} · {g.get('site', '')}</span><span class='mono'>{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</span></div>" for g in ltun)}249  </div>250  <div class='panel'><h4>Services internes</h4>{f"<p class='small' style='color:var(--ink-2)'>{note_4[0].replace('Autres services internes : ', '')}</p>" if note_4 else ''}</div>251</div>252</section>253254{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.")}255<figure class='map'><img src='map.png' alt='Carte mondiale des nœuds'><figcaption>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.</figcaption></figure>256<div class='sites'>{''.join(f"<div class='site'><div class='s-h'><b>{s['name']}</b><span class='mono'>{len(by_site.get(k, []))} nœud{'s' if len(by_site.get(k, [])) > 1 else ''}</span></div><div class='s-r'>{s['region']} · {s['operator']} · <span class='mono'>{s['lat']:.2f}, {s['lon']:.2f}</span></div><p>{s['note']}</p><div class='s-n'>{''.join('<span>' + n['id'] + '</span>' for n in by_site.get(k, []))}</div></div>" for k, s in sorted(sites.items(), key=lambda kv: -len(by_site.get(kv[0], []))))}</div>257</section>258259{sec('7', 'Répartitions et capacité')}260<h3>7a. Par emplacement</h3>261<table class='t bars'><col style='width:24%'><col style='width:6%'><col style='width:26%'><col style='width:22%'><col><thead><tr><th>Emplacement</th><th class='r'>Nœuds</th><th>Cœurs / threads</th><th>Mémoire</th><th>Alias</th></tr></thead><tbody>{site_bars()}</tbody></table>262<h3>7b. Par modèle et par puce</h3>{html_table(h_5b, r_5b, 'models', align_right=(1, 2, 3, 4), nowrap=(1, 2, 3, 4))}263<div class='keep'><h3>7c. Capacité libre</h3><p class='lead'>{cap_5c[0] if cap_5c else ''}</p>264<div class='kpis three'>{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')}</div></div>265</section>266267{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.")}268{apps_section()}269</section>270271{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.")}272<table class='t heat'><col style='width:9%'><col style='width:9%'><col style='width:5%'><col style='width:6%'><col style='width:9%'><col style='width:15%'><col style='width:19%'><col style='width:19%'><col style='width:4%'><col><thead><tr><th>Nœud</th><th>Puce</th><th class='r'>Cœurs</th><th class='r'>RAM</th><th>État</th><th>CPU</th><th>Mémoire</th><th>Disque</th><th class='r'>Load</th><th>macOS</th></tr></thead><tbody>{heat_rows()}</tbody></table>273</section>274275{sec('10', 'Sources et méthode')}276<p>{sources[0] if sources else ''}</p>277<p class='note'>Les identifiants (mots de passe sudo, DeskIn, VNC, jetons) ne figurent pas dans ce dossier : voir <code>NODES.md</code>. Document généré par <code>cluster-skill/dossier/build.py</code> à partir de <code>INVENTAIRE-MACLUSTR.md</code>, 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.</p>278<div class='end'>{LOGO}<span>MacLustr · {DATE}</span></div>279</section>280</body></html>"""281282(HERE / "cover.html").write_text(cover)283(HERE / "body.html").write_text(body)284print("cover.html + body.html écrits —", len(body), "octets")285