Files
jbergner fe32431d21
Some checks failed
release-tag / release-image (push) Failing after 55s
init
2026-08-01 23:54:45 +02:00

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()
}