diff --git a/.github/actions/setup-test-environment/action.yml b/.github/actions/setup-test-environment/action.yml index d5ab4c6c..ab849c32 100644 --- a/.github/actions/setup-test-environment/action.yml +++ b/.github/actions/setup-test-environment/action.yml @@ -54,6 +54,9 @@ inputs: description: 'Whether to use external images instead of loading from artifacts' required: false default: 'false' + documentdb-image: + description: 'DocumentDB image to use for the cluster' + required: true # GitHub configuration github-token: description: 'GitHub token for accessing packages' @@ -197,10 +200,25 @@ runs: mongosh --version echo "✓ mongosh installed successfully for ${{ inputs.architecture }}" + - name: Create kind config file + shell: bash + run: | + cat > /tmp/kind-config.yaml < "0.110-0") + # NOTE: This parsing assumes semver format "X.Y.Z" where the last component becomes + # hyphen-separated in the extension version. Pre-release tags (e.g., "0.110.0-beta") + # are not currently supported and would not be converted correctly. + NEW_VERSION_TAG="${NEW_IMAGE##*:}" + EXPECTED_VERSION=$(echo "$NEW_VERSION_TAG" | sed 's/\.\([^.]*\)$/-\1/') + echo "Expected DocumentDB version after upgrade: $EXPECTED_VERSION" + + timeout 600 bash -c ' + + while true; do + DB_STATUS=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.status}" 2>/dev/null) + CLUSTER_STATUS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.phase}" 2>/dev/null) + + # Check DocumentDB version in status + DOCUMENTDB_VERSION=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.documentDBVersion}" 2>/dev/null || echo "N/A") + + echo "DocumentDB status: $DB_STATUS, CNPG Cluster phase: $CLUSTER_STATUS, DocumentDB version: $DOCUMENTDB_VERSION" + + if [[ "$DB_STATUS" == "Cluster in healthy state" && "$CLUSTER_STATUS" == "Cluster in healthy state" ]]; then + if [[ "$DOCUMENTDB_VERSION" == "'"$EXPECTED_VERSION"'" ]]; then + HEALTHY_PODS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.instancesStatus.healthy}" 2>/dev/null | jq length 2>/dev/null || echo "0") + if [[ "$HEALTHY_PODS" -ge "1" ]]; then + echo "✓ Cluster is healthy with new image and $HEALTHY_PODS healthy pods" + break + fi + fi + fi + + sleep 10 + done + ' + + echo "Verifying new image is applied..." + FINAL_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.spec.documentDBImage}') + echo "Final DocumentDB image in spec: $FINAL_IMAGE" + + if [[ "$FINAL_IMAGE" != "$NEW_IMAGE" ]]; then + echo "❌ New image not applied to DocumentDB spec" + kubectl get documentdb $DB_NAME -n $DB_NS -o yaml + exit 1 + fi + + echo "✓ New image applied successfully" + + # Check DocumentDB version in status after upgrade + DOCUMENTDB_VERSION_AFTER=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.documentDBVersion}') + echo "DocumentDB version before upgrade: $DOCUMENTDB_VERSION_BEFORE" + echo "DocumentDB version after upgrade: $DOCUMENTDB_VERSION_AFTER" + + echo "" + echo "✅ DocumentDB image upgrade test completed successfully!" + echo "Upgraded from $OLD_IMAGE to $NEW_IMAGE" + - name: Setup port forwarding for comprehensive tests uses: ./.github/actions/setup-port-forwarding with: diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 4949b4df..120a6f43 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -9,10 +9,6 @@ on: - cron: '0 2 * * *' workflow_dispatch: inputs: - documentdb_version: - description: 'DocumentDB image version to test' - required: false - default: '16' node_count: description: 'Number of DocumentDB nodes' required: false @@ -27,11 +23,6 @@ on: description: 'Optional: Use existing image tag instead of building locally' required: false type: string - documentdb_version: - description: 'DocumentDB image version to test' - required: false - default: '16' - type: string node_count: description: 'Number of DocumentDB nodes' required: false @@ -52,6 +43,7 @@ env: DB_USERNAME: k8s_secret_user DB_PASSWORD: K8sSecret100 DB_PORT: 10260 + DOCUMENTDB_IMAGE: ghcr.io/guanzhousongmicrosoft/documentdb-pg18:0.110.0 jobs: # Conditional build workflow - only run if image_tag is not provided or on pull_request @@ -131,6 +123,7 @@ jobs: db-port: ${{ env.DB_PORT }} image-tag: ${{ env.IMAGE_TAG }} chart-version: ${{ env.CHART_VERSION }} + documentdb-image: ${{ env.DOCUMENTDB_IMAGE }} use-external-images: ${{ github.event_name != 'pull_request' && inputs.image_tag != '' && inputs.image_tag != null }} github-token: ${{ secrets.GITHUB_TOKEN }} repository-owner: ${{ github.repository_owner }} @@ -279,7 +272,7 @@ jobs: spec: nodeCount: ${{ matrix.node_count }} instancesPerNode: ${{ matrix.instances_per_node }} - documentDBImage: ghcr.io/microsoft/documentdb/documentdb-local:16 + documentDBImage: ${{ env.DOCUMENTDB_IMAGE }} gatewayImage: ghcr.io/microsoft/documentdb/documentdb-local:16 resource: storage: diff --git a/.github/workflows/test-build-and-package.yml b/.github/workflows/test-build-and-package.yml index afacfc57..8af51e78 100644 --- a/.github/workflows/test-build-and-package.yml +++ b/.github/workflows/test-build-and-package.yml @@ -118,52 +118,6 @@ jobs: path: sidecar-${{ matrix.arch }}-image.tar retention-days: 1 - build-documentdb: - name: Build DocumentDB Images - timeout-minutes: 30 - strategy: - matrix: - arch: [amd64, arm64] - include: - - arch: amd64 - base_arch: AMD64 - runner: ubuntu-22.04 - - arch: arm64 - base_arch: ARM64 - runner: ubuntu-22.04-arm - runs-on: ${{ matrix.runner }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build documentdb Docker image for ${{ matrix.arch }} - run: | - echo "Building documentdb Docker image for ${{ matrix.arch }} architecture..." - docker buildx build \ - --platform linux/${{ matrix.arch }} \ - --build-arg ARCH=${{ matrix.base_arch }} \ - --tag ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}-${{ matrix.arch }} \ - --load \ - -f .github/dockerfiles/Dockerfile_docdb . - - echo "✓ DocumentDB Docker image built successfully for ${{ matrix.arch }}" - - - name: Save documentdb Docker image as artifact - run: | - echo "Saving documentdb ${{ matrix.arch }} Docker image as tar file..." - docker save ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}-${{ matrix.arch }} > documentdb-${{ matrix.arch }}-image.tar - echo "✓ DocumentDB ${{ matrix.arch }} Docker image saved to tar file" - - - name: Upload documentdb Docker image artifact - uses: actions/upload-artifact@v4 - with: - name: build-docker-documentdb-${{ matrix.arch }} - path: documentdb-${{ matrix.arch }}-image.tar - retention-days: 1 - build-gateway: name: Build Gateway Images timeout-minutes: 30 @@ -214,7 +168,7 @@ jobs: name: Consolidate Platform Images runs-on: ubuntu-latest timeout-minutes: 15 - needs: [build-operator, build-sidecar, build-documentdb, build-gateway] + needs: [build-operator, build-sidecar, build-gateway] outputs: image_tag: ${{ env.IMAGE_TAG }} steps: @@ -273,29 +227,7 @@ jobs: ls -la ./artifacts/build-docker-sidecar-arm64/ || echo "Directory not found" exit 1 fi - - # Load documentdb images - echo "Loading documentdb AMD64 image..." - if [ -f ./artifacts/build-docker-documentdb-amd64/documentdb-amd64-image.tar ]; then - docker load < ./artifacts/build-docker-documentdb-amd64/documentdb-amd64-image.tar - echo "✓ DocumentDB AMD64 image loaded" - else - echo "❌ DocumentDB AMD64 image file not found" - ls -la ./artifacts/build-docker-documentdb-amd64/ || echo "Directory not found" - exit 1 - fi - - echo "Loading documentdb ARM64 image..." - if [ -f ./artifacts/build-docker-documentdb-arm64/documentdb-arm64-image.tar ]; then - docker load < ./artifacts/build-docker-documentdb-arm64/documentdb-arm64-image.tar - echo "✓ DocumentDB ARM64 image loaded" - else - echo "❌ DocumentDB ARM64 image file not found" - ls -la ./artifacts/build-docker-documentdb-arm64/ || echo "Directory not found" - exit 1 - fi - - # Load gateway images + echo "Loading gateway AMD64 image..." if [ -f ./artifacts/build-docker-gateway-amd64/gateway-amd64-image.tar ]; then docker load < ./artifacts/build-docker-gateway-amd64/gateway-amd64-image.tar @@ -324,14 +256,12 @@ jobs: run: | echo "Saving platform-specific images as artifacts..." - # Save all 8 platform-specific images (4 types x 2 architectures) + # Save all 6 platform-specific images (3 types x 2 architectures) docker save \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/operator:${{ env.IMAGE_TAG }}-amd64 \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/operator:${{ env.IMAGE_TAG }}-arm64 \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/sidecar:${{ env.IMAGE_TAG }}-amd64 \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/sidecar:${{ env.IMAGE_TAG }}-arm64 \ - ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}-amd64 \ - ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}-arm64 \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/gateway:${{ env.IMAGE_TAG }}-amd64 \ ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/gateway:${{ env.IMAGE_TAG }}-arm64 \ > platform-specific-images.tar diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index aeb53786..d077d1fe 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -33,6 +33,7 @@ env: DB_USERNAME: default_user DB_PASSWORD: Admin100 DB_PORT: 10260 + DOCUMENTDB_IMAGE: ghcr.io/guanzhousongmicrosoft/documentdb-pg18:0.110.0 jobs: # Use the reusable build workflow - only if no image tag is provided or on pull_request @@ -119,6 +120,7 @@ jobs: db-port: ${{ env.DB_PORT }} image-tag: ${{ env.IMAGE_TAG }} chart-version: ${{ env.CHART_VERSION }} + documentdb-image: ${{ env.DOCUMENTDB_IMAGE }} use-external-images: ${{ github.event_name != 'pull_request' && github.event.inputs.image_tag != '' && github.event.inputs.image_tag != null }} github-token: ${{ secrets.GITHUB_TOKEN }} repository-owner: ${{ github.repository_owner }} diff --git a/operator/documentdb-helm-chart/Chart.yaml b/operator/documentdb-helm-chart/Chart.yaml index 65959429..9bca1e09 100644 --- a/operator/documentdb-helm-chart/Chart.yaml +++ b/operator/documentdb-helm-chart/Chart.yaml @@ -6,5 +6,5 @@ description: A Helm chart for deploying the DocumentDB operator appVersion: "0.1.3" dependencies: - name: cloudnative-pg - version: "0.26.1" + version: "0.27.0" repository: "https://cloudnative-pg.github.io/charts/" \ No newline at end of file diff --git a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml index b5e41e80..738bf14c 100644 --- a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml +++ b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml @@ -192,6 +192,12 @@ spec: maximum: 1 minimum: 1 type: integer + postgresImage: + default: ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm + description: |- + PostgresImage is the container image to use for the PostgreSQL server. + If not specified, defaults to "ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm". + type: string resource: description: Resource specifies the storage resources for DocumentDB. properties: @@ -305,6 +311,10 @@ spec: properties: connectionString: type: string + documentDBVersion: + description: DocumentDBVersion is the currently installed version + of the DocumentDB extension. + type: string localPrimary: type: string status: diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 2ed37a0e..9c772ae7 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -38,6 +38,12 @@ type DocumentDBSpec struct { // If not specified, defaults to a version that matches the DocumentDB operator version. GatewayImage string `json:"gatewayImage,omitempty"` + // PostgresImage is the container image to use for the PostgreSQL server. + // If not specified, defaults to "ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm". + // +kubebuilder:default="ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm" + // +optional + PostgresImage string `json:"postgresImage,omitempty"` + // DocumentDbCredentialSecret is the name of the Kubernetes Secret containing credentials // for the DocumentDB gateway (expects keys `username` and `password`). If omitted, // a default secret name `documentdb-credentials` is used. @@ -214,6 +220,9 @@ type DocumentDBStatus struct { TargetPrimary string `json:"targetPrimary,omitempty"` LocalPrimary string `json:"localPrimary,omitempty"` + // DocumentDBVersion is the currently installed version of the DocumentDB extension. + DocumentDBVersion string `json:"documentDBVersion,omitempty"` + // TLS reports gateway TLS provisioning status (Phase 1). TLS *TLSStatus `json:"tls,omitempty"` } diff --git a/operator/src/config/crd/bases/documentdb.io_dbs.yaml b/operator/src/config/crd/bases/documentdb.io_dbs.yaml index b5e41e80..738bf14c 100644 --- a/operator/src/config/crd/bases/documentdb.io_dbs.yaml +++ b/operator/src/config/crd/bases/documentdb.io_dbs.yaml @@ -192,6 +192,12 @@ spec: maximum: 1 minimum: 1 type: integer + postgresImage: + default: ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm + description: |- + PostgresImage is the container image to use for the PostgreSQL server. + If not specified, defaults to "ghcr.io/cloudnative-pg/postgresql:18-standard-bookworm". + type: string resource: description: Resource specifies the storage resources for DocumentDB. properties: @@ -305,6 +311,10 @@ spec: properties: connectionString: type: string + documentDBVersion: + description: DocumentDBVersion is the currently installed version + of the DocumentDB extension. + type: string localPrimary: type: string status: diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 5761160b..64f8ecc0 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -8,6 +8,7 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/pointer" @@ -16,7 +17,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, documentdb_image, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { +func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, documentdbImage, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { sidecarPluginName := documentdb.Spec.SidecarInjectorPluginName if sidecarPluginName == "" { sidecarPluginName = util.DEFAULT_SIDECAR_INJECTOR_PLUGIN @@ -55,7 +56,7 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu Spec: func() cnpgv1.ClusterSpec { spec := cnpgv1.ClusterSpec{ Instances: documentdb.Spec.InstancesPerNode, - ImageName: documentdb_image, + ImageName: documentdb.Spec.PostgresImage, StorageConfiguration: cnpgv1.StorageConfiguration{ StorageClass: storageClassPointer, // Use configured storage class or default Size: documentdb.Spec.Resource.Storage.PvcSize, @@ -73,9 +74,16 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu Parameters: params, }} }(), - PostgresUID: 105, - PostgresGID: 108, PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: documentdbImage, + }, + LdLibraryPath: []string{"lib"}, + }, + }, AdditionalLibraries: []string{"pg_cron", "pg_documentdb_core", "pg_documentdb"}, Parameters: map[string]string{ "cron.database_name": "postgres", diff --git a/operator/src/internal/controller/certificate_controller_test.go b/operator/src/internal/controller/certificate_controller_test.go new file mode 100644 index 00000000..96620a3a --- /dev/null +++ b/operator/src/internal/controller/certificate_controller_test.go @@ -0,0 +1,143 @@ +package controller + +import ( + "context" + "testing" + "time" + + cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" + util "github.com/documentdb/documentdb-operator/internal/utils" +) + +// helper to build TLS reconciler with objects +func buildCertificateReconciler(t *testing.T, objs ...runtime.Object) *CertificateReconciler { + scheme := runtime.NewScheme() + require.NoError(t, dbpreview.AddToScheme(scheme)) + require.NoError(t, cmapi.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + builder := fake.NewClientBuilder().WithScheme(scheme) + if len(objs) > 0 { + builder = builder.WithRuntimeObjects(objs...) + clientObjs := make([]client.Object, 0, len(objs)) + for _, obj := range objs { + if co, ok := obj.(client.Object); ok { + clientObjs = append(clientObjs, co) + } + } + if len(clientObjs) > 0 { + builder = builder.WithStatusSubresource(clientObjs...) + } + } + c := builder.Build() + return &CertificateReconciler{Client: c, Scheme: scheme} +} + +func baseDocumentDB(name, ns string) *dbpreview.DocumentDB { + return &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: dbpreview.DocumentDBSpec{ + NodeCount: 1, + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "1Gi"}}, + DocumentDBImage: "test-image", + ExposeViaService: dbpreview.ExposeViaService{ServiceType: "ClusterIP"}, + }, + } +} + +func TestEnsureProvidedSecret(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-prov", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "Provided", Provided: &dbpreview.ProvidedTLS{SecretName: "mycert"}}} + // Secret missing first + r := buildCertificateReconciler(t, ddb) + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + require.False(t, ddb.Status.TLS.Ready, "Should not be ready until secret exists") + + // Create secret with required keys then reconcile again + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "mycert", Namespace: "default"}, Data: map[string][]byte{"tls.crt": []byte("crt"), "tls.key": []byte("key")}} + require.NoError(t, r.Client.Create(ctx, secret)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready, "Provided secret should mark TLS ready") + require.Equal(t, "mycert", ddb.Status.TLS.SecretName) +} + +func TestEnsureCertManagerManagedCert(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-cm", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "CertManager", CertManager: &dbpreview.CertManagerTLS{IssuerRef: dbpreview.IssuerRef{Name: "test-issuer", Kind: "Issuer"}, DNSNames: []string{"custom.example"}}}} + ddb.Status.TLS = &dbpreview.TLSStatus{} + issuer := &cmapi.Issuer{ObjectMeta: metav1.ObjectMeta{Name: "test-issuer", Namespace: "default"}, Spec: cmapi.IssuerSpec{IssuerConfig: cmapi.IssuerConfig{SelfSigned: &cmapi.SelfSignedIssuer{}}}} + r := buildCertificateReconciler(t, ddb, issuer) + + // Call certificate ensure twice to mimic reconcile loops + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + + cert := &cmapi.Certificate{} + // fetch certificate (self-created by reconcile). If not found, run reconcile again once. + require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-cm-gateway-cert", Namespace: "default"}, cert)) + // Debug: list all certificates to ensure store functioning + certList := &cmapi.CertificateList{} + _ = r.Client.List(ctx, certList) + for _, c := range certList.Items { + t.Logf("Found certificate: %s/%s secret=%s", c.Namespace, c.Name, c.Spec.SecretName) + } + require.Contains(t, cert.Spec.DNSNames, "custom.example") + // Should include service DNS names + serviceBase := util.DOCUMENTDB_SERVICE_PREFIX + ddb.Name + require.Contains(t, cert.Spec.DNSNames, serviceBase) + + // Simulate readiness condition then invoke ensure again (mimic reconcile loop) + cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) + require.NoError(t, r.Client.Update(ctx, cert)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready, "Cert-manager managed cert should mark ready after condition true") + require.NotEmpty(t, ddb.Status.TLS.SecretName) +} + +func TestEnsureSelfSignedCert(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-ss", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "SelfSigned"}} + ddb.Status.TLS = &dbpreview.TLSStatus{} + r := buildCertificateReconciler(t, ddb) + + // First call should create issuer and certificate + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + + // Certificate should exist + cert := &cmapi.Certificate{} + require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-ss-gateway-cert", Namespace: "default"}, cert)) + + // Simulate ready condition and call again + cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) + require.NoError(t, r.Client.Update(ctx, cert)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready) + require.NotEmpty(t, ddb.Status.TLS.SecretName) +} diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index c0e9b126..cd9a1f3a 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -6,6 +6,7 @@ package controller import ( "bytes" "context" + "encoding/json" "fmt" "slices" "strings" @@ -257,7 +258,13 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) } } - // Don't reque again unless there is a change + // Check if documentdb extension needs to be upgraded (image update + ALTER EXTENSION) + if err := r.upgradeDocumentDBExtensionIfNeeded(ctx, currentCnpgCluster, desiredCnpgCluster, documentdb); err != nil { + logger.Error(err, "Failed to upgrade DocumentDB extension") + return ctrl.Result{RequeueAfter: RequeueAfterShort}, nil + } + + // Don't requeue again unless there is a change return ctrl.Result{}, nil } @@ -469,3 +476,184 @@ func (r *DocumentDBReconciler) executeSQLCommand(ctx context.Context, cluster *c return stdout.String(), nil } + +// parseExtensionVersionsFromOutput parses the output of pg_available_extensions query +// Returns defaultVersion, installedVersion, and a boolean indicating if parsing was successful +// Expected output format: +// +// default_version | installed_version +// -----------------+------------------- +// 0.110-0 | 0.110-0 +func parseExtensionVersionsFromOutput(output string) (defaultVersion, installedVersion string, ok bool) { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 3 { + return "", "", false + } + + // Parse the data row (3rd line, index 2) + dataLine := strings.TrimSpace(lines[2]) + parts := strings.Split(dataLine, "|") + if len(parts) != 2 { + return "", "", false + } + + defaultVersion = strings.TrimSpace(parts[0]) + installedVersion = strings.TrimSpace(parts[1]) + return defaultVersion, installedVersion, true +} + +// upgradeDocumentDBExtensionIfNeeded handles the complete DocumentDB extension upgrade process: +// 1. Updates the extension image in CNPG cluster spec if needed (triggers rolling restart) +// 2. Runs ALTER EXTENSION documentdb UPDATE if the installed version differs from default +// 3. Updates the DocumentDB status with the new extension version +func (r *DocumentDBReconciler) upgradeDocumentDBExtensionIfNeeded(ctx context.Context, currentCluster, desiredCluster *cnpgv1.Cluster, documentdb *dbpreview.DocumentDB) error { + logger := log.FromContext(ctx) + + // Refetch documentdb to avoid potential race conditions with status updates + if err := r.Get(ctx, types.NamespacedName{Name: documentdb.Name, Namespace: documentdb.Namespace}, documentdb); err != nil { + return fmt.Errorf("failed to refetch DocumentDB resource: %w", err) + } + + // Step 1: Check if extension image needs to be updated in CNPG cluster spec + imageUpdated, err := r.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + if err != nil { + return fmt.Errorf("failed to update extension image: %w", err) + } + + // If image was updated, CNPG will trigger a rolling restart. + // Wait for pod to become healthy before running ALTER EXTENSION. + if imageUpdated { + logger.Info("Extension image updated, waiting for pod to become healthy before running ALTER EXTENSION") + return nil + } + + // Step 2: Check if primary pod is healthy before running ALTER EXTENSION + if !slices.Contains(currentCluster.Status.InstancesStatus[cnpgv1.PodHealthy], currentCluster.Status.CurrentPrimary) { + logger.Info("Current primary pod is not healthy; skipping DocumentDB extension upgrade") + return nil + } + + // Step 3: Check if ALTER EXTENSION UPDATE is needed + checkVersionSQL := "SELECT default_version, installed_version FROM pg_available_extensions WHERE name = 'documentdb'" + output, err := r.executeSQLCommand(ctx, currentCluster, checkVersionSQL) + if err != nil { + return fmt.Errorf("failed to check documentdb extension versions: %w", err) + } + + defaultVersion, installedVersion, ok := parseExtensionVersionsFromOutput(output) + if !ok { + logger.Info("DocumentDB extension not found or not installed yet", "output", output) + return nil + } + + if installedVersion == "" { + logger.Info("DocumentDB extension is not installed yet") + return nil + } + + // Step 4: Update DocumentDB version in status (even if no upgrade needed) + if documentdb.Status.DocumentDBVersion != installedVersion { + documentdb.Status.DocumentDBVersion = installedVersion + if err := r.Status().Update(ctx, documentdb); err != nil { + logger.Error(err, "Failed to update DocumentDB status with extension version") + return fmt.Errorf("failed to update DocumentDB status with extension version: %w", err) + } + } + + // If versions match, no upgrade needed + if defaultVersion == installedVersion { + logger.V(1).Info("DocumentDB extension is up to date", "version", installedVersion) + return nil + } + + // Step 5: Run ALTER EXTENSION to upgrade + logger.Info("Upgrading DocumentDB extension", + "fromVersion", installedVersion, + "toVersion", defaultVersion) + + updateSQL := "ALTER EXTENSION documentdb UPDATE" + if _, err := r.executeSQLCommand(ctx, currentCluster, updateSQL); err != nil { + return fmt.Errorf("failed to run ALTER EXTENSION documentdb UPDATE: %w", err) + } + + logger.Info("Successfully upgraded DocumentDB extension", + "fromVersion", installedVersion, + "toVersion", defaultVersion) + + // Step 6: Update DocumentDB version in status after upgrade + documentdb.Status.DocumentDBVersion = defaultVersion + if err := r.Status().Update(ctx, documentdb); err != nil { + logger.Error(err, "Failed to update DocumentDB status after extension upgrade") + return fmt.Errorf("failed to update DocumentDB status after extension upgrade: %w", err) + } + + return nil +} + +// updateExtensionImageIfNeeded checks if the CNPG cluster's extension image differs from the desired one +// and updates it using JSON patch if needed. Returns true if the image was updated. +func (r *DocumentDBReconciler) updateExtensionImageIfNeeded(ctx context.Context, currentCluster, desiredCluster *cnpgv1.Cluster) (bool, error) { + logger := log.FromContext(ctx) + + // Get current documentdb extension image + var currentImage string + for _, ext := range currentCluster.Spec.PostgresConfiguration.Extensions { + if ext.Name == "documentdb" { + currentImage = ext.ImageVolumeSource.Reference + break + } + } + + // Get desired documentdb extension image + var desiredExtImage string + for _, ext := range desiredCluster.Spec.PostgresConfiguration.Extensions { + if ext.Name == "documentdb" { + desiredExtImage = ext.ImageVolumeSource.Reference + break + } + } + + // If images are the same, no update needed + if currentImage == desiredExtImage { + return false, nil + } + + logger.Info("Updating DocumentDB extension image in CNPG cluster", + "currentImage", currentImage, + "desiredImage", desiredExtImage, + "clusterName", currentCluster.Name) + + // Find the index of the documentdb extension + extIndex := -1 + for i, ext := range currentCluster.Spec.PostgresConfiguration.Extensions { + if ext.Name == "documentdb" { + extIndex = i + break + } + } + + if extIndex == -1 { + return false, fmt.Errorf("documentdb extension not found in CNPG cluster spec") + } + + // Use JSON patch to update the extension image + patch := []map[string]interface{}{ + { + "op": "replace", + "path": fmt.Sprintf("/spec/postgresql/extensions/%d/image/reference", extIndex), + "value": desiredExtImage, + }, + } + + patchBytes, err := json.Marshal(patch) + if err != nil { + return false, fmt.Errorf("failed to marshal patch: %w", err) + } + + if err := r.Client.Patch(ctx, currentCluster, client.RawPatch(types.JSONPatchType, patchBytes)); err != nil { + return false, fmt.Errorf("failed to patch CNPG cluster with new extension image: %w", err) + } + + logger.Info("Successfully updated DocumentDB extension image in CNPG cluster") + return true, nil +} diff --git a/operator/src/internal/controller/documentdb_controller_test.go b/operator/src/internal/controller/documentdb_controller_test.go index 96620a3a..8d04a42e 100644 --- a/operator/src/internal/controller/documentdb_controller_test.go +++ b/operator/src/internal/controller/documentdb_controller_test.go @@ -1,13 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + package controller import ( "context" - "testing" - "time" - cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" - cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" - "github.com/stretchr/testify/require" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -16,128 +17,854 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" dbpreview "github.com/documentdb/documentdb-operator/api/preview" - util "github.com/documentdb/documentdb-operator/internal/utils" ) -// helper to build TLS reconciler with objects -func buildCertificateReconciler(t *testing.T, objs ...runtime.Object) *CertificateReconciler { - scheme := runtime.NewScheme() - require.NoError(t, dbpreview.AddToScheme(scheme)) - require.NoError(t, cmapi.AddToScheme(scheme)) - require.NoError(t, corev1.AddToScheme(scheme)) - builder := fake.NewClientBuilder().WithScheme(scheme) - if len(objs) > 0 { - builder = builder.WithRuntimeObjects(objs...) - clientObjs := make([]client.Object, 0, len(objs)) - for _, obj := range objs { - if co, ok := obj.(client.Object); ok { - clientObjs = append(clientObjs, co) - } - } - if len(clientObjs) > 0 { - builder = builder.WithStatusSubresource(clientObjs...) - } - } - c := builder.Build() - return &CertificateReconciler{Client: c, Scheme: scheme} +// parseExtensionVersions parses the output of pg_available_extensions query +// Returns defaultVersion, installedVersion, and a boolean indicating if parsing was successful +func parseExtensionVersions(output string) (defaultVersion, installedVersion string, ok bool) { + return parseExtensionVersionsFromOutput(output) } -func baseDocumentDB(name, ns string) *dbpreview.DocumentDB { - return &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, - Spec: dbpreview.DocumentDBSpec{ - NodeCount: 1, - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "1Gi"}}, - DocumentDBImage: "test-image", - ExposeViaService: dbpreview.ExposeViaService{ServiceType: "ClusterIP"}, - }, - } -} +var _ = Describe("DocumentDB Controller", func() { + const ( + clusterName = "test-cluster" + clusterNamespace = "default" + ) -func TestEnsureProvidedSecret(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-prov", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "Provided", Provided: &dbpreview.ProvidedTLS{SecretName: "mycert"}}} - // Secret missing first - r := buildCertificateReconciler(t, ddb) - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - require.False(t, ddb.Status.TLS.Ready, "Should not be ready until secret exists") - - // Create secret with required keys then reconcile again - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "mycert", Namespace: "default"}, Data: map[string][]byte{"tls.crt": []byte("crt"), "tls.key": []byte("key")}} - require.NoError(t, r.Client.Create(ctx, secret)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready, "Provided secret should mark TLS ready") - require.Equal(t, "mycert", ddb.Status.TLS.SecretName) -} + var ( + ctx context.Context + scheme *runtime.Scheme + ) -func TestEnsureCertManagerManagedCert(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-cm", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "CertManager", CertManager: &dbpreview.CertManagerTLS{IssuerRef: dbpreview.IssuerRef{Name: "test-issuer", Kind: "Issuer"}, DNSNames: []string{"custom.example"}}}} - ddb.Status.TLS = &dbpreview.TLSStatus{} - issuer := &cmapi.Issuer{ObjectMeta: metav1.ObjectMeta{Name: "test-issuer", Namespace: "default"}, Spec: cmapi.IssuerSpec{IssuerConfig: cmapi.IssuerConfig{SelfSigned: &cmapi.SelfSignedIssuer{}}}} - r := buildCertificateReconciler(t, ddb, issuer) - - // Call certificate ensure twice to mimic reconcile loops - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - - cert := &cmapi.Certificate{} - // fetch certificate (self-created by reconcile). If not found, run reconcile again once. - require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-cm-gateway-cert", Namespace: "default"}, cert)) - // Debug: list all certificates to ensure store functioning - certList := &cmapi.CertificateList{} - _ = r.Client.List(ctx, certList) - for _, c := range certList.Items { - t.Logf("Found certificate: %s/%s secret=%s", c.Namespace, c.Name, c.Spec.SecretName) - } - require.Contains(t, cert.Spec.DNSNames, "custom.example") - // Should include service DNS names - serviceBase := util.DOCUMENTDB_SERVICE_PREFIX + ddb.Name - require.Contains(t, cert.Spec.DNSNames, serviceBase) - - // Simulate readiness condition then invoke ensure again (mimic reconcile loop) - cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) - require.NoError(t, r.Client.Update(ctx, cert)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready, "Cert-manager managed cert should mark ready after condition true") - require.NotEmpty(t, ddb.Status.TLS.SecretName) -} + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + Expect(dbpreview.AddToScheme(scheme)).To(Succeed()) + Expect(cnpgv1.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + }) -func TestEnsureSelfSignedCert(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-ss", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "SelfSigned"}} - ddb.Status.TLS = &dbpreview.TLSStatus{} - r := buildCertificateReconciler(t, ddb) - - // First call should create issuer and certificate - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - - // Certificate should exist - cert := &cmapi.Certificate{} - require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-ss-gateway-cert", Namespace: "default"}, cert)) - - // Simulate ready condition and call again - cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) - require.NoError(t, r.Client.Update(ctx, cert)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready) - require.NotEmpty(t, ddb.Status.TLS.SecretName) -} + Describe("updateExtensionImageIfNeeded", func() { + It("should return false when current and desired images are the same", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeFalse()) + + // Verify the cluster was not updated (image should remain the same) + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v1.0.0")) + }) + + It("should update extension image and return true when current and desired images differ", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeTrue()) + + // Verify the cluster was updated with the new image + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v2.0.0")) + }) + + It("should return error when documentdb extension is not found in current cluster", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "other-extension", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "other/image:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + _, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("documentdb extension not found")) + }) + + It("should handle cluster with multiple extensions and update only documentdb", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "pg_stat_statements", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_stat_statements:v1.0.0", + }, + }, + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "pg_stat_statements", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_stat_statements:v1.0.0", + }, + }, + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeTrue()) + + // Verify only documentdb extension was updated + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions).To(HaveLen(3)) + Expect(result.Spec.PostgresConfiguration.Extensions[0].Name).To(Equal("pg_stat_statements")) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("postgres/pg_stat_statements:v1.0.0")) + Expect(result.Spec.PostgresConfiguration.Extensions[1].Name).To(Equal("documentdb")) + Expect(result.Spec.PostgresConfiguration.Extensions[1].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v2.0.0")) + Expect(result.Spec.PostgresConfiguration.Extensions[2].Name).To(Equal("pg_cron")) + Expect(result.Spec.PostgresConfiguration.Extensions[2].ImageVolumeSource.Reference).To(Equal("postgres/pg_cron:v1.0.0")) + }) + + It("should return false when no extensions exist in both clusters", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{}, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{}, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Both clusters have no extensions, images are both empty strings, so they match + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeFalse()) + }) + + It("should handle documentdb extension as the only extension", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + LdLibraryPath: []string{"lib"}, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v3.0.0", + }, + LdLibraryPath: []string{"lib"}, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeTrue()) + + // Verify the cluster was updated with the new image + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v3.0.0")) + // Verify other fields are preserved + Expect(result.Spec.PostgresConfiguration.Extensions[0].LdLibraryPath).To(Equal([]string{"lib"})) + }) + + It("should handle documentdb extension at the beginning of extensions list", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeTrue()) + + // Verify the cluster was updated + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v2.0.0")) + }) + + It("should handle documentdb extension at the end of extensions list", func() { + currentCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "pg_cron", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "postgres/pg_cron:v1.0.0", + }, + }, + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(currentCluster). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + updated, err := reconciler.updateExtensionImageIfNeeded(ctx, currentCluster, desiredCluster) + Expect(err).ToNot(HaveOccurred()) + Expect(updated).To(BeTrue()) + + // Verify the cluster was updated + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[1].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v2.0.0")) + }) + }) + + Describe("upgradeDocumentDBExtensionIfNeeded", func() { + It("should return nil when primary pod is not healthy", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + Status: cnpgv1.ClusterStatus{ + CurrentPrimary: "test-cluster-1", + InstancesStatus: map[cnpgv1.PodStatus][]string{ + cnpgv1.PodHealthy: {"test-cluster-2", "test-cluster-3"}, // Primary not in healthy list + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-documentdb", + Namespace: clusterNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cluster, documentdb). + WithStatusSubresource(&dbpreview.DocumentDB{}). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + err := reconciler.upgradeDocumentDBExtensionIfNeeded(ctx, cluster, desiredCluster, documentdb) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return nil when InstancesStatus is empty", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + Status: cnpgv1.ClusterStatus{ + CurrentPrimary: "test-cluster-1", + InstancesStatus: map[cnpgv1.PodStatus][]string{}, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-documentdb", + Namespace: clusterNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cluster, documentdb). + WithStatusSubresource(&dbpreview.DocumentDB{}). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + err := reconciler.upgradeDocumentDBExtensionIfNeeded(ctx, cluster, desiredCluster, documentdb) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should return nil and update image when image differs", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v1.0.0", + }, + }, + }, + }, + }, + Status: cnpgv1.ClusterStatus{ + CurrentPrimary: "test-cluster-1", + InstancesStatus: map[cnpgv1.PodStatus][]string{ + cnpgv1.PodHealthy: {"test-cluster-1"}, + }, + }, + } + + desiredCluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: clusterNamespace, + }, + Spec: cnpgv1.ClusterSpec{ + PostgresConfiguration: cnpgv1.PostgresConfiguration{ + Extensions: []cnpgv1.ExtensionConfiguration{ + { + Name: "documentdb", + ImageVolumeSource: corev1.ImageVolumeSource{ + Reference: "documentdb/documentdb:v2.0.0", + }, + }, + }, + }, + }, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-documentdb", + Namespace: clusterNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cluster, documentdb). + WithStatusSubresource(&dbpreview.DocumentDB{}). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + } + + // Should update image and return nil (waiting for pod to become healthy) + err := reconciler.upgradeDocumentDBExtensionIfNeeded(ctx, cluster, desiredCluster, documentdb) + Expect(err).ToNot(HaveOccurred()) + + // Verify image was updated + result := &cnpgv1.Cluster{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, result)).To(Succeed()) + Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("documentdb/documentdb:v2.0.0")) + }) + }) + + Describe("parseExtensionVersionsFromOutput", func() { + It("should parse valid output with matching versions", func() { + output := ` default_version | installed_version +-----------------+------------------- + 0.110-0 | 0.110-0 +(1 row)` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("0.110-0")) + Expect(installedVersion).To(Equal("0.110-0")) + }) + + It("should parse valid output with different versions", func() { + output := ` default_version | installed_version +-----------------+------------------- + 0.111-0 | 0.110-0 +(1 row)` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("0.111-0")) + Expect(installedVersion).To(Equal("0.110-0")) + }) + + It("should handle empty installed_version", func() { + output := ` default_version | installed_version +-----------------+------------------- + 0.110-0 | +(1 row)` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("0.110-0")) + Expect(installedVersion).To(Equal("")) + }) + + It("should return false for output with less than 3 lines", func() { + output := ` default_version | installed_version +-----------------+-------------------` + + _, _, ok := parseExtensionVersions(output) + Expect(ok).To(BeFalse()) + }) + + It("should return false for empty output", func() { + output := "" + + _, _, ok := parseExtensionVersions(output) + Expect(ok).To(BeFalse()) + }) + + It("should return false for output with no pipe separator", func() { + output := ` default_version installed_version +-----------------+------------------- + 0.110-0 0.110-0 +(1 row)` + + _, _, ok := parseExtensionVersions(output) + Expect(ok).To(BeFalse()) + }) + + It("should return false for output with too many pipe separators", func() { + output := ` default_version | installed_version | extra +-----------------+-------------------+------ + 0.110-0 | 0.110-0 | data +(1 row)` + + _, _, ok := parseExtensionVersions(output) + Expect(ok).To(BeFalse()) + }) + + It("should handle semantic version strings", func() { + output := ` default_version | installed_version +-----------------+------------------- + 1.2.3-beta.1 | 1.2.2 +(1 row)` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("1.2.3-beta.1")) + Expect(installedVersion).To(Equal("1.2.2")) + }) + + It("should trim whitespace from versions", func() { + output := ` default_version | installed_version +-----------------+------------------- + 0.110-0 | 0.109-0 +(1 row)` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("0.110-0")) + Expect(installedVersion).To(Equal("0.109-0")) + }) + + It("should handle output without row count footer", func() { + output := ` default_version | installed_version +-----------------+------------------- + 0.110-0 | 0.110-0` + + defaultVersion, installedVersion, ok := parseExtensionVersions(output) + Expect(ok).To(BeTrue()) + Expect(defaultVersion).To(Equal("0.110-0")) + Expect(installedVersion).To(Equal("0.110-0")) + }) + }) +})