145 lines
6.1 KiB
Python
145 lines
6.1 KiB
Python
#!/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()
|