Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions pkg/comp-functions/functions/common/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package backup

import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
Expand All @@ -10,13 +11,15 @@ 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"
"github.com/vshn/appcat/v4/pkg/comp-functions/runtime"
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"
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
66 changes: 21 additions & 45 deletions pkg/comp-functions/functions/vshnpostgrescnpg/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,13 @@ import (
"context"
"fmt"
"maps"
"strings"

vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1"
"github.com/vshn/appcat/v4/pkg/comp-functions/functions/common"
"github.com/vshn/appcat/v4/pkg/comp-functions/functions/common/backup"
"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
Expand All @@ -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 {
Expand All @@ -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",
},
}}

Expand All @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 21 additions & 17 deletions pkg/comp-functions/functions/vshnpostgrescnpg/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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"])
}
3 changes: 3 additions & 0 deletions test/functions/vshn-postgres/deploy/05_backup_cnpg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand Down
Loading