Skip to content

Commit

Permalink
Add DNS change waiting
Browse files Browse the repository at this point in the history
  • Loading branch information
dnnrly committed Jul 1, 2022
1 parent ea89ab6 commit 3a70ab4
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 8 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ or environment.
Typically, you would use this to wait on another resource (such as an HTTP resource)
to become available before continuing - or timeout and exit with an error.

At the moment, you can wait for a few different kinds of thing. They are:

* HTTP or HTTPS success response
* TCP or GRPC connection
* DNS IP resolve address change

[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/dnnrly/wait-for)](https://github.com/dnnrly/wait-for/releases/latest)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/dnnrly/wait-for/Release%20workflow)](https://github.com/dnnrly/wait-for/actions?query=workflow%3A%22Release+workflow%22)
[![codecov](https://codecov.io/gh/dnnrly/wait-for/branch/main/graph/badge.svg?token=s0OfKkTFuI)](https://codecov.io/gh/dnnrly/wait-for)
Expand Down Expand Up @@ -92,6 +98,9 @@ wait-for:
snmp-service:
type: tcp
target: snmp-trap-dns:514
dns-thing:
type: dns
target: your.r53-entry.com
```
### Using `wait-for` in Docker Compose
Expand Down
8 changes: 8 additions & 0 deletions cmd/wait-for/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"log"
"net"
"os"

waitfor "github.com/dnnrly/wait-for"
Expand Down Expand Up @@ -40,6 +41,13 @@ func main() {
os.Exit(1)
}

waitfor.SupportedWaiters = map[string]waitfor.Waiter{
"http": waitfor.WaiterFunc(waitfor.HTTPWaiter),
"tcp": waitfor.WaiterFunc(waitfor.TCPWaiter),
"grpc": waitfor.WaiterFunc(waitfor.GRPCWaiter),
"dns": waitfor.NewDNSWaiter(net.LookupIP, logger),
}

err = waitfor.WaitOn(config, logger, flag.Args(), waitfor.SupportedWaiters)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v", err)
Expand Down
9 changes: 9 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ func (c *Config) AddFromString(t string) error {
return nil
}

if strings.HasPrefix(t, "dns:") {
c.Targets[t] = TargetConfig{
Target: strings.Replace(t, "dns:", "", 1),
Type: "dns",
Timeout: c.DefaultTimeout,
}
return nil
}

return errors.New("unable to understand target " + t)
}

Expand Down
7 changes: 6 additions & 1 deletion config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ func TestConfig_AddFromString(t *testing.T) {
assert.NoError(t, config.AddFromString("https://some-host/endpoint"))
assert.NoError(t, config.AddFromString("http://another-host/endpoint"))
assert.NoError(t, config.AddFromString("tcp:listener-tcp:9090"))
assert.NoError(t, config.AddFromString("dns:some.dns.com"))
assert.Error(t, config.AddFromString("udp:some-listener:9090"))

assert.Equal(t, 4, len(config.Targets))
assert.Equal(t, 5, len(config.Targets))

assert.Equal(t, "http://some-host/endpoint", config.Targets["http://some-host/endpoint"].Target)
assert.Equal(t, "http", config.Targets["http://some-host/endpoint"].Type)
Expand All @@ -107,6 +108,10 @@ func TestConfig_AddFromString(t *testing.T) {
assert.Equal(t, "listener-tcp:9090", config.Targets["tcp:listener-tcp:9090"].Target)
assert.Equal(t, "tcp", config.Targets["tcp:listener-tcp:9090"].Type)
assert.Equal(t, time.Second*5, config.Targets["tcp:listener-tcp:9090"].Timeout)

assert.Equal(t, "some.dns.com", config.Targets["dns:some.dns.com"].Target)
assert.Equal(t, "dns", config.Targets["dns:some.dns.com"].Type)
assert.Equal(t, time.Second*5, config.Targets["dns:some.dns.com"].Timeout)
}

func TestConfig_Filters(t *testing.T) {
Expand Down
64 changes: 59 additions & 5 deletions waitfor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/http"
"sort"
"strings"
"time"

"google.golang.org/grpc/credentials/insecure"
Expand Down Expand Up @@ -33,11 +35,7 @@ type Logger func(string, ...interface{})
var NullLogger = func(f string, a ...interface{}) {}

// SupportedWaiters is a mapping of known protocol names to waiter implementations
var SupportedWaiters = map[string]Waiter{
"http": WaiterFunc(HTTPWaiter),
"tcp": WaiterFunc(TCPWaiter),
"grpc": WaiterFunc(GRPCWaiter),
}
var SupportedWaiters map[string]Waiter

// WaitOn implements waiting for many targets, using the location of config file provided with named targets to wait until
// all of those targets are responding as expected
Expand Down Expand Up @@ -192,3 +190,59 @@ func isSuccess(code int) bool {

return true
}

type DNSLookup func(host string) ([]net.IP, error)

type DNSWaiter struct {
lookup DNSLookup
logger Logger
}

func NewDNSWaiter(lookup DNSLookup, logger Logger) *DNSWaiter {
return &DNSWaiter{
lookup: lookup,
logger: logger,
}
}

type IPList []net.IP

func (l IPList) Equals(r IPList) bool {
return l.String() == r.String()
}

func (l IPList) Len() int {
return len(l)
}
func (l IPList) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l IPList) Less(i, j int) bool { return strings.Compare(l[i].String(), l[j].String()) < 0 }
func (l IPList) String() string {
sort.Sort(l)
var s []string
for _, v := range l {
s = append(s, v.String())
}
return strings.Join(s, ",")
}

func (w *DNSWaiter) Wait(host string, target *TargetConfig) error {
in, _ := w.lookup(target.Target)
initial := IPList(in)
last := initial

start := time.Now()
now := start

for now.Sub(start) < target.Timeout {
w.logger("got DNS result %s", last)
time.Sleep(time.Second)
l, _ := w.lookup(target.Target)
last = IPList(l)

if !initial.Equals(last) {
return nil
}
now = time.Now()
}
return fmt.Errorf("timed out waiting for DNS update to %s", host)
}
131 changes: 129 additions & 2 deletions waitfor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ import (
"github.com/stretchr/testify/require"
)

var (
ip1 = net.IPv4(byte(0x01), byte(0x02), byte(0x03), byte(0x04))
ip2 = net.IPv4(byte(0x11), byte(0x12), byte(0x13), byte(0x14))
ip3 = net.IPv4(byte(0x21), byte(0x22), byte(0x23), byte(0x24))
ip4 = net.IPv4(byte(0x04), byte(0x05), byte(0x06), byte(0x07))
ip5 = net.IPv4(byte(0x14), byte(0x15), byte(0x16), byte(0x17))
ip6 = net.IPv4(byte(0x24), byte(0x22), byte(0x23), byte(0x24))
)

func Test_isSuccess(t *testing.T) {
assert.True(t, isSuccess(200))
assert.True(t, isSuccess(214))
Expand Down Expand Up @@ -229,11 +238,27 @@ func TestGRPCWaiter_succeedsImmediately(t *testing.T) {
Target: lis.Addr().String(),
Timeout: DefaultTimeout,
Type: "grpc",
}, SupportedWaiters["grpc"])
}, WaiterFunc(GRPCWaiter))

assert.Nil(t, err, "error waiting for grpc: %v", err)
}

func TestIPList_Equality(t *testing.T) {
l1 := IPList([]net.IP{ip1, ip2, ip3})
l2 := IPList([]net.IP{ip1, ip3, ip2})
l3 := IPList([]net.IP{ip3, ip3, ip2})
l4 := IPList([]net.IP{ip1, ip2, ip3, ip3})

assert.Truef(t, l1.Equals(l2), "%s != %s", l1, l2)
assert.Truef(t, l2.Equals(l1), "%s != %s", l2, l1)
assert.Falsef(t, l1.Equals(l3), "%s == %s", l1, l3)
assert.Falsef(t, l1.Equals(l4), "%s == %s", l1, l4)
}

func TestIPList_String(t *testing.T) {
assert.Equal(t, "1.2.3.4,17.18.19.20,33.34.35.36", IPList{ip1, ip2, ip3}.String())
}

func TestGRPCWaiter_failsToConnect(t *testing.T) {
server, lis, err := setupGrpcServer(t)
if err != nil {
Expand All @@ -245,8 +270,110 @@ func TestGRPCWaiter_failsToConnect(t *testing.T) {
Target: "localhost:8081",
Timeout: DefaultTimeout,
Type: "grpc",
}, SupportedWaiters["grpc"])
}, WaiterFunc(GRPCWaiter))

assert.NotNil(t, err, "expected error but error was nil")
fmt.Println(err)
}

func TestDNSWaiter_resolvesCorrectDNSName(t *testing.T) {
name := ""
w := NewDNSWaiter(func(host string) ([]net.IP, error) {
name = host
return []net.IP{ip1, ip2, ip3}, nil
}, NullLogger)

_ = w.Wait("dns1", &TargetConfig{
Target: "dns.name",
})
assert.Equal(t, "dns.name", name)
}

func TestDNSWaiter_timesOutOnSameDNS(t *testing.T) {
w := NewDNSWaiter(func(host string) ([]net.IP, error) { return []net.IP{ip1, ip2, ip3}, nil }, NullLogger)

start := time.Now()
err := w.Wait("dns1", &TargetConfig{
Target: "dns.name",
Timeout: time.Second,
})
end := time.Now()
require.Error(t, err)
assert.Equal(t, "timed out waiting for DNS update to dns1", err.Error())
assert.GreaterOrEqual(t, end.Sub(start), time.Second)
}

func TestDNSWaiter_successAfterDNSChange(t *testing.T) {
ips := [][]net.IP{
{ip1, ip2, ip3},
{ip1, ip2, ip3},
{ip4, ip5, ip6},
}
w := NewDNSWaiter(func(host string) ([]net.IP, error) {
next := ips[0]
if len(ips) > 0 {
ips = ips[1:]
}
return next, nil
}, NullLogger)

err := w.Wait("dns1", &TargetConfig{
Target: "dns.name",
Type: "dns",
Timeout: time.Second * 3,
})
require.NoError(t, err)
}

func TestDNSWaiter_allowsAddressrderChange(t *testing.T) {
ips := [][]net.IP{
{ip1, ip2, ip3},
{ip2, ip1, ip3},
{ip1, ip3, ip2},
}
w := NewDNSWaiter(func(host string) ([]net.IP, error) {
next := ips[0]
if len(ips) > 0 {
ips = ips[1:]
}
return next, nil
}, NullLogger)

err := w.Wait("dns1", &TargetConfig{
Target: "dns.name",
Type: "dns",
Timeout: time.Second * 2,
})
require.Error(t, err)
}

func TestDNSWaiter_returnsErrorOnStart(t *testing.T) {
w := NewDNSWaiter(func(host string) ([]net.IP, error) {
return nil, fmt.Errorf("some error")
}, NullLogger)

err := w.Wait("dns1", &TargetConfig{
Target: "dns.name",
Type: "dns",
Timeout: time.Second * 2,
})
assert.Error(t, err)
}

func TestDNSWaiter_returnsErrorWhenWaitingz(t *testing.T) {
errs := []error{nil, nil, fmt.Errorf("some error")}
w := NewDNSWaiter(func(host string) ([]net.IP, error) {
next := errs[0]
if len(errs) > 0 {
errs = errs[1:]
}
return nil, next
}, NullLogger)

err := w.Wait("dns1", &TargetConfig{
Target: "dns.name",
Type: "dns",
Timeout: time.Second * 2,
})
assert.Error(t, err)
}

0 comments on commit 3a70ab4

Please sign in to comment.