Netpulse_SasS/server/internal/store/ncm.go
byrsapty 65b37a28ff
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння
go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на
пристрій за ідентифікатором (ім'я змінюють, історія не має від цього
розсипатись), файл за іменем. Однаковий вміст нового коміту не створює.

netpulse-gitsync переливає накопичену історію й відтворює втрачений
репозиторій із бази — тіла конфігів там і так лежать зашифрованими.

У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів
(?from=), інтерфейс — ні.

Дорогою виправлено чотири тести, які CI запустив уперше з базою.
Серед них справжня помилка: машинний токен не міг читати мапи —
ACL мап отримував порожній рядок замість uuid і давав 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:14 +03:00

238 lines
8.3 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"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/gitstore"
)
// ConfigSubmission — зібраний із чанків конфіг.
type ConfigSubmission struct {
JobID string
DeviceID string
ConfigType string
Body []byte
// sha256 тіла, як його порахував агент.
ClaimedHash []byte
}
// ConfigOutcome — що сервер із цим зробив.
type ConfigOutcome struct {
Accepted bool
Unchanged bool
ConfigID string
CommitSHA string
Err error
}
var ErrChecksumMismatch = errors.New("контрольна сума не збіглася")
// StoreConfig приймає конфіг: звіряє суму, визначає, чи є зміна,
// зберігає нову версію й кладе її в Git.
//
// Два сховища замість одного — свідомо. Тіло лежить зашифрованим у
// core.secrets і відповідає на «який конфіг зараз»; Git відповідає на
// «що і коли змінилось за півроку». Друге питання ставлять під час
// розбору аварії, і відповідь не має залежати від того, чи не почистив
// таблицю ретеншен.
//
// Без під'єднаного Git (UseGit не викликали) працює як і раніше:
// commit_sha містить hex контентного хеша. Це робочий режим для
// інсталяцій без окремого диска під історію.
func (s *Store) StoreConfig(ctx context.Context, a *Agent, sub ConfigSubmission, ring *crypto.Keyring) (ConfigOutcome, error) {
sum := sha256.Sum256(sub.Body)
if len(sub.ClaimedHash) > 0 && !equalBytes(sum[:], sub.ClaimedHash) {
return ConfigOutcome{Err: ErrChecksumMismatch}, nil
}
out := ConfigOutcome{}
err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
configType := sub.ConfigType
if configType == "" {
configType = "running"
}
// Чи змінилось? Порівняння за хешем нормалізованого тіла —
// саме тому scrub робиться на сервері: інакше кожен збір
// виглядав би зміною через рядок з uptime.
var (
prevID string
prevHash []byte
)
err := tx.QueryRow(ctx, `
SELECT id::text, content_hash
FROM ncm.configs
WHERE device_id = $1 AND tenant_id = $2 AND config_type = $3
ORDER BY collected_at DESC
LIMIT 1
`, sub.DeviceID, a.TenantID, configType).Scan(&prevID, &prevHash)
hasPrev := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if hasPrev && equalBytes(prevHash, sum[:]) {
out.Accepted = true
out.Unchanged = true
out.ConfigID = prevID
return nil
}
var repoID string
err = tx.QueryRow(ctx, `
SELECT id::text FROM ncm.repos WHERE tenant_id = $1 ORDER BY created_at LIMIT 1
`, a.TenantID).Scan(&repoID)
if errors.Is(err, pgx.ErrNoRows) {
// Репозиторій заводиться при першому ж бекапі.
// $1 тут потрібен і як uuid (колонка tenant_id), і як text
// (частина шляху). Приведення на місці не допомагає — воно
// не розв'язує конфлікт, а нав'язує один тип обом уживанням.
// Тому лишаємо параметр текстовим і кастуємо там, де треба uuid.
err = tx.QueryRow(ctx, `
INSERT INTO ncm.repos (tenant_id, name, storage_path)
VALUES ($1::uuid, 'default', '/var/lib/netpulse/git/' || $1 || '.git')
RETURNING id::text
`, a.TenantID).Scan(&repoID)
}
if err != nil {
return err
}
// Тіло — секрет: у конфігах живуть ключі, community-рядки
// і хеші паролів. Кладемо зашифрованим тим самим механізмом,
// що й креденшели.
aad := a.TenantID + "|config|" + sub.DeviceID
sec, err := ring.Encrypt(sub.Body, aad)
if err != nil {
return err
}
var secretID string
err = tx.QueryRow(ctx, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, 'generic', $2, $3, $4, $5, $6)
RETURNING id::text
`, a.TenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&secretID)
if err != nil {
return fmt.Errorf("збереження зашифрованого тіла: %w", err)
}
hexSum := hex.EncodeToString(sum[:])
lines := strings.Count(string(sub.Body), "\n") + 1
var deviceName string
if err := tx.QueryRow(ctx, `
SELECT name FROM inv.devices WHERE id = $1 AND tenant_id = $2
`, sub.DeviceID, a.TenantID).Scan(&deviceName); err != nil {
return fmt.Errorf("пристрій %s: %w", sub.DeviceID, err)
}
// Шлях за іменем, гілка за ідентифікатором. Ім'я в дереві
// читабельне й змінюється разом із пристроєм; гілка мусить
// пережити перейменування, інакше історія розсипається на дві.
path := fmt.Sprintf("%s/%s.cfg", sanitizePath(deviceName), configType)
branch := "device/" + sub.DeviceID
commitSHA, blobSHA := hexSum, hexSum
if s.git != nil {
res, gerr := s.git.Write(gitstore.Commit{
Repo: a.TenantID + ".git",
Branch: branch,
Path: path,
Body: sub.Body,
Message: fmt.Sprintf("%s: %s", deviceName, configType),
Author: "NetPulse",
Email: "netpulse@localhost",
})
if gerr != nil {
// Помилка Git не має губити бекап: конфіг уже зібрано з
// пристрою, і викинути його через проблему з диском —
// найдорожчий спосіб відреагувати. Версія лягає в базу
// з контентним хешем, а несправність видно в журналі.
slog.Error("git: конфіг не записано, лишаємо тільки в базі",
"device", sub.DeviceID, "error", gerr)
} else {
commitSHA, blobSHA = res.CommitSHA, res.BlobSHA
}
}
var prevArg any
if hasPrev {
prevArg = prevID
}
err = tx.QueryRow(ctx, `
INSERT INTO ncm.configs
(tenant_id, device_id, repo_id, commit_sha, blob_sha, branch, path,
config_type, size_bytes, line_count, content_hash, body_secret_id,
prev_config_id, is_change)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$14)
RETURNING id::text
`, a.TenantID, sub.DeviceID, repoID, commitSHA, blobSHA,
branch, path, configType,
len(sub.Body), lines, sum[:], secretID, prevArg, hasPrev).Scan(&out.ConfigID)
if err != nil {
return fmt.Errorf("вставка версії конфігу: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE ncm.device_policies
SET last_backup_at = now()
WHERE device_id = $1 AND tenant_id = $2
`, sub.DeviceID, a.TenantID); err != nil {
return err
}
out.Accepted = true
out.CommitSHA = commitSHA
return nil
})
return out, err
}
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// sanitizePath робить з імені пристрою придатну частину шляху.
//
// Імена заводять люди: там трапляються скісні риски, пробіли й крапки
// на початку. Скісна риска створила б зайвий рівень дерева, а ".." —
// вийшла б за межі репозиторію.
func sanitizePath(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-' || r == '_' || r == '.':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
out := strings.Trim(b.String(), ".-")
if out == "" {
return "device"
}
return out
}