diff --git a/README.md b/README.md index 185f1a4..2ad8783 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ spec: - [x] DMI/BIOS discovery engine #2 - [x] Local file / HTTP discovery engine #3 - [x] Hostname / node name discovery engine #4 -- [ ] Network config discovery engine #5 +- [x] Network config discovery engine #5 - [ ] Taint management #6 - [ ] Prometheus Exporter #7 - [ ] Better RBAC / admission webhook #8 @@ -109,6 +109,11 @@ The `labelsTemplates` section defines which Kubernetes labels Topomatik will man The [Sprig library](http://masterminds.github.io/sprig/) is available for advanced template operations, giving you access to string manipulation, regular expressions, and more. +> [!NOTE] +> Labels must be an empty string or consist of alphanumeric characters, "-", "\_" or ".", and must start and end with an alphanumeric character. Invalid characters will be replaced by "\_" and trailing non alpha-numeric characters will be removed. +> +> Example: "@@@foo+bar.foobar----." will be rendered as "foo_bar.foobar". + ### Auto-Discovery Engine Configuration Each auto-discovery engine has its own configuration section. @@ -215,6 +220,24 @@ hostname: Template variables are defined by the name of an file entry in the configuration. For instance, with the above configuration, this engine exposes `.hostname.zone`, `.hostname.region` and `.hostname.node`, but since the `pattern` in the configuration is optional, it also exposes the whole hostname as `.hostname.value`. +#### Network + +Reads network information of the device in order to group machines by subnet. + +##### Network configuration + +| Name | Description | Default value | +| --------- | --------------------------------------------------------------------------------------- | ------------- | +| enabled | Enable or disable this auto discovery engine | false | +| interval | Polling interval of the hardware information | `` | +| interface | Interface name to get the subnet from or auto to use any interface with default gateway | auto | + +##### Available template variables from the network engine + +| Name | Description | +| ---------------| ------------------------------------------------ | +| network.subnet | The subnet associated with the defined interface | + ## 🤝 Contributing Found a bug? Want to add support for another discovery engine? We welcome contributions! Just be sure your code is as clean as your commit messages. diff --git a/cmd/main.go b/cmd/main.go index 852e671..f42aa39 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -8,6 +8,7 @@ import ( "github.com/enix/topomatik/internal/autodiscovery/hardware" "github.com/enix/topomatik/internal/autodiscovery/hostname" "github.com/enix/topomatik/internal/autodiscovery/lldp" + "github.com/enix/topomatik/internal/autodiscovery/network" "github.com/enix/topomatik/internal/config" "github.com/enix/topomatik/internal/controller" "github.com/enix/topomatik/internal/schedulers" @@ -74,5 +75,8 @@ func main() { ctrl.Register("hostname", &hostname.HostnameDiscoveryEngine{Config: config.Hostname.Config}) } + if config.Network.Enabled { + ctrl.Register("network", &network.NetworkDiscoveryEngine{Config: config.Network.Config}) + } panic(ctrl.Start()) } diff --git a/internal/autodiscovery/lldp/lldp.go b/internal/autodiscovery/lldp/lldp.go index 312cdd5..5065b1a 100644 --- a/internal/autodiscovery/lldp/lldp.go +++ b/internal/autodiscovery/lldp/lldp.go @@ -1,9 +1,9 @@ package lldp import ( - "errors" "fmt" + "github.com/enix/topomatik/internal/autodiscovery/network" "github.com/google/gopacket" "github.com/google/gopacket/afpacket" "github.com/google/gopacket/layers" @@ -85,21 +85,15 @@ func (l *LLDPDiscoveryEngine) Watch(callback func(data map[string]string, err er } func getDefaultRouteInterfaceName() (string, error) { - routes, err := netlink.RouteList(nil, netlink.FAMILY_ALL) + defaultRoute, err := network.GetDefaultRoute() if err != nil { return "", err } - for _, route := range routes { - ones, _ := route.Dst.Mask.Size() - if route.Dst == nil || (route.Dst.IP.IsUnspecified() && ones == 0) { - link, err := netlink.LinkByIndex(route.LinkIndex) - if err != nil { - return "", err - } - return link.Attrs().Name, nil - } + link, err := netlink.LinkByIndex(defaultRoute.LinkIndex) + if err != nil { + return "", err } - return "", errors.New("default route not found") + return link.Attrs().Name, nil } diff --git a/internal/autodiscovery/network/network.go b/internal/autodiscovery/network/network.go new file mode 100644 index 0000000..8a09117 --- /dev/null +++ b/internal/autodiscovery/network/network.go @@ -0,0 +1,94 @@ +package network + +import ( + "errors" + "fmt" + "time" + + "github.com/vishvananda/netlink" +) + +type Config struct { + Interval time.Duration `yaml:"interval" validate:"required"` + Interface string `yaml:"interface"` +} + +type NetworkDiscoveryEngine struct { + Config +} + +func (n *NetworkDiscoveryEngine) Setup() (err error) { + return +} + +func (n *NetworkDiscoveryEngine) Watch(callback func(data map[string]string, err error)) { + var route *netlink.Route + var err error + + ticker := time.NewTicker(n.Interval) + + for { + if n.Interface == "" { + route, err = GetDefaultRoute() + } else { + route, err = getRouteByInterfaceName(n.Interface) + } + + if err != nil { + callback(nil, err) + return + } + + if route.Family != netlink.FAMILY_V4 { + callback(nil, fmt.Errorf("unsupported IP family (code: %d), must be IPV4", route.Family)) + return + } + + ones, _ := route.Src.DefaultMask().Size() + callback(map[string]string{ + "subnet": fmt.Sprintf("%s/%d", route.Src.Mask(route.Src.DefaultMask()), ones), + }, nil) + + <-ticker.C + } +} + +func GetDefaultRoute() (*netlink.Route, error) { + routes, err := netlink.RouteList(nil, netlink.FAMILY_ALL) + if err != nil { + return nil, err + } + + for _, route := range routes { + if route.Dst == nil { + return &route, nil + } + + ones, _ := route.Dst.Mask.Size() + if route.Dst.IP.IsUnspecified() && ones == 0 { + return &route, nil + } + } + + return nil, errors.New("default route not found") +} + +func getRouteByInterfaceName(interfaceName string) (*netlink.Route, error) { + routes, err := netlink.RouteList(nil, netlink.FAMILY_ALL) + if err != nil { + return nil, err + } + + for _, route := range routes { + link, err := netlink.LinkByIndex(route.LinkIndex) + if err != nil { + return nil, err + } + + if link.Attrs().Name == interfaceName { + return &route, nil + } + } + + return nil, fmt.Errorf("route with interface name \"%s\" not found", interfaceName) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6586a5e..276c679 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "github.com/enix/topomatik/internal/autodiscovery/hardware" "github.com/enix/topomatik/internal/autodiscovery/hostname" "github.com/enix/topomatik/internal/autodiscovery/lldp" + "github.com/enix/topomatik/internal/autodiscovery/network" "github.com/go-playground/validator/v10" "gopkg.in/yaml.v2" ) @@ -23,6 +24,7 @@ type Config struct { Files files.Config `yaml:"files" validate:"dive"` Hardware EngineConfig[hardware.Config] `yaml:"hardware"` Hostname EngineConfig[hostname.Config] `yaml:"hostname"` + Network EngineConfig[network.Config] `yaml:"network"` } type EngineConfig[T any] struct { @@ -79,6 +81,9 @@ func Load(path string) (*Config, error) { if !config.Hostname.Enabled { ignoredEngines = append(ignoredEngines, "Hostname") } + if !config.Network.Enabled { + ignoredEngines = append(ignoredEngines, "Network") + } if err := validate.StructExcept(config, ignoredEngines...); err != nil { if errs, ok := err.(validator.ValidationErrors); ok { diff --git a/internal/controller/controller.go b/internal/controller/controller.go index f10675a..966dc33 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "os" + "regexp" + "strings" "text/template" "github.com/Masterminds/sprig" @@ -17,6 +19,8 @@ import ( "k8s.io/client-go/kubernetes" ) +var labelRegexp = regexp.MustCompile(`[^A-Za-z0-9._-]`) + type ReconciliationScheduler interface { Trigger() C() <-chan struct{} @@ -121,9 +125,13 @@ func (c *Controller) reconcileNode() error { if err != nil { fmt.Printf("could not render template for %s: %s\n", label, err.Error()) } else { - if node.Labels[label] != value.String() { - labels[label] = value.String() - fmt.Printf("%s: %s\n", label, value) + sanitizedValue := labelRegexp.ReplaceAllString(value.String(), "_") + sanitizedValue = strings.TrimFunc(sanitizedValue, func(r rune) bool { + return strings.Contains("_.-", string(r)) + }) + if node.Labels[label] != sanitizedValue { + labels[label] = sanitizedValue + fmt.Printf("%s: %s\n", label, sanitizedValue) } } }