Netpulse_SasS/server/internal/store/ncm_read.go
byrsapty cdf5f1fb54 Візуальний diff конфігів
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
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>
2026-08-24 12:30:30 +03:00

182 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}