diff --git a/pkg/comp-functions/functions/common/backup/backup.go b/pkg/comp-functions/functions/common/backup/backup.go index dda734d6b4..a032132af0 100644 --- a/pkg/comp-functions/functions/common/backup/backup.go +++ b/pkg/comp-functions/functions/common/backup/backup.go @@ -2,6 +2,7 @@ package backup import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -10,6 +11,7 @@ import ( xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" k8upv1 "github.com/k8up-io/k8up/v2/api/v1" "github.com/sethvargo/go-password/password" + xhelmv1 "github.com/vshn/appcat/v4/apis/helm/release/v1beta1" xkube "github.com/vshn/appcat/v4/apis/kubernetes/v1alpha2" appcatv1 "github.com/vshn/appcat/v4/apis/v1" "github.com/vshn/appcat/v4/pkg/comp-functions/functions/common" @@ -17,6 +19,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/ptr" controllerruntime "sigs.k8s.io/controller-runtime" ) @@ -29,6 +32,13 @@ const ( BackupDisabledTimestampLabel = "appcat.vshn.io/backup-disabled-timestamp" ) +// RcloneProxyCredentials contains the backend credentials to connect to rclone +type RcloneProxyCredentials struct { + Region string + AccessID string + AccessKey string +} + // AddK8upBackup creates an S3 bucket and a K8up schedule according to the composition spec. // When backup is disabled, it only creates/preserves the bucket for retention but skips other backup objects. func AddK8upBackup(ctx context.Context, svc *runtime.ServiceRuntime, comp common.InfoGetter) error { @@ -427,3 +437,109 @@ func PatchConnectionSecretWithAllowDeletion(ctx context.Context, comp common.Inf return svc.SetDesiredKubeObject(secretObject, secretObjectName, runtime.KubeOptionAllowDeletion) } + +// DeployRcloneProxy deploys the rclone encryption proxy helm chart +func DeployRcloneProxy(ctx context.Context, svc *runtime.ServiceRuntime, comp common.InfoGetter) (*RcloneProxyCredentials, error) { + l := controllerruntime.LoggerFrom(ctx) + + // Get bucket connection details from observed composite resource + cd, err := svc.GetObservedComposedResourceConnectionDetails(comp.GetName() + "-backup") + if err != nil { + if err == runtime.ErrNotFound { + l.V(1).Info("Backup bucket connection details not found yet, skipping rclone proxy deployment") + return nil, nil + } + return nil, fmt.Errorf("cannot get backup bucket connection details: %w", err) + } + + bucket := string(cd["BUCKET_NAME"]) + region := string(cd["AWS_REGION"]) + accessID := string(cd["AWS_ACCESS_KEY_ID"]) + accessKey := string(cd["AWS_SECRET_ACCESS_KEY"]) + + // Determine bucket credentials secret name in instance namespace + bucketSecretName := credentialSecretName + "-" + comp.GetName() + + // Get chart configuration from service config + chartRepository := svc.Config.Data["rcloneproxyChartSource"] + chartVersion := svc.Config.Data["rcloneproxyChartVersion"] + chartName := svc.Config.Data["rcloneproxyChartName"] + + if chartRepository == "" || chartVersion == "" || chartName == "" { + return nil, fmt.Errorf("rclone chart configuration missing in service config (rcloneproxyChartSource, rcloneproxyChartVersion, rcloneproxyChartName)") + } + + // Prepare Helm values for rclone chart + isOpenshift := svc.GetBoolFromCompositionConfig("isOpenshift") + + values := map[string]any{ + "backend": map[string]any{ + "secretRef": map[string]any{ + "name": bucketSecretName, + "keys": map[string]any{ + "accessKeyID": "AWS_ACCESS_KEY_ID", + "accessKeySecret": "AWS_SECRET_ACCESS_KEY", + "endpoint": "ENDPOINT_URL", + "region": "AWS_REGION", + "bucket": "BUCKET_NAME", + }, + }, + }, + "isOpenshift": isOpenshift, + } + + // Marshal values to JSON + valueBytes, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("cannot marshal rclone helm values: %w", err) + } + + // Create Helm release for rclone proxy + release := &xhelmv1.Release{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + // needed to disable backups + runtime.WebhookAllowDeletionLabel: "true", + }, + Annotations: map[string]string{ + // Set stable external-name so Helm release name doesn't change on recreate + "crossplane.io/external-name": "rclone", + }, + }, + Spec: xhelmv1.ReleaseSpec{ + ForProvider: xhelmv1.ReleaseParameters{ + Chart: xhelmv1.ChartSpec{ + Repository: chartRepository, + Version: chartVersion, + Name: chartName, + }, + Namespace: comp.GetInstanceNamespace(), + ValuesSpec: xhelmv1.ValuesSpec{ + Values: k8sruntime.RawExtension{ + Raw: valueBytes, + }, + }, + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{ + Name: "helm", + }, + }, + }, + } + + err = svc.SetDesiredComposedResourceWithName(release, "rclone") + if err != nil { + return nil, fmt.Errorf("cannot set desired rclone proxy helm release: %w", err) + } + + l.Info("Deployed rclone encryption proxy", + "namespace", comp.GetInstanceNamespace(), + "backendBucket", bucket) + + return &RcloneProxyCredentials{ + Region: region, + AccessID: accessID, + AccessKey: accessKey, + }, nil +} diff --git a/pkg/comp-functions/functions/vshnpostgrescnpg/backup.go b/pkg/comp-functions/functions/vshnpostgrescnpg/backup.go index aafc34ed36..ceea8d30e7 100644 --- a/pkg/comp-functions/functions/vshnpostgrescnpg/backup.go +++ b/pkg/comp-functions/functions/vshnpostgrescnpg/backup.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "maps" - "strings" vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" "github.com/vshn/appcat/v4/pkg/comp-functions/functions/common" @@ -12,15 +11,6 @@ import ( "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" ) -// Backup bucket connection details -type backupCredentials struct { - endpoint string - bucket string - region string - accessId string - accessKey string -} - // Bootstrap backup (if enabled) func SetupBackup(ctx context.Context, svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL, values map[string]any) error { // CreateObjectBucket has its own IsBackupEnabled to deal with bucket retention @@ -36,21 +26,24 @@ func SetupBackup(ctx context.Context, svc *runtime.ServiceRuntime, comp *vshnv1. } if comp.IsBackupEnabled() && comp.GetInstances() != 0 { - // Configure barman cloud plugin via helm values - if err := insertBackupValues(svc, comp, values); err != nil { - return err + // Deploy rclone encryption proxy and get backend credentials + proxyCreds, err := backup.DeployRcloneProxy(ctx, svc, comp) + if err != nil { + return fmt.Errorf("cannot deploy rclone encryption proxy: %w", err) + } + + // Configure barman cloud plugin via helm values with rclone proxy + if proxyCreds != nil { + if err := insertBackupValues(svc, comp, values, proxyCreds); err != nil { + return err + } } } return nil } // Add backup config to helm values -func insertBackupValues(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL, values map[string]any) error { - connectionDetails, err := getBackupBucketConnectionDetails(svc, comp) - if err != nil { - return err - } - +func insertBackupValues(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL, values map[string]any, proxyCreds *backup.RcloneProxyCredentials) error { retention := comp.GetBackupRetention() retentionDays := retention.KeepDaily if retentionDays <= 0 { @@ -64,7 +57,7 @@ func insertBackupValues(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL "isWALArchiver": true, "parameters": map[string]any{ "barmanObjectName": "postgresql-object-store", - "serverName": "", + "serverName": "postgresql", }, }} @@ -76,20 +69,21 @@ func insertBackupValues(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL } cluster["plugins"] = clusterPlugins - // Configure backups using the barman cloud plugin + // Configure backups using the barman cloud plugin with rclone encryption proxy maps.Copy(values, map[string]any{ "backups": map[string]any{ "enabled": true, "provider": "s3", - "endpointURL": connectionDetails.endpoint, - "region": connectionDetails.region, + "endpointURL": "http://rcloneproxy:9095", + "region": proxyCreds.Region, "retentionPolicy": fmt.Sprintf("%dd", retentionDays), "s3": map[string]any{ - "bucket": connectionDetails.bucket, - "region": connectionDetails.region, + // rclone gets confused when the bucket name matches the one it's rooted at. Since this can be arbitrary we hardcode it + "bucket": "backup", + "region": proxyCreds.Region, "path": "/", - "accessKey": connectionDetails.accessId, - "secretKey": connectionDetails.accessKey, + "accessKey": proxyCreds.AccessID, + "secretKey": proxyCreds.AccessKey, }, "wal": map[string]any{ "compression": "gzip", @@ -118,24 +112,6 @@ func insertBackupValues(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL return nil } -func getBackupBucketConnectionDetails(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNPostgreSQL) (backupCredentials, error) { - backupCredentials := backupCredentials{} - cd, err := svc.GetObservedComposedResourceConnectionDetails(comp.GetName() + "-backup") - if err != nil && err == runtime.ErrNotFound { - return backupCredentials, fmt.Errorf("backup bucket connection details not found") - } else if err != nil { - return backupCredentials, err - } - - endpoint, _ := strings.CutSuffix(string(cd["ENDPOINT_URL"]), "/") - backupCredentials.endpoint = endpoint - backupCredentials.bucket = string(cd["BUCKET_NAME"]) - backupCredentials.region = string(cd["AWS_REGION"]) - backupCredentials.accessId = string(cd["AWS_ACCESS_KEY_ID"]) - backupCredentials.accessKey = string(cd["AWS_SECRET_ACCESS_KEY"]) - return backupCredentials, nil -} - // Transform backup schedule according to robfig/cron (used by CNPG) // https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format func transformSchedule(thisSchedule string) string { diff --git a/pkg/comp-functions/functions/vshnpostgrescnpg/backup_test.go b/pkg/comp-functions/functions/vshnpostgrescnpg/backup_test.go index 085f52037b..f223b8e3b5 100644 --- a/pkg/comp-functions/functions/vshnpostgrescnpg/backup_test.go +++ b/pkg/comp-functions/functions/vshnpostgrescnpg/backup_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + xhelmv1 "github.com/vshn/appcat/v4/apis/helm/release/v1beta1" appcatv1 "github.com/vshn/appcat/v4/apis/v1" "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" ) @@ -44,24 +45,18 @@ func TestBackupBooststrapEnabled(t *testing.T) { assert.Equal(t, "s3", backupValues["provider"]) assert.Equal(t, "6d", backupValues["retentionPolicy"]) - // Bucket configuration - cd, err := getBackupBucketConnectionDetails(svc, comp) - assert.NoError(t, err) - assert.Equal(t, cd.endpoint, "https://s3.minio.local") // No trailing / - assert.Equal(t, cd.bucket, "backupBucket") - assert.Equal(t, cd.region, "rma") - assert.Equal(t, cd.accessId, "secretAccessId") - assert.Equal(t, cd.accessKey, "secretAccessKey") - - // Check endpoint, region (top-level), and S3 configuration - assert.Equal(t, cd.endpoint, backupValues["endpointURL"]) - assert.Equal(t, cd.region, backupValues["region"]) + // Check that rclone proxy endpoint is used (not direct S3) + assert.Equal(t, "http://rcloneproxy:9095", backupValues["endpointURL"]) + + // Region and credentials are passed through from the bucket + assert.Equal(t, "rma", backupValues["region"]) s3Config := backupValues["s3"].(map[string]any) - assert.Equal(t, cd.bucket, s3Config["bucket"]) - assert.Equal(t, cd.region, s3Config["region"]) + // Bucket is hardcoded because rclone gets confused when the bucket name matches the backend bucket + assert.Equal(t, "backup", s3Config["bucket"]) + assert.Equal(t, "rma", s3Config["region"]) assert.Equal(t, "/", s3Config["path"]) - assert.Equal(t, cd.accessId, s3Config["accessKey"]) - assert.Equal(t, cd.accessKey, s3Config["secretKey"]) + assert.Equal(t, "secretAccessId", s3Config["accessKey"]) + assert.Equal(t, "secretAccessKey", s3Config["secretKey"]) // Check WAL and data configuration walConfig := backupValues["wal"].(map[string]any) @@ -88,7 +83,7 @@ func TestBackupBooststrapEnabled(t *testing.T) { assert.True(t, plugins[0]["isWALArchiver"].(bool)) pluginParams := plugins[0]["parameters"].(map[string]any) assert.Equal(t, "postgresql-object-store", pluginParams["barmanObjectName"]) - assert.Equal(t, "", pluginParams["serverName"]) + assert.Equal(t, "postgresql", pluginParams["serverName"]) // Check scheduled backups scheduledBackups := backupValues["scheduledBackups"].([]map[string]any) @@ -102,7 +97,16 @@ func TestBackupBooststrapEnabled(t *testing.T) { pluginConfig := scheduledBackups[0]["pluginConfiguration"].(map[string]string) assert.Equal(t, "barman-cloud.cloudnative-pg.io", pluginConfig["name"]) + // Check that backup bucket is created bucketName := comp.GetName() + "-backup" err = svc.GetDesiredComposedResourceByName(&appcatv1.XObjectBucket{}, bucketName) assert.NoError(t, err) + + // Check that rclone proxy Helm release is created + rcloneRelease := &xhelmv1.Release{} + err = svc.GetDesiredComposedResourceByName(rcloneRelease, "rclone") + assert.NoError(t, err, "rclone proxy Helm release should be created") + assert.Equal(t, comp.GetInstanceNamespace(), rcloneRelease.Spec.ForProvider.Namespace) + // Verify the stable Helm release name is set via external-name annotation + assert.Equal(t, "rclone", rcloneRelease.Annotations["crossplane.io/external-name"]) } diff --git a/test/functions/vshn-postgres/deploy/05_backup_cnpg.yaml b/test/functions/vshn-postgres/deploy/05_backup_cnpg.yaml index 47ab09e4e5..bf653c2878 100644 --- a/test/functions/vshn-postgres/deploy/05_backup_cnpg.yaml +++ b/test/functions/vshn-postgres/deploy/05_backup_cnpg.yaml @@ -215,6 +215,9 @@ input: "memory": "64Mi"}}, "setDbopsResult": {"limits": {"cpu": "250m", "memory": "256Mi"}, "requests": {"cpu": "100m", "memory": "64Mi"}}}' providerEnabled: "true" + rcloneproxyChartSource: "oci://ghcr.io/vshn/appcat-charts/rcloneproxy" + rcloneproxyChartVersion: "0.0.1" + rcloneproxyChartName: "rcloneproxy" kind: ConfigMap metadata: annotations: {}