diff --git a/collector/cloud_collector.go b/collector/cloud_collector.go new file mode 100644 index 00000000..5991f88d --- /dev/null +++ b/collector/cloud_collector.go @@ -0,0 +1,93 @@ +package collector + +import ( + "fmt" + "strings" + + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + "gopkg.in/routeros.v2/proto" +) + +type cloudCollector struct { + props []string + descriptions map[string]*prometheus.Desc +} + +func newCloudCollector() routerOSCollector { + c := &cloudCollector{} + c.init() + return c +} + +func (c *cloudCollector) init() { + c.props = []string{"public-address", "ddns-enabled"} + labelNames := []string{"name", "address", "public_address"} + c.descriptions = make(map[string]*prometheus.Desc) + for _, p := range c.props[1:] { + c.descriptions[p] = descriptionForPropertyName("cloud", p, labelNames) + } +} + +func (c *cloudCollector) describe(ch chan<- *prometheus.Desc) { + for _, d := range c.descriptions { + ch <- d + } +} + +func (c *cloudCollector) collect(ctx *collectorContext) error { + stats, err := c.fetch(ctx) + if err != nil { + return err + } + + for _, re := range stats { + c.collectForStat(re, ctx) + } + + return nil +} + +func (c *cloudCollector) fetch(ctx *collectorContext) ([]*proto.Sentence, error) { + reply, err := ctx.client.Run("/ip/cloud/print", "=.proplist="+strings.Join(c.props, ",")) + if err != nil { + log.WithFields(log.Fields{ + "device": ctx.device.Name, + "error": err, + }).Error("error fetching cloud metrics") + return nil, err + } + + return reply.Re, nil +} + +func (c *cloudCollector) collectForStat(re *proto.Sentence, ctx *collectorContext) { + publicIp := re.Map["public-address"] + + for _, p := range c.props[1:] { + c.collectMetricForProperty(p, publicIp, re, ctx) + } +} + +func (c *cloudCollector) collectMetricForProperty(property, publicIp string, re *proto.Sentence, ctx *collectorContext) { + desc := c.descriptions[property] + if value := re.Map[property]; value != "" { + var numericValue float64 + switch value { + case "false": + numericValue = 0 + case "true": + numericValue = 1 + default: + log.WithFields(log.Fields{ + "device": ctx.device.Name, + "public-address": publicIp, + "property": property, + "value": value, + "error": fmt.Errorf("unexpected cloud ddns-enabled value"), + }).Error("error parsing cloud metric value") + } + + ctx.ch <- prometheus.MustNewConstMetric(desc, prometheus.CounterValue, numericValue, ctx.device.Name, ctx.device.Address, publicIp) + } +} diff --git a/collector/cloud_collector_test.go b/collector/cloud_collector_test.go new file mode 100644 index 00000000..098ad6ea --- /dev/null +++ b/collector/cloud_collector_test.go @@ -0,0 +1,163 @@ +package collector + +import ( + "io" + "mikrotik-exporter/config" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" + routeros "gopkg.in/routeros.v2" + "gopkg.in/routeros.v2/proto" +) + +func TestCloudMetricDesc(t *testing.T) { + c := getFakeClient(t, "false") + defer c.Close() + + cloudCollector := newCloudCollector() + metrics := make(chan prometheus.Metric, 1) + + ctx := collectorContext{ + ch: metrics, + device: &config.Device{Name: "foo", Address: "test"}, + client: c, + } + + cloudCollector.collect(&ctx) + m := <-metrics + + d := descriptionForPropertyName("cloud", "ddns-enabled", []string{"name", "address", "public_address"}) + + assert.Equal(t, d, m.Desc(), "metrics description missmatch") +} + +func TestCloudCollectFalse(t *testing.T) { + var pb dto.Metric + + c := getFakeClient(t, "false") + defer c.Close() + + cloudCollector := newCloudCollector() + metrics := make(chan prometheus.Metric, 1) + + ctx := collectorContext{ + ch: metrics, + device: &config.Device{Name: "foo", Address: "test"}, + client: c, + } + + cloudCollector.collect(&ctx) + m := <-metrics + + assert.NoError(t, nil, m.Write(&pb), "error reading metrics") + assert.Equal(t, float64(0), pb.Counter.GetValue(), "excpeted output should be 0 for false") + + for _, l := range pb.Label { + switch l.GetName() { + case "name": + assert.Equal(t, "foo", l.GetValue(), "device name label value missmatch") + case "address": + assert.Equal(t, "test", l.GetValue(), "device address label value missmatch") + case "public_address": + assert.Equal(t, "0.0.0.0", l.GetValue(), "public_address label value missmatch") + default: + t.Fatalf("invalid or missing lables %s", l.GetName()) + } + } +} + +func TestCloudCollectTrue(t *testing.T) { + var pb dto.Metric + + c := getFakeClient(t, "true") + defer c.Close() + + cloudCollector := newCloudCollector() + metrics := make(chan prometheus.Metric, 1) + + ctx := collectorContext{ + ch: metrics, + device: &config.Device{Name: "foo", Address: "test"}, + client: c, + } + + cloudCollector.collect(&ctx) + m := <-metrics + + assert.NoError(t, nil, m.Write(&pb)) + assert.Equal(t, float64(1), pb.Counter.GetValue(), "excpeted output should be 1 for true") +} + +func getFakeClient(t *testing.T, state string) *routeros.Client { + c, s := newPair(t) + + go func() { + defer s.Close() + s.readSentence(t, "/ip/cloud/print @ [{`.proplist` `public-address,ddns-enabled`}]") + s.writeSentence(t, "!re", "=ddns-enabled="+state, "=public-address=0.0.0.0") + s.writeSentence(t, "!done") + }() + + return c +} + +// Heplers +type fakeServer struct { + r proto.Reader + w proto.Writer + io.Closer +} + +type conn struct { + *io.PipeReader + *io.PipeWriter +} + +func (c *conn) Close() error { + c.PipeReader.Close() + c.PipeWriter.Close() + return nil +} + +func newPair(t *testing.T) (*routeros.Client, *fakeServer) { + ar, aw := io.Pipe() + br, bw := io.Pipe() + + c, err := routeros.NewClient(&conn{ar, bw}) + if err != nil { + t.Fatal(err) + } + + s := &fakeServer{ + proto.NewReader(br), + proto.NewWriter(aw), + &conn{br, aw}, + } + + return c, s +} + +func (f *fakeServer) readSentence(t *testing.T, want string) { + sen, err := f.r.ReadSentence() + if err != nil { + t.Fatal(err) + } + if sen.String() != want { + t.Fatalf("Sentence (%s); want (%s)", sen.String(), want) + } + t.Logf("< %s\n", sen) +} + +func (f *fakeServer) writeSentence(t *testing.T, sentence ...string) { + t.Logf("> %#q\n", sentence) + f.w.BeginSentence() + for _, word := range sentence { + f.w.WriteWord(word) + } + err := f.w.EndSentence() + if err != nil { + t.Fatal(err) + } +} diff --git a/collector/collector.go b/collector/collector.go index 61e7c3c3..726b2cee 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -203,6 +203,13 @@ func WithNetwatch() Option { } } +// WithCloud enables cloud service metrics +func WithCloud() Option { + return func(c *collector) { + c.collectors = append(c.collectors, newCloudCollector()) + } +} + // Option applies options to collector type Option func(*collector) diff --git a/config/config.go b/config/config.go index 7905d256..39fb933a 100644 --- a/config/config.go +++ b/config/config.go @@ -30,6 +30,7 @@ type Config struct { Ipsec bool `yaml:"ipsec,omitempty"` Lte bool `yaml:"lte,omitempty"` Netwatch bool `yaml:"netwatch,omitempty"` + Cloud bool `yaml:"netwatch,omitempty"` } `yaml:"features,omitempty"` } diff --git a/config/config.test.yml b/config/config.test.yml index 753d44d0..23ffde10 100644 --- a/config/config.test.yml +++ b/config/config.test.yml @@ -22,3 +22,4 @@ features: ipsec: true lte: true netwatch: true + cloud: true diff --git a/config/config_test.go b/config/config_test.go index 7317566a..75b82f3b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -31,6 +31,7 @@ func TestShouldParse(t *testing.T) { assertFeature("Ipsec", c.Features.Ipsec, t) assertFeature("Lte", c.Features.Lte, t) assertFeature("Netwatch", c.Features.Netwatch, t) + assertFeature("Cloud", c.Features.Cloud, t) } func loadTestFile(t *testing.T) []byte { diff --git a/main.go b/main.go index f6596b82..6339c402 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ var ( withIpsec = flag.Bool("with-ipsec", false, "retrieves ipsec metrics") withLte = flag.Bool("with-lte", false, "retrieves lte metrics") withNetwatch = flag.Bool("with-netwatch", false, "retrieves netwatch metrics") + withCloud = flag.Bool("with-cloud", false, "retrives cloud services stats") cfg *config.Config @@ -274,6 +275,10 @@ func collectorOptions() []collector.Option { opts = append(opts, collector.WithNetwatch()) } + if *withCloud || cfg.Features.Cloud { + opts = append(opts, collector.WithCloud()) + } + if *timeout != collector.DefaultTimeout { opts = append(opts, collector.WithTimeout(*timeout)) }