@@ -0,0 +1,162 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateAndRead(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(source, []byte("A=1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := Store{Dir: filepath.Join(dir, "backups"), MaxBackups: 5, Now: func() time.Time { return time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) }}
|
||||
entry, err := store.Create(source, "save", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Read(entry.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "A=1\n" {
|
||||
t.Fatalf("unexpected data %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user