Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,29 @@ jobs:
go mod tidy
go vet ./...
make test
gotestsum --junitfile=tmp/test-results/junit.xml --format=standard-verbose -- -coverprofile=coverage.out -covermode=atomic ./...

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.txt
path: coverage.out

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: junit.xml
path: tmp/test-results/junit.xml
path: tmp/test-results/junit.xml

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ go.work
*.swp
*.swo
*~

tmp/**
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
IMG ?= quay.io/ullbergm/object-lease-controller:latest

run: build
./bin/lease-controller -group startpunkt.ullberg.us -kind Application -version v1alpha2 -leader-elect -leader-elect-namespace default
./bin/lease-controller -group startpunkt.ullberg.us -kind Application -version v1alpha2 -leader-elect -leader-elect-namespace default -opt-in-label-key "object-lease-controller.ullberg.us/enabled" -opt-in-label-value true

tidy:
go mod tidy
Expand All @@ -13,7 +13,7 @@ vet:
go vet ./...

test: tidy fmt vet
go test ./... -timeout 30s
go test ./... -race -coverprofile=coverage.out

build: tidy fmt vet test
go build -o bin/lease-controller ./cmd/main.go
Expand Down
48 changes: 35 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,41 @@ You can specify the time in hours, minutes, days, weeks, etc.
| `3h` | 3 hours |
| `10s` | 10 seconds |

#### object-lease-controller.ullberg.us/extended-at
### object-lease-controller.ullberg.us/lease-start

RFC3339 UTC timestamp. Single source of truth for when the lease started.

Controller behavior:

* If `ttl` exists and `lease-start` is missing or invalid, the controller sets `lease-start` to now.
* To extend a lease, delete `lease-start`. The controller sets it to now on the next reconcile.
* You can set `lease-start` explicitly to backdate or align with an external clock.

Examples:

If you set this value, the lease is calculated from this time rather from the creation time.
```bash
kubectl annotate pod test object-lease-controller.ullberg.us/extended-at=2026-06-11T20:48:11Z
# Extend now by resetting the start
kubectl annotate pod test object-lease-controller.ullberg.us/lease-start- --overwrite

# Set a specific start time
kubectl annotate pod test object-lease-controller.ullberg.us/lease-start=2025-01-01T12:00:00Z --overwrite
```

#### object-lease-controller.ullberg.us/expire-at
### object-lease-controller.ullberg.us/expire-at

This annotation is updated by the controller to show when the object will expire. This is meant to be used by systems that display information about objects in the environment.
Set by the controller. RFC3339 UTC timestamp for when the object will expire. Safe for dashboards to read.

#### object-lease-controller.ullberg.us/lease-status
### object-lease-controller.ullberg.us/lease-status

This annotation is updated by the controller to indicate status or issues with the annotations. This is meant to be human readable information.
Set by the controller. Human readable status or validation errors.

### Removing TTL

Remove `ttl` to stop lease management. The controller clears `lease-start`, `expire-at`, and `lease-status`.

```bash
kubectl annotate pod test object-lease-controller.ullberg.us/ttl-
```

## Example Use Cases
- Automatically manage leases for custom resources (e.g., Applications, Databases, Services)
Expand All @@ -112,10 +133,11 @@ cd object-lease-operator
make run
```

## Optimizations / Features
## Behavior summary

Here are some design decisions and optimizations:
- Controller instances are only managing a single GVK, providing separation of duty and scaling.
- Controllers use dedicated ServiceAccounts with permissions limited to the GVK they are managing.
- Reconcile loop knows when the object is going to expire and will not read the status from the object until it has expired or it has been updated.
- TTL can be added after the object has been created, in that case the expiration is based on the time the TTL annotation was added, rather than the object creation time.
* Add `ttl` to start management. Controller sets `lease-start` if missing.
* Delete `lease-start` to extend from now.
* Optionally set `lease-start` to a specific RFC3339 UTC time.
* Delete `ttl` to stop management. Controller removes lease annotations.
* Reconcile filters only react to changes in `ttl` and `lease-start`.
* The controller computes `expire-at` from `lease-start + ttl` and requeues until expiry.
59 changes: 54 additions & 5 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"expvar"
"flag"
"fmt"
"net/http"
Expand All @@ -16,7 +17,8 @@ import (

metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

"object-lease-controller/pkg/leasewatcher"
controllers "object-lease-controller/pkg/controllers"
"object-lease-controller/pkg/util"
)

var (
Expand All @@ -27,14 +29,19 @@ func main() {
ctrl.SetLogger(zap.New())

var group, version, kind string
var optInLabelKey, optInLabelValue string
flag.StringVar(&group, "group", "", "Kubernetes API group (e.g., \"apps\")")
flag.StringVar(&version, "version", "", "Kubernetes API version (e.g., \"v1\")")
flag.StringVar(&kind, "kind", "", "Kubernetes Kind (e.g., \"ConfigMap\")")

var metricsAddr, probeAddr string
flag.StringVar(&optInLabelKey, "opt-in-label-key", "", "The label key to opt-in namespaces")
flag.StringVar(&optInLabelValue, "opt-in-label-value", "", "The label value to opt-in namespaces")

var metricsAddr, probeAddr, pprofAddr string
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.StringVar(&pprofAddr, "pprof-bind-address", ":6060", "pprof address")

var enableLeaderElection bool
var leaderElectionNamespace string
Expand All @@ -57,6 +64,13 @@ func main() {
if kind == "" {
kind = os.Getenv("LEASE_GVK_KIND")
}
if optInLabelKey == "" {
optInLabelKey = os.Getenv("LEASE_OPT_IN_LABEL_KEY")
}
if optInLabelValue == "" {
optInLabelValue = os.Getenv("LEASE_OPT_IN_LABEL_VALUE")
}

if !enableLeaderElection {
if val := os.Getenv("LEASE_LEADER_ELECTION"); val != "" {
var err error
Expand Down Expand Up @@ -107,33 +121,68 @@ func main() {
BindAddress: metricsAddr,
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
mgrOpts := ctrl.Options{
Scheme: scheme,
LeaderElection: enableLeaderElection,
LeaderElectionID: leaderElectionID,
LeaderElectionNamespace: leaderElectionNamespace,
LeaderElectionReleaseOnCancel: true,
Metrics: metricsServerOptions,
HealthProbeBindAddress: probeAddr,
})
}

if pprofAddr != "" {
mgrOpts.PprofBindAddress = pprofAddr
}

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 := &leasewatcher.LeaseWatcher{
lw := &controllers.LeaseWatcher{
Client: mgr.GetClient(),
GVK: gvk,
Recorder: mgr.GetEventRecorderFor(leaderElectionID),
}

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)
}
}

// Health check: verify we can talk to the Kubernetes API
healthCheck := func(req *http.Request) error {
ctx := req.Context()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.24.0
toolchain go1.24.5

require (
github.com/go-logr/logr v1.4.2
k8s.io/api v0.33.0
k8s.io/apimachinery v0.33.0
k8s.io/client-go v0.33.0
Expand All @@ -19,7 +20,6 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
Expand Down
Loading
Loading