Skip to content

Commit 61c5d3e

Browse files
authored
Merge pull request #2 from fabiante/feat/configurable-max-age
Add support to configure controller via ConfigMap object `podbouncer-system/podbouncer-config`
2 parents 5c889e0 + 8589486 commit 61c5d3e

10 files changed

+265
-12
lines changed

PROJECT

+4
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,8 @@ resources:
1212
domain: fabitee.de
1313
kind: Pod
1414
version: v1alpha1
15+
- controller: true
16+
domain: fabitee.de
17+
kind: ConfigMap
18+
version: v1alpha1
1519
version: "3"

cmd/main.go

+11
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,24 @@ func main() {
140140
os.Exit(1)
141141
}
142142

143+
podReconcilerConfig := controller.NewPodReconcilerConfig()
144+
143145
if err = (&controller.PodReconciler{
144146
Client: mgr.GetClient(),
145147
Scheme: mgr.GetScheme(),
148+
Config: podReconcilerConfig,
146149
}).SetupWithManager(mgr); err != nil {
147150
setupLog.Error(err, "unable to create controller", "controller", "Pod")
148151
os.Exit(1)
149152
}
153+
if err = (&controller.ConfigMapReconciler{
154+
Client: mgr.GetClient(),
155+
Scheme: mgr.GetScheme(),
156+
Config: podReconcilerConfig,
157+
}).SetupWithManager(mgr); err != nil {
158+
setupLog.Error(err, "unable to create controller", "controller", "ConfigMap")
159+
os.Exit(1)
160+
}
150161
// +kubebuilder:scaffold:builder
151162

152163
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {

config/default/kustomization.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ resources:
3232
# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will
3333
# be able to communicate with the Webhook Server.
3434
#- ../network-policy
35+
- ../samples
3536

3637
# Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager
3738
patches:

config/rbac/role.yaml

+20-5
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,36 @@ rules:
77
- apiGroups:
88
- ""
99
resources:
10-
- pods
10+
- configmaps
1111
verbs:
12-
- create
13-
- delete
1412
- get
1513
- list
16-
- patch
17-
- update
1814
- watch
1915
- apiGroups:
2016
- ""
2117
resources:
18+
- configmaps/finalizers
2219
- pods/finalizers
2320
verbs:
2421
- update
22+
- apiGroups:
23+
- ""
24+
resources:
25+
- configmaps/status
26+
verbs:
27+
- get
28+
- apiGroups:
29+
- ""
30+
resources:
31+
- pods
32+
verbs:
33+
- create
34+
- delete
35+
- get
36+
- list
37+
- patch
38+
- update
39+
- watch
2540
- apiGroups:
2641
- ""
2742
resources:

config/samples/config.yaml

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: config
5+
labels:
6+
control-plane: controller-manager
7+
app.kubernetes.io/name: podbouncer
8+
app.kubernetes.io/managed-by: kustomize
9+
data:
10+
maxPodAge: "1h"

config/samples/kustomization.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
resources:
2+
- config.yaml

internal/controller/config.go

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package controller
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// PodReconcilerConfig is the configuration object used by PodReconciler.
9+
//
10+
// You should only keep pointers to a PodReconcilerConfig value since it embeds sync.Mutex.
11+
//
12+
// Use only the getter / setter funcs for thread-safe access.
13+
type PodReconcilerConfig struct {
14+
sync.Mutex
15+
16+
maxPodAge time.Duration
17+
}
18+
19+
func NewPodReconcilerConfig() *PodReconcilerConfig {
20+
return &PodReconcilerConfig{
21+
maxPodAge: time.Hour,
22+
}
23+
}
24+
25+
func (c *PodReconcilerConfig) SetMaxPodAge(d time.Duration) {
26+
c.Lock()
27+
defer c.Unlock()
28+
c.maxPodAge = d
29+
}
30+
31+
func (c *PodReconcilerConfig) MaxPodAge() time.Duration {
32+
c.Lock()
33+
defer c.Unlock()
34+
35+
return c.maxPodAge
36+
}
+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"time"
24+
25+
v1 "k8s.io/api/core/v1"
26+
"sigs.k8s.io/controller-runtime/pkg/event"
27+
"sigs.k8s.io/controller-runtime/pkg/handler"
28+
"sigs.k8s.io/controller-runtime/pkg/predicate"
29+
30+
"k8s.io/apimachinery/pkg/runtime"
31+
ctrl "sigs.k8s.io/controller-runtime"
32+
"sigs.k8s.io/controller-runtime/pkg/client"
33+
"sigs.k8s.io/controller-runtime/pkg/log"
34+
)
35+
36+
// ConfigMapReconciler reconciles a single ConfigMap object.
37+
//
38+
// The watched ConfigMap is used to configure a PodReconcilerConfig object.
39+
type ConfigMapReconciler struct {
40+
client.Client
41+
Scheme *runtime.Scheme
42+
43+
Config *PodReconcilerConfig
44+
}
45+
46+
const (
47+
configMapObjectNamespace = "podbouncer-system" // TODO: Make configurable
48+
configMapObjectName = "podbouncer-config" // TODO: Make configurable
49+
)
50+
51+
// TODO: Check if the permissions below are too broad. Maybe there are permissions this controller does not actually need.
52+
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch
53+
// +kubebuilder:rbac:groups=core,resources=configmaps/status,verbs=get
54+
// +kubebuilder:rbac:groups=core,resources=configmaps/finalizers,verbs=update
55+
56+
// Reconcile is part of the main kubernetes reconciliation loop which aims to
57+
// move the current state of the cluster closer to the desired state.
58+
func (r *ConfigMapReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
59+
logger := log.FromContext(ctx)
60+
61+
// Ignore objects which should not be reconciled
62+
if req.NamespacedName.String() != fmt.Sprintf("%s/%s", configMapObjectNamespace, configMapObjectName) {
63+
return ctrl.Result{}, nil
64+
}
65+
66+
// Get object
67+
var config v1.ConfigMap
68+
if err := r.Get(ctx, req.NamespacedName, &config); err != nil {
69+
return ctrl.Result{}, client.IgnoreNotFound(err)
70+
}
71+
72+
// Retrieve config value
73+
data := config.Data
74+
75+
if maxPodAgeStr, found := data["maxPodAge"]; !found {
76+
// Log error but do not requeue - the error must be fixed manually
77+
err := errors.New("missing maxPodAge property in ConfigMap")
78+
logger.Error(err, "Configuration will not be updated")
79+
return ctrl.Result{}, nil
80+
} else {
81+
maxPodAge, err := time.ParseDuration(maxPodAgeStr)
82+
83+
if err != nil {
84+
// Log error but do not requeue - the error must be fixed manually
85+
err := fmt.Errorf("invalid maxPodAge property in ConfigMap: %s", maxPodAgeStr)
86+
logger.Error(err, "Configuration will not be updated")
87+
return ctrl.Result{}, nil
88+
}
89+
90+
oldMaxPodAge := r.Config.MaxPodAge()
91+
92+
r.Config.SetMaxPodAge(maxPodAge)
93+
94+
logger.Info("Configuration updated", "newMaxPodAge", maxPodAge, "currentMaxPodAge", oldMaxPodAge)
95+
96+
return ctrl.Result{}, nil
97+
}
98+
}
99+
100+
// SetupWithManager sets up the controller with the Manager.
101+
func (r *ConfigMapReconciler) SetupWithManager(mgr ctrl.Manager) error {
102+
filter := func(o client.Object) bool {
103+
return o.GetName() == configMapObjectName && o.GetNamespace() == configMapObjectNamespace
104+
}
105+
106+
p := predicate.Funcs{
107+
CreateFunc: func(e event.TypedCreateEvent[client.Object]) bool {
108+
return filter(e.Object)
109+
},
110+
DeleteFunc: func(e event.TypedDeleteEvent[client.Object]) bool {
111+
return filter(e.Object)
112+
},
113+
UpdateFunc: func(e event.TypedUpdateEvent[client.Object]) bool {
114+
return filter(e.ObjectNew)
115+
},
116+
GenericFunc: func(e event.TypedGenericEvent[client.Object]) bool {
117+
return filter(e.Object)
118+
},
119+
}
120+
121+
return ctrl.NewControllerManagedBy(mgr).
122+
Watches(&v1.ConfigMap{}, &handler.EnqueueRequestForObject{}).
123+
WithEventFilter(p).
124+
Named("configmap").
125+
Complete(r)
126+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
Copyright 2024.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
. "github.com/onsi/ginkgo/v2"
21+
)
22+
23+
var _ = Describe("ConfigMap Controller", func() {
24+
Context("When reconciling a resource", func() {
25+
26+
It("should successfully reconcile the resource", func() {
27+
28+
// TODO(user): Add more specific assertions depending on your controller's reconciliation logic.
29+
// Example: If you expect a certain status condition after reconciliation, verify it here.
30+
})
31+
})
32+
})

internal/controller/pod_controller.go

+23-7
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ import (
3535
type PodReconciler struct {
3636
client.Client
3737
Scheme *runtime.Scheme
38-
}
3938

40-
const maxPodAge = time.Second * 15 // TODO: Add configurable value for max age
39+
Config *PodReconcilerConfig
40+
}
4141

4242
const excludedNamespace = "kube-system"
4343

@@ -69,16 +69,25 @@ func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
6969

7070
// Ignore pods which have not yet reached the deletion deadline
7171
// TODO: Refactor into testable function?
72-
podCreatedAt := pod.GetCreationTimestamp() // TODO: Check for zero value?
72+
podCreatedAt := pod.GetCreationTimestamp()
73+
if podCreatedAt.IsZero() {
74+
return ctrl.Result{}, fmt.Errorf("pod creation timestamp has unexpected zero value")
75+
}
76+
7377
podAge := time.Since(podCreatedAt.Time)
78+
maxPodAge := r.Config.MaxPodAge()
7479
if podAge < maxPodAge {
75-
// Pod is not yet read for deletion - calculate the expected time when it can be deleted
76-
requeueAfter := maxPodAge - podAge + time.Second
77-
logger.Info("Pod may eventually be deleted", "age", podAge, "phase", pod.Status.Phase, "deletionIn", requeueAfter, "deletionAt", time.Now().Add(requeueAfter))
80+
// Pod is not yet read for deletion - run reconciliation again in one minute.
81+
// We could wait the exact duration after which the object is reaches its max age
82+
// (maxPodAge - podAge) but then this logic would not properly react to changes
83+
// in the PodReconcilerConfig. To react to config updates, simply requeue within one minute
84+
// (which is a sensible delay for the operator to react to a config update) or less,
85+
// if the pod expires before that.
86+
requeueAfter := minDuration(time.Minute, maxPodAge+time.Second)
7887
return ctrl.Result{RequeueAfter: requeueAfter}, nil
7988
}
8089

81-
logger.Info("Deleting non-running pod", "phase", pod.Status.Phase)
90+
logger.Info("Deleting non-running pod", "phase", pod.Status.Phase, "podAge", podAge, "maxPodAge", maxPodAge)
8291

8392
if err := r.Delete(ctx, &pod); err != nil {
8493
return ctrl.Result{}, fmt.Errorf("failed to delete pod: %w", err)
@@ -89,6 +98,13 @@ func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
8998
return ctrl.Result{}, nil
9099
}
91100

101+
func minDuration(a, b time.Duration) time.Duration {
102+
if a < b {
103+
return a
104+
}
105+
return b
106+
}
107+
92108
func (r *PodReconciler) shouldDeletePod(pod *v1.Pod) bool {
93109
phase := pod.Status.Phase
94110
return phase == v1.PodPending || phase == v1.PodSucceeded || phase == v1.PodFailed

0 commit comments

Comments
 (0)