go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
8.3 KiB
Go
238 lines
8.3 KiB
Go
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
|
||
}
|