NetBox device sync; lab NetBox container
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 43s

ipam.device_sync mirrors commissioned machines as dcim devices: serial,
device type and role (created when missing), status, interfaces with MAC
objects, Rollcall's addresses assigned to them and the management address
as primary IP. Site, location and rack are matched by name from the
machine's location and never created. Ownership by tag and
rollcall_machine; a foreign device of the same name is a conflict.

lab/netbox/install.sh sets up a NetBox on the lab segment so the drivers
are tested without writing to a real NetBox.

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-30 22:00:43 +01:00
1 parent c5688314b5
commit 133b5f38d9
6 files changed
+982 -8

No files matched your search

+5
View File
@@ -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.<name>` 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`.
+34
View File
@@ -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.
+22 -8
View File
@@ -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
}
+443
View File
@@ -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
}
+328
View File
@@ -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)
}
}
}
+150
View File
@@ -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 <<EOF
NETBOX_DB_PASS=$(r 24)
NETBOX_SECRET_KEY=$(r 50)
NETBOX_PEPPER=$(r 50)
NETBOX_ADMIN_PASS=$(r 18)
ROLLCALL_PASS=$(r 18)
EOF
chmod 600 $S
fi
. $S
# ------------------------------------------------------------------ PostgreSQL, Valkey
sudo() { runuser -u "$2" -- "${@:3}"; } # "sudo -u postgres ..." without sudo
if [ -z "$(sudo -u postgres psql -tAqc "SELECT 1 FROM pg_roles WHERE rolname='netbox'")" ]; then
sudo -u postgres psql -qc "CREATE ROLE netbox LOGIN PASSWORD '$NETBOX_DB_PASS'"
# UTF-8 whatever the container's locale (C in the Proxmox template)
sudo -u postgres psql -qc "CREATE DATABASE netbox OWNER netbox ENCODING 'UTF8' LC_COLLATE 'C.UTF-8' LC_CTYPE 'C.UTF-8' TEMPLATE template0"
fi
sed -i 's/^# *databases .*/databases 16/' /etc/valkey/valkey.conf || true
systemctl enable --now valkey-server
# ------------------------------------------------------------------ NetBox
id netbox >/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 <<EOF
# pxestuff lab/netbox/install.sh generates this file
ALLOWED_HOSTS = ['$MYIP', 'localhost', '127.0.0.1']
DATABASES = {'default': {'NAME': 'netbox', 'USER': 'netbox', 'PASSWORD': '$NETBOX_DB_PASS',
'HOST': '127.0.0.1', 'PORT': '', 'CONN_MAX_AGE': 300}}
REDIS = {
'tasks': {'HOST': '127.0.0.1', 'PORT': 6379, 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 0, 'SSL': False},
'caching': {'HOST': '127.0.0.1', 'PORT': 6379, 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 1, 'SSL': False},
}
SECRET_KEY = '$NETBOX_SECRET_KEY'
API_TOKEN_PEPPERS = {1: '$NETBOX_PEPPER'}
TIME_ZONE = 'Europe/Stockholm'
CSRF_TRUSTED_ORIGINS = ['http://$MYIP']
EOF
chown -R netbox:netbox $D/netbox/media $D/netbox/reports $D/netbox/scripts 2>/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 <<EOF
# pxestuff lab/netbox/install.sh
http://$MYIP, http://localhost {
handle_path /static/* {
root * /opt/netbox/netbox/static
file_server
}
reverse_proxy 127.0.0.1:8001
}
EOF
systemctl enable caddy && systemctl restart caddy
# ------------------------------------------------------------------ what Rollcall needs
# admin and rollcall users, the ownership tag and field, Rollcall's permissions
# (as altyapi hosts/netops/seed/rollcall-izinler.py on netops), the lab site
# and its prefixes delegated to Rollcall (tagged).
NB_ADMIN_PASS=$NETBOX_ADMIN_PASS NB_ROLLCALL_PASS=$ROLLCALL_PASS NB_SITE=$SITE NB_PREFIXES=$PREFIXES \
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py shell -c '
import os
from django.contrib.contenttypes.models import ContentType
from users.models import User, ObjectPermission
from extras.models import Tag, CustomField
from dcim.models import Site
from ipam.models import Prefix
def user(name, pw, su):
u, _ = User.objects.get_or_create(username=name)
u.set_password(pw); u.is_superuser = su; u.is_active = True; u.save()
return u
user("admin", os.environ["NB_ADMIN_PASS"], True)
rc = user("rollcall", os.environ["NB_ROLLCALL_PASS"], False)
tag, _ = Tag.objects.get_or_create(slug="rollcall", defaults={"name": "rollcall", "color": "2196f3"})
cf, _ = CustomField.objects.get_or_create(name="rollcall_machine", defaults={"type": "text", "label": "Rollcall machine"})
for m in ("ipam.ipaddress", "dcim.device"):
cf.object_types.add(ContentType.objects.get_by_natural_key(*m.split(".")))
perms = {
"rollcall-ipam-write": (["ipam.ipaddress"], ["view", "add", "change", "delete"]),
"rollcall-read": (["ipam.prefix", "ipam.vlan", "ipam.iprange", "extras.tag", "dcim.site"], ["view"]),
"rollcall-dcim-write": (["dcim.device", "dcim.interface", "dcim.macaddress"], ["view", "add", "change", "delete"]),
"rollcall-dcim-types": (["dcim.manufacturer", "dcim.devicetype", "dcim.devicerole"], ["view", "add"]),
"rollcall-dcim-read": (["dcim.location", "dcim.rack"], ["view"]),
}
for name, (types, actions) in perms.items():
p, _ = ObjectPermission.objects.get_or_create(name=name, defaults={"actions": actions})
p.actions = actions; p.enabled = True; p.save()
p.object_types.set([ContentType.objects.get_by_natural_key(*t.split(".")) for t in types])
p.users.add(rc)
site, _ = Site.objects.get_or_create(slug=os.environ["NB_SITE"], defaults={"name": os.environ["NB_SITE"].capitalize()})
for cidr in os.environ["NB_PREFIXES"].split():
p, _ = Prefix.objects.get_or_create(prefix=cidr, defaults={"description": "Rollcall lab", "scope": site})
p.tags.add(tag)
print("rollcall objects ready")
'
# Rollcall's API token (v2), made once through the API with its password
if ! grep -q '^NETBOX_TOKEN_ROLLCALL=' $S; then
curl -fsS http://127.0.0.1:8001/api/users/tokens/provision/ -H "Host: $MYIP" \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-d "{\"username\":\"rollcall\",\"password\":\"$ROLLCALL_PASS\",\"description\":\"rollcall region\",\"write_enabled\":true}" |
python3 -c 'import json,sys;d=json.load(sys.stdin);print("NETBOX_TOKEN_ROLLCALL=nbt_%s.%s"%(d["key"],d["token"]))' >> $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/