plugin/file: trigger reload of zones based on mtime (#8085)

* Added fs.FileInfo.ModTime() based reload feature

Signed-off-by: Endre Szabo <git@end.re>

* Updated the plugin documentation.

Signed-off-by: Endre Szabo <git@end.re>

---------

Signed-off-by: Endre Szabo <git@end.re>
This commit is contained in:
Endre Szabo
2026-05-20 04:43:46 +02:00
committed by GitHub
parent ee7ff82cf5
commit 35391dd8a9
6 changed files with 283 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ If you want to round-robin A and AAAA responses look at the *loadbalance* plugin
~~~ ~~~
file DBFILE [ZONES... ] { file DBFILE [ZONES... ] {
reload DURATION reload DURATION
reload_by_mtime
fallthrough [ZONES...] fallthrough [ZONES...]
} }
~~~ ~~~
@@ -34,6 +35,8 @@ file DBFILE [ZONES... ] {
* `reload` interval to perform a reload of the zone if the SOA version changes. Default is one minute. * `reload` interval to perform a reload of the zone if the SOA version changes. Default is one minute.
Value of `0` means to not scan for changes and reload. For example, `30s` checks the zonefile every 30 seconds Value of `0` means to not scan for changes and reload. For example, `30s` checks the zonefile every 30 seconds
and reloads the zone when serial changes. and reloads the zone when serial changes.
* `reload_by_mtime` if set, decision to reload the zone will be based on the zone file modification time,
instead of change in the SOA serial.
* `fallthrough` If zone matches and no record can be generated, pass request to the next plugin. * `fallthrough` If zone matches and no record can be generated, pass request to the next plugin.
If **[ZONES...]** is omitted, then fallthrough happens for all zones for which the plugin If **[ZONES...]** is omitted, then fallthrough happens for all zones for which the plugin
is authoritative. If specific zones are listed (for example `in-addr.arpa` and `ip6.arpa`), then only is authoritative. If specific zones are listed (for example `in-addr.arpa` and `ip6.arpa`), then only

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/pkg/fall" "github.com/coredns/coredns/plugin/pkg/fall"
@@ -148,6 +149,15 @@ func Parse(f io.Reader, origin, fileName string, serial int64) (*Zone, error) {
zp := dns.NewZoneParser(f, dns.Fqdn(origin), fileName) zp := dns.NewZoneParser(f, dns.Fqdn(origin), fileName)
zp.SetIncludeAllowed(true) zp.SetIncludeAllowed(true)
z := NewZone(origin, fileName) z := NewZone(origin, fileName)
if z.ReloadByMtime {
fi, err := os.Stat(fileName)
if err != nil {
return nil, fmt.Errorf("failed to stat file %q with error %v", fileName, err)
}
z.file_mtime = fi.ModTime()
}
seenSOA := false seenSOA := false
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
if !seenSOA { if !seenSOA {

View File

@@ -20,13 +20,26 @@ func (z *Zone) Reload(t *transfer.Transfer) error {
select { select {
case <-tick.C: case <-tick.C:
zFile := z.File() zFile := z.File()
serial := z.SOASerialIfDefined()
if z.ReloadByMtime {
fi, err := os.Stat(zFile)
if err != nil {
log.Errorf("Failed to stat zone %q in %q: %v", z.origin, zFile, err)
continue
}
if !fi.ModTime().After(z.file_mtime) {
continue
}
serial = 0 // force reload of the zone
}
reader, err := os.Open(filepath.Clean(zFile)) reader, err := os.Open(filepath.Clean(zFile))
if err != nil { if err != nil {
log.Errorf("Failed to open zone %q in %q: %v", z.origin, zFile, err) log.Errorf("Failed to open zone %q in %q: %v", z.origin, zFile, err)
continue continue
} }
serial := z.SOASerialIfDefined()
zone, err := Parse(reader, z.origin, zFile, serial) zone, err := Parse(reader, z.origin, zFile, serial)
reader.Close() reader.Close()
if err != nil { if err != nil {

View File

@@ -77,6 +77,253 @@ func TestZoneReloadSOAChange(t *testing.T) {
} }
} }
func TestZoneReloadByMtime(t *testing.T) {
// Test 1: Basic mtime trigger - file modification should trigger reload
t.Run("BasicMtimeTrigger", func(t *testing.T) {
fileName, rm, err := test.TempFile(".", reloadZoneTest)
if err != nil {
t.Fatalf("Failed to create zone: %s", err)
}
defer rm()
reader, err := os.Open(fileName)
if err != nil {
t.Fatalf("Failed to open zone: %s", err)
}
z, err := Parse(reader, "miek.nl", fileName, 0)
if err != nil {
t.Fatalf("Failed to parse zone: %s", err)
}
reader.Close()
// Enable mtime-based reload
z.ReloadInterval = 10 * time.Millisecond
z.ReloadByMtime = true
z.Reload(&transfer.Transfer{})
// Wait for initial load to complete
time.Sleep(20 * time.Millisecond)
// Verify initial content (5 records)
rrs, err := z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
if len(rrs) != 5 {
t.Fatalf("Expected 5 initial RRs, got %d", len(rrs))
}
// Modify the zone file (this changes mtime)
if err := os.WriteFile(fileName, []byte(reloadZone2Test), 0644); err != nil {
t.Fatalf("Failed to write new zone data: %s", err)
}
// Wait for reload to trigger
time.Sleep(30 * time.Millisecond)
// Verify reload occurred (3 records now)
rrs, err = z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
if len(rrs) != 3 {
t.Fatalf("Expected 3 RRs after reload, got %d", len(rrs))
}
})
// Test 2: No reload when mtime unchanged
t.Run("NoReloadWhenMtimeUnchanged", func(t *testing.T) {
fileName, rm, err := test.TempFile(".", reloadZoneTest)
if err != nil {
t.Fatalf("Failed to create zone: %s", err)
}
defer rm()
reader, err := os.Open(fileName)
if err != nil {
t.Fatalf("Failed to open zone: %s", err)
}
z, err := Parse(reader, "miek.nl", fileName, 0)
if err != nil {
t.Fatalf("Failed to parse zone: %s", err)
}
reader.Close()
// Enable mtime-based reload
z.ReloadInterval = 10 * time.Millisecond
z.ReloadByMtime = true
z.Reload(&transfer.Transfer{})
// Wait for initial load
time.Sleep(20 * time.Millisecond)
// Record initial SOA serial
initialSerial := z.SOASerialIfDefined()
if initialSerial == -1 {
t.Fatal("Failed to get initial SOA serial")
}
// Record initial record count
rrs, err := z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
initialCount := len(rrs)
// Wait for multiple reload intervals WITHOUT modifying the file
time.Sleep(50 * time.Millisecond)
// Verify no reload occurred
currentSerial := z.SOASerialIfDefined()
if currentSerial != initialSerial {
t.Fatalf("SOA serial changed unexpectedly: %d -> %d", initialSerial, currentSerial)
}
rrs, err = z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
if len(rrs) != initialCount {
t.Fatalf("Record count changed unexpectedly: %d -> %d", initialCount, len(rrs))
}
})
// Test 3: Content verification after reload
t.Run("ContentVerificationAfterReload", func(t *testing.T) {
fileName, rm, err := test.TempFile(".", reloadZoneTest)
if err != nil {
t.Fatalf("Failed to create zone: %s", err)
}
defer rm()
reader, err := os.Open(fileName)
if err != nil {
t.Fatalf("Failed to open zone: %s", err)
}
z, err := Parse(reader, "miek.nl", fileName, 0)
if err != nil {
t.Fatalf("Failed to parse zone: %s", err)
}
reader.Close()
// Enable mtime-based reload
z.ReloadInterval = 10 * time.Millisecond
z.ReloadByMtime = true
z.Reload(&transfer.Transfer{})
ctx := context.TODO()
// Query initial content
r := new(dns.Msg)
r.SetQuestion("miek.nl", dns.TypeNS)
state := request.Request{W: &test.ResponseWriter{}, Req: r}
records, _, _, res := z.Lookup(ctx, state, "miek.nl.")
if res != Success {
t.Fatalf("Failed to lookup initial NS records, got %d", res)
}
// Initial zone has 4 NS records
if len(records) != 4 {
t.Fatalf("Expected 4 initial NS records, got %d", len(records))
}
// Modify to new zone content (only 2 NS records)
if err := os.WriteFile(fileName, []byte(reloadZone2Test), 0644); err != nil {
t.Fatalf("Failed to write new zone data: %s", err)
}
// Wait for reload
time.Sleep(30 * time.Millisecond)
// Query new content
records, _, _, res = z.Lookup(ctx, state, "miek.nl.")
if res != Success {
t.Fatalf("Failed to lookup reloaded NS records, got %d", res)
}
// Reloaded zone has 2 NS records
if len(records) != 2 {
t.Fatalf("Expected 2 reloaded NS records, got %d", len(records))
}
// Verify the actual NS record names match the new zone
nsNames := make([]string, len(records))
for i, rr := range records {
nsNames[i] = rr.(*dns.NS).Ns
}
expectedNS := []string{"ext.ns.whyscream.net.", "omval.tednet.nl."}
for i, expected := range expectedNS {
if nsNames[i] != expected {
t.Errorf("Expected NS record %d to be %s, got %s", i, expected, nsNames[i])
}
}
})
// Test 4: File deleted/missing during reload
t.Run("FileMissingDuringReload", func(t *testing.T) {
fileName, rm, err := test.TempFile(".", reloadZoneTest)
if err != nil {
t.Fatalf("Failed to create zone: %s", err)
}
defer rm()
reader, err := os.Open(fileName)
if err != nil {
t.Fatalf("Failed to open zone: %s", err)
}
z, err := Parse(reader, "miek.nl", fileName, 0)
if err != nil {
t.Fatalf("Failed to parse zone: %s", err)
}
reader.Close()
// Enable mtime-based reload
z.ReloadInterval = 10 * time.Millisecond
z.ReloadByMtime = true
z.Reload(&transfer.Transfer{})
// Wait for initial load
time.Sleep(20 * time.Millisecond)
// Verify initial content is loaded
rrs, err := z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
initialCount := len(rrs)
// Delete the zone file
if err := os.Remove(fileName); err != nil {
t.Fatalf("Failed to remove zone file: %s", err)
}
// Wait for reload interval (reload should fail gracefully)
time.Sleep(30 * time.Millisecond)
// Verify zone still serves old content (didn't crash)
rrs, err = z.ApexIfDefined()
if err != nil {
t.Fatal(err)
}
if len(rrs) != initialCount {
t.Fatalf("Zone content changed unexpectedly after file deletion: %d -> %d", initialCount, len(rrs))
}
// Verify DNS queries still work
ctx := context.TODO()
r := new(dns.Msg)
r.SetQuestion("miek.nl", dns.TypeSOA)
state := request.Request{W: &test.ResponseWriter{}, Req: r}
_, _, _, res := z.Lookup(ctx, state, "miek.nl.")
if res != Success {
t.Fatalf("Zone should still serve queries after file deletion, got result %d", res)
}
})
}
const reloadZoneTest = `miek.nl. 1627 IN SOA linode.atoom.net. miek.miek.nl. 1460175181 14400 3600 604800 14400 const reloadZoneTest = `miek.nl. 1627 IN SOA linode.atoom.net. miek.miek.nl. 1460175181 14400 3600 604800 14400
miek.nl. 1627 IN NS ext.ns.whyscream.net. miek.nl. 1627 IN NS ext.ns.whyscream.net.
miek.nl. 1627 IN NS omval.tednet.nl. miek.nl. 1627 IN NS omval.tednet.nl.

View File

@@ -77,6 +77,7 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) {
var openErr error var openErr error
reload := 1 * time.Minute reload := 1 * time.Minute
reload_by_mtime := false
for c.Next() { for c.Next() {
// file db.file [zones...] // file db.file [zones...]
@@ -131,6 +132,8 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) {
return Zones{}, fall, plugin.Error("file", err) return Zones{}, fall, plugin.Error("file", err)
} }
reload = d reload = d
case "reload_by_mtime":
reload_by_mtime = true
case "upstream": case "upstream":
// remove soon // remove soon
c.RemainingArgs() c.RemainingArgs()
@@ -143,6 +146,7 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) {
for i := range origins { for i := range origins {
z[origins[i]].ReloadInterval = reload z[origins[i]].ReloadInterval = reload
z[origins[i]].Upstream = upstream.New() z[origins[i]].Upstream = upstream.New()
z[origins[i]].ReloadByMtime = reload_by_mtime
} }
} }

View File

@@ -18,6 +18,7 @@ type Zone struct {
origin string origin string
origLen int origLen int
file string file string
file_mtime time.Time
*tree.Tree *tree.Tree
Apex Apex
Expired bool Expired bool
@@ -28,6 +29,7 @@ type Zone struct {
TransferFrom []string TransferFrom []string
ReloadInterval time.Duration ReloadInterval time.Duration
ReloadByMtime bool
reloadShutdown chan bool reloadShutdown chan bool
Upstream *upstream.Upstream // Upstream for looking up external names during the resolution process. Upstream *upstream.Upstream // Upstream for looking up external names during the resolution process.