#!/usr/bin/env python3 """Kanonischer Doku-Generator für das BF-Inventar. Wahrheit = CSV. Markdown wird daraus generiert. - `build.py --seed` : (einmalig) bf.csv aus IPs.xlsx erzeugen (mit Transform/Deltas) - `build.py` : bf.md aus bf.csv + bf-core-ports.md aus bf-core-ports.csv rendern Künftige Änderungen: bf.csv editieren -> `python3 build.py` -> bf.md aktuell. """ import sys, os, re, csv, zipfile import xml.etree.ElementTree as ET HERE = os.path.dirname(os.path.abspath(__file__)) XLSX = os.path.join(HERE, 'IPs.xlsx') BF_CSV = os.path.join(HERE, 'bf.csv') BF_MD = os.path.join(HERE, 'bf.md') PORTS_CSV = os.path.join(HERE, 'bf-core-ports.csv') PORTS_MD = os.path.join(HERE, 'bf-core-ports.md') IPRE = re.compile(r'^\d{1,3}(\.\d{1,3}){3}$') MACRE = re.compile(r'^[0-9A-Fa-f]{1,2}(:[0-9A-Fa-f]{1,2}){5}$') COLS = ['section', 'ip', 'hostname', 'mac', 'pve_ct', 'server_ip', 'server_mac', 'note'] def _strip(t): return re.sub(r'\{[^}]*\}', '', t) def read_xlsx_sheet(path, sheet): z = zipfile.ZipFile(path) shared = [] if 'xl/sharedStrings.xml' in z.namelist(): for si in ET.fromstring(z.read('xl/sharedStrings.xml')): shared.append(''.join(t.text or '' for t in si.iter() if _strip(t.tag) == 't')) wb = ET.fromstring(z.read('xl/workbook.xml')) rels = {r.get('Id'): r.get('Target') for r in ET.fromstring(z.read('xl/_rels/workbook.xml.rels'))} tgt = None for s in wb.iter(): if _strip(s.tag) == 'sheet' and s.get('name') == sheet: rid = [v for k, v in s.attrib.items() if k.endswith('}id') or k == 'id'][0] t = rels[rid]; tgt = t if t.startswith('xl/') else 'xl/' + t ws = ET.fromstring(z.read(tgt)) def colnum(ref): m = re.match(r'([A-Z]+)(\d+)', ref) if not m: return 0, 0 c = 0 for ch in m.group(1): c = c * 26 + (ord(ch) - 64) return c, int(m.group(2)) cells = {}; maxc = 0; maxr = 0 for c in ws.iter(): if _strip(c.tag) != 'c': continue ref = c.get('r') or ''; t = c.get('t'); col, row = colnum(ref) if not col: continue vs = [x for x in c if _strip(x.tag) == 'v']; iss = [x for x in c if _strip(x.tag) == 'is'] val = '' if t == 's' and vs: try: val = shared[int(vs[0].text)] except Exception: val = vs[0].text or '' elif iss: val = ''.join(x.text or '' for x in iss[0].iter() if _strip(x.tag) == 't') elif vs: val = vs[0].text or '' if val != '': cells.setdefault(row, {})[col] = val; maxc = max(maxc, col); maxr = max(maxr, row) grid = [[cells.get(r, {}).get(c, '') for c in range(1, maxc + 1)] for r in range(1, maxr + 1)] return grid def seed_bf_csv(): grid = read_xlsx_sheet(XLSX, 'bf') out = [] section = '' for r in grid[1:]: # skip header row nz = [x for x in r if x.strip()] if not nz: continue # cols: A(0) empty | B(1) ip/section | C(2) host | D(3) mac | E(4) ct | F(5) sip | G(6) smac | H(7) note | I(8) note2 def g(i): return r[i].strip() if i < len(r) else '' ipcell = g(1) # section header row = single non-empty cell, not an IP if len(nz) == 1 and not IPRE.match(nz[0].strip()): section = nz[0].strip(); continue ip = ipcell if IPRE.match(ipcell) else '' host = g(2); mac = g(3) if MACRE.match(g(3)) else '' ct = g(4); sip = g(5); smac = g(6) notes = [g(7), g(8)] # defensive: misplaced data -> note if g(3) and not MACRE.match(g(3)): notes.append('mac?=' + g(3)) if ct and not ct.isdigit(): notes.append(ct); ct = '' if sip and not IPRE.match(sip): notes.append(sip); sip = '' if smac and not MACRE.match(smac): notes.append(smac); smac = '' note = ' / '.join(n for n in notes if n.strip()) # --- deltas (User 2026-06-05) --- if host == 'VPNjoernfuenfstueck': continue # gekickt if host == 'VPNchristoph': note = 'Fallback bis CF One überall läuft; IP umziehbar/unwichtig' out.append([section, ip, host, mac, ct, sip, smac, note]) with open(BF_CSV, 'w', newline='') as f: w = csv.writer(f); w.writerow(COLS); w.writerows(out) print(f'seed: wrote {BF_CSV} ({len(out)} rows)') def render_bf_md(): with open(BF_CSV) as f: rows = list(csv.DictReader(f)) lines = ['# Inventar — Standort BF (kanonisch)', '', '> Generiert aus `bf.csv` via `build.py`. **Quelle der Wahrheit = bf.csv.** Seed: `IPs.xlsx`.', '> ⚠️ Manche `pve_ct`/Einträge können Doku-Leichen sein (z.B. re-dockerisierte Container).', ''] cur = None cols = ['ip', 'hostname', 'mac', 'pve_ct', 'server_ip', 'server_mac', 'note'] for row in rows: if row['section'] != cur: cur = row['section'] lines += ['', f'## {cur or "(ohne Sektion)"}', '', '| ' + ' | '.join(cols) + ' |', '|' + '|'.join(['---'] * len(cols)) + '|'] if not any(row[c].strip() for c in cols): continue lines.append('| ' + ' | '.join(row[c].replace('|', '\\|') for c in cols) + ' |') with open(BF_MD, 'w') as f: f.write('\n'.join(lines) + '\n') print(f'wrote {BF_MD} ({len(rows)} rows)') def render_ports_md(): if not os.path.exists(PORTS_CSV): return with open(PORTS_CSV) as f: rows = list(csv.DictReader(f)) cols = list(rows[0].keys()) if rows else [] lines = ['# Core-Switch ALL-SG8316M (sw1, 10.40.41.239) — Port-Map', '', '> Ist-Belegung. Ports 4/8/15/16 **behalten** (4+8 interim bis neue Switches produktiv).', '> Übrige Ports (1–3, 5–7, 9–14): noch nicht erfasst — gemeinsam ergänzen.', '', '| ' + ' | '.join(cols) + ' |', '|' + '|'.join(['---'] * len(cols)) + '|'] for r in rows: lines.append('| ' + ' | '.join((r[c] or '').replace('|', '\\|') for c in cols) + ' |') with open(PORTS_MD, 'w') as f: f.write('\n'.join(lines) + '\n') print(f'wrote {PORTS_MD} ({len(rows)} ports)') if __name__ == '__main__': if '--seed' in sys.argv or not os.path.exists(BF_CSV): seed_bf_csv() render_bf_md() render_ports_md()