Skip to content

Commit ec7e6d9

Browse files
authored
Increase test coverage and refactor parseParameters to ParseParams struct (#55)
* feat(test): Add more test coverage Signed-off-by: Magnus Ullberg <magnus@ullberg.us>
1 parent b7a01c7 commit ec7e6d9

9 files changed

Lines changed: 1342 additions & 101 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Dockerfile.cross
1313
# Output of the go coverage tool, specifically when used with LiteIDE
1414
*.out
1515
coverage.txt
16+
coverage.html
1617

1718
# Go workspace file
1819
go.work

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ vet: ## Vet Go code
3939
.PHONY: test
4040
test: tidy fmt vet ## Run tests with coverage
4141
go test ./... -race -coverprofile=coverage.out
42+
go tool cover -html=coverage.out -o=coverage.html
4243

4344
.PHONY: build
4445
build: tidy fmt vet test ## Build the binary

cmd/main.go

Lines changed: 179 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,121 @@ const (
3434
AnnStatus = "object-lease-controller.ullberg.io/lease-status"
3535
)
3636

37+
// ParseParams holds runtime configuration parsed from flags and environment.
38+
type ParseParams struct {
39+
Group string
40+
Version string
41+
Kind string
42+
OptInLabelKey string
43+
OptInLabelValue string
44+
MetricsBindAddress string
45+
HealthProbeBindAddress string
46+
PprofBindAddress string
47+
LeaderElectionEnabled bool
48+
LeaderElectionNamespace string
49+
}
50+
3751
var (
3852
setupLog = ctrl.Log.WithName("setup")
3953
)
4054

55+
// Allow injection for testing
56+
var statFn = os.Stat
57+
var readFileFn = os.ReadFile
58+
4159
func main() {
4260
ctrl.SetLogger(zap.New())
4361

62+
params := parseParameters()
63+
64+
enableLeaderElection, leaderElectionNamespace, errE := parseLeaderElectionConfig(params.LeaderElectionEnabled, params.LeaderElectionNamespace)
65+
if errE != nil {
66+
fmt.Printf("%v\n", errE)
67+
os.Exit(1)
68+
}
69+
70+
if params.Version == "" || params.Kind == "" {
71+
fmt.Println("Usage: lease-controller -group=GROUP -version=VERSION -kind=KIND [--leader-elect] [--leader-elect-namespace=NAMESPACE]")
72+
fmt.Println("Or set LEASE_GVK_GROUP, LEASE_GVK_VERSION, LEASE_GVK_KIND, LEASE_LEADER_ELECTION env vars")
73+
os.Exit(1)
74+
}
75+
76+
scheme := runtime.NewScheme()
77+
_ = corev1.AddToScheme(scheme)
78+
79+
gvk := schema.GroupVersionKind{
80+
Group: params.Group,
81+
Version: params.Version,
82+
Kind: params.Kind,
83+
}
84+
85+
// Use a unique leader election ID per GVK in lower case
86+
leaderElectionID := strings.ToLower(fmt.Sprintf("object-lease-controller-%s-%s-%s", params.Group, params.Version, params.Kind))
87+
88+
mgrOpts := buildManagerOptions(scheme, params.Group, params.Version, params.Kind, params.MetricsBindAddress, params.HealthProbeBindAddress, params.PprofBindAddress, enableLeaderElection, leaderElectionNamespace)
89+
90+
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts)
91+
if err != nil {
92+
setupLog.Error(err, "unable to start manager")
93+
panic(err)
94+
}
95+
96+
// Create a LeaseWatcher for the specified GVK
97+
lw := newLeaseWatcher(mgr, gvk, leaderElectionID)
98+
99+
if tr, err := configureNamespaceReconciler(mgr, params.OptInLabelKey, params.OptInLabelValue, leaderElectionID); err != nil {
100+
setupLog.Error(err, "unable to create controller", "GVK", gvk)
101+
panic(err)
102+
} else {
103+
lw.Tracker = tr
104+
}
105+
106+
// Register the LeaseWatcher with the manager
107+
if err := lw.SetupWithManager(mgr); err != nil {
108+
setupLog.Error(err, "unable to create controller", "GVK", gvk)
109+
panic(err)
110+
}
111+
112+
// Add metrics server expvar handler
113+
if params.MetricsBindAddress != "" {
114+
setupLog.Info("Adding /debug/vars to metrics", "address", params.MetricsBindAddress)
115+
if err := mgr.AddMetricsServerExtraHandler("/debug/vars", expvar.Handler()); err != nil {
116+
setupLog.Error(err, "unable to set up metrics server extra handler")
117+
os.Exit(1)
118+
}
119+
}
120+
121+
healthCheck := func(req *http.Request) error {
122+
return healthCheck(req, mgr, gvk)
123+
}
124+
125+
if err := mgr.AddHealthzCheck("gvk", healthCheck); err != nil {
126+
setupLog.Error(err, "unable to set up health check")
127+
os.Exit(1)
128+
}
129+
130+
// Ready check: verify manager cache is synced
131+
readyCheck := func(req *http.Request) error {
132+
if !mgr.GetCache().WaitForCacheSync(req.Context()) {
133+
return fmt.Errorf("cache not synced")
134+
}
135+
return nil
136+
}
137+
if err := mgr.AddReadyzCheck("readyz", readyCheck); err != nil {
138+
setupLog.Error(err, "unable to set up ready check")
139+
os.Exit(1)
140+
}
141+
142+
setupLog.Info("Starting manager", "group", params.Group, "version", params.Version, "kind", params.Kind, "leaderElectionID", leaderElectionID)
143+
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
144+
setupLog.Error(err, "problem running manager")
145+
panic(err)
146+
}
147+
}
148+
149+
// parseParameters returns a ParseParams struct instead of a tuple to make it
150+
// easier to extend and pass around in tests.
151+
func parseParameters() ParseParams {
44152
var group, version, kind string
45153
var optInLabelKey, optInLabelValue string
46154
flag.StringVar(&group, "group", "", "Kubernetes API group (e.g., \"apps\")")
@@ -84,56 +192,71 @@ func main() {
84192
optInLabelValue = os.Getenv("LEASE_OPT_IN_LABEL_VALUE")
85193
}
86194

195+
// Leader election may be enabled via env var when not set via flags
196+
if !enableLeaderElection {
197+
if ele := os.Getenv("LEASE_LEADER_ELECTION"); ele != "" {
198+
if strings.EqualFold(ele, "true") || ele == "1" {
199+
enableLeaderElection = true
200+
}
201+
}
202+
}
203+
204+
// If no leader election namespace was provided via flags, allow env fallback.
205+
if leaderElectionNamespace == "" {
206+
leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE")
207+
}
208+
209+
return ParseParams{
210+
Group: group,
211+
Version: version,
212+
Kind: kind,
213+
OptInLabelKey: optInLabelKey,
214+
OptInLabelValue: optInLabelValue,
215+
MetricsBindAddress: metricsAddr,
216+
HealthProbeBindAddress: probeAddr,
217+
PprofBindAddress: pprofAddr,
218+
LeaderElectionEnabled: enableLeaderElection,
219+
LeaderElectionNamespace: leaderElectionNamespace,
220+
}
221+
}
222+
223+
// Parse leader election configuration from flags and environment.
224+
// Returns (enabled, namespace, error)
225+
func parseLeaderElectionConfig(enableLeaderElection bool, leaderElectionNamespace string) (bool, string, error) {
87226
if !enableLeaderElection {
88227
if val := os.Getenv("LEASE_LEADER_ELECTION"); val != "" {
89228
var err error
90229
enableLeaderElection, err = strconv.ParseBool(val)
91230
if err != nil {
92-
fmt.Printf("Invalid LEASE_LEADER_ELECTION value: %v\n", err)
93-
os.Exit(1)
231+
return false, "", fmt.Errorf("invalid LEASE_LEADER_ELECTION value: %v", err)
94232
}
95233

96-
// If leader election is enabled, check for namespace and fail if not set and not running in a cluster
97234
if enableLeaderElection && leaderElectionNamespace == "" {
98235
leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE")
99236
}
100-
if leaderElectionNamespace == "" {
101-
// If running outside a cluster, we need a namespace for leader election
102-
if _, err := os.Stat("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); os.IsNotExist(err) {
103-
fmt.Println("Leader election enabled but LEASE_LEADER_ELECTION_NAMESPACE is not set. Please set it to a valid namespace.")
104-
os.Exit(1)
105-
} else {
106-
// Default to the namespace file if running in a cluster
107-
data, _ := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
237+
238+
if enableLeaderElection && leaderElectionNamespace == "" {
239+
if _, err := statFn("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); os.IsNotExist(err) {
240+
return false, "", fmt.Errorf("leader election enabled but LEASE_LEADER_ELECTION_NAMESPACE is not set; set it to a valid namespace")
241+
}
242+
// we're in a cluster; default to serviceaccount namespace
243+
if data, err := readFileFn("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
108244
leaderElectionNamespace = strings.TrimSpace(string(data))
109245
}
110246
}
111247
}
112248
}
113249

114-
if version == "" || kind == "" {
115-
fmt.Println("Usage: lease-controller -group=GROUP -version=VERSION -kind=KIND [--leader-elect] [--leader-elect-namespace=NAMESPACE]")
116-
fmt.Println("Or set LEASE_GVK_GROUP, LEASE_GVK_VERSION, LEASE_GVK_KIND, LEASE_LEADER_ELECTION env vars")
117-
os.Exit(1)
118-
}
119-
120-
scheme := runtime.NewScheme()
121-
_ = corev1.AddToScheme(scheme)
122-
123-
gvk := schema.GroupVersionKind{
124-
Group: group,
125-
Version: version,
126-
Kind: kind,
250+
if leaderElectionNamespace == "" {
251+
leaderElectionNamespace = os.Getenv("LEASE_LEADER_ELECTION_NAMESPACE")
127252
}
253+
return enableLeaderElection, leaderElectionNamespace, nil
254+
}
128255

129-
// Use a unique leader election ID per GVK in lower case
256+
// Build manager options for a given GVK and flags; extracted for unit testing
257+
func buildManagerOptions(scheme *runtime.Scheme, group, version, kind string, metricsAddr, probeAddr, pprofAddr string, enableLeaderElection bool, leaderElectionNamespace string) ctrl.Options {
130258
leaderElectionID := strings.ToLower(fmt.Sprintf("object-lease-controller-%s-%s-%s", group, version, kind))
131-
132-
// Set up metrics server options
133-
metricsServerOptions := metricsserver.Options{
134-
BindAddress: metricsAddr,
135-
}
136-
259+
metricsServerOptions := metricsserver.Options{BindAddress: metricsAddr}
137260
mgrOpts := ctrl.Options{
138261
Scheme: scheme,
139262
LeaderElection: enableLeaderElection,
@@ -143,24 +266,20 @@ func main() {
143266
Metrics: metricsServerOptions,
144267
HealthProbeBindAddress: probeAddr,
145268
Cache: cache.Options{
146-
DefaultTransform: util.MinimalObjectTransform(
147-
AnnTTL, AnnLeaseStart, AnnExpireAt, AnnStatus,
148-
),
269+
DefaultTransform: util.MinimalObjectTransform(AnnTTL, AnnLeaseStart, AnnExpireAt, AnnStatus),
149270
},
150271
}
151-
152272
if pprofAddr != "" {
153273
mgrOpts.PprofBindAddress = pprofAddr
154274
}
275+
return mgrOpts
276+
}
155277

156-
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOpts)
157-
if err != nil {
158-
setupLog.Error(err, "unable to start manager")
159-
panic(err)
160-
}
161-
162-
// Create a LeaseWatcher for the specified GVK
163-
lw := &controllers.LeaseWatcher{
278+
// Create a LeaseWatcher attached to the given manager. The LeaseWatcher is initialized
279+
// with default annotations and metrics for the provided GVK. The function does not
280+
// call SetupWithManager - this is left to the caller.
281+
func newLeaseWatcher(mgr ctrl.Manager, gvk schema.GroupVersionKind, leaderElectionID string) *controllers.LeaseWatcher {
282+
return &controllers.LeaseWatcher{
164283
Client: mgr.GetClient(),
165284
GVK: gvk,
166285
Recorder: mgr.GetEventRecorderFor(leaderElectionID),
@@ -172,68 +291,27 @@ func main() {
172291
},
173292
Metrics: ometrics.NewLeaseMetrics(gvk),
174293
}
294+
}
175295

176-
if optInLabelKey != "" && optInLabelValue != "" {
177-
tracker := util.NewNamespaceTracker()
178-
179-
nw := &controllers.NamespaceReconciler{
180-
Client: mgr.GetClient(),
181-
Recorder: mgr.GetEventRecorderFor(leaderElectionID),
182-
LabelKey: optInLabelKey,
183-
LabelValue: optInLabelValue,
184-
Tracker: tracker,
185-
}
186-
187-
// Register NamespaceReconciler with the manager
188-
if err := nw.SetupWithManager(mgr); err != nil {
189-
setupLog.Error(err, "unable to create controller", "GVK", gvk)
190-
panic(err)
191-
}
192-
193-
lw.Tracker = tracker
194-
}
195-
196-
// Register the LeaseWatcher with the manager
197-
if err := lw.SetupWithManager(mgr); err != nil {
198-
setupLog.Error(err, "unable to create controller", "GVK", gvk)
199-
panic(err)
200-
}
201-
202-
// Add metrics server expvar handler
203-
if metricsAddr != "" {
204-
setupLog.Info("Adding /debug/vars to metrics", "address", metricsAddr)
205-
if err := mgr.AddMetricsServerExtraHandler("/debug/vars", expvar.Handler()); err != nil {
206-
setupLog.Error(err, "unable to set up metrics server extra handler")
207-
os.Exit(1)
208-
}
209-
}
210-
211-
healthCheck := func(req *http.Request) error {
212-
return healthCheck(req, mgr, gvk)
213-
}
214-
215-
if err := mgr.AddHealthzCheck("gvk", healthCheck); err != nil {
216-
setupLog.Error(err, "unable to set up health check")
217-
os.Exit(1)
296+
// If optInLabelKey and optInLabelValue are provided, create a NamespaceReconciler and
297+
// register it with the manager. Returns the NamespaceTracker that was created if any,
298+
// or nil if opt-in was not requested.
299+
func configureNamespaceReconciler(mgr ctrl.Manager, optInLabelKey, optInLabelValue, leaderElectionID string) (*util.NamespaceTracker, error) {
300+
if optInLabelKey == "" || optInLabelValue == "" {
301+
return nil, nil
218302
}
219-
220-
// Ready check: verify manager cache is synced
221-
readyCheck := func(req *http.Request) error {
222-
if !mgr.GetCache().WaitForCacheSync(req.Context()) {
223-
return fmt.Errorf("cache not synced")
224-
}
225-
return nil
226-
}
227-
if err := mgr.AddReadyzCheck("readyz", readyCheck); err != nil {
228-
setupLog.Error(err, "unable to set up ready check")
229-
os.Exit(1)
303+
tracker := util.NewNamespaceTracker()
304+
nw := &controllers.NamespaceReconciler{
305+
Client: mgr.GetClient(),
306+
Recorder: mgr.GetEventRecorderFor(leaderElectionID),
307+
LabelKey: optInLabelKey,
308+
LabelValue: optInLabelValue,
309+
Tracker: tracker,
230310
}
231-
232-
setupLog.Info("Starting manager", "group", group, "version", version, "kind", kind, "leaderElectionID", leaderElectionID)
233-
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
234-
setupLog.Error(err, "problem running manager")
235-
panic(err)
311+
if err := nw.SetupWithManager(mgr); err != nil {
312+
return nil, err
236313
}
314+
return tracker, nil
237315
}
238316

239317
// Health check: confirm GVK is discoverable and listable with minimal load

0 commit comments

Comments
 (0)