Add core modules (SSH args parser, cache, resolver, NetBox client) with tests
Release / release (push) Failing after 51s
Release / release (push) Failing after 51s
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// Chain tries each strategy in order until one returns an IP.
|
||||
type Chain struct {
|
||||
strategies []Strategy
|
||||
}
|
||||
|
||||
// New builds a Chain from the strategy names listed in the resolver config.
|
||||
func New(cfg config.ResolverConfig) (*Chain, error) {
|
||||
var strategies []Strategy
|
||||
for _, name := range cfg.Strategies {
|
||||
s, err := newStrategy(name, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolver strategy %q: %w", name, err)
|
||||
}
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
return &Chain{strategies: strategies}, nil
|
||||
}
|
||||
|
||||
func (c *Chain) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) {
|
||||
for _, s := range c.strategies {
|
||||
ip, err := s.Resolve(ctx, entry, client)
|
||||
if err == nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no strategy resolved an IP for %q", entry.Name)
|
||||
}
|
||||
|
||||
func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) {
|
||||
switch name {
|
||||
case "primary_ip":
|
||||
return &PrimaryIPStrategy{}, nil
|
||||
case "management_subnet":
|
||||
s, err := NewManagementSubnetStrategy(cfg.ManagementSubnets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
case "interface_name":
|
||||
if cfg.InterfaceName == "" {
|
||||
return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set")
|
||||
}
|
||||
return &InterfaceNameStrategy{name: cfg.InterfaceName}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown strategy %q", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// stubStrategy is a test double for Strategy.
|
||||
type stubStrategy struct {
|
||||
name string
|
||||
ip string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubStrategy) Name() string { return s.name }
|
||||
func (s *stubStrategy) Resolve(_ context.Context, _ *netbox.HostEntry, _ *netbox.Client) (string, error) {
|
||||
return s.ip, s.err
|
||||
}
|
||||
|
||||
func TestChain_FirstStrategySucceeds(t *testing.T) {
|
||||
c := &Chain{strategies: []Strategy{
|
||||
&stubStrategy{name: "first", ip: "10.0.0.1"},
|
||||
&stubStrategy{name: "second", ip: "10.0.0.2"},
|
||||
}}
|
||||
ip, err := c.Resolve(context.Background(), &netbox.HostEntry{Name: "host"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "10.0.0.1" {
|
||||
t.Errorf("got %q, want first strategy's IP %q", ip, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_FallsBackToNextStrategy(t *testing.T) {
|
||||
c := &Chain{strategies: []Strategy{
|
||||
&stubStrategy{name: "first", err: ErrNoIP},
|
||||
&stubStrategy{name: "second", ip: "10.0.0.2"},
|
||||
}}
|
||||
ip, err := c.Resolve(context.Background(), &netbox.HostEntry{Name: "host"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "10.0.0.2" {
|
||||
t.Errorf("got %q, want second strategy's IP %q", ip, "10.0.0.2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_AllStrategiesFail(t *testing.T) {
|
||||
c := &Chain{strategies: []Strategy{
|
||||
&stubStrategy{name: "a", err: ErrNoIP},
|
||||
&stubStrategy{name: "b", err: errors.New("api error")},
|
||||
}}
|
||||
_, err := c.Resolve(context.Background(), &netbox.HostEntry{Name: "host"}, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when all strategies fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChain_EmptyStrategies(t *testing.T) {
|
||||
c := &Chain{}
|
||||
_, err := c.Resolve(context.Background(), &netbox.HostEntry{Name: "host"}, nil)
|
||||
if err == nil {
|
||||
t.Error("empty chain should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_PrimaryIP(t *testing.T) {
|
||||
cfg := config.ResolverConfig{Strategies: []string{"primary_ip"}}
|
||||
c, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(c.strategies) != 1 {
|
||||
t.Errorf("got %d strategies, want 1", len(c.strategies))
|
||||
}
|
||||
if c.strategies[0].Name() != "primary_ip" {
|
||||
t.Errorf("strategy name: got %q, want %q", c.strategies[0].Name(), "primary_ip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ManagementSubnet(t *testing.T) {
|
||||
cfg := config.ResolverConfig{
|
||||
Strategies: []string{"management_subnet"},
|
||||
ManagementSubnets: []string{"10.0.0.0/8"},
|
||||
}
|
||||
c, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if c.strategies[0].Name() != "management_subnet" {
|
||||
t.Errorf("strategy name: got %q, want %q", c.strategies[0].Name(), "management_subnet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ManagementSubnet_InvalidCIDR(t *testing.T) {
|
||||
cfg := config.ResolverConfig{
|
||||
Strategies: []string{"management_subnet"},
|
||||
ManagementSubnets: []string{"not-a-cidr"},
|
||||
}
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Error("invalid CIDR should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InterfaceName(t *testing.T) {
|
||||
cfg := config.ResolverConfig{
|
||||
Strategies: []string{"interface_name"},
|
||||
InterfaceName: "mgmt0",
|
||||
}
|
||||
c, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if c.strategies[0].Name() != "interface_name" {
|
||||
t.Errorf("strategy name: got %q", c.strategies[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InterfaceName_MissingConfig(t *testing.T) {
|
||||
cfg := config.ResolverConfig{
|
||||
Strategies: []string{"interface_name"},
|
||||
InterfaceName: "", // not set
|
||||
}
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Error("interface_name without config should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_UnknownStrategy(t *testing.T) {
|
||||
cfg := config.ResolverConfig{Strategies: []string{"nonexistent"}}
|
||||
_, err := New(cfg)
|
||||
if err == nil {
|
||||
t.Error("unknown strategy should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_MultipleStrategies(t *testing.T) {
|
||||
cfg := config.ResolverConfig{
|
||||
Strategies: []string{"management_subnet", "primary_ip"},
|
||||
ManagementSubnets: []string{"10.0.0.0/8"},
|
||||
}
|
||||
c, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if len(c.strategies) != 2 {
|
||||
t.Errorf("got %d strategies, want 2", len(c.strategies))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// InterfaceNameStrategy finds the first IP assigned to a named interface (e.g. "mgmt0", "eth0").
|
||||
type InterfaceNameStrategy struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *InterfaceNameStrategy) Name() string { return "interface_name" }
|
||||
|
||||
func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) {
|
||||
// Build filter parameters for IP addresses attached to the named interface.
|
||||
var filterParam string
|
||||
switch entry.Kind {
|
||||
case "device":
|
||||
filterParam = fmt.Sprintf("device_id=%d&interface_name=%s", entry.ID, url.QueryEscape(s.name))
|
||||
case "vm":
|
||||
filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface_name=%s", entry.ID, url.QueryEscape(s.name))
|
||||
default:
|
||||
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||
}
|
||||
|
||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return "", ErrNoIP
|
||||
}
|
||||
return ips[0], nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// ManagementSubnetStrategy finds the first IP of a host that falls within
|
||||
// one of the configured management subnets.
|
||||
type ManagementSubnetStrategy struct {
|
||||
subnets []*net.IPNet
|
||||
}
|
||||
|
||||
func NewManagementSubnetStrategy(cidrs []string) (*ManagementSubnetStrategy, error) {
|
||||
nets := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err)
|
||||
}
|
||||
nets = append(nets, ipNet)
|
||||
}
|
||||
return &ManagementSubnetStrategy{subnets: nets}, nil
|
||||
}
|
||||
|
||||
func (s *ManagementSubnetStrategy) Name() string { return "management_subnet" }
|
||||
|
||||
func (s *ManagementSubnetStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) {
|
||||
if len(s.subnets) == 0 {
|
||||
return "", ErrNoIP
|
||||
}
|
||||
|
||||
ips, err := client.GetIPs(ctx, *entry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching IPs: %w", err)
|
||||
}
|
||||
|
||||
for _, rawIP := range ips {
|
||||
ip := net.ParseIP(rawIP)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
for _, subnet := range s.subnets {
|
||||
if subnet.Contains(ip) {
|
||||
return rawIP, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ErrNoIP
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// newIPServer returns a test server that always responds with the given IP list.
|
||||
func newIPServer(t *testing.T, ips []string) *httptest.Server {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
Address string `json:"address"`
|
||||
}
|
||||
type response struct {
|
||||
Count int `json:"count"`
|
||||
Results []result `json:"results"`
|
||||
}
|
||||
resp := response{Count: len(ips)}
|
||||
for _, ip := range ips {
|
||||
resp.Results = append(resp.Results, result{Address: ip})
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(body)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
|
||||
srv := newIPServer(t, []string{"10.0.1.5/24", "192.168.0.1/24"})
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "10.0.1.5" {
|
||||
t.Errorf("got %q, want %q", ip, "10.0.1.5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
|
||||
srv := newIPServer(t, []string{"192.168.0.1/24"})
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
|
||||
_, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != ErrNoIP {
|
||||
t.Errorf("no matching subnet should return ErrNoIP, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
|
||||
srv := newIPServer(t, []string{"10.0.1.1/24", "10.0.1.2/24"})
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "10.0.1.1" {
|
||||
t.Errorf("got %q, want first matching IP %q", ip, "10.0.1.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_VMKind(t *testing.T) {
|
||||
srv := newIPServer(t, []string{"172.16.5.10/16"})
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "172.16.5.10" {
|
||||
t.Errorf("got %q, want %q", ip, "172.16.5.10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
|
||||
srv := newIPServer(t, []string{"fd00::1/64"})
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "fd00::1" {
|
||||
t.Errorf("got %q, want %q", ip, "fd00::1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// PrimaryIPStrategy returns the primary IP configured in NetBox.
|
||||
// Prefers IPv4, falls back to IPv6.
|
||||
type PrimaryIPStrategy struct{}
|
||||
|
||||
func (s *PrimaryIPStrategy) Name() string { return "primary_ip" }
|
||||
|
||||
func (s *PrimaryIPStrategy) Resolve(_ context.Context, entry *netbox.HostEntry, _ *netbox.Client) (string, error) {
|
||||
if entry.PrimaryIP4 != "" {
|
||||
return entry.PrimaryIP4, nil
|
||||
}
|
||||
if entry.PrimaryIP6 != "" {
|
||||
return entry.PrimaryIP6, nil
|
||||
}
|
||||
return "", ErrNoIP
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func TestPrimaryIPStrategy_Name(t *testing.T) {
|
||||
s := &PrimaryIPStrategy{}
|
||||
if s.Name() != "primary_ip" {
|
||||
t.Errorf("Name: got %q, want %q", s.Name(), "primary_ip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryIPStrategy_IPv4(t *testing.T) {
|
||||
s := &PrimaryIPStrategy{}
|
||||
e := &netbox.HostEntry{PrimaryIP4: "10.0.0.1", PrimaryIP6: "::1"}
|
||||
ip, err := s.Resolve(context.Background(), e, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "10.0.0.1" {
|
||||
t.Errorf("got %q, want IPv4 %q", ip, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryIPStrategy_IPv6Fallback(t *testing.T) {
|
||||
s := &PrimaryIPStrategy{}
|
||||
e := &netbox.HostEntry{PrimaryIP6: "::1"}
|
||||
ip, err := s.Resolve(context.Background(), e, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ip != "::1" {
|
||||
t.Errorf("got %q, want IPv6 %q", ip, "::1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryIPStrategy_NoIP(t *testing.T) {
|
||||
s := &PrimaryIPStrategy{}
|
||||
_, err := s.Resolve(context.Background(), &netbox.HostEntry{}, nil)
|
||||
if err != ErrNoIP {
|
||||
t.Errorf("got %v, want ErrNoIP", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_Name(t *testing.T) {
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
if s.Name() != "management_subnet" {
|
||||
t.Errorf("Name: got %q, want %q", s.Name(), "management_subnet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_InvalidCIDR(t *testing.T) {
|
||||
_, err := NewManagementSubnetStrategy([]string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Error("invalid CIDR should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_EmptyCIDRs(t *testing.T) {
|
||||
s, _ := NewManagementSubnetStrategy([]string{})
|
||||
_, err := s.Resolve(context.Background(), &netbox.HostEntry{}, nil)
|
||||
if err != ErrNoIP {
|
||||
t.Errorf("empty subnets should return ErrNoIP, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementSubnetStrategy_MultipleCIDRs(t *testing.T) {
|
||||
_, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"})
|
||||
if err != nil {
|
||||
t.Fatalf("valid CIDRs should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameStrategy_Name(t *testing.T) {
|
||||
s := &InterfaceNameStrategy{name: "mgmt0"}
|
||||
if s.Name() != "interface_name" {
|
||||
t.Errorf("Name: got %q, want %q", s.Name(), "interface_name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameStrategy_UnknownKind(t *testing.T) {
|
||||
s := &InterfaceNameStrategy{name: "eth0"}
|
||||
_, err := s.Resolve(context.Background(), &netbox.HostEntry{Kind: "unknown"}, nil)
|
||||
if err == nil {
|
||||
t.Error("unknown kind should return an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// ErrNoIP is returned when a strategy cannot find a matching IP address.
|
||||
var ErrNoIP = errors.New("no matching IP found")
|
||||
|
||||
// Strategy is a single rule for resolving an IP address from a NetBox host entry.
|
||||
type Strategy interface {
|
||||
Name() string
|
||||
Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error)
|
||||
}
|
||||
Reference in New Issue
Block a user