244 lines
5.8 KiB
Go
244 lines
5.8 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.casaderoll.de/michael/urbm/internal/model"
|
|
)
|
|
|
|
type Store struct {
|
|
dir string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func New(dir string) *Store { return &Store{dir: dir} }
|
|
|
|
func (s *Store) Init() error {
|
|
if err := os.MkdirAll(s.dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) {
|
|
return s.SaveConfig(model.DefaultConfig())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) LoadConfig() (model.Config, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var c model.Config
|
|
if err := readJSON(s.configPath(), &c); err != nil {
|
|
return c, err
|
|
}
|
|
c = model.NormalizeConfig(c)
|
|
return c, model.ValidateConfig(c)
|
|
}
|
|
|
|
func (s *Store) SaveConfig(c model.Config) error {
|
|
c = model.NormalizeConfig(c)
|
|
if err := model.ValidateConfig(c); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return writeJSONAtomic(s.configPath(), c, 0600)
|
|
}
|
|
|
|
func (s *Store) LoadRuns() ([]model.Run, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var runs []model.Run
|
|
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
|
|
return []model.Run{}, nil
|
|
} else if err != nil {
|
|
var decodeErr *jsonDecodeError
|
|
if errors.As(err, &decodeErr) {
|
|
quarantined, quarantineErr := quarantineFile(s.runsPath())
|
|
if quarantineErr != nil {
|
|
return nil, fmt.Errorf("%w; quarantine failed: %v", err, quarantineErr)
|
|
}
|
|
return nil, fmt.Errorf("%w; corrupt file moved to %s", err, quarantined)
|
|
}
|
|
return nil, err
|
|
}
|
|
return runs, nil
|
|
}
|
|
|
|
func (s *Store) SaveRuns(runs []model.Run) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(runs) > 500 {
|
|
runs = runs[len(runs)-500:]
|
|
}
|
|
return writeJSONAtomic(s.runsPath(), runs, 0600)
|
|
}
|
|
|
|
func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
path := s.backupMetricsPath()
|
|
b, err := os.ReadFile(path)
|
|
legacySource := false
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
path = s.legacyBackupMetricsPath()
|
|
b, err = os.ReadFile(path)
|
|
legacySource = true
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []model.BackupMetric{}, false, nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
b = bytes.TrimSpace(b)
|
|
if len(b) == 0 {
|
|
return []model.BackupMetric{}, false, nil
|
|
}
|
|
if b[0] == '[' {
|
|
var metrics []model.BackupMetric
|
|
if err := json.Unmarshal(b, &metrics); err != nil {
|
|
return nil, true, quarantineDecodeError(path, err)
|
|
}
|
|
return metrics, true, nil
|
|
}
|
|
lines := bytes.Split(b, []byte{'\n'})
|
|
metrics := make([]model.BackupMetric, 0, len(lines))
|
|
for index, line := range lines {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var metric model.BackupMetric
|
|
if err := json.Unmarshal(line, &metric); err != nil {
|
|
return nil, legacySource, quarantineDecodeError(path, fmt.Errorf("line %d: %w", index+1, err))
|
|
}
|
|
metrics = append(metrics, metric)
|
|
}
|
|
return metrics, legacySource, nil
|
|
}
|
|
|
|
func (s *Store) SaveBackupMetrics(metrics []model.BackupMetric) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(metrics) > 5000 {
|
|
metrics = metrics[len(metrics)-5000:]
|
|
}
|
|
var data bytes.Buffer
|
|
encoder := json.NewEncoder(&data)
|
|
for _, metric := range metrics {
|
|
if err := encoder.Encode(metric); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writeBytesAtomic(s.backupMetricsPath(), data.Bytes(), 0600)
|
|
}
|
|
|
|
func (s *Store) AppendBackupMetric(metric model.BackupMetric) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b, err := json.Marshal(metric)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
file, err := os.OpenFile(s.backupMetricsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := file.Write(b); err != nil {
|
|
file.Close()
|
|
return err
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
file.Close()
|
|
return err
|
|
}
|
|
return file.Close()
|
|
}
|
|
|
|
func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") }
|
|
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") }
|
|
func (s *Store) backupMetricsPath() string {
|
|
return filepath.Join(s.dir, "backup-metrics.jsonl")
|
|
}
|
|
func (s *Store) legacyBackupMetricsPath() string {
|
|
return filepath.Join(s.dir, "backup-metrics.json")
|
|
}
|
|
|
|
func readJSON(path string, target any) error {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(b, target); err != nil {
|
|
return &jsonDecodeError{path: path, err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type jsonDecodeError struct {
|
|
path string
|
|
err error
|
|
}
|
|
|
|
func (e *jsonDecodeError) Error() string { return fmt.Sprintf("decode %s: %v", e.path, e.err) }
|
|
func (e *jsonDecodeError) Unwrap() error { return e.err }
|
|
|
|
func quarantineDecodeError(path string, decodeErr error) error {
|
|
quarantined, err := quarantineFile(path)
|
|
wrapped := &jsonDecodeError{path: path, err: decodeErr}
|
|
if err != nil {
|
|
return fmt.Errorf("%w; quarantine failed: %v", wrapped, err)
|
|
}
|
|
return fmt.Errorf("%w; corrupt file moved to %s", wrapped, quarantined)
|
|
}
|
|
|
|
func quarantineFile(path string) (string, error) {
|
|
quarantined := path + ".corrupt-" + time.Now().UTC().Format("20060102-150405.000000000")
|
|
if err := os.Rename(path, quarantined); err != nil {
|
|
return "", err
|
|
}
|
|
return quarantined, nil
|
|
}
|
|
|
|
func writeJSONAtomic(path string, value any, mode os.FileMode) error {
|
|
b, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
return writeBytesAtomic(path, b, mode)
|
|
}
|
|
|
|
func writeBytesAtomic(path string, b []byte, mode os.FileMode) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".urbm-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if err := tmp.Chmod(mode); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|