163 lines
3.6 KiB
Go
163 lines
3.6 KiB
Go
package backup
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Entry struct {
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Reason string `json:"reason"`
|
|
Actor string `json:"actor"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
type Store struct {
|
|
Dir string
|
|
MaxBackups int
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (s Store) Create(sourcePath, reason, actor string) (Entry, error) {
|
|
data, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
return Entry{}, err
|
|
}
|
|
if s.Now == nil {
|
|
s.Now = time.Now
|
|
}
|
|
now := s.Now().UTC()
|
|
sum := sha256.Sum256(data)
|
|
hash := hex.EncodeToString(sum[:])
|
|
reason = safe(reason)
|
|
name := fmt.Sprintf("%s_%s_%s.env", now.Format("20060102T150405.000000000Z"), reason, hash[:12])
|
|
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
path := filepath.Join(s.Dir, name)
|
|
if err := writeExclusive(path, data, 0o600); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
entry := Entry{Name: name, CreatedAt: now, Reason: reason, Actor: actor, SHA256: hash, Size: int64(len(data))}
|
|
meta, _ := json.MarshalIndent(entry, "", " ")
|
|
if err := writeExclusive(path+".json", append(meta, '\n'), 0o600); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
_ = s.appendAudit(entry)
|
|
_ = s.prune()
|
|
return entry, nil
|
|
}
|
|
|
|
func (s Store) List() ([]Entry, error) {
|
|
items, err := os.ReadDir(s.Dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var out []Entry
|
|
for _, item := range items {
|
|
if item.IsDir() || !strings.HasSuffix(item.Name(), ".env.json") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(s.Dir, item.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var entry Entry
|
|
if json.Unmarshal(data, &entry) == nil {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (s Store) Path(name string) (string, error) {
|
|
if filepath.Base(name) != name || !strings.HasSuffix(name, ".env") {
|
|
return "", fmt.Errorf("invalid backup name")
|
|
}
|
|
path := filepath.Join(s.Dir, name)
|
|
if _, err := os.Stat(path); err != nil {
|
|
return "", err
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func (s Store) Read(name string) ([]byte, error) {
|
|
path, err := s.Path(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.ReadFile(path)
|
|
}
|
|
|
|
func (s Store) appendAudit(entry Entry) error {
|
|
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(filepath.Join(s.Dir, "audit.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
data, _ := json.Marshal(entry)
|
|
_, err = f.Write(append(data, '\n'))
|
|
return err
|
|
}
|
|
|
|
func (s Store) prune() error {
|
|
if s.MaxBackups <= 0 {
|
|
return nil
|
|
}
|
|
entries, err := s.List()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) <= s.MaxBackups {
|
|
return nil
|
|
}
|
|
for _, entry := range entries[s.MaxBackups:] {
|
|
_ = os.Remove(filepath.Join(s.Dir, entry.Name))
|
|
_ = os.Remove(filepath.Join(s.Dir, entry.Name+".json"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func safe(value string) string {
|
|
value = strings.ToLower(value)
|
|
var b strings.Builder
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
if b.Len() == 0 {
|
|
return "change"
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func writeExclusive(path string, data []byte, mode fs.FileMode) error {
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return f.Sync()
|
|
}
|