Збір працював, але подивитись на зібране було ніде: жодної сторінки, а lines_added завжди 0 — порівняння ніхто не рахував. difftext: власне порядкове порівняння з ділянками й контекстом. Бібліотека принесла б підтримку слів, символів і кольорів у терміналі — десяток речей, які тут не знадобляться. - спільний початок і кінець відкидаються до основного алгоритму: у конфігах змінюється кілька рядків із тисячі, і квадратична таблиця будувалася б там, де досить порівняти десяток - понад чотири мільйони клітинок дають truncated і грубу заміну блоку: точність там нічого не дає, а чесна позначка краща за правдоподібний, але вигаданий diff - сусідні зміни зливаються в одну ділянку, інакше контекст дублюється Результат кешується в ncm.diffs — diff двох версій незмінний назавжди. Підсумок +N/−M заразом дозаписується у version, щоб список історії не розшифровував два тіла на кожен рядок. UI: сторінка «Конфіги» — хости зліва, історія й diff справа. Порівняння з попередньою версією відкривається одразу: питання завжди одне — що змінилось цього разу. Перевірено наживо: друга версія стенду дала @@ −8,3 +8,4 @@ з одним доданим рядком і контекстом, лічильник +1 −0 дозаписався, повний текст на 292 байти читається. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
6.4 KiB
Go
182 lines
6.4 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
"github.com/netpulse/netpulse/server/internal/difftext"
|
||
)
|
||
|
||
var ErrConfigNotFound = errors.New("версію конфігу не знайдено")
|
||
|
||
// ConfigVersion — рядок історії конфігів.
|
||
type ConfigVersion struct {
|
||
ID string `json:"id"`
|
||
DeviceID string `json:"device_id"`
|
||
ConfigType string `json:"config_type"`
|
||
SizeBytes int `json:"size_bytes"`
|
||
LineCount int `json:"line_count"`
|
||
ContentHash string `json:"content_hash"`
|
||
PrevConfigID string `json:"prev_config_id,omitempty"`
|
||
LinesAdded int `json:"lines_added"`
|
||
LinesRemoved int `json:"lines_removed"`
|
||
IsChange bool `json:"is_change"`
|
||
CollectedAt time.Time `json:"collected_at"`
|
||
}
|
||
|
||
// ListDeviceConfigs — історія версій конфігу хоста.
|
||
func (s *Store) ListDeviceConfigs(ctx context.Context, tenantID, deviceID string, limit int) ([]ConfigVersion, error) {
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 50
|
||
}
|
||
var out []ConfigVersion
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, device_id::text, config_type, size_bytes,
|
||
COALESCE(line_count,0), encode(content_hash,'hex'),
|
||
COALESCE(prev_config_id::text,''),
|
||
lines_added, lines_removed, is_change, collected_at
|
||
FROM ncm.configs
|
||
WHERE tenant_id = $1 AND device_id = $2
|
||
ORDER BY collected_at DESC
|
||
LIMIT $3
|
||
`, tenantID, deviceID, limit)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c ConfigVersion
|
||
if err := rows.Scan(&c.ID, &c.DeviceID, &c.ConfigType, &c.SizeBytes,
|
||
&c.LineCount, &c.ContentHash, &c.PrevConfigID,
|
||
&c.LinesAdded, &c.LinesRemoved, &c.IsChange, &c.CollectedAt); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// ConfigBody повертає розшифрований текст конфігу.
|
||
//
|
||
// Розшифровка робиться тут, а не в обробнику: DEK не має покидати шар
|
||
// доступу до даних, а обробнику потрібен лише текст.
|
||
func (s *Store) ConfigBody(ctx context.Context, tenantID, configID string, ring *crypto.Keyring) (string, ConfigVersion, error) {
|
||
var (
|
||
v ConfigVersion
|
||
keyID, aad *string
|
||
nonce, ct, tag []byte
|
||
)
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT c.id::text, c.device_id::text, c.config_type, c.size_bytes,
|
||
COALESCE(c.line_count,0), encode(c.content_hash,'hex'),
|
||
COALESCE(c.prev_config_id::text,''),
|
||
c.lines_added, c.lines_removed, c.is_change, c.collected_at,
|
||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||
FROM ncm.configs c
|
||
LEFT JOIN core.secrets s ON s.id = c.body_secret_id
|
||
WHERE c.tenant_id = $1 AND c.id = $2
|
||
`, tenantID, configID).Scan(&v.ID, &v.DeviceID, &v.ConfigType, &v.SizeBytes,
|
||
&v.LineCount, &v.ContentHash, &v.PrevConfigID,
|
||
&v.LinesAdded, &v.LinesRemoved, &v.IsChange, &v.CollectedAt,
|
||
&keyID, &nonce, &ct, &tag, &aad)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", v, ErrConfigNotFound
|
||
}
|
||
if err != nil {
|
||
return "", v, err
|
||
}
|
||
if keyID == nil {
|
||
// Тіло не збереглося — таке буває лише після ручного втручання
|
||
// в БД, але мовчати про це не можна.
|
||
return "", v, fmt.Errorf("тіло версії %s відсутнє", configID)
|
||
}
|
||
if ring == nil {
|
||
return "", v, errors.New("сервер запущено без ключа шифрування")
|
||
}
|
||
|
||
plain, err := ring.Decrypt(&crypto.Secret{
|
||
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||
}, derefStr(aad))
|
||
if err != nil {
|
||
return "", v, fmt.Errorf("розшифровка конфігу: %w", err)
|
||
}
|
||
return string(plain), v, nil
|
||
}
|
||
|
||
// DiffConfigs порівнює дві версії й кешує результат.
|
||
//
|
||
// Кеш потрібен не для швидкості: diff між двома конкретними версіями
|
||
// незмінний назавжди, і рахувати його щоразу, коли хтось відкриває
|
||
// сторінку історії, — це палити процесор на відому відповідь.
|
||
func (s *Store) DiffConfigs(ctx context.Context, tenantID, fromID, toID string, ring *crypto.Keyring) (difftext.Result, error) {
|
||
var res difftext.Result
|
||
|
||
// Кеш.
|
||
var cached string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT hunks::text FROM ncm.diffs
|
||
WHERE tenant_id = $1 AND from_config_id = $2 AND to_config_id = $3
|
||
AND format = 'json_hunks'
|
||
`, tenantID, fromID, toID).Scan(&cached)
|
||
})
|
||
if err == nil && cached != "" {
|
||
if err := json.Unmarshal([]byte(cached), &res); err == nil {
|
||
return res, nil
|
||
}
|
||
// Пошкоджений кеш — не привід відмовляти: перерахуємо.
|
||
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||
return res, err
|
||
}
|
||
|
||
oldText, _, err := s.ConfigBody(ctx, tenantID, fromID, ring)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
newText, _, err := s.ConfigBody(ctx, tenantID, toID, ring)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
|
||
res = difftext.Compare(oldText, newText)
|
||
|
||
payload, err := json.Marshal(res)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO ncm.diffs
|
||
(tenant_id, from_config_id, to_config_id, format, hunks,
|
||
lines_added, lines_removed)
|
||
VALUES ($1, $2, $3, 'json_hunks', $4::jsonb, $5, $6)
|
||
ON CONFLICT (from_config_id, to_config_id, format) DO NOTHING
|
||
`, tenantID, fromID, toID, string(payload), res.LinesAdded, res.LinesRemoved)
|
||
return err
|
||
})
|
||
|
||
// Підсумок змін пишемо й у саму версію: список історії показує
|
||
// «+12/−3» без читання й розшифровки двох тіл на кожен рядок.
|
||
_ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE ncm.configs
|
||
SET lines_added = $3, lines_removed = $4
|
||
WHERE tenant_id = $1 AND id = $2 AND prev_config_id = $5
|
||
`, tenantID, toID, res.LinesAdded, res.LinesRemoved, fromID)
|
||
return err
|
||
})
|
||
|
||
return res, nil
|
||
}
|
||
|