Skip to content

Commit 6335cd6

Browse files
authored
Merge pull request #32 from vshn/add/inplace-resize
Add inplace pvc resize controller
2 parents 00b6452 + 147f6ee commit 6335cd6

12 files changed

+605
-414
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
cover.out
22
.github/release-notes.md
3+
.vscode
4+
__debug_bin*
35

46
# Binaries for programs and plugins
57
*.exe

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ ENVTEST_ASSETS_DIR=$(shell pwd)/testbin
99
all: fmt vet build
1010

1111
.PHONY: build
12-
build:
12+
build:
1313
CGO_ENABLED=0 go build
1414

1515

1616
run: fmt vet ## Run against the configured Kubernetes cluster in ~/.kube/config
1717
go run ./main.go
1818

1919
.PHONY: test
20-
test: fmt ## Run tests
20+
test: fmt ## Run tests
2121
go test -tags="" ./... -coverprofile cover.out
2222

2323
.PHONY: integration-test
24-
integration-test: export ENVTEST_K8S_VERSION = 1.19.x
24+
integration-test: export ENVTEST_K8S_VERSION = 1.24.x
2525
integration-test: ## Run integration tests with envtest
2626
mkdir -p ${ENVTEST_ASSETS_DIR}
2727
$(setup-envtest) use '$(ENVTEST_K8S_VERSION)!'

config/rbac/role.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
21
---
32
apiVersion: rbac.authorization.k8s.io/v1
43
kind: ClusterRole
54
metadata:
6-
creationTimestamp: null
75
name: controller-manager
86
rules:
97
- apiGroups:

controllers/controller_util_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,16 @@ func rbNotExists(ctx context.Context, c client.Client, other *rbacv1.RoleBinding
320320
return apierrors.IsNotFound(err) || (err == nil && rb.DeletionTimestamp != nil)
321321
}
322322

323+
func pvcEqualSize(ctx context.Context, c client.Client, other *corev1.PersistentVolumeClaim, newSize string) bool {
324+
pvc := &corev1.PersistentVolumeClaim{}
325+
key := client.ObjectKeyFromObject(other)
326+
err := c.Get(ctx, key, pvc)
327+
if err != nil {
328+
return false
329+
}
330+
return newSize == pvc.Spec.Resources.Requests.Storage().String()
331+
}
332+
323333
// Only succeeds if the condition is valid for `waitFor` time.
324334
// Checks the condition every `tick`
325335
func consistently(t assert.TestingT, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...interface{}) bool {

controllers/inplace_controller.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/vshn/statefulset-resize-controller/statefulset"
8+
appsv1 "k8s.io/api/apps/v1"
9+
apierrors "k8s.io/apimachinery/pkg/api/errors"
10+
"k8s.io/apimachinery/pkg/runtime"
11+
"k8s.io/client-go/tools/record"
12+
ctrl "sigs.k8s.io/controller-runtime"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
"sigs.k8s.io/controller-runtime/pkg/log"
15+
)
16+
17+
// StatefulSetController is an interface for various implementations
18+
// of the StatefulSet controller.
19+
type StatefulSetController interface {
20+
SetupWithManager(ctrl.Manager) error
21+
}
22+
23+
// InplaceReconciler reconciles a StatefulSet object
24+
// It will resize the PVCs according to the sts template.
25+
type InplaceReconciler struct {
26+
client.Client
27+
Scheme *runtime.Scheme
28+
Recorder record.EventRecorder
29+
30+
RequeueAfter time.Duration
31+
LabelName string
32+
}
33+
34+
// Reconcile is the main work loop, reacting to changes in statefulsets and initiating resizing of StatefulSets.
35+
func (r *InplaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
36+
l := log.FromContext(ctx).WithValues("statefulset", req.NamespacedName)
37+
ctx = log.IntoContext(ctx, l)
38+
39+
sts := &appsv1.StatefulSet{}
40+
err := r.Client.Get(ctx, req.NamespacedName, sts)
41+
if err != nil {
42+
if apierrors.IsNotFound(err) {
43+
return ctrl.Result{}, nil
44+
}
45+
return ctrl.Result{}, err
46+
}
47+
48+
l.V(1).Info("Checking label for sts", "labelName", r.LabelName)
49+
if sts.GetLabels()[r.LabelName] != "true" {
50+
l.V(1).Info("Label not found, skipping sts")
51+
return ctrl.Result{}, nil
52+
}
53+
54+
l.Info("Found sts with label", "labelName", r.LabelName)
55+
56+
stsEntity, err := statefulset.NewEntity(sts)
57+
if err != nil {
58+
return ctrl.Result{}, err
59+
}
60+
61+
stsEntity.Pvcs, err = fetchResizablePVCs(ctx, r.Client, *stsEntity)
62+
if err != nil {
63+
return ctrl.Result{}, err
64+
}
65+
66+
if len(stsEntity.Pvcs) == 0 {
67+
l.Info("All PVCs have the right size")
68+
return ctrl.Result{}, nil
69+
}
70+
71+
err = resizePVCsInplace(ctx, r.Client, stsEntity.Pvcs)
72+
if err != nil {
73+
r.Recorder.Event(sts, "Warning", "ResizeFailed", "There was an error during the PVC resize")
74+
return ctrl.Result{}, err
75+
}
76+
77+
r.Recorder.Event(sts, "Normal", "ResizeSuccessful", "All PVCs have been resized successfully")
78+
79+
return ctrl.Result{}, nil
80+
}
81+
82+
// SetupWithManager sets up the controller with the Manager.
83+
func (r *InplaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
84+
return ctrl.NewControllerManagedBy(mgr).
85+
For(&appsv1.StatefulSet{}).
86+
Complete(r)
87+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//go:build integration
2+
// +build integration
3+
4+
package controllers
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/require"
12+
appsv1 "k8s.io/api/apps/v1"
13+
batchv1 "k8s.io/api/batch/v1"
14+
corev1 "k8s.io/api/core/v1"
15+
rbacv1 "k8s.io/api/rbac/v1"
16+
storagev1 "k8s.io/api/storage/v1"
17+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+
"k8s.io/apimachinery/pkg/runtime"
19+
"k8s.io/utils/pointer"
20+
ctrl "sigs.k8s.io/controller-runtime"
21+
"sigs.k8s.io/controller-runtime/pkg/client"
22+
"sigs.k8s.io/controller-runtime/pkg/envtest"
23+
)
24+
25+
const (
26+
testLabelName = "mylabel"
27+
)
28+
29+
func TestInplaceController(t *testing.T) {
30+
ctx, cancel := context.WithCancel(context.Background())
31+
defer cancel()
32+
c, stop := startInplaceTestReconciler(t, ctx, "")
33+
defer stop()
34+
35+
t.Run("InplaceE2E", func(t *testing.T) {
36+
37+
sc := &storagev1.StorageClass{
38+
ObjectMeta: metav1.ObjectMeta{
39+
Name: "mysc",
40+
Annotations: map[string]string{
41+
"storageclass.kubernetes.io/is-default-class": "true",
42+
},
43+
},
44+
Provisioner: "mysc",
45+
AllowVolumeExpansion: pointer.Bool(true),
46+
}
47+
48+
require.NoError(t, c.Create(ctx, sc))
49+
50+
t.Run("Don't touch correct PVCs", func(t *testing.T) {
51+
t.Parallel()
52+
ctx := context.Background()
53+
ns := "e2e1"
54+
require := require.New(t)
55+
require.NoError(c.Create(ctx, &corev1.Namespace{
56+
ObjectMeta: metav1.ObjectMeta{
57+
Name: ns,
58+
},
59+
}))
60+
pvcSize := "2G"
61+
sts := newTestStatefulSet(ns, "test", 1, pvcSize)
62+
sts.Labels = map[string]string{
63+
testLabelName: "true",
64+
}
65+
66+
pvc := applyResizablePVC(ctx, "data-test-0", ns, pvcSize, sts, c, require)
67+
68+
require.NoError(c.Create(ctx, sts))
69+
70+
consistently(t, func() bool {
71+
return pvcEqualSize(ctx, c, pvc, pvcSize)
72+
}, duration, interval, "PVCs equal size")
73+
74+
})
75+
t.Run("Ignore STS without the label", func(t *testing.T) {
76+
t.Parallel()
77+
ctx := context.Background()
78+
ns := "e2e2"
79+
require := require.New(t)
80+
require.NoError(c.Create(ctx, &corev1.Namespace{
81+
ObjectMeta: metav1.ObjectMeta{
82+
Name: ns,
83+
},
84+
}))
85+
sts := newTestStatefulSet(ns, "test", 1, "2G")
86+
87+
pvc := applyResizablePVC(ctx, "data-test-0", ns, "1G", sts, c, require)
88+
89+
require.NoError(c.Create(ctx, sts))
90+
91+
consistently(t, func() bool {
92+
return pvcEqualSize(ctx, c, pvc, "1G")
93+
}, duration, interval, "PVCs equal size")
94+
})
95+
t.Run("Change PVCs if they not match", func(t *testing.T) {
96+
t.Parallel()
97+
ctx := context.Background()
98+
ns := "e2e3"
99+
require := require.New(t)
100+
require.NoError(c.Create(ctx, &corev1.Namespace{
101+
ObjectMeta: metav1.ObjectMeta{
102+
Name: ns,
103+
},
104+
}))
105+
sts := newTestStatefulSet(ns, "test", 1, "2G")
106+
sts.Labels = map[string]string{
107+
testLabelName: "true",
108+
}
109+
110+
pvc := applyResizablePVC(ctx, "data-test-0", ns, "1G", sts, c, require)
111+
112+
require.NoError(c.Create(ctx, sts))
113+
114+
consistently(t, func() bool {
115+
return pvcEqualSize(ctx, c, pvc, "2G")
116+
}, duration, interval, "PVCs equal size")
117+
})
118+
})
119+
120+
}
121+
122+
// startInplaceTestReconciler sets up a separate test env and starts the controller
123+
func startInplaceTestReconciler(t *testing.T, ctx context.Context, crname string) (client.Client, func() error) {
124+
req := require.New(t)
125+
126+
testEnv := &envtest.Environment{}
127+
conf, err := testEnv.Start()
128+
req.NoError(err)
129+
130+
s := runtime.NewScheme()
131+
req.NoError(appsv1.AddToScheme(s))
132+
req.NoError(corev1.AddToScheme(s))
133+
req.NoError(batchv1.AddToScheme(s))
134+
req.NoError(rbacv1.AddToScheme(s))
135+
req.NoError(storagev1.AddToScheme(s))
136+
137+
mgr, err := ctrl.NewManager(conf, ctrl.Options{
138+
Scheme: s,
139+
})
140+
req.NoError(err)
141+
req.NoError((&InplaceReconciler{
142+
Client: mgr.GetClient(),
143+
Scheme: mgr.GetScheme(),
144+
Recorder: mgr.GetEventRecorderFor("statefulset-resize-controller"),
145+
RequeueAfter: time.Second,
146+
LabelName: testLabelName,
147+
}).SetupWithManager(mgr))
148+
go func() {
149+
req.NoError(mgr.Start(ctx))
150+
}()
151+
152+
return mgr.GetClient(), testEnv.Stop
153+
}
154+
155+
func applyResizablePVC(ctx context.Context, name, ns, size string, sts *appsv1.StatefulSet, c client.Client, require *require.Assertions) *corev1.PersistentVolumeClaim {
156+
pvc := newSource(ns, name, size,
157+
func(pvc *corev1.PersistentVolumeClaim) *corev1.PersistentVolumeClaim {
158+
pvc.Labels = sts.Spec.Selector.MatchLabels
159+
return pvc
160+
})
161+
162+
pvc.Spec.StorageClassName = pointer.String("mysc")
163+
require.NoError(c.Create(ctx, pvc))
164+
165+
// we need to set the PVC to bound in order for the resize to work
166+
pvc.Status.Phase = corev1.ClaimBound
167+
require.NoError(c.Status().Update(ctx, pvc))
168+
return pvc
169+
}

controllers/pvc.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import (
1616
)
1717

1818
// getResizablePVCs fetches the information of all PVCs that are smaller than the request of the statefulset
19-
func (r StatefulSetReconciler) fetchResizablePVCs(ctx context.Context, si statefulset.Entity) ([]pvc.Entity, error) {
19+
func fetchResizablePVCs(ctx context.Context, cl client.Client, si statefulset.Entity) ([]pvc.Entity, error) {
2020
// NOTE(glrf) This will get _all_ PVCs that belonged to the sts. Even the ones not used anymore (i.e. if scaled up and down).
2121
sts, err := si.StatefulSet()
2222
if err != nil {
2323
return nil, err
2424
}
2525
pvcs := corev1.PersistentVolumeClaimList{}
26-
if err := r.List(ctx, &pvcs, client.InNamespace(sts.Namespace), client.MatchingLabels(sts.Spec.Selector.MatchLabels)); err != nil {
26+
if err := cl.List(ctx, &pvcs, client.InNamespace(sts.Namespace), client.MatchingLabels(sts.Spec.Selector.MatchLabels)); err != nil {
2727
return nil, err
2828
}
2929
pis := filterResizablePVCs(ctx, *sts, pvcs.Items)
@@ -109,3 +109,22 @@ func (r *StatefulSetReconciler) resizePVCs(ctx context.Context, oldPIs []pvc.Ent
109109
}
110110
return pis, nil
111111
}
112+
113+
func resizePVCsInplace(ctx context.Context, cl client.Client, PVCs []pvc.Entity) error {
114+
l := log.FromContext(ctx)
115+
116+
for _, pvc := range PVCs {
117+
l.Info("Updating PVC", "PVCName", pvc.SourceName)
118+
119+
resizedPVC := pvc.GetResizedSource()
120+
resizedPVC.Spec.StorageClassName = pvc.SourceStorageClass
121+
resizedPVC.Spec.VolumeName = pvc.Spec.VolumeName
122+
123+
err := cl.Update(ctx, resizedPVC)
124+
if err != nil {
125+
return err
126+
}
127+
}
128+
129+
return nil
130+
}

controllers/statefulset.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (r StatefulSetReconciler) fetchStatefulSet(ctx context.Context, namespacedN
5050
}
5151

5252
if !sts.Resizing() {
53-
sts.Pvcs, err = r.fetchResizablePVCs(ctx, *sts)
53+
sts.Pvcs, err = fetchResizablePVCs(ctx, r.Client, *sts)
5454
return sts, err
5555
}
5656
return sts, nil

0 commit comments

Comments
 (0)