From ad8d23c8b8c4fb351196ace6627698eeb5a844e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Wed, 19 Aug 2020 13:01:12 +0200 Subject: [PATCH] Add a high-level DNS change test This more or less reproduces the scenario described in #726. --- js/initcontext_test.go | 14 ++-- js/runner.go | 4 +- js/runner_test.go | 51 ++++++++++++ lib/netext/dialer.go | 4 +- lib/netext/resolver.go | 63 ++++++++------- lib/netext/resolver_test.go | 92 ++++------------------ lib/testutils/httpmultibin/httpmultibin.go | 4 +- lib/testutils/resolver.go | 65 +++++++++++++++ 8 files changed, 178 insertions(+), 119 deletions(-) create mode 100644 lib/testutils/resolver.go diff --git a/js/initcontext_test.go b/js/initcontext_test.go index 76316abb2dc..0eb2021a3cf 100644 --- a/js/initcontext_test.go +++ b/js/initcontext_test.go @@ -42,6 +42,7 @@ import ( "github.com/loadimpact/k6/lib" "github.com/loadimpact/k6/lib/consts" "github.com/loadimpact/k6/lib/netext" + "github.com/loadimpact/k6/lib/testutils" "github.com/loadimpact/k6/stats" ) @@ -382,16 +383,19 @@ func TestRequestWithBinaryFile(t *testing.T) { logger.Level = logrus.DebugLevel logger.Out = ioutil.Discard + resolver := netext.NewResolver(testutils.NewMockResolver(nil)) + dialer := netext.NewDialer(net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 60 * time.Second, + DualStack: true, + }, resolver) + state := &lib.State{ Options: lib.Options{}, Logger: logger, Group: root, Transport: &http.Transport{ - DialContext: (netext.NewDialer(net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 60 * time.Second, - DualStack: true, - })).DialContext, + DialContext: dialer.DialContext, }, BPool: bpool.NewBufferPool(1), Samples: make(chan stats.SampleContainer, 500), diff --git a/js/runner.go b/js/runner.go index 713c1862938..e85a3fdd458 100644 --- a/js/runner.go +++ b/js/runner.go @@ -59,7 +59,7 @@ type Runner struct { defaultGroup *lib.Group BaseDialer net.Dialer - Resolver netext.Resolver + Resolver *netext.Resolver RPSLimit *rate.Limiter console *console @@ -99,7 +99,7 @@ func NewFromBundle(b *Bundle) (*Runner, error) { DualStack: true, }, console: newConsole(), - Resolver: netext.NewResolver(), + Resolver: netext.NewResolver(nil), } err = r.SetOptions(r.Bundle.Options) diff --git a/js/runner_test.go b/js/runner_test.go index d3cd1025e82..3d7d940cb62 100644 --- a/js/runner_test.go +++ b/js/runner_test.go @@ -55,6 +55,7 @@ import ( "github.com/loadimpact/k6/lib" _ "github.com/loadimpact/k6/lib/executor" // TODO: figure out something better "github.com/loadimpact/k6/lib/metrics" + "github.com/loadimpact/k6/lib/testutils" "github.com/loadimpact/k6/lib/testutils/httpmultibin" "github.com/loadimpact/k6/lib/types" "github.com/loadimpact/k6/stats" @@ -1665,3 +1666,53 @@ func TestSystemTags(t *testing.T) { }) } } + +func TestDNSCache(t *testing.T) { + tb := httpmultibin.NewHTTPMultiBin(t) + defer tb.Cleanup() + sr := tb.Replacer.Replace + + runner, err := getSimpleRunner("/script.js", tb.Replacer.Replace(` + var http = require("k6/http"); + var url = "HTTPBIN_URL/"; + exports.default = function() { + var res = http.get(url); + if (res.status != 200) { throw new Error("wrong status: " + res.status); } + } + `)) + if !assert.NoError(t, err) { + return + } + runner.SetOptions(lib.Options{ + Throw: null.BoolFrom(true), + MaxRedirects: null.IntFrom(10), + // NoConnectionReuse: null.BoolFrom(true), + }) + + resolver := testutils.NewMockResolver(nil) + samples := make(chan stats.SampleContainer, 100) + + runVU := func(rr string, expErr string) { + resolver.SetRR("httpbin.local.", rr) + initVU, err := runner.NewVU(1, samples) + // Replace the VU base resolver so that the DNS change can take effect. + (initVU.(*VU)).Dialer.Resolver.BaseResolver = resolver + require.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + vu := initVU.Activate(&lib.VUActivationParams{RunContext: ctx}) + err = vu.RunOnce() + if expErr != "" { + assert.Contains(t, err.Error(), expErr) + } else { + assert.NoError(t, err) + } + } + + runVU("0 IN A 127.0.0.1", "") + // Needs to be high enough for the connection to time out(?). + // If NoConnectionReuse is specified, RunOnce() doesn't return an error. :-/ + time.Sleep(5 * time.Second) + runVU("0 IN A 127.0.0.254", + sr("dial tcp 127.0.0.254:HTTPBIN_PORT: connect: connection refused")) +} diff --git a/lib/netext/dialer.go b/lib/netext/dialer.go index 45ee22b32da..d3e6f5d8741 100644 --- a/lib/netext/dialer.go +++ b/lib/netext/dialer.go @@ -38,7 +38,7 @@ import ( type Dialer struct { net.Dialer ctx context.Context - Resolver Resolver + Resolver *Resolver Blacklist []*lib.IPNet Hosts map[string]net.IP @@ -47,7 +47,7 @@ type Dialer struct { } // NewDialer constructs a new custom Dialer that wraps dialer and uses resolver. -func NewDialer(dialer net.Dialer, resolver Resolver) *Dialer { +func NewDialer(dialer net.Dialer, resolver *Resolver) *Dialer { return &Dialer{Dialer: dialer, Resolver: resolver} } diff --git a/lib/netext/resolver.go b/lib/netext/resolver.go index 2b3532b1997..cc179619f81 100644 --- a/lib/netext/resolver.go +++ b/lib/netext/resolver.go @@ -45,19 +45,14 @@ var ( } ) -// Resolver is the public DNS resolution interface. -type Resolver interface { - Resolve(ctx context.Context, host string, depth uint8) (net.IP, error) +// BaseResolver is the low level DNS resolution interface. +type BaseResolver interface { + Resolve(context.Context, *dns.Msg) (*dns.Msg, error) } -// baseResolver is an internal interface used to mock out the underlying -// resolver in tests. -type baseResolver interface { - resolve(context.Context, *dns.Msg) (*dns.Msg, error) -} - -// NewResolver returns a new DNS resolver with a preconfigured cache. -func NewResolver() Resolver { +// NewResolver returns a new DNS resolver with a preconfigured TTL-based cache +// using the given base resolver. If nil, the sdns resolver will be used. +func NewResolver(base BaseResolver) *Resolver { cfg := new(config.Config) cfg.RootServers = nameservers // TODO: Make this configurable? @@ -66,21 +61,25 @@ func NewResolver() Resolver { cfg.CacheSize = 1024 cfg.Timeout.Duration = 2 * time.Second - return &resolver{ - baseResolver: newSdnsResolver(cfg), + if base == nil { + base = newSdnsResolver(cfg) + } + + return &Resolver{ + BaseResolver: base, cache: cachem.New(cfg), ip4: make(map[string]bool), cname: make(map[string]canonicalName), } } -type resolver struct { - baseResolver baseResolver - ctx context.Context - authservers *authcache.AuthServers - cache *cachem.Cache - ip4 map[string]bool // IPv4 last seen - cname map[string]canonicalName +type Resolver struct { + BaseResolver + ctx context.Context + authservers *authcache.AuthServers + cache *cachem.Cache + ip4 map[string]bool // IPv4 last seen + cname map[string]canonicalName } // canonicalName is an expiring CNAME value. @@ -95,7 +94,7 @@ type sdnsResolver struct { authservers *authcache.AuthServers } -func newSdnsResolver(cfg *config.Config) baseResolver { +func newSdnsResolver(cfg *config.Config) BaseResolver { authservers := &authcache.AuthServers{} authservers.Zone = "." // should this be dynamic? for _, ns := range nameservers { @@ -111,14 +110,14 @@ func newSdnsResolver(cfg *config.Config) baseResolver { } } -func (r *sdnsResolver) resolve(ctx context.Context, req *dns.Msg) (*dns.Msg, error) { +func (r *sdnsResolver) Resolve(ctx context.Context, req *dns.Msg) (*dns.Msg, error) { return r.Resolver.Resolve(ctx, req, r.authservers, false, 30, 0, false, nil) } // Resolve maps a host string to an IP address. // Host string may be an IP address string or a domain name. // Follows CNAME chain up to depth steps. -func (r *resolver) Resolve(ctx context.Context, host string, depth uint8) (net.IP, error) { +func (r *Resolver) Resolve(ctx context.Context, host string, depth uint8) (net.IP, error) { r.ctx = ctx ip := net.ParseIP(host) if ip != nil { @@ -136,7 +135,7 @@ func (r *resolver) Resolve(ctx context.Context, host string, depth uint8) (net.I // Prefers IPv4 if last resolution produced it. // Otherwise prefers IPv6. // Package config constrains to only IP versions available on the system. -func (r *resolver) lookup(host string) (net.IP, dns.RR, error) { +func (r *Resolver) lookup(host string) (net.IP, dns.RR, error) { if ip6 && ip4 { // Both versions available if r.ip4[host] { @@ -179,7 +178,7 @@ func (r *resolver) lookup(host string) (net.IP, dns.RR, error) { // lookup64 performs a single lookup preferring IPv6. // Used on first resolution, if last resolution failed, // or if last resolution produced IPv6. -func (r *resolver) lookup64(host string) (net.IP, dns.RR, error) { +func (r *Resolver) lookup64(host string) (net.IP, dns.RR, error) { ip, cname, err := r.lookup6(host) if err != nil { return nil, nil, err @@ -207,7 +206,7 @@ func (r *resolver) lookup64(host string) (net.IP, dns.RR, error) { // lookup46 performs a single lookup preferring IPv4. // Used if last resolution produced IPv4. // Prevents hitting network looking for IPv6 for names with only IPv4. -func (r *resolver) lookup46(host string) (net.IP, dns.RR, error) { +func (r *Resolver) lookup46(host string) (net.IP, dns.RR, error) { ip, cname, err := r.lookup4(host) if err != nil { return nil, nil, err @@ -233,12 +232,12 @@ func (r *resolver) lookup46(host string) (net.IP, dns.RR, error) { } // lookup6 performs a single lookup for IPv6. -func (r *resolver) lookup6(host string) (net.IP, dns.RR, error) { +func (r *Resolver) lookup6(host string) (net.IP, dns.RR, error) { req := makeReq(host, dns.TypeA) key := cache.Hash(req.Question[0]) resp, _, err := r.cache.GetP(key, req) if resp == nil || err != nil { - resp, err = r.baseResolver.resolve(r.ctx, req) + resp, err = r.BaseResolver.Resolve(r.ctx, req) if resp != nil { r.cache.Set(key, resp) } @@ -259,12 +258,12 @@ func (r *resolver) lookup6(host string) (net.IP, dns.RR, error) { } // lookup4 performs a single lookup for IPv4. -func (r *resolver) lookup4(host string) (net.IP, dns.RR, error) { +func (r *Resolver) lookup4(host string) (net.IP, dns.RR, error) { req := makeReq(host, dns.TypeA) key := cache.Hash(req.Question[0]) resp, _, err := r.cache.GetP(key, req) if resp == nil || err != nil { - resp, err = r.baseResolver.resolve(r.ctx, req) + resp, err = r.BaseResolver.Resolve(r.ctx, req) if resp != nil { r.cache.Set(key, resp) } @@ -287,7 +286,7 @@ func (r *resolver) lookup4(host string) (net.IP, dns.RR, error) { // resolveName maps a domain name to an IP address. // Follows CNAME chain up to depth steps. // Fails on CNAME chain cycle. -func (r *resolver) resolveName( +func (r *Resolver) resolveName( requested string, name string, depth uint8, @@ -337,7 +336,7 @@ func (r *resolver) resolveName( // Follows CNAME chain up to depth steps. // Purges expired CNAME entries. // Fails on a cycle. -func (r *resolver) canonicalName(name string, depth uint8) (string, error) { +func (r *Resolver) canonicalName(name string, depth uint8) (string, error) { cname := normalName(name) observed := make(map[string]struct{}) observed[cname] = struct{}{} diff --git a/lib/netext/resolver_test.go b/lib/netext/resolver_test.go index 89851648272..b51b14a9bb5 100644 --- a/lib/netext/resolver_test.go +++ b/lib/netext/resolver_test.go @@ -1,113 +1,51 @@ package netext import ( - "context" - "fmt" - "net" "os" "testing" - "github.com/miekg/dns" - "github.com/semihalev/sdns/cache" - "github.com/semihalev/sdns/config" - cachem "github.com/semihalev/sdns/middleware/cache" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" -) - -// mockResolver implements both netext.Resolver and netext.baseResolver -type mockResolver struct { - hosts map[string]net.IP - cache *cache.Cache -} -func (r *mockResolver) Resolve(_ context.Context, host string, _ uint8) (resp *dns.Msg, err error) { - req := makeReq(host, dns.TypeA) - key := cache.Hash(req.Question[0]) - if val, ok := r.cache.Get(key); !ok { - resp, err = r.resolve(nil, req) - if resp != nil { - r.cache.Add(key, resp) - } - if err != nil { - return nil, err - } - } else { - resp = val.(*dns.Msg) - } - return -} - -func (r *mockResolver) resolve(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) { - resp = new(dns.Msg) - resp.SetReply(req) - host := req.Question[0].Name - if ip, ok := r.hosts[host]; ok { - var rtype string - if ip.To4() == nil { - rtype = "AAAA" - } else { - rtype = "A" - } - rs := fmt.Sprintf("%s 5 IN %s %s", host, rtype, ip) - rr, err := dns.NewRR(rs) - if err != nil { - return nil, err - } - resp.Answer = append(resp.Answer, rr) - } - return resp, nil -} - -func newMockResolver() *mockResolver { - return &mockResolver{ - cache: cache.New(1024), - hosts: map[string]net.IP{ - "host4.test.": net.ParseIP("127.0.0.1"), - "host6.test.": net.ParseIP("::1"), - }, - } -} - -func newTestResolver() *resolver { - cfg := new(config.Config) - cfg.Expire = 600 - cfg.CacheSize = 1024 + "github.com/loadimpact/k6/lib/testutils" +) - return &resolver{ - baseResolver: newMockResolver(), - cache: cachem.New(cfg), - ip4: make(map[string]bool), - cname: make(map[string]canonicalName), +func newTestResolver() *Resolver { + hosts := map[string]string{ + "host4.test.": "5 IN A 127.0.0.1", + "host6.test.": "5 IN AAAA ::1", } + baseResolver := testutils.NewMockResolver(hosts) + return NewResolver(baseResolver) } func TestLookup(t *testing.T) { t.Run("never resolved", func(t *testing.T) { r := newTestResolver() - require.False(t, r.ip4["example.com."]) + assert.False(t, r.ip4["example.com."]) }) t.Run("resolution failure", func(t *testing.T) { r := newTestResolver() _, _, err := r.lookup("example.badtld.") require.Error(t, err) - require.False(t, r.ip4["example.badtld."]) + assert.False(t, r.ip4["example.badtld."]) }) t.Run("find ipv6", func(t *testing.T) { r := newTestResolver() ip, _, err := r.lookup("host6.test.") require.NoError(t, err) - require.True(t, ip.To4() == nil) - require.False(t, r.ip4["host6.test."]) + assert.True(t, ip.To4() == nil) + assert.False(t, r.ip4["host6.test."]) }) t.Run("find ipv4", func(t *testing.T) { r := newTestResolver() ip, _, err := r.lookup("host4.test.") require.NoError(t, err) - require.True(t, ip.To4() != nil) - require.True(t, r.ip4["host4.test."]) + assert.Equal(t, "127.0.0.1", ip.String()) + assert.True(t, r.ip4["host4.test."]) }) } diff --git a/lib/testutils/httpmultibin/httpmultibin.go b/lib/testutils/httpmultibin/httpmultibin.go index 3feb268bea2..59352c03145 100644 --- a/lib/testutils/httpmultibin/httpmultibin.go +++ b/lib/testutils/httpmultibin/httpmultibin.go @@ -48,6 +48,7 @@ import ( "github.com/loadimpact/k6/lib/netext" "github.com/loadimpact/k6/lib/netext/httpext" + "github.com/loadimpact/k6/lib/testutils" ) // GetTLSClientConfig returns a TLS config that trusts the supplied @@ -242,12 +243,13 @@ func NewHTTPMultiBin(t testing.TB) *HTTPMultiBin { http2IP := net.ParseIP(http2URL.Hostname()) require.NotNil(t, http2IP) + resolver := netext.NewResolver(testutils.NewMockResolver(nil)) // Set up the dialer with shorter timeouts and the custom domains dialer := netext.NewDialer(net.Dialer{ Timeout: 2 * time.Second, KeepAlive: 10 * time.Second, DualStack: true, - }) + }, resolver) dialer.Hosts = map[string]net.IP{ httpDomain: httpIP, httpsDomain: httpsIP, diff --git a/lib/testutils/resolver.go b/lib/testutils/resolver.go new file mode 100644 index 00000000000..153888fabd6 --- /dev/null +++ b/lib/testutils/resolver.go @@ -0,0 +1,65 @@ +/* + * + * k6 - a next-generation load testing tool + * Copyright (C) 2020 Load Impact + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +package testutils + +import ( + "context" + "fmt" + "sync" + + "github.com/miekg/dns" +) + +// MockResolver implements netext.BaseResolver, and allows changing the defined +// hosts at runtime. +type MockResolver struct { + m *sync.Mutex + // Mapping of FQDNs including ending period to partial DNS resource records. + // E.g. "example.com.": "5 IN A 127.0.0.1" + hosts map[string]string +} + +func NewMockResolver(hosts map[string]string) *MockResolver { + if hosts == nil { + hosts = make(map[string]string) + } + return &MockResolver{&sync.Mutex{}, hosts} +} + +func (r *MockResolver) Resolve(_ context.Context, req *dns.Msg) (resp *dns.Msg, err error) { + resp = new(dns.Msg) + resp.SetReply(req) + host := req.Question[0].Name + if rrs, ok := r.hosts[host]; ok { + rr, err := dns.NewRR(fmt.Sprintf("%s %s", host, rrs)) + if err != nil { + return nil, err + } + resp.Answer = append(resp.Answer, rr) + } + return resp, nil +} + +func (r *MockResolver) SetRR(host, rr string) { + r.m.Lock() + defer r.m.Unlock() + r.hosts[host] = rr +}