Каталог команд збору конфігу як власні дані

Команди для зчитування конфігу тепер живуть у 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:
byrsapty 2026-08-24 02:46:23 +03:00
parent f162dcab19
commit ee3f653bd8
11 changed files with 1690 additions and 784 deletions

View file

@ -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` після збірки каже «міграція актуальна» — генерація
детермінована.
### Чого це ще не дає

View file

@ -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 — окрема задача, і базу для них варто
починати саме з імпортера, а не з ручного наповнення.
**Обсяг:** ~57 днів на схему + реконсиляцію + імпортер + UI редактора шаблонів.
Найбільший ризик — саме імпорт із NOC; його варто оцінити окремо, побачивши
формат, який ти маєш.
**Обсяг:** ~57 днів на схему + реконсиляцію + імпортер + UI редактора
шаблонів.
---

View file

@ -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-команди, яку можна взяти.

View file

@ -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()

View file

@ -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()

View file

@ -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
View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -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. Профілі лежать готовими даними, і