Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions snmp-discovery/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ package config

import "time"

// TargetType selects how a target is ingested into NetBox.
// TargetTypeDevice (default, empty/"device") emits a dcim.Device graph.
// TargetTypeVirtualMachine ("virtualmachine") emits a
// virtualization.VirtualMachine graph. Stored lowercase; user input is
// case-insensitive (see manager.applyDefaults).
const (
TargetTypeDevice = "device"
TargetTypeVirtualMachine = "virtualmachine"
)

// Status represents the status of the snmp-discovery service
type Status struct {
StartTime time.Time `json:"start_time"`
Expand Down Expand Up @@ -91,6 +101,9 @@ type Defaults struct {
VLAN VLANDefaults `yaml:"vlan,omitempty"`
InterfacePatterns []InterfacePattern `yaml:"interface_patterns,omitempty"`
InterfaceExcludePatterns []string `yaml:"interface_exclude_patterns,omitempty"`
Type string `yaml:"type,omitempty"` // TargetTypeDevice | TargetTypeVirtualMachine; default Device
Cluster string `yaml:"cluster,omitempty"` // optional VM cluster name; ignored when Type == TargetTypeDevice
ClusterType string `yaml:"cluster_type,omitempty"` // optional VM cluster type (NetBox virtualization.ClusterType); required to auto-create a Cluster
}

// MergeDefaults merges target-level override defaults with policy-level defaults
Expand Down Expand Up @@ -198,6 +211,17 @@ func MergeDefaults(policyDefaults, overrideDefaults *Defaults) *Defaults {
merged.InterfaceExcludePatterns = overrideDefaults.InterfaceExcludePatterns
}

// Override Type / Cluster / ClusterType if provided.
if overrideDefaults.Type != "" {
merged.Type = overrideDefaults.Type
}
if overrideDefaults.Cluster != "" {
merged.Cluster = overrideDefaults.Cluster
}
if overrideDefaults.ClusterType != "" {
merged.ClusterType = overrideDefaults.ClusterType
}

return &merged
}

Expand Down
35 changes: 35 additions & 0 deletions snmp-discovery/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,41 @@ func TestMergeDefaults_PolicyDeviceModelManufacturerPlatformSurviveNilOverride(t
assert.Equal(t, "policy-plat", merged.Device.Platform)
}

func TestMergeDefaults_TypeAndCluster(t *testing.T) {
policyDef := &Defaults{Type: TargetTypeDevice, Cluster: ""}
override := &Defaults{Type: TargetTypeVirtualMachine, Cluster: "proxmox-a"}
merged := MergeDefaults(policyDef, override)
assert.Equal(t, TargetTypeVirtualMachine, merged.Type)
assert.Equal(t, "proxmox-a", merged.Cluster)

// Empty override Type must not clobber a non-empty policy Type.
merged2 := MergeDefaults(&Defaults{Type: TargetTypeVirtualMachine, Cluster: "proxmox-a"}, &Defaults{})
assert.Equal(t, TargetTypeVirtualMachine, merged2.Type)
assert.Equal(t, "proxmox-a", merged2.Cluster)

// Override Cluster wins over policy Cluster when both set.
merged3 := MergeDefaults(
&Defaults{Type: TargetTypeVirtualMachine, Cluster: "policy-cluster"},
&Defaults{Cluster: "override-cluster"},
)
assert.Equal(t, TargetTypeVirtualMachine, merged3.Type)
assert.Equal(t, "override-cluster", merged3.Cluster)

// ClusterType merges and follows the same override-wins rule.
merged4 := MergeDefaults(
&Defaults{Type: TargetTypeVirtualMachine, ClusterType: "policy-type"},
&Defaults{ClusterType: "override-type"},
)
assert.Equal(t, "override-type", merged4.ClusterType)

// Empty override ClusterType must not clobber a non-empty policy ClusterType.
merged5 := MergeDefaults(
&Defaults{Type: TargetTypeVirtualMachine, ClusterType: "policy-type"},
&Defaults{},
)
assert.Equal(t, "policy-type", merged5.ClusterType)
}

func TestTargetNetboxID_parsed(t *testing.T) {
input := `
host: "192.168.1.1"
Expand Down
15 changes: 11 additions & 4 deletions snmp-discovery/mapping/mappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ const (
maxInterfaceSpeed = 2147483647
)

// MTU constants
// MTU constants. The upper bound matches NetBox's own
// PositiveIntegerField(MaxValueValidator(65536)) on dcim.Interface.mtu and
// virtualization.VMInterface.mtu — values outside [1, 65536] are rejected
// by the reconciler, which would drop the entire interface (and its IPs /
// MACs) from a change-set. We skip the field instead so the rest of the
// entity still ingests.
const (
minInterfaceMTU = 1
maxInterfaceMTU = 2147483647
maxInterfaceMTU = 65536
)

// IPAddressMapper is a struct that maps IP addresses to entities
Expand Down Expand Up @@ -837,9 +842,11 @@ func (m *InterfaceMapper) Map(values map[ObjectIDIndex]*ObjectIDValue, mappingEn
m.logger.Debug("mtu is zero, skipping", "value", value.Value)
continue
}
// Check if MTU is within valid range (1 to 2147483647 inclusive) and not overflowing int32
// Check if MTU is within NetBox's accepted range (1 to 65536 inclusive).
// Values outside this range are skipped (field left unset) rather
// than dropping the whole interface in the reconciler.
if mtu < minInterfaceMTU || mtu > maxInterfaceMTU {
m.logger.Warn("interface MTU is outside valid range (1-2147483647) or overflows int32", "mtu", mtu,
m.logger.Warn("interface MTU is outside valid range (1-65536), skipping field", "mtu", mtu,
"value", value.Value, "mapping_id", propertyMappingEntry.OID, "interface_index", objectID)
continue
}
Expand Down
10 changes: 5 additions & 5 deletions snmp-discovery/mapping/mappers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,7 @@ func TestInterfaceMapper_Map(t *testing.T) {
OID: "1.3.6.1.2.1.2.2.1.4.1",
Index: "1",
Parent: "1.3.6.1.2.1.2.2.1.4",
Value: "2147483647",
Value: "65536",
Type: mapping.Integer,
},
},
Expand Down Expand Up @@ -1167,7 +1167,7 @@ func TestInterfaceMapper_Map(t *testing.T) {
defaults: nil,
expectedEntity: &diode.Interface{
Name: mapping.StringPtr("eth0"),
Mtu: int64Ptr(2147483647), // MTU should be set when value is at maximum valid range
Mtu: int64Ptr(65536), // MTU should be set when value is at NetBox's maximum (65536)
},
expectError: false,
},
Expand Down Expand Up @@ -1226,7 +1226,7 @@ func TestInterfaceMapper_Map(t *testing.T) {
expectError: false,
},
{
name: "mapping with MTU just above maximum should result in nil MTU",
name: "mapping with MTU just above NetBox's maximum (65537) should result in nil MTU",
values: map[mapping.ObjectIDIndex]*mapping.ObjectIDValue{
"1.3.6.1.2.1.2.2.1.1.1": {
OID: "1.3.6.1.2.1.2.2.1.1.1",
Expand All @@ -1246,7 +1246,7 @@ func TestInterfaceMapper_Map(t *testing.T) {
OID: "1.3.6.1.2.1.2.2.1.4.1",
Index: "1",
Parent: "1.3.6.1.2.1.2.2.1.4",
Value: "2147483648",
Value: "65537",
Type: mapping.Integer,
},
},
Expand Down Expand Up @@ -1275,7 +1275,7 @@ func TestInterfaceMapper_Map(t *testing.T) {
defaults: nil,
expectedEntity: &diode.Interface{
Name: mapping.StringPtr("eth0"),
Mtu: nil, // MTU should be nil when value is just above maximum
Mtu: nil, // MTU should be nil when value is just above NetBox's maximum (65536)
},
expectError: false,
},
Expand Down
155 changes: 153 additions & 2 deletions snmp-discovery/mapping/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,15 @@ func newMACMatchStub(mac *diode.MACAddress) *diode.MACAddress {
//
// Metadata.source_match (e.g. netbox_id) is the diode-netbox-plugin's
// PK-based match path, so it must not diverge between rich and stub.
// Annotation metadata such as run_id is intentionally NOT copied —
// stubs are matcher-only.
// Annotation metadata such as run_id is NOT copied here at stub
// construction — but note that the run_id annotation walker
// (annotateEntitiesWithRunID) DOES reach cycle-break stubs embedded at
// rich Device.PrimaryIp4.AssignedObject.Device because that chain is
// traversed before PruneNestedRefs runs. Diode ignores run_id on
// matcher refs (matching uses Name / Site / Tenant / source_match /
// asset_tag / primary_ip), so the leaked run_id is inert payload, not
// a correctness issue. The "stubs are matcher-only" property holds
// for everything that matters to NetBox resolution.
func newDeviceStub(d *diode.Device) *diode.Device {
if d == nil {
return nil
Expand Down Expand Up @@ -142,6 +149,150 @@ func CurrentDeviceFrom(entities []diode.Entity) *diode.Device {
return nil
}

// CurrentVirtualMachineFrom returns the first *diode.VirtualMachine in
// the slice, or nil. Mirrors CurrentDeviceFrom for the VM-target path
// added by TransformToVirtualMachine — when a target is flagged as a
// VM, the runner threads this into PruneNestedRefsVM instead of
// PruneNestedRefs.
func CurrentVirtualMachineFrom(entities []diode.Entity) *diode.VirtualMachine {
for _, e := range entities {
if vm, ok := e.(*diode.VirtualMachine); ok {
return vm
}
}
return nil
}

// newVMMatchStub returns a VirtualMachine populated with matcher
// fields (Name, Site, Cluster, Tenant, Role, PrimaryIp4/6 matcher
// stubs) plus metadata.source_match when present on the source. Used
// wherever a VirtualMachine appears as a nested reference
// (VMInterface.VirtualMachine, and the stub assigned to a stubbed
// VMInterface used by IPAddress.AssignedObject). Cluster is included
// because the NetBox plugin VM matcher uses (name, cluster) and
// (name, cluster, tenant) — omitting it would fail to resolve the
// right VM when cluster is the discriminating field.
func newVMMatchStub(vm *diode.VirtualMachine) *diode.VirtualMachine {
if vm == nil {
return nil
}
stub := &diode.VirtualMachine{
Name: vm.Name,
Site: vm.Site,
Cluster: vm.Cluster,
Tenant: vm.Tenant,
Role: vm.Role,
PrimaryIp4: newIPMatchStub(vm.PrimaryIp4),
PrimaryIp6: newIPMatchStub(vm.PrimaryIp6),
}
if sm, ok := vm.Metadata["source_match"]; ok {
stub.Metadata = diode.Metadata{"source_match": sm}
}
return stub
}

// newVMInterfaceStub returns a VMInterface populated with matcher
// fields and a stubbed VirtualMachine ref. Used for
// IPAddress.AssignedObject and for VMInterface.Parent / .Bridge
// during VM-rooted pruning. PrimaryMacAddress is preserved (via
// newMACMatchStub) so the stub keeps the unique_primary_mac_address
// matcher precedence and resolves to the same VMInterface as the rich
// top-level entity.
func newVMInterfaceStub(iface *diode.VMInterface, vmStub *diode.VirtualMachine) *diode.VMInterface {
if iface == nil {
return nil
}
return &diode.VMInterface{
Name: iface.Name,
VirtualMachine: vmStub,
PrimaryMacAddress: newMACMatchStub(iface.PrimaryMacAddress),
}
}

// PruneNestedRefsVM is the VM-rooted analog of PruneNestedRefs. Walks
// entities once and replaces nested VirtualMachine and VMInterface
// references with matcher-only stubs. Top-level rich VirtualMachine
// and top-level VMInterface entities are left unchanged — only nested
// references *to* them are rewritten.
//
// Call from the runner AFTER annotateDeviceWithSourceMatch and
// annotateEntitiesWithRunID, BEFORE Ingest, matching the
// PruneNestedRefs call ordering.
//
// No-op if entities is empty or currentVM is nil.
func PruneNestedRefsVM(entities []diode.Entity, currentVM *diode.VirtualMachine) {
if len(entities) == 0 || currentVM == nil {
return
}

vmStub := newVMMatchStub(currentVM)

// Build a name -> top-level VMInterfaces index. Slice-valued to
// mirror PruneNestedRefs's ifaceByName treatment of cross-member
// duplicates: when a name is ambiguous (more than one top-level
// VMInterface shares it), the stubForIface owner-rewrite skips
// rather than silently rebinding to whichever entry happened to
// land in the map first. Single-VM walks have unique interface
// names so the unambiguous (len==1) branch is the common path.
vmIfaceByName := map[string][]*diode.VMInterface{}
for _, e := range entities {
if v, ok := e.(*diode.VMInterface); ok && v != nil && v.Name != nil {
vmIfaceByName[*v.Name] = append(vmIfaceByName[*v.Name], v)
}
}

// Cache stubs per source VMInterface so Parent/Bridge/IP-assigned
// references to the same iface share one stub and pruning is O(N).
stubCache := map[*diode.VMInterface]*diode.VMInterface{}
stubForIface := func(ref *diode.VMInterface) *diode.VMInterface {
if ref == nil {
return nil
}
if cached, ok := stubCache[ref]; ok {
return cached
}
// Prefer the top-level VMInterface for this name (it's the
// authoritative entry — matches Device-prune's stubForIface).
// Only rewrite owner when the by-name lookup is unambiguous;
// duplicate names leave owner as `ref` so we don't silently
// repoint to a wrong sibling.
owner := ref
if ref.Name != nil {
if tops, ok := vmIfaceByName[*ref.Name]; ok && len(tops) == 1 && tops[0] != nil {
owner = tops[0]
}
}
stub := newVMInterfaceStub(owner, vmStub)
stubCache[ref] = stub
return stub
}

for _, e := range entities {
switch v := e.(type) {
case *diode.VMInterface:
if v == nil {
continue
}
if v.VirtualMachine == currentVM {
v.VirtualMachine = vmStub
}
if v.Parent != nil {
v.Parent = stubForIface(v.Parent)
}
if v.Bridge != nil {
v.Bridge = stubForIface(v.Bridge)
}
case *diode.IPAddress:
if v == nil {
continue
}
if vmIf, ok := v.AssignedObject.(*diode.VMInterface); ok && vmIf != nil {
v.AssignedObject = stubForIface(vmIf)
}
}
}
}

// PruneNestedRefs walks entities once and replaces nested Device and
// Interface references with matcher-only stubs. Top-level rich Device
// entities are left unchanged — only nested references *to* them on
Expand Down
Loading
Loading