#!/usr/bin/env python3 """ kb-audit-apply — автоматически применяет ТОЛЬКО безопасные правки inventory. Безопасное = структурная правка по фактам, без мнения/описаний: 1. Новый VMID в live, отсутствует в inventory → добавить строку в "🟢 Запущенные" или "🔴 Остановленные" (LXC/VM, по типу) 2. VMID в inventory (вне секции 🗑️), отсутствует в live → переместить в 🗑️ Удалённые с датой (явно deletion, не stopped — pct list показывает stopped) НЕ трогает: описания, назначения, IP. Если проблема сложнее — оставляет drift-отчёту. Коммитит как `kb-audit-bot ` чтобы можно было фильтровать. """ import re import subprocess import sys from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) # используем те же helpers from kb_audit_helpers import parse_live, parse_inventory, find_deleted_section, INVENTORY # type: ignore VAULT = Path(__file__).resolve().parent.parent BOT_NAME = "kb-audit-bot" BOT_EMAIL = "kb-audit@dttb.ru" def find_section_insert_point(text: str, section_header: str) -> int | None: """Найти позицию СТРОКИ в конце markdown-таблицы сразу после заголовка секции.""" m = re.search(re.escape(section_header), text) if not m: return None # после заголовка ищем конец таблицы (пустая строка или следующий заголовок ##) after = text[m.end():] rows_block = re.search( r"\|[^\n]*\|\n\|[-:\s|]+\|\n((?:\|[^\n]*\|\n)+)", after, re.MULTILINE, ) if not rows_block: return None return m.end() + rows_block.end() def insert_row_in_table(text: str, section_header: str, row: str) -> str | None: """Вставить row в конец таблицы указанной секции.""" pos = find_section_insert_point(text, section_header) if pos is None: return None return text[:pos] + row + text[pos:] def apply_new_vmid(text: str, vmid: str, info: dict) -> str | None: """Добавить строку в соответствующую таблицу. Таблицы inventory (по памяти — см. parse): - ## 🟢 Запущенные LXC → подзаголовки ### LXC NNN ... - ## 🔴 Остановленные LXC → таблица | VMID | Имя | Назначение | - ## 🟢 Запущенные VM — тоже подзаголовки ### VM NNN - ## 🔴 Остановленные VM → таблица Мы можем ТОЛЬКО дописывать в таблицы Остановленных (простая структура). Running-хосты требуют отдельной секции ### — это семантическая правка, оставляем человеку. """ if info["status"] != "stopped": return None # для running — выдать через drift, пусть человек сам добавит секцию if info["type"] == "LXC": header = "## 🔴 Остановленные LXC" row = f"| {vmid} | {info['name']} | (новый, уточнить) |\n" else: header = "## 🔴 Остановленные VM (QEMU)" row = f"| {vmid} | {info['name']} | — | — | — | (новый, уточнить) |\n" return insert_row_in_table(text, header, row) def move_to_deleted(text: str, vmid: str) -> str | None: """Переместить упоминание VMID в секцию '🗑️ Удалённые'.""" today = date.today().isoformat() # удаляем строки table-row с |VMID| row_pattern = re.compile(rf"^\s*\|\s*{vmid}\s*\|.*\n", re.MULTILINE) found_rows = row_pattern.findall(text) if not found_rows: # нет явной строки — значит упомянут только в заголовке (### LXC NNN) # это сложная правка — не трогаем return None new_text = row_pattern.sub("", text) # добавляем в секцию удалённых deleted_row = f"| {vmid} | (auto-detected missing) | {today} | — |\n" result = insert_row_in_table(new_text, "## 🗑️ Удалённые (история)", deleted_row) return result def safe_apply_all(): text = INVENTORY.read_text() original = text live = parse_live() inventory = parse_inventory(INVENTORY) deleted = find_deleted_section(INVENTORY) live_ids = set(live.keys()) inv_ids = set(inventory.keys()) # safety: live не должно быть пустым (если Proxmox недоступен — abort) if len(live_ids) < 5: print(f"safety abort: live {len(live_ids)} < 5. Proxmox не отвечает?", file=sys.stderr) sys.exit(2) applied = [] skipped = [] # новые LXC/VM: в live, не в inventory for vmid in sorted(live_ids - inv_ids, key=int): info = live[vmid] result = apply_new_vmid(text, vmid, info) if result: text = result applied.append(f"+ VMID {vmid} ({info['type']} {info['name']}, {info['status']})") else: skipped.append(f" VMID {vmid} ({info['type']} {info['name']}, {info['status']}) — требует ручной правки (running → новая секция)") # пропавшие: в inventory (не в 🗑️), не в live for vmid in sorted(inv_ids - live_ids - deleted, key=int): result = move_to_deleted(text, vmid) if result: text = result applied.append(f"→🗑️ VMID {vmid} (больше нет в pct/qm list)") else: skipped.append(f" VMID {vmid} — упомянут только в заголовке, ручная правка") if text == original: print("no changes to apply") return INVENTORY.write_text(text) summary = "\n".join(applied) if skipped: summary += "\n\nSkipped (too complex for auto-apply):\n" + "\n".join(skipped) # commit subprocess.run( ["git", "-C", str(VAULT), "add", str(INVENTORY.relative_to(VAULT))], check=True, ) subprocess.run( [ "git", "-C", str(VAULT), "-c", f"user.name={BOT_NAME}", "-c", f"user.email={BOT_EMAIL}", "commit", "-m", f"kb-audit-bot: auto-apply drift {date.today().isoformat()}\n\n{summary}", ], check=True, ) subprocess.run(["git", "-C", str(VAULT), "push"], check=False) print(f"applied {len(applied)} change(s):") print(summary) if __name__ == "__main__": safe_apply_all()