Files
coredns/plugin/clouddns/clouddns_test.go
Aradhya Jain 538c3554bc plugin/clouddns: add refresh directive (#8616)
Brings clouddns to parity with route53, which already exposes a
refresh directive to control how often zones are re-synced. clouddns
currently hardcodes this to 1 minute (delay := 1 * time.Minute in Run)
with no way to change it.

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

Extends TestSetupCloudDNS 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:47:32 -07:00

460 lines
12 KiB
Go

package clouddns
import (
"context"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/coredns/coredns/plugin/file"
"github.com/coredns/coredns/plugin/pkg/dnstest"
"github.com/coredns/coredns/plugin/pkg/fall"
"github.com/coredns/coredns/plugin/pkg/upstream"
"github.com/coredns/coredns/plugin/test"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
gcp "google.golang.org/api/dns/v1"
)
type fakeGCPClient struct {
*gcp.Service
}
func (c fakeGCPClient) zoneExists(_projectName, _hostedZoneName string) error {
return nil
}
func (c fakeGCPClient) listRRSets(_ctx context.Context, projectName, hostedZoneName string) (*gcp.ResourceRecordSetsListResponse, error) {
if projectName == "bad-project" || hostedZoneName == "bad-zone" {
return nil, errors.New("the 'parameters.managedZone' resource named 'bad-zone' does not exist")
}
var rr []*gcp.ResourceRecordSet
if hostedZoneName == "sample-zone-1" {
rr = []*gcp.ResourceRecordSet{
{
Name: "example.org.",
Ttl: 300,
Type: "A",
Rrdatas: []string{"1.2.3.4"},
},
{
Name: "www.example.org",
Ttl: 300,
Type: "A",
Rrdatas: []string{"1.2.3.4"},
},
{
Name: "*.www.example.org",
Ttl: 300,
Type: "CNAME",
Rrdatas: []string{"www.example.org"},
},
{
Name: "example.org.",
Ttl: 300,
Type: "AAAA",
Rrdatas: []string{"2001:db8:85a3::8a2e:370:7334"},
},
{
Name: "sample.example.org",
Ttl: 300,
Type: "CNAME",
Rrdatas: []string{"example.org"},
},
{
Name: "example.org.",
Ttl: 300,
Type: "PTR",
Rrdatas: []string{"ptr.example.org."},
},
{
Name: "org.",
Ttl: 300,
Type: "SOA",
Rrdatas: []string{"ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
{
Name: "com.",
Ttl: 300,
Type: "NS",
Rrdatas: []string{"ns-cloud-c4.googledomains.com."},
},
{
Name: "split-example.gov.",
Ttl: 300,
Type: "A",
Rrdatas: []string{"1.2.3.4"},
},
{
Name: "swag.",
Ttl: 300,
Type: "YOLO",
Rrdatas: []string{"foobar"},
},
}
} else {
rr = []*gcp.ResourceRecordSet{
{
Name: "split-example.org.",
Ttl: 300,
Type: "A",
Rrdatas: []string{"1.2.3.4"},
},
{
Name: "other-example.org.",
Ttl: 300,
Type: "A",
Rrdatas: []string{"3.5.7.9"},
},
{
Name: "org.",
Ttl: 300,
Type: "SOA",
Rrdatas: []string{"ns-cloud-e1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
{
Name: "_dummy._tcp.example.org.",
Ttl: 300,
Type: "SRV",
Rrdatas: []string{
"0 0 5269 split-example.org",
"0 0 5269 other-example.org",
},
},
}
}
return &gcp.ResourceRecordSetsListResponse{Rrsets: rr}, nil
}
func TestCloudDNS(t *testing.T) {
ctx := context.Background()
r, err := New(ctx, fakeGCPClient{}, map[string][]string{"bad.": {"bad-project:bad-zone"}}, &upstream.Upstream{}, time.Minute)
if err != nil {
t.Fatalf("Failed to create Cloud DNS: %v", err)
}
if err = r.Run(ctx); err == nil {
t.Fatalf("Expected errors for zone bad.")
}
r, err = New(ctx, fakeGCPClient{}, map[string][]string{"org.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"}, "gov.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"}}, &upstream.Upstream{}, time.Minute)
if err != nil {
t.Fatalf("Failed to create Cloud DNS: %v", err)
}
r.Fall = fall.Zero
r.Fall.SetZonesFromArgs([]string{"gov."})
r.Next = test.HandlerFunc(func(_ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
state := request.Request{W: w, Req: r}
qname := state.Name()
m := new(dns.Msg)
rcode := dns.RcodeServerFailure
if qname == "example.gov." {
m.SetReply(r)
rr, err := dns.NewRR("example.gov. 300 IN A 2.4.6.8")
if err != nil {
t.Fatalf("Failed to create Resource Record: %v", err)
}
m.Answer = []dns.RR{rr}
m.Authoritative = true
rcode = dns.RcodeSuccess
}
m.SetRcode(r, rcode)
w.WriteMsg(m)
return rcode, nil
})
err = r.Run(ctx)
if err != nil {
t.Fatalf("Failed to initialize Cloud DNS: %v", err)
}
tests := []struct {
qname string
qtype uint16
wantRetCode int
wantAnswer []string // ownernames for the records in the additional section.
wantMsgRCode int
wantNS []string
expectedErr error
}{
// 0. example.org A found - success.
{
qname: "example.org",
qtype: dns.TypeA,
wantAnswer: []string{"example.org. 300 IN A 1.2.3.4"},
},
// 1. example.org AAAA found - success.
{
qname: "example.org",
qtype: dns.TypeAAAA,
wantAnswer: []string{"example.org. 300 IN AAAA 2001:db8:85a3::8a2e:370:7334"},
},
// 2. exampled.org PTR found - success.
{
qname: "example.org",
qtype: dns.TypePTR,
wantAnswer: []string{"example.org. 300 IN PTR ptr.example.org."},
},
// 3. sample.example.org points to example.org CNAME.
// Query must return both CNAME and A recs.
{
qname: "sample.example.org",
qtype: dns.TypeA,
wantAnswer: []string{
"sample.example.org. 300 IN CNAME example.org.",
"example.org. 300 IN A 1.2.3.4",
},
},
// 4. Explicit CNAME query for sample.example.org.
// Query must return just CNAME.
{
qname: "sample.example.org",
qtype: dns.TypeCNAME,
wantAnswer: []string{"sample.example.org. 300 IN CNAME example.org."},
},
// 5. Explicit SOA query for example.org.
{
qname: "example.org",
qtype: dns.TypeNS,
wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
// 6. AAAA query for split-example.org must return NODATA.
{
qname: "split-example.gov",
qtype: dns.TypeAAAA,
wantRetCode: dns.RcodeSuccess,
wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
// 7. Zone not configured.
{
qname: "badexample.com",
qtype: dns.TypeA,
wantRetCode: dns.RcodeServerFailure,
wantMsgRCode: dns.RcodeServerFailure,
},
// 8. No record found. Return SOA record.
{
qname: "bad.org",
qtype: dns.TypeA,
wantRetCode: dns.RcodeSuccess,
wantMsgRCode: dns.RcodeNameError,
wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
// 9. No record found. Fallthrough.
{
qname: "example.gov",
qtype: dns.TypeA,
wantAnswer: []string{"example.gov. 300 IN A 2.4.6.8"},
},
// 10. other-zone.example.org is stored in a different hosted zone. success
{
qname: "other-example.org",
qtype: dns.TypeA,
wantAnswer: []string{"other-example.org. 300 IN A 3.5.7.9"},
},
// 11. split-example.org only has A record. Expect NODATA.
{
qname: "split-example.org",
qtype: dns.TypeAAAA,
wantNS: []string{"org. 300 IN SOA ns-cloud-e1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"},
},
// 12. *.www.example.org is a wildcard CNAME to www.example.org.
{
qname: "a.www.example.org",
qtype: dns.TypeA,
wantAnswer: []string{
"a.www.example.org. 300 IN CNAME www.example.org.",
"www.example.org. 300 IN A 1.2.3.4",
},
},
// 13. example.org SRV found with 2 answers - success.
{
qname: "_dummy._tcp.example.org.",
qtype: dns.TypeSRV,
wantAnswer: []string{
"_dummy._tcp.example.org. 300 IN SRV 0 0 5269 split-example.org.",
"_dummy._tcp.example.org. 300 IN SRV 0 0 5269 other-example.org.",
},
},
}
for ti, tc := range tests {
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype)
rec := dnstest.NewRecorder(&test.ResponseWriter{})
code, err := r.ServeDNS(ctx, rec, req)
if err != tc.expectedErr {
t.Fatalf("Test %d: Expected error %v, but got %v", ti, tc.expectedErr, err)
}
if code != tc.wantRetCode {
t.Fatalf("Test %d: Expected returned status code %s, but got %s", ti, dns.RcodeToString[tc.wantRetCode], dns.RcodeToString[code])
}
if tc.wantMsgRCode != rec.Msg.Rcode {
t.Errorf("Test %d: Unexpected msg status code. Want: %s, got: %s", ti, dns.RcodeToString[tc.wantMsgRCode], dns.RcodeToString[rec.Msg.Rcode])
}
if len(tc.wantAnswer) != len(rec.Msg.Answer) {
t.Errorf("Test %d: Unexpected number of Answers. Want: %d, got: %d", ti, len(tc.wantAnswer), len(rec.Msg.Answer))
} else {
for i, gotAnswer := range rec.Msg.Answer {
if gotAnswer.String() != tc.wantAnswer[i] {
t.Errorf("Test %d: Unexpected answer.\nWant:\n\t%s\nGot:\n\t%s", ti, tc.wantAnswer[i], gotAnswer)
}
}
}
if len(tc.wantNS) != len(rec.Msg.Ns) {
t.Errorf("Test %d: Unexpected NS number. Want: %d, got: %d", ti, len(tc.wantNS), len(rec.Msg.Ns))
} else {
for i, ns := range rec.Msg.Ns {
got, ok := ns.(*dns.SOA)
if !ok {
t.Errorf("Test %d: Unexpected NS type. Want: SOA, got: %v", ti, reflect.TypeFor[*dns.SOA]())
}
if got.String() != tc.wantNS[i] {
t.Errorf("Test %d: Unexpected NS.\nWant: %v\nGot: %v", ti, tc.wantNS[i], got)
}
}
}
}
}
// TestCloudDNSConcurrentServeDNS stresses r.ServeDNS directly to trigger
// concurrent Elem.Name() initialization from the file plugin.
func TestCloudDNSConcurrentServeDNS(t *testing.T) {
ctx := context.Background()
r, err := New(ctx,
fakeGCPClient{},
map[string][]string{
"org.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"},
},
&upstream.Upstream{},
time.Minute,
)
if err != nil {
t.Fatalf("Failed to create Cloud DNS: %v", err)
}
if err := r.Run(ctx); err != nil {
t.Fatalf("Failed to initialize Cloud DNS: %v", err)
}
queries := []struct {
qname string
qtype uint16
}{
{"example.org", dns.TypeA},
{"example.org", dns.TypeAAAA},
{"sample.example.org", dns.TypeA},
{"a.www.example.org", dns.TypeA},
{"split-example.org", dns.TypeA},
{"other-example.org", dns.TypeA},
{"_dummy._tcp.example.org.", dns.TypeSRV},
}
var wg sync.WaitGroup
// Concurrently refresh zones to race with Lookup reads.
wg.Go(func() {
for range 50 {
_ = r.updateZones(ctx)
}
})
const workers = 32
const iterations = 200
for w := range workers {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := range iterations {
tc := queries[(id+i)%len(queries)]
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype)
rec := dnstest.NewRecorder(&test.ResponseWriter{})
code, err := r.ServeDNS(ctx, rec, req)
if err != nil {
t.Errorf("ServeDNS error: %v", err)
return
}
if code != dns.RcodeSuccess {
t.Errorf("unexpected return code: %v", code)
return
}
}
}(w)
}
wg.Wait()
}
// TestCloudDNSConcurrentLookupNameCache stresses hostedZone.z.Lookup
// directly to trigger concurrent Elem.Name() initialization from the file plugin.
func TestCloudDNSConcurrentLookupNameCache(t *testing.T) {
ctx := context.Background()
r, err := New(ctx,
fakeGCPClient{},
map[string][]string{
"org.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"},
},
&upstream.Upstream{},
time.Minute)
if err != nil {
t.Fatalf("Failed to create Cloud DNS: %v", err)
}
if err := r.Run(ctx); err != nil {
t.Fatalf("Failed to initialize Cloud DNS: %v", err)
}
// Use a fixed set of qnames that exist in the zones to maximize reuse of the same Elems.
queries := []struct {
qname string
qtype uint16
}{
{"example.org.", dns.TypeA},
{"example.org.", dns.TypeAAAA},
{"sample.example.org.", dns.TypeA},
{"a.www.example.org.", dns.TypeA},
{"split-example.org.", dns.TypeA},
{"other-example.org.", dns.TypeA},
{"_dummy._tcp.example.org.", dns.TypeSRV},
}
// Fan-out goroutines that repeatedly call Lookup on the same Zone pointer.
const workers = 32
const iterations = 300
var wg sync.WaitGroup
for _, hostedZones := range r.zones {
for _, hz := range hostedZones {
z := hz.z
wg.Add(workers)
for w := range workers {
go func(id int, zptr *file.Zone) {
defer wg.Done()
for i := range iterations {
tc := queries[(id+i)%len(queries)]
req := new(dns.Msg)
req.SetQuestion(tc.qname, tc.qtype)
rec := dnstest.NewRecorder(&test.ResponseWriter{})
state := request.Request{W: rec, Req: req}
_, _, _, _ = zptr.Lookup(ctx, state, tc.qname)
}
}(w, z)
}
}
}
wg.Wait()
}