Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
497 lines
20 KiB
Go
497 lines
20 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"regexp"
|
||
"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
|
||
// Скільки хостів не вдалося розпізнати.
|
||
Failed int
|
||
// Скільки хостів відповіли іншим sysObjectID, ніж мали.
|
||
HardwareChanged int
|
||
// Остання помилка — щоб було що написати в журнал сервера, не
|
||
// ховаючи решту хостів за нею.
|
||
LastError error
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Помилка на одному хості не має ронити весь звіт.
|
||
//
|
||
// Досі роняла — і саме через це хост DGS-3420 місяцями стояв би
|
||
// нерозпізнаним: запит до перейменованої таблиці падав, звіт
|
||
// обривався, системна група вже була записана, а причина не
|
||
// лишалась ніде. Ззовні це виглядало як «система просто не
|
||
// розпізнала», без жодної підказки, що робити.
|
||
//
|
||
// Тепер кожен хост обробляється окремо, а причина відмови осідає
|
||
// в inv.devices.identify_error — там, де її шукатимуть.
|
||
for _, d := range devices {
|
||
if d.GetDeviceId() == "" {
|
||
// Знахідки сканування (без device_id) тут не обробляємо:
|
||
// заводити хост із них — окреме рішення, яке має приймати
|
||
// людина, а не побічний ефект обходу топології.
|
||
continue
|
||
}
|
||
|
||
if err := s.identifyOne(ctx, tenantID, d, rules, &stat); err != nil {
|
||
stat.Failed++
|
||
// Причину зберігаємо саме тут, а не в журналі сервера:
|
||
// журнал читає той, хто вже знає, що щось не так, а поле
|
||
// в картці бачить той, хто питає «чому порожньо».
|
||
if e := s.noteIdentifyError(ctx, tenantID, d.GetDeviceId(), err); e != nil {
|
||
stat.LastError = e
|
||
} else {
|
||
stat.LastError = err
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
return stat, nil
|
||
}
|
||
|
||
// identifyOne — весь шлях розпізнавання одного хоста.
|
||
func (s *Store) identifyOne(ctx context.Context, tenantID string,
|
||
d *npv1.DiscoveredDevice, rules []autoAssignRule, stat *AutoAssignStat) error {
|
||
|
||
changed, err := s.noteHardwareChange(ctx, tenantID, d)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if changed {
|
||
stat.HardwareChanged++
|
||
}
|
||
|
||
if err := s.saveSystemInfo(ctx, tenantID, d); err != nil {
|
||
return err
|
||
}
|
||
stat.Described++
|
||
|
||
// Заповнюємо «виробник», «тип», модель і версію.
|
||
//
|
||
// Поля самі собою нічого не збирають, але ними користується решта
|
||
// системи: фільтри, значки на мапі, звіти й запасний підбір профілю
|
||
// для хостів без SNMP. Уже задане не чіпаємо — людина могла
|
||
// написати точніше за наш довідник. Виняток один: заміна заліза,
|
||
// коли старе значення описує коробку, якої вже немає.
|
||
if err := s.fillFromOIDForce(ctx, tenantID, d.GetDeviceId(), changed); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Профіль збору закріплюємо за хостом, а не підбираємо щоразу
|
||
// наново: інакше в картці порожньо, і людина не бачить, чим її
|
||
// хост зніматимуть.
|
||
if err := s.pinAutoProfile(ctx, tenantID, d.GetDeviceId(), changed); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := s.clearIdentifyError(ctx, tenantID, d.GetDeviceId()); err != nil {
|
||
return err
|
||
}
|
||
|
||
tplID := matchAutoAssign(rules, d.GetSysObjectId())
|
||
if tplID == "" {
|
||
return nil
|
||
}
|
||
added, err := s.attachTemplate(ctx, tenantID, d.GetDeviceId(), tplID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if added {
|
||
stat.Assigned++
|
||
}
|
||
return 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
|
||
}
|
||
|
||
// fillFromOID проставляє виробника, тип, модель і версію ПЗ.
|
||
//
|
||
// Правило береться те саме, що підбирає профіль збору конфігу, і тим
|
||
// самим порядком: уточнення за описом перед загальним, довший префікс
|
||
// перед коротшим. Інакше хост міг би отримати тип від одного правила, а
|
||
// профіль від іншого — і пояснити людині, чому OLT підписаний
|
||
// комутатором, було б нічим.
|
||
//
|
||
// Модель і версію дістає Go, а не Postgres: потрібні групи захоплення.
|
||
// Зразки RE2 не мають відкату, тож правило з довідника не підвісить
|
||
// збір навіть якщо його напише людина.
|
||
//
|
||
// Уже задане не чіпаємо. `other` у типі й порожній рядок в решті — це
|
||
// «не задано»: типові значення колонок, яких людина не обирала.
|
||
func (s *Store) fillFromOIDForce(ctx context.Context, tenantID, deviceID string, force bool) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var (
|
||
descr string
|
||
vendor, kind *string
|
||
modelRe, versionRe *string
|
||
)
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT COALESCE(dev.sys_descr,''), r.vendor, r.kind::text,
|
||
r.model_regex, r.version_regex
|
||
FROM inv.ident_rules r, inv.devices dev
|
||
WHERE dev.id = $1 AND dev.tenant_id = $2
|
||
AND r.enabled
|
||
AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id)
|
||
AND (dev.sys_object_id = r.oid_prefix
|
||
OR dev.sys_object_id LIKE r.oid_prefix || '.%')
|
||
AND (r.descr_regex IS NULL
|
||
OR COALESCE(dev.sys_descr,'') ~ r.descr_regex)
|
||
ORDER BY r.tenant_id NULLS LAST,
|
||
(r.descr_regex IS NULL),
|
||
length(r.oid_prefix) DESC
|
||
LIMIT 1
|
||
`, deviceID, tenantID).Scan(&descr, &vendor, &kind, &modelRe, &versionRe)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
model := firstGroup(modelRe, descr)
|
||
version := firstGroup(versionRe, descr)
|
||
|
||
// $7 — «залізо замінили». Тоді старе значення описує коробку,
|
||
// якої вже немає, і берегти його — берегти неправду. У всіх
|
||
// інших випадках рука людини важить більше за довідник.
|
||
_, err = tx.Exec(ctx, `
|
||
UPDATE inv.devices SET
|
||
vendor = CASE WHEN ($7 OR COALESCE(vendor,'') = '') AND $3 <> ''
|
||
THEN $3 ELSE vendor END,
|
||
kind = CASE WHEN ($7 OR kind = 'other') AND $4 <> ''
|
||
THEN $4::inv.device_kind ELSE kind END,
|
||
model = CASE WHEN ($7 OR COALESCE(model,'') = '') AND $5 <> ''
|
||
THEN $5 ELSE model END,
|
||
os_version = CASE WHEN ($7 OR COALESCE(os_version,'') = '') AND $6 <> ''
|
||
THEN $6 ELSE os_version END,
|
||
identified_at = now(),
|
||
updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, deviceID, tenantID, deref(vendor), deref(kind), model, version, force)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func deref(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return *p
|
||
}
|
||
|
||
// firstGroup витягує першу групу захоплення. Порожньо, якщо зразка
|
||
// немає, він не збігся або взагалі не компілюється.
|
||
//
|
||
// Зіпсований зразок мовчки нічого не дає, а не валить розпізнавання:
|
||
// правила редагує людина, і помилка в одному рядку довідника не має
|
||
// зупиняти онбординг усіх інших хостів. Помітно її буде там, де й
|
||
// має бути — у порожньому полі моделі.
|
||
func firstGroup(pattern *string, text string) string {
|
||
if pattern == nil || *pattern == "" || text == "" {
|
||
return ""
|
||
}
|
||
re, err := regexp.Compile(*pattern)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
m := re.FindStringSubmatch(text)
|
||
if len(m) < 2 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(m[1])
|
||
}
|
||
|
||
// noteHardwareChange помічає, що в шафі стоїть інша коробка.
|
||
//
|
||
// Повертає true лише коли sysObjectID БУВ і став іншим. Перше
|
||
// розпізнавання зміною не рахується: воно не скасовує нічого, а
|
||
// заповнює порожнє.
|
||
//
|
||
// Навіщо окремо від решти. Заміна заліза знецінює все, що система про
|
||
// хост знала: модель, версію, тип, профіль збору конфігу. Мовчки
|
||
// підмінити ці поля — залишити людину з карткою, яка змінилась сама
|
||
// собою; не підмінити — залишити її з карткою, яка бреше. Тому мітка
|
||
// часу: поля оновлюються, і водночас видно, коли й чому.
|
||
func (s *Store) noteHardwareChange(ctx context.Context, tenantID string, d *npv1.DiscoveredDevice) (bool, error) {
|
||
oid := strings.TrimSpace(d.GetSysObjectId())
|
||
if oid == "" {
|
||
return false, nil
|
||
}
|
||
|
||
var changed bool
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
UPDATE inv.devices
|
||
SET hardware_changed_at = now(), updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
AND COALESCE(sys_object_id,'') NOT IN ('', $3)
|
||
RETURNING true
|
||
`, d.GetDeviceId(), tenantID, oid).Scan(&changed)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return false, nil
|
||
}
|
||
return changed, err
|
||
}
|
||
|
||
// pinAutoProfile закріплює за хостом підібраний профіль збору конфігу.
|
||
//
|
||
// Профіль і далі підбирається тими самими правилами — змінюється лише
|
||
// те, що результат тепер видно в картці й він не переграється щоразу
|
||
// наново. Обраний людиною не чіпається ніколи: прапорець profile_auto
|
||
// саме для того й заведений.
|
||
//
|
||
// Виняток — заміна заліза: автоматично підібраний профіль для коробки,
|
||
// якої вже немає, гарантовано хибний.
|
||
func (s *Store) pinAutoProfile(ctx context.Context, tenantID, deviceID string, hardwareChanged bool) error {
|
||
prof, err := s.ResolveProfile(ctx, tenantID, deviceID, nil)
|
||
if err != nil {
|
||
// Немає профілю — не привід валити розпізнавання: більшість
|
||
// решти роботи вже зроблена й корисна сама собою.
|
||
if errors.Is(err, ErrNoProfile) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
if prof.ID == "" {
|
||
return nil
|
||
}
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO ncm.device_policies (tenant_id, device_id, profile_id, profile_auto)
|
||
VALUES ($1, $2, $3, true)
|
||
ON CONFLICT (device_id) DO UPDATE
|
||
SET profile_id = EXCLUDED.profile_id, profile_auto = true
|
||
WHERE ncm.device_policies.profile_id IS NULL
|
||
OR (ncm.device_policies.profile_auto AND $4)
|
||
`, tenantID, deviceID, prof.ID, hardwareChanged)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// noteIdentifyError лишає слід невдачі в картці хоста.
|
||
func (s *Store) noteIdentifyError(ctx context.Context, tenantID, deviceID string, cause error) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices SET identify_error = $3, updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, deviceID, tenantID, cause.Error())
|
||
return err
|
||
})
|
||
}
|
||
|
||
func (s *Store) clearIdentifyError(ctx context.Context, tenantID, deviceID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices SET identify_error = NULL
|
||
WHERE id = $1 AND tenant_id = $2 AND identify_error IS NOT NULL
|
||
`, deviceID, tenantID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// Скільки початку конфігу дивитись у пошуках версії.
|
||
//
|
||
// Шапка з моделлю й прошивкою стоїть на самому початку — так роблять
|
||
// усі, бо це заголовок файлу. Ганяти зразок по тридцяти тисячах рядків
|
||
// заради рядка, який завжди в перших двадцяти, означало б платити за
|
||
// кожен зібраний конфіг рівно нічим не виправдану ціну.
|
||
const configHeadBytes = 4096
|
||
|
||
// fillFromConfig дістає версію, модель і серійний номер із тіла конфігу.
|
||
//
|
||
// Запасний шлях до того, що не приїхало по SNMP. Уже відоме не
|
||
// перезаписується: SNMP — джерело свіжіше, конфіг міг бути зібраний
|
||
// учора.
|
||
func (s *Store) fillFromConfig(ctx context.Context, tenantID, deviceID string, body []byte) error {
|
||
if len(body) == 0 {
|
||
return nil
|
||
}
|
||
head := body
|
||
if len(head) > configHeadBytes {
|
||
head = head[:configHeadBytes]
|
||
}
|
||
text := string(head)
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var modelRe, versionRe, serialRe *string
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT r.config_model_regex, r.config_version_regex, r.config_serial_regex
|
||
FROM inv.ident_rules r, inv.devices dev
|
||
WHERE dev.id = $1 AND dev.tenant_id = $2
|
||
AND r.enabled
|
||
AND (r.tenant_id IS NULL OR r.tenant_id = dev.tenant_id)
|
||
AND (dev.sys_object_id = r.oid_prefix
|
||
OR dev.sys_object_id LIKE r.oid_prefix || '.%')
|
||
AND (r.descr_regex IS NULL
|
||
OR COALESCE(dev.sys_descr,'') ~ r.descr_regex)
|
||
ORDER BY r.tenant_id NULLS LAST,
|
||
(r.descr_regex IS NULL),
|
||
length(r.oid_prefix) DESC
|
||
LIMIT 1
|
||
`, deviceID, tenantID).Scan(&modelRe, &versionRe, &serialRe)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
model := firstGroup(modelRe, text)
|
||
version := firstGroup(versionRe, text)
|
||
serial := firstGroup(serialRe, text)
|
||
if model == "" && version == "" && serial == "" {
|
||
return nil
|
||
}
|
||
|
||
_, err = tx.Exec(ctx, `
|
||
UPDATE inv.devices SET
|
||
model = CASE WHEN COALESCE(model,'') = '' AND $3 <> ''
|
||
THEN $3 ELSE model END,
|
||
os_version = CASE WHEN COALESCE(os_version,'') = '' AND $4 <> ''
|
||
THEN $4 ELSE os_version END,
|
||
serial_number = CASE WHEN COALESCE(serial_number,'') = '' AND $5 <> ''
|
||
THEN $5 ELSE serial_number END,
|
||
updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, deviceID, tenantID, model, version, serial)
|
||
return err
|
||
})
|
||
}
|