Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть у шаблонах, і два джерела правди про одне число розійшлися б саме тоді, коли треба швидко зрозуміти, чому хост опитується не так, як написано. Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек. Групи, шаблони й доступи обираються пошуком із чипами: список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі осей, перелік ключів метрик замість посилань на елементи — графік має право показувати й те, що прийшло з іншого шаблону. Графік без жодного знайденого ряду не показується: порожня рамка з підписом — це обіцянка даних, яких немає. Знайдено живим прогоном і виправлено: обробник виділення на полотні створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав головний потік, і мертвими ставали всі сторінки, не лише мапа. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
559 lines
19 KiB
Go
559 lines
19 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
)
|
||
|
||
var ErrCheckNotFound = errors.New("перевірку не знайдено")
|
||
|
||
// CheckType — що взагалі вміє система опитувати.
|
||
//
|
||
// Перелік читається з БД, а не зашитий у фронтенд: плагіни додають свої
|
||
// типи міграцією, і UI має підхоплювати їх без перезбирання.
|
||
type CheckType struct {
|
||
Key string `json:"key"`
|
||
PluginKey string `json:"plugin_key"`
|
||
Name string `json:"name"`
|
||
ParamsSchema json.RawMessage `json:"params_schema"`
|
||
Metrics json.RawMessage `json:"metrics"`
|
||
/** Чи ввімкнений плагін у цього тенанта. */
|
||
Available bool `json:"available"`
|
||
}
|
||
|
||
// Check — одна перевірка на хості.
|
||
type Check struct {
|
||
ID string `json:"id"`
|
||
DeviceID string `json:"device_id"`
|
||
CheckType string `json:"check_type"`
|
||
TypeName string `json:"type_name,omitempty"`
|
||
Params json.RawMessage `json:"params"`
|
||
IntervalSec int `json:"interval_sec"`
|
||
TimeoutMs int `json:"timeout_ms"`
|
||
Retries int `json:"retries"`
|
||
Enabled bool `json:"enabled"`
|
||
LastRunAt *string `json:"last_run_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
}
|
||
|
||
// ListCheckTypes віддає доступні типи перевірок.
|
||
func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckType, error) {
|
||
var out []CheckType
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT ct.key, ct.plugin_key::text, ct.name,
|
||
ct.params_schema::text, ct.metrics::text,
|
||
-- Плагін доступний, якщо його явно ввімкнули цьому
|
||
-- тенанту або він базовий: вимагати «встановлення»
|
||
-- для пінга означало б зустрічати кожного клієнта
|
||
-- порожнім списком перевірок.
|
||
(p.is_core OR COALESCE(pi.enabled, false))
|
||
FROM core.check_types ct
|
||
JOIN core.plugins p ON p.key = ct.plugin_key
|
||
LEFT JOIN core.plugin_installs pi
|
||
ON pi.plugin_key = ct.plugin_key AND pi.tenant_id = $1
|
||
ORDER BY ct.plugin_key, ct.key
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c CheckType
|
||
var schema, metrics string
|
||
if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics,
|
||
&c.Available); err != nil {
|
||
return err
|
||
}
|
||
c.ParamsSchema = json.RawMessage(schema)
|
||
c.Metrics = json.RawMessage(metrics)
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// ListDeviceChecks — перевірки одного хоста.
|
||
func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string) ([]Check, error) {
|
||
var out []Check
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, c.device_id::text, c.check_type, COALESCE(ct.name,''),
|
||
c.params::text, c.interval_sec, c.timeout_ms, c.retries, c.enabled,
|
||
to_char(c.last_run_at, 'YYYY-MM-DD"T"HH24:MI:SSOF'),
|
||
COALESCE(c.last_error,'')
|
||
FROM core.checks c
|
||
LEFT JOIN core.check_types ct ON ct.key = c.check_type
|
||
WHERE c.tenant_id = $1 AND c.device_id = $2
|
||
AND c.template_id IS NULL
|
||
ORDER BY c.check_type
|
||
`, tenantID, deviceID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c Check
|
||
var params string
|
||
if err := rows.Scan(&c.ID, &c.DeviceID, &c.CheckType, &c.TypeName, ¶ms,
|
||
&c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled,
|
||
&c.LastRunAt, &c.LastError); err != nil {
|
||
return err
|
||
}
|
||
c.Params = json.RawMessage(params)
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// CheckInput — те, що приходить із форми. Порожній ID означає нову
|
||
// перевірку.
|
||
type CheckInput struct {
|
||
ID string
|
||
CheckType string
|
||
Params string
|
||
IntervalSec int
|
||
TimeoutMs int
|
||
Retries int
|
||
Enabled bool
|
||
}
|
||
|
||
// SetDeviceChecks замінює набір перевірок хоста цілком.
|
||
//
|
||
// Заміна, а не додавання: форма показує повний список, і прибраний
|
||
// рядок треба вміти прибрати.
|
||
//
|
||
// Наявні перевірки оновлюються за своїм id, а не перестворюються.
|
||
// Причина не в економії: унікальний індекс включає md5(params), тому
|
||
// «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні
|
||
// параметрів створило б ДРУГУ перевірку того самого типу. Плюс
|
||
// перестворення скидає next_run_at і збиває рівномірність опитування
|
||
// по всьому парку.
|
||
func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, in []CheckInput) 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
|
||
}
|
||
|
||
keep := make([]string, 0, len(in))
|
||
for _, c := range in {
|
||
if c.IntervalSec < 5 {
|
||
c.IntervalSec = 60
|
||
}
|
||
if c.IntervalSec > 86400 {
|
||
c.IntervalSec = 86400
|
||
}
|
||
if c.TimeoutMs <= 0 {
|
||
c.TimeoutMs = 3000
|
||
}
|
||
if c.Retries < 0 {
|
||
c.Retries = 0
|
||
}
|
||
params := c.Params
|
||
if params == "" {
|
||
params = "{}"
|
||
}
|
||
|
||
var id string
|
||
if c.ID != "" {
|
||
err := tx.QueryRow(ctx, `
|
||
UPDATE core.checks
|
||
SET params = $4::jsonb, interval_sec = $5, timeout_ms = $6,
|
||
retries = $7, enabled = $8, updated_at = now()
|
||
WHERE tenant_id = $1 AND device_id = $2 AND id = $3
|
||
RETURNING id::text
|
||
`, tenantID, deviceID, c.ID, params, c.IntervalSec,
|
||
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id)
|
||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
|
||
}
|
||
}
|
||
|
||
// Рядка з таким id немає (або його не передали) — створюємо.
|
||
if id == "" {
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.checks
|
||
(tenant_id, device_id, check_type, params, interval_sec,
|
||
timeout_ms, retries, enabled)
|
||
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
|
||
RETURNING id::text
|
||
`, tenantID, deviceID, c.CheckType, params, c.IntervalSec,
|
||
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil {
|
||
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
|
||
}
|
||
}
|
||
keep = append(keep, id)
|
||
}
|
||
|
||
// Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо:
|
||
// їх заводить автовиявлення, і форма хоста про них не знає.
|
||
// Породжені шаблоном — так само: ними володіє реконсиляція, і
|
||
// видалення тут означало б, що вони зникають на кожне
|
||
// збереження форми, щоб за секунду з'явитися знову.
|
||
_, err := tx.Exec(ctx, `
|
||
DELETE FROM core.checks
|
||
WHERE tenant_id = $1 AND device_id = $2
|
||
AND interface_id IS NULL
|
||
AND template_id IS NULL
|
||
AND NOT (id = ANY($3::uuid[]))
|
||
`, tenantID, deviceID, nonNilIDs(keep))
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Креденшели хоста
|
||
// ---------------------------------------------------------------------
|
||
|
||
type Credential struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Proto string `json:"proto"`
|
||
Username string `json:"username,omitempty"`
|
||
Port int `json:"port,omitempty"`
|
||
IsDefault bool `json:"is_default"`
|
||
}
|
||
|
||
func (s *Store) ListCredentials(ctx context.Context, tenantID string) ([]Credential, error) {
|
||
var out []Credential
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, name, proto::text, COALESCE(username,''),
|
||
COALESCE(port,0), is_default
|
||
FROM inv.credentials
|
||
WHERE tenant_id = $1
|
||
ORDER BY proto, name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c Credential
|
||
if err := rows.Scan(&c.ID, &c.Name, &c.Proto, &c.Username,
|
||
&c.Port, &c.IsDefault); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// CreateCredential зберігає доступ із зашифрованим секретом.
|
||
func (s *Store) CreateCredential(ctx context.Context, tenantID, name, proto, username string,
|
||
port int, secret string, options string, ring *crypto.Keyring) (string, error) {
|
||
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var secretID any
|
||
if secret != "" {
|
||
if ring == nil {
|
||
return errors.New("сервер запущено без ключа шифрування")
|
||
}
|
||
aad := tenantID + "|inv.credential"
|
||
sec, err := ring.Encrypt([]byte(secret), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
kind := "snmp_v3"
|
||
switch proto {
|
||
case "snmp_v2c":
|
||
// Community — це не пароль SNMPv3, але шифрується так само:
|
||
// у дампі БД вона дає рівно той самий доступ до обладнання.
|
||
kind = "api_token"
|
||
case "ssh":
|
||
kind = "ssh_password"
|
||
case "telnet":
|
||
kind = "telnet"
|
||
}
|
||
var sid string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
|
||
RETURNING id::text
|
||
`, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).
|
||
Scan(&sid); err != nil {
|
||
return err
|
||
}
|
||
secretID = sid
|
||
}
|
||
|
||
opts := options
|
||
if opts == "" {
|
||
opts = "{}"
|
||
}
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO inv.credentials
|
||
(tenant_id, name, proto, username, port, secret_id, options)
|
||
VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0), $6, $7::jsonb)
|
||
RETURNING id::text
|
||
`, tenantID, name, proto, username, port, secretID, opts).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
// SetDeviceCredentials замінює прив'язку доступів до хоста.
|
||
func (s *Store) SetDeviceCredentials(ctx context.Context, tenantID, deviceID string, credIDs []string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.device_credentials dc
|
||
USING inv.credentials c
|
||
WHERE dc.credential_id = c.id AND dc.device_id = $1 AND c.tenant_id = $2
|
||
`, deviceID, tenantID); err != nil {
|
||
return err
|
||
}
|
||
if len(credIDs) == 0 {
|
||
return nil
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
|
||
SELECT $1, c.id, 100
|
||
FROM inv.credentials c
|
||
WHERE c.tenant_id = $2 AND c.id = ANY($3::uuid[])
|
||
ON CONFLICT DO NOTHING
|
||
`, deviceID, tenantID, credIDs)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// DeviceCredentialsOf — які доступи прив'язані до кожного хоста.
|
||
func (s *Store) DeviceCredentialsOf(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 dc.device_id::text, dc.credential_id::text
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
WHERE c.tenant_id = $1
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var d, c string
|
||
if err := rows.Scan(&d, &c); err != nil {
|
||
return err
|
||
}
|
||
out[d] = append(out[d], c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// UpdateCredential змінює доступ.
|
||
//
|
||
// Порожній secret означає «пароль лишити як є»: форма ніколи не показує
|
||
// збережений секрет (розшифрувати його для показу — це віддати його
|
||
// туди, звідки він уже не повернеться), тож надіслати незмінений вона не
|
||
// може навіть теоретично.
|
||
func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, proto, username string,
|
||
port int, secret string, options string, ring *crypto.Keyring) error {
|
||
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var curProto string
|
||
var oldSecret *string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT proto::text, secret_id::text FROM inv.credentials
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID).Scan(&curProto, &oldSecret); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Порожній proto означає «не чіпати». Зміна протоколу дозволена:
|
||
// community SNMP і пароль SSH зберігаються однаково, тож
|
||
// переналаштувати запис дешевше, ніж заводити новий і
|
||
// перевʼязувати всі хости.
|
||
if proto == "" {
|
||
proto = curProto
|
||
}
|
||
|
||
secretID := any(nil)
|
||
if oldSecret != nil {
|
||
secretID = *oldSecret
|
||
}
|
||
|
||
// Секрет зашифрований під видом старого протоколу. Якщо
|
||
// протокол змінили, а новий пароль не дали, збережений стає
|
||
// сміттям із чужим kind — тому вимагаємо ввести його заново.
|
||
if proto != curProto && secret == "" && oldSecret != nil {
|
||
return fmt.Errorf("%w: при зміні протоколу треба ввести пароль заново", ErrInvalid)
|
||
}
|
||
|
||
if secret != "" {
|
||
if ring == nil {
|
||
return errors.New("сервер запущено без ключа шифрування")
|
||
}
|
||
aad := tenantID + "|inv.credential"
|
||
sec, err := ring.Encrypt([]byte(secret), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var sid string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
|
||
RETURNING id::text
|
||
`, tenantID, secretKind(proto), sec.KeyID, sec.Nonce, sec.Ciphertext,
|
||
sec.AuthTag, aad).Scan(&sid); err != nil {
|
||
return err
|
||
}
|
||
// Старий секрет прибираємо лише після того, як новий ліг:
|
||
// зворотний порядок на помилці шифрування лишив би доступ
|
||
// без пароля взагалі.
|
||
if oldSecret != nil {
|
||
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
secretID = sid
|
||
}
|
||
|
||
opts := options
|
||
if opts == "" {
|
||
opts = "{}"
|
||
}
|
||
ct, err := tx.Exec(ctx, `
|
||
UPDATE inv.credentials
|
||
SET name = $3, proto = $8::inv.credential_proto,
|
||
username = NULLIF($4,''), port = NULLIF($5,0),
|
||
secret_id = $6, options = $7::jsonb
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID, name, username, port, secretID, opts, proto)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ct.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// DeleteCredential прибирає доступ разом із секретом.
|
||
//
|
||
// Прив'язки до хостів зникають каскадом. Це саме те, що треба: доступ,
|
||
// якого немає, не має лишатись у списку хоста й створювати враження, що
|
||
// пристрій опитується.
|
||
func (s *Store) DeleteCredential(ctx context.Context, tenantID, id string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var secretID *string
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT secret_id::text FROM inv.credentials WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID).Scan(&secretID); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM inv.credentials WHERE id = $1 AND tenant_id = $2
|
||
`, id, tenantID); err != nil {
|
||
return err
|
||
}
|
||
if secretID != nil {
|
||
_, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *secretID)
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// CredentialUsage — скільки хостів користується доступом.
|
||
//
|
||
// Потрібне попередженню перед видаленням: «доступ використовують 34
|
||
// хости» — це інформація, а «Ви впевнені?» — ні.
|
||
func (s *Store) CredentialUsage(ctx context.Context, tenantID string) (map[string]int, error) {
|
||
out := map[string]int{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, count(dc.device_id)
|
||
FROM inv.credentials c
|
||
LEFT JOIN inv.device_credentials dc ON dc.credential_id = c.id
|
||
WHERE c.tenant_id = $1
|
||
GROUP BY c.id
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id string
|
||
var n int
|
||
if err := rows.Scan(&id, &n); err != nil {
|
||
return err
|
||
}
|
||
out[id] = n
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// secretKind — під яким видом секрет лягає в core.secrets.
|
||
//
|
||
// Community SNMPv2c — це не пароль, але шифрується так само: у дампі БД
|
||
// вона дає рівно той самий доступ до обладнання.
|
||
func secretKind(proto string) string {
|
||
switch proto {
|
||
case "snmp_v2c":
|
||
return "api_token"
|
||
case "ssh":
|
||
return "ssh_password"
|
||
case "telnet":
|
||
return "telnet"
|
||
default:
|
||
return "snmp_v3"
|
||
}
|
||
}
|
||
|
||
// DeviceCredentialIDs — які доступи прив'язані до хоста.
|
||
//
|
||
// Потрібне формі: показати порожній список там, де доступи задані,
|
||
// означало б стерти їх першим же збереженням.
|
||
func (s *Store) DeviceCredentialIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
|
||
ids := []string{}
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT dc.credential_id::text
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
WHERE dc.device_id = $1 AND c.tenant_id = $2
|
||
ORDER BY dc.priority
|
||
`, deviceID, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return err
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return ids, err
|
||
}
|