add integration test (running against an actual dnsmasq)

This commit is contained in:
Michael Stapelberg 2018-05-05 15:00:19 +02:00
parent d106190ef8
commit b68bff846a
5 changed files with 284 additions and 77 deletions

22
.travis.yml Normal file
View File

@ -0,0 +1,22 @@
# Use the (faster) container-based infrastructure, see also
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/
sudo: false
dist: trusty
services:
- docker
language: go
go:
- "1.10"
go_import_path: github.com/stapelberg/dnsmasq_exporter
script:
# Check whether files are syntactically correct.
- "gofmt -l $(find . -name '*.go' | tr '\\n' ' ') >/dev/null"
# Check whether files were not gofmt'ed.
- "gosrc=$(find . -name '*.go' | tr '\\n' ' '); [ $(gofmt -l $gosrc 2>&- | wc -l) -eq 0 ] || (echo 'gofmt was not run on these files:'; gofmt -l $gosrc 2>&-; false)"
- go tool vet .
- go test -c
- docker build --pull --no-cache --rm -t=dns -f travis/Dockerfile .
- docker run -v $PWD:/usr/src:ro dns /bin/sh -c './dnsmasq_exporter.test -test.v'

View File

@ -101,85 +101,98 @@ func init() {
// be: // be:
// dig +short chaos txt cachesize.bind // dig +short chaos txt cachesize.bind
type server struct {
promHandler http.Handler
dnsClient *dns.Client
dnsmasqAddr string
leasesPath string
}
func (s *server) metrics(w http.ResponseWriter, r *http.Request) {
var eg errgroup.Group
eg.Go(func() error {
msg := &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: dns.Id(),
RecursionDesired: true,
},
Question: []dns.Question{
dns.Question{"cachesize.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"insertions.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"evictions.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"misses.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"hits.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"auth.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"servers.bind.", dns.TypeTXT, dns.ClassCHAOS},
},
}
in, _, err := s.dnsClient.Exchange(msg, s.dnsmasqAddr)
if err != nil {
return err
}
for _, a := range in.Answer {
txt, ok := a.(*dns.TXT)
if !ok {
continue
}
switch txt.Hdr.Name {
case "servers.bind.":
// TODO: parse <server> <successes> <errors>, also with multiple upstreams
default:
g, ok := floatMetrics[txt.Hdr.Name]
if !ok {
continue // ignore unexpected answer from dnsmasq
}
if got, want := len(txt.Txt), 1; got != want {
return fmt.Errorf("stats DNS record %q: unexpected number of replies: got %d, want %d", txt.Hdr.Name, got, want)
}
f, err := strconv.ParseFloat(txt.Txt[0], 64)
if err != nil {
return err
}
g.Set(f)
}
}
return nil
})
eg.Go(func() error {
f, err := os.Open(s.leasesPath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines float64
for scanner.Scan() {
lines++
}
if err := scanner.Err(); err != nil {
return err
}
leases.Set(lines)
return nil
})
if err := eg.Wait(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.promHandler.ServeHTTP(w, r)
}
func main() { func main() {
flag.Parse() flag.Parse()
promHandler := promhttp.Handler() s := &server{
dnsClient := &dns.Client{ promHandler: promhttp.Handler(),
SingleInflight: true, dnsClient: &dns.Client{
SingleInflight: true,
},
dnsmasqAddr: *dnsmasqAddr,
leasesPath: *leasesPath,
} }
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/metrics", s.metrics)
var eg errgroup.Group
eg.Go(func() error {
msg := &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: dns.Id(),
RecursionDesired: true,
},
Question: []dns.Question{
dns.Question{"cachesize.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"insertions.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"evictions.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"misses.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"hits.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"auth.bind.", dns.TypeTXT, dns.ClassCHAOS},
dns.Question{"servers.bind.", dns.TypeTXT, dns.ClassCHAOS},
},
}
in, _, err := dnsClient.Exchange(msg, *dnsmasqAddr)
if err != nil {
return err
}
for _, a := range in.Answer {
txt, ok := a.(*dns.TXT)
if !ok {
continue
}
switch txt.Hdr.Name {
case "servers.bind.":
// TODO: parse <server> <successes> <errors>, also with multiple upstreams
default:
g, ok := floatMetrics[txt.Hdr.Name]
if !ok {
continue // ignore unexpected answer from dnsmasq
}
if got, want := len(txt.Txt), 1; got != want {
return fmt.Errorf("stats DNS record %q: unexpected number of replies: got %d, want %d", txt.Hdr.Name, got, want)
}
f, err := strconv.ParseFloat(txt.Txt[0], 64)
if err != nil {
return err
}
g.Set(f)
}
}
return nil
})
eg.Go(func() error {
f, err := os.Open(*leasesPath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines float64
for scanner.Scan() {
lines++
}
if err := scanner.Err(); err != nil {
return err
}
leases.Set(lines)
return nil
})
if err := eg.Wait(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
promHandler.ServeHTTP(w, r)
})
log.Fatal(http.ListenAndServe(*listen, nil)) log.Fatal(http.ListenAndServe(*listen, nil))
} }

152
dnsmasq_test.go Normal file
View File

@ -0,0 +1,152 @@
package main
import (
"fmt"
"io/ioutil"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func TestDnsmasqExporter(t *testing.T) {
// NOTE(stapelberg): dnsmasq disables DNS operation upon --port=0 (as
// opposed to picking a free port). Hence, we must pick one. This is
// inherently prone to race conditions: another process could grab the port
// between our ln.Close() and dnsmasqs bind(). Ideally, dnsmasq would
// support grabbing a free port and announcing it, or inheriting a listening
// socket à la systemd socket activation.
ln, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatal(err)
}
_, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
ln.Close()
dnsmasq := exec.Command(
"dnsmasq",
"--port="+port,
"--no-daemon",
"--cache-size=666",
"--bind-interfaces",
"--interface=lo")
dnsmasq.Stderr = os.Stderr
fmt.Printf("starting %v\n", dnsmasq.Args)
if err := dnsmasq.Start(); err != nil {
t.Fatal(err)
}
defer dnsmasq.Process.Kill()
// Wait until dnsmasq started up
resolver := &dns.Client{}
for {
// Cause a cache miss (dnsmasq must forward this query)
var m dns.Msg
m.SetQuestion("localhost.", dns.TypeA)
if _, _, err := resolver.Exchange(&m, "localhost:"+port); err == nil {
break
}
time.Sleep(10 * time.Millisecond) // do not hog the CPU
}
s := &server{
promHandler: promhttp.Handler(),
dnsClient: &dns.Client{
SingleInflight: true,
},
dnsmasqAddr: "localhost:" + port,
leasesPath: "testdata/dnsmasq.leases",
}
t.Run("first", func(t *testing.T) {
metrics := fetchMetrics(t, s)
want := map[string]string{
"dnsmasq_leases": "2",
"dnsmasq_cachesize": "666",
"dnsmasq_hits": "1",
"dnsmasq_misses": "0",
}
for key, val := range want {
if got, want := metrics[key], val; got != want {
t.Errorf("metric %q: got %q, want %q", key, got, want)
}
}
})
t.Run("second", func(t *testing.T) {
metrics := fetchMetrics(t, s)
want := map[string]string{
"dnsmasq_leases": "2",
"dnsmasq_cachesize": "666",
"dnsmasq_hits": "2",
"dnsmasq_misses": "0",
}
for key, val := range want {
if got, want := metrics[key], val; got != want {
t.Errorf("metric %q: got %q, want %q", key, got, want)
}
}
})
// Cause a cache miss (dnsmasq must forward this query)
var m dns.Msg
m.SetQuestion("no.such.domain.invalid.", dns.TypeA)
if _, _, err := resolver.Exchange(&m, "localhost:"+port); err != nil {
t.Fatal(err)
}
t.Run("after query", func(t *testing.T) {
metrics := fetchMetrics(t, s)
want := map[string]string{
"dnsmasq_leases": "2",
"dnsmasq_cachesize": "666",
"dnsmasq_hits": "3",
"dnsmasq_misses": "1",
}
for key, val := range want {
if got, want := metrics[key], val; got != want {
t.Errorf("metric %q: got %q, want %q", key, got, want)
}
}
})
}
func fetchMetrics(t *testing.T, s *server) map[string]string {
rec := httptest.NewRecorder()
s.metrics(rec, httptest.NewRequest("GET", "/metrics", nil))
resp := rec.Result()
if got, want := resp.StatusCode, http.StatusOK; got != want {
b, _ := ioutil.ReadAll(resp.Body)
t.Fatalf("unexpected HTTP status: got %v (%v), want %v", resp.Status, string(b), want)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
metrics := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
if strings.HasPrefix(line, "#") {
continue
}
parts := strings.Split(line, " ")
if len(parts) < 2 {
continue
}
if !strings.HasPrefix(parts[0], "dnsmasq_") {
continue
}
metrics[parts[0]] = parts[1]
}
return metrics
}

2
testdata/dnsmasq.leases vendored Normal file
View File

@ -0,0 +1,2 @@
lease1
lease2

18
travis/Dockerfile Normal file
View File

@ -0,0 +1,18 @@
# vim:ft=Dockerfile
FROM debian:sid
RUN echo force-unsafe-io > /etc/dpkg/dpkg.cfg.d/docker-apt-speedup
# Paper over occasional network flakiness of some mirrors.
RUN echo 'APT::Acquire::Retries "5";' > /etc/apt/apt.conf.d/80retry
# NOTE: I tried exclusively using gce_debian_mirror.storage.googleapis.com
# instead of httpredir.debian.org, but the results (Fetched 123 MB in 36s (3357
# kB/s)) are not any better than httpredir.debian.org (Fetched 123 MB in 34s
# (3608 kB/s)). Hence, lets stick with httpredir.debian.org (default) for now.
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
dnsmasq && \
rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src