Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
506 lines
16 KiB
Go
506 lines
16 KiB
Go
// Package gitstore — версіювання конфігів пристроїв у Git.
|
||
//
|
||
// Один голий репозиторій на тенанта, гілка на пристрій:
|
||
// refs/heads/device/<device_id>. Гілка за ідентифікатором, а не за
|
||
// іменем: пристрій перейменовують, і історія не має від цього
|
||
// розсипатись на дві.
|
||
//
|
||
// Голий репозиторій, без робочого каталогу: конфіги й так лежать в
|
||
// об'єктах Git, а друга копія на диску подвоює місце й дає ще один шлях,
|
||
// з якого їх можна прочитати.
|
||
//
|
||
// Чому взагалі Git, коли тіла вже лежать зашифрованими в базі: база
|
||
// відповідає на «який конфіг зараз», Git — на «що і коли змінилось за
|
||
// півроку». Друге питання ставлять під час розбору аварії, і відповідь
|
||
// на нього не має залежати від того, чи не почистив таблицю ретеншен.
|
||
package gitstore
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-git/go-git/v5"
|
||
"github.com/go-git/go-git/v5/plumbing"
|
||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||
"github.com/go-git/go-git/v5/plumbing/object"
|
||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||
)
|
||
|
||
// Store — сховище репозиторіїв під спільним коренем.
|
||
type Store struct {
|
||
root string
|
||
|
||
// Запис у Git не потоково-безпечний: два коміти в одну гілку
|
||
// одночасно дають гонку за посилання, і один із них тихо зникає.
|
||
// Блокування на репозиторій, а не одне на все: тенанти між собою
|
||
// не конкурують.
|
||
mu sync.Mutex
|
||
locks map[string]*sync.Mutex
|
||
}
|
||
|
||
func New(root string) *Store {
|
||
return &Store{root: root, locks: map[string]*sync.Mutex{}}
|
||
}
|
||
|
||
// Root — корінь сховища. Порожній означає вимкнене версіювання.
|
||
func (s *Store) Root() string { return s.root }
|
||
|
||
func (s *Store) lockFor(path string) *sync.Mutex {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
m, ok := s.locks[path]
|
||
if !ok {
|
||
m = &sync.Mutex{}
|
||
s.locks[path] = m
|
||
}
|
||
return m
|
||
}
|
||
|
||
// Commit — що покласти в репозиторій.
|
||
type Commit struct {
|
||
// Repo — шлях репозиторію відносно кореня, наприклад "<tenant>.git".
|
||
Repo string
|
||
Branch string
|
||
Path string
|
||
Body []byte
|
||
|
||
Message string
|
||
Author string
|
||
Email string
|
||
When time.Time
|
||
}
|
||
|
||
// Result — координати записаного.
|
||
type Result struct {
|
||
CommitSHA string
|
||
BlobSHA string
|
||
// Unchanged — вміст за цим шляхом уже такий; нового коміту немає,
|
||
// повернуто наявний.
|
||
Unchanged bool
|
||
}
|
||
|
||
var ErrDisabled = errors.New("версіювання в Git вимкнено")
|
||
|
||
// Write кладе тіло за шляхом і повертає координати коміту.
|
||
//
|
||
// Повторний запис того самого вмісту не створює коміту: інакше кожен
|
||
// нічний бекап незмінного пристрою додавав би порожню зміну, і за місяць
|
||
// історія складалась би з тридцяти однакових записів, серед яких треба
|
||
// шукати одну справжню правку.
|
||
func (s *Store) Write(c Commit) (Result, error) {
|
||
if s.root == "" {
|
||
return Result{}, ErrDisabled
|
||
}
|
||
if c.Repo == "" || c.Branch == "" || c.Path == "" {
|
||
return Result{}, errors.New("gitstore: не задано репозиторій, гілку або шлях")
|
||
}
|
||
|
||
full := filepath.Join(s.root, c.Repo)
|
||
lock := s.lockFor(full)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
repo, err := open(full)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
st := repo.Storer
|
||
|
||
blobHash, err := writeBlob(st, c.Body)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
|
||
refName := plumbing.NewBranchReferenceName(c.Branch)
|
||
var (
|
||
parents []plumbing.Hash
|
||
baseTree *object.Tree
|
||
)
|
||
ref, err := repo.Reference(refName, true)
|
||
switch {
|
||
case err == nil:
|
||
parent, err := object.GetCommit(st, ref.Hash())
|
||
if err != nil {
|
||
return Result{}, fmt.Errorf("gitstore: батьківський коміт: %w", err)
|
||
}
|
||
parents = []plumbing.Hash{parent.Hash}
|
||
baseTree, err = parent.Tree()
|
||
if err != nil {
|
||
return Result{}, fmt.Errorf("gitstore: дерево батька: %w", err)
|
||
}
|
||
|
||
// Уже те саме? Тоді коміту немає.
|
||
if e, err := baseTree.FindEntry(c.Path); err == nil && e.Hash == blobHash {
|
||
return Result{
|
||
CommitSHA: parent.Hash.String(),
|
||
BlobSHA: blobHash.String(),
|
||
Unchanged: true,
|
||
}, nil
|
||
}
|
||
case errors.Is(err, plumbing.ErrReferenceNotFound):
|
||
// Перший конфіг цього пристрою — гілки ще немає.
|
||
default:
|
||
return Result{}, fmt.Errorf("gitstore: читання гілки: %w", err)
|
||
}
|
||
|
||
treeHash, err := putPath(st, baseTree, splitPath(c.Path), blobHash)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
|
||
when := c.When
|
||
if when.IsZero() {
|
||
when = time.Now()
|
||
}
|
||
sig := object.Signature{
|
||
Name: orDefault(c.Author, "NetPulse"),
|
||
Email: orDefault(c.Email, "netpulse@localhost"),
|
||
When: when,
|
||
}
|
||
|
||
commit := &object.Commit{
|
||
Author: sig,
|
||
Committer: sig,
|
||
Message: orDefault(c.Message, "оновлено "+c.Path),
|
||
TreeHash: treeHash,
|
||
ParentHashes: parents,
|
||
}
|
||
obj := st.NewEncodedObject()
|
||
if err := commit.Encode(obj); err != nil {
|
||
return Result{}, err
|
||
}
|
||
commitHash, err := st.SetEncodedObject(obj)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
|
||
if err := st.SetReference(plumbing.NewHashReference(refName, commitHash)); err != nil {
|
||
return Result{}, fmt.Errorf("gitstore: оновлення гілки: %w", err)
|
||
}
|
||
|
||
return Result{CommitSHA: commitHash.String(), BlobSHA: blobHash.String()}, nil
|
||
}
|
||
|
||
// Read повертає вміст файлу на момент коміту.
|
||
func (s *Store) Read(repoName, commitSHA, path string) ([]byte, error) {
|
||
if s.root == "" {
|
||
return nil, ErrDisabled
|
||
}
|
||
repo, err := open(filepath.Join(s.root, repoName))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
commit, err := object.GetCommit(repo.Storer, plumbing.NewHash(commitSHA))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gitstore: коміт %s: %w", short(commitSHA), err)
|
||
}
|
||
f, err := commit.File(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gitstore: %s у коміті %s: %w", path, short(commitSHA), err)
|
||
}
|
||
r, err := f.Blob.Reader()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer r.Close()
|
||
return io.ReadAll(r)
|
||
}
|
||
|
||
// Entry — рядок історії гілки.
|
||
type Entry struct {
|
||
CommitSHA string
|
||
Message string
|
||
Author string
|
||
When time.Time
|
||
}
|
||
|
||
// History повертає коміти гілки, новіші першими.
|
||
//
|
||
// Обхід лише за першим батьком: злиттів тут не буває — гілка пристрою
|
||
// лінійна за побудовою, її пише один процес під блокуванням.
|
||
func (s *Store) History(repoName, branch string, limit int) ([]Entry, error) {
|
||
if s.root == "" {
|
||
return nil, ErrDisabled
|
||
}
|
||
repo, err := open(filepath.Join(s.root, repoName))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true)
|
||
if err != nil {
|
||
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
var out []Entry
|
||
hash := ref.Hash()
|
||
for {
|
||
commit, err := object.GetCommit(repo.Storer, hash)
|
||
if err != nil {
|
||
return out, err
|
||
}
|
||
out = append(out, Entry{
|
||
CommitSHA: commit.Hash.String(),
|
||
Message: commit.Message,
|
||
Author: commit.Author.Name,
|
||
When: commit.Author.When,
|
||
})
|
||
if (limit > 0 && len(out) >= limit) || len(commit.ParentHashes) == 0 {
|
||
return out, nil
|
||
}
|
||
hash = commit.ParentHashes[0]
|
||
}
|
||
}
|
||
|
||
// --- нутрощі --------------------------------------------------------
|
||
|
||
func open(path string) (*git.Repository, error) {
|
||
repo, err := git.PlainOpen(path)
|
||
if err == nil {
|
||
return repo, nil
|
||
}
|
||
if !errors.Is(err, git.ErrRepositoryNotExists) {
|
||
return nil, fmt.Errorf("gitstore: відкриття %s: %w", path, err)
|
||
}
|
||
// 0700: у конфігах живуть ключі й хеші паролів, і Git тримає їх
|
||
// відкритим текстом у своїх об'єктах. Читати їх має право лише той
|
||
// користувач, під яким працює сервер.
|
||
if err := os.MkdirAll(path, 0o700); err != nil {
|
||
return nil, err
|
||
}
|
||
repo, err = git.PlainInit(path, true)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("gitstore: створення %s: %w", path, err)
|
||
}
|
||
if err := seed(repo); err != nil {
|
||
return nil, err
|
||
}
|
||
return repo, nil
|
||
}
|
||
|
||
// seed кладе в новий репозиторій гілку main з поясненням розкладки.
|
||
//
|
||
// Без неї HEAD свіжого репозиторію показує на ненароджену гілку, і
|
||
// `git fsck` та `git clone` про це кажуть — виглядає як пошкодження,
|
||
// хоча все ціле. Заразом той, хто склонує репозиторій руками, бачить
|
||
// пояснення, а не порожній каталог.
|
||
func seed(repo *git.Repository) error {
|
||
const readme = `# Конфіги пристроїв NetPulse
|
||
|
||
Гілка на пристрій: ` + "`refs/heads/device/<device_id>`" + `.
|
||
Усередині гілки — файли на кожен зріз: ` + "`<пристрій>/<тип>.cfg`" + `.
|
||
|
||
Ідентифікатор, а не ім'я, у назві гілки: пристрій перейменовують, і
|
||
історія не має від цього розсипатись на дві.
|
||
|
||
Репозиторій пише NetPulse. Правки руками наступний бекап не помітить:
|
||
сервер порівнює вміст із тим, що записав сам.
|
||
`
|
||
|
||
st := repo.Storer
|
||
blob, err := writeBlob(st, []byte(readme))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tree, err := putPath(st, nil, []string{"README.md"}, blob)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
sig := object.Signature{Name: "NetPulse", Email: "netpulse@localhost", When: time.Now()}
|
||
commit := &object.Commit{Author: sig, Committer: sig, Message: "розкладка репозиторію", TreeHash: tree}
|
||
obj := st.NewEncodedObject()
|
||
if err := commit.Encode(obj); err != nil {
|
||
return err
|
||
}
|
||
head, err := st.SetEncodedObject(obj)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
main := plumbing.NewBranchReferenceName("main")
|
||
if err := st.SetReference(plumbing.NewHashReference(main, head)); err != nil {
|
||
return err
|
||
}
|
||
return st.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, main))
|
||
}
|
||
|
||
func writeBlob(st storer.EncodedObjectStorer, body []byte) (plumbing.Hash, error) {
|
||
obj := st.NewEncodedObject()
|
||
obj.SetType(plumbing.BlobObject)
|
||
obj.SetSize(int64(len(body)))
|
||
w, err := obj.Writer()
|
||
if err != nil {
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
if _, err := w.Write(body); err != nil {
|
||
w.Close()
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
return st.SetEncodedObject(obj)
|
||
}
|
||
|
||
// putPath кладе blob за шляхом, зберігаючи решту дерева.
|
||
//
|
||
// Рекурсія по частинах шляху: на кожному рівні береться наявне дерево,
|
||
// у ньому заміняється один запис, і результат стає записом рівнем вище.
|
||
// Голий репозиторій не має індексу, тож дерева доводиться складати
|
||
// самому — go-git дає для цього об'єкти, але не зручності worktree.
|
||
func putPath(
|
||
st storer.EncodedObjectStorer,
|
||
base *object.Tree,
|
||
parts []string,
|
||
blob plumbing.Hash,
|
||
) (plumbing.Hash, error) {
|
||
if len(parts) == 0 {
|
||
return plumbing.ZeroHash, errors.New("gitstore: порожній шлях")
|
||
}
|
||
|
||
var entries []object.TreeEntry
|
||
if base != nil {
|
||
entries = append(entries, base.Entries...)
|
||
}
|
||
|
||
name := parts[0]
|
||
entry := object.TreeEntry{Name: name}
|
||
|
||
if len(parts) == 1 {
|
||
entry.Mode = filemode.Regular
|
||
entry.Hash = blob
|
||
} else {
|
||
var sub *object.Tree
|
||
if base != nil {
|
||
for _, e := range base.Entries {
|
||
if e.Name == name && e.Mode == filemode.Dir {
|
||
t, err := object.GetTree(st, e.Hash)
|
||
if err != nil {
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
sub = t
|
||
break
|
||
}
|
||
}
|
||
}
|
||
h, err := putPath(st, sub, parts[1:], blob)
|
||
if err != nil {
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
entry.Mode = filemode.Dir
|
||
entry.Hash = h
|
||
}
|
||
|
||
replaced := false
|
||
for i := range entries {
|
||
if entries[i].Name == name {
|
||
entries[i] = entry
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
entries = append(entries, entry)
|
||
}
|
||
|
||
// Git вимагає впорядкованих записів дерева: той самий вміст із
|
||
// іншим порядком дає інший хеш, а `git fsck` називає таке дерево
|
||
// пошкодженим.
|
||
sort.Slice(entries, func(i, j int) bool { return sortKey(entries[i]) < sortKey(entries[j]) })
|
||
|
||
tree := &object.Tree{Entries: entries}
|
||
obj := st.NewEncodedObject()
|
||
if err := tree.Encode(obj); err != nil {
|
||
return plumbing.ZeroHash, err
|
||
}
|
||
return st.SetEncodedObject(obj)
|
||
}
|
||
|
||
// sortKey — правило впорядкування записів дерева в Git: каталог
|
||
// порівнюється так, ніби його ім'я закінчується скісною рискою.
|
||
func sortKey(e object.TreeEntry) string {
|
||
if e.Mode == filemode.Dir {
|
||
return e.Name + "/"
|
||
}
|
||
return e.Name
|
||
}
|
||
|
||
func splitPath(p string) []string {
|
||
var out []string
|
||
for _, part := range strings.Split(p, "/") {
|
||
if part != "" && part != "." && part != ".." {
|
||
out = append(out, part)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|
||
|
||
func short(sha string) string {
|
||
if len(sha) > 8 {
|
||
return sha[:8]
|
||
}
|
||
return sha
|
||
}
|
||
|
||
// RenameBranch переносить гілку під нове ім'я.
|
||
//
|
||
// Потрібне, коли хост перейменували або змінили йому адресу: ім'я гілки
|
||
// складається з них, і без переносу історія пристрою розпалась би на
|
||
// дві — половина під старим іменем, половина під новим, і знайти першу
|
||
// можна лише знаючи, як хост звався колись.
|
||
//
|
||
// Сама історія при цьому не змінюється: переставляється лише посилання.
|
||
// Відсутність старої гілки помилкою не вважається — пристрій міг ще не
|
||
// комітитись, або перенос уже відбувся раніше.
|
||
func (s *Store) RenameBranch(repoName, from, to string) error {
|
||
if from == to || from == "" || to == "" {
|
||
return nil
|
||
}
|
||
path := filepath.Join(s.root, repoName)
|
||
|
||
mu := s.lockFor(path)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
repo, err := open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
oldRef := plumbing.NewBranchReferenceName(from)
|
||
ref, err := repo.Reference(oldRef, true)
|
||
if err != nil {
|
||
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("гілка %s: %w", from, err)
|
||
}
|
||
|
||
newRef := plumbing.NewBranchReferenceName(to)
|
||
if err := repo.Storer.SetReference(plumbing.NewHashReference(newRef, ref.Hash())); err != nil {
|
||
return fmt.Errorf("нова гілка %s: %w", to, err)
|
||
}
|
||
// Стару прибираємо ПІСЛЯ того, як нова вказала на той самий коміт:
|
||
// обрив між двома діями лишає історію досяжною хоч під одним іменем.
|
||
if err := repo.Storer.RemoveReference(oldRef); err != nil {
|
||
return fmt.Errorf("прибрати гілку %s: %w", from, err)
|
||
}
|
||
return nil
|
||
}
|