Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
163 lines
5.0 KiB
Go
163 lines
5.0 KiB
Go
package importcsv
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// Device CSV columns: name (required), addresses (";"-separated), site, role, kind (default network),
|
|
// snmp_version, community, credential (name of an existing credential), port, parents (";"-separated
|
|
// slugs or names of existing devices or of other rows in the same file).
|
|
|
|
func parseDevices(r io.Reader, inv *inventory) ([]DevicePlan, []RowError, error) {
|
|
cols, rows, err := readRecords(r, FileDevices, "name")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var plans []DevicePlan
|
|
var errs []RowError
|
|
seen := map[string]int{}
|
|
for i, rec := range rows {
|
|
if blank(rec) {
|
|
continue
|
|
}
|
|
row := i + 2
|
|
p, msgs := parseDeviceRow(cols, rec, row, inv, seen)
|
|
for _, m := range msgs {
|
|
errs = append(errs, RowError{File: FileDevices, Row: row, Msg: m})
|
|
}
|
|
plans = append(plans, p)
|
|
}
|
|
errs = append(errs, resolveParents(plans, inv)...)
|
|
return plans, errs, nil
|
|
}
|
|
|
|
func parseDeviceRow(cols map[string]int, rec []string, row int, inv *inventory, seen map[string]int) (DevicePlan, []string) {
|
|
var msgs []string
|
|
p := DevicePlan{
|
|
Row: row, Name: field(cols, rec, "name"), Site: field(cols, rec, "site"), Role: field(cols, rec, "role"),
|
|
Kind: strings.ToLower(field(cols, rec, "kind")), Port: store.DefaultSNMPPort,
|
|
}
|
|
if p.Name == "" {
|
|
msgs = append(msgs, "name is required")
|
|
} else {
|
|
msgs = append(msgs, checkName(p.Name, row, inv, seen)...)
|
|
}
|
|
if p.Kind == "" {
|
|
p.Kind = store.KindNetwork
|
|
}
|
|
switch p.Kind {
|
|
case store.KindNetwork, store.KindVM, store.KindContainer, store.KindCheckOnly:
|
|
default:
|
|
msgs = append(msgs, fmt.Sprintf("kind %q must be network, vm, container or check_only", p.Kind))
|
|
}
|
|
addrs, m := parseAddresses(field(cols, rec, "addresses"))
|
|
p.Addresses, msgs = addrs, append(msgs, m...)
|
|
if s := field(cols, rec, "port"); s != "" {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 1 || n > 65535 {
|
|
msgs = append(msgs, fmt.Sprintf("port %q must be 1-65535", s))
|
|
} else {
|
|
p.Port = n
|
|
}
|
|
}
|
|
p.CredentialID, p.NewCredential, m = parseCredential(cols, rec, p.Name, inv)
|
|
msgs = append(msgs, m...)
|
|
p.Parents = splitList(field(cols, rec, "parents"))
|
|
return p, msgs
|
|
}
|
|
|
|
func checkName(name string, row int, inv *inventory, seen map[string]int) []string {
|
|
k := strings.ToLower(name)
|
|
var msgs []string
|
|
if prev, ok := seen[k]; ok {
|
|
msgs = append(msgs, fmt.Sprintf("duplicate device name %q (first on row %d)", name, prev))
|
|
}
|
|
seen[k] = row
|
|
if _, ok := inv.lookup(name); ok {
|
|
msgs = append(msgs, fmt.Sprintf("device %q already exists", name))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func parseAddresses(s string) ([]string, []string) {
|
|
var out, msgs []string
|
|
for _, a := range splitList(s) {
|
|
ip, err := netip.ParseAddr(a)
|
|
if err != nil {
|
|
msgs = append(msgs, fmt.Sprintf("address %q is not an IP address", a))
|
|
continue
|
|
}
|
|
out = append(out, ip.String())
|
|
}
|
|
return out, msgs
|
|
}
|
|
|
|
// parseCredential resolves a named credential, or builds a new v1/v2c one from an inline community.
|
|
func parseCredential(cols map[string]int, rec []string, devName string, inv *inventory) (int64, *store.SNMPCredential, []string) {
|
|
name, ver, comm := field(cols, rec, "credential"), strings.ToLower(field(cols, rec, "snmp_version")), field(cols, rec, "community")
|
|
switch {
|
|
case name != "":
|
|
id, ok := inv.creds[strings.ToLower(name)]
|
|
if !ok {
|
|
return 0, nil, []string{fmt.Sprintf("credential %q does not exist", name)}
|
|
}
|
|
return id, nil, nil
|
|
case comm != "":
|
|
if ver == "" {
|
|
ver = "v2c"
|
|
}
|
|
if ver != "v1" && ver != "v2c" {
|
|
return 0, nil, []string{fmt.Sprintf("snmp_version %q with a community must be v1 or v2c", ver)}
|
|
}
|
|
cname := "import-" + devName
|
|
if _, taken := inv.creds[strings.ToLower(cname)]; taken {
|
|
return 0, nil, []string{fmt.Sprintf("credential %q already exists; name it in the credential column", cname)}
|
|
}
|
|
return 0, &store.SNMPCredential{Name: cname, Version: ver, Community: comm}, nil
|
|
case ver == "v3":
|
|
return 0, nil, []string{"v3 devices must name an existing credential"}
|
|
}
|
|
return 0, nil, nil
|
|
}
|
|
|
|
// resolveParents rewrites each parent token to its key: the slug of an existing device or the
|
|
// name of another planned device. Unknown parents and self-parents are row errors.
|
|
func resolveParents(plans []DevicePlan, inv *inventory) []RowError {
|
|
planned := map[string]string{} // lower-case name to name
|
|
for _, p := range plans {
|
|
planned[strings.ToLower(p.Name)] = p.Name
|
|
}
|
|
var errs []RowError
|
|
for i := range plans {
|
|
p := &plans[i]
|
|
keys := make([]string, 0, len(p.Parents))
|
|
for _, tok := range p.Parents {
|
|
if d, ok := inv.lookup(tok); ok {
|
|
keys = append(keys, d.Slug)
|
|
} else if n, ok := planned[strings.ToLower(tok)]; ok {
|
|
keys = append(keys, n)
|
|
} else {
|
|
errs = append(errs, RowError{File: FileDevices, Row: p.Row, Msg: fmt.Sprintf("parent %q is not a known device", tok)})
|
|
}
|
|
}
|
|
p.Parents = keys
|
|
}
|
|
return errs
|
|
}
|
|
|
|
func splitList(s string) []string {
|
|
var out []string
|
|
for _, f := range strings.Split(s, ";") {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|