diff --git a/config.go b/config.go index 948bd1a..c3f3cba 100644 --- a/config.go +++ b/config.go @@ -30,6 +30,7 @@ type eureka struct { InTheCloud bool // default false ConnectTimeoutSeconds int // default 10s UseDNSForServiceUrls bool // default false + DNSDiscoveryZone string // default "" ServerDNSName string // default "" ServiceUrls []string // default [] ServerPort int // default 7001 diff --git a/connection.go b/connection.go index c8900b1..3531f38 100644 --- a/connection.go +++ b/connection.go @@ -15,7 +15,27 @@ func init() { // balancing scheme. // TODO: Make this not just pick a random one. func (e *EurekaConnection) SelectServiceURL() string { - return e.ServiceUrls[rand.Int()%len(e.ServiceUrls)] + if e.discoveryTtl == nil { + e.discoveryTtl = make(chan struct{}, 1) + } + if e.DNSDiscovery && len(e.discoveryTtl) == 0 { + servers, ttl, err := discoverDNS(e.DiscoveryZone, e.ServicePort) + if err != nil { + return choice(e.ServiceUrls) + } + e.discoveryTtl <- struct{}{} + time.AfterFunc(ttl, func() { + // At the end of the timeout, empty the channel so that the next + // SelectServiceURL call will refresh the DNS info + <-e.discoveryTtl + }) + e.ServiceUrls = servers + } + return choice(e.ServiceUrls) +} + +func choice(options []string) string { + return options[rand.Int()%len(options)] } // NewConnFromConfigFile sets up a connection object based on a config in @@ -32,17 +52,19 @@ func NewConnFromConfigFile(location string) (c EurekaConnection, err error) { // NewConnFromConfig will, given a Config struct, return a connection based on // those options func NewConnFromConfig(conf Config) (c EurekaConnection) { - if conf.Eureka.UseDNSForServiceUrls { - //TODO: Read service urls from DNS TXT records - log.Critical("ERROR: UseDNSForServiceUrls option unsupported.") - } c.ServiceUrls = conf.Eureka.ServiceUrls + c.ServicePort = conf.Eureka.ServerPort if len(c.ServiceUrls) == 0 && len(conf.Eureka.ServerDNSName) > 0 { c.ServiceUrls = []string{conf.Eureka.ServerDNSName} } c.Timeout = time.Duration(conf.Eureka.ConnectTimeoutSeconds) * time.Second c.PollInterval = time.Duration(conf.Eureka.PollIntervalSeconds) * time.Second c.PreferSameZone = conf.Eureka.PreferSameZone + if conf.Eureka.UseDNSForServiceUrls { + log.Warning("UseDNSForServiceUrls is an experimental option") + c.DNSDiscovery = true + c.DiscoveryZone = conf.Eureka.DNSDiscoveryZone + } return c } diff --git a/dns_discover.go b/dns_discover.go new file mode 100644 index 0000000..67885bc --- /dev/null +++ b/dns_discover.go @@ -0,0 +1,106 @@ +package fargo + +// MIT Licensed (see README.md) - Copyright (c) 2013 Hudl <@Hudl> + +import ( + "fmt" + "github.com/franela/goreq" + "github.com/miekg/dns" + "time" +) + +const azURL = "http://169.254.169.254/latest/meta-data/placement/availability-zone" + +var ErrNotInAWS = fmt.Errorf("Not in AWS") + +func discoverDNS(domain string, port int) (servers []string, ttl time.Duration, err error) { + r, _ := region() + + // all DNS queries must use the FQDN + domain = "txt." + r + "." + dns.Fqdn(domain) + if _, ok := dns.IsDomainName(domain); !ok { + err = fmt.Errorf("invalid domain name: '%s' is not a domain name", domain) + return + } + regionRecords, ttl, err := findTXT(domain) + if err != nil { + return + } + + for _, az := range regionRecords { + instances, _, er := findTXT("txt." + dns.Fqdn(az)) + if er != nil { + continue + } + for _, instance := range instances { + // format the service URL + servers = append(servers, fmt.Sprintf("http://%s:%d/eureka/v2", instance, port)) + } + } + return +} + +func findTXT(fqdn string) ([]string, time.Duration, error) { + defaultTTL := 120 * time.Second + query := new(dns.Msg) + query.SetQuestion(fqdn, dns.TypeTXT) + response, err := dns.Exchange(query, dnsServerAddr) + if err != nil { + log.Error("Failure resolving name %s err=%s", fqdn, err.Error()) + return nil, defaultTTL, err + } + if len(response.Answer) < 1 { + err := fmt.Errorf("no Eureka discovery TXT record returned for name=%s", fqdn) + log.Error("no answer for name=%s err=%s", fqdn, err.Error()) + return nil, defaultTTL, err + } + if response.Answer[0].Header().Rrtype != dns.TypeTXT { + err := fmt.Errorf("did not receive TXT record back from query specifying TXT record. This should never happen.") + log.Error("Failure resolving name %s err=%s", fqdn, err.Error()) + return nil, defaultTTL, err + } + txt := response.Answer[0].(*dns.TXT) + ttl := response.Answer[0].Header().Ttl + if ttl < 60 { + ttl = 60 + } + + return txt.Txt, time.Duration(ttl) * time.Second, nil +} + +var dnsServerAddr string + +func init() { + // Find a DNS server using the OS resolv.conf + config, _ := dns.ClientConfigFromFile("/etc/resolv.conf") + dnsServerAddr = config.Servers[0] + ":" + config.Port +} + +func region() (string, error) { + zone, err := availabilityZone() + if err != nil { + log.Error("Could not retrieve availability zone err=%s", err.Error()) + return "us-east-1", err + } + return zone[:len(zone)-1], nil +} + +// defaults to us-east-1 if there's a problem +func availabilityZone() (string, error) { + response, err := goreq.Request{Uri: azURL}.Do() + if err != nil { + return "", err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := response.Body.ToString() + return "", fmt.Errorf("bad response code: code %d does not indicate successful request, body=%s", + response.StatusCode, + body, + ) + } + zone, err := response.Body.ToString() + if err != nil { + return "", err + } + return zone[:len(zone)-1], nil +} diff --git a/dns_discover_test.go b/dns_discover_test.go new file mode 100644 index 0000000..bdff59d --- /dev/null +++ b/dns_discover_test.go @@ -0,0 +1,59 @@ +package fargo + +// MIT Licensed (see README.md) - Copyright (c) 2013 Hudl <@Hudl> + +import ( + . "github.com/smartystreets/goconvey/convey" + "testing" + "time" +) + +func TestGetNXDomain(t *testing.T) { + Convey("Given nonexistent domain nxd.local.", t, func() { + resp, _, err := findTXT("nxd.local.") + So(err, ShouldNotBeNil) + So(len(resp), ShouldEqual, 0) + }) +} + +func TestGetNetflixTestDomain(t *testing.T) { + Convey("Given domain txt.us-east-1.discoverytest.netflix.net.", t, func() { + // TODO: use a mock DNS server to eliminate dependency on netflix + // keeping their discoverytest domain up + resp, ttl, err := findTXT("txt.us-east-1.discoverytest.netflix.net.") + So(err, ShouldBeNil) + So(ttl, ShouldEqual, 60*time.Second) + So(len(resp), ShouldEqual, 3) + Convey("And the contents are zone records", func() { + expected := map[string]bool{ + "us-east-1c.us-east-1.discoverytest.netflix.net": true, + "us-east-1d.us-east-1.discoverytest.netflix.net": true, + "us-east-1e.us-east-1.discoverytest.netflix.net": true, + } + for _, item := range resp { + _, ok := expected[item] + So(ok, ShouldEqual, true) + } + Convey("And the zone records contain instances", func() { + for _, record := range resp { + servers, _, err := findTXT("txt." + record + ".") + So(err, ShouldBeNil) + So(len(servers) >= 1, ShouldEqual, true) + // servers should be EC2 DNS names + So(servers[0][0:4], ShouldEqual, "ec2-") + } + }) + }) + }) + Convey("Autodiscover discoverytest.netflix.net.", t, func() { + servers, ttl, err := discoverDNS("discoverytest.netflix.net", 7001) + So(ttl, ShouldEqual, 60*time.Second) + So(err, ShouldBeNil) + So(len(servers), ShouldEqual, 4) + Convey("Servers discovered should all be EC2 DNS names", func() { + for _, s := range servers { + So(s[0:11], ShouldEqual, "http://ec2-") + } + }) + }) +} diff --git a/struct.go b/struct.go index 5067c06..1ea0b96 100644 --- a/struct.go +++ b/struct.go @@ -15,10 +15,14 @@ var EurekaURLSlugs = map[string]string{ // EurekaConnection is the settings required to make eureka requests type EurekaConnection struct { ServiceUrls []string + ServicePort int Timeout time.Duration PollInterval time.Duration PreferSameZone bool Retries int + DNSDiscovery bool + DiscoveryZone string + discoveryTtl chan struct{} UseJson bool }