Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
539 lines
18 KiB
Go
539 lines
18 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
var ErrGroupNotFound = errors.New("групу не знайдено")
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи пристроїв
|
||
// ---------------------------------------------------------------------
|
||
|
||
type DeviceGroup struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Color string `json:"color,omitempty"`
|
||
DeviceCount int `json:"device_count"`
|
||
}
|
||
|
||
func (s *Store) ListDeviceGroups(ctx context.Context, tenantID string) ([]DeviceGroup, error) {
|
||
var out []DeviceGroup
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT g.id::text, g.name, g.kind::text, COALESCE(g.color,''),
|
||
(SELECT count(*) FROM inv.device_group_members m
|
||
JOIN inv.devices d ON d.id = m.device_id AND d.deleted_at IS NULL
|
||
WHERE m.group_id = g.id)::int
|
||
FROM inv.device_groups g
|
||
WHERE g.tenant_id = $1
|
||
ORDER BY g.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var g DeviceGroup
|
||
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.Color, &g.DeviceCount); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, g)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) CreateDeviceGroup(ctx context.Context, tenantID, name, color string) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO inv.device_groups (tenant_id, name, kind, color)
|
||
VALUES ($1, $2, 'static', NULLIF($3,''))
|
||
RETURNING id::text
|
||
`, tenantID, name, color).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
func (s *Store) DeleteDeviceGroup(ctx context.Context, tenantID, groupID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx,
|
||
`DELETE FROM inv.device_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrGroupNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// SetDeviceGroups замінює членство пристрою цілком.
|
||
//
|
||
// Саме заміна, а не додавання: інтерфейс показує повний набір галочок,
|
||
// і зняту галочку треба вміти зняти. Часткові операції довелося б
|
||
// узгоджувати з тим, що бачить людина, і будь-яке розходження
|
||
// закінчувалося б непоміченим доступом.
|
||
func (s *Store) SetDeviceGroups(ctx context.Context, tenantID, deviceID string, groupIDs []string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var owned bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT EXISTS (SELECT 1 FROM inv.devices
|
||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
|
||
`, deviceID, tenantID).Scan(&owned); err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
return ErrNotFound
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.device_group_members m
|
||
USING inv.device_groups g
|
||
WHERE m.group_id = g.id AND m.device_id = $1 AND g.tenant_id = $2
|
||
`, deviceID, tenantID); err != nil {
|
||
return err
|
||
}
|
||
if len(groupIDs) == 0 {
|
||
return nil
|
||
}
|
||
// Групи звіряються з тенантом усередині запиту: інакше чужий id
|
||
// у списку тихо додав би пристрій у групу сусіда.
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO inv.device_group_members (group_id, device_id)
|
||
SELECT g.id, $1 FROM inv.device_groups g
|
||
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
|
||
ON CONFLICT DO NOTHING
|
||
`, deviceID, tenantID, groupIDs)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи користувачів і права на групи пристроїв
|
||
// ---------------------------------------------------------------------
|
||
|
||
type UserGroup struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
MemberIDs []string `json:"member_ids"`
|
||
Permissions map[string]string `json:"permissions"` // device_group_id → deny|read|write
|
||
}
|
||
|
||
func (s *Store) ListUserGroups(ctx context.Context, tenantID string) ([]UserGroup, error) {
|
||
var out []UserGroup
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT g.id::text, g.name, COALESCE(g.description,''),
|
||
COALESCE(array_agg(DISTINCT m.user_id::text)
|
||
FILTER (WHERE m.user_id IS NOT NULL), '{}')
|
||
FROM core.user_groups g
|
||
LEFT JOIN core.user_group_members m ON m.group_id = g.id
|
||
WHERE g.tenant_id = $1
|
||
GROUP BY g.id, g.name, g.description
|
||
ORDER BY g.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
byID := map[string]int{}
|
||
for rows.Next() {
|
||
var g UserGroup
|
||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberIDs); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
g.Permissions = map[string]string{}
|
||
byID[g.ID] = len(out)
|
||
out = append(out, g)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
|
||
perms, err := tx.Query(ctx, `
|
||
SELECT p.user_group_id::text, p.device_group_id::text, p.level::text
|
||
FROM core.group_permissions p
|
||
JOIN core.user_groups g ON g.id = p.user_group_id
|
||
WHERE g.tenant_id = $1
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer perms.Close()
|
||
for perms.Next() {
|
||
var ug, dg, lvl string
|
||
if err := perms.Scan(&ug, &dg, &lvl); err != nil {
|
||
return err
|
||
}
|
||
if i, ok := byID[ug]; ok {
|
||
out[i].Permissions[dg] = lvl
|
||
}
|
||
}
|
||
return perms.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) CreateUserGroup(ctx context.Context, tenantID, name, description string) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO core.user_groups (tenant_id, name, description)
|
||
VALUES ($1, $2, NULLIF($3,''))
|
||
RETURNING id::text
|
||
`, tenantID, name, description).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
func (s *Store) DeleteUserGroup(ctx context.Context, tenantID, groupID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx,
|
||
`DELETE FROM core.user_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrGroupNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// SetUserGroupMembers і SetUserGroupPermissions — теж повна заміна, з
|
||
// тієї самої причини, що й у пристроїв.
|
||
func (s *Store) SetUserGroupMembers(ctx context.Context, tenantID, groupID string, userIDs []string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx,
|
||
`DELETE FROM core.user_group_members WHERE group_id = $1`, groupID); err != nil {
|
||
return err
|
||
}
|
||
if len(userIDs) == 0 {
|
||
return nil
|
||
}
|
||
// Лише ті, хто справді є в цьому тенанті: додати чужого
|
||
// користувача в свою групу означало б видати йому доступ.
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO core.user_group_members (group_id, user_id)
|
||
SELECT $1, m.user_id
|
||
FROM core.memberships m
|
||
WHERE m.tenant_id = $2 AND m.user_id = ANY($3::uuid[])
|
||
ON CONFLICT DO NOTHING
|
||
`, groupID, tenantID, userIDs)
|
||
return err
|
||
})
|
||
}
|
||
|
||
func (s *Store) SetUserGroupPermissions(ctx context.Context, tenantID, groupID string, perms map[string]string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx,
|
||
`DELETE FROM core.group_permissions WHERE user_group_id = $1`, groupID); err != nil {
|
||
return err
|
||
}
|
||
for dg, lvl := range perms {
|
||
switch lvl {
|
||
case "deny", "read", "write":
|
||
default:
|
||
return fmt.Errorf("невідомий рівень доступу %q", lvl)
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO core.group_permissions (user_group_id, device_group_id, level)
|
||
SELECT $1, g.id, $3::core.access_level
|
||
FROM inv.device_groups g
|
||
WHERE g.id = $2 AND g.tenant_id = $4
|
||
`, groupID, dg, lvl, tenantID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func assertUserGroup(ctx context.Context, tx pgx.Tx, tenantID, groupID string) error {
|
||
var ok bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT EXISTS (SELECT 1 FROM core.user_groups WHERE id = $1 AND tenant_id = $2)
|
||
`, groupID, tenantID).Scan(&ok); err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return ErrGroupNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Обмеження видимості
|
||
// ---------------------------------------------------------------------
|
||
|
||
// Scope — що саме цій людині видно.
|
||
//
|
||
// Обчислюється раз на запит і далі йде параметром у вибірки. Питати
|
||
// БД про кожен пристрій окремо означало б перетворити список на N
|
||
// запитів рівно тоді, коли пристроїв багато.
|
||
type Scope struct {
|
||
// Unrestricted — людина в жодній групі користувачів, тож групами не
|
||
// обмежена; доступ визначає роль. Див. коментар у міграції 0013.
|
||
Unrestricted bool
|
||
Readable []string
|
||
Writable []string
|
||
}
|
||
|
||
func (s Scope) CanWrite(deviceID string) bool {
|
||
if s.Unrestricted {
|
||
return true
|
||
}
|
||
for _, id := range s.Writable {
|
||
if id == deviceID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s Scope) CanRead(deviceID string) bool {
|
||
if s.Unrestricted {
|
||
return true
|
||
}
|
||
for _, id := range s.Readable {
|
||
if id == deviceID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// LoadScope рахує доступні пристрої одним запитом.
|
||
func (s *Store) LoadScope(ctx context.Context, tenantID, userID string) (Scope, error) {
|
||
sc := Scope{Unrestricted: true}
|
||
if userID == "" {
|
||
// Машинний токен групами не обмежений: у нього немає членства,
|
||
// а його межі задає власний scopes.
|
||
return sc, nil
|
||
}
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var inGroups bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM core.user_group_members m
|
||
JOIN core.user_groups g ON g.id = m.group_id
|
||
WHERE m.user_id = $1 AND g.tenant_id = $2)
|
||
`, userID, tenantID).Scan(&inGroups); err != nil {
|
||
return err
|
||
}
|
||
if !inGroups {
|
||
return nil
|
||
}
|
||
sc.Unrestricted = false
|
||
|
||
for _, lvl := range []string{"read", "write"} {
|
||
rows, err := tx.Query(ctx,
|
||
`SELECT device_id::text FROM core.accessible_devices($1, $2, $3::core.access_level)`,
|
||
userID, tenantID, lvl)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var ids []string
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
if lvl == "read" {
|
||
sc.Readable = ids
|
||
} else {
|
||
sc.Writable = ids
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
return sc, err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Хости
|
||
// ---------------------------------------------------------------------
|
||
|
||
type DeviceInput struct {
|
||
Name string
|
||
Address string
|
||
Kind string
|
||
Vendor string
|
||
AgentID string
|
||
SiteID string
|
||
Enabled bool
|
||
GroupIDs []string
|
||
Notes string
|
||
}
|
||
|
||
func (s *Store) CreateDevice(ctx context.Context, tenantID string, in DeviceInput) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO inv.devices
|
||
(tenant_id, name, address, kind, vendor, agent_id, site_id, enabled, notes, source)
|
||
VALUES ($1, $2, NULLIF($3,'')::inet, $4::inv.device_kind, NULLIF($5,''),
|
||
$6, $7, $8, NULLIF($9,''), 'manual')
|
||
RETURNING id::text
|
||
`, tenantID, in.Name, in.Address, in.Kind, in.Vendor,
|
||
nullUUID(in.AgentID), nullUUID(in.SiteID), in.Enabled, in.Notes).Scan(&id); err != nil {
|
||
return fmt.Errorf("створення хоста: %w", err)
|
||
}
|
||
if len(in.GroupIDs) == 0 {
|
||
return nil
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO inv.device_group_members (group_id, device_id)
|
||
SELECT g.id, $1 FROM inv.device_groups g
|
||
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
|
||
ON CONFLICT DO NOTHING
|
||
`, id, tenantID, in.GroupIDs)
|
||
return err
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in DeviceInput) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET name = $3, address = NULLIF($4,'')::inet, kind = $5::inv.device_kind,
|
||
vendor = NULLIF($6,''), agent_id = $7, enabled = $8,
|
||
notes = NULLIF($9,''), updated_at = now()
|
||
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
|
||
`, tenantID, deviceID, in.Name, in.Address, in.Kind, in.Vendor,
|
||
nullUUID(in.AgentID), in.Enabled, in.Notes)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DeleteDevice — АРХІВНЕ видалення: хост зникає з інтерфейсу, зібране
|
||
// лишається в базі.
|
||
//
|
||
// Це одна з двох дій, а не «видалення взагалі». Друга — PurgeDevices
|
||
// (devices_purge.go): вона стирає хост разом із телеметрією, архівом
|
||
// конфігів і гілкою в Git. Обидві законні, різниця незворотна, тому
|
||
// обирає її людина у вікні підтвердження, а не код тут.
|
||
//
|
||
// Що лишається після цієї: телеметрія, версії конфігів, історія
|
||
// алертів, вузли на мапах. Що припиняється: опитування, збір конфігів,
|
||
// нові алерти.
|
||
//
|
||
// Ця ручка лишається архівною й тоді, коли її смикають з API напряму.
|
||
// Незворотну дію не роблять параметром запиту, який легко не помітити:
|
||
// повне видалення йде окремим шляхом (POST /devices/bulk-delete з
|
||
// mode=purge), де перед ним обов'язково стоїть перелік із числами.
|
||
func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE inv.devices
|
||
SET deleted_at = now(), enabled = false, archived_enabled = enabled
|
||
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
|
||
// Чеки вимикаємо разом із хостом.
|
||
//
|
||
// План їх і так не бере (BuildPlan фільтрує deleted_at), тож
|
||
// на опитування це не впливає. Впливає на все інше, що дивиться
|
||
// в core.checks: увімкнений чек, який ніколи не виконується,
|
||
// виглядає як затор — і сторінка «Черги» чесно показує його
|
||
// червоним рівно доти, доки на неї не перестануть дивитись.
|
||
//
|
||
// Не видаляємо, а вимикаємо: хост відновлюють разом із його
|
||
// налаштуваннями, а відновити те, чого немає, нема з чого.
|
||
//
|
||
// archived_off позначає, що чек вимкнула саме ця дія, а не
|
||
// людина. Без позначки повернення хоста мусило б або вмикати
|
||
// все підряд — разом із тим, що тримали вимкненим свідомо, —
|
||
// або не вмикати нічого, і тоді хост повертався б у переліки
|
||
// мовчазним. Умова `AND enabled` тут і є те, що робить
|
||
// позначку правдивою: вимкнене до архівування нею не
|
||
// позначається.
|
||
if _, err := tx.Exec(ctx, `
|
||
UPDATE core.checks
|
||
SET enabled = false, archived_off = true, updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = $2 AND enabled
|
||
`, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DeviceGroupsOf — у яких групах пристрій. Потрібне формі редагування.
|
||
func (s *Store) DeviceGroupsOf(ctx context.Context, tenantID string) (map[string][]string, error) {
|
||
out := map[string][]string{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT m.device_id::text, m.group_id::text
|
||
FROM inv.device_group_members m
|
||
JOIN inv.device_groups g ON g.id = m.group_id
|
||
WHERE g.tenant_id = $1
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var d, g string
|
||
if err := rows.Scan(&d, &g); err != nil {
|
||
return err
|
||
}
|
||
out[d] = append(out[d], g)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// nonNilIDs віддає порожній масив замість nil.
|
||
//
|
||
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не
|
||
// «жоден», а NULL, тобто рядок не проходить умову, але й помилки немає.
|
||
// Такий фільтр мовчки ховав би всі пристрої.
|
||
func nonNilIDs(ids []string) []string {
|
||
if ids == nil {
|
||
return []string{}
|
||
}
|
||
return ids
|
||
}
|