From da598f79a08ccc2716936daa1c6cf6b78ffa661b Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 20:22:16 -0500 Subject: [PATCH] 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) --- poller/cmd/mib-parser/main.go | 102 +++++++++++++++ poller/cmd/mib-parser/tree.go | 226 ++++++++++++++++++++++++++++++++++ poller/go.mod | 2 + poller/go.sum | 16 +++ 4 files changed, 346 insertions(+) create mode 100644 poller/cmd/mib-parser/main.go create mode 100644 poller/cmd/mib-parser/tree.go diff --git a/poller/cmd/mib-parser/main.go b/poller/cmd/mib-parser/main.go new file mode 100644 index 0000000..c7d3431 --- /dev/null +++ b/poller/cmd/mib-parser/main.go @@ -0,0 +1,102 @@ +// tod-mib-parser parses vendor MIB files using gosmi and outputs a JSON OID tree. +// +// Usage: +// +// tod-mib-parser [--search-path ] +// +// 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 [--search-path ]") + } + + 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}) +} diff --git a/poller/cmd/mib-parser/tree.go b/poller/cmd/mib-parser/tree.go new file mode 100644 index 0000000..5d26792 --- /dev/null +++ b/poller/cmd/mib-parser/tree.go @@ -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 +} diff --git a/poller/go.mod b/poller/go.mod index 9396ffc..39c53d8 100644 --- a/poller/go.mod +++ b/poller/go.mod @@ -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 diff --git a/poller/go.sum b/poller/go.sum index c565553..0791f29 100644 --- a/poller/go.sum +++ b/poller/go.sum @@ -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=