Skip to content

Commit 0578ab2

Browse files
committed
Allow Postgres PVC expansion
Previously, any change to spec.database.size on an existing OpenStackLightspeed instance returned ErrPostgresPVCSizeMismatch, forcing users to delete the PVC (and lose all data) to resize storage. Now the operator distinguishes three cases: - Expand: patches the PVC's storage request to the larger value. - Equal: no-op, no error. - Shrink: returns ErrPostgresPVCSizeShrink with a clear message showing the current and requested sizes; the PVC is not modified. Unit tests cover all three cases using a fake client. Kuttl tests was considered by rejected because it requires the cluster's default StorageClass to have allowVolumeExpansionenabled, which can not be assumed in a portable test environment. And since kuttl also has no proper mechanism to skip tests conditionally it would produce unreliable results across different clusters. Signed-off-by: Lucas Alvares Gomes <lucasagomes@gmail.com>
1 parent e43d692 commit 0578ab2

3 files changed

Lines changed: 177 additions & 6 deletions

File tree

internal/controller/errors.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ var (
6767
ErrGetPostgresConfigMap = errors.New("failed to get Postgres configmap")
6868
ErrCreatePostgresNetworkPolicy = errors.New("failed to create Postgres network policy")
6969
ErrCreatePostgresPVC = errors.New("failed to create Postgres PVC")
70+
ErrPatchPostgresPVC = errors.New("failed to patch Postgres PVC")
7071
ErrGetPostgresPVC = errors.New("failed to get Postgres PVC")
71-
ErrPostgresPVCSizeMismatch = errors.New("existing Postgres PVC size does not match requested size")
72+
ErrPostgresPVCSizeShrink = errors.New("cannot shrink existing Postgres PVC")
7273
)

internal/controller/postgres_reconciler.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,22 @@ func reconcilePostgresPVC(h *common_helper.Helper, ctx context.Context, instance
248248
}
249249

250250
if err == nil {
251-
// PVC already exists, validate if the size matches
252251
existingQty := pvc.Spec.Resources.Requests[corev1.ResourceStorage]
253-
if requestedQty.Cmp(existingQty) != 0 {
254-
return fmt.Errorf("%w: requested size %s but existing PVC has %s",
255-
ErrPostgresPVCSizeMismatch, requestedQty.String(), existingQty.String())
252+
cmp := requestedQty.Cmp(existingQty)
253+
if cmp < 0 {
254+
return fmt.Errorf("%w: current %s, requested %s",
255+
ErrPostgresPVCSizeShrink, existingQty.String(), requestedQty.String())
256256
}
257-
h.GetLogger().Info("Reusing the existing PostgreSQL PVC with a matching size", "name", pvc.Name)
257+
if cmp == 0 {
258+
h.GetLogger().Info("Reusing the existing PostgreSQL PVC with a matching size", "name", pvc.Name)
259+
return nil
260+
}
261+
patch := client.MergeFrom(pvc.DeepCopy())
262+
pvc.Spec.Resources.Requests[corev1.ResourceStorage] = requestedQty
263+
if err := h.GetClient().Patch(ctx, pvc, patch); err != nil {
264+
return fmt.Errorf("%w: %w", ErrPatchPostgresPVC, err)
265+
}
266+
h.GetLogger().Info("Postgres PVC storage request expanded", "name", pvc.Name, "from", existingQty.String(), "to", requestedQty.String())
258267
return nil
259268
}
260269

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
Copyright 2026.
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+
"bytes"
21+
"context"
22+
"errors"
23+
"fmt"
24+
"testing"
25+
26+
common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper"
27+
apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1"
28+
corev1 "k8s.io/api/core/v1"
29+
"k8s.io/apimachinery/pkg/api/resource"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/runtime"
32+
"sigs.k8s.io/controller-runtime/pkg/client"
33+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
34+
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
35+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
36+
)
37+
38+
func makeExistingPVC(size string) *corev1.PersistentVolumeClaim {
39+
return &corev1.PersistentVolumeClaim{
40+
ObjectMeta: metav1.ObjectMeta{
41+
Name: PostgresDataPVCName,
42+
Namespace: "test-ns",
43+
},
44+
Spec: corev1.PersistentVolumeClaimSpec{
45+
Resources: corev1.VolumeResourceRequirements{
46+
Requests: corev1.ResourceList{
47+
corev1.ResourceStorage: resource.MustParse(size),
48+
},
49+
},
50+
},
51+
}
52+
}
53+
54+
func makeInstanceWithDBSize(size string) *apiv1beta1.OpenStackLightspeed {
55+
return &apiv1beta1.OpenStackLightspeed{
56+
ObjectMeta: metav1.ObjectMeta{
57+
Name: "test-instance",
58+
Namespace: "test-ns",
59+
},
60+
Spec: apiv1beta1.OpenStackLightspeedSpec{
61+
Database: &apiv1beta1.DatabaseSpec{
62+
Size: resource.MustParse(size),
63+
},
64+
},
65+
}
66+
}
67+
68+
// Expanding the PVC patches its storage request to the larger value and returns no error.
69+
func TestReconcilePostgresPVC_Expand(t *testing.T) {
70+
h := newTestHelper(t, makeExistingPVC("1Gi"))
71+
instance := makeInstanceWithDBSize("2Gi")
72+
73+
ctx := context.Background()
74+
if err := reconcilePostgresPVC(h, ctx, instance); err != nil {
75+
t.Fatalf("unexpected error: %v", err)
76+
}
77+
78+
updated := &corev1.PersistentVolumeClaim{}
79+
if err := h.GetClient().Get(ctx, client.ObjectKey{Name: PostgresDataPVCName, Namespace: "test-ns"}, updated); err != nil {
80+
t.Fatalf("failed to get PVC after expand: %v", err)
81+
}
82+
got := updated.Spec.Resources.Requests[corev1.ResourceStorage]
83+
want := resource.MustParse("2Gi")
84+
if got.Cmp(want) != 0 {
85+
t.Errorf("expected PVC storage %s after expand, got %s", want.String(), got.String())
86+
}
87+
}
88+
89+
// Shrinking the PVC is rejected with ErrPostgresPVCSizeShrink; the PVC is not modified.
90+
func TestReconcilePostgresPVC_Shrink(t *testing.T) {
91+
h := newTestHelper(t, makeExistingPVC("2Gi"))
92+
instance := makeInstanceWithDBSize("1Gi")
93+
94+
ctx := context.Background()
95+
err := reconcilePostgresPVC(h, ctx, instance)
96+
if err == nil {
97+
t.Fatal("expected error when shrinking PVC, got nil")
98+
}
99+
if !errors.Is(err, ErrPostgresPVCSizeShrink) {
100+
t.Errorf("expected ErrPostgresPVCSizeShrink, got %v", err)
101+
}
102+
}
103+
104+
// When the requested size matches the existing PVC, reconciliation is a no-op.
105+
func TestReconcilePostgresPVC_NoOp(t *testing.T) {
106+
h := newTestHelper(t, makeExistingPVC("1Gi"))
107+
instance := makeInstanceWithDBSize("1Gi")
108+
109+
ctx := context.Background()
110+
if err := reconcilePostgresPVC(h, ctx, instance); err != nil {
111+
t.Fatalf("unexpected error for matching size: %v", err)
112+
}
113+
}
114+
115+
func newTestHelperWithPatchErr(t *testing.T, objs ...client.Object) *common_helper.Helper {
116+
t.Helper()
117+
118+
scheme := runtime.NewScheme()
119+
if err := corev1.AddToScheme(scheme); err != nil {
120+
t.Fatalf("failed to add corev1 to scheme: %v", err)
121+
}
122+
if err := apiv1beta1.AddToScheme(scheme); err != nil {
123+
t.Fatalf("failed to add apiv1beta1 to scheme: %v", err)
124+
}
125+
126+
patchErr := fmt.Errorf("forced patch failure")
127+
fakeClient := fake.NewClientBuilder().
128+
WithScheme(scheme).
129+
WithObjects(objs...).
130+
WithInterceptorFuncs(interceptor.Funcs{
131+
Patch: func(_ context.Context, _ client.WithWatch, _ client.Object, _ client.Patch, _ ...client.PatchOption) error {
132+
return patchErr
133+
},
134+
}).
135+
Build()
136+
137+
instance := &apiv1beta1.OpenStackLightspeed{
138+
ObjectMeta: metav1.ObjectMeta{Name: "test-instance", Namespace: "test-ns"},
139+
}
140+
logger := zap.New(zap.WriteTo(bytes.NewBuffer(nil)))
141+
h, err := common_helper.NewHelper(instance, fakeClient, nil, scheme, logger)
142+
if err != nil {
143+
t.Fatalf("failed to create helper: %v", err)
144+
}
145+
return h
146+
}
147+
148+
// If the API server rejects the patch (e.g. StorageClass lacks allowVolumeExpansion), ErrPatchPostgresPVC is returned.
149+
func TestReconcilePostgresPVC_PatchError(t *testing.T) {
150+
h := newTestHelperWithPatchErr(t, makeExistingPVC("1Gi"))
151+
instance := makeInstanceWithDBSize("2Gi")
152+
153+
ctx := context.Background()
154+
err := reconcilePostgresPVC(h, ctx, instance)
155+
if err == nil {
156+
t.Fatal("expected error when patch fails, got nil")
157+
}
158+
if !errors.Is(err, ErrPatchPostgresPVC) {
159+
t.Errorf("expected ErrPatchPostgresPVC, got %v", err)
160+
}
161+
}

0 commit comments

Comments
 (0)