Compare commits
4 Commits
614025eafb
...
123a0fefdf
| Author | SHA1 | Date | |
|---|---|---|---|
| 123a0fefdf | |||
| aebf3776ac | |||
| b6f2129b41 | |||
| 82cd8098ef |
39
docs/announcements.md
Normal file
39
docs/announcements.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Announcements — bf-infra
|
||||
|
||||
Breaking News / Querschnitts-Änderungen, die **jede** folgende Session kennen muss, bevor sie
|
||||
losarbeitet — Dinge, die man sonst erst nach dem ersten Fehlschlag merkt. Kein Ersatz für die
|
||||
TP-Prosa (`docs/teilprojekte.md`) oder das Gitea-Board; hier steht nur, was *quer* zu den TPs gilt.
|
||||
|
||||
**Format je Eintrag:** Überschrift `## YYYY-MM-DD — <Titel>`, dann die Zeilen
|
||||
**Betrifft:** (wer/was) und **Aktion:** (was eine Session konkret anders machen muss),
|
||||
optional ein, zwei Sätze Kontext. **Neueste oben.** Der Skill `/news` liest diese Datei und
|
||||
zeigt die Einträge, die seit dem letzten `/news`-Lauf dazugekommen sind (Rest auf Wunsch).
|
||||
|
||||
<!-- TP35 Schicht 4. Eintrag hinzufügen = neuen ##-Block ganz oben unter dieser Zeile einfügen. -->
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-12 — claude.json-Secrets laufen über `{env:}` (TP35 Schicht 2 ✅)
|
||||
**Betrifft:** alle, die `~/.claude.json` oder `~/.secrets/exports.zsh` anfassen.
|
||||
**Aktion:** Keine Klartext-Secrets mehr in `~/.claude.json` — MCP-Credentials referenzieren jetzt
|
||||
`{env:MCP_*}` (dieselben Namen wie `opencode.jsonc`, bidirektional kompatibel). SSoT ist
|
||||
`~/.secrets/exports.zsh` (25 Vars). Nach jeder Änderung dort: `~/dev/.bin/gui-env-sync.sh`
|
||||
**und App-Neustart** (laufender Prozess erbt die launchd-Umgebung nicht). ⚠ `~/.claude.json` nie aus
|
||||
einer **laufenden** Session editieren — Claude Code schreibt dort State, der Edit würde überschrieben;
|
||||
der Flip läuft bei beendeter App über `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude`.
|
||||
|
||||
## 2026-09-12 — TP36-Live: Gerätedaten stehen als CustomFields in Nautobot
|
||||
**Betrifft:** alle, die Geräte-Ist-Werte (RouterOS-Version, Compliance %, DHCP-Server-Zahl,
|
||||
DNS-Modus, Mgmt-IP) brauchen.
|
||||
**Aktion:** Nicht mehr aus der Prosa ablesen und nicht raten — die fünf Felder (`grouping: TP36-Live`)
|
||||
hängen an den 5 RouterOS-`dcim.Device` in Nautobot. Aktualisieren: `python3 nautobot/gc-snapshot.py
|
||||
--commit` (liest nur aus Nautobot/GC, kontaktiert kein Gerät). Die Werte sind so aktuell wie das
|
||||
letzte Golden-Config-Backup (`backup_last_success_date` am Gerät).
|
||||
|
||||
## 2026-09-07 — SSoT-Cutover: Index/Status = Gitea, NICHT mehr teilprojekte.md
|
||||
**Betrifft:** jede Session, die einen TP-Status ändert, anlegt oder abschließt.
|
||||
**Aktion:** Status/Reihenfolge/„was lief zuletzt" gehen ins **Gitea-Issue**
|
||||
([`admin/bf-infra`](https://gitea.emsenhuber.net/admin/bf-infra/issues), Labels `status:*`/`thema:*`),
|
||||
**nicht** in eine Tabelle in `docs/teilprojekte.md`. Die Datei trägt nur noch die `### TPnn`-Prosa.
|
||||
`/projektstatus` liest Baum + Status aus der Gitea-API. Branch `tpNN-kurz` jederzeit möglich, Merge
|
||||
nach `main`, sobald Code/Config steht.
|
||||
@@ -2354,15 +2354,15 @@ kann in Ruhe nachziehen.
|
||||
|
||||
---
|
||||
|
||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 6 (Skill) ✅**
|
||||
### TP35 — MCP-Schichtung global/projekt/user + 3-Schicht-CLAUDE.md + Announcement-Mechanismus + Backup-Guard ▸ ⏳ **ANGELEGT 2026-08-28, Schicht 1 (FQDN→IP) + Schicht 3 (globale Regeln) + Schicht 4 (Announcement/`/news`) + Schicht 5 (Backup-Guard) + Schicht 6 (Skill) ✅**
|
||||
- **Auslöser:** Bestandsaufnahme Session 2026-08-28 (Handoff `docs/handoff-20260828-pre-opencode-migration.md`). Erkenntnis: MCP-Configs sind heute über `~/.config/opencode/opencode.jsonc` UND `~/.claude.json` verstreut, ohne klares Schema.
|
||||
- **Befund (geprüft):** 11/12 Backups im Verzeichnis `~/dev/.pre-opencode-migration-backups/20260828-150335/` gzip-valid; `uwe-job-bewerbung.tar.gz` korrupt (Source unverändert da). Repos und globale Configs md5-/HEAD-identisch zum Vor-Session-Stand. Synology-Drive-Sync aktiv (Anwender, manuell).
|
||||
- **Scope:**
|
||||
1. **MCP-Schichtung** (global/projekt/user) — wo lebt welche MCP-Definition, wie wird sie aufgelöst, wie bleibt sie synchron, wenn ein Projekt physisch auf einen anderen Host umzieht. **✅ 2026-08-29 (Teilschritt):** `gitea-mcp` + `n8n-mcp` in der globalen `opencode.jsonc` von CF-FQDN auf LAN-IP migriert (s. Ist-Stand-Block). `cloudflare`-MCP bleibt FQDN (User-Direktive).
|
||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab.
|
||||
2. **Credentials-Schichtung** — alle Secrets in `~/.secrets/exports.zsh`, MCP-Configs ausschließlich `{env:...}`. Schließt TP30 Posten #1 ab. **✅ 2026-09-12 angewandt:** `exports.zsh` um die 3 fehlenden `MCP_MIKROMCP_{ROUTER_AP3,CRS326_CORE,CRS326_BRANCH}_PASS` ergänzt (jetzt 25 Vars, die opencode schon referenzierte — vorher liefen die dort leer), `gui-env-sync` gelaufen (25/25 in launchd-Env). `~/.claude.json` via `~/dev/.bin/schicht2-claude-json-migrate.py --apply-claude` (bei beendeter App, Backup `.bak-20260912-*`) geflippt — **alle 7 Secrets** (`mikromcp` 5× PASS, `hostinger-api` Token, `n8n-mcp` Bearer) jetzt `{env:}`, verifiziert; App neu gestartet, MCP-Server laden sauber. 🔴 **Merkregel:** `~/.claude.json` nie aus laufender Session editieren (Claude Code schreibt dort State → überschreibt). Restcheck offen: **authentifizierter** mikromcp-Read **on-network** (am 09-12 Connect-Timeout VLAN99:80 = Erreichbarkeit vom Standort, kein Auth-Fehler). Rollback = `.bak` zurück.
|
||||
3. **3-Schicht-CLAUDE.md** — `CLAUDE.md` (öffentlich) / `CLAUDE.local.md` (gitignored) / `~/.claude/CLAUDE.md` (global). **✅ 2026-08-29 (globale Schicht):** `~/.config/opencode/AGENTS.md` als SSoT angelegt (Secrets + VLAN-Schema + 5 Lessons + SoC + Übergabe-Mechanik + Mid-Action + Commit-Hinweis + Backup-Guard); `~/.claude/CLAUDE.md` hält denselben Block als Spiegel. **Offen:** die pro-Projekt-Schicht (`CLAUDE.md`/`CLAUDE.local.md` + Handover-Posts) — eigene Sessions.
|
||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort (voraussichtlich `bf-infra/docs/announcements.md`) + Skill `/news` oder `/announce` zum Einlesen beim Session-Start. Initial: Breaking News über die MCP-Schichtung selbst.
|
||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff.
|
||||
4. **Announcement-Mechanismus (Hybrid)** — zentraler Ort + Skill zum Einlesen beim Session-Start. **✅ 2026-09-12:** `docs/announcements.md` angelegt (Format: `## YYYY-MM-DD — Titel` + `Betrifft:`/`Aktion:`, neueste oben) + globaler Skill **`/news`** (in beiden Skill-Pfaden `~/.claude/skills/news/` **und** `~/.config/opencode/skills/news/`, `cp` kein Symlink — Schicht-6-Lehre). `/news` zeigt Einträge, die seit dem letzten Lauf dazukamen (Seen-State pro Repo unter `~/.news-seen/`). Erste Einträge: SSoT-Cutover (TP36) + TP36-Live.
|
||||
5. **Backup-Guard** — `~/dev/.bin/backup-guard.sh`, Pre-Commit-Hook pro Projekt, „3 Tage aktiv"-Definition: 5 Commits oder 1000 LOC diff. **✅ 2026-09-12 abgenommen:** Guard (112 Z., `check`/`mark`/`status`) + `.git/hooks/pre-commit` in `bf-infra` live verdrahtet; der Hook hat beim TP36-Live-Commit real gegriffen („Projekt aktiv 12 Commits/198 LOC, Backup markiert"). ⚠️ **Bekannte Grenze:** `check` prüft nur *Existenz* der Guard-Datei, nicht deren Alter — ein alter `mark` (hier 2026-08-29) lässt den Commit durch. Das ist als Push-Erinnerung gewollt, keine Frische-Garantie.
|
||||
6. **`/projektstatus`-Skill global** — als Erstes, vor den anderen Schichten. Alle Projekte verwenden dasselbe Skill. **✅ 2026-08-28:** `/projektstatus`-Skill nach `~/.config/opencode/skills/projektstatus/SKILL.md` kopiert (`cp`, nicht `ln -s`) — opencode scannt diesen Default-Pfad, kein `skills.paths`-Eintrag in der JSONC nötig. Lehre im CLAUDE.md-Lessons-Block.
|
||||
- **Abgrenzung:** TP35 ist Konzept + Schichtung, nicht die Umsetzung. Pro Projekt eigene Session, eigener Handover-Post, eigene Mini-Anpassung.
|
||||
- **Verzahnt mit:** TP30 (Credentials), TP34 (Repository-Pattern), TP22 (Nautobot-GC als Vorbild für SSoT + Compliance), TP11 (Monitoring).
|
||||
|
||||
197
nautobot/gc-snapshot.py
Normal file
197
nautobot/gc-snapshot.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TP36-Live — Live-Gerätedaten als CustomFields auf den RouterOS-dcim.Device.
|
||||
|
||||
Legt (idempotent, get-or-create) fünf CustomFields auf `dcim.device` an und
|
||||
befüllt sie für jedes RouterOS-Gerät. Die Werte kommen AUSSCHLIESSLICH aus
|
||||
Nautobot selbst — es wird KEIN Gerät kontaktiert:
|
||||
|
||||
ros_version aus der GoldenConfig-`backup_config` ("by RouterOS X.Y.Z")
|
||||
dhcp_server_count gezählte Einträge der Sektion "/ip dhcp-server" im Backup
|
||||
dns_upstream_mode aus der "/ip dns"-Sektion des Backups: doh | plain | none
|
||||
compliance_pct GC config-compliance: compliant / total * 100 (gerundet)
|
||||
mgmt_ip device.primary_ip4 (ohne /prefix)
|
||||
|
||||
Warum aus Nautobot statt live vom Gerät: der MGMT-/REST-Weg (VLAN99:80) ist
|
||||
nicht aus jedem Vantage erreichbar; die GC-Backups werden ohnehin 2×/Tag per
|
||||
SSH gezogen und sind die Doku-SSoT. Damit ist der Snapshot cron-fest und
|
||||
braucht keine Geräte-Credentials. Aktualität steht in `backup_last_success_date`.
|
||||
|
||||
Der Backup-Config-Text wird nur INTERN geparst (Zähler/Modus abgeleitet) und
|
||||
NIE ausgegeben — er kann gescrubte Reste enthalten (PSK-Leak-Regel).
|
||||
|
||||
Token-Quelle: docs/nas-vlan20/stacks/210-nautobot.yml (gitignored),
|
||||
Schlüssel NAUTOBOT_SUPERUSER_API_TOKEN.
|
||||
|
||||
Aufruf:
|
||||
python3 nautobot/gc-snapshot.py # Dry-Run (default) — zeigt nur an
|
||||
python3 nautobot/gc-snapshot.py --commit # legt CustomFields an + patcht Devices
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
NB = os.environ.get("NAUTOBOT_URL", "http://10.40.20.30:8080")
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
YML = REPO / "docs" / "nas-vlan20" / "stacks" / "210-nautobot.yml"
|
||||
COMMIT = "--commit" in sys.argv
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for line in YML.read_text().splitlines():
|
||||
m = re.search(r"NAUTOBOT_SUPERUSER_API_TOKEN[=:]\s*([^\s\"']+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
sys.exit(f"NAUTOBOT_SUPERUSER_API_TOKEN nicht in {YML} gefunden")
|
||||
|
||||
|
||||
TOKEN = load_token()
|
||||
HDRS = {"Authorization": f"Token {TOKEN}", "Accept": "application/json",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def req(method: str, path: str, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
r = urllib.request.Request(f"{NB}{path}", data=data, headers=HDRS, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=20) as resp:
|
||||
return resp.status, json.loads(resp.read().decode() or "{}")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode() or "{}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- CustomFields
|
||||
# (key, label, type, description). content_types wird ergänzt.
|
||||
CUSTOM_FIELDS = [
|
||||
("ros_version", "RouterOS-Version", "text",
|
||||
"RouterOS-Version aus der GC-backup_config (by RouterOS …)."),
|
||||
("compliance_pct", "GC-Compliance %", "integer",
|
||||
"Golden-Config config-compliance: compliant/total in Prozent."),
|
||||
("dhcp_server_count", "DHCP-Server", "integer",
|
||||
"Anzahl Einträge der Sektion /ip dhcp-server im GC-Backup."),
|
||||
("mgmt_ip", "Mgmt-IP", "text",
|
||||
"device.primary_ip4 ohne Prefix (VLAN99-Management)."),
|
||||
("dns_upstream_mode", "DNS-Upstream-Modus", "text",
|
||||
"Aus /ip dns des GC-Backups: doh | plain | none."),
|
||||
]
|
||||
CF_KEYS = [c[0] for c in CUSTOM_FIELDS]
|
||||
|
||||
|
||||
def ensure_custom_fields():
|
||||
"""get-or-create die fünf CFs; bei Bestand: content_type dcim.device sicherstellen."""
|
||||
st, d = req("GET", "/api/extras/custom-fields/?limit=0")
|
||||
have = {r["key"]: r for r in d.get("results", [])}
|
||||
for key, label, typ, desc in CUSTOM_FIELDS:
|
||||
ex = have.get(key)
|
||||
if ex:
|
||||
cts = ex.get("content_types", [])
|
||||
ok = "dcim.device" in cts
|
||||
print(f" = CF '{key}' existiert ({ex['id']}){'' if ok else ' ⚠ ohne dcim.device!'}")
|
||||
if not ok and COMMIT:
|
||||
req("PATCH", f"/api/extras/custom-fields/{ex['id']}/",
|
||||
{"content_types": sorted(set(cts) | {"dcim.device"})})
|
||||
print(f" + dcim.device ergänzt")
|
||||
continue
|
||||
payload = {"key": key, "label": label, "type": typ, "description": desc,
|
||||
"content_types": ["dcim.device"], "grouping": "TP36-Live",
|
||||
"required": False}
|
||||
if not COMMIT:
|
||||
print(f" + CF '{key}' ({typ}) [DRY-RUN]")
|
||||
continue
|
||||
st, r = req("POST", "/api/extras/custom-fields/", payload)
|
||||
print(f" {'+' if st in (200,201) else '!'} CF '{key}'"
|
||||
+ (f" angelegt ({r.get('id')})" if st in (200, 201)
|
||||
else f" FEHLER {st}: {json.dumps(r)[:200]}"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Backup-Parser
|
||||
def parse_backup(cfg: str):
|
||||
"""Werte aus dem flachen RouterOS-Export (eine '/…'-Zeile pro Kommando)."""
|
||||
ver = ""
|
||||
m = re.search(r"by RouterOS ([0-9][0-9A-Za-z.\-]*)", cfg)
|
||||
if m:
|
||||
ver = m.group(1)
|
||||
# DHCP-Server = Zeilen '/ip dhcp-server add …' (NICHT lease/network/option)
|
||||
dhcp = len(re.findall(r"(?m)^\s*/ip dhcp-server add\b", cfg))
|
||||
# DNS-Modus aus der '/ip dns set …'-Zeile
|
||||
mode = "none"
|
||||
md = re.search(r"(?m)^\s*/ip dns set\b.*", cfg)
|
||||
if md:
|
||||
line = md.group(0)
|
||||
doh = re.search(r"use-doh-server=([^\s]+)", line)
|
||||
srv = re.search(r"\bservers=([^\s]+)", line)
|
||||
if doh and doh.group(1) not in ("", '""'):
|
||||
mode = "doh"
|
||||
elif srv and srv.group(1) not in ("", '""'):
|
||||
mode = "plain"
|
||||
return ver, dhcp, mode
|
||||
|
||||
|
||||
def compliance_pct(device_name: str):
|
||||
q = urllib.parse.urlencode({"device": device_name, "limit": 0, "depth": 0})
|
||||
st, d = req("GET", f"/api/plugins/golden-config/config-compliance/?{q}")
|
||||
rows = d.get("results", [])
|
||||
if not rows:
|
||||
return None
|
||||
ok = sum(1 for r in rows if r.get("compliance"))
|
||||
return round(ok / len(rows) * 100)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
def routeros_devices():
|
||||
q = urllib.parse.urlencode({"limit": 0, "depth": 1, "platform": "RouterOS"})
|
||||
st, d = req("GET", f"/api/dcim/devices/?{q}")
|
||||
devs = []
|
||||
for r in d.get("results", []):
|
||||
ip = r.get("primary_ip4")
|
||||
ip = ip.get("address") if isinstance(ip, dict) else ip
|
||||
if not ip:
|
||||
continue # tote Altgeräte (cudy/sw) ohne IP überspringen
|
||||
devs.append((r["id"], r["name"], ip.split("/")[0]))
|
||||
return sorted(devs, key=lambda x: x[1])
|
||||
|
||||
|
||||
def main():
|
||||
print(f"== TP36-Live gc-snapshot ({'COMMIT' if COMMIT else 'DRY-RUN'}) @ {NB} ==")
|
||||
|
||||
print("\n[1] CustomFields sicherstellen")
|
||||
ensure_custom_fields()
|
||||
|
||||
print("\n[2] Golden-Config-Backups laden")
|
||||
st, gc = req("GET", "/api/plugins/golden-config/golden-config/?depth=1&limit=0")
|
||||
backups = {}
|
||||
for r in gc.get("results", []):
|
||||
dev = r.get("device")
|
||||
dev = dev.get("name") if isinstance(dev, dict) else dev
|
||||
backups[dev] = (r.get("backup_config") or "", r.get("backup_last_success_date") or "?")
|
||||
|
||||
print("\n[3] Snapshot je Gerät")
|
||||
print(f" {'Gerät':16} {'ros':9} {'cmpl%':>5} {'dhcp':>4} {'dns':6} {'mgmt-ip':15} backup")
|
||||
for dev_id, name, mgmt in routeros_devices():
|
||||
cfg, when = backups.get(name, ("", "—"))
|
||||
ver, dhcp, dns = parse_backup(cfg) if cfg else ("?", 0, "?")
|
||||
pct = compliance_pct(name)
|
||||
vals = {
|
||||
"ros_version": ver or None,
|
||||
"compliance_pct": pct,
|
||||
"dhcp_server_count": dhcp,
|
||||
"mgmt_ip": mgmt,
|
||||
"dns_upstream_mode": dns,
|
||||
}
|
||||
pct_s = "—" if pct is None else str(pct)
|
||||
print(f" {name:16} {ver or '?':9} {pct_s:>5} {dhcp:>4} {dns:6} {mgmt:15} {when[:10]}")
|
||||
if COMMIT:
|
||||
st, r = req("PATCH", f"/api/dcim/devices/{dev_id}/", {"custom_fields": vals})
|
||||
if st not in (200, 201):
|
||||
print(f" ! PATCH FEHLER {st}: {json.dumps(r)[:200]}")
|
||||
|
||||
print(f"\n== fertig ({'COMMIT — geschrieben' if COMMIT else 'DRY-RUN — nichts geschrieben'}) ==")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user