Files
rollcall/cmd/rollcall/location.go
T
Ilker ManapandClaude Opus 5.5 dc6eb55b26
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 42s
Location changes kick the IPAM reconcile
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>
2026-10-30 22:11:38 +01:00

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
}