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:
102
poller/cmd/mib-parser/main.go
Normal file
102
poller/cmd/mib-parser/main.go
Normal 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})
|
||||
}
|
||||
226
poller/cmd/mib-parser/tree.go
Normal file
226
poller/cmd/mib-parser/tree.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user