diff --git a/CHANGELOG.md b/CHANGELOG.md index 67943e3..57f3311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ publish a tag that has no section here. the PowerDNS API (ownership by RRset comment) or RFC 2136 dynamic update with TSIG (ownership by a `_rollcall.` TXT marker and update prerequisites, so foreign records are never overwritten; drift is detected by querying). `rollcall dns`, `rollcall dns sync [-dry]`, a doctor check. +- NetBox device sync (`ipam.device_sync`): machines become dcim devices with serial, device type, + role, status, interfaces with MAC objects, assigned addresses and primary IP. Site, location and + rack are matched by name from the machine's location and never created; ownership by tag and + `rollcall_machine` as for addresses, foreign devices of the same name are conflicts. +- Lab NetBox (`lab/netbox/install.sh`): a container on the lab segment for the NetBox drivers. - Fixed leases on the site's DHCP server for proxy-mode VLANs (`dhcp_reservations`), for Kea with `host_cmds` (Kea 3.0+) and OpenWrt (rpcd). Rollcall's own leases are reconciled; foreign ones are reported. `rollcall reservations`. diff --git a/README.md b/README.md index 654c69d..5b0a2b7 100644 --- a/README.md +++ b/README.md @@ -788,6 +788,40 @@ rollcall ipam sync rollcall ipam prefixes | import ``` +### Devices in NetBox + +With `device_sync` in the `ipam` block, the NetBox driver also keeps every +commissioned machine as a `dcim` device: + +```json +"ipam": { "provider": "netbox", ..., "device_sync": { "site": "dc1", "role": "server" } } +``` + +- **What is written.** Name (node name, else the machine ID), serial, status + (deployed/booted/stateless are `active`, commissioned ones `staged`, failed + jobs `failed`), the device type from the DMI manufacturer and product, the + role (default `server`), interfaces with their MACs (NetBox 4.2+ MAC objects, + set as primary MAC), Rollcall's addresses assigned to those interfaces, and + the management address as primary IP. Machines still `new` are skipped. +- **Location comes from NetBox.** The machine's building, room and rack are + matched by name to a site, a location and a rack (in that site); the unit + becomes the rack position, the asset tag the device's. Rollcall never creates + sites, locations or racks; a machine whose building matches no site goes to + `device_sync.site`, which must exist. Manufacturers, device types and the role + are created when missing (a device needs them). +- **Ownership as for addresses.** Devices carry the tag `rollcall` and the + machine ID in `rollcall_machine` (enable the field for devices too). A device + of the same name without them is reported as a conflict and left alone; + Rollcall's devices whose machine is gone are deleted. Interfaces are removed + only when they carry the tag. +- **It runs after the address reconcile**, in the same loop; `rollcall ipam + sync` reports both (`devices` in the JSON). + +The NetBox user needs `view/add/change/delete` on `dcim.device`, +`dcim.interface` and `dcim.macaddress`, `view/add` on `dcim.manufacturer`, +`dcim.devicetype` and `dcim.devicerole`, and `view` on `dcim.location` and +`dcim.rack`, on top of the IPAM permissions. + The provider is an interface (reserve, owned records, update, delete, delegated prefixes); the NetBox driver is the first. An Infoblox WAPI driver maps them to `record:host`/`fixedaddress` with extensible attributes. diff --git a/cmd/rollcall/ipam.go b/cmd/rollcall/ipam.go index 0ac660a..b64aadf 100644 --- a/cmd/rollcall/ipam.go +++ b/cmd/rollcall/ipam.go @@ -45,6 +45,8 @@ type IPAMConfig struct { Tag string `json:"tag"` // ownership tag, default "rollcall" MachineField string `json:"machine_field"` // custom field holding the machine ID, default "rollcall_machine" SyncInterval string `json:"sync_interval"` // reconcile period, default 5m + // DeviceSync mirrors machines as NetBox devices (nbdevices.go); nil = off + DeviceSync *DeviceSyncConfig `json:"device_sync,omitempty"` } // extAddr is one address as the authority holds it. @@ -79,14 +81,15 @@ type ipamProvider interface { } type ipamReport struct { - Provider string `json:"provider"` - At time.Time `json:"at"` - DryRun bool `json:"dry_run,omitempty"` - Created []string `json:"created"` - Updated []string `json:"updated"` - Deleted []string `json:"deleted"` - Conflicts []string `json:"conflicts"` - Error string `json:"error,omitempty"` + Provider string `json:"provider"` + At time.Time `json:"at"` + DryRun bool `json:"dry_run,omitempty"` + Created []string `json:"created"` + Updated []string `json:"updated"` + Deleted []string `json:"deleted"` + Conflicts []string `json:"conflicts"` + Error string `json:"error,omitempty"` + Devices *devReport `json:"devices,omitempty"` // ipam.device_sync } // fail notes one record's error; the reconcile goes on with the others. @@ -279,6 +282,9 @@ func (a *App) startIPAM() error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) rep := a.reconcileIPAM(ctx, false) cancel() + if d := rep.Devices; d != nil && (d.Error != "" || len(d.Created)+len(d.Deleted)+len(d.Conflicts) > 0) { + log.Printf("ipam %s devices: created %v deleted %v conflicts %v %s", p.Name(), d.Created, d.Deleted, d.Conflicts, d.Error) + } if rep.Error != "" { log.Printf("ipam %s: %s", p.Name(), rep.Error) } else if n := len(rep.Created) + len(rep.Updated) + len(rep.Deleted) + len(rep.Conflicts); n > 0 { @@ -312,6 +318,14 @@ func (a *App) reconcileIPAM(ctx context.Context, dry bool) ipamReport { return rep } a.reconcileAddrs(ctx, local, &rep) + if nb, ok := a.ipam.(*netBox); ok && a.cfg.IPAM.DeviceSync != nil && !dry { + // after the addresses: the sync assigns them to the interfaces + if ms, err := a.store.List(ctx); err != nil { + rep.Devices = &devReport{Error: err.Error()} + } else { + rep.Devices = nb.syncDevices(ctx, *a.cfg.IPAM.DeviceSync, ms, local) + } + } return rep } diff --git a/cmd/rollcall/nbdevices.go b/cmd/rollcall/nbdevices.go new file mode 100644 index 0000000..25d1ff2 --- /dev/null +++ b/cmd/rollcall/nbdevices.go @@ -0,0 +1,443 @@ +package main + +// NetBox device sync (ipam.device_sync): each machine becomes a dcim device +// with its serial, interfaces (MACs as dcim mac-addresses) and location, and +// Rollcall's IP addresses in NetBox are assigned to those interfaces (the +// management address becomes the device's primary IP). +// +// Sites, locations and racks are NetBox's: Rollcall matches the machine's +// building, room and rack to them by name and never creates them (a machine +// whose building matches no site goes to device_sync.site). Manufacturers, +// device types and the role are created when missing, since a device needs +// them. Devices carry the tag and the rollcall_machine custom field like +// addresses; devices without them are never changed or deleted, and one +// holding a machine's name is a conflict. + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" +) + +type DeviceSyncConfig struct { + Site string `json:"site"` // site slug for machines whose building matches no site (required) + Role string `json:"role"` // device role slug, default "server" (created when missing) +} + +type devReport struct { + Created []string `json:"created"` + Updated []string `json:"updated"` + Deleted []string `json:"deleted"` + Conflicts []string `json:"conflicts"` + Error string `json:"error,omitempty"` +} + +func (r *devReport) fail(e string) { r.Error = joinErr(r.Error, e) } + +var nbSlugRe = regexp.MustCompile(`[^a-z0-9]+`) + +func nbSlug(s string) string { + s = strings.Trim(nbSlugRe.ReplaceAllString(strings.ToLower(s), "-"), "-") + if len(s) > 100 { + s = s[:100] + } + return orDefault(s, "unknown") +} + +// all reads every object of a list endpoint. +func (n *netBox) all(ctx context.Context, path string, q url.Values) ([]obj, error) { + if q == nil { + q = url.Values{} + } + q.Set("limit", "500") + next := path + "?" + q.Encode() + var out []obj + for next != "" { + var p nbPage[obj] + if err := n.do(ctx, "GET", next, nil, &p); err != nil { + return nil, err + } + out = append(out, p.Results...) + next = "" + if p.Next != nil { + next = *p.Next + } + } + return out, nil +} + +func nbID(o obj) int64 { + f, _ := o["id"].(float64) + return int64(f) +} + +// nbRef is the id of a nested object field ({"id": 3, ...}), 0 when unset. +func nbRef(o obj, field string) int64 { + if m, ok := o[field].(map[string]any); ok { + return nbID(m) + } + return 0 +} + +// mine is the machine a NetBox object belongs to, "" when it is not Rollcall's. +func (n *netBox) mine(o obj) string { return n.owner(nbIPFromObj(o)) } + +// ensure finds an object by a query or creates it. +func (n *netBox) ensure(ctx context.Context, path string, q url.Values, create obj) (int64, error) { + got, err := n.all(ctx, path, q) + if err != nil { + return 0, err + } + if len(got) > 0 { + return nbID(got[0]), nil + } + var made obj + if err := n.do(ctx, "POST", path, create, &made); err != nil { + return 0, err + } + return nbID(made), nil +} + +func nbStatus(state string) string { + switch { + case state == "deployed" || state == "booted" || state == "stateless" || state == "deploying" || state == "releasing": + return "active" + case strings.HasSuffix(state, "-failed"): + return "failed" + case state == "new": + return "planned" + } + return "staged" // commissioned, ready to deploy +} + +func nbIfaceType(n invNIC) string { + switch n.Speed { + case 1000: + return "1000base-t" + case 10000: + return "10gbase-x-sfpp" + case 25000: + return "25gbase-x-sfp28" + case 40000: + return "40gbase-x-qsfpp" + case 100000: + return "100gbase-x-qsfp28" + case 200000: + return "200gbase-x-qsfp56" + case 400000: + return "400gbase-x-qsfpdd" + } + return "other" +} + +// syncDevices brings NetBox's devices in line with the machines. +func (n *netBox) syncDevices(ctx context.Context, cfg DeviceSyncConfig, machines []*Machine, ips []IPAddr) *devReport { + rep := &devReport{Created: []string{}, Updated: []string{}, Deleted: []string{}, Conflicts: []string{}} + if cfg.Site == "" { + rep.Error = "ipam.device_sync.site (a site slug) is required" + return rep + } + sites, err := n.all(ctx, "dcim/sites/", nil) + if err != nil { + rep.Error = err.Error() + return rep + } + siteBy := map[string]int64{} + for _, s := range sites { + siteBy[strings.ToLower(str(s["slug"]))] = nbID(s) + siteBy[strings.ToLower(str(s["name"]))] = nbID(s) + } + defSite, ok := siteBy[strings.ToLower(cfg.Site)] + if !ok { + rep.Error = fmt.Sprintf("NetBox has no site %q (ipam.device_sync.site)", cfg.Site) + return rep + } + type siteName struct { + site int64 + name string + } + locBy, rackBy := map[siteName]int64{}, map[siteName]int64{} + if locs, err := n.all(ctx, "dcim/locations/", nil); err == nil { + for _, l := range locs { + locBy[siteName{nbRef(l, "site"), strings.ToLower(str(l["name"]))}] = nbID(l) + } + } + if racks, err := n.all(ctx, "dcim/racks/", nil); err == nil { + for _, r := range racks { + rackBy[siteName{nbRef(r, "site"), strings.ToLower(str(r["name"]))}] = nbID(r) + } + } + tags := []obj{{"name": n.tag}} + role := orDefault(cfg.Role, "server") + roleID, err := n.ensure(ctx, "dcim/device-roles/", url.Values{"slug": {nbSlug(role)}}, + obj{"name": strings.ToUpper(role[:1]) + role[1:], "slug": nbSlug(role), "color": "2196f3", "tags": tags}) + if err != nil { + rep.Error = "device role: " + err.Error() + return rep + } + owned := map[string]obj{} + devs, err := n.all(ctx, "dcim/devices/", url.Values{"tag": {n.tag}}) + if err != nil { + rep.Error = err.Error() + return rep + } + for _, d := range devs { + if m := n.mine(d); m != "" { + owned[m] = d + } + } + byMachine := map[string][]IPAddr{} + for _, x := range ips { + if x.MachineID != "" { + byMachine[x.MachineID] = append(byMachine[x.MachineID], x) + } + } + nbIPs := map[string]obj{} // Rollcall's addresses in NetBox, by IP + if all, err := n.all(ctx, "ipam/ip-addresses/", url.Values{"tag": {n.tag}}); err == nil { + for _, a := range all { + if p, ok := parseAddr(str(a["address"])); ok { + nbIPs[p.String()] = a + } + } + } + types := map[string]int64{} // manufacturer|model -> device type id + sort.Slice(machines, func(i, j int) bool { return machines[i].ID < machines[j].ID }) + for _, m := range machines { + if m.State == "new" { + continue + } + name := orDefault(deref(m.NodeName), m.ID) + mfr, model := orDefault(deref(m.Manufacturer), "Unknown"), orDefault(deref(m.Product), "Unknown") + tk := mfr + "|" + model + if _, ok := types[tk]; !ok { + mid, err := n.ensure(ctx, "dcim/manufacturers/", url.Values{"slug": {nbSlug(mfr)}}, obj{"name": mfr, "slug": nbSlug(mfr), "tags": tags}) + if err != nil { + rep.fail(fmt.Sprintf("%s: manufacturer: %v", name, err)) + continue + } + tid, err := n.ensure(ctx, "dcim/device-types/", url.Values{"manufacturer_id": {strconv.FormatInt(mid, 10)}, "slug": {nbSlug(model)}}, + obj{"manufacturer": mid, "model": model, "slug": nbSlug(model), "u_height": 1, "tags": tags}) + if err != nil { + rep.fail(fmt.Sprintf("%s: device type: %v", name, err)) + continue + } + types[tk] = tid + } + site := defSite + if id, ok := siteBy[strings.ToLower(m.Location.Building)]; ok && m.Location.Building != "" { + site = id + } + body := obj{"name": name, "device_type": types[tk], "role": roleID, "site": site, "status": nbStatus(m.State), + "serial": trunc(deref(m.Serial), 50), "tags": tags, "custom_fields": obj{n.field: m.ID}, + "location": nil, "rack": nil, "position": nil, "face": nil} + if id, ok := locBy[siteName{site, strings.ToLower(m.Location.Room)}]; ok && m.Location.Room != "" { + body["location"] = id + } + if id, ok := rackBy[siteName{site, strings.ToLower(m.Location.Rack)}]; ok && m.Location.Rack != "" { + body["rack"] = id + if m.Location.Unit > 0 { + body["position"], body["face"] = m.Location.Unit, "front" + } + } + if m.Location.AssetTag != "" { + body["asset_tag"] = m.Location.AssetTag + } + var dev obj + if d, ok := owned[m.ID]; ok { + delete(owned, m.ID) + if deviceDiffers(d, body) { + if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/devices/%d/", nbID(d)), body, &dev); err != nil { + rep.fail(fmt.Sprintf("%s: %v", name, err)) + continue + } + rep.Updated = append(rep.Updated, name) + } else { + dev = d + } + } else { + if same, err := n.all(ctx, "dcim/devices/", url.Values{"name": {name}, "site_id": {strconv.FormatInt(site, 10)}}); err != nil { + rep.fail(fmt.Sprintf("%s: %v", name, err)) + continue + } else if len(same) > 0 { + rep.Conflicts = append(rep.Conflicts, name+": NetBox has a device of that name (not Rollcall's)") + continue + } + if err := n.do(ctx, "POST", "dcim/devices/", body, &dev); err != nil { + rep.fail(fmt.Sprintf("%s: %v", name, err)) + continue + } + rep.Created = append(rep.Created, name) + } + if err := n.syncInterfaces(ctx, dev, m, byMachine[m.ID], nbIPs, tags); err != nil { + rep.fail(fmt.Sprintf("%s interfaces: %v", name, err)) + } + } + gone := make([]string, 0, len(owned)) + for k := range owned { + gone = append(gone, k) + } + sort.Strings(gone) + for _, k := range gone { // machines no longer in Rollcall + d := owned[k] + if err := n.do(ctx, "DELETE", fmt.Sprintf("dcim/devices/%d/", nbID(d)), nil, nil); err != nil { + rep.fail(fmt.Sprintf("delete %s: %v", str(d["name"]), err)) + continue + } + rep.Deleted = append(rep.Deleted, str(d["name"])) + } + return rep +} + +// deviceDiffers compares what Rollcall sets with what NetBox holds. +func deviceDiffers(d, want obj) bool { + ref := func(f string) int64 { return nbRef(d, f) } + num := func(v any) int64 { + switch x := v.(type) { + case int64: + return x + case int: + return int64(x) + case float64: + return int64(x) + } + return 0 + } + if str(d["name"]) != str(want["name"]) || str(d["serial"]) != str(want["serial"]) || str(asObj(d["status"])["value"]) != str(want["status"]) { + return true + } + for _, f := range []string{"device_type", "role", "site", "location", "rack"} { + if ref(f) != num(want[f]) { + return true + } + } + if num(d["position"]) != num(want["position"]) { + return true + } + return str(d["asset_tag"]) != str(want["asset_tag"]) +} + +// syncInterfaces keeps a device's interfaces, MACs and assigned addresses. +func (n *netBox) syncInterfaces(ctx context.Context, dev obj, m *Machine, ips []IPAddr, nbIPs map[string]obj, tags []obj) error { + devID := nbID(dev) + have := map[string]obj{} + got, err := n.all(ctx, "dcim/interfaces/", url.Values{"device_id": {strconv.FormatInt(devID, 10)}}) + if err != nil { + return err + } + for _, i := range got { + have[str(i["name"])] = i + } + ifaceID := map[string]int64{} + nics := machineNICs(m) + for _, nic := range nics { + cur, ok := have[nic.Name] + delete(have, nic.Name) + if !ok { + if err := n.do(ctx, "POST", "dcim/interfaces/", obj{"device": devID, "name": nic.Name, "type": nbIfaceType(nic), "tags": tags}, &cur); err != nil { + return err + } + } + ifaceID[nic.Name] = nbID(cur) + if nic.MAC == "" { + continue + } + if pm := asObj(cur["primary_mac_address"]); strings.EqualFold(str(pm["mac_address"]), nic.MAC) { + continue + } + macs, err := n.all(ctx, "dcim/mac-addresses/", url.Values{"interface_id": {strconv.FormatInt(nbID(cur), 10)}}) + if err != nil { + return err + } + var macID int64 + for _, x := range macs { + if strings.EqualFold(str(x["mac_address"]), nic.MAC) { + macID = nbID(x) + } + } + if macID == 0 { + var made obj + if err := n.do(ctx, "POST", "dcim/mac-addresses/", obj{"mac_address": nic.MAC, "assigned_object_type": "dcim.interface", "assigned_object_id": nbID(cur), "tags": tags}, &made); err != nil { + return err + } + macID = nbID(made) + } + if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/interfaces/%d/", nbID(cur)), obj{"primary_mac_address": macID}, nil); err != nil { + return err + } + } + for name, i := range have { // interfaces Rollcall made that the machine no longer has + if n.tagged(i) { + if err := n.do(ctx, "DELETE", fmt.Sprintf("dcim/interfaces/%d/", nbID(i)), nil, nil); err != nil { + return fmt.Errorf("delete interface %s: %w", name, err) + } + } + } + // addresses on their interfaces; the management address (else the first + // one on the boot NIC) is the primary IP + var boot string + if b := bootNIC(nics, m.MACs); b != nil { + boot = b.Name + } + mgmt := deref(m.MgmtIP) + primary := map[int]int64{} + for _, x := range ips { + a, ok := nbIPs[x.IP] + if !ok || n.mine(a) != m.ID { + continue + } + iid, ok := ifaceID[orDefault(x.Iface, boot)] + if !ok && x.IP == mgmt { + iid, ok = ifaceID[boot] + } + if !ok { + continue // a bond or VLAN interface: not in the inventory + } + if str(a["assigned_object_type"]) != "dcim.interface" || int64(num(a["assigned_object_id"])) != iid { + if err := n.do(ctx, "PATCH", fmt.Sprintf("ipam/ip-addresses/%d/", nbID(a)), obj{"assigned_object_type": "dcim.interface", "assigned_object_id": iid}, nil); err != nil { + return fmt.Errorf("assign %s: %w", x.IP, err) + } + } + fam := 4 + if strings.Contains(x.IP, ":") { + fam = 6 + } + if _, set := primary[fam]; x.IP == mgmt || (!set && iid == ifaceID[boot] && mgmt == "") { + primary[fam] = nbID(a) + } + } + body := obj{} + if id, ok := primary[4]; ok && nbRef(dev, "primary_ip4") != id { + body["primary_ip4"] = id + } + if id, ok := primary[6]; ok && nbRef(dev, "primary_ip6") != id { + body["primary_ip6"] = id + } + if len(body) > 0 { + if err := n.do(ctx, "PATCH", fmt.Sprintf("dcim/devices/%d/", devID), body, nil); err != nil { + return fmt.Errorf("primary ip: %w", err) + } + } + return nil +} + +// tagged: an object Rollcall created (it carries the tag). +func (n *netBox) tagged(o obj) bool { + for _, t := range toList(o["tags"]) { + if str(asObj(t)["name"]) == n.tag { + return true + } + } + return false +} + +func nbIPFromObj(a obj) nbIP { + x := nbIP{CustomFields: asObj(a["custom_fields"])} + for _, t := range toList(a["tags"]) { + x.Tags = append(x.Tags, struct{ Name string }{str(asObj(t)["name"])}) + } + return x +} diff --git a/cmd/rollcall/nbdevices_test.go b/cmd/rollcall/nbdevices_test.go new file mode 100644 index 0000000..db4de8c --- /dev/null +++ b/cmd/rollcall/nbdevices_test.go @@ -0,0 +1,328 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "testing" +) + +// fakeDCIM keeps NetBox collections as plain objects; references are stored +// as ids and served nested ({"id": n}), as NetBox does. +type fakeDCIM struct { + mu sync.Mutex + next int64 + objs map[string]map[int64]map[string]any // collection path -> id -> object + reqs []string +} + +var nbNested = []string{"site", "location", "rack", "role", "device_type", "manufacturer", "device", "primary_ip4", "primary_ip6", "primary_mac_address"} + +func newFakeDCIM() *fakeDCIM { return &fakeDCIM{next: 1, objs: map[string]map[int64]map[string]any{}} } + +func (f *fakeDCIM) put(coll string, o map[string]any) int64 { + if f.objs[coll] == nil { + f.objs[coll] = map[int64]map[string]any{} + } + id := f.next + f.next++ + o["id"] = float64(id) + if s, ok := o["status"].(string); ok { + o["status"] = map[string]any{"value": s} + } + f.objs[coll][id] = o + return id +} + +func (f *fakeDCIM) out(coll string, o map[string]any) map[string]any { + c := map[string]any{} + for k, v := range o { + c[k] = v + } + for _, k := range nbNested { + if id, ok := tnum(c[k]); ok && id > 0 { + n := map[string]any{"id": float64(id)} + if k == "primary_mac_address" { + n["mac_address"] = f.objs["dcim/mac-addresses/"][id]["mac_address"] + } + c[k] = n + } + } + return c +} + +func tnum(v any) (int64, bool) { + switch x := v.(type) { + case float64: + return int64(x), true + case int64: + return x, true + case int: + return int64(x), true + } + return 0, false +} + +func (f *fakeDCIM) match(o map[string]any, k, want string) bool { + switch k { + case "limit": + return true + case "tag": + for _, t := range toList(o["tags"]) { + if asObj(t)["name"] == want { + return true + } + } + return false + case "interface_id": + k = "assigned_object_id" + } + if strings.HasSuffix(k, "_id") && k != "assigned_object_id" { + k = strings.TrimSuffix(k, "_id") + } + if id, ok := tnum(o[k]); ok { + return fmt.Sprint(id) == want + } + return fmt.Sprint(o[k]) == want +} + +func (f *fakeDCIM) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.reqs = append(f.reqs, r.Method+" "+r.URL.Path) + write := func(code int, v any) { w.WriteHeader(code); _ = json.NewEncoder(w).Encode(v) } + p := strings.TrimPrefix(r.URL.Path, "/api/") + var coll string + var id int64 + if i := strings.LastIndex(strings.TrimSuffix(p, "/"), "/"); i > 0 { + if _, err := fmt.Sscanf(p[i+1:], "%d/", &id); err == nil { + coll = p[:i+1] + } + } + var body map[string]any + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + if coll == "" { + coll = p + switch r.Method { + case "GET": + ids := []int64{} + for i := range f.objs[coll] { + ids = append(ids, i) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + res := []any{} + next: + for _, i := range ids { + o := f.objs[coll][i] + for k, v := range r.URL.Query() { + if !f.match(o, k, v[0]) { + continue next + } + } + res = append(res, f.out(coll, o)) + } + write(200, map[string]any{"next": nil, "results": res}) + case "POST": + if coll == "dcim/devices/" { + for _, o := range f.objs[coll] { + if o["name"] == body["name"] && fmt.Sprint(o["site"]) == fmt.Sprint(body["site"]) { + write(400, map[string]any{"__all__": []string{"Device name must be unique per site."}}) + return + } + } + } + id := f.put(coll, body) + write(201, f.out(coll, f.objs[coll][id])) + } + return + } + o, ok := f.objs[coll][id] + if !ok { + write(404, map[string]any{"detail": "not found"}) + return + } + switch r.Method { + case "PATCH": + for k, v := range body { + o[k] = v + } + if s, ok := o["status"].(string); ok { + o["status"] = map[string]any{"value": s} + } + write(200, f.out(coll, o)) + case "DELETE": + delete(f.objs[coll], id) + if coll == "dcim/devices/" { // interfaces go with the device + for i, x := range f.objs["dcim/interfaces/"] { + if n, _ := tnum(x["device"]); n == id { + delete(f.objs["dcim/interfaces/"], i) + } + } + } + w.WriteHeader(204) + default: + write(200, f.out(coll, o)) + } +} + +func (f *fakeDCIM) find(coll, k, v string) map[string]any { + for _, o := range f.objs[coll] { + if fmt.Sprint(o[k]) == v { + return o + } + } + return nil +} + +func (f *fakeDCIM) count(coll string) int { return len(f.objs[coll]) } + +func testDCIM(t *testing.T) (*netBox, *fakeDCIM) { + t.Helper() + f := newFakeDCIM() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + tok := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tok, []byte("nbt_k.secret\n"), 0o600); err != nil { + t.Fatal(err) + } + p, err := newIPAMProvider(IPAMConfig{Provider: "netbox", URL: srv.URL, TokenFile: tok}) + if err != nil { + t.Fatal(err) + } + ev := f.put("dcim/sites/", map[string]any{"name": "Ev", "slug": "ev"}) + dc := f.put("dcim/sites/", map[string]any{"name": "DC1", "slug": "dc1"}) + f.put("dcim/locations/", map[string]any{"name": "Hall A", "site": float64(dc)}) + f.put("dcim/racks/", map[string]any{"name": "R12", "site": float64(dc)}) + f.put("dcim/racks/", map[string]any{"name": "R12", "site": float64(ev)}) // same name, other site + return p.(*netBox), f +} + +func dcimMachine(id, name, state string, loc Location, nics ...[2]string) *Machine { + var net []obj + for _, n := range nics { + net = append(net, obj{"name": n[0], "mac": n[1], "speed": "25000Mb/s"}) + } + inv, _ := json.Marshal(obj{"network": net}) + s := func(v string) *string { return &v } + m := &Machine{ID: id, State: state, Serial: s("SN-" + id), Manufacturer: s("Dell Inc."), Product: s("PowerEdge R760"), + Inventory: inv, Location: loc} + if name != "" { + m.NodeName = s(name) + } + if len(nics) > 0 { + m.MACs = []string{nics[0][1]} + } + return m +} + +func TestNetBoxDeviceSync(t *testing.T) { + nb, f := testDCIM(t) + ctx := context.Background() + cfg := DeviceSyncConfig{Site: "ev"} + tagged := []any{map[string]any{"name": "rollcall"}} + ipID := f.put("ipam/ip-addresses/", map[string]any{"address": "10.213.77.50/24", "tags": tagged, + "custom_fields": map[string]any{"rollcall_machine": "m-a"}}) + foreignIP := f.put("ipam/ip-addresses/", map[string]any{"address": "10.213.77.51/24", "tags": []any{}, + "custom_fields": map[string]any{}}) + // a device someone else made with a machine's name + f.put("dcim/devices/", map[string]any{"name": "taken", "site": float64(1), "tags": []any{}, "custom_fields": map[string]any{}}) + + a := dcimMachine("m-a", "node001", "deployed", Location{Building: "DC1", Room: "hall a", Rack: "R12", Unit: 10, AssetTag: "AT-1"}, + [2]string{"eno1", "aa:bb:cc:00:00:01"}, [2]string{"eno2", "aa:bb:cc:00:00:02"}) + b := dcimMachine("m-b", "", "ready", Location{Building: "nowhere", Rack: "R12"}, [2]string{"eth0", "aa:bb:cc:00:00:03"}) + c := dcimMachine("m-c", "taken", "commission-failed", Location{}) + n := dcimMachine("m-n", "", "new", Location{}) + ips := []IPAddr{{IP: "10.213.77.50", MachineID: "m-a", Iface: "eno1"}, {IP: "10.213.77.51", MachineID: "m-b"}} + + rep := nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips) + if rep.Error != "" { + t.Fatal(rep.Error) + } + if fmt.Sprint(rep.Created) != "[node001 m-b]" || len(rep.Conflicts) != 1 || !strings.Contains(rep.Conflicts[0], "taken") { + t.Fatalf("report %+v", rep) + } + d := f.find("dcim/devices/", "name", "node001") + dc := f.find("dcim/sites/", "slug", "dc1")["id"] + hall := f.find("dcim/locations/", "name", "Hall A")["id"] + if d["site"] != dc || d["location"] != hall || d["position"] != float64(10) || d["asset_tag"] != "AT-1" || + d["serial"] != "SN-m-a" || asObj(d["status"])["value"] != "active" || asObj(d["custom_fields"])["rollcall_machine"] != "m-a" { + t.Fatalf("device %v", d) + } + if r := f.objs["dcim/racks/"]; r[int64(num(d["rack"]))]["site"] != dc { + t.Fatalf("rack from another site: %v", d["rack"]) + } + db := f.find("dcim/devices/", "name", "m-b") + if num(db["site"]) != 1 || num(db["rack"]) != 5 || asObj(db["status"])["value"] != "staged" { + t.Fatalf("default-site device %v", db) // unknown building: the default site, and its own R12 + } + if f.find("dcim/devices/", "name", "m-n") != nil { + t.Fatal("a new machine (not commissioned) was synced") + } + if f.count("dcim/manufacturers/") != 1 || f.count("dcim/device-types/") != 1 || f.count("dcim/device-roles/") != 1 { + t.Fatal("types not shared") + } + // interfaces with primary MACs, the address on eno1 and as primary IP + eno1 := f.find("dcim/interfaces/", "name", "eno1") + if eno1["type"] != "25gbase-x-sfp28" { + t.Fatalf("iface %v", eno1) + } + mac := f.objs["dcim/mac-addresses/"][int64(num(eno1["primary_mac_address"]))] + if mac["mac_address"] != "aa:bb:cc:00:00:01" { + t.Fatalf("primary mac %v", eno1["primary_mac_address"]) + } + ip := f.objs["ipam/ip-addresses/"][ipID] + if ip["assigned_object_type"] != "dcim.interface" || ip["assigned_object_id"] != eno1["id"] || d["primary_ip4"] != float64(ipID) { + t.Fatalf("ip %v primary %v", ip, d["primary_ip4"]) + } + if f.objs["ipam/ip-addresses/"][foreignIP]["assigned_object_id"] != nil { + t.Fatal("assigned an address that is not Rollcall's") + } + + // a second pass changes nothing + f.reqs = nil + rep = nb.syncDevices(ctx, cfg, []*Machine{a, b, c, n}, ips) + for _, r := range f.reqs { + if !strings.HasPrefix(r, "GET ") { + t.Fatalf("second pass wrote: %v (%+v)", f.reqs, rep) + } + } + + // moves, a lost NIC, a removed machine + a.State = "releasing" + a.Location.Unit = 20 + a.Inventory, _ = json.Marshal(obj{"network": []obj{{"name": "eno1", "mac": "aa:bb:cc:00:00:01", "speed": "25000Mb/s"}}}) + rep = nb.syncDevices(ctx, cfg, []*Machine{a, c}, ips) + if fmt.Sprint(rep.Updated) != "[node001]" || fmt.Sprint(rep.Deleted) != "[m-b]" || rep.Error != "" { + t.Fatalf("report %+v", rep) + } + if d["position"] != float64(20) || f.find("dcim/interfaces/", "name", "eno2") != nil || f.find("dcim/devices/", "name", "taken") == nil { + t.Fatalf("after update: %v", d) + } +} + +func TestNetBoxDeviceSyncSite(t *testing.T) { + nb, _ := testDCIM(t) + if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{}, nil, nil); !strings.Contains(rep.Error, "required") { + t.Fatal(rep.Error) + } + if rep := nb.syncDevices(context.Background(), DeviceSyncConfig{Site: "mars"}, nil, nil); !strings.Contains(rep.Error, "no site") { + t.Fatal(rep.Error) + } +} + +func TestNBSlug(t *testing.T) { + for in, want := range map[string]string{"Dell Inc.": "dell-inc", "PowerEdge R760": "poweredge-r760", " ": "unknown", "xFusion/2288H V7": "xfusion-2288h-v7"} { + if got := nbSlug(in); got != want { + t.Errorf("%q: %q", in, got) + } + } +} diff --git a/lab/netbox/install.sh b/lab/netbox/install.sh new file mode 100644 index 0000000..5665b7f --- /dev/null +++ b/lab/netbox/install.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Lab NetBox for Rollcall's NetBox drivers (IPAM, device sync): a Debian 13 +# container on the lab segment, so tests never write to a real NetBox. +# Same install as netops (altyapi hosts/netops/kurulum.sh) without PowerDNS, +# ACME and backups; plain HTTP on :80 (the segment is isolated). +# +# Run as root inside the container; rerunnable. Secrets are generated here: +# /etc/lab-netbox/secrets.env (admin password, Rollcall's API token) +# +# On pve1 (example): +# pct create 225 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst --hostname lab-netbox \ +# --cores 2 --memory 2048 --swap 512 --rootfs zfspool:8 --unprivileged 1 \ +# --net0 name=eth0,bridge=pxenet,ip=10.213.77.6/24,gw=10.213.77.1 --nameserver 10.213.77.1 +# pct start 225 && pct push 225 install.sh /root/install.sh && pct exec 225 -- bash /root/install.sh +set -euo pipefail +NETBOX_VERSION=${NETBOX_VERSION:-4.7.4} +MYIP=${MYIP:-10.213.77.6} +SITE=${SITE:-lab} +PREFIXES=${PREFIXES:-"10.213.77.0/24 10.213.78.0/24 10.213.79.0/24 fd00:213:77::/64 fd00:213:78::/64"} +export DEBIAN_FRONTEND=noninteractive + +apt-get update -q +apt-get install -yq postgresql valkey-server caddy \ + python3 python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev \ + libpq-dev libssl-dev zlib1g-dev git curl jq + +install -d -m 700 /etc/lab-netbox +S=/etc/lab-netbox/secrets.env +if [ ! -f $S ]; then + r() { python3 -c "import secrets,sys;print(secrets.token_urlsafe(int(sys.argv[1])))" "$1"; } + cat > $S </dev/null 2>&1 || adduser --system --group --home /opt/netbox netbox +D=/opt/netbox-$NETBOX_VERSION +if [ ! -d $D ]; then + curl -fsSL https://github.com/netbox-community/netbox/archive/refs/tags/v$NETBOX_VERSION.tar.gz | tar -xz -C /opt +fi +[ -L /opt/netbox ] || { [ -d /opt/netbox ] && rmdir /opt/netbox 2>/dev/null || true; } +ln -sfn $D /opt/netbox +cat > /opt/netbox/netbox/netbox/configuration.py </dev/null || true +cd /opt/netbox && PYTHON=/usr/bin/python3 ./upgrade.sh >/var/log/netbox-upgrade.log 2>&1 || { tail -40 /var/log/netbox-upgrade.log; exit 1; } +chown -R netbox:netbox /opt/netbox/netbox/media +cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py +sed -i "s/^bind = .*/bind = '127.0.0.1:8001'/" /opt/netbox/gunicorn.py +cp /opt/netbox/contrib/netbox.service /opt/netbox/contrib/netbox-rq.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now netbox netbox-rq +systemctl restart netbox netbox-rq + +cat > /etc/caddy/Caddyfile <> $S +fi + +echo "--- ready" +systemctl is-active postgresql valkey-server netbox netbox-rq caddy | paste -sd' ' +. $S +curl -fsS -o /dev/null -w "rollcall token: %{http_code}\n" -H "Authorization: Bearer $NETBOX_TOKEN_ROLLCALL" http://$MYIP/api/ipam/prefixes/