dnsmasq_exporter/dnsmasq_test.go
Brandon Richardson e3caa1dcf7
feat: add option to enable granular lease metrics (#24)
Introduce a new flag that can be used to expose each DHCP lease as a
specific metric. Given the high cardinality of these metrics, exposition
of lease information is disabled by default.

This feature takes cues from node_exporter collectors which are
disabled by default, like the `processes` or `systemd` collectors for
instance. Typically, high-cardinality metrics are against prometheus
best-practices but for smaller networks (like home networks) these
features can be quite powerful.
2023-04-07 18:09:17 +02:00

200 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
exposeLeases: false,
}
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)
}
}
})
t.Run("should not expose lease information when disabled", func(t *testing.T) {
metrics := fetchMetrics(t, s)
for key, _ := range metrics {
if strings.Contains(key, "dnsmasq_lease_expiry") {
t.Errorf("lease information should not be exposed when disabled: %v", key)
}
}
})
s.exposeLeases = true
t.Run("with high-cardinality lease metrics enabled", func(t *testing.T) {
metrics := fetchMetrics(t, s)
want := map[string]string{
"dnsmasq_leases": "2",
"dnsmasq_cachesize": "666",
"dnsmasq_hits": "5",
"dnsmasq_misses": "1",
"dnsmasq_lease_expiry{client_id=\"00:00:00:00:00:00\",computer_name=\"host-1\",ip_addr=\"10.10.10.10\",mac_addr=\"00:00:00:00:00:00\"}": "1.625595932e+09",
"dnsmasq_lease_expiry{client_id=\"00:00:00:00:00:01\",computer_name=\"host-2\",ip_addr=\"10.10.10.11\",mac_addr=\"00:00:00:00:00:01\"}": "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)
}
}
})
s.leasesPath = "testdata/dnsmasq.leases.does.not.exists"
t.Run("without leases file", func(t *testing.T) {
metrics := fetchMetrics(t, s)
want := map[string]string{
"dnsmasq_leases": "0",
"dnsmasq_cachesize": "666",
"dnsmasq_hits": "6",
"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
}