f-46: statefile: add a generic atomic state file #46
Persistance d'un état de composant dans un fichier, générique sur le type stocké : temp + chmod 0600 + sync + rename, répertoire en 0700. Chaque écriture remplace le fichier, donc un chmod externe est effacé, et un échec d'encodage ne tronque pas l'état précédent. Load crée le fichier absent et rend le zéro du type ; un fichier vide est un point de départ valide, un fichier corrompu est une erreur. Dans pkg/ plutôt que sous internal/dhcpd : une réutilisation par l'intel est envisagée. Écarte Badger pour cet usage — une instance par subnet coûterait ~74 Mio et 4 goroutines pour ~1 Kio d'état dans un tmpfs, plus un LOCK résiduel après un arrêt brutal. 11 tests, quatre mutations toutes détectées. Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
e44f3aca96
commit
8802de4fb0
2 changed files with 281 additions and 0 deletions
92
pkg/db/statefile/statefile.go
Normal file
92
pkg/db/statefile/statefile.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package statefile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FileMode = 0o600
|
||||||
|
DirMode = 0o700
|
||||||
|
)
|
||||||
|
|
||||||
|
type File[T any] struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New[T any](path string) *File[T] {
|
||||||
|
return &File[T]{path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File[T]) Path() string {
|
||||||
|
return f.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File[T]) Load() (T, error) {
|
||||||
|
var value T
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(f.path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return value, f.Save(value)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return value, fmt.Errorf("read %s: %w", f.path, err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("parse %s: %w", f.path, err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File[T]) Save(value T) error {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode state for %s: %w", f.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(f.path)
|
||||||
|
if err := os.MkdirAll(dir, DirMode); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp(dir, filepath.Base(f.path)+".tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp state in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
|
||||||
|
if err := tmp.Chmod(FileMode); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("chmod %s: %w", tmp.Name(), err)
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(raw); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("write %s: %w", tmp.Name(), err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("sync %s: %w", tmp.Name(), err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close %s: %w", tmp.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmp.Name(), f.path); err != nil {
|
||||||
|
return fmt.Errorf("rename %s to %s: %w", tmp.Name(), f.path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File[T]) Remove() error {
|
||||||
|
if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove %s: %w", f.path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
189
pkg/db/statefile/statefile_test.go
Normal file
189
pkg/db/statefile/statefile_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package statefile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type payload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func path(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return filepath.Join(t.TempDir(), "component.state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_CreatesTheFileWhenAbsent(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
f := New[payload](p)
|
||||||
|
|
||||||
|
value, err := f.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if value.Name != "" || len(value.Items) != 0 {
|
||||||
|
t.Errorf("value = %+v, want the zero value", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the file must be created on load: %v", err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != 0o600 {
|
||||||
|
t.Errorf("mode = %o, want 600", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_CreatesTheDirectoryWhenAbsent(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "nested", "deeper", "component.state")
|
||||||
|
|
||||||
|
if _, err := New[payload](p).Load(); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(filepath.Dir(p))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the directory must be created: %v", err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != 0o700 {
|
||||||
|
t.Errorf("directory mode = %o, want 700", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_EmptyFileYieldsTheZeroValue(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
if err := os.WriteFile(p, nil, 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := New[payload](p).Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an empty file is a valid starting point: %v", err)
|
||||||
|
}
|
||||||
|
if value.Name != "" {
|
||||||
|
t.Errorf("value = %+v, want the zero value", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_CorruptedFileIsReported(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := New[payload](p).Load(); err == nil {
|
||||||
|
t.Fatal("a corrupted file must be reported, not silently ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_RoundTrips(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
f := New[payload](p)
|
||||||
|
|
||||||
|
want := payload{Name: "vp-admin_br-000001", Items: []string{"a", "b"}}
|
||||||
|
if err := f.Save(want); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := New[payload](p).Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != want.Name || len(got.Items) != len(want.Items) {
|
||||||
|
t.Errorf("value = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_RestoresTheModeAfterAnExternalChmod(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
f := New[payload](p)
|
||||||
|
if _, err := f.Load(); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(p, 0o644); err != nil {
|
||||||
|
t.Fatalf("Chmod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Save(payload{Name: "x"}); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != 0o600 {
|
||||||
|
t.Errorf("mode = %o, want 600: each save must replace the file, not edit it in place", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_LeavesNoTemporaryFileBehind(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
f := New[payload](p)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
if err := f.Save(payload{Name: "x"}); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(p))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("directory holds %d entries, want only the state file: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSave_DoesNotTruncateOnEncodingFailure(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
good := New[payload](p)
|
||||||
|
if err := good.Save(payload{Name: "kept"}); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
broken := New[chan int](p)
|
||||||
|
if err := broken.Save(make(chan int)); err == nil {
|
||||||
|
t.Fatal("an unencodable value must be reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := good.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "kept" {
|
||||||
|
t.Errorf("value = %+v, want the previous state untouched", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemove_DeletesTheFile(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
f := New[payload](p)
|
||||||
|
if _, err := f.Load(); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Remove(); err != nil {
|
||||||
|
t.Fatalf("Remove: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("the file must be gone, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemove_OnAnAbsentFileIsNotAnError(t *testing.T) {
|
||||||
|
if err := New[payload](path(t)).Remove(); err != nil {
|
||||||
|
t.Errorf("removing an absent file must be idempotent, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPath_ReportsTheFileItOwns(t *testing.T) {
|
||||||
|
p := path(t)
|
||||||
|
if got := New[payload](p).Path(); got != p {
|
||||||
|
t.Errorf("Path = %s, want %s", got, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue