diff --git a/.gitignore b/.gitignore index 0ef0667..ed83d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ Dockerfile.cross # Output of the go coverage tool, specifically when used with LiteIDE *.out coverage.txt +coverage.html # Go workspace file go.work diff --git a/Makefile b/Makefile index 2e1c954..266437d 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ vet: ## Vet Go code .PHONY: test test: tidy fmt vet ## Run tests with coverage go test ./... -race -coverprofile=coverage.out + go tool cover -html=coverage.out -o=coverage.html .PHONY: build build: tidy fmt vet test ## Build the binary diff --git a/cmd/main.go b/cmd/main.go index ca8cbe9..4417942 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,13 +34,121 @@ const ( AnnStatus = "object-lease-controller.ullberg.io/lease-status" ) +// ParseParams holds runtime configuration parsed from flags and environment. +type ParseParams struct { + Group string + Version string + Kind string + OptInLabelKey string + OptInLabelValue string + MetricsBindAddress string + HealthProbeBindAddress string + PprofBindAddress string + LeaderElectionEnabled bool + LeaderElectionNamespace string +} + var ( setupLog = ctrl.Log.WithName("setup") ) +// Allow injection for testing +var statFn = os.Stat +var readFileFn = os.ReadFile + func main() { ctrl.SetLogger(zap.New()) + params := parseParameters() + + enableLeaderElection, leaderElectionNamespace, errE := parseLeaderElectionConfig(params.LeaderElectionEnabled, params.LeaderElectionNamespace) + if errE != nil { + fmt.Printf("%v\n", errE) + os.Exit(1) + } + + if params.Version == "" || params.Kind == "" { + fmt.Println("Usage: lease-controller -group=GROUP -version=VERSION -kind=KIND [--leader-elect] [--leader-elect-namespace=NAMESPACE]") + fmt.Println("Or set LEASE_GVK_GROUP, LEASE_GVK_VERSION, LEASE_GVK_KIND, LEASE_LEADER_ELECTION env vars") + os.Exit(1) + } + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + gvk := schema.GroupVersionKind{ + Group: params.Group, + Version: params.Version, + Kind: params.Kind, + } + + // Use a unique leader election ID per GVK in lower case + leaderElectionID := strings.ToLower(fmt.Sprintf("object-lease-controller-%s-%s-%s", params.Group, params.Version, params.Kind)) + + mgrOpts := buildManagerOptions(scheme, params.Group, params.Version, params.Kind, params.MetricsBindAddress, params.HealthProbeBindAddress, params.PprofBindAddress, enableLeaderElection, leaderElectionNamespace) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) + if err != nil { + setupLog.Error(err, "unable to start manager") + panic(err) + } + + // Create a LeaseWatcher for the specified GVK + lw := newLeaseWatcher(mgr, gvk, leaderElectionID) + + if tr, err := configureNamespaceReconciler(mgr, params.OptInLabelKey, params.OptInLabelValue, leaderElectionID); err != nil { + setupLog.Error(err, "unable to create controller", "GVK", gvk) + panic(err) + } else { + lw.Tracker = tr + } + + // Register the LeaseWatcher with the manager + if err := lw.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "GVK", gvk) + panic(err) + } + + // Add metrics server expvar handler + if params.MetricsBindAddress != "" { + setupLog.Info("Adding /debug/vars to metrics", "address", params.MetricsBindAddress) + if err := mgr.AddMetricsServerExtraHandler("/debug/vars", expvar.Handler()); err != nil { + setupLog.Error(err, "unable to set up metrics server extra handler") + os.Exit(1) + } + } + + healthCheck := func(req *http.Request) error { + return healthCheck(req, mgr, gvk) + } + + if err := mgr.AddHealthzCheck("gvk", healthCheck); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + + // Ready check: verify manager cache is synced + readyCheck := func(req *http.Request) error { + if !mgr.GetCache().WaitForCacheSync(req.Context()) { + return fmt.Errorf("cache not synced") + } + return nil + } + if err := mgr.AddReadyzCheck("readyz", readyCheck); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("Starting manager", "group", params.Group, "version", params.Version, "kind", params.Kind, "leaderElectionID", leaderElectionID) + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + panic(err) + } +} + +// parseParameters returns a ParseParams struct instead of a tuple to make it +// easier to extend and pass around in tests. +func parseParameters() ParseParams { var group, version, kind string var optInLabelKey, optInLabelValue string flag.StringVar(&group, "group", "", "Kubernetes API group (e.g., \"apps\")") @@ -84,56 +192,71 @@ func main() { optInLabelValue = os.Getenv("LEASE_OPT_IN_LABEL_VALUE") } + // Leader election may be enabled via env var when not set via flags + if !enableLeaderElection { + if ele := os.Getenv("LEASE_LEADER_ELECTION"); ele != "" { + if strings.EqualFold(ele, "true") || ele == "1" { + enableLeaderElection = true + } + } + } + + // If no leader election namespace was provided via flags, allow env fallback. + if leaderElectionNamespace == "" { + leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE") + } + + return ParseParams{ + Group: group, + Version: version, + Kind: kind, + OptInLabelKey: optInLabelKey, + OptInLabelValue: optInLabelValue, + MetricsBindAddress: metricsAddr, + HealthProbeBindAddress: probeAddr, + PprofBindAddress: pprofAddr, + LeaderElectionEnabled: enableLeaderElection, + LeaderElectionNamespace: leaderElectionNamespace, + } +} + +// Parse leader election configuration from flags and environment. +// Returns (enabled, namespace, error) +func parseLeaderElectionConfig(enableLeaderElection bool, leaderElectionNamespace string) (bool, string, error) { if !enableLeaderElection { if val := os.Getenv("LEASE_LEADER_ELECTION"); val != "" { var err error enableLeaderElection, err = strconv.ParseBool(val) if err != nil { - fmt.Printf("Invalid LEASE_LEADER_ELECTION value: %v\n", err) - os.Exit(1) + return false, "", fmt.Errorf("invalid LEASE_LEADER_ELECTION value: %v", err) } - // If leader election is enabled, check for namespace and fail if not set and not running in a cluster if enableLeaderElection && leaderElectionNamespace == "" { leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE") } - if leaderElectionNamespace == "" { - // If running outside a cluster, we need a namespace for leader election - if _, err := os.Stat("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); os.IsNotExist(err) { - fmt.Println("Leader election enabled but LEASE_LEADER_ELECTION_NAMESPACE is not set. Please set it to a valid namespace.") - os.Exit(1) - } else { - // Default to the namespace file if running in a cluster - data, _ := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + + if enableLeaderElection && leaderElectionNamespace == "" { + if _, err := statFn("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); os.IsNotExist(err) { + return false, "", fmt.Errorf("leader election enabled but LEASE_LEADER_ELECTION_NAMESPACE is not set; set it to a valid namespace") + } + // we're in a cluster; default to serviceaccount namespace + if data, err := readFileFn("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { leaderElectionNamespace = strings.TrimSpace(string(data)) } } } } - if version == "" || kind == "" { - fmt.Println("Usage: lease-controller -group=GROUP -version=VERSION -kind=KIND [--leader-elect] [--leader-elect-namespace=NAMESPACE]") - fmt.Println("Or set LEASE_GVK_GROUP, LEASE_GVK_VERSION, LEASE_GVK_KIND, LEASE_LEADER_ELECTION env vars") - os.Exit(1) - } - - scheme := runtime.NewScheme() - _ = corev1.AddToScheme(scheme) - - gvk := schema.GroupVersionKind{ - Group: group, - Version: version, - Kind: kind, + if leaderElectionNamespace == "" { + leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE") } + return enableLeaderElection, leaderElectionNamespace, nil +} - // Use a unique leader election ID per GVK in lower case +// Build manager options for a given GVK and flags; extracted for unit testing +func buildManagerOptions(scheme *runtime.Scheme, group, version, kind string, metricsAddr, probeAddr, pprofAddr string, enableLeaderElection bool, leaderElectionNamespace string) ctrl.Options { leaderElectionID := strings.ToLower(fmt.Sprintf("object-lease-controller-%s-%s-%s", group, version, kind)) - - // Set up metrics server options - metricsServerOptions := metricsserver.Options{ - BindAddress: metricsAddr, - } - + metricsServerOptions := metricsserver.Options{BindAddress: metricsAddr} mgrOpts := ctrl.Options{ Scheme: scheme, LeaderElection: enableLeaderElection, @@ -143,24 +266,20 @@ func main() { Metrics: metricsServerOptions, HealthProbeBindAddress: probeAddr, Cache: cache.Options{ - DefaultTransform: util.MinimalObjectTransform( - AnnTTL, AnnLeaseStart, AnnExpireAt, AnnStatus, - ), + DefaultTransform: util.MinimalObjectTransform(AnnTTL, AnnLeaseStart, AnnExpireAt, AnnStatus), }, } - if pprofAddr != "" { mgrOpts.PprofBindAddress = pprofAddr } + return mgrOpts +} - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts) - if err != nil { - setupLog.Error(err, "unable to start manager") - panic(err) - } - - // Create a LeaseWatcher for the specified GVK - lw := &controllers.LeaseWatcher{ +// Create a LeaseWatcher attached to the given manager. The LeaseWatcher is initialized +// with default annotations and metrics for the provided GVK. The function does not +// call SetupWithManager - this is left to the caller. +func newLeaseWatcher(mgr ctrl.Manager, gvk schema.GroupVersionKind, leaderElectionID string) *controllers.LeaseWatcher { + return &controllers.LeaseWatcher{ Client: mgr.GetClient(), GVK: gvk, Recorder: mgr.GetEventRecorderFor(leaderElectionID), @@ -172,68 +291,27 @@ func main() { }, Metrics: ometrics.NewLeaseMetrics(gvk), } +} - if optInLabelKey != "" && optInLabelValue != "" { - tracker := util.NewNamespaceTracker() - - nw := &controllers.NamespaceReconciler{ - Client: mgr.GetClient(), - Recorder: mgr.GetEventRecorderFor(leaderElectionID), - LabelKey: optInLabelKey, - LabelValue: optInLabelValue, - Tracker: tracker, - } - - // Register NamespaceReconciler with the manager - if err := nw.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "GVK", gvk) - panic(err) - } - - lw.Tracker = tracker - } - - // Register the LeaseWatcher with the manager - if err := lw.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "GVK", gvk) - panic(err) - } - - // Add metrics server expvar handler - if metricsAddr != "" { - setupLog.Info("Adding /debug/vars to metrics", "address", metricsAddr) - if err := mgr.AddMetricsServerExtraHandler("/debug/vars", expvar.Handler()); err != nil { - setupLog.Error(err, "unable to set up metrics server extra handler") - os.Exit(1) - } - } - - healthCheck := func(req *http.Request) error { - return healthCheck(req, mgr, gvk) - } - - if err := mgr.AddHealthzCheck("gvk", healthCheck); err != nil { - setupLog.Error(err, "unable to set up health check") - os.Exit(1) +// If optInLabelKey and optInLabelValue are provided, create a NamespaceReconciler and +// register it with the manager. Returns the NamespaceTracker that was created if any, +// or nil if opt-in was not requested. +func configureNamespaceReconciler(mgr ctrl.Manager, optInLabelKey, optInLabelValue, leaderElectionID string) (*util.NamespaceTracker, error) { + if optInLabelKey == "" || optInLabelValue == "" { + return nil, nil } - - // Ready check: verify manager cache is synced - readyCheck := func(req *http.Request) error { - if !mgr.GetCache().WaitForCacheSync(req.Context()) { - return fmt.Errorf("cache not synced") - } - return nil - } - if err := mgr.AddReadyzCheck("readyz", readyCheck); err != nil { - setupLog.Error(err, "unable to set up ready check") - os.Exit(1) + tracker := util.NewNamespaceTracker() + nw := &controllers.NamespaceReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor(leaderElectionID), + LabelKey: optInLabelKey, + LabelValue: optInLabelValue, + Tracker: tracker, } - - setupLog.Info("Starting manager", "group", group, "version", version, "kind", kind, "leaderElectionID", leaderElectionID) - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - setupLog.Error(err, "problem running manager") - panic(err) + if err := nw.SetupWithManager(mgr); err != nil { + return nil, err } + return tracker, nil } // Health check: confirm GVK is discoverable and listable with minimal load diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..2e1e38d --- /dev/null +++ b/cmd/main_test.go @@ -0,0 +1,424 @@ +package main + +import ( + "context" + "errors" + "flag" + "net/http" + "os" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + logr "github.com/go-logr/logr" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + config "sigs.k8s.io/controller-runtime/pkg/config" + + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + // ctrl alias not required; we use manager.Runnable from pkg/manager +) + +// A small fake manager used to test healthCheck. +type testMgr struct { + client client.Client + mapper apimeta.RESTMapper +} + +func (t *testMgr) GetClient() client.Client { return t.client } +func (t *testMgr) GetAPIReader() client.Reader { return t.client } +func (t *testMgr) GetRESTMapper() apimeta.RESTMapper { return t.mapper } + +// A minimal fake manager for tests in this package +type fakeManager struct { + client client.Client + scheme *runtime.Scheme +} + +func (f *fakeManager) GetClient() client.Client { return f.client } +func (f *fakeManager) GetScheme() *runtime.Scheme { return f.scheme } +func (f *fakeManager) GetConfig() *rest.Config { return &rest.Config{} } +func (f *fakeManager) GetHTTPClient() *http.Client { return &http.Client{} } +func (f *fakeManager) GetCache() cache.Cache { return nil } +func (f *fakeManager) GetFieldIndexer() client.FieldIndexer { return nil } +func (f *fakeManager) GetEventRecorderFor(name string) record.EventRecorder { + return record.NewFakeRecorder(10) +} +func (f *fakeManager) GetRESTMapper() apimeta.RESTMapper { + return apimeta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "", Version: "v1"}}) +} +func (f *fakeManager) GetAPIReader() client.Reader { return f.client } +func (f *fakeManager) Start(ctx context.Context) error { return nil } + +func (f *fakeManager) Add(r manager.Runnable) error { return nil } +func (f *fakeManager) Elected() <-chan struct{} { return make(chan struct{}) } +func (f *fakeManager) AddMetricsServerExtraHandler(path string, handler http.Handler) error { + return nil +} +func (f *fakeManager) AddHealthzCheck(name string, check healthz.Checker) error { return nil } +func (f *fakeManager) AddReadyzCheck(name string, check healthz.Checker) error { return nil } +func (f *fakeManager) GetWebhookServer() webhook.Server { return nil } +func (f *fakeManager) GetLogger() logr.Logger { return logr.Discard() } +func (f *fakeManager) GetControllerOptions() config.Controller { return config.Controller{} } + +// The rest of the manager methods are not used by healthCheck; add stubs to satisfy interface +func (t *testMgr) GetScheme() *runtime.Scheme { return runtime.NewScheme() } +func (t *testMgr) GetConfig() *rest.Config { return &rest.Config{} } +func (t *testMgr) GetHTTPClient() *http.Client { return &http.Client{} } +func (t *testMgr) GetFieldIndexer() client.FieldIndexer { return nil } +func (t *testMgr) GetEventRecorderFor(s string) record.EventRecorder { return nil } +func (t *testMgr) GetCache() cache.Cache { return nil } +func (t *testMgr) Start(ctx context.Context) error { return nil } +func (t *testMgr) Add(r manager.Runnable) error { return nil } +func (t *testMgr) Elected() <-chan struct{} { return make(chan struct{}) } +func (t *testMgr) AddMetricsServerExtraHandler(path string, handler http.Handler) error { return nil } +func (t *testMgr) AddHealthzCheck(name string, check healthz.Checker) error { return nil } +func (t *testMgr) AddReadyzCheck(name string, check healthz.Checker) error { return nil } +func (t *testMgr) GetWebhookServer() webhook.Server { return nil } +func (t *testMgr) GetLogger() logr.Logger { return logr.Discard() } +func (t *testMgr) GetControllerOptions() config.Controller { return config.Controller{} } + +// GetRESTMapper already defined above + +// A client that returns an error when listing +type listErrorClient struct { + client.Client + listErr error +} + +func (c *listErrorClient) List(ctx context.Context, l client.ObjectList, opts ...client.ListOption) error { + if c.listErr != nil { + return c.listErr + } + return c.Client.List(ctx, l, opts...) +} + +func TestHealthCheck_Success(t *testing.T) { + scheme := runtime.NewScheme() + // add core types so RESTMapper and client can work + _ = corev1.AddToScheme(scheme) + + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + // Create a fake ConfigMap in default namespace + obj := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}} + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(obj).Build() + + mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "", Version: "v1"}}) + mapper.Add(gvk, apimeta.RESTScopeNamespace) + + mgr := &testMgr{client: cl, mapper: mapper} + + req := new(http.Request) + req = req.WithContext(context.Background()) + if err := healthCheck(req, mgr, gvk); err != nil { + t.Fatalf("expected success from healthCheck, got error: %v", err) + } +} + +func TestHealthCheck_RESTMappingError(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + // mapper with no mapping for gvk + mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "", Version: "v1"}}) + + mgr := &testMgr{client: cl, mapper: mapper} + req := new(http.Request) + req = req.WithContext(context.Background()) + if err := healthCheck(req, mgr, gvk); err == nil { + t.Fatalf("expected healthCheck to fail when mapping missing") + } +} + +func TestHealthCheck_ListFails(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + // build a real client but wrap it so List returns an error + base := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "xxx"}}).Build() + cl := &listErrorClient{Client: base, listErr: errors.New("boom")} + + mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "", Version: "v1"}}) + mapper.Add(gvk, apimeta.RESTScopeNamespace) + + mgr := &testMgr{client: cl, mapper: mapper} + + req := new(http.Request) + req = req.WithContext(context.Background()) + if err := healthCheck(req, mgr, gvk); err == nil { + t.Fatalf("expected healthCheck to return error when List fails") + } +} + +func TestParseLeaderElection_InvalidBool(t *testing.T) { + os.Setenv("LEASE_LEADER_ELECTION", "notabool") + defer os.Unsetenv("LEASE_LEADER_ELECTION") + _, _, err := parseLeaderElectionConfig(false, "") + if err == nil { + t.Fatalf("expected error on invalid LEASE_LEADER_ELECTION value") + } +} + +func TestParseLeaderElection_EnabledNoNamespaceOffline(t *testing.T) { + os.Setenv("LEASE_LEADER_ELECTION", "true") + defer os.Unsetenv("LEASE_LEADER_ELECTION") + + // Simulate running outside cluster: stat returns IsNotExist + oldStat := statFn + defer func() { statFn = oldStat }() + statFn = func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } + + _, _, err := parseLeaderElectionConfig(false, "") + if err == nil { + t.Fatalf("expected error when leader election enabled but no namespace in env and not in cluster") + } +} + +func TestParseLeaderElection_UsesEnvNamespace(t *testing.T) { + os.Setenv("LEASE_LEADER_ELECTION", "true") + os.Setenv("LEASE_LEADER_ELECTION_NAMESPACE", "myns") + defer func() { os.Unsetenv("LEASE_LEADER_ELECTION"); os.Unsetenv("LEASE_LEADER_ELECTION_NAMESPACE") }() + + enabled, ns, err := parseLeaderElectionConfig(false, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled || ns != "myns" { + t.Fatalf("expected enabled=true ns=myns, got enabled=%v ns=%q", enabled, ns) + } +} + +func TestParseLeaderElection_DefaultsToServiceAccountNamespace(t *testing.T) { + os.Setenv("LEASE_LEADER_ELECTION", "true") + defer os.Unsetenv("LEASE_LEADER_ELECTION") + + oldStat := statFn + oldRead := readFileFn + defer func() { statFn = oldStat; readFileFn = oldRead }() + // Simulate presence of namespace file in cluster + statFn = func(name string) (os.FileInfo, error) { return nil, nil } + readFileFn = func(name string) ([]byte, error) { return []byte("svcns\n"), nil } + + enabled, ns, err := parseLeaderElectionConfig(false, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled || ns != "svcns" { + t.Fatalf("expected enabled=true ns=svcns, got enabled=%v ns=%q", enabled, ns) + } +} + +func TestBuildManagerOptions(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + opts := buildManagerOptions(scheme, "apps", "v1", "Deployment", ":8080", ":8081", ":6060", true, "myns") + if opts.LeaderElection != true { + t.Fatalf("expected leader election true") + } + if opts.LeaderElectionNamespace != "myns" { + t.Fatalf("expected leaderElectionNamespace myns, got %q", opts.LeaderElectionNamespace) + } + if opts.Metrics.BindAddress != ":8080" { + t.Fatalf("metrics bind address mismatch") + } + if opts.PprofBindAddress != ":6060" { + t.Fatalf("pprof bind address mismatch") + } + if opts.HealthProbeBindAddress != ":8081" { + t.Fatalf("probe bind address mismatch") + } + // assert LeaderElectionID contains group/version/kind + if !strings.Contains(opts.LeaderElectionID, "apps") || !strings.Contains(opts.LeaderElectionID, "v1") || !strings.Contains(opts.LeaderElectionID, "deployment") { + t.Fatalf("leaderElectionID %q does not contain group/version/kind", opts.LeaderElectionID) + } +} + +func TestNewLeaseWatcher(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // minimal fake manager + mov := &fakeManager{client: fake.NewClientBuilder().WithScheme(scheme).Build(), scheme: scheme} + + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + lw := newLeaseWatcher(mov, gvk, "lid") + if lw == nil { + t.Fatalf("newLeaseWatcher returned nil") + } + if lw.GVK != gvk { + t.Fatalf("unexpected GVK: %v", lw.GVK) + } + if lw.Metrics == nil { + t.Fatalf("expected metrics to be initialized") + } + if lw.Client == nil || lw.Recorder == nil { + t.Fatalf("expected client and recorder to be set") + } +} + +func TestConfigureNamespaceReconciler(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + mov := &fakeManager{client: fake.NewClientBuilder().WithScheme(scheme).Build(), scheme: scheme} + + // empty labels -> no tracker + tr, err := configureNamespaceReconciler(mov, "", "", "lid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr != nil { + t.Fatalf("expected no tracker when opt-in not provided") + } + + // With labels, expect tracker returned + tr2, err := configureNamespaceReconciler(mov, "watch/enabled", "true", "lid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr2 == nil { + t.Fatalf("expected tracker when opt-in provided") + } +} + +func TestParseParameters_FromFlags(t *testing.T) { + // Save global state + oldArgs := os.Args + oldFlags := flag.CommandLine + t.Cleanup(func() { os.Args = oldArgs; flag.CommandLine = oldFlags }) + + // Reset flags and set args as if provided via command line + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + os.Args = []string{"cmd", + "-group=apps", + "-version=v1", + "-kind=ConfigMap", + "-opt-in-label-key=watch/enabled", + "-opt-in-label-value=true", + "-metrics-bind-address=:9090", + "-health-probe-bind-address=:8082", + "-pprof-bind-address=:6061", + "-leader-elect=true", + "-leader-elect-namespace=ldns", + } + + params := parseParameters() + + if params.Group != "apps" || params.Version != "v1" || params.Kind != "ConfigMap" { + t.Fatalf("unexpected gvk: %s/%s/%s", params.Group, params.Version, params.Kind) + } + if params.OptInLabelKey != "watch/enabled" || params.OptInLabelValue != "true" { + t.Fatalf("unexpected opt-in labels: %s=%s", params.OptInLabelKey, params.OptInLabelValue) + } + if params.MetricsBindAddress != ":9090" || params.HealthProbeBindAddress != ":8082" || params.PprofBindAddress != ":6061" { + t.Fatalf("unexpected addresses: %s %s %s", params.MetricsBindAddress, params.HealthProbeBindAddress, params.PprofBindAddress) + } + if params.LeaderElectionEnabled != true || params.LeaderElectionNamespace != "ldns" { + t.Fatalf("unexpected leader election flags: enabled=%v ns=%s", params.LeaderElectionEnabled, params.LeaderElectionNamespace) + } +} + +func TestParseParameters_FromEnv(t *testing.T) { + // Save/restore env + old := map[string]string{ + "LEASE_GVK_GROUP": os.Getenv("LEASE_GVK_GROUP"), + "LEASE_GVK_VERSION": os.Getenv("LEASE_GVK_VERSION"), + "LEASE_GVK_KIND": os.Getenv("LEASE_GVK_KIND"), + "LEASE_OPT_IN_LABEL_KEY": os.Getenv("LEASE_OPT_IN_LABEL_KEY"), + "LEASE_OPT_IN_LABEL_VALUE": os.Getenv("LEASE_OPT_IN_LABEL_VALUE"), + "LEASE_LEADER_ELECTION": os.Getenv("LEASE_LEADER_ELECTION"), + "LEASE_LEADER_ELECTION_NAMESPACE": os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE"), + } + t.Cleanup(func() { + for k, v := range old { + if v == "" { + os.Unsetenv(k) + } else { + os.Setenv(k, v) + } + } + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + os.Args = []string{"cmd"} + }) + + os.Setenv("LEASE_GVK_GROUP", "apps") + os.Setenv("LEASE_GVK_VERSION", "v1") + os.Setenv("LEASE_GVK_KIND", "ConfigMap") + os.Setenv("LEASE_OPT_IN_LABEL_KEY", "watch/enabled") + os.Setenv("LEASE_OPT_IN_LABEL_VALUE", "true") + os.Setenv("LEASE_LEADER_ELECTION", "true") + os.Setenv("LEASE_LEADER_ELECTION_NAMESPACE", "envns") + + // Reset flags and args + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + os.Args = []string{"cmd"} + + params := parseParameters() + + if params.Group != "apps" || params.Version != "v1" || params.Kind != "ConfigMap" { + t.Fatalf("unexpected gvk from env: %s/%s/%s", params.Group, params.Version, params.Kind) + } + if params.OptInLabelKey != "watch/enabled" || params.OptInLabelValue != "true" { + t.Fatalf("unexpected opt-in from env: %s=%s", params.OptInLabelKey, params.OptInLabelValue) + } + if params.LeaderElectionEnabled != true || params.LeaderElectionNamespace != "envns" { + t.Fatalf("unexpected leader from env: %v %s", params.LeaderElectionEnabled, params.LeaderElectionNamespace) + } + + // metrics defaults should be present when not specified via flags + if params.MetricsBindAddress == "" || params.HealthProbeBindAddress == "" || params.PprofBindAddress == "" { + t.Fatalf("expected default addresses to be set, got metrics=%q probe=%q pprof=%q", params.MetricsBindAddress, params.HealthProbeBindAddress, params.PprofBindAddress) + } +} + +func TestParseParameters_LeaderElectionFlagPrecedence(t *testing.T) { + // Save global state + oldArgs := os.Args + oldFlags := flag.CommandLine + t.Cleanup(func() { os.Args = oldArgs; flag.CommandLine = oldFlags }) + + // Set env to false but pass flag that enables leader election + os.Setenv("LEASE_LEADER_ELECTION", "false") + defer os.Unsetenv("LEASE_LEADER_ELECTION") + + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + os.Args = []string{"cmd", "-leader-elect=true"} + + params := parseParameters() + if !params.LeaderElectionEnabled { + t.Fatalf("expected leader election enabled due to flag, got false") + } +} + +func TestParseParameters_LeaderElectionEnvOneValue(t *testing.T) { + // Save global state + oldArgs := os.Args + oldFlags := flag.CommandLine + t.Cleanup(func() { os.Args = oldArgs; flag.CommandLine = oldFlags }) + + // Set env to numeric truthy value + os.Setenv("LEASE_LEADER_ELECTION", "1") + defer os.Unsetenv("LEASE_LEADER_ELECTION") + + // Reset flags with no leader-elect set + flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError) + os.Args = []string{"cmd"} + + params := parseParameters() + if !params.LeaderElectionEnabled { + t.Fatalf("expected leader election enabled due to LEASE_LEADER_ELECTION=1, got false") + } +} diff --git a/pkg/controllers/lease_controller_test.go b/pkg/controllers/lease_controller_test.go index 8a25dfe..cf84b19 100644 --- a/pkg/controllers/lease_controller_test.go +++ b/pkg/controllers/lease_controller_test.go @@ -2,22 +2,37 @@ package controllers import ( "context" + "fmt" "object-lease-controller/pkg/util" "reflect" "strings" "testing" "time" + "net/http" + ometrics "object-lease-controller/pkg/metrics" + + "github.com/go-logr/logr" + "github.com/prometheus/client_golang/prometheus" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" controller_runtime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" + crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" + "sigs.k8s.io/controller-runtime/pkg/webhook" ) // helpers @@ -174,6 +189,14 @@ func TestOnlyWithTTLAnnotation_Update(t *testing.T) { if pred.UpdateFunc(evBad) { t.Errorf("UpdateFunc(wrong types) = true, want false") } + + // TTL added should trigger + old := makeObj(nil) + newWithTTL := makeObj(map[string]string{a.TTL: "2h"}) + evAdd := event.UpdateEvent{ObjectOld: old, ObjectNew: newWithTTL} + if !pred.UpdateFunc(evAdd) { + t.Errorf("UpdateFunc(TTL added) = false, want true") + } } func TestOnlyWithTTLAnnotation_Delete_Generic(t *testing.T) { @@ -188,6 +211,244 @@ func TestOnlyWithTTLAnnotation_Delete_Generic(t *testing.T) { } } +func TestOnlyWithTTLAnnotation_Create_NonUnstructured(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + r, _, _ := newWatcher(t, gvk) + pred := r.onlyWithTTLAnnotation() + + // Use a core type that is not *unstructured.Unstructured + pod := &corev1.Pod{} + ev := event.CreateEvent{Object: pod} + if pred.CreateFunc(ev) { + t.Errorf("CreateFunc(non-Unstructured) = true, want false") + } +} + +func TestGetObject_SetsEmptyAnnotationsWhenNil(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"} + u := &unstructured.Unstructured{} + setMeta(u, gvk, "default", "noanns") + // Start with nil annotations + u.SetAnnotations(nil) + + r, _, _ := newWatcher(t, gvk, u) + obj, err := r.getObject(context.Background(), client.ObjectKey{Namespace: "default", Name: "noanns"}) + if err != nil { + t.Fatalf("getObject error: %v", err) + } + if obj.GetAnnotations() == nil { + t.Fatalf("expect annotations to be non-nil after getObject") + } + if len(obj.GetAnnotations()) != 0 { + t.Fatalf("expected empty map, got %v", obj.GetAnnotations()) + } + + // Also ensure that getObject returns NotFound as client.IgnoreNotFound would + r2, _, _ := newWatcher(t, gvk /* no objects */) + _, err = r2.getObject(context.Background(), client.ObjectKey{Namespace: "none", Name: "missing"}) + if err == nil { + t.Fatalf("expected error for missing object, got nil") + } +} + +func TestCleanupLeaseAnnotations_NoChange(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "clean-me") + obj.SetAnnotations(map[string]string{"foo": "bar"}) + + r, _, _ := newWatcher(t, gvk, obj) + // call cleanup with no TTL present -- should return early without error + r.cleanupLeaseAnnotations(context.Background(), obj) + // ensure unchanged + cur := get(t, r.Client, gvk, "default", "clean-me") + if cur.GetAnnotations()["foo"] != "bar" { + t.Fatalf("unexpected change to annotations: %v", cur.GetAnnotations()) + } +} + +func TestUpdateAnnotations_HandlesNilAnnotations(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "patch") + obj.SetAnnotations(nil) + + r, _, _ := newWatcher(t, gvk, obj) + r.updateAnnotations(context.Background(), obj, map[string]string{"a": "b"}) + got := get(t, r.Client, gvk, "default", "patch") + if got.GetAnnotations()["a"] != "b" { + t.Fatalf("expected updated annotation, got %v", got.GetAnnotations()) + } +} + +// withIsolatedRegistry is copied from metrics_test.go to allow test isolation +func withIsolatedRegistry(t *testing.T) *prometheus.Registry { + t.Helper() + old := crmetrics.Registry + reg := prometheus.NewRegistry() + crmetrics.Registry = reg + t.Cleanup(func() { crmetrics.Registry = old }) + return reg +} + +func TestEnsureLeaseStart_RecordsAndIncrementsMetrics(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + reg := withIsolatedRegistry(t) + + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "es1") + obj.SetAnnotations(map[string]string{}) + + r, _, _ := newWatcher(t, gvk, obj) + r.Metrics = ometrics.NewLeaseMetrics(gvk) + r.Recorder = record.NewFakeRecorder(10) + + now := time.Now().UTC() + _ = r.ensureLeaseStart(context.Background(), obj, now) + + // Ensure the LeasesStarted metric incremented + mfs, err := reg.Gather() + if err != nil { + t.Fatalf("gather failed: %v", err) + } + found := false + for _, mf := range mfs { + if mf.GetName() == "object_lease_controller_leases_started_total" { + if mf.Metric[0].GetCounter().GetValue() <= 0 { + t.Fatalf("expected leases_started_total > 0, got %v", mf.Metric[0].GetCounter().GetValue()) + } + found = true + } + } + if !found { + t.Fatalf("leases_started_total metric not found") + } +} + +func TestMarkInvalidTTL_RecordsAndIncrementsMetrics(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + reg := withIsolatedRegistry(t) + + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "badttl") + obj.SetAnnotations(map[string]string{defaultAnn().TTL: "not-a-ttl"}) + + r, _, _ := newWatcher(t, gvk, obj) + r.Metrics = ometrics.NewLeaseMetrics(gvk) + r.Recorder = record.NewFakeRecorder(10) + + r.markInvalidTTL(context.Background(), obj, fmt.Errorf("boom")) + + mfs, err := reg.Gather() + if err != nil { + t.Fatalf("gather failed: %v", err) + } + for _, mf := range mfs { + if mf.GetName() == "object_lease_controller_invalid_ttl_total" { + if mf.Metric[0].GetCounter().GetValue() <= 0 { + t.Fatalf("invalid_ttl_total should be incremented") + } + return + } + } + t.Fatalf("invalid_ttl_total not found in metrics") +} + +// captureClientErr forces Delete to return an error +type captureClientErr struct { + client.Client + forceErr error +} + +func (c *captureClientErr) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if c.forceErr != nil { + return c.forceErr + } + return c.Client.Delete(ctx, obj, opts...) +} + +func TestHandleExpired_PropagatesDeleteErrorAndIncrementsMetrics(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "will-delete") + obj.SetAnnotations(map[string]string{defaultAnn().TTL: "1s", defaultAnn().LeaseStart: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)}) + + r, cl, _ := newWatcher(t, gvk, obj) + cc := &captureClientErr{Client: cl, forceErr: fmt.Errorf("boom")} + r.Client = cc + r.Metrics = ometrics.NewLeaseMetrics(gvk) + + _, err := r.handleExpired(context.Background(), obj, time.Now().UTC()) + if err == nil { + t.Fatalf("expected error from handleExpired due to delete failure") + } +} + +func TestHandleExpired_RecordsEventAndIncrementsMetrics(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "delete-ok") + obj.SetAnnotations(map[string]string{defaultAnn().TTL: "1s", defaultAnn().LeaseStart: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)}) + + reg := withIsolatedRegistry(t) + + r, _, _ := newWatcher(t, gvk, obj) + r.Metrics = ometrics.NewLeaseMetrics(gvk) + r.Recorder = record.NewFakeRecorder(1) + + _, err := r.handleExpired(context.Background(), obj, time.Now().UTC()) + if err != nil { + t.Fatalf("handleExpired returned error: %v", err) + } + + // event was recorded + select { + case ev := <-r.Recorder.(*record.FakeRecorder).Events: + if !strings.Contains(ev, "LeaseExpired") { + t.Fatalf("unexpected event: %v", ev) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected LeaseExpired event but none found") + } + + // metric incremented + mfs, err := reg.Gather() + if err != nil { + t.Fatalf("gather failed: %v", err) + } + for _, mf := range mfs { + if mf.GetName() == "object_lease_controller_leases_expired_total" { + if mf.Metric[0].GetCounter().GetValue() <= 0 { + t.Fatalf("leases_expired_total should be incremented") + } + return + } + } + t.Fatalf("leases_expired_total not found in metrics") +} + +func TestOnlyWithTTLAnnotation_Update_NilOldOrNew(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + r, _, _ := newWatcher(t, gvk) + pred := r.onlyWithTTLAnnotation() + + // ObjectOld nil + evOldNil := event.UpdateEvent{ObjectOld: nil, ObjectNew: makeObj(map[string]string{"foo": "bar"})} + if pred.UpdateFunc(evOldNil) { + t.Errorf("UpdateFunc(ObjectOld=nil) = true, want false") + } + + // ObjectNew nil + evNewNil := event.UpdateEvent{ObjectOld: makeObj(map[string]string{"foo": "bar"}), ObjectNew: nil} + if pred.UpdateFunc(evNewNil) { + t.Errorf("UpdateFunc(ObjectNew=nil) = true, want false") + } +} + // TTL change updates expire-at func TestReconcile_UpdatesExpireAtWhenTTLChanges(t *testing.T) { ctx := context.Background() @@ -749,3 +1010,183 @@ func TestHandleNamespaceEvents_ProcessesMultipleObjectsInAddedNamespace(t *testi t.Fatalf("object without TTL should be ignored, got expire-at=%q", v) } } + +func TestCleanupLeaseAnnotations_RecordsEvent(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "clean-me-event") + obj.SetAnnotations(map[string]string{"foo": "bar", defaultAnn().ExpireAt: "x", defaultAnn().Status: "y"}) + + r, _, _ := newWatcher(t, gvk, obj) + r.Recorder = record.NewFakeRecorder(1) + // remove TTL to trigger cleanup flow + r.cleanupLeaseAnnotations(context.Background(), obj) + select { + case ev := <-r.Recorder.(*record.FakeRecorder).Events: + if !strings.Contains(ev, "Removed lease annotations") { + t.Fatalf("unexpected event: %v", ev) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected event for cleaned annotations but none found") + } +} + +func TestEnsureLeaseStart_RecordsOnInvalidLeaseStart(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + obj := &unstructured.Unstructured{} + setMeta(obj, gvk, "default", "invalid-start") + obj.SetAnnotations(map[string]string{defaultAnn().TTL: "1h", defaultAnn().LeaseStart: "not-a-time"}) + + r, _, _ := newWatcher(t, gvk, obj) + r.Recorder = record.NewFakeRecorder(1) + now := time.Now().UTC() + _ = r.ensureLeaseStart(context.Background(), obj, now) + select { + case ev := <-r.Recorder.(*record.FakeRecorder).Events: + if !strings.Contains(ev, "LeaseStartReset") { + t.Fatalf("unexpected event: %v", ev) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected LeaseStartReset event but none found") + } +} + +// Use erroringClient type defined in namespace_controller_test.go + +func TestReconcile_MetricsOnError(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + u := &unstructured.Unstructured{} + setMeta(u, gvk, "default", "e1") + u.SetAnnotations(map[string]string{defaultAnn().TTL: "1h"}) + + // base client holds the object listed by handleNamespaceEvents + base := fake.NewClientBuilder().WithScheme(runtime.NewScheme()).WithObjects(u).Build() + // watcher uses erroring client for reconcile + r := &LeaseWatcher{Client: &erroringClient{client: base, err: fmt.Errorf("boom")}, GVK: gvk, Annotations: defaultAnn()} + // metrics enabled + reg := withIsolatedRegistry(t) + r.Metrics = ometrics.NewLeaseMetrics(gvk) + + _, err := r.Reconcile(context.Background(), controller_runtime.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "e1"}}) + if err == nil { + t.Fatalf("expected error from reconcile, got nil") + } + + mfs, err := reg.Gather() + if err != nil { + t.Fatalf("gather failed: %v", err) + } + // reconcile_errors_total should be > 0 + found := false + for _, mf := range mfs { + if mf.GetName() == "object_lease_controller_reconcile_errors_total" { + if mf.Metric[0].GetCounter().GetValue() <= 0 { + t.Fatalf("reconcile_errors_total should be >0") + } + found = true + } + } + if !found { + t.Fatalf("reconcile_errors_total metric missing") + } +} + +func TestHandleNamespaceEvents_LogsOnReconcileError(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + withTTL := &unstructured.Unstructured{} + setMeta(withTTL, gvk, "ns-e", "cm-ttl") + withTTL.SetAnnotations(map[string]string{defaultAnn().TTL: "30m"}) + + // base client has object; handleNamespaceEvents uses manager client for listing + base := fake.NewClientBuilder().WithScheme(runtime.NewScheme()).WithObjects(withTTL).Build() + + // r.Reconcile will use a client that always returns error on Get + r, _, _ := newWatcher(t, gvk, withTTL) + r.Client = &erroringClient{client: base, err: fmt.Errorf("boom")} + + // event channel + r.eventChan = make(chan util.NamespaceChangeEvent, 1) + go r.handleNamespaceEvents(stubMgr{c: base}) + + // send Added event - list should succeed but reconcile should error and be logged + r.eventChan <- util.NamespaceChangeEvent{Namespace: "ns-e", Change: util.NamespaceAdded} + close(r.eventChan) + + // wait a bit for goroutine to execute + time.Sleep(200 * time.Millisecond) +} + +func TestSetupWithManager_InitializesMetricsAndTracker(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"} + + // isolate metrics registry to avoid global conflicts + reg := withIsolatedRegistry(t) + + // create watcher with tracker + tr := util.NewNamespaceTracker() + r := &LeaseWatcher{Client: fake.NewClientBuilder().WithScheme(runtime.NewScheme()).Build(), GVK: gvk, Tracker: tr, Annotations: defaultAnn()} + + // fake manager with scheme + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + mov := &fakeManager{client: r.Client, scheme: scheme} + + // Should not panic or return error + if err := r.SetupWithManager(mov); err != nil { + t.Fatalf("SetupWithManager failed: %v", err) + } + + if r.Metrics == nil { + t.Fatalf("expected metrics to be initialized") + } + if r.eventChan == nil { + t.Fatalf("expected eventChan to be created when tracker present") + } + + // Sanity: confirm the info metric family is registered + mfs, err := reg.Gather() + if err != nil { + t.Fatalf("gather failed: %v", err) + } + found := false + for _, mf := range mfs { + if mf.GetName() == "object_lease_controller_info" { + found = true + } + } + if !found { + t.Fatalf("expected info metric to be registered") + } +} + +// A minimal fake manager implementing manager.Manager with no-op methods for testing SetupWithManager +type fakeManager struct { + client client.Client + scheme *runtime.Scheme +} + +func (f *fakeManager) GetClient() client.Client { return f.client } +func (f *fakeManager) GetScheme() *runtime.Scheme { return f.scheme } +func (f *fakeManager) GetConfig() *rest.Config { return &rest.Config{} } +func (f *fakeManager) GetHTTPClient() *http.Client { return &http.Client{} } +func (f *fakeManager) GetCache() cache.Cache { return nil } +func (f *fakeManager) GetFieldIndexer() client.FieldIndexer { return nil } +func (f *fakeManager) GetEventRecorderFor(name string) record.EventRecorder { + return record.NewFakeRecorder(10) +} +func (f *fakeManager) GetRESTMapper() meta.RESTMapper { + return meta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "", Version: "v1"}}) +} +func (f *fakeManager) GetAPIReader() client.Reader { return f.client } +func (f *fakeManager) Start(ctx context.Context) error { return nil } + +func (f *fakeManager) Add(r manager.Runnable) error { return nil } +func (f *fakeManager) Elected() <-chan struct{} { return make(chan struct{}) } +func (f *fakeManager) AddMetricsServerExtraHandler(path string, handler http.Handler) error { + return nil +} +func (f *fakeManager) AddHealthzCheck(name string, check healthz.Checker) error { return nil } +func (f *fakeManager) AddReadyzCheck(name string, check healthz.Checker) error { return nil } +func (f *fakeManager) GetWebhookServer() webhook.Server { return nil } +func (f *fakeManager) GetLogger() logr.Logger { return logr.Discard() } +func (f *fakeManager) GetControllerOptions() config.Controller { return config.Controller{} } diff --git a/pkg/controllers/namespace_controller_test.go b/pkg/controllers/namespace_controller_test.go index 10e5dfd..d576110 100644 --- a/pkg/controllers/namespace_controller_test.go +++ b/pkg/controllers/namespace_controller_test.go @@ -211,3 +211,20 @@ func TestReconcile_ClientErrorBubblesUp(t *testing.T) { t.Fatalf("expected tracker to still contain namespace ns on error") } } + +func TestSetupWithManager_DoesNotError(t *testing.T) { + scheme := newScheme(t) + cl := fake.NewClientBuilder().WithScheme(scheme).Build() + + r := &NamespaceReconciler{ + Client: cl, + LabelKey: "watch/enabled", + LabelValue: "true", + Tracker: util.NewNamespaceTracker(), + } + + mov := &fakeManager{client: r.Client, scheme: scheme} + if err := r.SetupWithManager(mov); err != nil { + t.Fatalf("SetupWithManager failed: %v", err) + } +} diff --git a/pkg/util/duration_test.go b/pkg/util/duration_test.go index 0cd61a1..3f62c60 100644 --- a/pkg/util/duration_test.go +++ b/pkg/util/duration_test.go @@ -1,6 +1,7 @@ package util import ( + "strings" "testing" "time" ) @@ -71,3 +72,18 @@ func TestParseFlexibleDuration(t *testing.T) { }) } } + +func TestParseFlexibleDuration_RangeErrors(t *testing.T) { + t.Parallel() + + // Extremely large number should cause ParseDuration to fail with range error. + big := strings.Repeat("9", 400) + if _, err := ParseFlexibleDuration(big + "h"); err == nil { + t.Fatalf("expected range error for extremely large duration number using time.ParseDuration") + } + + // For custom units that use strconv.ParseFloat downstream, similarly expect an error + if _, err := ParseFlexibleDuration(big + "d"); err == nil { + t.Fatalf("expected range error for extremely large duration number using custom parse path") + } +} diff --git a/pkg/util/object_filter_test.go b/pkg/util/object_filter_test.go new file mode 100644 index 0000000..d1a112e --- /dev/null +++ b/pkg/util/object_filter_test.go @@ -0,0 +1,174 @@ +package util + +import ( + "testing" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestMinimalObjectTransform_UnstructuredPreservesExpectedFields(t *testing.T) { + t.Parallel() + + u := &unstructured.Unstructured{} + u.SetAPIVersion("apps/v1") + u.SetKind("Deployment") + u.SetName("nginx") + u.SetNamespace("default") + u.SetUID("abc123") + u.SetAnnotations(map[string]string{"keep": "yes", "drop": "no"}) + + tf := MinimalObjectTransform("keep") + + res, err := tf(u) + if err != nil { + t.Fatalf("transform error: %v", err) + } + + out, ok := res.(*unstructured.Unstructured) + if !ok { + t.Fatalf("expected *unstructured.Unstructured, got %T", res) + } + + // Fields should be preserved + if out.GetName() != "nginx" || out.GetNamespace() != "default" || out.GetAPIVersion() != "apps/v1" { + t.Fatalf("basic metadata not preserved: %s/%s %s", out.GetNamespace(), out.GetName(), out.GetAPIVersion()) + } + + if _, ok := out.GetAnnotations()["keep"]; !ok { + t.Fatalf("expected 'keep' annotation to be preserved") + } + if _, ok := out.GetAnnotations()["drop"]; ok { + t.Fatalf("expected 'drop' annotation to be removed") + } +} + +func TestMinimalObjectTransform_UnstructuredList(t *testing.T) { + t.Parallel() + + l := &unstructured.UnstructuredList{} + u1 := unstructured.Unstructured{} + u1.SetAPIVersion("v1") + u1.SetKind("ConfigMap") + u1.SetName("one") + u1.SetNamespace("ns") + u1.SetUID("u1") + u1.SetAnnotations(map[string]string{"keep": "x", "drop": "y"}) + + u2 := unstructured.Unstructured{} + u2.SetAPIVersion("v1") + u2.SetKind("ConfigMap") + u2.SetName("two") + u2.SetNamespace("ns") + u2.SetUID("u2") + u2.SetAnnotations(map[string]string{"keep": "x", "drop": "y"}) + + l.Items = append(l.Items, u1, u2) + + tf := MinimalObjectTransform("keep") + res, err := tf(l) + if err != nil { + t.Fatalf("transform list error: %v", err) + } + + out, ok := res.(*unstructured.UnstructuredList) + if !ok { + t.Fatalf("expected *unstructured.UnstructuredList, got %T", res) + } + if len(out.Items) != 2 { + t.Fatalf("expected 2 items after transform, got %d", len(out.Items)) + } + for _, item := range out.Items { + if item.GetAnnotations()["keep"] != "x" { + t.Fatalf("expected keep annotation preserved for item %s", item.GetName()) + } + if _, ok := item.GetAnnotations()["drop"]; ok { + t.Fatalf("expected drop annotation removed for item %s", item.GetName()) + } + } +} + +func TestMinimalObjectTransform_OtherTypes(t *testing.T) { + t.Parallel() + + tf := MinimalObjectTransform("keep") + // When passing an unsupported type, it should be returned unchanged + s := "hello" + res, err := tf(s) + if err != nil { + t.Fatalf("expected no error for non-k8s type, got %v", err) + } + if res != s { + t.Fatalf("expected identity for non-k8s type, got %v", res) + } +} + +func TestStripU_PreservesDeletionTimestamp(t *testing.T) { + t.Parallel() + + now := time.Now().UTC().Truncate(time.Second) + u := &unstructured.Unstructured{} + u.SetName("to-delete") + u.SetNamespace("ns") + // set deletion timestamp to simulate graceful deletion + u.SetDeletionTimestamp(&v1.Time{Time: now}) + out := stripU(u, map[string]struct{}{}) + if out.GetDeletionTimestamp() == nil { + t.Fatalf("expected deletion timestamp to be preserved") + } + if !out.GetDeletionTimestamp().Time.Equal(now) { + t.Fatalf("deletion timestamp mismatch: got %v want %v", out.GetDeletionTimestamp(), now) + } +} + +func TestStripU_AnnotationFilteringMultipleAndDuplicate(t *testing.T) { + t.Parallel() + + u := &unstructured.Unstructured{} + u.SetName("multi") + u.SetNamespace("ns") + u.SetAnnotations(map[string]string{"one": "1", "two": "2", "three": "3"}) + + // Build keep map with duplicates + keys := []string{"one", "one", "two"} + keep := make(map[string]struct{}) + for _, k := range keys { + keep[k] = struct{}{} + } + + out := stripU(u, keep) + anns := out.GetAnnotations() + if len(anns) != 2 { + t.Fatalf("expected 2 annotations after filtering, got %d: %+v", len(anns), anns) + } + if anns["one"] != "1" || anns["two"] != "2" { + t.Fatalf("annotations filtered incorrectly, got %+v", anns) + } + if _, ok := anns["three"]; ok { + t.Fatalf("expected 'three' to be removed from annotations") + } +} + +func TestMinimalObjectTransform_StripsManagedFields(t *testing.T) { + t.Parallel() + + u := &unstructured.Unstructured{} + u.SetAPIVersion("v1") + u.SetKind("ConfigMap") + u.SetName("mf") + u.SetNamespace("ns") + // add a managed fields entry + u.SetManagedFields([]v1.ManagedFieldsEntry{{Manager: "kube-apiserver"}}) + u.SetAnnotations(map[string]string{"keep": "yes"}) + + tf := MinimalObjectTransform("keep") + res, err := tf(u) + if err != nil { + t.Fatalf("transform error: %v", err) + } + out := res.(*unstructured.Unstructured) + if len(out.GetManagedFields()) != 0 { + t.Fatalf("expected managed fields to be stripped, got %v", out.GetManagedFields()) + } +} diff --git a/pkg/util/tracker_test.go b/pkg/util/tracker_test.go new file mode 100644 index 0000000..a5deaf3 --- /dev/null +++ b/pkg/util/tracker_test.go @@ -0,0 +1,89 @@ +package util + +import ( + "testing" + "time" +) + +func TestNamespaceTracker_BasicOperations(t *testing.T) { + t.Parallel() + + tr := NewNamespaceTracker() + + // Initially empty + if len(tr.ListNamespaces()) != 0 { + t.Fatalf("expected empty namespace list, got %v", tr.ListNamespaces()) + } + + // Add and check TrackingNamespace + tr.AddNamespace("ns1") + if !tr.TrackingNamespace("ns1") { + t.Fatalf("expected ns1 to be tracked") + } + + // Add duplicate should be idempotent + tr.AddNamespace("ns1") + list := tr.ListNamespaces() + if len(list) != 1 { + t.Fatalf("duplicate namespaces resulted in %d items, want 1", len(list)) + } + + // Remove and check + tr.RemoveNamespace("ns1") + if tr.TrackingNamespace("ns1") { + t.Fatalf("expected ns1 to not be tracked after removal") + } +} + +func TestNamespaceTracker_Listeners(t *testing.T) { + t.Parallel() + + tr := NewNamespaceTracker() + + // Buffered channel to receive events + ch := make(chan NamespaceChangeEvent, 10) + tr.RegisterListener(ch) + + tr.AddNamespace("alpha") + select { + case ev := <-ch: + if ev.Namespace != "alpha" || ev.Change != NamespaceAdded { + t.Fatalf("unexpected event: %+v", ev) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected event for add to be sent to listener") + } + + tr.RemoveNamespace("alpha") + select { + case ev := <-ch: + if ev.Namespace != "alpha" || ev.Change != NamespaceRemoved { + t.Fatalf("unexpected event: %+v", ev) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected event for remove to be sent to listener") + } +} + +func TestNamespaceTracker_NonBlockingOnFullListener(t *testing.T) { + t.Parallel() + + tr := NewNamespaceTracker() + + // Unbuffered channel ensures the listener is not ready - send attempt would block + ch := make(chan NamespaceChangeEvent) + tr.RegisterListener(ch) + + done := make(chan struct{}) + go func() { + tr.AddNamespace("blocked") + close(done) + }() + + select { + case <-done: + // success - AddNamespace did not block even though listener was not ready + case <-time.After(200 * time.Millisecond): + t.Fatalf("AddNamespace blocked while notifying a full/unready listener") + } +}