Files
nmon/internal/api/import.go

104 lines
4.1 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/deps"
"github.com/v10networks/nmon/internal/importcsv"
)
// importMaxBytes bounds the multipart body of one import.
const importMaxBytes = 8 << 20
// ImportForm is the multipart form of POST /devices/import; send either file or both.
type ImportForm struct {
Devices huma.FormFile `form:"devices" contentType:"text/csv,text/plain,application/csv,application/vnd.ms-excel,application/octet-stream" required:"false" doc:"devices.csv: name, address, site, role, credential, parents."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Ports huma.FormFile `form:"ports" contentType:"text/csv,text/plain,application/csv,application/vnd.ms-excel,application/octet-stream" required:"false" doc:"ports.csv: device, ifname, alias (stored as the port description)."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
}
// ImportResult counts what an import wrote.
type ImportResult struct {
DevicesCreated int `json:"devices_created"`
SNMPProfilesCreated int `json:"snmp_profiles_created" doc:"SNMP credential records created for devices that gave a community inline."`
PortsUpdated int `json:"ports_updated" doc:"Interfaces whose operator-owned description was set."`
}
func (s *server) registerImport(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "import-devices", Method: http.MethodPost, Path: "/api/v1/devices/import", Tags: []string{"devices"},
Summary: "Import devices and port descriptions from CSV",
Description: "Admin only. Multipart form with devices.csv and/or ports.csv. Every problem is reported with its row; " +
"a dependency cycle is rejected with its path. Nothing is written unless the whole import is valid. " +
"Port descriptions are operator-owned: discovery never overwrites them.",
MaxBodyBytes: importMaxBytes,
Errors: []int{http.StatusUnprocessableEntity},
}, s.importDevices)
}
func (s *server) importDevices(ctx context.Context, in *struct {
RawBody huma.MultipartFormFiles[ImportForm]
}) (*struct{ Body ImportResult }, error) {
form := in.RawBody.Data()
var inputs importcsv.Inputs
if form.Devices.IsSet {
inputs.Devices = form.Devices
}
if form.Ports.IsSet {
inputs.Ports = form.Ports
}
if inputs.Devices == nil && inputs.Ports == nil {
return nil, huma.Error422UnprocessableEntity("send devices.csv, ports.csv or both")
}
plan, err := importcsv.Build(ctx, s.Store, inputs)
if err != nil {
return nil, s.importErr(err)
}
res, err := importcsv.Apply(ctx, s.Store, plan)
if err != nil {
return nil, s.storeErr(err)
}
if err := s.Store.AppendAudit(ctx, actorOf(ctx), "device.import", "import", nil, res); err != nil {
s.Logger.Warn("audit device.import", "err", err)
}
s.afterImport(ctx, res)
return &struct{ Body ImportResult }{ImportResult{
DevicesCreated: res.DevicesCreated, SNMPProfilesCreated: res.CredentialsCreated, PortsUpdated: res.PortsUpdated,
}}, nil
}
// afterImport makes the poller and the dependency graph see the new devices and starts their discovery.
func (s *server) afterImport(ctx context.Context, res importcsv.Result) {
if s.Poller != nil {
s.Poller.Refresh()
}
if err := s.Dependencies.Refresh(ctx); err != nil {
s.Logger.Warn("refresh dependency graph after import", "err", err)
}
if s.OnImported != nil && len(res.DeviceIDs) > 0 {
s.OnImported(res.DeviceIDs)
}
}
// importErr turns a plan failure into a 422 that names every bad row, or the cycle's path.
func (s *server) importErr(err error) error {
var pe *importcsv.PlanError
if errors.As(err, &pe) {
details := make([]error, 0, len(pe.Errors))
for _, e := range pe.Errors {
details = append(details, &huma.ErrorDetail{Message: e.Msg, Location: fmt.Sprintf("%s.csv row %d", e.File, e.Row)})
}
msg := fmt.Sprintf("%d row(s) are invalid; nothing was imported", len(pe.Errors))
return huma.NewError(http.StatusUnprocessableEntity, msg, details...)
}
var ce *deps.CycleError
if errors.As(err, &ce) {
return huma.Error422UnprocessableEntity("nothing was imported: " + err.Error())
}
return s.storeErr(err)
}