Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
180 lines
4.7 KiB
Go
180 lines
4.7 KiB
Go
// Package importcsv turns the device CSV and the port-description CSV into a
|
|
// validated Plan and applies it to the inventory in one transaction. A plan is
|
|
// built without writing anything; every problem is reported with its CSV row
|
|
// number, and a dependency cycle is rejected with its path. Apply is
|
|
// all-or-nothing: any failure rolls the whole import back.
|
|
package importcsv
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/v10networks/nmon/internal/deps"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// File names used in row errors.
|
|
const (
|
|
FileDevices = "devices"
|
|
FilePorts = "ports"
|
|
)
|
|
|
|
// RowError is one problem in one CSV row. Row is 1-based and counts the header
|
|
// as row 1, so it matches what a spreadsheet shows.
|
|
type RowError struct {
|
|
File string
|
|
Row int
|
|
Msg string
|
|
}
|
|
|
|
func (e RowError) Error() string { return fmt.Sprintf("%s csv row %d: %s", e.File, e.Row, e.Msg) }
|
|
|
|
// PlanError collects every row error found in one pass.
|
|
type PlanError struct{ Errors []RowError }
|
|
|
|
func (e *PlanError) Error() string {
|
|
parts := make([]string, len(e.Errors))
|
|
for i, r := range e.Errors {
|
|
parts[i] = r.Error()
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
// DevicePlan is one device to create.
|
|
type DevicePlan struct {
|
|
Row int
|
|
Name string
|
|
Addresses []string
|
|
Site string
|
|
Role string
|
|
Kind string
|
|
Port int
|
|
// CredentialID is an existing credential, 0 when none or when NewCredential is set.
|
|
CredentialID int64
|
|
// NewCredential is created in the same transaction (v1/v2c community given inline).
|
|
NewCredential *store.SNMPCredential
|
|
// Parents are resolved keys: an existing device slug, or the Name of another planned device.
|
|
Parents []string
|
|
}
|
|
|
|
// PortPlan sets the operator-owned description of one existing interface (CSV column "alias" or "description").
|
|
type PortPlan struct {
|
|
Row int
|
|
DeviceID int64
|
|
Device string // slug
|
|
IfName string
|
|
IfaceID string
|
|
Description string
|
|
}
|
|
|
|
// Plan is a validated import. It holds no store handles.
|
|
type Plan struct {
|
|
Devices []DevicePlan
|
|
Ports []PortPlan
|
|
}
|
|
|
|
// Inputs are the CSV readers; either may be nil.
|
|
type Inputs struct {
|
|
Devices io.Reader
|
|
Ports io.Reader
|
|
}
|
|
|
|
// Build parses and validates the inputs against the current inventory. It
|
|
// returns a *PlanError for row problems, or an error wrapping *deps.CycleError
|
|
// when the requested parents would form a cycle. Nothing is written.
|
|
func Build(ctx context.Context, st store.Store, in Inputs) (*Plan, error) {
|
|
inv, err := loadInventory(ctx, st)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := &Plan{}
|
|
var errs []RowError
|
|
if in.Devices != nil {
|
|
devs, e, err := parseDevices(in.Devices, inv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Devices, errs = devs, append(errs, e...)
|
|
}
|
|
if in.Ports != nil {
|
|
ports, e, err := parsePorts(ctx, st, in.Ports, inv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Ports, errs = ports, append(errs, e...)
|
|
}
|
|
if len(errs) > 0 {
|
|
return nil, &PlanError{Errors: errs}
|
|
}
|
|
if err := checkCycles(ctx, st, p.Devices); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// checkCycles validates the parent edges of the new devices on top of the stored graph.
|
|
func checkCycles(ctx context.Context, st store.Store, devs []DevicePlan) error {
|
|
g := deps.New()
|
|
if err := g.Load(ctx, st); err != nil {
|
|
return fmt.Errorf("load dependency graph: %w", err)
|
|
}
|
|
changes := map[string][]string{}
|
|
for _, d := range devs {
|
|
changes[d.Name] = d.Parents
|
|
}
|
|
if err := g.Check(changes); err != nil {
|
|
var ce *deps.CycleError
|
|
if errors.As(err, &ce) {
|
|
return fmt.Errorf("devices csv: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readRecords reads the whole CSV and maps header names (lower-case) to columns.
|
|
func readRecords(r io.Reader, file string, required ...string) (cols map[string]int, rows [][]string, err error) {
|
|
cr := csv.NewReader(r)
|
|
cr.FieldsPerRecord = -1
|
|
cr.TrimLeadingSpace = true
|
|
all, err := cr.ReadAll()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%s csv: %w", file, err)
|
|
}
|
|
if len(all) == 0 {
|
|
return nil, nil, fmt.Errorf("%s csv: empty file, need a header row", file)
|
|
}
|
|
cols = map[string]int{}
|
|
for i, h := range all[0] {
|
|
cols[strings.ToLower(strings.TrimSpace(strings.TrimPrefix(h, "\ufeff")))] = i
|
|
}
|
|
for _, c := range required {
|
|
if _, ok := cols[c]; !ok {
|
|
return nil, nil, fmt.Errorf("%s csv: missing column %q", file, c)
|
|
}
|
|
}
|
|
return cols, all[1:], nil
|
|
}
|
|
|
|
// field returns the trimmed value of column name in rec, or "".
|
|
func field(cols map[string]int, rec []string, name string) string {
|
|
i, ok := cols[name]
|
|
if !ok || i >= len(rec) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(rec[i])
|
|
}
|
|
|
|
func blank(rec []string) bool {
|
|
for _, f := range rec {
|
|
if strings.TrimSpace(f) != "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|