// Package gitstore — версіювання конфігів пристроїв у Git. // // Один голий репозиторій на тенанта, гілка на пристрій: // refs/heads/device/. Гілка за ідентифікатором, а не за // іменем: пристрій перейменовують, і історія не має від цього // розсипатись на дві. // // Голий репозиторій, без робочого каталогу: конфіги й так лежать в // об'єктах 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 — шлях репозиторію відносно кореня, наприклад ".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/`" + `. Усередині гілки — файли на кожен зріз: ` + "`<пристрій>/<тип>.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 }