Fix/watch err (#1697)

* refactor(errors): centralize fatal error handling

Detect wrapped PanicError values consistently and preserve their underlying causes when adding fatal context. Apply the helpers to manager, cluster, and IPVS error paths.

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

* fix(watchers): restart after terminal watch failures

Propagate fatal endpoint watcher failures through the owning service watcher so kube-vip releases leadership instead of remaining active with a stale watch. Treat terminal service, node, and annotation watch failures as errors while preserving clean context cancellation.

Return exhausted authorization failures to RetryWatcher, safely decode watch error objects, and replace direct go-spew diagnostics with structured logging.

Fixes #1685

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

* fix(services): replace state after traffic policy changes

Recreate the service context and instance as one generation when a Service change requires teardown. Ignore delayed leadership cleanup from superseded contexts so it cannot remove replacement state.

This prevents a stale Cluster-policy endpoint watcher from winning the service lease after externalTrafficPolicy changes to Local.

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

* fix(cli): return command errors to container runtime

Propagate manager and service command failures through Cobra so the process exits with status 1. Show usage for invocation errors while keeping runtime failures concise.

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

* refactor(logging): use structured errors

Replace direct stdout error output with slog records for command failures and traffic mirror qdisc lookup failures.

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

* fix(watchers): continue after endpoint deletion

Keep EndpointSlice watchers active when an individual endpoint object is deleted so replacement objects can be observed and service traffic can recover.

Assisted-by: GitHub-Copilot:unspecified
Signed-off-by: Marcel Fest <marcel.fest@telekom.de>

---------

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
This commit is contained in:
Marcel Fest
2026-08-20 13:03:08 +02:00
committed by GitHub
parent 4e13a81af0
commit 5e2220fd4d
18 changed files with 295 additions and 159 deletions

View File

@@ -52,8 +52,9 @@ var (
) )
var kubeVipCmd = &cobra.Command{ var kubeVipCmd = &cobra.Command{
Use: "kube-vip", Use: "kube-vip",
Short: "This is a server for providing a Virtual IP and load-balancer for the Kubernetes control-plane", Short: "This is a server for providing a Virtual IP and load-balancer for the Kubernetes control-plane",
SilenceErrors: true,
} }
func init() { func init() {
@@ -187,11 +188,16 @@ func init() {
} }
// Execute - starts the command parsing process // Execute - starts the command parsing process
func Execute() { func Execute() int {
if err := kubeVipCmd.Execute(); err != nil { cmd, err := kubeVipCmd.ExecuteC()
fmt.Println(err) if err != nil {
os.Exit(1) log.Error("command failed", "err", err)
if cmd == kubeVipCmd {
_ = cmd.Usage()
}
return 1
} }
return 0
} }
var kubeVipVersion = &cobra.Command{ var kubeVipVersion = &cobra.Command{
@@ -215,26 +221,24 @@ var kubeVipSample = &cobra.Command{
var kubeVipService = &cobra.Command{ var kubeVipService = &cobra.Command{
Use: "service", Use: "service",
Short: "Start the Virtual IP / Load balancer as a service within a Kubernetes cluster", Short: "Start the Virtual IP / Load balancer as a service within a Kubernetes cluster",
Run: func(cmd *cobra.Command, args []string) { //nolint TODO RunE: func(cmd *cobra.Command, args []string) error { //nolint TODO
cmd.SilenceUsage = true
// Load configuration from file if specified (lowest priority) // Load configuration from file if specified (lowest priority)
if initConfig.ConfigFile != "" { if initConfig.ConfigFile != "" {
err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile) err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile)
if err != nil { if err != nil {
log.Error("loading config file", "err", err) return fmt.Errorf("loading config file: %w", err)
return
} }
} }
// parse environment variables, these will overwrite anything loaded from config file // parse environment variables, these will overwrite anything loaded from config file
err := kubevip.ParseEnvironment(&initConfig) err := kubevip.ParseEnvironment(&initConfig)
if err != nil { if err != nil {
log.Error("parsing env", "err", err) return fmt.Errorf("parsing environment: %w", err)
return
} }
if err := initConfig.Validate(); err != nil { if err := initConfig.Validate(); err != nil {
log.Error("validating configuration", "err", err) return fmt.Errorf("validating configuration: %w", err)
return
} }
// Change RTN_UNSPEC to default type // Change RTN_UNSPEC to default type
@@ -246,8 +250,7 @@ var kubeVipService = &cobra.Command{
log.SetLogLoggerLevel(log.Level(initConfig.Logging)) log.SetLogLoggerLevel(log.Level(initConfig.Logging))
if err := initConfig.CheckInterface(); err != nil { if err := initConfig.CheckInterface(); err != nil {
log.Error("checking interface", "err", err) return fmt.Errorf("checking interface: %w", err)
return
} }
// User Environment variables as an option to make manifest clearer // User Environment variables as an option to make manifest clearer
@@ -260,8 +263,7 @@ var kubeVipService = &cobra.Command{
if initConfig.EnableControlPlane && if initConfig.EnableControlPlane &&
(initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) { (initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) {
if err := initConfig.CheckSubnetExists(); err != nil { if err := initConfig.CheckSubnetExists(); err != nil {
log.Error("checking subnet exists if vip_address defined", "err", err) return fmt.Errorf("checking subnet exists if vip_address defined: %w", err)
return
} }
} }
@@ -269,8 +271,7 @@ var kubeVipService = &cobra.Command{
if initConfig.VIPSubnet == "" && initConfig.Address != "" { if initConfig.VIPSubnet == "" && initConfig.Address != "" {
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode) initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
if err != nil { if err != nil {
log.Error("generating CIDR", "err", err) return fmt.Errorf("generating CIDR: %w", err)
return
} }
} }
@@ -280,41 +281,39 @@ var kubeVipService = &cobra.Command{
// Define the new service manager // Define the new service manager
mgr, err := manager.New(ctx, configMap, &initConfig) mgr, err := manager.New(ctx, configMap, &initConfig)
if err != nil { if err != nil {
log.Error("new manager", "err", err) return fmt.Errorf("new manager: %w", err)
return
} }
// Start the service manager, this will watch the config Map and construct kube-vip services for it // Start the service manager, this will watch the config Map and construct kube-vip services for it
err = mgr.Start(ctx) err = mgr.Start(ctx)
if err != nil { if err != nil {
log.Error("manager start", "err", err) return fmt.Errorf("manager start: %w", err)
return
} }
return nil
}, },
} }
var kubeVipManager = &cobra.Command{ var kubeVipManager = &cobra.Command{
Use: "manager", Use: "manager",
Short: "Start the kube-vip manager", Short: "Start the kube-vip manager",
Run: func(cmd *cobra.Command, args []string) { //nolint TODO RunE: func(cmd *cobra.Command, args []string) error { //nolint TODO
cmd.SilenceUsage = true
// Load configuration from file if specified (lowest priority) // Load configuration from file if specified (lowest priority)
if initConfig.ConfigFile != "" { if initConfig.ConfigFile != "" {
err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile) err := kubevip.MergeConfigFromFile(&initConfig, initConfig.ConfigFile)
if err != nil { if err != nil {
log.Error("loading config file", "err", err) return fmt.Errorf("loading config file: %w", err)
return
} }
} }
// parse environment variables, these will overwrite anything loaded from config file // parse environment variables, these will overwrite anything loaded from config file
err := kubevip.ParseEnvironment(&initConfig) err := kubevip.ParseEnvironment(&initConfig)
if err != nil { if err != nil {
log.Error("parsing environment", "err", err) return fmt.Errorf("parsing environment: %w", err)
return
} }
if err := initConfig.Validate(); err != nil { if err := initConfig.Validate(); err != nil {
log.Error("validating configuration", "err", err) return fmt.Errorf("validating configuration: %w", err)
return
} }
// Change RTN_UNSPEC to default type // Change RTN_UNSPEC to default type
@@ -329,8 +328,7 @@ var kubeVipManager = &cobra.Command{
if initConfig.EnableControlPlane && if initConfig.EnableControlPlane &&
(initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) { (initConfig.EnableARP || initConfig.EnableBGP || initConfig.EnableRoutingTable) {
if err := initConfig.CheckSubnetExists(); err != nil { if err := initConfig.CheckSubnetExists(); err != nil {
log.Error("checking subnet exists if vip_address defined", "err", err) return fmt.Errorf("checking subnet exists if vip_address defined: %w", err)
return
} }
} }
@@ -338,8 +336,7 @@ var kubeVipManager = &cobra.Command{
if initConfig.VIPSubnet == "" && initConfig.Address != "" { if initConfig.VIPSubnet == "" && initConfig.Address != "" {
initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode) initConfig.VIPSubnet, err = GenerateCidrRange(initConfig.Address, initConfig.DNSMode)
if err != nil { if err != nil {
log.Error("No interface is specified for kube-vip to bind to") return fmt.Errorf("generating CIDR: %w", err)
return
} }
} }
@@ -388,13 +385,11 @@ var kubeVipManager = &cobra.Command{
} }
if mode == "" { if mode == "" {
log.Error("no valid kube-vip mode detected, ensure a supported mode is configured") return fmt.Errorf("no valid kube-vip mode detected, ensure a supported mode is configured")
return
} }
if modesEnabled > 1 { if modesEnabled > 1 {
log.Error("multiple kube-vip modes detected, ensure only one mode is configured") return fmt.Errorf("multiple kube-vip modes detected, ensure only one mode is configured")
return
} }
// Provide configuration to output/logging // Provide configuration to output/logging
@@ -402,18 +397,15 @@ var kubeVipManager = &cobra.Command{
// End if nothing is enabled // End if nothing is enabled
if !initConfig.EnableServices && !initConfig.EnableControlPlane { if !initConfig.EnableServices && !initConfig.EnableControlPlane {
log.Error("no features are enabled") return fmt.Errorf("no features are enabled")
return
} }
if !initConfig.EnableARP && strings.Contains(initConfig.VIPSubnet, kubevip.Auto) { if !initConfig.EnableARP && strings.Contains(initConfig.VIPSubnet, kubevip.Auto) {
log.Error("auto subnet discovery cannot be used outside ARP mode") return fmt.Errorf("auto subnet discovery cannot be used outside ARP mode")
return
} }
if strings.Contains(initConfig.VIPSubnet, kubevip.Auto) && initConfig.Address != "" { if strings.Contains(initConfig.VIPSubnet, kubevip.Auto) && initConfig.Address != "" {
log.Error("auto subnet discovery cannot be used if VIP address was provided") return fmt.Errorf("auto subnet discovery cannot be used if VIP address was provided")
return
} }
// If we're using wireguard then all traffic goes through the wg0 interface // If we're using wireguard then all traffic goes through the wg0 interface
@@ -430,20 +422,17 @@ var kubeVipManager = &cobra.Command{
log.Warn("attempting to create wireguard interface", "interface not found", initConfig.Interface) log.Warn("attempting to create wireguard interface", "interface not found", initConfig.Interface)
err = netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: initConfig.Interface}}) err = netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: initConfig.Interface}})
if err != nil { if err != nil {
log.Error("adding link", "err", err) return fmt.Errorf("adding link: %w", err)
return
} }
l, err = netlink.LinkByName(initConfig.Interface) l, err = netlink.LinkByName(initConfig.Interface)
if err != nil { if err != nil {
log.Error("finding link", "err", err) return fmt.Errorf("finding link: %w", err)
return
} }
} }
} }
err = netlink.LinkSetUp(l) err = netlink.LinkSetUp(l)
if err != nil { if err != nil {
log.Error("setting link UP", "err", err) return fmt.Errorf("setting link UP: %w", err)
return
} }
} else { // if we're not using Wireguard then we'll need to use an actual interface } else { // if we're not using Wireguard then we'll need to use an actual interface
@@ -453,8 +442,7 @@ var kubeVipManager = &cobra.Command{
defaultIF, err := vip.GetDefaultGatewayInterface() defaultIF, err := vip.GetDefaultGatewayInterface()
if err != nil { if err != nil {
_ = cmd.Help() _ = cmd.Help()
log.Error("detecting interface", "err", err) return fmt.Errorf("detecting interface: %w", err)
return
} }
initConfig.Interface = defaultIF.Name initConfig.Interface = defaultIF.Name
log.Info("kube-vip bind", "interface", initConfig.Interface) log.Info("kube-vip bind", "interface", initConfig.Interface)
@@ -470,8 +458,7 @@ var kubeVipManager = &cobra.Command{
} }
// Perform a check on the state of the interface // Perform a check on the state of the interface
if err := initConfig.CheckInterface(); err != nil { if err := initConfig.CheckInterface(); err != nil {
log.Error("checking interface", "err", err) return fmt.Errorf("checking interface: %w", err)
return
} }
// User Environment variables as an option to make manifest clearer // User Environment variables as an option to make manifest clearer
@@ -483,8 +470,7 @@ var kubeVipManager = &cobra.Command{
// Define the new service manager // Define the new service manager
mgr, err := manager.New(ctx, configMap, &initConfig) mgr, err := manager.New(ctx, configMap, &initConfig)
if err != nil { if err != nil {
log.Error("new manager", "err", err) return fmt.Errorf("new manager: %w", err)
return
} }
metrics.RegisterPrometheusMetrics() metrics.RegisterPrometheusMetrics()
@@ -493,9 +479,9 @@ var kubeVipManager = &cobra.Command{
// Start the service manager, this will watch the config Map and construct kube-vip services for it // Start the service manager, this will watch the config Map and construct kube-vip services for it
err = mgr.Start(ctx) err = mgr.Start(ctx)
if err != nil { if err != nil {
log.Error("start manager", "err", err) return fmt.Errorf("start manager: %w", err)
return
} }
return nil
}, },
} }

2
go.mod
View File

@@ -5,7 +5,6 @@ go 1.26.4
require ( require (
github.com/cloudflare/ipvs v0.12.0 github.com/cloudflare/ipvs v0.12.0
github.com/containernetworking/plugins v1.9.1 github.com/containernetworking/plugins v1.9.1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/florianl/go-conntrack v0.7.0 github.com/florianl/go-conntrack v0.7.0
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
@@ -54,6 +53,7 @@ require (
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-connections v0.7.0 // indirect

View File

@@ -1,6 +1,8 @@
package main package main
import ( import (
"os"
"github.com/kube-vip/kube-vip/cmd" "github.com/kube-vip/kube-vip/cmd"
) )
@@ -14,5 +16,5 @@ func main() {
cmd.Release.Version = Version cmd.Release.Version = Version
cmd.Release.Build = Build cmd.Release.Build = Build
cmd.Execute() os.Exit(cmd.Execute())
} }

View File

@@ -113,7 +113,7 @@ func (cluster *Cluster) StartVipService(ctx context.Context, c *kubevip.Config,
err = em.NodeWatcher(ctx, lb, c.Port) err = em.NodeWatcher(ctx, lb, c.Port)
if err != nil { if err != nil {
log.Error("Error watching node labels", "err", err) log.Error("Error watching node labels", "err", err)
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
killFunc() killFunc()
return return
} }
@@ -429,7 +429,7 @@ func (cluster *Cluster) StartLoadBalancerService(ctx context.Context, c *kubevip
if err := network.SetMask(c.VIPSubnet); err != nil { if err := network.SetMask(c.VIPSubnet); err != nil {
log.Error("failed to set mask", "subnet", c.VIPSubnet, "err", err) log.Error("failed to set mask", "subnet", c.VIPSubnet, "err", err)
lbCancel() lbCancel()
return utils.NewPanicError(fmt.Sprintf("failed to set mask for subnet %q: %s", c.VIPSubnet, err.Error())) return utils.WrapPanicError(err, "failed to set mask for subnet %q", c.VIPSubnet)
} }
_, err := network.DeleteIP() _, err := network.DeleteIP()
if err != nil { if err != nil {

View File

@@ -2,14 +2,12 @@ package election
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sync" "sync"
"time" "time"
log "log/slog" log "log/slog"
"github.com/davecgh/go-spew/spew"
"github.com/kube-vip/kube-vip/pkg/etcd" "github.com/kube-vip/kube-vip/pkg/etcd"
"github.com/kube-vip/kube-vip/pkg/kubevip" "github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease" "github.com/kube-vip/kube-vip/pkg/lease"
@@ -17,7 +15,6 @@ import (
"github.com/kube-vip/kube-vip/pkg/utils" "github.com/kube-vip/kube-vip/pkg/utils"
clientv3 "go.etcd.io/etcd/client/v3" clientv3 "go.etcd.io/etcd/client/v3"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -199,7 +196,7 @@ func (em *Manager) NodeWatcher(ctx context.Context, lb *loadbalancer.IPVSLoadBal
err = lb.AddBackend(node.Status.Addresses[x].Address, port) err = lb.AddBackend(node.Status.Addresses[x].Address, port)
if err != nil { if err != nil {
log.Error("adding node to load balancer", "node", node.Name, "ip", node.Status.Addresses[x].Address, "err", err) log.Error("adding node to load balancer", "node", node.Name, "ip", node.Status.Addresses[x].Address, "err", err)
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
return fmt.Errorf("add IPVS backend: %w", err) return fmt.Errorf("add IPVS backend: %w", err)
} }
} }
@@ -233,23 +230,20 @@ func (em *Manager) NodeWatcher(ctx context.Context, lb *loadbalancer.IPVSLoadBal
// Un-used // Un-used
case watch.Error: case watch.Error:
log.Error("Error attempting to watch Kubernetes Nodes") log.Error("Error attempting to watch Kubernetes Nodes")
watchErr = fmt.Errorf("node watcher error: %w", utils.WatchError(event.Object))
// This round trip allows us to handle unstructured status log.Error("watcher", "err", watchErr)
errObject := apierrors.FromObject(event.Object)
statusErr, ok := errObject.(*apierrors.StatusError)
if !ok {
log.Error(spew.Sprintf("Received an error which is not *metav1.Status but %#+v", event.Object))
}
status := statusErr.ErrStatus
log.Error("watcher", "status", status)
watchErr = fmt.Errorf("node watcher error, status: %s", status.String())
default: default:
} }
} }
log.Info("Exiting Node watcher") log.Info("Exiting Node watcher")
return watchErr if watchErr != nil {
return watchErr
}
if ctx.Err() != nil {
return nil
}
return utils.NewPanicError("node watcher channel closed unexpectedly")
} }
func checkIfNodeIsReady(node *v1.Node) bool { func checkIfNodeIsReady(node *v1.Node) bool {

View File

@@ -240,7 +240,7 @@ func (lb *IPVSLoadBalancer) addBackend(address string, port uint16) error {
// Fatal error at this point as IPVS is probably not working // Fatal error at this point as IPVS is probably not working
log.Error("Unable to create an IPVS service, ensure IPVS kernel modules are loaded") log.Error("Unable to create an IPVS service, ensure IPVS kernel modules are loaded")
log.Error("IPVS service", "err", err) log.Error("IPVS service", "err", err)
return utils.NewPanicError(fmt.Sprintf("unable to create an IPVS service - %s", err)) return utils.WrapPanicError(err, "unable to create an IPVS service")
} }
log.Info("load-Balancer services created", "address", lb.addrString(), "port", lb.Port) log.Info("load-Balancer services created", "address", lb.addrString(), "port", lb.Port)

View File

@@ -2,7 +2,6 @@ package manager
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -414,7 +413,7 @@ func (sm *Manager) startMode(ctx context.Context) error {
return nil return nil
default: default:
if err = w.StartServices(modeCtx); err != nil { if err = w.StartServices(modeCtx); err != nil {
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
sm.Kill() sm.Kill()
return fmt.Errorf("failed to reconcile services, non-recoverable error: %w", err) return fmt.Errorf("failed to reconcile services, non-recoverable error: %w", err)
} else { } else {

View File

@@ -11,10 +11,9 @@ import (
log "log/slog" log "log/slog"
"github.com/kube-vip/kube-vip/pkg/kubevip" "github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/utils"
"github.com/davecgh/go-spew/spew"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -108,22 +107,15 @@ func annotationsWatcher(ctx context.Context, clientSet,
// Un-used // Un-used
case watch.Error: case watch.Error:
log.Error("Error attempting to watch Kubernetes Nodes") log.Error("Error attempting to watch Kubernetes Nodes")
log.Error("annotations watcher failed", "err", utils.WatchError(event.Object))
// This round trip allows us to handle unstructured status
errObject := apierrors.FromObject(event.Object)
statusErr, ok := errObject.(*apierrors.StatusError)
if !ok {
log.Error(spew.Sprintf("Received an error which is not *metav1.Status but %#+v", event.Object))
}
status := statusErr.ErrStatus
log.Error(status.String())
default: default:
} }
} }
log.Info("[annotations] exiting annotations watcher") log.Info("[annotations] exiting annotations watcher")
return nil if ctx.Err() != nil {
return nil
}
return utils.NewPanicError("annotations watcher channel closed unexpectedly")
} }
// parseNodeAnnotations parses the annotations on the node and updates the configuration // parseNodeAnnotations parses the annotations on the node and updates the configuration

View File

@@ -122,7 +122,7 @@ func (p *Processor) StartServicesLeaderElection(svcCtx *servicecontext.Context,
// Block until service context is cancelled // Block until service context is cancelled
<-svcCtx.Ctx.Done() <-svcCtx.Ctx.Done()
if err := p.onStoppedLeading(svcLease, service); err != nil { if err := p.onStoppedLeading(svcCtx, svcLease, service); err != nil {
log.Error("error on stopped leading", "error", err) log.Error("error on stopped leading", "error", err)
} }
@@ -159,7 +159,7 @@ func (p *Processor) StartServicesLeaderElection(svcCtx *servicecontext.Context,
// we can do cleanup here // we can do cleanup here
svcLease.Elected.Store(false) svcLease.Elected.Store(false)
log.Info("leadership lost", "service", service.Name, "uid", service.UID, "leader", p.config.NodeName) log.Info("leadership lost", "service", service.Name, "uid", service.UID, "leader", p.config.NodeName)
if err := p.onStoppedLeading(svcLease, service); err != nil { if err := p.onStoppedLeading(svcCtx, svcLease, service); err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(service.Namespace, service.Name, "delete_service").Inc() metrics.ServiceReconcileErrorsTotal.WithLabelValues(service.Namespace, service.Name, "delete_service").Inc()
leaderCancel() leaderCancel()
} }
@@ -195,9 +195,18 @@ func (p *Processor) onStartedLeading(svcCtx *servicecontext.Context, service *v1
return nil return nil
} }
func (p *Processor) onStoppedLeading(svcLease *lease.Lease, service *v1.Service) error { func (p *Processor) onStoppedLeading(svcCtx *servicecontext.Context, svcLease *lease.Lease, service *v1.Service) error {
currentSvcCtx, err := p.getServiceContext(service.UID)
if err != nil {
return err
}
if currentSvcCtx != nil && currentSvcCtx != svcCtx {
log.Debug("skipping cleanup from superseded service context", "service", service.Name, "uid", service.UID)
return nil
}
log.Debug("deleting service due to lost leadership", "uid", service.UID) log.Debug("deleting service due to lost leadership", "uid", service.UID)
err := p.deleteService(svcLease.Ctx, service.UID) err = p.deleteService(svcLease.Ctx, service.UID)
if err != nil { if err != nil {
log.Error("service deletion", "err", err) log.Error("service deletion", "err", err)
return err return err

View File

@@ -2,7 +2,6 @@ package services
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
log "log/slog" log "log/slog"
"reflect" "reflect"
@@ -91,7 +90,8 @@ func NewServicesProcessor(config *kubevip.Config, bgpServer *bgp.Server,
} }
} }
func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceFunc *Callback, forcedOnly bool, wg *sync.WaitGroup) error { func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceFunc *Callback, forcedOnly bool,
wg *sync.WaitGroup, cancelWatcher context.CancelCauseFunc) error {
svc, ok := event.Object.(*v1.Service) svc, ok := event.Object.(*v1.Service)
if !ok { if !ok {
return fmt.Errorf("unable to parse Kubernetes services from API watcher") return fmt.Errorf("unable to parse Kubernetes services from API watcher")
@@ -142,16 +142,6 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
} }
svcInstance := instance.FindServiceInstance(svc, p.ServiceInstances) svcInstance := instance.FindServiceInstance(svc, p.ServiceInstances)
var err error
if svcInstance == nil {
svcInstance, err = instance.NewInstance(ctx, svc, p.config, p.intfMgr, p.arpMgr, p.routeMgr, p.nodeLabelManager, wg)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "new_instance").Inc()
return fmt.Errorf("unable to create instance for service %s/%s", svc.Namespace, svc.Name)
}
p.ServiceInstances = append(p.ServiceInstances, svcInstance)
p.updateActiveServicesMetric()
}
_, usesCommonLease := svc.Annotations[kubevip.ServiceLease] _, usesCommonLease := svc.Annotations[kubevip.ServiceLease]
if usesCommonLease && svc.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster { if usesCommonLease && svc.Spec.ExternalTrafficPolicy != v1.ServiceExternalTrafficPolicyTypeCluster {
@@ -194,8 +184,6 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "delete_service").Inc() metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "delete_service").Inc()
log.Error("(svc) unable to remove", "service", svc.UID) log.Error("(svc) unable to remove", "service", svc.UID)
} }
// in theory this should never fail
p.svcMap.Delete(svc.UID)
// Retire the lease before the replacement context is built, so Add below // Retire the lease before the replacement context is built, so Add below
// cannot hand back an instance the pending cleanup is about to cancel. // cannot hand back an instance the pending cleanup is about to cancel.
// A lease shared with other services keeps their references and survives. // A lease shared with other services keeps their references and survives.
@@ -205,6 +193,7 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
// Reset the the svcCtx when it was garbage collected // Reset the the svcCtx when it was garbage collected
// As the next function will create a new context when nil // As the next function will create a new context when nil
svcCtx = nil svcCtx = nil
svcInstance = nil
p.updateActiveServicesMetric() p.updateActiveServicesMetric()
} }
} }
@@ -223,6 +212,16 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
p.svcMap.Store(svc.UID, svcCtx) p.svcMap.Store(svc.UID, svcCtx)
} }
if svcInstance == nil {
svcInstance, err = instance.NewInstance(ctx, svc, p.config, p.intfMgr, p.arpMgr, p.routeMgr, p.nodeLabelManager, wg)
if err != nil {
metrics.ServiceReconcileErrorsTotal.WithLabelValues(svc.Namespace, svc.Name, "new_instance").Inc()
return fmt.Errorf("unable to create instance for service %s/%s", svc.Namespace, svc.Name)
}
p.ServiceInstances = append(p.ServiceInstances, svcInstance)
p.updateActiveServicesMetric()
}
// this goroutine starts service handling function (with or without leaderelection) // this goroutine starts service handling function (with or without leaderelection)
if !svcCtx.IsWatched { if !svcCtx.IsWatched {
wg.Go(func() { wg.Go(func() {
@@ -240,7 +239,7 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
err = serviceFunc.Run(svcCtx, svc, wg) err = serviceFunc.Run(svcCtx, svc, wg)
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
// cancel service context on panic error // cancel service context on panic error
// TODO: should we quit kube-vip altogether here? // TODO: should we quit kube-vip altogether here?
svcCtx.Cancel() svcCtx.Cancel()
@@ -258,8 +257,11 @@ func (p *Processor) AddOrModify(ctx context.Context, event watch.Event, serviceF
} else { } else {
provider = providers.NewEndpointslices() provider = providers.NewEndpointslices()
} }
if err = p.watchEndpoint(svcCtx, p.config.NodeName, svc, provider); err != nil { if err := p.watchEndpoint(svcCtx, p.config.NodeName, svc, provider, cancelWatcher); err != nil {
log.Error(err.Error()) log.Error("endpoint watcher failed", "service", svc.Name, "namespace", svc.Namespace, "err", err)
if utils.IsPanicError(err) {
cancelWatcher(err)
}
} }
}) })

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/kube-vip/kube-vip/pkg/instance"
"github.com/kube-vip/kube-vip/pkg/kubevip" "github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/lease" "github.com/kube-vip/kube-vip/pkg/lease"
"github.com/kube-vip/kube-vip/pkg/servicecontext" "github.com/kube-vip/kube-vip/pkg/servicecontext"
@@ -117,3 +118,37 @@ func TestDropCancelledServiceContextAllowsLeaseRecreation(t *testing.T) {
t.Fatal("expected a new lease to be created once the cancelled service context was dropped") t.Fatal("expected a new lease to be created once the cancelled service context was dropped")
} }
} }
func TestOnStoppedLeadingDoesNotDeleteReplacementContext(t *testing.T) {
p := &Processor{
config: &kubevip.Config{},
leaseMgr: lease.NewManager(),
}
service := &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "example",
Namespace: "default",
UID: types.UID("service-uid"),
},
}
oldCtx := servicecontext.New(context.Background())
replacementCtx := servicecontext.New(context.Background())
p.svcMap.Store(service.UID, replacementCtx)
replacementInstance := &instance.Instance{ServiceSnapshot: service.DeepCopy()}
p.ServiceInstances = []*instance.Instance{replacementInstance}
leaseNamespace, serviceLease := lease.ServiceName(service)
svcLease := p.leaseMgr.Add(context.Background(), lease.NewID(p.config.LeaderElectionType, leaseNamespace, serviceLease))
if err := p.onStoppedLeading(oldCtx, svcLease, service); err != nil {
t.Fatalf("onStoppedLeading returned an error: %v", err)
}
if got, err := p.getServiceContext(service.UID); err != nil || got != replacementCtx {
t.Fatalf("replacement context was changed: got %v, err %v", got, err)
}
if len(p.ServiceInstances) != 1 || p.ServiceInstances[0] != replacementInstance {
t.Fatal("replacement service instance was removed by superseded cleanup")
}
}

View File

@@ -1,6 +1,7 @@
package services package services
import ( import (
"context"
"fmt" "fmt"
"sync" "sync"
@@ -10,28 +11,31 @@ import (
"github.com/kube-vip/kube-vip/pkg/endpoints" "github.com/kube-vip/kube-vip/pkg/endpoints"
"github.com/kube-vip/kube-vip/pkg/endpoints/providers" "github.com/kube-vip/kube-vip/pkg/endpoints/providers"
"github.com/kube-vip/kube-vip/pkg/servicecontext" "github.com/kube-vip/kube-vip/pkg/servicecontext"
"github.com/kube-vip/kube-vip/pkg/utils"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
) )
func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, service *v1.Service, provider providers.Provider) error { func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, service *v1.Service,
provider providers.Provider, cancelWatcher context.CancelCauseFunc) error {
log.Info("watching", "provider", provider.GetLabel(), "service_name", service.Name, "namespace", service.Namespace) log.Info("watching", "provider", provider.GetLabel(), "service_name", service.Name, "namespace", service.Namespace)
// Use a restartable watcher, as this should help in the event of etcd or timeout issues // Use a restartable watcher, as this should help in the event of etcd or timeout issues
rw, err := provider.CreateRetryWatcher(svcCtx.Ctx, p.rwClientSet, service) rw, err := provider.CreateRetryWatcher(svcCtx.Ctx, p.rwClientSet, service)
if err != nil { if err != nil {
return fmt.Errorf("[%s] error watching endpoints: %w", provider.GetLabel(), err) if svcCtx.Ctx.Err() != nil {
return nil
}
return utils.WrapPanicError(err, "[%s] error watching endpoints", provider.GetLabel())
} }
d, err := debouncer.New(rw.ResultChan(), p.config.DebounceTime) d, err := debouncer.New(rw.ResultChan(), p.config.DebounceTime)
if err != nil { if err != nil {
rw.Stop() rw.Stop()
return fmt.Errorf("failed to create debouncer for endpoints event: %w", err) return utils.WrapPanicError(err, "failed to create debouncer for endpoints event")
} }
wg := sync.WaitGroup{} var wg sync.WaitGroup
stopChan := make(chan any) stopChan := make(chan any)
defer func() { defer func() {
@@ -46,6 +50,9 @@ func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, ser
if d != nil { if d != nil {
if err := d.Start(svcCtx.Ctx); err != nil { if err := d.Start(svcCtx.Ctx); err != nil {
log.Error("[endpoint watcher] debouncer, cancelling context", "error", err.Error()) log.Error("[endpoint watcher] debouncer, cancelling context", "error", err.Error())
if svcCtx.Ctx.Err() == nil {
cancelWatcher(utils.WrapPanicError(err, "[%s] endpoint debouncer failed", provider.GetLabel()))
}
svcCtx.Cancel() svcCtx.Cancel()
} }
} }
@@ -85,14 +92,19 @@ func (p *Processor) watchEndpoint(svcCtx *servicecontext.Context, id string, ser
return fmt.Errorf("[%s] error while processing delete event: %w", provider.GetLabel(), err) return fmt.Errorf("[%s] error while processing delete event: %w", provider.GetLabel(), err)
} }
log.Info("[endpoint watcher] stopping watching - endpoint object deleted", "provider", provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace) log.Info("[endpoint watcher] endpoint object deleted", "provider", provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace)
return nil
case watch.Error: case watch.Error:
errObject := apierrors.FromObject(event.Object) if svcCtx.Ctx.Err() != nil {
statusErr, _ := errObject.(*apierrors.StatusError) return nil
log.Error("watch error", "provider", provider.GetLabel(), "err", statusErr) }
watchErr := utils.WatchError(event.Object)
log.Error("watch error", "provider", provider.GetLabel(), "err", watchErr)
return utils.WrapPanicError(watchErr, "[%s] endpoint watch failed", provider.GetLabel())
} }
} }
if svcCtx.Ctx.Err() != nil {
return nil
}
log.Info("[endpoint watcher] stopping watching", "provider", provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace) log.Info("[endpoint watcher] stopping watching", "provider", provider.GetLabel(), "service name", service.Name, "namespace", service.Namespace)
return nil //nolint:govet return utils.NewPanicError("[%s] endpoint watch channel closed unexpectedly for service %s/%s", provider.GetLabel(), service.Namespace, service.Name)
} }

View File

@@ -2,13 +2,11 @@ package services
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sync" "sync"
log "log/slog" log "log/slog"
"github.com/davecgh/go-spew/spew"
"github.com/kube-vip/kube-vip/pkg/debouncer" "github.com/kube-vip/kube-vip/pkg/debouncer"
"github.com/kube-vip/kube-vip/pkg/kubevip" "github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/metrics" "github.com/kube-vip/kube-vip/pkg/metrics"
@@ -16,7 +14,6 @@ import (
"github.com/kube-vip/kube-vip/pkg/utils" "github.com/kube-vip/kube-vip/pkg/utils"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/cache"
@@ -62,7 +59,7 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
return fmt.Errorf("failed to create debouncer for endpoints event: %w", err) return fmt.Errorf("failed to create debouncer for endpoints event: %w", err)
} }
wg := sync.WaitGroup{} var wg sync.WaitGroup
defer func() { defer func() {
if d != nil { if d != nil {
d.Stop() d.Stop()
@@ -71,14 +68,14 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
wg.Wait() wg.Wait()
}() }()
watcherCtx, watcherCancel := context.WithCancel(ctx) watcherCtx, cancelWatcher := context.WithCancelCause(ctx)
defer watcherCancel() defer cancelWatcher(nil)
wg.Go(func() { wg.Go(func() {
if d != nil { if d != nil {
if err := d.Start(watcherCtx); err != nil { if err := d.Start(watcherCtx); err != nil {
log.Error("(svcs) debouncer, cancelling context", "error", err.Error()) log.Error("(svcs) debouncer, cancelling context", "error", err.Error())
watcherCancel() cancelWatcher(utils.WrapPanicError(err, "service debouncer failed"))
} }
} }
<-watcherCtx.Done() <-watcherCtx.Done()
@@ -102,41 +99,38 @@ func (p *Processor) ServicesWatcher(ctx context.Context, serviceFunc *Callback,
// We need to inspect the event and get ResourceVersion out of it // We need to inspect the event and get ResourceVersion out of it
switch event.Type { switch event.Type {
case watch.Added, watch.Modified: case watch.Added, watch.Modified:
if err := p.AddOrModify(watcherCtx, event, serviceFunc, forcedOnly, &wg); err != nil { if err := p.AddOrModify(watcherCtx, event, serviceFunc, forcedOnly, &wg, cancelWatcher); err != nil {
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
return fmt.Errorf("add/modify service error: %w", err) return fmt.Errorf("add/modify service error: %w", err)
} else {
log.Error("service watcher event failed", "type", event.Type, "error", err)
} }
log.Error("service watcher event failed", "type", event.Type, "error", err)
} }
case watch.Deleted: case watch.Deleted:
if err := p.Delete(event, forcedOnly); err != nil { if err := p.Delete(event, forcedOnly); err != nil {
if errors.Is(err, &utils.PanicError{}) { if utils.IsPanicError(err) {
return fmt.Errorf("delete service error: %w", err) return fmt.Errorf("delete service error: %w", err)
} else {
log.Error("service watcher event failed", "type", event.Type, "error", err)
} }
log.Error("service watcher event failed", "type", event.Type, "error", err)
} }
case watch.Bookmark: case watch.Bookmark:
// Un-used // Un-used
case watch.Error: case watch.Error:
log.Error("Error attempting to watch Kubernetes services") log.Error("Error attempting to watch Kubernetes services")
watchErr := utils.WatchError(event.Object)
// This round trip allows us to handle unstructured status log.Error("services", "err", watchErr)
errObject := apierrors.FromObject(event.Object) return utils.WrapPanicError(watchErr, "service watch failed")
statusErr, ok := errObject.(*apierrors.StatusError)
if !ok {
log.Error(spew.Sprintf("Received an error which is not *metav1.Status but %#+v", event.Object))
}
status := statusErr.ErrStatus
log.Error("services", "err", status)
default: default:
} }
} }
if ctx.Err() != nil {
return nil
}
if watcherErr := context.Cause(watcherCtx); watcherErr != nil {
return watcherErr
}
log.Warn("Stopping watching services for type: LoadBalancer in all namespaces") log.Warn("Stopping watching services for type: LoadBalancer in all namespaces")
return nil return utils.NewPanicError("service watch channel closed unexpectedly")
} }
func lbClassFilterLegacy(svc *v1.Service, config *kubevip.Config) bool { func lbClassFilterLegacy(svc *v1.Service, config *kubevip.Config) bool {

View File

@@ -146,7 +146,7 @@ func getQdiscFromInterfaceByType(nicID int, nicName string, qType string) (uint3
// get id through tc qdisc show dev fromNICName // get id through tc qdisc show dev fromNICName
qs, err := netlink.QdiscList(&netlink.Ifb{LinkAttrs: netlink.LinkAttrs{Index: nicID}}) qs, err := netlink.QdiscList(&netlink.Ifb{LinkAttrs: netlink.LinkAttrs{Index: nicID}})
if err != nil { if err != nil {
fmt.Printf("Failed to list qdisc for interface %s: %v", nicName, err) log.Error("failed to list qdisc", "interface", nicName, "err", err)
return 0, err return 0, err
} }
for _, q := range qs { for _, q := range qs {

View File

@@ -1,15 +1,31 @@
package utils package utils
import "fmt" import (
"errors"
"fmt"
)
type PanicError struct { type PanicError struct {
cause string cause error
} }
func (e *PanicError) Error() string { func (e *PanicError) Error() string {
return fmt.Sprintf("%s - unrecoverable error", e.cause) return fmt.Sprintf("%s - unrecoverable error", e.cause)
} }
func NewPanicError(cause string) error { func (e *PanicError) Unwrap() error {
return &PanicError{cause: cause} return e.cause
}
func NewPanicError(format string, args ...any) error {
return &PanicError{cause: fmt.Errorf(format, args...)}
}
func WrapPanicError(err error, format string, args ...any) error {
return &PanicError{cause: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)}
}
func IsPanicError(err error) bool {
var panicErr *PanicError
return errors.As(err, &panicErr)
} }

41
pkg/utils/panic_test.go Normal file
View File

@@ -0,0 +1,41 @@
package utils
import (
"errors"
"fmt"
"testing"
)
func TestIsPanicError(t *testing.T) {
panicErr := NewPanicError("endpoint watch stopped")
tests := []struct {
name string
err error
want bool
}{
{name: "direct", err: panicErr, want: true},
{name: "wrapped", err: fmt.Errorf("watch failed: %w", panicErr), want: true},
{name: "ordinary", err: errors.New("watch failed"), want: false},
{name: "nil", err: nil, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := IsPanicError(test.err); got != test.want {
t.Fatalf("IsPanicError() = %t, want %t", got, test.want)
}
})
}
}
func TestWrapPanicErrorPreservesCause(t *testing.T) {
cause := errors.New("endpointslices is forbidden")
err := WrapPanicError(cause, "endpoint watch failed")
if !IsPanicError(err) {
t.Fatal("expected wrapped error to be classified as PanicError")
}
if !errors.Is(err, cause) {
t.Fatal("expected wrapped PanicError to preserve its cause")
}
}

View File

@@ -7,10 +7,20 @@ import (
"time" "time"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
) )
// WatchError converts a Kubernetes watch error object into a safe Go error.
func WatchError(object runtime.Object) error {
errObject := apierrors.FromObject(object)
if statusErr, ok := errObject.(*apierrors.StatusError); ok {
return statusErr
}
return fmt.Errorf("unknown watch error object of type %T: %v", object, object)
}
// watchWithAuthRetry retries watchFn with exponential backoff on transient 403 Forbidden // watchWithAuthRetry retries watchFn with exponential backoff on transient 403 Forbidden
// and 401 Unauthorized errors. On joining control plane nodes with K8s 1.34+, the local // and 401 Unauthorized errors. On joining control plane nodes with K8s 1.34+, the local
// etcd may still be a learner when kube-vip starts, causing RBAC data to be unavailable. // etcd may still be a learner when kube-vip starts, causing RBAC data to be unavailable.
@@ -39,7 +49,14 @@ func WatchWithAuthRetry(ctx context.Context, watchFn func(context.Context) (watc
return false, nil return false, nil
}) })
if err != nil { if err != nil {
return nil, NewPanicError(fmt.Sprintf("watch failed after retries: %q (last: %v)", err.Error(), lastErr)) if ctx.Err() != nil {
return nil, ctx.Err()
}
if lastErr != nil {
log.Error("watch auth retries exhausted", "err", lastErr)
return nil, lastErr
}
return nil, WrapPanicError(err, "watch failed after retries (last: %v)", lastErr)
} }
return w, nil return w, nil
} }

37
pkg/utils/watcher_test.go Normal file
View File

@@ -0,0 +1,37 @@
package utils
import (
"errors"
"strings"
"testing"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestWatchErrorPreservesStatusError(t *testing.T) {
status := &metav1.Status{
Status: metav1.StatusFailure,
Reason: metav1.StatusReasonForbidden,
Message: "endpointslices is forbidden",
}
err := WatchError(status)
var statusErr *apierrors.StatusError
if !errors.As(err, &statusErr) {
t.Fatalf("expected a status error, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), status.Message) {
t.Fatalf("expected error to contain %q, got %q", status.Message, err)
}
}
func TestWatchErrorHandlesUnexpectedObject(t *testing.T) {
err := WatchError(&metav1.APIGroup{})
if err == nil {
t.Fatal("expected an error for an unexpected watch object")
}
if !strings.Contains(err.Error(), "unknown watch error object") {
t.Fatalf("expected unexpected-object context, got %q", err)
}
}