mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
276 lines
10 KiB
Go
276 lines
10 KiB
Go
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
// sudoCommand wraps exec.Command with sudo when running as non-root.
|
|
// OVS/OVN and ip commands require elevated privileges; in Docker and
|
|
// production the daemon runs as root, but in dev environments it may not.
|
|
func sudoCommand(name string, args ...string) *exec.Cmd {
|
|
if os.Getuid() == 0 {
|
|
return exec.Command(name, args...)
|
|
}
|
|
return exec.Command("sudo", append([]string{name}, args...)...)
|
|
}
|
|
|
|
// NetworkPlumber handles tap device and OVS bridge operations for VM networking.
|
|
// The live implementation runs system commands (ip, ovs-vsctl); tests use a mock.
|
|
type NetworkPlumber interface {
|
|
// SetupTapDevice creates a tap device and adds it to the OVS br-int bridge
|
|
// with the correct iface-id for OVN port binding.
|
|
SetupTapDevice(eniId, mac string) error
|
|
|
|
// CleanupTapDevice removes the tap device from br-int and deletes it.
|
|
CleanupTapDevice(eniId string) error
|
|
}
|
|
|
|
// OVSNetworkPlumber implements NetworkPlumber using system commands.
|
|
type OVSNetworkPlumber struct{}
|
|
|
|
func (p *OVSNetworkPlumber) SetupTapDevice(eniId, mac string) error {
|
|
tapName := TapDeviceName(eniId)
|
|
ifaceID := OVSIfaceID(eniId)
|
|
|
|
// 0. If tap already exists (e.g. unclean shutdown), clean it up first
|
|
if _, err := os.Stat("/sys/class/net/" + tapName); err == nil {
|
|
slog.Warn("Stale tap device found, cleaning up before recreate", "tap", tapName)
|
|
_ = sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).Run()
|
|
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
|
}
|
|
|
|
// 1. Create tap device
|
|
if out, err := sudoCommand("ip", "tuntap", "add", "dev", tapName, "mode", "tap").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("create tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// 2. Bring tap up
|
|
if out, err := sudoCommand("ip", "link", "set", tapName, "up").CombinedOutput(); err != nil {
|
|
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
|
return fmt.Errorf("bring up tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// 3. Add to br-int with iface-id for OVN port binding
|
|
if out, err := sudoCommand("ovs-vsctl",
|
|
"add-port", "br-int", tapName,
|
|
"--", "set", "Interface", tapName,
|
|
fmt.Sprintf("external_ids:iface-id=%s", ifaceID),
|
|
fmt.Sprintf("external_ids:attached-mac=%s", mac),
|
|
).CombinedOutput(); err != nil {
|
|
_ = sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").Run()
|
|
return fmt.Errorf("add tap to br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
slog.Info("Network plumbing complete", "tap", tapName, "iface-id", ifaceID, "mac", mac)
|
|
return nil
|
|
}
|
|
|
|
func (p *OVSNetworkPlumber) CleanupTapDevice(eniId string) error {
|
|
tapName := TapDeviceName(eniId)
|
|
|
|
// 1. Remove from br-int (--if-exists avoids error if already gone)
|
|
if out, err := sudoCommand("ovs-vsctl", "--if-exists", "del-port", "br-int", tapName).CombinedOutput(); err != nil {
|
|
slog.Warn("Failed to remove tap from br-int", "tap", tapName, "err", err, "out", strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
// 2. Delete tap device
|
|
if out, err := sudoCommand("ip", "tuntap", "del", "dev", tapName, "mode", "tap").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("delete tap %s: %s: %w", tapName, strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
slog.Info("Network cleanup complete", "tap", tapName)
|
|
return nil
|
|
}
|
|
|
|
// TapDeviceName returns the Linux tap device name for an ENI.
|
|
// Linux IFNAMSIZ limits interface names to 15 characters.
|
|
// ENI IDs like "eni-abc123def456789" are too long, so we truncate.
|
|
func TapDeviceName(eniId string) string {
|
|
id := strings.TrimPrefix(eniId, "eni-")
|
|
name := "tap" + id
|
|
if len(name) > 15 {
|
|
name = name[:15]
|
|
}
|
|
return name
|
|
}
|
|
|
|
// OVSIfaceID returns the OVS external_ids:iface-id value for an ENI.
|
|
// This must match the OVN LogicalSwitchPort name for ovn-controller binding.
|
|
func OVSIfaceID(eniId string) string {
|
|
return "port-" + eniId
|
|
}
|
|
|
|
// generateDevMAC creates a locally-administered unicast MAC for the dev/hostfwd NIC.
|
|
// Uses prefix 02:de:00 to distinguish from ENI MACs (02:00:00).
|
|
// All octets must be valid hex for QEMU's virtio-net-pci mac property.
|
|
func generateDevMAC(instanceId string) string {
|
|
h := uint32(0)
|
|
for _, c := range instanceId {
|
|
h = h*31 + uint32(c)
|
|
}
|
|
return fmt.Sprintf("02:de:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
|
|
}
|
|
|
|
// OVNHealthStatus reports the readiness of OVN networking on this compute node.
|
|
type OVNHealthStatus struct {
|
|
BrIntExists bool `json:"br_int_exists"`
|
|
OVNControllerUp bool `json:"ovn_controller_up"`
|
|
ChassisID string `json:"chassis_id,omitempty"`
|
|
EncapIP string `json:"encap_ip,omitempty"`
|
|
OVNRemote string `json:"ovn_remote,omitempty"`
|
|
}
|
|
|
|
// CheckOVNHealth probes local OVS/OVN state to determine network readiness.
|
|
func CheckOVNHealth() OVNHealthStatus {
|
|
status := OVNHealthStatus{}
|
|
|
|
// Check br-int exists
|
|
if err := sudoCommand("ovs-vsctl", "br-exists", "br-int").Run(); err == nil {
|
|
status.BrIntExists = true
|
|
}
|
|
|
|
// Check ovn-controller is running via ovs-appctl (more reliable than pgrep)
|
|
if out, err := sudoCommand("ovs-appctl", "-t", "ovn-controller", "version").CombinedOutput(); err == nil && len(out) > 0 {
|
|
status.OVNControllerUp = true
|
|
}
|
|
|
|
// Read chassis identity from OVS external_ids
|
|
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:system-id").CombinedOutput(); err == nil {
|
|
status.ChassisID = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
|
}
|
|
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-encap-ip").CombinedOutput(); err == nil {
|
|
status.EncapIP = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
|
}
|
|
if out, err := sudoCommand("ovs-vsctl", "get", "Open_vSwitch", ".", "external_ids:ovn-remote").CombinedOutput(); err == nil {
|
|
status.OVNRemote = strings.Trim(strings.TrimSpace(string(out)), "\"")
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
// SetupComputeNode configures OVS for OVN on this compute node.
|
|
// It creates br-int with secure fail-mode and sets the OVN external_ids.
|
|
func SetupComputeNode(chassisID, ovnRemote, encapIP string) error {
|
|
// Create br-int if it doesn't exist
|
|
if out, err := sudoCommand("ovs-vsctl", "--may-exist", "add-br", "br-int").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("create br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// Set fail-mode=secure (preserves flows during ovn-controller restart)
|
|
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "fail-mode=secure").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("set br-int fail-mode: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// Disable in-band management (prevents OVS from adding its own flows)
|
|
if out, err := sudoCommand("ovs-vsctl", "set", "Bridge", "br-int", "other-config:disable-in-band=true").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("set br-int disable-in-band: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// Bring br-int up
|
|
if out, err := sudoCommand("ip", "link", "set", "br-int", "up").CombinedOutput(); err != nil {
|
|
return fmt.Errorf("bring up br-int: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// Set OVN external_ids on the Open_vSwitch table
|
|
if out, err := sudoCommand("ovs-vsctl", "set", "Open_vSwitch", ".",
|
|
fmt.Sprintf("external_ids:system-id=%s", chassisID),
|
|
fmt.Sprintf("external_ids:ovn-remote=%s", ovnRemote),
|
|
fmt.Sprintf("external_ids:ovn-encap-ip=%s", encapIP),
|
|
"external_ids:ovn-encap-type=geneve",
|
|
).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("set OVN external_ids: %s: %w", strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
slog.Info("OVN compute node configured",
|
|
"chassis_id", chassisID,
|
|
"ovn_remote", ovnRemote,
|
|
"encap_ip", encapIP,
|
|
)
|
|
|
|
// Ensure the data NIC is preferred for Geneve tunnel routing.
|
|
if err := EnsureDataRoute(encapIP); err != nil {
|
|
slog.Warn("Failed to configure data NIC routing (Geneve tunnels may use wrong source IP)", "err", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EnsureDataRoute ensures the kernel routes Geneve tunnel traffic through the
|
|
// data NIC (the interface holding the encap IP). When management and data NICs
|
|
// share the same subnet with equal route metrics, the kernel may pick the
|
|
// management NIC, causing Geneve packets to have the wrong source IP. Remote
|
|
// OVS nodes then drop these packets because the source doesn't match the
|
|
// expected tunnel remote_ip.
|
|
//
|
|
// Fix: find the data NIC's subnet route and replace it with a lower metric (50)
|
|
// so it's preferred over the management NIC's route (typically metric 100+).
|
|
func EnsureDataRoute(encapIP string) error {
|
|
dataIface, err := findInterfaceByIP(encapIP)
|
|
if err != nil {
|
|
return fmt.Errorf("find data interface for %s: %w", encapIP, err)
|
|
}
|
|
|
|
// Read the existing subnet route for the data interface
|
|
out, err := sudoCommand("ip", "-o", "-4", "route", "show", "dev", dataIface, "proto", "kernel", "scope", "link").CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("read routes for %s: %s: %w", dataIface, strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
// Parse the subnet CIDR from the route output (e.g. "10.1.0.0/16 dev eth1 ...")
|
|
fields := strings.Fields(strings.TrimSpace(string(out)))
|
|
if len(fields) == 0 {
|
|
return fmt.Errorf("no kernel route found for %s", dataIface)
|
|
}
|
|
subnet := fields[0]
|
|
|
|
// Replace the route with a lower metric so the data NIC is preferred
|
|
if out, err := sudoCommand("ip", "route", "replace", subnet,
|
|
"dev", dataIface, "src", encapIP, "metric", "50",
|
|
).CombinedOutput(); err != nil {
|
|
return fmt.Errorf("replace route for %s: %s: %w", subnet, strings.TrimSpace(string(out)), err)
|
|
}
|
|
|
|
slog.Info("Data NIC route configured for Geneve tunnels",
|
|
"interface", dataIface,
|
|
"subnet", subnet,
|
|
"encap_ip", encapIP,
|
|
"metric", 50,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// findInterfaceByIP returns the network interface name that holds the given IP address.
|
|
func findInterfaceByIP(ipAddr string) (string, error) {
|
|
targetIP := net.ParseIP(ipAddr)
|
|
if targetIP == nil {
|
|
return "", fmt.Errorf("invalid IP address: %s", ipAddr)
|
|
}
|
|
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return "", fmt.Errorf("list interfaces: %w", err)
|
|
}
|
|
|
|
for _, iface := range ifaces {
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
ipNet, ok := addr.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ipNet.IP.Equal(targetIP) {
|
|
return iface.Name, nil
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no interface found with IP %s", ipAddr)
|
|
}
|