feat(20-01): add tod-mib-parser Go CLI binary

- Add gosmi v1.0.4 dependency for MIB parsing
- Create poller/cmd/mib-parser/ with main.go and tree.go
- CLI accepts MIB file path and optional --search-path
- Outputs JSON OID tree with oid, name, description, type, access, status, children
- Errors output as JSON {"error":"..."} to stdout (exit 0) for Python backend
- Panic recovery wraps gosmi LoadModule for malformed MIBs
- Parent-child tree built from OID hierarchy with numeric sort

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 20:22:16 -05:00
parent 655f1eadae
commit da598f79a0
4 changed files with 346 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
// tod-mib-parser parses vendor MIB files using gosmi and outputs a JSON OID tree.
//
// Usage:
//
// tod-mib-parser <mib-file-path> [--search-path <dir>]
//
// The binary reads a MIB file, parses it with opsbl/gosmi, and writes a JSON
// OID tree to stdout. On any parse error it outputs {"error": "..."} to stdout
// and exits 0 (the Python backend reads stdout, not exit codes).
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/opsbl/gosmi"
)
func main() {
mibPath, searchPath, err := parseArgs(os.Args[1:])
if err != nil {
writeError(err.Error())
return
}
result, err := parseMIB(mibPath, searchPath)
if err != nil {
writeError(err.Error())
return
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(result); err != nil {
writeError(fmt.Sprintf("json encode: %s", err.Error()))
}
}
// parseArgs extracts the MIB file path and optional --search-path from argv.
func parseArgs(args []string) (mibPath, searchPath string, err error) {
if len(args) == 0 {
return "", "", fmt.Errorf("usage: tod-mib-parser <mib-file-path> [--search-path <dir>]")
}
mibPath = args[0]
searchPath = filepath.Dir(mibPath)
for i := 1; i < len(args); i++ {
if args[i] == "--search-path" {
if i+1 >= len(args) {
return "", "", fmt.Errorf("--search-path requires a directory argument")
}
searchPath = args[i+1]
i++
}
}
if _, statErr := os.Stat(mibPath); statErr != nil {
return "", "", fmt.Errorf("cannot access MIB file: %s", statErr.Error())
}
return mibPath, searchPath, nil
}
// parseMIB loads a MIB file with gosmi and builds the OID tree. It recovers
// from gosmi panics on malformed MIBs and returns them as errors.
func parseMIB(mibPath, searchPath string) (result *ParseResult, err error) {
defer func() {
if r := recover(); r != nil {
result = nil
err = fmt.Errorf("gosmi panic: %v", r)
}
}()
inst, err := gosmi.New("tod-mib-parser")
if err != nil {
return nil, fmt.Errorf("gosmi init: %s", err.Error())
}
defer inst.Close()
// Append rather than replace so the default IETF/IANA MIB search paths
// from gosmi bootstrap are preserved. The MIB file's directory (or the
// explicit --search-path) is added so dependent MIBs co-located with the
// input file are found.
inst.AppendPath(searchPath)
moduleName, err := inst.LoadModule(filepath.Base(mibPath))
if err != nil {
return nil, fmt.Errorf("load module: %s", err.Error())
}
return BuildOIDTree(inst, moduleName)
}
// writeError outputs a JSON error object to stdout.
func writeError(msg string) {
fmt.Fprintf(os.Stderr, "tod-mib-parser: %s\n", msg)
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,226 @@
package main
import (
"fmt"
"sort"
"strings"
"github.com/opsbl/gosmi"
"github.com/opsbl/gosmi/types"
)
// OIDNode represents a single node in the MIB OID tree.
type OIDNode struct {
OID string `json:"oid"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
Access string `json:"access,omitempty"`
Status string `json:"status,omitempty"`
Children []*OIDNode `json:"children,omitempty"`
}
// ParseResult is the top-level output from a MIB parse.
type ParseResult struct {
ModuleName string `json:"module_name"`
Nodes []*OIDNode `json:"nodes"`
NodeCount int `json:"node_count"`
}
// BuildOIDTree walks all nodes in the named module and assembles them into a
// parent-child tree based on OID hierarchy.
func BuildOIDTree(inst *gosmi.Instance, moduleName string) (*ParseResult, error) {
mod, err := inst.GetModule(moduleName)
if err != nil {
return nil, fmt.Errorf("get module %q: %s", moduleName, err.Error())
}
smiNodes := mod.GetNodes()
if len(smiNodes) == 0 {
return &ParseResult{
ModuleName: moduleName,
Nodes: []*OIDNode{},
NodeCount: 0,
}, nil
}
// Build a flat map of OID string -> OIDNode.
nodeMap := make(map[string]*OIDNode, len(smiNodes))
for _, sn := range smiNodes {
oidStr := sn.RenderNumeric()
node := &OIDNode{
OID: oidStr,
Name: sn.Name,
Description: cleanDescription(sn.Description),
Type: nodeType(sn),
Access: nodeAccess(sn.Access),
Status: nodeStatus(sn.Status),
}
nodeMap[oidStr] = node
}
// Build parent-child relationships. A node's parent OID is everything up
// to (but not including) the last dot segment.
roots := make([]*OIDNode, 0)
for oid, node := range nodeMap {
parentOID := parentOf(oid)
if parent, ok := nodeMap[parentOID]; ok {
parent.Children = append(parent.Children, node)
} else {
roots = append(roots, node)
}
}
// Sort children at every level by OID for deterministic output.
for _, node := range nodeMap {
sortChildren(node)
}
sort.Slice(roots, func(i, j int) bool {
return compareOID(roots[i].OID, roots[j].OID)
})
return &ParseResult{
ModuleName: moduleName,
Nodes: roots,
NodeCount: len(smiNodes),
}, nil
}
// nodeType returns a human-readable type string for a node. For leaf nodes with
// an SMI type it uses the type name or base type. For branch nodes (table, row,
// bare node) it uses the node kind.
func nodeType(sn gosmi.SmiNode) string {
if sn.SmiType != nil {
name := sn.SmiType.Name
if name != "" {
return strings.ToLower(name)
}
bt := sn.SmiType.BaseType.String()
if bt != "" && bt != "Unknown" {
return strings.ToLower(bt)
}
}
// Fall back to node kind for branch/structural nodes.
return nodeKindLabel(sn.Kind)
}
// nodeKindLabel converts a gosmi NodeKind to a lowercase label.
func nodeKindLabel(k types.NodeKind) string {
switch k {
case types.NodeNode:
return "node"
case types.NodeScalar:
return "scalar"
case types.NodeTable:
return "table"
case types.NodeRow:
return "row"
case types.NodeColumn:
return "column"
case types.NodeNotification:
return "notification"
case types.NodeGroup:
return "group"
case types.NodeCompliance:
return "compliance"
case types.NodeCapabilities:
return "capabilities"
default:
return ""
}
}
// nodeAccess converts a gosmi Access value to a kebab-case string matching
// standard MIB notation. Returns empty for unknown/not-implemented.
func nodeAccess(a types.Access) string {
switch a {
case types.AccessNotAccessible:
return "not-accessible"
case types.AccessNotify:
return "accessible-for-notify"
case types.AccessReadOnly:
return "read-only"
case types.AccessReadWrite:
return "read-write"
default:
return ""
}
}
// nodeStatus converts a gosmi Status value to a lowercase string.
func nodeStatus(s types.Status) string {
switch s {
case types.StatusCurrent:
return "current"
case types.StatusDeprecated:
return "deprecated"
case types.StatusObsolete:
return "obsolete"
case types.StatusMandatory:
return "mandatory"
case types.StatusOptional:
return "optional"
default:
return ""
}
}
// parentOf returns the parent OID by stripping the last dotted component.
func parentOf(oid string) string {
idx := strings.LastIndex(oid, ".")
if idx <= 0 {
return ""
}
return oid[:idx]
}
// sortChildren sorts a node's children slice by OID.
func sortChildren(node *OIDNode) {
if len(node.Children) < 2 {
return
}
sort.Slice(node.Children, func(i, j int) bool {
return compareOID(node.Children[i].OID, node.Children[j].OID)
})
}
// compareOID compares two dotted-decimal OID strings numerically.
func compareOID(a, b string) bool {
pa := strings.Split(a, ".")
pb := strings.Split(b, ".")
minLen := len(pa)
if len(pb) < minLen {
minLen = len(pb)
}
for i := 0; i < minLen; i++ {
na := atoiSafe(pa[i])
nb := atoiSafe(pb[i])
if na != nb {
return na < nb
}
}
return len(pa) < len(pb)
}
// atoiSafe parses a string as an integer, returning 0 on error.
func atoiSafe(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
// cleanDescription trims whitespace and collapses internal runs of whitespace
// in MIB description strings, which often contain excessive formatting.
func cleanDescription(s string) string {
if s == "" {
return ""
}
// Replace newlines and tabs with spaces, then collapse multiple spaces.
s = strings.Join(strings.Fields(s), " ")
return s
}

View File

@@ -28,6 +28,7 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/alecthomas/participle v0.4.1 // indirect
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -73,6 +74,7 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opsbl/gosmi v1.0.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect

View File

@@ -6,6 +6,14 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alecthomas/go-thrift v0.0.0-20170109061633-7914173639b2/go.mod h1:CxCgO+NdpMdi9SsTlGbc0W+/UNxO3I0AabOEJZ3w61w=
github.com/alecthomas/kong v0.2.1/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/participle v0.4.1 h1:P2PJWzwrSpuCWXKnzqvw0b0phSfH1kJo4p2HvLynVsI=
github.com/alecthomas/participle v0.4.1/go.mod h1:T8u4bQOSMwrkTWOSyt8/jSFPEnRtd0FKFMjVfYBlqPs=
github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/repr v0.0.0-20210301060118-828286944d6a/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
@@ -101,6 +109,7 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
@@ -135,6 +144,9 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opsbl/gosmi v1.0.4 h1:dJAfMcPQOiqvSZxEJPE1WIwf5H1Afm72DOfcZUsuSY4=
github.com/opsbl/gosmi v1.0.4/go.mod h1:iUDfepYtPjbnSjgTCZtV4vi8578MiCq/7HTDlPU0sWI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
@@ -162,7 +174,9 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -234,9 +248,11 @@ google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=