Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
104 lines
4.1 KiB
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)
|
|
}
|