state: a state file without a section loads it empty (closes #267)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Load fills in each of domains, hostnames, ports and certificates that
the file leaves out or holds as null with an empty map. Before, such a
section loaded as a nil map, and the first check's write into it
panicked, so a state file holding {} stopped the watcher at every
start.
The test loads {} and a file missing each section in turn, then saves
an entry in every section as the first check does; it does not run a
check, which would look names up in live DNS.
Model: opus-5-5
This commit is contained in:
@@ -650,6 +650,10 @@ A port entry's `hostnames` lists every name that resolves to its address,
|
||||
domains included. A port entry in the older format, with one `hostname` instead
|
||||
of the `hostnames` list, loads as a list of that one name.
|
||||
|
||||
A state file that leaves out any of `domains`, `hostnames`, `ports` and
|
||||
`certificates`, or holds `null` for one, loads with that section empty. A file
|
||||
holding `{}` loads as empty state.
|
||||
|
||||
---
|
||||
|
||||
## Entrypoints
|
||||
|
||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
|
||||
that section empty; the first check no longer crashes on it (closes #267).
|
||||
- 2026-10-13: a save syncs the state file to disk before renaming it into place,
|
||||
so a crash cannot leave it empty and stop the next start (closes #264).
|
||||
- 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming
|
||||
|
||||
@@ -216,6 +216,24 @@ func (s *State) Load() error {
|
||||
return fmt.Errorf("parsing state file: %w", err)
|
||||
}
|
||||
|
||||
// A section the file leaves out, or holds as null, loads empty: the
|
||||
// setters write into these maps, and writing into a nil map panics.
|
||||
if snapshot.Domains == nil {
|
||||
snapshot.Domains = make(map[string]*DomainState)
|
||||
}
|
||||
|
||||
if snapshot.Hostnames == nil {
|
||||
snapshot.Hostnames = make(map[string]*HostnameState)
|
||||
}
|
||||
|
||||
if snapshot.Ports == nil {
|
||||
snapshot.Ports = make(map[string]*PortState)
|
||||
}
|
||||
|
||||
if snapshot.Certificates == nil {
|
||||
snapshot.Certificates = make(map[string]*CertificateState)
|
||||
}
|
||||
|
||||
// A state file saved before each record value was stored once can
|
||||
// hold a hostname's CNAME once for every record type asked for.
|
||||
// Each value is kept once, so the first check does not see a
|
||||
|
||||
@@ -695,6 +695,71 @@ func TestLoadMissingFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFileWithoutSections loads a state file holding {}, and files
|
||||
// that each leave out one section. Every section loads as an empty map,
|
||||
// and the first check can then save an entry in each; a section loaded
|
||||
// as nil makes that panic.
|
||||
func TestLoadFileWithoutSections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
}{
|
||||
{"no sections", `{}`},
|
||||
{"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`},
|
||||
{"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`},
|
||||
{"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`},
|
||||
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(
|
||||
filepath.Join(dir, "state.json"), []byte(tt.file), 0o600,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("writing state file: %v", err)
|
||||
}
|
||||
|
||||
s := state.NewForTestWithDataDir(dir)
|
||||
|
||||
err = s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
want := state.Snapshot{
|
||||
Domains: map[string]*state.DomainState{},
|
||||
Hostnames: map[string]*state.HostnameState{},
|
||||
Ports: map[string]*state.PortState{},
|
||||
Certificates: map[string]*state.CertificateState{},
|
||||
}
|
||||
|
||||
if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("loaded %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
s.SetDomainState("example.com", &state.DomainState{})
|
||||
s.SetHostnameState(testHostname, &state.HostnameState{})
|
||||
s.SetPortState(testIP+":443", &state.PortState{})
|
||||
s.SetCertificateState(
|
||||
testIP+":443:"+testHostname, &state.CertificateState{},
|
||||
)
|
||||
|
||||
snap := s.GetSnapshot()
|
||||
if len(snap.Domains) != 1 || len(snap.Hostnames) != 1 ||
|
||||
len(snap.Ports) != 1 || len(snap.Certificates) != 1 {
|
||||
t.Errorf("saved one entry in each section, got %#v", snap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadCorruptFile verifies that a corrupt state file returns an error.
|
||||
func TestLoadCorruptFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user