Files
nmon/internal/importcsv/plan.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
}