A machine's NetBox device follows a move at once, not at the next pass. Refs #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
459 lines
12 KiB
Go
459 lines
12 KiB
Go
package main
|
|
|
|
// Physical location: building / room / rack / unit (and an asset tag) per machine,
|
|
// plus optional rack definitions (height) for the elevation view. Locations are
|
|
// set by hand, in bulk, or imported from a CSV exported by a DCIM / asset list.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type Location struct {
|
|
Building string `json:"building"`
|
|
Room string `json:"room"`
|
|
Rack string `json:"rack"`
|
|
Unit int `json:"unit"` // lowest U occupied, 0 = unknown
|
|
AssetTag string `json:"asset_tag"`
|
|
}
|
|
|
|
func (l Location) String() string {
|
|
var p []string
|
|
for _, s := range []string{l.Building, l.Room, l.Rack} {
|
|
if s != "" {
|
|
p = append(p, s)
|
|
}
|
|
}
|
|
s := strings.Join(p, " / ")
|
|
if l.Unit > 0 {
|
|
s += fmt.Sprintf(" U%d", l.Unit)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (l Location) empty() bool {
|
|
return l.Building == "" && l.Room == "" && l.Rack == "" && l.Unit == 0 && l.AssetTag == ""
|
|
}
|
|
|
|
type Rack struct {
|
|
Building string `json:"building"`
|
|
Room string `json:"room"`
|
|
Name string `json:"name"`
|
|
Height int `json:"height"`
|
|
Comment string `json:"comment"`
|
|
Machines int `json:"machines,omitempty"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------- store
|
|
|
|
func (s *Store) SetLocation(ctx context.Context, id string, l Location) error {
|
|
tag, err := s.pool.Exec(ctx, `UPDATE machines SET building=nullif($2,''), room=nullif($3,''), rack=nullif($4,''), rack_unit=nullif($5,0), asset_tag=nullif($6,'') WHERE id=$1`,
|
|
id, strings.TrimSpace(l.Building), strings.TrimSpace(l.Room), strings.TrimSpace(l.Rack), l.Unit, strings.TrimSpace(l.AssetTag))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return errNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Racks(ctx context.Context) ([]Rack, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,'') FROM racks ORDER BY building, room, name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Rack{}
|
|
for rows.Next() {
|
|
var r Rack
|
|
if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) PutRack(ctx context.Context, r Rack) error {
|
|
if r.Height <= 0 {
|
|
r.Height = 42
|
|
}
|
|
_, err := s.pool.Exec(ctx, `INSERT INTO racks(building, room, name, height, comment) VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (building, room, name) DO UPDATE SET height=$4, comment=$5`, r.Building, r.Room, r.Name, r.Height, r.Comment)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) DeleteRack(ctx context.Context, building, room, name string) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM racks WHERE building=$1 AND room=$2 AND name=$3`, building, room, name)
|
|
return err
|
|
}
|
|
|
|
// ---------------------------------------------------------------- handlers
|
|
|
|
// handleLocation sets one machine's location: {"building","room","rack","unit","asset_tag"}.
|
|
// Fields that are absent keep their value; "" or 0 clears one.
|
|
func (a *App) handleLocation(w http.ResponseWriter, r *http.Request) {
|
|
m, err := a.store.Get(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
var req map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
l := m.Location
|
|
if v, ok := req["building"]; ok {
|
|
l.Building = str(v)
|
|
}
|
|
if v, ok := req["room"]; ok {
|
|
l.Room = str(v)
|
|
}
|
|
if v, ok := req["rack"]; ok {
|
|
l.Rack = str(v)
|
|
}
|
|
if v, ok := req["unit"]; ok {
|
|
l.Unit = atoi(v)
|
|
}
|
|
if v, ok := req["asset_tag"]; ok {
|
|
l.AssetTag = str(v)
|
|
}
|
|
if err := a.store.SetLocation(r.Context(), m.ID, l); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.ensureRack(r.Context(), l)
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "location", l.String())
|
|
m, _ = a.store.Get(r.Context(), m.ID)
|
|
a.retag(r.Context(), []*Machine{m}) // rules may look at the rack
|
|
a.kickIPAM() // the NetBox device follows (ipam.device_sync)
|
|
writeJSON(w, 200, obj{"id": m.ID, "location": m.Location})
|
|
}
|
|
|
|
// ensureRack creates a rack definition (default height) the first time a machine lands in it.
|
|
func (a *App) ensureRack(ctx context.Context, l Location) {
|
|
if l.Rack == "" {
|
|
return
|
|
}
|
|
racks, _ := a.store.Racks(ctx)
|
|
for _, r := range racks {
|
|
if r.Building == l.Building && r.Room == l.Room && r.Name == l.Rack {
|
|
return
|
|
}
|
|
}
|
|
_ = a.store.PutRack(ctx, Rack{Building: l.Building, Room: l.Room, Name: l.Rack})
|
|
}
|
|
|
|
func (a *App) handleRacks(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
racks, err := a.store.Racks(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
ms, _ := a.store.List(r.Context())
|
|
for i := range racks {
|
|
for _, m := range ms {
|
|
if m.Location.Building == racks[i].Building && m.Location.Room == racks[i].Room && m.Location.Rack == racks[i].Name {
|
|
racks[i].Machines++
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, 200, racks)
|
|
case http.MethodPost:
|
|
var rk Rack
|
|
if err := json.NewDecoder(r.Body).Decode(&rk); err != nil || rk.Name == "" {
|
|
fail(w, 400, errors.New("name is required"))
|
|
return
|
|
}
|
|
if err := a.store.PutRack(r.Context(), rk); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, rk)
|
|
case http.MethodDelete:
|
|
q := r.URL.Query()
|
|
if err := a.store.DeleteRack(r.Context(), q.Get("building"), q.Get("room"), q.Get("name")); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, obj{"deleted": q.Get("name")})
|
|
}
|
|
}
|
|
|
|
// handleLocationImport takes CSV (text/csv) or JSON rows. Columns (header names, any order):
|
|
//
|
|
// id | serial | mac | node_name one of these identifies the machine
|
|
// building, room, rack, unit, asset_tag
|
|
//
|
|
// Response lists matched / unmatched rows. Dry run with ?dry=1.
|
|
func (a *App) handleLocationImport(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
|
|
if err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
var rows []map[string]string
|
|
if strings.HasPrefix(strings.TrimSpace(string(body)), "[") {
|
|
var raw []map[string]any
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
for _, x := range raw {
|
|
row := map[string]string{}
|
|
for k, v := range x {
|
|
row[strings.ToLower(k)] = strings.TrimSpace(str(v))
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
} else {
|
|
rd := csv.NewReader(strings.NewReader(string(body)))
|
|
rd.FieldsPerRecord = -1
|
|
rd.TrimLeadingSpace = true
|
|
recs, err := rd.ReadAll()
|
|
if err != nil {
|
|
fail(w, 400, fmt.Errorf("csv: %w", err))
|
|
return
|
|
}
|
|
if len(recs) < 2 {
|
|
fail(w, 400, errors.New("csv needs a header and at least one row"))
|
|
return
|
|
}
|
|
hdr := recs[0]
|
|
for i := range hdr {
|
|
hdr[i] = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(hdr[i], "\xef\xbb\xbf"))) // strip UTF-8 BOM
|
|
}
|
|
for _, rec := range recs[1:] {
|
|
row := map[string]string{}
|
|
for i, v := range rec {
|
|
if i < len(hdr) {
|
|
row[hdr[i]] = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
ms, err := a.store.List(r.Context())
|
|
if err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
find := func(row map[string]string) *Machine {
|
|
for _, m := range ms {
|
|
if id := row["id"]; id != "" && m.ID == id {
|
|
return m
|
|
}
|
|
if sn := row["serial"]; sn != "" && strings.EqualFold(deref(m.Serial), sn) {
|
|
return m
|
|
}
|
|
if nn := row["node_name"]; nn != "" && deref(m.NodeName) == nn {
|
|
return m
|
|
}
|
|
if mac := normMAC(row["mac"]); mac != "" && hasTag(m.MACs, mac) {
|
|
return m
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
dry := r.URL.Query().Get("dry") == "1"
|
|
var matched, unmatched []obj
|
|
for i, row := range rows {
|
|
m := find(row)
|
|
if m == nil {
|
|
unmatched = append(unmatched, obj{"row": i + 1, "keys": obj{"id": row["id"], "serial": row["serial"], "mac": row["mac"], "node_name": row["node_name"]}})
|
|
continue
|
|
}
|
|
l := m.Location
|
|
if v, ok := row["building"]; ok {
|
|
l.Building = v
|
|
}
|
|
if v, ok := row["room"]; ok {
|
|
l.Room = v
|
|
}
|
|
if v, ok := row["rack"]; ok {
|
|
l.Rack = v
|
|
}
|
|
if v, ok := row["unit"]; ok {
|
|
l.Unit, _ = strconv.Atoi(strings.TrimPrefix(strings.ToUpper(v), "U"))
|
|
}
|
|
if v, ok := row["asset_tag"]; ok {
|
|
l.AssetTag = v
|
|
}
|
|
matched = append(matched, obj{"row": i + 1, "id": m.ID, "name": deref(m.NodeName), "location": l})
|
|
if !dry {
|
|
if err := a.store.SetLocation(r.Context(), m.ID, l); err != nil {
|
|
fail(w, 500, err)
|
|
return
|
|
}
|
|
a.ensureRack(r.Context(), l)
|
|
_ = a.store.AddEvent(r.Context(), m.ID, "location", l.String()+" (import)")
|
|
}
|
|
}
|
|
if !dry {
|
|
a.retag(r.Context(), nil)
|
|
a.kickIPAM()
|
|
}
|
|
if matched == nil {
|
|
matched = []obj{}
|
|
}
|
|
if unmatched == nil {
|
|
unmatched = []obj{}
|
|
}
|
|
writeJSON(w, 200, obj{"dry_run": dry, "matched": matched, "unmatched": unmatched})
|
|
}
|
|
|
|
// locationFilter applies ?building= ?room= ?rack= to the list.
|
|
func locationMatch(m *Machine, q map[string][]string) bool {
|
|
for _, k := range []string{"building", "room", "rack"} {
|
|
if v := q[k]; len(v) > 0 && v[0] != "" {
|
|
var have string
|
|
switch k {
|
|
case "building":
|
|
have = m.Location.Building
|
|
case "room":
|
|
have = m.Location.Room
|
|
case "rack":
|
|
have = m.Location.Rack
|
|
}
|
|
if !strings.EqualFold(have, v[0]) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------- CLI
|
|
|
|
func (c *client) locationCmd(args []string) error {
|
|
if len(args) < 1 {
|
|
usage()
|
|
}
|
|
switch args[0] {
|
|
case "import":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := newFlags("location import")
|
|
dry := fs.Bool("dry", false, "show what would change")
|
|
_ = fs.Parse(args[2:])
|
|
b, err := readFileArg(args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := ""
|
|
if *dry {
|
|
q = "?dry=1"
|
|
}
|
|
data, err := c.rawBody("POST", "/api/v1/locations/import"+q, "text/csv", b)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var res struct {
|
|
DryRun bool `json:"dry_run"`
|
|
Matched []obj `json:"matched"`
|
|
Unmatched []obj `json:"unmatched"`
|
|
}
|
|
_ = json.Unmarshal(data, &res)
|
|
for _, m := range res.Matched {
|
|
lb, _ := json.Marshal(m["location"])
|
|
fmt.Printf("row %v: %s %s -> %s\n", m["row"], str(m["id"]), str(m["name"]), lb)
|
|
}
|
|
for _, u := range res.Unmatched {
|
|
kb, _ := json.Marshal(u["keys"])
|
|
fmt.Printf("row %v: NO MATCH %s\n", u["row"], kb)
|
|
}
|
|
fmt.Printf("%d matched, %d unmatched%s\n", len(res.Matched), len(res.Unmatched), map[bool]string{true: " (dry run)", false: ""}[res.DryRun])
|
|
case "racks":
|
|
var racks []Rack
|
|
if err := c.do("GET", "/api/v1/racks", nil, &racks); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-14s %-14s %-12s %6s %8s %s\n", "BUILDING", "ROOM", "RACK", "HEIGHT", "MACHINES", "COMMENT")
|
|
for _, r := range racks {
|
|
fmt.Printf("%-14s %-14s %-12s %6d %8d %s\n", r.Building, r.Room, r.Name, r.Height, r.Machines, r.Comment)
|
|
}
|
|
case "rack":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
fs := newFlags("location rack")
|
|
b := fs.String("building", "", "building")
|
|
rm := fs.String("room", "", "room")
|
|
h := fs.Int("height", 42, "rack units")
|
|
cm := fs.String("comment", "", "comment")
|
|
_ = fs.Parse(args[2:])
|
|
return c.do("POST", "/api/v1/racks", Rack{Building: *b, Room: *rm, Name: args[1], Height: *h, Comment: *cm}, nil)
|
|
default: // location ID -building B -room R -rack K -unit N -asset T
|
|
fs := newFlags("location")
|
|
b := fs.String("building", "\x00", "building")
|
|
rm := fs.String("room", "\x00", "room")
|
|
rk := fs.String("rack", "\x00", "rack")
|
|
u := fs.Int("unit", -1, "lowest rack unit")
|
|
at := fs.String("asset", "\x00", "asset tag")
|
|
_ = fs.Parse(args[1:])
|
|
ids, err := c.targets(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req := obj{}
|
|
if *b != "\x00" {
|
|
req["building"] = *b
|
|
}
|
|
if *rm != "\x00" {
|
|
req["room"] = *rm
|
|
}
|
|
if *rk != "\x00" {
|
|
req["rack"] = *rk
|
|
}
|
|
if *u >= 0 {
|
|
req["unit"] = *u
|
|
}
|
|
if *at != "\x00" {
|
|
req["asset_tag"] = *at
|
|
}
|
|
if len(req) == 0 {
|
|
var ms []Summary
|
|
if err := c.do("GET", "/api/v1/machines", nil, &ms); err != nil {
|
|
return err
|
|
}
|
|
for _, m := range ms {
|
|
if hasTag(ids, m.ID) {
|
|
fmt.Printf("%s %s: %s\n", m.ID, deref(m.NodeName), m.Location.String())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for _, id := range ids {
|
|
var d obj
|
|
if err := c.do("POST", "/api/v1/machines/"+id+"/location", req, &d); err != nil {
|
|
return err
|
|
}
|
|
lb, _ := json.Marshal(d["location"])
|
|
fmt.Printf("%s -> %s\n", id, lb)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sortedKeys(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|