NetBox device sync; lab NetBox container
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:
1 parent
c5688314b5
commit
133b5f38d9
6 files changed
+982
-8
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
Reference in new issue
Block a user