Автопризначення шаблонів за sysObjectID
Пристрій сам каже, що він таке, і шаблон чіпляється без натискань. Системна група знімається тією ж SNMP-сесією, що й обхід топології: три зайві PDU дешевші за окремий чек із власним розкладом. Збіг за префіксом на межі компонента: моделей у виробника тисячі, і повний збіг означав би рядок на кожну коробку. Довший префікс перемагає. Дванадцять вбудованих правил на основних виробників. Шаблони тільки додаються, ніколи не знімаються: автоматика знає модель пристрою, але не знає, чому цьому хосту дали ще один шаблон руками. DiscoveredDevice отримав device_id: зіставляти за адресою не можна — за одним NAT кілька хостів мають ту саму адресу опитування. Заразом дубль перевірки перестав давати «внутрішню помилку». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3124fe3163
commit
29f440d436
12 changed files with 461 additions and 9 deletions
68
HISTORY.md
68
HISTORY.md
|
|
@ -2698,3 +2698,71 @@ SQL_ASCII не кодування, а його відсутність: серв
|
|||
інтерфейс вкладка поруч із «Конфіги», обидві таблиці з живими
|
||||
даними, «нічого — саме це й проблема» на місці
|
||||
```
|
||||
|
||||
## Автопризначення шаблонів за sysObjectID
|
||||
|
||||
### Пристрій сам каже, що він таке
|
||||
|
||||
`1.3.6.1.2.1.1.2.0` повертає OID виробника й моделі. Далі це питання
|
||||
зіставлення, а не здогадок — і саме тут онбординг перестає бути ручною
|
||||
роботою: додав сотню комутаторів, вони самі отримали свої перевірки,
|
||||
графіки й тригери.
|
||||
|
||||
Системна група знімається **тією ж сесією**, що й обхід топології: три
|
||||
зайві PDU коштують незрівнянно менше, ніж власний чек із власним
|
||||
розкладом і власним підключенням.
|
||||
|
||||
### Збіг за префіксом, і чому саме так
|
||||
|
||||
Виробник кодує в sysObjectID і серію, і модель: у Cisco це
|
||||
`1.3.6.1.4.1.9.1.<модель>`, і моделей там тисячі. Вимагати повного збігу
|
||||
означало б заводити рядок на кожну коробку — тобто ту саму ручну роботу,
|
||||
від якої тікаємо.
|
||||
|
||||
Довший префікс перемагає: правило на конкретну серію точніше за загальне
|
||||
правило виробника. Збіг рахується на межі компонента — `1.3.6.1.4.1.9` не
|
||||
ловить `1.3.6.1.4.1.99`, інакше правило для Cisco накрило б чужого
|
||||
виробника з номером, що починається так само.
|
||||
|
||||
Дванадцять вбудованих правил: Net-SNMP, UCD, Windows, Cisco, Huawei,
|
||||
MikroTik, D-Link, Eltex, HP, Netgear, Dell, Juniper.
|
||||
|
||||
### Тільки додаємо
|
||||
|
||||
Шаблони автоматика лише чіпляє, ніколи не знімає. Вона знає модель
|
||||
пристрою, але не знає, чому саме цьому хосту дали ще один шаблон руками.
|
||||
|
||||
Порожнє поле в системній групі не стирає те, що знали: пристрій міг не
|
||||
віддати частину через ACL на своєму SNMP-агенті.
|
||||
|
||||
### Проto довелося розширити
|
||||
|
||||
`DiscoveredDevice` описував знахідку сканування й не мав `device_id`.
|
||||
Зіставляти за адресою не можна: за одним NAT цілком може стояти кілька
|
||||
хостів із тією самою адресою опитування — на стенді це буквально так.
|
||||
Додано поле 12, згенеровано buf.
|
||||
|
||||
### Перевірено наскрізно
|
||||
|
||||
Ізольований стек на власній UTF8-базі, щоб не чіпати дані стенда:
|
||||
|
||||
```
|
||||
зонд зареєстровано, план 1 задача (topology.discover)
|
||||
опитування net-snmp на 127.0.0.1
|
||||
системна група sysObjectID = 1.3.6.1.4.1.8072.3.2.10
|
||||
sysDescr = Linux new-ct 6.17.13-2-pve…
|
||||
зіставлення 1.3.6.1.4.1.8072 → шаблон snmp-generic
|
||||
сервер «шаблони призначено за sysObjectID: хостів 1, призначено 1»
|
||||
наслідок план зонда виріс 1 → 3 задачі: чек шаблону створився сам
|
||||
ідемпотентність 4 звіти автовиявлення — призначення рівно одне
|
||||
```
|
||||
|
||||
### Дорогою
|
||||
|
||||
Дубль перевірки (той самий тип і інтервал на одному хості) давав
|
||||
«внутрішню помилку» замість пояснення. Тепер 409 із текстом.
|
||||
|
||||
І втретє за сесію наступив на `pkill -f`: шаблон збігається з рядком
|
||||
власної ssh-сесії й тихо вбиває її. Правильна форма — `pkill -x` за
|
||||
іменем процесу, як і написано в коментарі до `np-restart.sh`, який я сам
|
||||
колись і писав.
|
||||
|
|
|
|||
11
ROADMAP.md
11
ROADMAP.md
|
|
@ -23,7 +23,7 @@ API віддає готове полотно з живими статусами,
|
|||
| Мапа, топологія, телеметрія | ✅ | ✅ |
|
||||
| Автовиявлення LLDP/CDP/ARP/FDB | ✅ | ✅ |
|
||||
| **Користувачі, ролі, вхід** | ✅ | ✅ |
|
||||
| **Шаблони опитування** | ✅ | ⚠️ є; немає автопризначення за sysObjectID, snmp.walk, тригерів у шаблоні |
|
||||
| **Шаблони опитування** | ✅ | ⚠️ є, з тригерами й автопризначенням; немає snmp.walk і прототипів |
|
||||
| **NCM (збір конфігів)** | ✅ | ⚠️ збір, розклад, Git, syslog і compliance є; немає відкату |
|
||||
| **Керування зондом із UI** | ✅ | ✅ |
|
||||
| **Алерти й сповіщення** | ✅ | ✅ |
|
||||
|
|
@ -109,9 +109,12 @@ POST /api/v1/roles кастомна роль із набором
|
|||
> **Додано 2026-08-24:** графіки описуються в шаблоні (шість видів,
|
||||
> жорсткі межі осей), картка хоста на вкладках без інтервалів.
|
||||
>
|
||||
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
|
||||
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
|
||||
> всередині шаблону.
|
||||
> **Додано 2026-08-25:** тригери описуються в шаблоні й розгортаються в
|
||||
> правила сповіщень; автопризначення за `sysObjectID` — пристрій сам
|
||||
> каже, що він таке, і шаблон чіпляється без жодного натискання.
|
||||
>
|
||||
> **Лишилось:** прототипи шаблонів і `snmp.walk` як тип елемента
|
||||
> (таблиці з динамічним індексом).
|
||||
|
||||
## Етап 6 (початковий план)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ type Result struct {
|
|||
// прив'язані до моменту часу так, як метрики.
|
||||
Neighbors []*npv1.NeighborRecord
|
||||
InterfaceRecords []*npv1.InterfaceRecord
|
||||
// Системна інформація про вже відомі хости: sysObjectID, sysDescr,
|
||||
// sysName. Їде тим самим звітом, бо збирається тією ж сесією.
|
||||
Devices []*npv1.DiscoveredDevice
|
||||
// Непрозоре навантаження, яке не є метрикою (напр. http.status → тіло відповіді).
|
||||
Payload []byte
|
||||
}
|
||||
|
|
|
|||
84
agent/internal/modules/topology/sysinfo.go
Normal file
84
agent/internal/modules/topology/sysinfo.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package topology
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// SNMPv2-MIB, system group. Три значення, які пристрій розповідає про
|
||||
// себе сам і які не змінюються від опитування до опитування.
|
||||
const (
|
||||
oidSysDescr = ".1.3.6.1.2.1.1.1.0"
|
||||
oidSysObjectID = ".1.3.6.1.2.1.1.2.0"
|
||||
oidSysName = ".1.3.6.1.2.1.1.5.0"
|
||||
)
|
||||
|
||||
// collectSystem знімає системну групу з уже відкритої сесії.
|
||||
//
|
||||
// Три OID разом із обходом топології, а не окремим чеком: сесія вже
|
||||
// відкрита, і три зайві PDU коштують незрівнянно менше, ніж власне
|
||||
// опитування з власним розкладом і власним підключенням.
|
||||
//
|
||||
// Помилка тут не зриває автовиявлення: пристрій, який не віддає system
|
||||
// group, — рідкість, але не привід втратити зібраних сусідів.
|
||||
func collectSystem(ctx context.Context, client *gosnmp.GoSNMP, deviceID, address string) *npv1.DiscoveredDevice {
|
||||
pkt, err := client.Get([]string{oidSysDescr, oidSysObjectID, oidSysName})
|
||||
if err != nil || pkt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := &npv1.DiscoveredDevice{
|
||||
DeviceId: deviceID,
|
||||
Address: address,
|
||||
SeenAt: timestamppb.Now(),
|
||||
}
|
||||
for _, v := range pkt.Variables {
|
||||
switch strings.TrimPrefix(v.Name, ".") {
|
||||
case strings.TrimPrefix(oidSysDescr, "."):
|
||||
out.SysDescr = snmpString(v)
|
||||
case strings.TrimPrefix(oidSysObjectID, "."):
|
||||
out.SysObjectId = normalizeOID(snmpString(v))
|
||||
case strings.TrimPrefix(oidSysName, "."):
|
||||
out.SysName = snmpString(v)
|
||||
}
|
||||
}
|
||||
if out.SysObjectId == "" && out.SysDescr == "" {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snmpString зводить значення до тексту.
|
||||
//
|
||||
// Тип залежить від того, що саме питали: sysDescr приходить октетним
|
||||
// рядком, sysObjectID — типом ObjectIdentifier, і go-snmp віддає їх
|
||||
// різними Go-типами.
|
||||
func snmpString(v gosnmp.SnmpPDU) string {
|
||||
switch x := v.Value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(x)
|
||||
case []byte:
|
||||
return strings.TrimSpace(string(x))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOID прибирає провідну крапку.
|
||||
//
|
||||
// Різні агенти віддають sysObjectID то з нею, то без, а на сервері за
|
||||
// цим рядком шукають збіг префікса — розбіжність у першому символі
|
||||
// зробила б збіг неможливим саме там, де все інше збігається.
|
||||
func normalizeOID(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, ".")
|
||||
// Деякі агенти пишуть "iso.3.6.1..." замість "1.3.6.1...".
|
||||
if rest, ok := strings.CutPrefix(s, "iso."); ok {
|
||||
s = "1." + rest
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
@ -105,6 +105,12 @@ func (m *Module) Run(ctx context.Context, task module.Task) (module.Result, erro
|
|||
errs []string
|
||||
)
|
||||
|
||||
// Системна група — найдешевше, що можна взяти з цієї сесії, і
|
||||
// єдине джерело для автопризначення шаблонів за sysObjectID.
|
||||
if d := collectSystem(ctx, client, task.DeviceID, task.Target.Address); d != nil {
|
||||
res.Devices = append(res.Devices, d)
|
||||
}
|
||||
|
||||
// Інвентар портів потрібен першим: LLDP оперує власною нумерацією
|
||||
// портів, і без ifName/ifDescr її нема на що відобразити.
|
||||
ifaces, err := collectInterfaces(ctx, client, task.DeviceID)
|
||||
|
|
|
|||
|
|
@ -154,13 +154,14 @@ func (s *Session) Credentials(deviceID string) []*npv1.Credential {
|
|||
// знімків, поки немає зв'язку, немає сенсу — наступний обхід віддасть
|
||||
// свіжіші дані.
|
||||
func (s *Session) ReportDiscovery(res module.Result) {
|
||||
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 {
|
||||
if len(res.Neighbors) == 0 && len(res.InterfaceRecords) == 0 && len(res.Devices) == 0 {
|
||||
return
|
||||
}
|
||||
rep := &npv1.DiscoveryReport{
|
||||
AgentId: s.cfg.AgentID,
|
||||
Neighbors: res.Neighbors,
|
||||
Interfaces: res.InterfaceRecords,
|
||||
Devices: res.Devices,
|
||||
Final: true,
|
||||
StartedAt: timestamppb.Now(),
|
||||
FinishedAt: timestamppb.Now(),
|
||||
|
|
|
|||
|
|
@ -347,8 +347,12 @@ type DiscoveredDevice struct {
|
|||
// Які транспорти відповіли під час сканування.
|
||||
ReachableVia []Transport `protobuf:"varint,9,rep,packed,name=reachable_via,json=reachableVia,proto3,enum=netpulse.v1.Transport" json:"reachable_via,omitempty"`
|
||||
// Здогад агента про тип пристрою — сервер може перевизначити.
|
||||
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
|
||||
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
|
||||
GuessedKind string `protobuf:"bytes,10,opt,name=guessed_kind,json=guessedKind,proto3" json:"guessed_kind,omitempty"`
|
||||
SeenAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=seen_at,json=seenAt,proto3" json:"seen_at,omitempty"`
|
||||
// Заповнене, якщо це не знахідка сканування, а системна інформація
|
||||
// про вже відомий хост. Адреси для зіставлення мало: за одним NAT
|
||||
// цілком може стояти кілька хостів з тією самою адресою опитування.
|
||||
DeviceId string `protobuf:"bytes,12,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -460,6 +464,13 @@ func (x *DiscoveredDevice) GetSeenAt() *timestamppb.Timestamp {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *DiscoveredDevice) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DiscoveryReport struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
|
||||
|
|
@ -694,7 +705,7 @@ const file_netpulse_v1_discovery_proto_rawDesc = "" +
|
|||
"\voper_status\x18\v \x01(\tR\n" +
|
||||
"operStatus\x12&\n" +
|
||||
"\x0fparent_if_index\x18\f \x01(\x03R\rparentIfIndex\x12!\n" +
|
||||
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\xf9\x02\n" +
|
||||
"\fip_addresses\x18\r \x03(\tR\vipAddresses\"\x96\x03\n" +
|
||||
"\x10DiscoveredDevice\x12\x18\n" +
|
||||
"\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" +
|
||||
"\bhostname\x18\x02 \x01(\tR\bhostname\x12\x10\n" +
|
||||
|
|
@ -707,7 +718,8 @@ const file_netpulse_v1_discovery_proto_rawDesc = "" +
|
|||
"\rreachable_via\x18\t \x03(\x0e2\x16.netpulse.v1.TransportR\freachableVia\x12!\n" +
|
||||
"\fguessed_kind\x18\n" +
|
||||
" \x01(\tR\vguessedKind\x123\n" +
|
||||
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\"\xc3\x03\n" +
|
||||
"\aseen_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\x06seenAt\x12\x1b\n" +
|
||||
"\tdevice_id\x18\f \x01(\tR\bdeviceId\"\xc3\x03\n" +
|
||||
"\x0fDiscoveryReport\x12\x19\n" +
|
||||
"\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x15\n" +
|
||||
"\x06run_id\x18\x02 \x01(\tR\x05runId\x129\n" +
|
||||
|
|
|
|||
|
|
@ -89,6 +89,10 @@ message DiscoveredDevice {
|
|||
// Здогад агента про тип пристрою — сервер може перевизначити.
|
||||
string guessed_kind = 10;
|
||||
google.protobuf.Timestamp seen_at = 11;
|
||||
// Заповнене, якщо це не знахідка сканування, а системна інформація
|
||||
// про вже відомий хост. Адреси для зіставлення мало: за одним NAT
|
||||
// цілком може стояти кілька хостів з тією самою адресою опитування.
|
||||
string device_id = 12;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -205,6 +205,16 @@ func (s *Service) ReportDiscovery(ctx context.Context, rep *npv1.DiscoveryReport
|
|||
|
||||
s.refreshInterfaceChecks(ctx, agent, rep)
|
||||
|
||||
// Системна інформація приїжджає тим самим звітом і дає найдешевший
|
||||
// онбординг з можливих: пристрій сам сказав, що він таке, і шаблон
|
||||
// причепився без жодного натискання.
|
||||
if info, err := s.store.ApplySystemInfo(ctx, agent.TenantID, rep.GetDevices()); err != nil {
|
||||
s.log.Error("системна інформація не застосувалась", "agent", agent.ID, "err", err)
|
||||
} else if info.Assigned > 0 {
|
||||
s.log.Info("шаблони призначено за sysObjectID",
|
||||
"agent", agent.ID, "хостів", info.Described, "призначено", info.Assigned)
|
||||
}
|
||||
|
||||
return &npv1.DiscoveryAck{
|
||||
Accepted: true,
|
||||
NeighborsResolved: uint32(st.NeighborsResolved),
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ func (s *Server) handleSetDeviceChecks(w http.ResponseWriter, r *http.Request, p
|
|||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||||
return
|
||||
}
|
||||
// Той самий тип перевірки з тим самим інтервалом на одному
|
||||
// хості — помилка людини, а не сервера. «Внутрішня помилка» тут
|
||||
// лишає гадати, що зламалось, замість прибрати дубль.
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "duplicate_check",
|
||||
"така перевірка з таким інтервалом на цьому хості вже є")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "збереження перевірок", err)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
173
server/internal/store/autoassign.go
Normal file
173
server/internal/store/autoassign.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||||
)
|
||||
|
||||
// AutoAssignStat — що дав один звіт із системною інформацією.
|
||||
type AutoAssignStat struct {
|
||||
// Скільки хостів оновили sysObjectID.
|
||||
Described int
|
||||
// Скільки хостів отримали новий шаблон.
|
||||
Assigned int
|
||||
}
|
||||
|
||||
// ApplySystemInfo зберігає системну групу й довішує шаблони за
|
||||
// sysObjectID.
|
||||
//
|
||||
// Пристрій сам каже, що він таке, і далі це питання зіставлення, а не
|
||||
// здогадок. Саме тут онбординг перестає бути ручною роботою: додав сотню
|
||||
// комутаторів — вони самі отримали свої перевірки, графіки й тригери.
|
||||
//
|
||||
// Шаблони тільки ДОдаються. Прибирати те, що людина причепила руками,
|
||||
// автоматика не має права: вона знає модель пристрою, але не знає, чому
|
||||
// саме цьому хосту дали ще один шаблон.
|
||||
func (s *Store) ApplySystemInfo(ctx context.Context, tenantID string, devices []*npv1.DiscoveredDevice) (AutoAssignStat, error) {
|
||||
var stat AutoAssignStat
|
||||
if len(devices) == 0 {
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
// Правила читаємо один раз на звіт: їх десятки, а хостів у звіті
|
||||
// може бути сотня.
|
||||
rules, err := s.autoAssignRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
|
||||
for _, d := range devices {
|
||||
if d.GetDeviceId() == "" {
|
||||
// Знахідки сканування (без device_id) тут не обробляємо:
|
||||
// заводити хост із них — окреме рішення, яке має приймати
|
||||
// людина, а не побічний ефект обходу топології.
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.saveSystemInfo(ctx, tenantID, d); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
stat.Described++
|
||||
|
||||
tplID := matchAutoAssign(rules, d.GetSysObjectId())
|
||||
if tplID == "" {
|
||||
continue
|
||||
}
|
||||
added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if added {
|
||||
stat.Assigned++
|
||||
}
|
||||
}
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
type autoAssignRule struct {
|
||||
prefix string
|
||||
templateID string
|
||||
}
|
||||
|
||||
func (s *Store) autoAssignRules(ctx context.Context, tenantID string) ([]autoAssignRule, error) {
|
||||
var out []autoAssignRule
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT oid_prefix, template_id::text
|
||||
FROM tpl.auto_assign
|
||||
WHERE enabled AND (tenant_id IS NULL OR tenant_id = $1)
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r autoAssignRule
|
||||
if err := rows.Scan(&r.prefix, &r.templateID); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Довший префікс попереду: правило на конкретну серію точніше за
|
||||
// загальне правило виробника й має вигравати, якщо задане.
|
||||
sort.Slice(out, func(i, j int) bool { return len(out[i].prefix) > len(out[j].prefix) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchAutoAssign шукає найточніше правило під цей OID.
|
||||
//
|
||||
// Збіг за префіксом на межі компонента: `1.3.6.1.4.1.9` не має ловити
|
||||
// `1.3.6.1.4.1.99`, інакше правило для Cisco накрило б чужого виробника
|
||||
// з номером, що починається так само.
|
||||
func matchAutoAssign(rules []autoAssignRule, oid string) string {
|
||||
oid = strings.TrimPrefix(strings.TrimSpace(oid), ".")
|
||||
if oid == "" {
|
||||
return ""
|
||||
}
|
||||
for _, r := range rules {
|
||||
p := strings.TrimPrefix(r.prefix, ".")
|
||||
if oid == p || strings.HasPrefix(oid, p+".") {
|
||||
return r.templateID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Store) saveSystemInfo(ctx context.Context, tenantID string, d *npv1.DiscoveredDevice) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// COALESCE на боці SQL: порожнє поле означає «агент не дізнався»,
|
||||
// а не «стерти те, що знали». Пристрій міг не віддати частину
|
||||
// системної групи через ACL на SNMP-агенті.
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE inv.devices SET
|
||||
sys_object_id = COALESCE(NULLIF($3,''), sys_object_id),
|
||||
sys_descr = COALESCE(NULLIF($4,''), sys_descr),
|
||||
system_name = COALESCE(NULLIF($5,''), system_name),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
`, d.GetDeviceId(), tenantID, d.GetSysObjectId(), d.GetSysDescr(), d.GetSysName())
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// attachTemplate чіпляє шаблон, якщо його там ще немає.
|
||||
//
|
||||
// Повертає, чи справді щось змінилось: реконсиляція чеків недешева, і
|
||||
// ганяти її на кожному обході топології заради нічого не варто.
|
||||
func (s *Store) attachTemplate(ctx context.Context, tenantID, deviceID, templateID string) (bool, error) {
|
||||
var added bool
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
|
||||
`, deviceID, templateID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
added = tag.RowsAffected() > 0
|
||||
return nil
|
||||
})
|
||||
if err != nil || !added {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := s.ReconcileDeviceTemplates(ctx, tenantID, deviceID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Тригери шаблону теж мають стати правилами — інакше хост збирав би
|
||||
// метрики й мовчав про проблеми.
|
||||
if err := s.ReconcileTemplateRules(ctx, tenantID, templateID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
80
server/migrations/0027_sysobjectid_autoassign.sql
Normal file
80
server/migrations/0027_sysobjectid_autoassign.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0027_sysobjectid_autoassign.sql
|
||||
-- Автопризначення шаблонів за sysObjectID.
|
||||
--
|
||||
-- Пристрій сам каже, що він таке: `1.3.6.1.2.1.1.2.0` повертає OID
|
||||
-- виробника й моделі. Далі це питання зіставлення, а не здогадок —
|
||||
-- і саме так онбординг перестає бути ручною роботою: додав сотню
|
||||
-- комутаторів, вони самі отримали свої перевірки, графіки й тригери.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE inv.devices
|
||||
ADD COLUMN sys_object_id text,
|
||||
ADD COLUMN sys_descr text;
|
||||
|
||||
CREATE INDEX devices_sysobjectid_idx ON inv.devices (sys_object_id)
|
||||
WHERE sys_object_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN inv.devices.sys_object_id IS
|
||||
'OID виробника з sysObjectID.0 — джерело автопризначення шаблонів';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Правила зіставлення
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Збіг за ПРЕФІКСОМ, а не за повним OID.
|
||||
--
|
||||
-- Виробник кодує в sysObjectID і серію, і модель: у Cisco це
|
||||
-- 1.3.6.1.4.1.9.1.<модель>, і моделей там тисячі. Вимагати повного збігу
|
||||
-- означало б заводити рядок на кожну коробку — тобто ту саму ручну
|
||||
-- роботу, від якої тікаємо.
|
||||
--
|
||||
-- Довший префікс перемагає: правило на 1.3.6.1.4.1.9.1.1745 точніше за
|
||||
-- загальне 1.3.6.1.4.1.9 і має виграти, якщо задане.
|
||||
CREATE TABLE tpl.auto_assign (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
-- NULL = вбудоване правило, спільне для всіх кабінетів.
|
||||
tenant_id uuid REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
oid_prefix text NOT NULL CHECK (oid_prefix ~ '^[0-9.]+$'),
|
||||
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
|
||||
name text NOT NULL DEFAULT '',
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, oid_prefix, template_id)
|
||||
);
|
||||
CREATE INDEX tpl_auto_assign_prefix_idx ON tpl.auto_assign (oid_prefix) WHERE enabled;
|
||||
|
||||
ALTER TABLE tpl.auto_assign ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.auto_assign FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- Та сама логіка, що для шаблонів: вбудовані видно всім, власні — своїм.
|
||||
CREATE POLICY auto_assign_visible ON tpl.auto_assign
|
||||
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Вбудовані правила
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Прив'язуємось до наявного загального SNMP-шаблону: він збирає те, що
|
||||
-- віддає будь-який пристрій з SNMP-агентом (uptime, навантаження,
|
||||
-- пам'ять). Точніші шаблони під конкретні серії — справа кабінету.
|
||||
INSERT INTO tpl.auto_assign (tenant_id, oid_prefix, template_id, name)
|
||||
SELECT NULL, v.prefix, t.id, v.label
|
||||
FROM tpl.templates t
|
||||
CROSS JOIN (VALUES
|
||||
('1.3.6.1.4.1.8072', 'Net-SNMP (Linux, BSD)'),
|
||||
('1.3.6.1.4.1.2021', 'UCD-SNMP (Linux)'),
|
||||
('1.3.6.1.4.1.311', 'Windows SNMP'),
|
||||
('1.3.6.1.4.1.9', 'Cisco'),
|
||||
('1.3.6.1.4.1.2011', 'Huawei'),
|
||||
('1.3.6.1.4.1.14988', 'MikroTik'),
|
||||
('1.3.6.1.4.1.171', 'D-Link'),
|
||||
('1.3.6.1.4.1.35265', 'Eltex'),
|
||||
('1.3.6.1.4.1.11', 'HP / HPE'),
|
||||
('1.3.6.1.4.1.4526', 'Netgear'),
|
||||
('1.3.6.1.4.1.674', 'Dell'),
|
||||
('1.3.6.1.4.1.2636', 'Juniper')
|
||||
) AS v(prefix, label)
|
||||
WHERE t.tenant_id IS NULL AND t.key = 'snmp-generic'
|
||||
ON CONFLICT DO NOTHING;
|
||||
Loading…
Add table
Reference in a new issue