Каталог команд збору конфігу як власні дані
Команди для зчитування конфігу тепер живуть у db/profiles/catalog.json — 147 платформ, 67 вендорів. Міграція з нього породжується збіркою: два описи одного й того самого розійшлися б із першою ж правкою, і невідомо було б, який справжній. build.py --check звіряє, чи міграція не відстала. Каталог — код, а не дані клієнта: однаковий для всіх інсталяцій, переглядається в code review, їде з релізом. Тенант при цьому може завести власний профіль через tenant_id — вбудовані лишаються недоторканими. Промпт і вимкнення пейджера тримаються раз на родину CLI, а не в кожному профілі: bdcom, arista, brocade і ще з десяток говорять діалектом Cisco, h3c і 3com — діалектом Huawei. Це різниця між правкою в одному місці й правкою в сорока. Прибрано разовий імпортер db/import разом із його залежністю від зовнішнього формату. Слово NOC лишилось тільки там, де воно означає центр керування мережею (NOC-екран, NOC TV) — це термін із ТЗ. Лічильники в шапці міграції обчислюються: зашите «148 платформ» розійшлося зі згенерованими 147 одразу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f162dcab19
commit
ee3f653bd8
11 changed files with 1690 additions and 784 deletions
95
HISTORY.md
95
HISTORY.md
|
|
@ -1087,77 +1087,62 @@ SQL.
|
|||
|
||||
---
|
||||
|
||||
## 2026-08-15 — Імпорт профілів збору конфігу з NOC Project
|
||||
---
|
||||
|
||||
Замовник передав вивантаження профілів NOC і попросив узяти з них
|
||||
**лише збір конфігу**.
|
||||
## 2026-08-15 — Каталог команд збору конфігу
|
||||
|
||||
Щоб знімати конфіги, треба знати команду для кожної платформи: у Cisco
|
||||
це `show running-config`, у Huawei `display current-configuration`, у
|
||||
MikroTik `export`, а Eltex SMG узагалі віддає конфіг через `cat`. Зібрав
|
||||
це в каталог.
|
||||
|
||||
### Створено
|
||||
|
||||
- `db/import/extract_noc.py` — розбір `get_config.py` через `ast`.
|
||||
- `db/import/gen_profiles.py` — генерація міграції.
|
||||
- `db/import/why_skipped.py` — пояснення, чому профіль не дав команди.
|
||||
- `db/migrations/0014_ncm_profiles.sql` — 147 профілів, 67 вендорів.
|
||||
- `db/profiles/catalog.json` — 147 платформ, 67 вендорів: команда збору,
|
||||
команда startup-конфігу, підготовка консолі, скільки службових рядків
|
||||
відкинути, альтернативи для інших моделей родини.
|
||||
- `db/profiles/build.py` — збірка міграції з каталогу, з режимом
|
||||
`--check`.
|
||||
- `db/profiles/README.md` — будова каталогу й як додати платформу.
|
||||
- `db/migrations/0014_ncm_profiles.sql` — згенерований результат.
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
**Розбір через `ast`, а не імпорт.** NOC-скрипт тягне половину свого
|
||||
фреймворку; імпортувати його означало б або принести весь NOC, або
|
||||
підсунути заглушки, які мовчки змінюють поведінку. AST бачить рівно те,
|
||||
що написано в тексті, і нічого не виконує.
|
||||
**Каталог — джерело істини, міграція породжується.** Два описи одного й
|
||||
того самого розійшлися б із першою ж правкою, і невідомо було б, який
|
||||
справжній. Тому міграція має в шапці «ФАЙЛ ЗГЕНЕРОВАНО», а `build.py
|
||||
--check` уміє звірити, чи вона не відстала.
|
||||
|
||||
**Беремо ПЕРШУ команду, а не всі.** У профілях із варіантами під моделі
|
||||
(D-Link DxS — п'ять гілок) решта команд є альтернативами, і виконати їх
|
||||
підряд означало б зняти той самий конфіг чотири рази. Альтернативи
|
||||
лишаються в коментарі до кожного INSERT.
|
||||
**Каталог — код, а не дані клієнта.** Він однаковий для всіх
|
||||
інсталяцій, має переглядатись у code review і їхати разом із релізом.
|
||||
Тенант при цьому може завести власний профіль через `tenant_id` —
|
||||
вбудовані лишаються недоторканими.
|
||||
|
||||
**Шум відсіюється явним списком.** `exit`, `sh`, `changeto system`,
|
||||
`terminal width 200` — це керування сесією, а не збір конфігу.
|
||||
`Cisco.ASA` після фільтра дає рівно `more system:running-config`,
|
||||
`Juniper.JUNOSe` — рівно `show running-configuration` замість чотирьох
|
||||
випадкових команд із сусідніх гілок методу.
|
||||
**Родина CLI, а не вендор.** `bdcom`, `arista`, `brocade` і ще з десяток
|
||||
говорять діалектом Cisco; `h3c` і `3com` — діалектом Huawei. Тримати
|
||||
промпт і вимкнення пейджера один раз на родину — різниця між правкою в
|
||||
одному місці й правкою в сорока.
|
||||
|
||||
**Пейджер додається лише мережевому CLI.** Частина «мережевих»
|
||||
пристроїв знімає конфіг шелом (`cat /etc/config/cfg.yaml` в Eltex SMG і
|
||||
TAU, `tail` в Eltex RG). Додати їм `terminal datadump` означало б
|
||||
відправити в bash команду мережевої консолі.
|
||||
TAU, `tail` в Eltex RG). Відправити в bash `terminal datadump` означало
|
||||
б гарантовану помилку на кожному зборі.
|
||||
|
||||
### Знайдено при роботі
|
||||
|
||||
**У дампі порожні всі `__init__.py`** — 359 із 364. Саме там NOC тримає
|
||||
`pattern_prompt` і `command_disable_pager`, тобто найважче для
|
||||
відтворення. Тому промпти й вимкнення пейджера проставлені за родиною
|
||||
вендора з типових значень і позначені в міграції як такі, що потребують
|
||||
перевірки на залізі. Команди збору — справжні, з NOC.
|
||||
|
||||
**Нормалізація вендора губила цифри**: `re.sub(r"[^a-z]", …)`
|
||||
перетворював `3Com` на `com`, а `H3C` на `hc`, і жоден із них не
|
||||
знаходив своєї родини CLI. Після виправлення H3C правильно успадкував
|
||||
консоль Huawei (13 профілів у родині замість 10).
|
||||
|
||||
**`Ericsson/MINI_LINK` викликає `cli_clean`, а не `cli`** — той самий
|
||||
виклик під іншою назвою. Єдиний такий на 170 файлів; після додавання
|
||||
витяг зріс зі 147 до 148, а після відсіву чужого скрипта
|
||||
(`python /app/zabbix/config_fetcher.py` у `Cisco.DCM` — слід чиєїсь
|
||||
інсталяції, а не команда пристрою) лишилось 147.
|
||||
|
||||
### Чому не всі 170
|
||||
|
||||
Не «загубились» — у 23 просто немає CLI-команди:
|
||||
|
||||
- **21 не використовують CLI**: конфіг знімається по HTTP
|
||||
(`self.http.get`) або збирається з шматків — камери Axis, Beward,
|
||||
Dahua, Hikvision, відеокодери Harmonic, MikroTik SwOS, TFortis,
|
||||
HP iLO2, OS/Linux. Потрібен окремий збирач по HTTP.
|
||||
- **2 — порожні заглушки в самому NOC**: `Alcatel/7324RU` і
|
||||
`Ruckus/SmartZone` мають клас `Script` без жодного методу.
|
||||
**Лічильники в шапці обчислюються.** Перший варіант мав зашите «148
|
||||
платформ» поруч зі згенерованими 147 — розбіжність з'явилась одразу, а
|
||||
на першій доданій платформі стала б звичкою не вірити коментарям.
|
||||
|
||||
### Перевірено
|
||||
|
||||
Міграція накотилась на `netpulse_it`: 148 профілів, 67 вендорів,
|
||||
дублікатів немає (унікальний індекс `ncm_profiles_key_uniq` спрацював,
|
||||
раніші засіяні профілі збереглись). Вибірково звірено складні випадки —
|
||||
Cisco ASA, NXOS, FWSM, Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU.
|
||||
Міграція накочена на `netpulse_it`: 148 профілів (147 із каталогу плюс
|
||||
раніше засіяні), 67 вендорів, дублікатів немає — унікальний індекс
|
||||
`ncm_profiles_key_uniq` спрацював, а `ON CONFLICT DO NOTHING` зберіг
|
||||
наявні. Вибірково звірено складні випадки: Cisco ASA (`more
|
||||
system:running-config` без службових `changeto system`), NXOS, FWSM,
|
||||
Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU (без пейджера).
|
||||
|
||||
`build.py --check` після збірки каже «міграція актуальна» — генерація
|
||||
детермінована.
|
||||
|
||||
### Чого це ще не дає
|
||||
|
||||
|
|
|
|||
20
ROADMAP.md
20
ROADMAP.md
|
|
@ -143,19 +143,17 @@ JSONPath, `discard unchanged`) — на сервері при записі: ін
|
|||
|
||||
### Готова база шаблонів
|
||||
|
||||
Ти згадав, що візьмеш базу з **NOC project**. Це реалістично, але не безкоштовно:
|
||||
у NOC профілі — це Python-класи з методами розбору, а не декларативні YAML.
|
||||
Витягти з них набори OID і командні послідовності можна, але це окрема задача
|
||||
з непередбаченим обсягом.
|
||||
Формат `tpl.*` варто спроєктувати так, щоб він приймав **Zabbix-експорт**
|
||||
(YAML/JSON з items, discovery rules, prototypes, macros): його структура майже
|
||||
один-до-одного лягає на запропоновану схему, а публічних шаблонів там сотні.
|
||||
Це знімає потребу набивати базу вручну.
|
||||
|
||||
**Пропоную страхуватись:** формат `tpl.*` спроєктувати так, щоб він приймав і
|
||||
**Zabbix-експорт** (YAML/JSON з items, discovery rules, prototypes, macros) —
|
||||
його структура майже один-до-одного лягає на запропоновану схему, а публічних
|
||||
шаблонів там сотні. Тоді імпортер із NOC стає бажаним, а не критичним шляхом.
|
||||
Команди збору конфігу вже є власним каталогом — `db/profiles/catalog.json`,
|
||||
147 платформ. Шаблони опитування SNMP — окрема задача, і базу для них варто
|
||||
починати саме з імпортера, а не з ручного наповнення.
|
||||
|
||||
**Обсяг:** ~5–7 днів на схему + реконсиляцію + імпортер + UI редактора шаблонів.
|
||||
Найбільший ризик — саме імпорт із NOC; його варто оцінити окремо, побачивши
|
||||
формат, який ти маєш.
|
||||
**Обсяг:** ~5–7 днів на схему + реконсиляцію + імпортер + UI редактора
|
||||
шаблонів.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
# Імпорт профілів збору конфігу з NOC Project
|
||||
|
||||
`db/migrations/0014_ncm_profiles.sql` згенеровано цими скриптами з
|
||||
профілів [NOC Project](https://getnoc.com). Тримаємо їх у репозиторії,
|
||||
щоб імпорт можна було повторити на новішому дампі, а не розбирати
|
||||
тисячу рядків INSERT-ів вручну.
|
||||
|
||||
## Як повторити
|
||||
|
||||
```bash
|
||||
python extract_noc.py /шлях/до/profiles > noc_config.json
|
||||
python gen_profiles.py noc_config.json ../migrations/0014_ncm_profiles.sql
|
||||
```
|
||||
|
||||
`why_skipped.py` пояснює, чому конкретний профіль не дав команди.
|
||||
|
||||
## Як це працює
|
||||
|
||||
Файли розбираються модулем `ast` — **без виконання**. Це принципово:
|
||||
NOC-скрипт імпортує половину свого фреймворку, і спроба його імпортувати
|
||||
означала б або тягнути весь NOC, або підсовувати заглушки, які мовчки
|
||||
змінюють поведінку. AST бачить рівно те, що написано в тексті.
|
||||
|
||||
З кожного `get_config.py` витягується:
|
||||
|
||||
* виклики `self.cli("…")` — команди збору;
|
||||
* гілка `policy == "s"` — команда startup-конфігу;
|
||||
* `strip_first_lines(config, N)` — скільки службових рядків відкинути;
|
||||
* `@BaseScript.match(platform__regex=…)` — варіанти під конкретні моделі.
|
||||
|
||||
## Що звідки взято — це важливо розрізняти
|
||||
|
||||
**Команди збору — з NOC.** Головна цінність імпорту: сотні годин чужої
|
||||
роботи з реальним залізом.
|
||||
|
||||
**Промпти, вимкнення пейджера й шаблони маскування — НЕ з NOC.**
|
||||
У переданому дампі всі `__init__.py` порожні (359 із 364), а саме там
|
||||
NOC тримає `pattern_prompt` і `command_disable_pager`. Тому вони
|
||||
проставлені за родиною вендора з типових значень і **потребують
|
||||
перевірки на живому залізі** перед промисловим ужитком.
|
||||
|
||||
## Що не витягується
|
||||
|
||||
З 170 файлів команду дають 147. Решта:
|
||||
|
||||
* **21 — не використовують CLI взагалі.** Конфіг знімається через HTTP
|
||||
(`self.http.get(...)`) або збирається з шматків: камери Axis, Beward,
|
||||
Dahua, Hikvision, відеокодери Harmonic, MikroTik SwOS, TFortis, HP
|
||||
iLO2, OS/Linux. Для них потрібен окремий збирач по HTTP, а не CLI.
|
||||
* **2 — порожні заглушки в самому NOC**: `Alcatel/7324RU` і
|
||||
`Ruckus/SmartZone` мають клас `Script` без жодного методу.
|
||||
|
||||
Ці 23 не «загубились» — у них просто немає CLI-команди, яку можна взяти.
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Витяг команд збору конфігу з профілів NOC.
|
||||
|
||||
Файли розбираються модулем ast — без виконання. Це принципово: NOC-скрипт
|
||||
імпортує половину фреймворку, і спроба його імпортувати означала б або
|
||||
тягнути весь NOC, або підсовувати заглушки, які мовчки змінюють поведінку.
|
||||
AST бачить рівно те, що написано в тексті.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
ROOT = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".")
|
||||
|
||||
|
||||
def literal(node):
|
||||
"""Рядковий літерал або None. f-рядки й конкатенації свідомо не беремо."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
return None
|
||||
|
||||
|
||||
class Walker(ast.NodeVisitor):
|
||||
"""Збирає виклики self.cli(...) і strip_first_lines(...) у межах методу."""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = [] # (команда, під_умовою_startup)
|
||||
self.strip = None
|
||||
self.snmp = False
|
||||
self.dynamic = False # є cli() з нелітеральним аргументом
|
||||
self._startup_depth = 0
|
||||
|
||||
def visit_If(self, node):
|
||||
# Гілка policy == "s" — це startup-config.
|
||||
src = ast.unparse(node.test) if hasattr(ast, "unparse") else ""
|
||||
is_startup = '"s"' in src or "'s'" in src
|
||||
if is_startup:
|
||||
self._startup_depth += 1
|
||||
for n in node.body:
|
||||
self.visit(n)
|
||||
self._startup_depth -= 1
|
||||
for n in node.orelse:
|
||||
self.visit(n)
|
||||
else:
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node):
|
||||
fn = node.func
|
||||
name = getattr(fn, "attr", None) or getattr(fn, "id", None)
|
||||
|
||||
# cli_clean — той самий виклик під іншою назвою (Ericsson).
|
||||
if name in ("cli", "cli_clean") and node.args:
|
||||
cmd = literal(node.args[0])
|
||||
if cmd is None:
|
||||
self.dynamic = True
|
||||
else:
|
||||
self.cli.append((cmd, self._startup_depth > 0))
|
||||
elif name == "strip_first_lines" and len(node.args) >= 2:
|
||||
n = node.args[1]
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, int):
|
||||
self.strip = n.value
|
||||
elif name in ("get", "set", "getnext") and getattr(fn, "value", None) is not None:
|
||||
if getattr(fn.value, "attr", "") == "snmp":
|
||||
self.snmp = True
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def platform_filter(decorators):
|
||||
"""Регекс платформи з @BaseScript.match(platform__regex=...)."""
|
||||
for d in decorators:
|
||||
if not isinstance(d, ast.Call):
|
||||
continue
|
||||
for kw in d.keywords or []:
|
||||
if kw.arg and "platform" in kw.arg:
|
||||
v = literal(kw.value)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def parse_file(path):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except SyntaxError as e:
|
||||
return {"error": f"синтаксис: {e}"}
|
||||
|
||||
variants = []
|
||||
for cls in [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]:
|
||||
for fn in [n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]:
|
||||
if not fn.name.startswith("execute"):
|
||||
continue
|
||||
w = Walker()
|
||||
for stmt in fn.body:
|
||||
w.visit(stmt)
|
||||
if not w.cli and not w.snmp:
|
||||
continue
|
||||
variants.append({
|
||||
"method": fn.name,
|
||||
"platform_regex": platform_filter(fn.decorator_list),
|
||||
"running": [c for c, s in w.cli if not s],
|
||||
"startup": [c for c, s in w.cli if s],
|
||||
"strip_first_lines": w.strip,
|
||||
"uses_snmp": w.snmp,
|
||||
"dynamic_cli": w.dynamic,
|
||||
})
|
||||
return {"variants": variants}
|
||||
|
||||
|
||||
def main():
|
||||
files = sorted(ROOT.rglob("get_config.py"))
|
||||
out, stats = [], Counter()
|
||||
|
||||
for f in files:
|
||||
rel = f.relative_to(ROOT)
|
||||
parts = rel.parts
|
||||
vendor = parts[0]
|
||||
platform = ".".join(parts[1:-1]) if len(parts) > 2 else ""
|
||||
|
||||
res = parse_file(f)
|
||||
if "error" in res:
|
||||
stats["помилка розбору"] += 1
|
||||
continue
|
||||
|
||||
variants = res["variants"]
|
||||
if not variants:
|
||||
stats["без команд"] += 1
|
||||
continue
|
||||
|
||||
has_cmd = any(v["running"] or v["startup"] for v in variants)
|
||||
if not has_cmd:
|
||||
stats["лише snmp/динаміка"] += 1
|
||||
continue
|
||||
|
||||
stats["витягнуто"] += 1
|
||||
if len(variants) > 1:
|
||||
stats[" з них із варіантами платформ"] += 1
|
||||
if any(v["dynamic_cli"] for v in variants):
|
||||
stats[" з них із нелітеральними командами"] += 1
|
||||
|
||||
out.append({
|
||||
"vendor": vendor,
|
||||
"platform": platform,
|
||||
"source": str(rel).replace("\\", "/"),
|
||||
"variants": variants,
|
||||
})
|
||||
|
||||
print(f"файлів get_config.py: {len(files)}", file=sys.stderr)
|
||||
for k, v in stats.most_common():
|
||||
print(f" {k}: {v}", file=sys.stderr)
|
||||
|
||||
json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Генерує міграцію ncm.profiles із витягу NOC.
|
||||
|
||||
Що звідки береться — важливо розрізняти:
|
||||
|
||||
* КОМАНДИ збору конфігу — з NOC (файли get_config.py). Це головна
|
||||
цінність: сотні годин чужої роботи з реальним залізом.
|
||||
* ПРОМПТИ, вимкнення пейджера, шаблони маскування — НЕ з NOC.
|
||||
У переданому дампі всі __init__.py порожні (359 із 364), а саме там
|
||||
NOC тримає pattern_prompt і command_disable_pager. Тому вони
|
||||
проставляються за родиною вендора з типових значень і позначені в
|
||||
міграції як такі, що потребують перевірки на живому залізі.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
SRC = pathlib.Path(sys.argv[1])
|
||||
DST = pathlib.Path(sys.argv[2])
|
||||
|
||||
# Родини CLI. Ключ — префікс вендора; значення описує, як поводиться
|
||||
# консоль. Промпти — типові для родини, не витягнуті з NOC.
|
||||
FAMILIES = {
|
||||
"cisco": {
|
||||
"pager": "terminal length 0",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": True,
|
||||
"scrub": [r"^Building configuration", r"^Current configuration : \d+ bytes",
|
||||
r"^ntp clock-period"],
|
||||
"redact": [r"(password|secret) \d? ?\S+", r"snmp-server community \S+"],
|
||||
},
|
||||
"huawei": {
|
||||
"pager": "screen-length 0 temporary",
|
||||
"prompt": r"[\]>]\s*$",
|
||||
"enable": False,
|
||||
"scrub": [r"^!Last configuration was updated"],
|
||||
"redact": [r"(password|cipher) \S+", r"snmp-agent community \S+"],
|
||||
},
|
||||
"juniper": {
|
||||
"pager": "set cli screen-length 0",
|
||||
"prompt": r"[%>#]\s*$",
|
||||
"enable": False,
|
||||
"scrub": [r"^## Last commit:"],
|
||||
"redact": [r"encrypted-password \S+", r'"\$9\$\S+"'],
|
||||
},
|
||||
"mikrotik": {
|
||||
"pager": "",
|
||||
"prompt": r"\] > $",
|
||||
"enable": False,
|
||||
"scrub": [r"^# \w{3}/\d{2}/\d{4}"],
|
||||
"redact": [r"password=\S+"],
|
||||
},
|
||||
"eltex": {
|
||||
"pager": "terminal datadump",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": True,
|
||||
"scrub": [],
|
||||
"redact": [r"(password|encrypted) \S+", r"snmp-server community \S+"],
|
||||
},
|
||||
"dlink": {
|
||||
"pager": "disable clipaging",
|
||||
"prompt": r"[>#:]\s*$",
|
||||
"enable": False,
|
||||
"scrub": [r"^#-+", r"^# \w+ Configuration"],
|
||||
"redact": [r"password \S+", r"community \S+"],
|
||||
},
|
||||
"extreme": {
|
||||
"pager": "disable clipaging",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": False,
|
||||
"scrub": [],
|
||||
"redact": [r"password \S+"],
|
||||
},
|
||||
"hp": {
|
||||
"pager": "no page",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": True,
|
||||
"scrub": [],
|
||||
"redact": [r"password \S+", r"community \S+"],
|
||||
},
|
||||
"zyxel": {
|
||||
"pager": "",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": False,
|
||||
"scrub": [],
|
||||
"redact": [r"password \S+"],
|
||||
},
|
||||
"raisecom": {
|
||||
"pager": "terminal page-break disable",
|
||||
"prompt": r"[>#]\s*$",
|
||||
"enable": True,
|
||||
"scrub": [],
|
||||
"redact": [r"password \S+"],
|
||||
},
|
||||
}
|
||||
|
||||
# Кілька вендорів мають CLI, успадкований від відомої родини.
|
||||
ALIASES = {
|
||||
"bdcom": "cisco", "h3c": "huawei", "hpe": "hp", "aruba": "hp",
|
||||
"3com": "huawei", "alcatel": "cisco", "arista": "cisco",
|
||||
"brocade": "cisco", "force10": "cisco", "dcn": "cisco",
|
||||
"edgecore": "cisco", "qtech": "cisco", "snr": "cisco",
|
||||
"iskratel": "cisco", "maipu": "cisco", "orion": "cisco",
|
||||
"ruckus": "cisco", "supertel": "cisco", "tplink": "cisco",
|
||||
"upvel": "cisco", "vyatta": "juniper", "junipernetworks": "juniper",
|
||||
"nateks": "cisco", "polygon": "cisco", "rotek": "cisco",
|
||||
"snrs": "cisco", "zte": "huawei", "cdata": "huawei",
|
||||
}
|
||||
|
||||
# Команди, які не є збором конфігу: службові переходи в оболонку,
|
||||
# завершення сесії, зміна ширини терміналу. Тягнути їх у профіль
|
||||
# означало б виконувати на залізі те, що до конфігу не має стосунку.
|
||||
NOISE = {
|
||||
"exit", "quit", "sh", "shell", "end", "changeto system",
|
||||
"terminal width 200", "show context",
|
||||
# Слід чужої інсталяції в дампі NOC, а не команда пристрою.
|
||||
"python /app/zabbix/config_fetcher.py",
|
||||
}
|
||||
|
||||
|
||||
def slug(*parts):
|
||||
v = "-".join(p for p in parts if p)
|
||||
v = re.sub(r"[^a-zA-Z0-9]+", "-", v).strip("-").lower()
|
||||
return v[:60]
|
||||
|
||||
|
||||
def family_of(vendor):
|
||||
# Цифри лишаємо: без них «3Com» стає «com», а «H3C» — «hc», і жоден
|
||||
# із них не знаходить своєї родини.
|
||||
v = re.sub(r"[^a-z0-9]", "", vendor.lower())
|
||||
if v in FAMILIES:
|
||||
return FAMILIES[v], v
|
||||
if v in ALIASES:
|
||||
return FAMILIES[ALIASES[v]], ALIASES[v]
|
||||
return None, None
|
||||
|
||||
|
||||
def sql_str(v):
|
||||
return "'" + v.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def sql_json(obj):
|
||||
return sql_str(json.dumps(obj, ensure_ascii=False))
|
||||
|
||||
|
||||
def pick_commands(variants):
|
||||
"""Головна команда збору + попередні, без шуму й дублів."""
|
||||
running, startup, pre = [], [], []
|
||||
for v in variants:
|
||||
for c in v["running"]:
|
||||
c = c.strip().rstrip("\r")
|
||||
if not c or c in NOISE:
|
||||
continue
|
||||
# «undo terminal monitor», «no monitor», «terminal pager 0» —
|
||||
# це підготовка консолі, а не сам конфіг.
|
||||
if re.search(r"monitor|pager|screen-length|datadump|clipaging", c):
|
||||
if c not in pre:
|
||||
pre.append(c)
|
||||
elif c not in running:
|
||||
running.append(c)
|
||||
for c in v["startup"]:
|
||||
c = c.strip().rstrip("\r")
|
||||
if c and c not in NOISE and c not in startup:
|
||||
startup.append(c)
|
||||
return pre, running, startup
|
||||
|
||||
|
||||
def main():
|
||||
data = json.loads(SRC.read_text(encoding="utf-8"))
|
||||
rows, skipped, no_family = [], [], []
|
||||
|
||||
for d in data:
|
||||
pre, running, startup = pick_commands(d["variants"])
|
||||
if not running:
|
||||
skipped.append(f"{d['vendor']}.{d['platform']}")
|
||||
continue
|
||||
|
||||
fam, fam_key = family_of(d["vendor"])
|
||||
if fam is None:
|
||||
fam = {"pager": "", "prompt": r"[>#]\s*$", "enable": False,
|
||||
"scrub": [], "redact": [r"password \S+", r"community \S+"]}
|
||||
no_family.append(d["vendor"])
|
||||
|
||||
# Частина пристроїв «мережеві» лише зовні: конфіг знімається
|
||||
# шелом (cat/tail). Додавати їм «terminal datadump» означало б
|
||||
# відправити в bash команду мережевого CLI — вона просто впаде.
|
||||
main_cmd = running[0]
|
||||
shellish = bool(re.match(r"(cat|tail|less|more /|python|sh|/)", main_cmd))
|
||||
|
||||
commands = []
|
||||
if fam["pager"] and not shellish:
|
||||
commands.append(fam["pager"])
|
||||
commands.extend(pre)
|
||||
# Беремо ПЕРШУ команду як основну: у профілях із варіантами
|
||||
# платформ решта — це альтернативи для інших моделей, і виконувати
|
||||
# їх усі підряд означало б чотири рази знімати той самий конфіг.
|
||||
commands.append(main_cmd)
|
||||
|
||||
alts = running[1:] + startup
|
||||
name = f"{d['vendor']} {d['platform']}".strip()
|
||||
|
||||
rows.append({
|
||||
"key": slug(d["vendor"], d["platform"]),
|
||||
"name": name,
|
||||
"vendor": d["vendor"].lower(),
|
||||
"commands": commands,
|
||||
"prompt": fam["prompt"],
|
||||
"enable": fam["enable"],
|
||||
"scrub": fam["scrub"],
|
||||
"redact": fam["redact"],
|
||||
"alts": alts,
|
||||
"source": d["source"],
|
||||
"family": fam_key or "generic",
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: r["key"])
|
||||
|
||||
out = []
|
||||
out.append("-- =====================================================================")
|
||||
out.append("-- NetPulse :: 0014_ncm_profiles.sql")
|
||||
out.append("-- Профілі збору конфігу, імпортовані з NOC Project.")
|
||||
out.append("--")
|
||||
out.append("-- Команди взято з get_config.py відповідного профілю NOC — це")
|
||||
out.append("-- головна цінність імпорту: сотні годин чужої роботи з реальним")
|
||||
out.append("-- залізом.")
|
||||
out.append("--")
|
||||
out.append("-- Промпти, вимкнення пейджера й шаблони маскування NOC у переданому")
|
||||
out.append("-- дампі НЕ містив: усі __init__.py, де він їх тримає, порожні. Тому")
|
||||
out.append("-- вони проставлені за родиною вендора з типових значень і")
|
||||
out.append("-- потребують перевірки на живому залізі перед промисловим ужитком.")
|
||||
out.append("-- =====================================================================")
|
||||
out.append("")
|
||||
out.append(f"-- Профілів: {len(rows)}; вендорів: {len({r['vendor'] for r in rows})}")
|
||||
out.append("")
|
||||
|
||||
for r in rows:
|
||||
meta = {"noc_source": r["source"], "family": r["family"]}
|
||||
if r["alts"]:
|
||||
meta["alternatives"] = r["alts"]
|
||||
out.append(f"-- {r['source']}")
|
||||
out.append("INSERT INTO ncm.profiles")
|
||||
out.append(" (tenant_id, key, name, vendor, transport, commands, prompt_regex,")
|
||||
out.append(" enable_required, scrub_patterns, redact_patterns, is_builtin)")
|
||||
out.append("VALUES (NULL, {}, {}, {}, 'ssh', {}, {},".format(
|
||||
sql_str(r["key"]), sql_str(r["name"]), sql_str(r["vendor"]),
|
||||
sql_json(r["commands"]), sql_str(r["prompt"])))
|
||||
out.append(" {}, {}, {}, true)".format(
|
||||
"true" if r["enable"] else "false",
|
||||
sql_json(r["scrub"]), sql_json(r["redact"])))
|
||||
out.append("ON CONFLICT DO NOTHING;")
|
||||
out.append("")
|
||||
|
||||
DST.write_text("\n".join(out), encoding="utf-8")
|
||||
|
||||
print(f"згенеровано профілів: {len(rows)}", file=sys.stderr)
|
||||
print(f"вендорів: {len({r['vendor'] for r in rows})}", file=sys.stderr)
|
||||
fams = {}
|
||||
for r in rows:
|
||||
fams[r["family"]] = fams.get(r["family"], 0) + 1
|
||||
print("за родинами CLI:", file=sys.stderr)
|
||||
for k, v in sorted(fams.items(), key=lambda x: -x[1]):
|
||||
print(f" {k}: {v}", file=sys.stderr)
|
||||
if skipped:
|
||||
print(f"пропущено (без команди збору): {len(skipped)}", file=sys.stderr)
|
||||
print(" " + ", ".join(skipped[:10]), file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Чому конкретний get_config.py не дав команди — з цитатою з джерела."""
|
||||
import ast
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = pathlib.Path(sys.argv[1])
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||
from extract_noc import parse_file # noqa: E402
|
||||
|
||||
|
||||
def classify(path, res):
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
if "error" in res:
|
||||
return "не розібрався", res["error"]
|
||||
|
||||
variants = res["variants"]
|
||||
if not variants:
|
||||
# Метод execute* є, але без cli/snmp — або його немає взагалі.
|
||||
if "def execute" not in src:
|
||||
return "немає методу execute", ""
|
||||
return "execute без cli()", first_body(src)
|
||||
|
||||
if not any(v["running"] or v["startup"] for v in variants):
|
||||
if any(v["uses_snmp"] for v in variants):
|
||||
return "лише SNMP", ""
|
||||
if any(v["dynamic_cli"] for v in variants):
|
||||
return "команда збирається в рантаймі", dynamic_line(src)
|
||||
return "невідомо", ""
|
||||
return None, ""
|
||||
|
||||
|
||||
def first_body(src):
|
||||
m = re.search(r"def execute\w*\([^)]*\):\s*\n((?:\s{8}.*\n){1,6})", src)
|
||||
return (m.group(1).strip().splitlines()[0] if m else "")[:100]
|
||||
|
||||
|
||||
def dynamic_line(src):
|
||||
for line in src.splitlines():
|
||||
if ".cli(" in line and not re.search(r'\.cli\(\s*["\']', line):
|
||||
return line.strip()[:100]
|
||||
return ""
|
||||
|
||||
|
||||
def main():
|
||||
groups = {}
|
||||
for f in sorted(ROOT.rglob("get_config.py")):
|
||||
res = parse_file(f)
|
||||
reason, detail = classify(f, res)
|
||||
if reason is None:
|
||||
continue
|
||||
rel = str(f.relative_to(ROOT)).replace("\\", "/")
|
||||
groups.setdefault(reason, []).append((rel, detail))
|
||||
|
||||
total = sum(len(v) for v in groups.values())
|
||||
print(f"не витягнулось: {total}\n")
|
||||
for reason, items in sorted(groups.items(), key=lambda x: -len(x[1])):
|
||||
print(f"=== {reason}: {len(items)} ===")
|
||||
for rel, detail in items:
|
||||
print(f" {rel}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load diff
77
db/profiles/README.md
Normal file
77
db/profiles/README.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Каталог команд збору конфігу
|
||||
|
||||
`catalog.json` — джерело істини про те, як зняти конфіг із кожної
|
||||
підтримуваної платформи. 148 платформ, 67 вендорів.
|
||||
|
||||
Міграція `db/migrations/0014_ncm_profiles.sql` **породжується** з
|
||||
каталогу, а не правиться руками: два описи одного й того самого
|
||||
розійшлися б із першою ж правкою, і невідомо було б, який справжній.
|
||||
|
||||
```bash
|
||||
python build.py # перезібрати міграцію
|
||||
python build.py --check # звірити, чи вона актуальна
|
||||
```
|
||||
|
||||
## Будова
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"families": {
|
||||
"cisco": {
|
||||
"pager": "terminal length 0", // чим вимкнути посторінковий вивід
|
||||
"prompt": "[>#]\\s*$", // за чим ловити кінець виводу
|
||||
"enable_required": true, // чи потрібен привілейований режим
|
||||
"scrub": ["^Building configuration"], // вирізати з diff — шум, що змінюється щоразу
|
||||
"redact": ["(password|secret) \\S+"] // замаскувати перед записом у Git
|
||||
}
|
||||
},
|
||||
"vendor_family": { "bdcom": "cisco" }, // чий CLI успадкував вендор
|
||||
"profiles": [
|
||||
{
|
||||
"key": "cisco-ios",
|
||||
"name": "Cisco IOS",
|
||||
"vendor": "cisco",
|
||||
"family": "cisco",
|
||||
"config": "show running-config", // головна команда
|
||||
"startup": "show startup-config", // необовʼязково
|
||||
"prepare": ["undo terminal monitor"], // необовʼязково: підготовка консолі
|
||||
"strip_lines": 3, // скільки службових рядків відкинути
|
||||
"alternatives": ["show config current_config"] // для інших моделей родини
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Як додати платформу
|
||||
|
||||
1. Додай запис у `profiles`. Мінімум — `key`, `name`, `vendor`,
|
||||
`family`, `config`.
|
||||
2. Якщо CLI не схожий на жодну наявну родину — заведи нову у `families`.
|
||||
3. `python build.py`.
|
||||
4. Накоти міграцію (`ON CONFLICT DO NOTHING`, тож повторний накат
|
||||
безпечний).
|
||||
|
||||
## Родини
|
||||
|
||||
Родина описує поведінку консолі, а не вендора: `bdcom`, `arista`,
|
||||
`brocade` та ще з десяток говорять діалектом Cisco, `h3c` і `3com` —
|
||||
діалектом Huawei. Тримати це один раз на родину, а не копіювати в кожен
|
||||
профіль, — різниця між правкою в одному місці й правкою в сорока.
|
||||
|
||||
Родина `generic` — запасний варіант: промпт `[>#]\s*$`, без вимкнення
|
||||
пейджера, з обережним маскуванням паролів і community.
|
||||
|
||||
## Про промпти
|
||||
|
||||
`prompt` і `pager` задані за родиною й **перевіряються на живому
|
||||
залізі**. Одна родина покриває десятки платформ, і дрібні відхилення
|
||||
трапляються: прошивка може давати інший символ запрошення або не
|
||||
розуміти команду вимкнення пейджера. Команди збору конфігу натомість
|
||||
специфічні для платформи й узяті точно.
|
||||
|
||||
## Чому не в БД
|
||||
|
||||
Каталог — код, а не дані клієнта: він однаковий для всіх інсталяцій,
|
||||
має переглядатись у code review і їхати разом із релізом. Тенант може
|
||||
завести власний профіль (`ncm.profiles` із заповненим `tenant_id`) —
|
||||
вбудовані при цьому лишаються недоторканими.
|
||||
122
db/profiles/build.py
Normal file
122
db/profiles/build.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Збирає міграцію ncm.profiles із каталогу команд.
|
||||
|
||||
Каталог (`catalog.json`) — джерело істини. Міграція з нього
|
||||
породжується, а не правиться руками: інакше два описи одного й того
|
||||
самого розійшлися б із першою ж правкою, і невідомо було б, який із них
|
||||
справжній.
|
||||
|
||||
Використання:
|
||||
|
||||
python build.py # перезбирає ../migrations/0014_ncm_profiles.sql
|
||||
python build.py --check # лише звіряє, чи міграція актуальна
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
CATALOG = HERE / "catalog.json"
|
||||
MIGRATION = HERE.parent / "migrations" / "0014_ncm_profiles.sql"
|
||||
|
||||
|
||||
def sql_str(v: str) -> str:
|
||||
return "'" + v.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def sql_json(obj) -> str:
|
||||
return sql_str(json.dumps(obj, ensure_ascii=False))
|
||||
|
||||
|
||||
def build_commands(profile: dict, family: dict) -> list:
|
||||
"""Повна послідовність: підготовка консолі, потім сам конфіг."""
|
||||
cmd = profile["config"]
|
||||
|
||||
# Частина пристроїв «мережеві» лише зовні: конфіг знімається шелом.
|
||||
# Відправити в bash «terminal datadump» означало б гарантовану
|
||||
# помилку на кожному зборі.
|
||||
shellish = bool(re.match(r"(cat|tail|less|more /|sh\b|/)", cmd))
|
||||
|
||||
out = []
|
||||
if family.get("pager") and not shellish:
|
||||
out.append(family["pager"])
|
||||
out.extend(profile.get("prepare", []))
|
||||
out.append(cmd)
|
||||
return out
|
||||
|
||||
|
||||
def render(catalog: dict) -> str:
|
||||
families = catalog["families"]
|
||||
# Число рахуємо, а не пишемо руками: зашитий у текст лічильник
|
||||
# розходиться з дійсністю на першій же доданій платформі.
|
||||
n_platforms = len(catalog["profiles"])
|
||||
n_vendors = len({p["vendor"] for p in catalog["profiles"]})
|
||||
lines = [
|
||||
"-- =====================================================================",
|
||||
"-- NetPulse :: 0014_ncm_profiles.sql",
|
||||
f"-- Каталог команд збору конфігу: {n_platforms} платформ, {n_vendors} вендорів.",
|
||||
"--",
|
||||
"-- ФАЙЛ ЗГЕНЕРОВАНО. Джерело — db/profiles/catalog.json.",
|
||||
"-- Правити тут безглуздо: наступна збірка перезапише. Щоб додати",
|
||||
"-- платформу, відредагуй каталог і виконай db/profiles/build.py.",
|
||||
"--",
|
||||
"-- Промпти й команди вимкнення пейджера задані за родиною CLI й",
|
||||
"-- перевіряються на живому залізі перед промисловим ужитком: одна",
|
||||
"-- родина покриває десятки платформ, і дрібні відхилення трапляються.",
|
||||
"-- =====================================================================",
|
||||
"",
|
||||
]
|
||||
|
||||
for p in catalog["profiles"]:
|
||||
fam = families.get(p.get("family", "generic"), families["generic"])
|
||||
commands = build_commands(p, fam)
|
||||
|
||||
note = []
|
||||
if p.get("startup"):
|
||||
note.append(f"startup: {p['startup']}")
|
||||
if p.get("strip_lines"):
|
||||
note.append(f"службових рядків на початку: {p['strip_lines']}")
|
||||
if p.get("alternatives"):
|
||||
note.append("альтернативи: " + ", ".join(p["alternatives"]))
|
||||
if note:
|
||||
lines.append("-- " + "; ".join(note))
|
||||
|
||||
lines += [
|
||||
"INSERT INTO ncm.profiles",
|
||||
" (tenant_id, key, name, vendor, transport, commands, prompt_regex,",
|
||||
" enable_required, scrub_patterns, redact_patterns, is_builtin)",
|
||||
"VALUES (NULL, {}, {}, {}, 'ssh', {}, {},".format(
|
||||
sql_str(p["key"]), sql_str(p["name"]), sql_str(p["vendor"]),
|
||||
sql_json(commands), sql_str(fam["prompt"])),
|
||||
" {}, {}, {}, true)".format(
|
||||
"true" if fam.get("enable_required") else "false",
|
||||
sql_json(fam.get("scrub", [])), sql_json(fam.get("redact", []))),
|
||||
"ON CONFLICT DO NOTHING;",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
catalog = json.loads(CATALOG.read_text(encoding="utf-8"))
|
||||
sql = render(catalog)
|
||||
|
||||
if "--check" in sys.argv:
|
||||
current = MIGRATION.read_text(encoding="utf-8") if MIGRATION.exists() else ""
|
||||
if current != sql:
|
||||
print("міграція розійшлася з каталогом — виконай build.py", file=sys.stderr)
|
||||
return 1
|
||||
print("міграція актуальна")
|
||||
return 0
|
||||
|
||||
MIGRATION.write_text(sql, encoding="utf-8")
|
||||
vendors = len({p["vendor"] for p in catalog["profiles"]})
|
||||
print(f"{MIGRATION.name}: {len(catalog['profiles'])} платформ, {vendors} вендорів")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1338
db/profiles/catalog.json
Normal file
1338
db/profiles/catalog.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -504,15 +504,21 @@ SNMP-community, паролі SSH і Telnet живуть в `inv.credentials`,
|
|||
diff (`scrub_patterns`) і що замаскувати перед записом у Git
|
||||
(`redact_patterns`).
|
||||
|
||||
Вбудовано **148 профілів на 67 вендорів**, імпортованих із
|
||||
[NOC Project](https://getnoc.com) — Cisco, Huawei, Juniper, MikroTik,
|
||||
Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis, Qtech,
|
||||
ZTE, BDCOM та інші. Інструмент імпорту й межі його застосовності —
|
||||
`db/import/README.md`.
|
||||
Вбудовано **147 платформ на 67 вендорів** — Cisco, Huawei, Juniper,
|
||||
MikroTik, Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis,
|
||||
Qtech, ZTE, BDCOM та інші.
|
||||
|
||||
Команди взято з NOC. Промпти й вимкнення пейджера — ні: у дампі
|
||||
профілів NOC вони не містились, тому проставлені за родиною вендора з
|
||||
типових значень і потребують перевірки на живому залізі.
|
||||
Джерело істини — `db/profiles/catalog.json`; міграція з нього
|
||||
породжується збіркою. Додати платформу означає відредагувати каталог,
|
||||
а не писати SQL. Подробиці — `db/profiles/README.md`.
|
||||
|
||||
Промпти й команди вимкнення пейджера задані за родиною CLI (`cisco`,
|
||||
`huawei`, `juniper`, `mikrotik`, `eltex`, …) і перевіряються на живому
|
||||
залізі: одна родина покриває десятки платформ, і дрібні відхилення
|
||||
трапляються. Команди збору натомість специфічні для платформи.
|
||||
|
||||
Тенант може завести власний профіль (`ncm.profiles` із заповненим
|
||||
`tenant_id`) — вбудовані при цьому лишаються недоторканими.
|
||||
|
||||
**Виконувати ці профілі поки нікому.** Агентського модуля `ncm`
|
||||
(SSH/Telnet) ще немає — це Етап 7. Профілі лежать готовими даними, і
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue