add integration test (running against an actual dnsmasq)
This commit is contained in:
parent
d106190ef8
commit
b68bff846a
22
.travis.yml
Normal file
22
.travis.yml
Normal 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'
|
167
dnsmasq.go
167
dnsmasq.go
@ -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
152
dnsmasq_test.go
Normal 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 dnsmasq’s 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
2
testdata/dnsmasq.leases
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
lease1
|
||||||
|
lease2
|
18
travis/Dockerfile
Normal file
18
travis/Dockerfile
Normal 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, let’s 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
|
Loading…
x
Reference in New Issue
Block a user