Skip to content

Commit df489b5

Browse files
authored
Merge pull request #34 from umago/pvc-resize
Allow Postgres PVC expansion
2 parents e7ab64d + 0578ab2 commit df489b5

5 files changed

Lines changed: 195 additions & 24 deletions

File tree

go.mod

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ require (
1313
github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0
1414
github.com/operator-framework/api v0.37.0
1515
k8s.io/api v0.34.2
16-
k8s.io/apiextensions-apiserver v0.34.2
16+
k8s.io/apiextensions-apiserver v0.34.2 // indirect
1717
k8s.io/apimachinery v0.34.3
1818
k8s.io/client-go v0.34.2
1919
sigs.k8s.io/controller-runtime v0.22.4
@@ -25,7 +25,7 @@ require (
2525
replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e
2626

2727
require (
28-
cel.dev/expr v0.24.0 // indirect
28+
cel.dev/expr v0.25.1 // indirect
2929
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
3030
github.com/beorn7/perks v1.0.1 // indirect
3131
github.com/blang/semver/v4 v4.0.0 // indirect
@@ -85,17 +85,17 @@ require (
8585
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
8686
golang.org/x/mod v0.37.0 // indirect
8787
golang.org/x/net v0.56.0 // indirect
88-
golang.org/x/oauth2 v0.34.0 // indirect
88+
golang.org/x/oauth2 v0.36.0 // indirect
8989
golang.org/x/sync v0.21.0 // indirect
9090
golang.org/x/sys v0.46.0 // indirect
9191
golang.org/x/term v0.44.0 // indirect
9292
golang.org/x/text v0.39.0 // indirect
9393
golang.org/x/time v0.12.0 // indirect
9494
golang.org/x/tools v0.47.0 // indirect
9595
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
96-
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
97-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
98-
google.golang.org/grpc v1.78.0 // indirect
96+
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
97+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
98+
google.golang.org/grpc v1.82.1 // indirect
9999
google.golang.org/protobuf v1.36.11 // indirect
100100
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
101101
gopkg.in/inf.v0 v0.9.1 // indirect

go.sum

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
2-
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
1+
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
2+
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
33
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
44
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
55
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@@ -208,8 +208,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
208208
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
209209
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
210210
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
211-
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
212-
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
211+
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
212+
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
213213
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
214214
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
215215
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -241,14 +241,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
241241
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
242242
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
243243
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
244-
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
245-
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
246-
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
247-
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
248-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
249-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
250-
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
251-
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
244+
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
245+
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
246+
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
247+
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
248+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
249+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
250+
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
251+
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
252252
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
253253
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
254254
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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)