Repository: kentik/ktranslate
Pull request #298 (2022-03-22 23:58:52)

WIP for working with the Arista eAPI system
Ads in metrics around:

`show mlag detail` and `show ip bgp summary vrf all` router commands. 

These get mapped to `kentik.eapi.mlag.` and `kentik.eapi.bgp.` metrics, for example `kentik.eapi.mlag.PortsConfigured`

Enable by setting up a eapi section in the target device config:

```
    ext:
      eapi_config:
        username: $USERNAME
        password: $PASSWORD
        transport: https
        port: 443
```

For example. Then these metrics come in along with regular SNMP metrics. 


i3149: I think this is ready for consideration for merging? 
Mesverrum: At this point the Arista data collection is pretty much hard coded, have we done any work to model out a "profile" for api's yet?
i3149: > At this point the Arista data collection is pretty much hard coded, have we done any work to model out a "profile" for api's yet?

Good q. I could imagine a profile having a list of sections the user could opt into using? Kinda like how mibs are set up now. For the Arista, sections would be BGP and MLAG right now, but could be expanded to others. For what metrics come from each section though, the statically typed nature of go makes it hard to pick what is used. Say what you will about snmp but at least here the results are all in a standard format.  

Patch:
diff --git a/.github/workflows/publish-eapi.yml b/.github/workflows/publish-eapi.yml
new file mode 100644
index 0000000..256b1f9
--- /dev/null
+++ b/.github/workflows/publish-eapi.yml
@@ -0,0 +1,66 @@
+name: Publish EAPI
+
+on:
+  push:
+    branches:
+      - eapi
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          submodules: recursive
+          fetch-depth: 0         # Fetch all history for .GitInfo and .Lastmod
+
+      - name: Set Version
+        run: |
+           echo KENTIK_KTRANSLATE_VERSION=`date +"kt-%Y-%m-%d-${GITHUB_RUN_ID}"` >> $GITHUB_ENV
+
+      - name: Setup Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: '1.17.2'
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v1
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v1
+
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Install dependencies
+        run: sudo apt-get install make libpcap-dev golint
+
+      - name: Run build
+        run: |
+          echo ${{ env.KENTIK_KTRANSLATE_VERSION }}
+          KENTIK_KTRANSLATE_VERSION=${{ env.KENTIK_KTRANSLATE_VERSION }} make
+
+      - name: Run vet & lint
+        run: |
+          go vet .
+          golint .
+
+      - name: Run testing
+        run: make test
+
+      - name: Build and Publish Docker
+        uses: docker/build-push-action@v2
+        with:
+          builder: ${{ steps.buildx.outputs.name }}
+          context: .
+          platforms: linux/amd64, linux/arm64
+          push: true
+          tags: kentik/ktranslate:eapi
+          build-args: |
+            MAXMIND_LICENSE_KEY=${{ secrets.MM_DOWNLOAD_KEY }}
+            KENTIK_KTRANSLATE_VERSION=${{ env.KENTIK_KTRANSLATE_VERSION }}
+          secrets: |
+            MAXMIND_LICENSE_KEY=${{ secrets.MM_DOWNLOAD_KEY }}
diff --git a/go.mod b/go.mod
index 5a8a048..779084b 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.16
 require (
 	cloud.google.com/go/pubsub v1.17.1
 	cloud.google.com/go/storage v1.18.2
+	github.com/aristanetworks/goeapi v0.6.0 // indirect
 	github.com/aws/aws-lambda-go v1.27.0
 	github.com/aws/aws-sdk-go v1.41.19
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
@@ -25,6 +26,8 @@ require (
 	github.com/kentik/patricia v0.0.0-20210909164817-21603333b70e
 	github.com/liamg/furious v0.0.0-20191231090757-c295c872d6c1
 	github.com/linkedin/goavro/v2 v2.10.1
+	github.com/mattn/go-colorable v0.1.12 // indirect
+	github.com/mitchellh/mapstructure v1.4.3 // indirect
 	github.com/montanaflynn/stats v0.6.6
 	github.com/netsampler/goflow2 v1.0.5-0.20211114004132-de5e75172982
 	github.com/onsi/gomega v1.13.0 // indirect
@@ -37,6 +40,7 @@ require (
 	github.com/stretchr/testify v1.7.0
 	github.com/syndtr/goleveldb v1.0.0
 	github.com/tinylib/msgp v1.1.6 // indirect
+	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect
 	golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
 	google.golang.org/grpc v1.42.0
 	gopkg.in/mcuadros/go-syslog.v2 v2.3.0
diff --git a/go.sum b/go.sum
index 1e31975..a07195b 100644
--- a/go.sum
+++ b/go.sum
@@ -63,6 +63,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/aristanetworks/goeapi v0.6.0 h1:E1zvh1R7ai1Ntb0PzJRCD3ToLc9uOF6EzRRASceW9eY=
+github.com/aristanetworks/goeapi v0.6.0/go.mod h1:l2NklnkGMAycH+WLsjBpjGikgTKtqaSKi43EbiY87i0=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/aws/aws-lambda-go v1.27.0 h1:aLzrJwdyHoF1A18YeVdJjX8Ixkd+bpogdxVInvHcWjM=
 github.com/aws/aws-lambda-go v1.27.0/go.mod h1:jJmlefzPfGnckuHdXX7/80O3BvUUi12XOkbv4w9SGLU=
@@ -283,6 +285,8 @@ github.com/judwhite/go-svc v1.2.1 h1:a7fsJzYUa33sfDJRF2N/WXhA+LonCEEY8BJb1tuS5tA
 github.com/judwhite/go-svc v1.2.1/go.mod h1:mo/P2JNX8C07ywpP9YtO2gnBgnUiFTHqtsZekJrUuTk=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40=
+github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg=
 github.com/kentik/api-schema-public v0.0.0-20211011204132-acc22cb40b78 h1:7OfbiKWgQA7Z7MLTcRL/LOrr9MjTcDDZSTRst2+mhjo=
 github.com/kentik/api-schema-public v0.0.0-20211011204132-acc22cb40b78/go.mod h1:3eRiIiOwdOkInkMptcgaG5q4AvDjQd7zvQYGM7KeSzc=
 github.com/kentik/go-metrics v0.0.0-20200109025407-4bfd4a9b42c5 h1:1rZ84eG01A0q93bOxVEY38qvJlfbcFlgKboFERP9TRQ=
@@ -311,10 +315,16 @@ github.com/libp2p/go-reuseport v0.1.0/go.mod h1:bQVn9hmfcTaoo0c9v5pBhOarsU1eNOBZ
 github.com/linkedin/goavro/v2 v2.10.1 h1:ExVurHDnf0eyUocILs48kiZ4pGvaEbDvBOQcfLruA/0=
 github.com/linkedin/goavro/v2 v2.10.1/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
+github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -422,6 +432,8 @@ github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOM
 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
 github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
 github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
+github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
 github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc=
 github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
@@ -633,6 +645,7 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678 h1:J27LZFQBFoihqXoegpscI10HpjZ7B5WQLLKL2FZXQKw=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
 golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
diff --git a/pkg/inputs/snmp/metrics/poll.go b/pkg/inputs/snmp/metrics/poll.go
index 3784de2..252d0ac 100644
--- a/pkg/inputs/snmp/metrics/poll.go
+++ b/pkg/inputs/snmp/metrics/poll.go
@@ -9,6 +9,7 @@ import (
 	"github.com/kentik/ktranslate/pkg/eggs/logger"
 	"github.com/kentik/ktranslate/pkg/inputs/snmp/mibs"
 	"github.com/kentik/ktranslate/pkg/inputs/snmp/ping"
+	extension "github.com/kentik/ktranslate/pkg/inputs/snmp/x"
 	"github.com/kentik/ktranslate/pkg/kt"
 	"github.com/kentik/ktranslate/pkg/util/tick"
 )
@@ -27,6 +28,7 @@ type Poller struct {
 	counterTimeSec   int
 	dropIfOutside    bool
 	pinger           *ping.Pinger
+	extension        extension.Extension
 }
 
 func NewPoller(server *gosnmp.GoSNMP, gconf *kt.SnmpGlobalConfig, conf *kt.SnmpDeviceConfig, jchfChan chan []*kt.JCHF, metrics *kt.SnmpDeviceMetric, profile *mibs.Profile, log logger.ContextL) *Poller {
@@ -71,6 +73,15 @@ func NewPoller(server *gosnmp.GoSNMP, gconf *kt.SnmpGlobalConfig, conf *kt.SnmpD
 		dropIfOutside:    dropIfOutside,
 	}
 
+	// If we are extending the metrics for this device in any way, set it up now.
+	ext, err := extension.NewExtension(jchfChan, conf, metrics, log)
+	if err != nil {
+		log.Errorf("Cannot setup extension for %s -> %s: %v", err, conf.DeviceIP, conf.DeviceName)
+	} else if ext != nil {
+		poller.extension = ext
+		log.Infof("Enabling extension %s for %s -> %s", ext.GetName(), conf.DeviceIP, conf.DeviceName)
+	}
+
 	return &poller
 }
 
@@ -120,7 +131,6 @@ func (p *Poller) StartLoop(ctx context.Context) {
 	statusCheck := time.NewTicker(STATUS_CHECK_TIME)
 
 	p.log.Infof("snmpCounterPoll: First poll will be at %v. Polling every %v, drop=%v", firstCollection, counterAlignment, p.dropIfOutside)
-
 	go func() {
 		for {
 			select {
@@ -179,6 +189,11 @@ func (p *Poller) StartLoop(ctx context.Context) {
 			}
 		}
 	}()
+
+	// If there's any extensions, start them here.
+	if p.extension != nil {
+		go p.extension.Run(ctx, counterAlignment)
+	}
 }
 
 // PollSNMPCounter polls SNMP for counter statistics like # bytes and packets transferred.
diff --git a/pkg/inputs/snmp/x/arista/eapi.go b/pkg/inputs/snmp/x/arista/eapi.go
new file mode 100644
index 0000000..33c1377
--- /dev/null
+++ b/pkg/inputs/snmp/x/arista/eapi.go
@@ -0,0 +1,335 @@
+package arista
+
+import (
+	"context"
+	"strings"
+	"time"
+
+	"github.com/aristanetworks/goeapi"
+	"github.com/aristanetworks/goeapi/module"
+
+	"github.com/kentik/ktranslate/pkg/eggs/logger"
+	"github.com/kentik/ktranslate/pkg/kt"
+)
+
+type EAPIClient struct {
+	log      logger.ContextL
+	jchfChan chan []*kt.JCHF
+	conf     *kt.SnmpDeviceConfig
+	metrics  *kt.SnmpDeviceMetric
+	client   *goeapi.Node
+}
+
+func NewEAPIClient(jchfChan chan []*kt.JCHF, conf *kt.SnmpDeviceConfig, metrics *kt.SnmpDeviceMetric, log logger.ContextL) (*EAPIClient, error) {
+	c := EAPIClient{
+		log:      log,
+		jchfChan: jchfChan,
+		conf:     conf,
+		metrics:  metrics,
+	}
+
+	node, err := goeapi.Connect(conf.Ext.EAPIConfig.Transport, conf.DeviceIP, conf.Ext.EAPIConfig.Username, conf.Ext.EAPIConfig.Password, conf.Ext.EAPIConfig.Port)
+	if err != nil {
+		return nil, err
+	}
+	c.log.Infof("Testing %s to %s", c.GetName(), conf.DeviceName)
+	sys := module.System(node)
+	sysInfo := sys.Get() // Only here do we make the RPC call. @TODO, how long can this block for?
+	c.log.Infof("%s connected to %s (%s)", c.GetName(), conf.DeviceName, sysInfo.HostName())
+	c.client = node
+
+	return &c, nil
+}
+
+func (c *EAPIClient) GetName() string {
+	return "Arista eAPI"
+}
+
+func (c *EAPIClient) Run(ctx context.Context, dur time.Duration) {
+	poll := time.NewTicker(dur)
+	defer poll.Stop()
+
+	for {
+		select {
+
+		// Track the counters here, to convert from raw counters to differences
+		case _ = <-poll.C:
+			if res, err := c.getBGP(); err != nil {
+				c.log.Infof("eAPI cannot get BGP Info: %v", err)
+			} else if len(res) > 0 {
+				c.jchfChan <- res
+			}
+
+			if res, err := c.getMLAG(); err != nil {
+				c.log.Infof("eAPI cannot get MLAG Info: %v", err)
+			} else if len(res) > 0 {
+				c.jchfChan <- res
+			}
+
+		case <-ctx.Done():
+			c.log.Infof("eAPI Poll Done")
+			return
+		}
+	}
+}
+
+var (
+	stateMap = map[string]int64{
+		"established": 0,
+		"openconfirm": 1,
+		"opensent":    2,
+		"active":      3,
+		"connect":     4,
+		"idle":        5,
+	}
+
+	boolMap = map[bool]string{
+		true:  "true",
+		false: "false",
+	}
+
+	mlag_strings = map[string]int64{
+		"consistent": 1,
+		"up":         2,
+		"connected":  3,
+		"active":     4,
+		"primary":    5,
+		"secondary":  6,
+		"disabled":   7,
+		"unknown":    8,
+	}
+
+	mlag_bool = map[bool]int64{
+		true:  1,
+		false: 0,
+	}
+)
+
+type Peer struct {
+	Description         string  `json:"description"`
+	MsgSent             int64   `json:"msgSent"`
+	InMsgQueue          int64   `json:"inMsgQueue"`
+	PrefixReceived      int64   `json:"prefixReceived"`
+	UpDownTime          float64 `json:"upDownTime"`
+	Version             int     `json:"version"`
+	PrefixAccepted      int64   `json:"prefixAccepted"`
+	MsgReceived         int64   `json:"msgReceived"`
+	PeerState           string  `json:"peerState"`
+	OutMsgQueue         int64   `json:"outMsgQueue"`
+	UnderMaintenance    bool    `json:"underMaintenance"`
+	ASN                 string  `json:"asn"`
+	PeerStateIdleReason string  `json:"peerStateIdleReason"`
+}
+
+type VRF struct {
+	RouterID string          `json:"routerId"`
+	Peers    map[string]Peer `json:"peers"`
+	VRF      string          `json:"vrf"`
+	ASN      string          `json:"asn"`
+}
+
+type ShowBGP struct {
+	VRFs map[string]VRF `json:"vrfs"`
+}
+
+func (s *ShowBGP) GetCmd() string {
+	return "show ip bgp summary vrf all"
+}
+
+func (c *EAPIClient) getBGP() ([]*kt.JCHF, error) {
+	sv := &ShowBGP{}
+	handle, _ := c.client.GetHandle("json")
+	handle.AddCommand(sv)
+	if err := handle.Call(); err != nil {
+		return nil, err
+	}
+
+	return c.parseBGP(sv)
+}
+
+func (c *EAPIClient) parseBGP(sv *ShowBGP) ([]*kt.JCHF, error) {
+	res := make([]*kt.JCHF, 0)
+	for _, vrf := range sv.VRFs {
+		for peer, state := range vrf.Peers {
+			dst := kt.NewJCHF()
+			dst.CustomStr = map[string]string{
+				"router_id":              vrf.RouterID,
+				"vrf":                    vrf.VRF,
+				"peer":                   peer,
+				"peer_state":             state.PeerState,
+				"under_maintenance":      boolMap[state.UnderMaintenance],
+				"peer_state_idle_reason": state.PeerStateIdleReason,
+				"asn":                    vrf.ASN,
+				"peer_asn":               state.ASN,
+			}
+			dst.CustomInt = map[string]int32{}
+			dst.CustomBigInt = map[string]int64{}
+			dst.EventType = kt.KENTIK_EVENT_SNMP_DEV_METRIC
+			dst.Provider = c.conf.Provider
+			dst.DeviceName = c.conf.DeviceName
+			dst.SrcAddr = c.conf.DeviceIP
+			dst.Timestamp = time.Now().Unix()
+			dst.CustomMetrics = map[string]kt.MetricInfo{}
+
+			dst.CustomBigInt["MsgSent"] = int64(state.MsgSent)
+			dst.CustomMetrics["MsgSent"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bpg", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["InMsgQueue"] = int64(state.InMsgQueue)
+			dst.CustomMetrics["InMsgQueue"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["PrefixReceived"] = int64(state.PrefixReceived)
+			dst.CustomMetrics["PrefixReceived"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["UpDownTime"] = int64(state.UpDownTime)
+			dst.CustomMetrics["UpDownTime"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["Version"] = int64(state.Version)
+			dst.CustomMetrics["Version"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["MsgReceived"] = int64(state.MsgReceived)
+			dst.CustomMetrics["MsgReceived"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["PrefixAccepted"] = int64(state.PrefixAccepted)
+			dst.CustomMetrics["PrefixAccepted"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["PeerState"] = stateMap[strings.ToLower(state.PeerState)]
+			dst.CustomMetrics["PeerState"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			dst.CustomBigInt["OutMsgQueue"] = int64(state.OutMsgQueue)
+			dst.CustomMetrics["OutMsgQueue"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.bgp", Type: "eapi.bgp"}
+
+			c.conf.SetUserTags(dst.CustomStr)
+			res = append(res, dst)
+		}
+	}
+
+	return res, nil
+}
+
+type MLAGPorts struct {
+	Disabled      int64 `json:"Disabled"`
+	ActivePartial int64 `json:"Active-partial"`
+	Inactive      int64 `json:"Inactive"`
+	Configured    int64 `json:"Configured"`
+	ActiveFull    int64 `json:"Active-full"`
+}
+
+type MLAGDetail struct {
+	FailoverCauseList            []string `json:"failoverCauseList"`
+	UdpHeartbeatsSent            int64    `json:"udpHeartbeatsSent"`
+	LacpStandby                  bool     `json:"lacpStandby"`
+	MlagState                    string   `json:"mlagState"`
+	HeartbeatInterval            int      `json:"heartbeatInterval"`
+	EffectiveHeartbeatInterval   int      `json:"effectiveHeartbeatInterval"`
+	HeartbeatTimeout             int      `json:"heartbeatTimeout"`
+	StateChanges                 int      `json:"stateChanges"`
+	FastMacRedirectionEnabled    bool     `json:"fastMacRedirectionEnabled"`
+	PeerPortsErrdisabled         bool     `json:"peerPortsErrdisabled"`
+	MlagHwReady                  bool     `json:"mlagHwReady"`
+	UdpHeartbeatAlive            bool     `json:"udpHeartbeatAlive"`
+	FailoverInitiated            bool     `json:"failoverInitiated"`
+	PeerMlagState                string   `json:"peerMlagState"`
+	SecondaryFromFailover        bool     `json:"secondaryFromFailover"`
+	PrimaryPriority              int      `json:"primaryPriority"`
+	Failover                     bool     `json:"failover"`
+	Enabled                      bool     `json:"enabled"`
+	PeerMacRoutingSupported      bool     `json:"peerMacRoutingSupported"`
+	PeerPrimaryPriority          int      `json:"peerPrimaryPriority"`
+	udpHeartbeatsReceived        int64    `json:"udpHeartbeatsReceived"`
+	PeerMacAddress               string   `json:"peerMacAddress"`
+	MountChanges                 int      `json:"mountChanges"`
+	HeartbeatTimeoutsSinceReboot int64    `json:"heartbeatTimeoutsSinceReboot"`
+}
+
+type ShowMlag struct {
+	ConfigSanity                string     `json:"configSanity"`
+	DomainId                    string     `json:"domainId"`
+	LocalIntfStatus             string     `json:"localIntfStatus"`
+	LocalInterface              string     `json:"localInterface"`
+	State                       string     `json:"state"`
+	ReloadDelay                 int        `json:"reloadDelay"`
+	PeerLink                    string     `json:"peerLink"`
+	NegStatus                   string     `json:"negStatus"`
+	PeerAddress                 string     `json:"peerAddress"`
+	PeerLinkStatus              string     `json:"peerLinkStatus"`
+	SystemId                    string     `json:"systemId"`
+	DualPrimaryDetectionState   string     `json:"dualPrimaryDetectionState"`
+	ReloadDelayNonMlag          int        `json:"reloadDelayNonMlag"`
+	MlagPorts                   MLAGPorts  `json:"mlagPorts"`
+	PortsErrdisabled            bool       `json:"portsErrdisabled"`
+	DualPrimaryPortsErrdisabled bool       `json:"dualPrimaryPortsErrdisabled"`
+	Detail                      MLAGDetail `json:"detail"`
+}
+
+func (s *ShowMlag) GetCmd() string {
+	return "show mlag detail"
+}
+
+func (c *EAPIClient) getMLAG() ([]*kt.JCHF, error) {
+	sv := &ShowMlag{}
+	handle, _ := c.client.GetHandle("json")
+	handle.AddCommand(sv)
+	if err := handle.Call(); err != nil {
+		return nil, err
+	}
+
+	return c.parseMLAG(sv)
+}
+
+func (c *EAPIClient) parseMLAG(sv *ShowMlag) ([]*kt.JCHF, error) {
+	dst := kt.NewJCHF()
+	dst.CustomStr = map[string]string{
+		"config_sanity":     sv.ConfigSanity,
+		"local_intf_status": sv.LocalIntfStatus,
+		"neg_status":        sv.NegStatus,
+		"peer_link_status":  sv.PeerLinkStatus,
+		"state":             sv.State,
+		"domain_id":         sv.DomainId,
+		"local_interface":   sv.LocalInterface,
+		"peer_address":      sv.PeerAddress,
+		"peer_link":         sv.PeerLink,
+	}
+	dst.CustomInt = map[string]int32{}
+	dst.CustomBigInt = map[string]int64{}
+	dst.EventType = kt.KENTIK_EVENT_SNMP_DEV_METRIC
+	dst.Provider = c.conf.Provider
+	dst.DeviceName = c.conf.DeviceName
+	dst.SrcAddr = c.conf.DeviceIP
+	dst.Timestamp = time.Now().Unix()
+	dst.CustomMetrics = map[string]kt.MetricInfo{}
+
+	dst.CustomBigInt["ConfigSanity"] = mlag_strings[strings.ToLower(sv.ConfigSanity)]
+	dst.CustomMetrics["ConfigSanity"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["LocalIntfStatus"] = mlag_strings[strings.ToLower(sv.LocalIntfStatus)]
+	dst.CustomMetrics["LocalIntfStatus"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["NegStatus"] = mlag_strings[strings.ToLower(sv.NegStatus)]
+	dst.CustomMetrics["NegStatus"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PeerLinkStatus"] = mlag_strings[strings.ToLower(sv.PeerLinkStatus)]
+	dst.CustomMetrics["PeerLinkStatus"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsErrdisabled"] = mlag_bool[sv.PortsErrdisabled]
+	dst.CustomMetrics["PortsErrdisabled"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["State"] = mlag_strings[strings.ToLower(sv.State)]
+	dst.CustomMetrics["State"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsDisabled"] = sv.MlagPorts.Disabled
+	dst.CustomMetrics["PortsDisabled"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsActivePartial"] = sv.MlagPorts.ActivePartial
+	dst.CustomMetrics["PortsActivePartial"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsInactive"] = sv.MlagPorts.Inactive
+	dst.CustomMetrics["PortsInactive"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsConfigured"] = sv.MlagPorts.Configured
+	dst.CustomMetrics["PortsConfigured"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	dst.CustomBigInt["PortsActiveFull"] = sv.MlagPorts.ActiveFull
+	dst.CustomMetrics["PortsActiveFull"] = kt.MetricInfo{Oid: "eapi", Mib: "eapi", Profile: "eapi.mlag", Type: "eapi.mlag"}
+
+	return []*kt.JCHF{dst}, nil
+}
diff --git a/pkg/inputs/snmp/x/ext.go b/pkg/inputs/snmp/x/ext.go
new file mode 100644
index 0000000..60196d4
--- /dev/null
+++ b/pkg/inputs/snmp/x/ext.go
@@ -0,0 +1,28 @@
+package x
+
+import (
+	"context"
+	"time"
+
+	"github.com/kentik/ktranslate/pkg/eggs/logger"
+	"github.com/kentik/ktranslate/pkg/inputs/snmp/x/arista"
+	"github.com/kentik/ktranslate/pkg/kt"
+)
+
+// Code to handle various vendor extensions to snmp.
+type Extension interface {
+	Run(context.Context, time.Duration)
+	GetName() string
+}
+
+func NewExtension(jchfChan chan []*kt.JCHF, conf *kt.SnmpDeviceConfig, metrics *kt.SnmpDeviceMetric, log logger.ContextL) (Extension, error) {
+	if conf.Ext == nil { // No extensions set.
+		return nil, nil
+	}
+
+	if conf.Ext.EAPIConfig != nil {
+		return arista.NewEAPIClient(jchfChan, conf, metrics, log)
+	}
+
+	return nil, nil
+}
diff --git a/pkg/kt/snmp.go b/pkg/kt/snmp.go
index d5bfc78..c9a4f7c 100644
--- a/pkg/kt/snmp.go
+++ b/pkg/kt/snmp.go
@@ -134,6 +134,20 @@ type V3SNMPConfig struct {
 	origStr                  string
 }
 
+type EAPIConfig struct {
+	Host      string `yaml:"host"`
+	Username  string `yaml:"username"`
+	Password  string `yaml:"password"`
+	Transport string `yaml:"transport"`
+	Port      int    `yaml:"port"`
+	origStr   string
+}
+
+// Contain various extensions to snmp which can be used to get data.
+type ExtensionSet struct {
+	EAPIConfig *EAPIConfig `yaml:"eapi_config,omitempty"` // Arista eAPI.
+}
+
 type SnmpDeviceConfig struct {
 	DeviceName          string            `yaml:"device_name"`
 	DeviceIP            string            `yaml:"device_ip"`
@@ -161,6 +175,7 @@ type SnmpDeviceConfig struct {
 	NoUseBulkWalkAll    bool              `yaml:"no_use_bulkwalkall"`
 	InstrumentationName string            `yaml:"instrumentationName,omitempty"`
 	RunPing             bool              `yaml:"response_time,omitempty"`
+	Ext                 *ExtensionSet     `yaml:"ext,omitempty"`
 	PingSec             int               `yaml:"ping_interval_sec,omitempty"`
 	allUserTags         map[string]string
 	walker              SNMPTestWalker
@@ -618,3 +633,43 @@ func (d *SnmpDeviceConfig) SetTestWalker(w SNMPTestWalker) {
 type SNMPTestWalker interface {
 	WalkAll(string) ([]gosnmp.SnmpPDU, error)
 }
+
+type EAPIC EAPIConfig // Need a 2nd type alias to avoid stack overflow on parsing.
+
+// Make sure that things serialize back to how they were.
+func (a *EAPIConfig) MarshalYAML() (interface{}, error) {
+	if a.origStr != "" {
+		return a.origStr, nil
+	}
+	return a, nil
+}
+
+// This lets the config get overriden by a global_v3 string.
+func (a *EAPIConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var conf = EAPIC{}
+	err := unmarshal(&conf)
+	if err != nil {
+		var single string
+		err := unmarshal(&single)
+		if err != nil {
+			return err
+		}
+		if strings.HasPrefix(single, "${") { // get the whole yaml block out of an env var.
+			raw := os.Getenv(single[2 : len(single)-1])
+			if err = yaml.Unmarshal([]byte(raw), &conf); err != nil {
+				return err
+			}
+		} else if strings.HasPrefix(single, AwsSmPrefix) { // See if we can pull these out of AWS Secret Manager directly
+			raw := loadViaAWSSecrets(single[len(AwsSmPrefix):])
+			if err = yaml.Unmarshal([]byte(raw), &conf); err != nil {
+				return err
+			}
+		}
+		conf.origStr = single // Let us know where this came from.
+		*a = EAPIConfig(conf)
+	} else { // Just use the value directly.
+		*a = EAPIConfig(conf)
+	}
+
+	return nil
+}


Test patch:
diff --git a/pkg/inputs/snmp/x/arista/eapi_test.go b/pkg/inputs/snmp/x/arista/eapi_test.go
new file mode 100644
index 0000000..3826c64
--- /dev/null
+++ b/pkg/inputs/snmp/x/arista/eapi_test.go
@@ -0,0 +1,189 @@
+package arista
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/kentik/ktranslate/pkg/eggs/logger"
+	lt "github.com/kentik/ktranslate/pkg/eggs/logger/testing"
+	"github.com/kentik/ktranslate/pkg/kt"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseBGP(t *testing.T) {
+	l := lt.NewTestContextL(logger.NilContext, t)
+	testIn := []byte(`
+    {
+      "vrfs": {
+        "default": {
+          "routerId": "10.192.255.3",
+          "peers": {
+            "10.99.2.107": {
+              "description": "AWS_PRIVATE",
+              "msgSent": 1411756,
+              "inMsgQueue": 1,
+              "prefixReceived": 10,
+              "upDownTime": 1647491584,
+              "version": 4,
+              "prefixAccepted": 10,
+              "msgReceived": 1222121,
+              "peerState": "Established",
+              "outMsgQueue": 0,
+              "underMaintenance": false,
+              "asn": "1"
+            },
+            "10.192.250.19": {
+              "description": "FW_INSIDE",
+              "msgSent": 4135220,
+              "inMsgQueue": 2,
+              "prefixReceived": 16,
+              "upDownTime": 1629291904,
+              "version": 4,
+              "prefixAccepted": 9,
+              "msgReceived": 1377152,
+              "peerState": "Established",
+              "outMsgQueue": 0,
+              "underMaintenance": false,
+              "asn": "2"
+            },
+            "10.192.250.4": {
+              "description": "LEAF_UNDERLAY",
+              "msgSent": 902580,
+              "inMsgQueue": 3,
+              "prefixReceived": 15,
+              "upDownTime": 1612622720,
+              "version": 4,
+              "prefixAccepted": 15,
+              "msgReceived": 924834,
+              "peerState": "Established",
+              "outMsgQueue": 0,
+              "underMaintenance": false,
+              "asn": "3"
+            }
+          },
+          "vrf": "default",
+          "asn": "100"
+        },
+        "internet": {
+          "routerId": "2.78.112.2",
+          "peers": {
+            "1.14.99.165": {
+              "description": "DESC",
+              "msgSent": 1406038,
+              "inMsgQueue": 4,
+              "prefixReceived": 872997,
+              "upDownTime": 1630422912,
+              "version": 4,
+              "prefixAccepted": 1,
+              "msgReceived": 110745391,
+              "peerState": "Established",
+              "outMsgQueue": 0,
+              "underMaintenance": false,
+              "asn": "5"
+            },
+            "1.78.112.3": {
+              "description": "INET_IBGP",
+              "msgSent": 702988,
+              "inMsgQueue": 5,
+              "prefixReceived": 2,
+              "upDownTime": 1612622720,
+              "version": 4,
+              "prefixAccepted": 2,
+              "msgReceived": 703019,
+              "peerState": "Connect",
+              "outMsgQueue": 0,
+              "underMaintenance": false,
+              "asn": "6"
+            }
+          },
+          "vrf": "internet",
+          "asn": "100"
+        }
+      }
+    }`)
+
+	sv := ShowBGP{}
+	err := json.Unmarshal(testIn, &sv)
+	assert.NoError(t, err)
+
+	c := &EAPIClient{
+		log:  l,
+		conf: &kt.SnmpDeviceConfig{Provider: "provider"},
+	}
+	res, err := c.parseBGP(&sv)
+	assert.NoError(t, err)
+	assert.Equal(t, 5, len(res)) // 6 results, because 6 peers.
+	for _, r := range res {
+		assert.True(t, r.CustomBigInt["InMsgQueue"] > 0, r.CustomBigInt)
+		assert.True(t, r.CustomBigInt["OutMsgQueue"] == 0, r.CustomBigInt)
+		assert.True(t, r.CustomStr["asn"] == "100", r.CustomStr)
+		if r.CustomStr["peer"] == "1.78.112.3" {
+			assert.Equal(t, int(stateMap["connect"]), int(r.CustomBigInt["PeerState"]), r.CustomStr)
+		}
+	}
+}
+
+func TestParseMLAG(t *testing.T) {
+	l := lt.NewTestContextL(logger.NilContext, t)
+	testIn := []byte(`
+    {
+      "localInterface": "",
+      "state": "connected",
+      "reloadDelay": 300,
+      "peerLink": "",
+      "dualPrimaryDetectionState": "disabled",
+      "reloadDelayNonMlag": 300,
+      "mlagPorts": {
+        "Disabled": 0,
+        "Active-partial": 0,
+        "Inactive": 0,
+        "Configured": 0,
+        "Active-full": 2
+      },
+      "portsErrdisabled": false,
+      "dualPrimaryPortsErrdisabled": false,
+      "detail": {
+        "failoverCauseList": [
+          "Unknown"
+        ],
+        "udpHeartbeatsSent": 0,
+        "lacpStandby": false,
+        "mlagState": "disabled",
+        "heartbeatInterval": 4000,
+        "effectiveHeartbeatInterval": 0,
+        "heartbeatTimeout": 0,
+        "stateChanges": 0,
+        "fastMacRedirectionEnabled": false,
+        "peerPortsErrdisabled": false,
+        "mlagHwReady": true,
+        "udpHeartbeatAlive": false,
+        "failoverInitiated": false,
+        "peerMlagState": "unknown",
+        "secondaryFromFailover": false,
+        "primaryPriority": 32767,
+        "failover": false,
+        "enabled": false,
+        "peerMacRoutingSupported": false,
+        "peerPrimaryPriority": 0,
+        "udpHeartbeatsReceived": 0,
+        "peerMacAddress": "00:00:00:00:00:00",
+        "mountChanges": 0,
+        "heartbeatTimeoutsSinceReboot": 0
+      }
+    }`)
+
+	sv := ShowMlag{}
+	err := json.Unmarshal(testIn, &sv)
+	assert.NoError(t, err)
+
+	c := &EAPIClient{
+		log:  l,
+		conf: &kt.SnmpDeviceConfig{Provider: "provider"},
+	}
+	res, err := c.parseMLAG(&sv)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, len(res)) // 6 results, because 6 peers.
+	assert.Equal(t, mlag_strings["connected"], res[0].CustomBigInt["State"], res[0].CustomBigInt)
+	assert.Equal(t, 2, int(res[0].CustomBigInt["PortsActiveFull"]), res[0].CustomBigInt)
+}