gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
141 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|