H1/H2/M1: atomicity at the file boundary
H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
This commit is contained in:
parent
8e421f925e
commit
93ed180d8f
4 changed files with 131 additions and 19 deletions
|
|
@ -25,7 +25,7 @@ func TestZoneFile_LoadRRs(t *testing.T) {
|
|||
dir := withTempZonesDir(t, "auth.example.com")
|
||||
zf := openZoneFile(filepath.Join(dir, "auth.example.com.zone"), "auth.example.com.")
|
||||
|
||||
rrs, err := zf.loadRRs()
|
||||
rrs, _, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadRRs: %v", err)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ func TestZoneFile_LoadRRs(t *testing.T) {
|
|||
|
||||
func TestZoneFile_LoadRRs_MissingFile(t *testing.T) {
|
||||
zf := openZoneFile("/nope/missing.zone", "missing.")
|
||||
if _, err := zf.loadRRs(); err == nil {
|
||||
if _, _, err := zf.loadRRs(); err == nil {
|
||||
t.Errorf("expected error loading missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -234,7 +234,7 @@ func TestZoneFile_WriteAtomic_RoundTrip(t *testing.T) {
|
|||
zf := openZoneFile(path, "auth.example.com.")
|
||||
zf.AutoCommit = false // not testing commit here
|
||||
|
||||
rrs, err := zf.loadRRs()
|
||||
rrs, _, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
t.Fatalf("initial load: %v", err)
|
||||
}
|
||||
|
|
@ -247,7 +247,7 @@ func TestZoneFile_WriteAtomic_RoundTrip(t *testing.T) {
|
|||
}
|
||||
|
||||
// Re-load and verify.
|
||||
after, err := zf.loadRRs()
|
||||
after, _, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
t.Fatalf("re-load: %v", err)
|
||||
}
|
||||
|
|
@ -272,7 +272,7 @@ func TestZoneFile_WriteAtomic_LeavesNoTempFile(t *testing.T) {
|
|||
zf := openZoneFile(path, "auth.example.com.")
|
||||
zf.AutoCommit = false
|
||||
|
||||
rrs, _ := zf.loadRRs()
|
||||
rrs, _, _ := zf.loadRRs()
|
||||
_ = zf.writeAtomic(rrs, time.Now())
|
||||
|
||||
// No .rfc2136-*.zone temp files should remain.
|
||||
|
|
@ -282,13 +282,46 @@ func TestZoneFile_WriteAtomic_LeavesNoTempFile(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestZoneFile_CheckUnchanged_DetectsExternalModification covers H1:
|
||||
// between loadRRs and the next writeAtomic, if anything (rsync, manual
|
||||
// edit, git checkout) clobbered the file, checkUnchanged returns an
|
||||
// error so the caller refuses the UPDATE instead of losing the
|
||||
// external write.
|
||||
func TestZoneFile_CheckUnchanged_DetectsExternalModification(t *testing.T) {
|
||||
dir := withTempZonesDir(t, "auth.example.com")
|
||||
path := filepath.Join(dir, "auth.example.com.zone")
|
||||
zf := openZoneFile(path, "auth.example.com.")
|
||||
zf.AutoCommit = false
|
||||
|
||||
_, snap, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadRRs: %v", err)
|
||||
}
|
||||
|
||||
// Pristine snapshot — checkUnchanged should pass.
|
||||
if err := zf.checkUnchanged(snap); err != nil {
|
||||
t.Errorf("pristine snapshot rejected: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an external editor clobbering the file (rsync-style:
|
||||
// new content, different size, mtime advances).
|
||||
time.Sleep(10 * time.Millisecond) // ensure mtime differs
|
||||
if err := os.WriteFile(path, []byte("; external edit\n$ORIGIN auth.example.com.\nauth.example.com. 3600 IN SOA ns.example. admin.example. 1 60 60 60 60\n"), 0644); err != nil {
|
||||
t.Fatalf("simulated external edit: %v", err)
|
||||
}
|
||||
|
||||
if err := zf.checkUnchanged(snap); err == nil {
|
||||
t.Errorf("checkUnchanged accepted modified file; expected concurrent-modification error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneFile_WriteAtomic_FileEndsWithNewline(t *testing.T) {
|
||||
dir := withTempZonesDir(t, "auth.example.com")
|
||||
path := filepath.Join(dir, "auth.example.com.zone")
|
||||
zf := openZoneFile(path, "auth.example.com.")
|
||||
zf.AutoCommit = false
|
||||
|
||||
rrs, _ := zf.loadRRs()
|
||||
rrs, _, _ := zf.loadRRs()
|
||||
_ = zf.writeAtomic(rrs, time.Now())
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue