Files
coredns/plugin/azure/sync_test.go
Aradhya Jain ef18404448 plugin/azure: add refresh directive (#8614)
Brings azure to parity with route53, which already exposes a refresh
directive to control how often zones are re-synced. azure currently
hardcodes this to 1 minute (Run calls h.run(ctx, time.Minute)) with no
way to change it, even though the internal run() method already takes
an arbitrary interval parameter - the plumbing exists, it just wasn't
exposed as a Corefile option.

Adds a refresh field to the Azure struct, threads it through New(), and
has Run() use h.refresh instead of the hardcoded value. Parses the
refresh directive in parse() mirroring route53's exact validation
(accepts a plain number as seconds or a duration string, must be
greater than 0).

Extends TestSetup with refresh coverage (valid duration, valid plain
number, missing arg, non-numeric, zero, negative). Verified it fails
without the setup.go change (unknown property error) and passes with
it.

Signed-off-by: stackedbyaradhya <aradhyaj736@gmail.com>
2026-10-08 18:48:10 -07:00

477 lines
16 KiB
Go

package azure
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/coredns/coredns/plugin/test"
publicdns "github.com/Azure/azure-sdk-for-go/profiles/latest/dns/mgmt/dns"
privatedns "github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns"
"github.com/miekg/dns"
)
type azureAPIHandler func(http.ResponseWriter, *http.Request) bool
// Use the real SDK's HTTP decoding, pagination and cancellation paths.
func newTestAzure(t *testing.T, private bool, handle azureAPIHandler) (*Azure, *atomic.Int32) {
t.Helper()
requests := new(atomic.Int32)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if handle != nil && handle(w, r) {
return
}
name := "healthy.example"
if strings.Contains(r.URL.Path, "/missing.example/") {
name = "missing.example"
} else if !strings.Contains(r.URL.Path, "/healthy.example/") {
t.Errorf("unexpected API path %q", r.URL.Path)
http.Error(w, "unexpected API path", http.StatusBadRequest)
return
}
writeAzureRecords(t, w, private, name, "", true, true)
}))
t.Cleanup(server.Close)
client := server.Client()
client.Timeout = 5 * time.Second
publicClient := publicdns.NewRecordSetsClientWithBaseURI(server.URL, "test-subscription")
privateClient := privatedns.NewRecordSetsClientWithBaseURI(server.URL, "test-subscription")
publicClient.Sender, privateClient.Sender = client, client
// Zero skips the request entirely in the legacy registration wrapper.
publicClient.RetryAttempts, privateClient.RetryAttempts = 1, 1
publicClient.RetryDuration, privateClient.RetryDuration = time.Millisecond, time.Millisecond
access := "public"
if private {
access = "private"
}
h, err := New(context.Background(), publicClient, privateClient,
map[string][]string{"rg": {"healthy.example", "missing.example"}},
map[string]string{"rghealthy.example": access, "rgmissing.example": access},
time.Minute)
if err != nil {
t.Fatal(err)
}
return h, requests
}
func writeAzureRecords(t *testing.T, w http.ResponseWriter, private bool, name, next string, soa, address bool) {
t.Helper()
ttlKey, soaKey, aKey, minimumKey := "TTL", "SOARecord", "ARecords", "minimumTTL"
if private {
ttlKey, soaKey, aKey, minimumKey = "ttl", "soaRecord", "aRecords", "minimumTtl"
}
values := make([]any, 0, 2)
if soa {
values = append(values, map[string]any{"name": "@", "properties": map[string]any{
"fqdn": name + ".", ttlKey: 60, soaKey: map[string]any{
"host": "ns." + name + ".", "email": "hostmaster." + name + ".",
"serialNumber": 1, "refreshTime": 3600, "retryTime": 300,
"expireTime": 86400, minimumKey: 60,
},
}})
}
if address {
values = append(values, map[string]any{"name": "www", "properties": map[string]any{
"fqdn": "www." + name + ".", ttlKey: 60,
aKey: []any{map[string]string{"ipv4Address": "192.0.2.10"}},
}})
}
body := map[string]any{"value": values}
if next != "" {
body["nextLink"] = next
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Errorf("encode records: %v", err)
}
}
func writeAzureError(t *testing.T, w http.ResponseWriter, status int) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"code": http.StatusText(status), "message": "zone unavailable in test"},
}); err != nil {
t.Errorf("encode error: %v", err)
}
}
func azureQuery(h *Azure, name string) (int, *dns.Msg, error) {
r := new(dns.Msg)
r.SetQuestion("www."+name+".", dns.TypeA)
w := dnstest.NewRecorder(&test.ResponseWriter{})
code, err := h.ServeDNS(context.Background(), w, r)
return code, w.Msg, err
}
func hasAzureAnswer(h *Azure, name string) bool {
code, m, err := azureQuery(h, name)
if err != nil || code != dns.RcodeSuccess || m == nil || len(m.Answer) != 1 {
return false
}
a, ok := m.Answer[0].(*dns.A)
return ok && a.A.String() == "192.0.2.10"
}
func waitAzure(t *testing.T, check func() bool) {
t.Helper()
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for !check() {
select {
case <-timeout.C:
t.Fatal("timed out waiting for Azure synchronization")
case <-tick.C:
}
}
}
func testAzureModes(t *testing.T, test func(*testing.T, bool)) {
t.Helper()
t.Run("private", func(t *testing.T) { test(t, true) })
t.Run("public", func(t *testing.T) { test(t, false) })
}
func TestNewDoesNotContactAzure(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
h, requests := newTestAzure(t, private, func(w http.ResponseWriter, _ *http.Request) bool {
writeAzureError(t, w, http.StatusNotFound)
return true
})
if requests.Load() != 0 || len(h.zoneNames) != 2 {
t.Fatalf("New must configure both zones without HTTP requests: requests=%d zones=%v", requests.Load(), h.zoneNames)
}
})
}
func TestRunWithUnavailableZone(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if strings.Contains(r.URL.Path, "/missing.example/") {
writeAzureError(t, w, http.StatusNotFound)
return true
}
return false
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() { cancel(); h.updates.Wait() })
if err := h.Run(ctx); err != nil {
t.Fatal(err)
}
waitAzure(t, func() bool { return hasAzureAnswer(h, "healthy.example") })
if code, m, err := azureQuery(h, "missing.example"); code != dns.RcodeServerFailure || m != nil || err != nil {
t.Fatalf("unloaded zone: code=%d msg=%v err=%v", code, m, err)
}
})
}
func TestRunDoesNotWaitForAzure(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
entered := make(chan struct{})
var once sync.Once
h, requests := newTestAzure(t, private, func(_ http.ResponseWriter, r *http.Request) bool {
once.Do(func() { close(entered) })
<-r.Context().Done()
return true
})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() { cancel(); h.updates.Wait() })
started := make(chan error, 1)
go func() { started <- h.Run(ctx) }()
select {
case err := <-started:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run blocked on Azure")
}
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("background sync never contacted Azure")
}
cancel()
stopped := make(chan struct{})
go func() { h.updates.Wait(); close(stopped) }()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("cancellation did not stop the blocked synchronization")
}
if requests.Load() != 1 {
t.Fatalf("canceled sync contacted another zone: %d requests", requests.Load())
}
})
}
func TestZoneRecovery(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
var missing atomic.Bool
missing.Store(true)
var failures atomic.Int32
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if strings.Contains(r.URL.Path, "/missing.example/") && missing.Load() {
failures.Add(1)
writeAzureError(t, w, http.StatusNotFound)
return true
}
return false
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); h.run(ctx, 5*time.Millisecond) }()
t.Cleanup(func() { cancel(); <-done })
waitAzure(t, func() bool { return failures.Load() > 0 && hasAzureAnswer(h, "healthy.example") })
missing.Store(false)
waitAzure(t, func() bool { return hasAzureAnswer(h, "missing.example") })
})
}
func TestFailedUpdatePreservesZone(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
var fail atomic.Bool
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if fail.Load() && strings.Contains(r.URL.Path, "/missing.example/") {
writeAzureError(t, w, status)
return true
}
return false
})
if err := h.updateZones(context.Background()); err != nil {
t.Fatal(err)
}
old := h.zones["missing.example."][0].z
healthy := h.zones["healthy.example."][0].z
fail.Store(true)
if err := h.updateZones(context.Background()); err == nil {
t.Fatal("expected update error")
}
if h.zones["missing.example."][0].z != old || !hasAzureAnswer(h, "missing.example") || !hasAzureAnswer(h, "healthy.example") {
t.Fatal("failed refresh lost valid zone data")
}
if h.zones["healthy.example."][0].z == healthy {
t.Fatal("failed zone prevented healthy zone refresh")
}
})
}
})
}
func TestZonePagination(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
for _, status := range []int{http.StatusOK, http.StatusNotFound, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
var paginated atomic.Bool
var pageRequests atomic.Int32
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if !paginated.Load() || !strings.Contains(r.URL.Path, "/missing.example/") {
return false
}
if r.URL.Query().Get("page") == "2" {
// Bound a regression that retries the unchanged previous page forever.
if pageRequests.Add(1) <= 4 && status != http.StatusOK {
writeAzureError(t, w, status)
} else {
writeAzureRecords(t, w, private, "missing.example", "", false, true)
}
} else {
next := "http://" + r.Host + r.URL.Path + "?page=2"
writeAzureRecords(t, w, private, "missing.example", next, true, false)
}
return true
})
if err := h.updateZones(context.Background()); err != nil {
t.Fatal(err)
}
old := h.zones["missing.example."][0].z
healthy := h.zones["healthy.example."][0].z
paginated.Store(true)
err := h.updateZones(context.Background())
if (err == nil) != (status == http.StatusOK) {
t.Fatalf("pagination status %d: err=%v", status, err)
}
if pageRequests.Load() == 0 || pageRequests.Load() > 2 {
t.Fatalf("unexpected next-page attempts: %d", pageRequests.Load())
}
if status != http.StatusOK && h.zones["missing.example."][0].z != old {
t.Fatal("published a partial zone after a pagination error")
}
if !hasAzureAnswer(h, "missing.example") || !hasAzureAnswer(h, "healthy.example") {
t.Fatal("lost an A answer after pagination")
}
if h.zones["healthy.example."][0].z == healthy {
t.Fatal("pagination failure prevented healthy zone refresh")
}
})
}
})
}
func TestCanceledPaginationPreservesZone(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
var paginated atomic.Bool
entered := make(chan struct{})
var once sync.Once
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if !paginated.Load() || !strings.Contains(r.URL.Path, "/missing.example/") {
return false
}
if r.URL.Query().Get("page") == "2" {
once.Do(func() { close(entered) })
<-r.Context().Done()
} else {
next := "http://" + r.Host + r.URL.Path + "?page=2"
writeAzureRecords(t, w, private, "missing.example", next, true, false)
}
return true
})
if err := h.updateZones(context.Background()); err != nil {
t.Fatal(err)
}
old := h.zones["missing.example."][0].z
paginated.Store(true)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { defer close(done); done <- h.updateZones(ctx) }()
t.Cleanup(func() { cancel(); <-done })
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("second page was not requested")
}
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("expected canceled pagination error")
}
case <-time.After(5 * time.Second):
t.Fatal("pagination did not stop on cancellation")
}
if h.zones["missing.example."][0].z != old || !hasAzureAnswer(h, "missing.example") {
t.Fatal("canceled pagination replaced valid zone data")
}
})
}
func TestIncompleteZoneIsNotPublished(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if strings.Contains(r.URL.Path, "/missing.example/") {
writeAzureRecords(t, w, private, "missing.example", "", false, true)
return true
}
return false
})
old := h.zones["missing.example."][0].z
if err := h.updateZones(context.Background()); err == nil {
t.Fatal("expected missing SOA error")
}
if h.zones["missing.example."][0].z != old || !hasAzureAnswer(h, "healthy.example") {
t.Fatal("incomplete zone was published or prevented healthy zone update")
}
})
}
func TestUnloadedZoneFallthrough(t *testing.T) {
h, _ := newTestAzure(t, true, nil)
var calls int
h.Next = test.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
calls++
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeRefused)
return dns.RcodeSuccess, w.WriteMsg(m)
})
h.Fall.SetZonesFromArgs([]string{"missing.example"})
if code, m, err := azureQuery(h, "healthy.example"); code != dns.RcodeServerFailure || m != nil || err != nil || calls != 0 {
t.Fatalf("out-of-scope fallthrough: code=%d msg=%v err=%v calls=%d", code, m, err, calls)
}
if code, m, err := azureQuery(h, "missing.example"); code != dns.RcodeSuccess || m == nil || m.Rcode != dns.RcodeRefused || err != nil || calls != 1 {
t.Fatalf("explicit fallthrough: code=%d msg=%v err=%v calls=%d", code, m, err, calls)
}
if _, _, err := azureQuery(h, "unrelated.example"); err != nil || calls != 2 {
t.Fatalf("unrelated query did not reach next plugin: err=%v calls=%d", err, calls)
}
}
func TestSameZoneInMultipleResourceGroups(t *testing.T) {
testAzureModes(t, func(t *testing.T, private bool) {
t.Helper()
h, _ := newTestAzure(t, private, func(w http.ResponseWriter, r *http.Request) bool {
if strings.Contains(r.URL.Path, "/missing-rg/") {
writeAzureError(t, w, http.StatusNotFound)
return true
}
return false
})
h.zones["healthy.example."] = append([]*zone{{id: "missing-rg", zone: "healthy.example", private: private, z: file.NewZone("healthy.example.", "")}}, h.zones["healthy.example."]...)
if err := h.updateZones(context.Background()); err == nil {
t.Fatal("expected missing resource group error")
}
if !hasAzureAnswer(h, "healthy.example") {
t.Fatal("missing resource group masked the healthy copy of the zone")
}
})
}
func TestConcurrentZoneUpdates(t *testing.T) {
h, _ := newTestAzure(t, true, nil)
if err := h.updateZones(context.Background()); err != nil {
t.Fatal(err)
}
var readers sync.WaitGroup
for range 4 {
readers.Go(func() {
for range 100 {
if !hasAzureAnswer(h, "healthy.example") {
t.Error("concurrent query lost its answer")
return
}
}
})
}
for range 20 {
if err := h.updateZones(context.Background()); err != nil {
t.Error(err)
}
}
readers.Wait()
}
func TestRunWithCanceledContext(t *testing.T) {
h, requests := newTestAzure(t, true, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := h.Run(ctx); err != nil {
t.Fatal(err)
}
h.updates.Wait()
if requests.Load() != 0 {
t.Fatalf("canceled synchronization issued %d requests", requests.Load())
}
}