Files
felhom-agent/internal/config/cutwrite_test.go
T

141 lines
4.7 KiB
Go

package config
import (
"errors"
"os"
"path/filepath"
"testing"
)
const (
oldCfg = `{"proxmox":{"tls":{"fingerprint":"AA:BB"}},"keep":"me"}`
newCfg = `{"proxmox":{"tls":{"ca_file":"/etc/felhom-agent/pve-root-ca.pem","fingerprint":""}},"keep":"me"}`
)
// cutBox is a config file and a journal folder in a temp dir, with the write cut at one named step.
func cutBox(t *testing.T) (cfg string) {
t.Helper()
dir := t.TempDir()
cfg = filepath.Join(dir, "etc", "agent.json")
if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfg, []byte(oldCfg), 0o600); err != nil {
t.Fatal(err)
}
prevDir, prevCut := CutWriteDir, cutPoint
CutWriteDir = filepath.Join(dir, "state")
t.Cleanup(func() { CutWriteDir, cutPoint = prevDir, prevCut })
return cfg
}
func cutAt(step string) {
cutPoint = func(s string) error {
if s == step {
return errors.New("power cut at " + s)
}
return nil
}
}
func read(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// The consequence that matters: after a power cut at ANY step of the write and the repair at the next start, the
// config is the whole old file or the whole new file — never a broken one — and Load reads it.
func TestRecoverCutWrite_AfterACutAtEveryStepTheConfigIsWholeAndLoads(t *testing.T) {
want := map[string]string{
"journal-written": oldCfg, // nothing was touched yet
"truncated": oldCfg, // the file is EMPTY at the cut
"half-written": oldCfg, // the file is HALF at the cut
"synced": newCfg, // the new file is whole; only the journal is left
}
for step, wantAfter := range want {
t.Run(step, func(t *testing.T) {
cfg := cutBox(t)
cutAt(step)
if err := ReplaceInPlace(cfg, []byte(oldCfg), []byte(newCfg)); err == nil {
t.Fatal("the staged cut did not stop the write")
}
atCut := read(t, cfg)
if (step == "truncated" || step == "half-written") && isJSONObject([]byte(atCut)) {
t.Fatalf("the staging is wrong: at %s the file must be broken, it is %q", step, atCut)
}
cutPoint = func(string) error { return nil } // the next start: no cut
note, err := RecoverCutWrite(cfg)
if err != nil {
t.Fatalf("repair: %v", err)
}
if note == "" {
t.Fatal("the repair saw no journal — a cut write would stay broken")
}
if got := read(t, cfg); got != wantAfter {
t.Fatalf("after a cut at %s and the repair the config is %q, want %q", step, got, wantAfter)
}
if _, err := Load(cfg); err != nil {
t.Fatalf("the agent does not start with the repaired config: %v", err)
}
if _, err := os.Stat(filepath.Join(CutWriteDir, cutWriteJournal)); !os.IsNotExist(err) {
t.Fatalf("the journal stayed after the repair (%v) — an old journal would undo a later change", err)
}
})
}
}
// A write that is not cut leaves the new file and NO journal: a journal that stayed would put an old config back
// at some later start.
func TestReplaceInPlace_AWholeWriteLeavesTheNewFileAndNoJournal(t *testing.T) {
cfg := cutBox(t)
if err := ReplaceInPlace(cfg, []byte(oldCfg), []byte(newCfg)); err != nil {
t.Fatal(err)
}
if got := read(t, cfg); got != newCfg {
t.Fatalf("config = %q", got)
}
if _, err := os.Stat(filepath.Join(CutWriteDir, cutWriteJournal)); !os.IsNotExist(err) {
t.Fatalf("the journal stayed after a whole write: %v", err)
}
if note, err := RecoverCutWrite(cfg); err != nil || note != "" {
t.Fatalf("a start after a whole write must do nothing: note=%q err=%v", note, err)
}
if got := read(t, cfg); got != newCfg {
t.Fatalf("the start changed a whole config to %q", got)
}
}
// No journal folder, no write: the old file stays as it was.
func TestReplaceInPlace_WithoutAJournalNothingIsWritten(t *testing.T) {
cfg := cutBox(t)
// the journal cannot be made: a folder sits where its new file must be written
if err := os.MkdirAll(filepath.Join(CutWriteDir, cutWriteJournal+".tmp"), 0o700); err != nil {
t.Fatal(err)
}
if err := ReplaceInPlace(cfg, []byte(oldCfg), []byte(newCfg)); err == nil {
t.Fatal("the write went on with no journal")
}
if got := read(t, cfg); got != oldCfg {
t.Fatalf("the config changed with no journal: %q", got)
}
}
// A config that is broken with NO journal is not a cut write: the repair does not touch it, and says nothing.
func TestRecoverCutWrite_ABrokenConfigWithNoJournalIsLeftAlone(t *testing.T) {
cfg := cutBox(t)
if err := os.WriteFile(cfg, []byte(`{"half":`), 0o600); err != nil {
t.Fatal(err)
}
note, err := RecoverCutWrite(cfg)
if err != nil || note != "" {
t.Fatalf("note=%q err=%v", note, err)
}
if got := read(t, cfg); got != `{"half":` {
t.Fatalf("the repair wrote a config it had no journal for: %q", got)
}
}