diff --git a/go.mod b/go.mod index b416d29..75160fb 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/openstack-k8s-operators/lib-common/modules/common v0.6.0 github.com/operator-framework/api v0.37.0 k8s.io/api v0.34.2 - k8s.io/apiextensions-apiserver v0.34.2 + k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.2 sigs.k8s.io/controller-runtime v0.22.4 @@ -25,7 +25,7 @@ require ( replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -85,7 +85,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect @@ -93,9 +93,9 @@ require ( golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 2c3b091..076c28c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 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 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/controller/errors.go b/internal/controller/errors.go index a3883a7..5f97677 100644 --- a/internal/controller/errors.go +++ b/internal/controller/errors.go @@ -67,6 +67,7 @@ var ( ErrGetPostgresConfigMap = errors.New("failed to get Postgres configmap") ErrCreatePostgresNetworkPolicy = errors.New("failed to create Postgres network policy") ErrCreatePostgresPVC = errors.New("failed to create Postgres PVC") + ErrPatchPostgresPVC = errors.New("failed to patch Postgres PVC") ErrGetPostgresPVC = errors.New("failed to get Postgres PVC") - ErrPostgresPVCSizeMismatch = errors.New("existing Postgres PVC size does not match requested size") + ErrPostgresPVCSizeShrink = errors.New("cannot shrink existing Postgres PVC") ) diff --git a/internal/controller/postgres_reconciler.go b/internal/controller/postgres_reconciler.go index 5adfb93..6d20fd7 100644 --- a/internal/controller/postgres_reconciler.go +++ b/internal/controller/postgres_reconciler.go @@ -248,13 +248,22 @@ func reconcilePostgresPVC(h *common_helper.Helper, ctx context.Context, instance } if err == nil { - // PVC already exists, validate if the size matches existingQty := pvc.Spec.Resources.Requests[corev1.ResourceStorage] - if requestedQty.Cmp(existingQty) != 0 { - return fmt.Errorf("%w: requested size %s but existing PVC has %s", - ErrPostgresPVCSizeMismatch, requestedQty.String(), existingQty.String()) + cmp := requestedQty.Cmp(existingQty) + if cmp < 0 { + return fmt.Errorf("%w: current %s, requested %s", + ErrPostgresPVCSizeShrink, existingQty.String(), requestedQty.String()) } - h.GetLogger().Info("Reusing the existing PostgreSQL PVC with a matching size", "name", pvc.Name) + if cmp == 0 { + h.GetLogger().Info("Reusing the existing PostgreSQL PVC with a matching size", "name", pvc.Name) + return nil + } + patch := client.MergeFrom(pvc.DeepCopy()) + pvc.Spec.Resources.Requests[corev1.ResourceStorage] = requestedQty + if err := h.GetClient().Patch(ctx, pvc, patch); err != nil { + return fmt.Errorf("%w: %w", ErrPatchPostgresPVC, err) + } + h.GetLogger().Info("Postgres PVC storage request expanded", "name", pvc.Name, "from", existingQty.String(), "to", requestedQty.String()) return nil } diff --git a/internal/controller/postgres_reconciler_test.go b/internal/controller/postgres_reconciler_test.go new file mode 100644 index 0000000..3e183c1 --- /dev/null +++ b/internal/controller/postgres_reconciler_test.go @@ -0,0 +1,161 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + common_helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func makeExistingPVC(size string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: PostgresDataPVCName, + Namespace: "test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse(size), + }, + }, + }, + } +} + +func makeInstanceWithDBSize(size string) *apiv1beta1.OpenStackLightspeed { + return &apiv1beta1.OpenStackLightspeed{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-instance", + Namespace: "test-ns", + }, + Spec: apiv1beta1.OpenStackLightspeedSpec{ + Database: &apiv1beta1.DatabaseSpec{ + Size: resource.MustParse(size), + }, + }, + } +} + +// Expanding the PVC patches its storage request to the larger value and returns no error. +func TestReconcilePostgresPVC_Expand(t *testing.T) { + h := newTestHelper(t, makeExistingPVC("1Gi")) + instance := makeInstanceWithDBSize("2Gi") + + ctx := context.Background() + if err := reconcilePostgresPVC(h, ctx, instance); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updated := &corev1.PersistentVolumeClaim{} + if err := h.GetClient().Get(ctx, client.ObjectKey{Name: PostgresDataPVCName, Namespace: "test-ns"}, updated); err != nil { + t.Fatalf("failed to get PVC after expand: %v", err) + } + got := updated.Spec.Resources.Requests[corev1.ResourceStorage] + want := resource.MustParse("2Gi") + if got.Cmp(want) != 0 { + t.Errorf("expected PVC storage %s after expand, got %s", want.String(), got.String()) + } +} + +// Shrinking the PVC is rejected with ErrPostgresPVCSizeShrink; the PVC is not modified. +func TestReconcilePostgresPVC_Shrink(t *testing.T) { + h := newTestHelper(t, makeExistingPVC("2Gi")) + instance := makeInstanceWithDBSize("1Gi") + + ctx := context.Background() + err := reconcilePostgresPVC(h, ctx, instance) + if err == nil { + t.Fatal("expected error when shrinking PVC, got nil") + } + if !errors.Is(err, ErrPostgresPVCSizeShrink) { + t.Errorf("expected ErrPostgresPVCSizeShrink, got %v", err) + } +} + +// When the requested size matches the existing PVC, reconciliation is a no-op. +func TestReconcilePostgresPVC_NoOp(t *testing.T) { + h := newTestHelper(t, makeExistingPVC("1Gi")) + instance := makeInstanceWithDBSize("1Gi") + + ctx := context.Background() + if err := reconcilePostgresPVC(h, ctx, instance); err != nil { + t.Fatalf("unexpected error for matching size: %v", err) + } +} + +func newTestHelperWithPatchErr(t *testing.T, objs ...client.Object) *common_helper.Helper { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add corev1 to scheme: %v", err) + } + if err := apiv1beta1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add apiv1beta1 to scheme: %v", err) + } + + patchErr := fmt.Errorf("forced patch failure") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithInterceptorFuncs(interceptor.Funcs{ + Patch: func(_ context.Context, _ client.WithWatch, _ client.Object, _ client.Patch, _ ...client.PatchOption) error { + return patchErr + }, + }). + Build() + + instance := &apiv1beta1.OpenStackLightspeed{ + ObjectMeta: metav1.ObjectMeta{Name: "test-instance", Namespace: "test-ns"}, + } + logger := zap.New(zap.WriteTo(bytes.NewBuffer(nil))) + h, err := common_helper.NewHelper(instance, fakeClient, nil, scheme, logger) + if err != nil { + t.Fatalf("failed to create helper: %v", err) + } + return h +} + +// If the API server rejects the patch (e.g. StorageClass lacks allowVolumeExpansion), ErrPatchPostgresPVC is returned. +func TestReconcilePostgresPVC_PatchError(t *testing.T) { + h := newTestHelperWithPatchErr(t, makeExistingPVC("1Gi")) + instance := makeInstanceWithDBSize("2Gi") + + ctx := context.Background() + err := reconcilePostgresPVC(h, ctx, instance) + if err == nil { + t.Fatal("expected error when patch fails, got nil") + } + if !errors.Is(err, ErrPatchPostgresPVC) { + t.Errorf("expected ErrPatchPostgresPVC, got %v", err) + } +}