mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 22:13:18 +09:00
feat(backups): record the RDB snapshot location and size for Redis backups
Backups made by the Redis strategy, which also serves Valkey, carried no Backup.status.artifact at all: neither a URI nor a size. The snapshot is one object per backup, <namespace>/<app>/<backup>.rdb, whose key the driver already records, and the default strategy uploads it with the S3 coordinates of the backup credentials Secret the controller projects into the application namespace. On completion the driver now stats that object with the same coordinates and records its s3:// URI and size. The artifact is set only when the object is found, so a custom strategy that writes elsewhere gets no URI pointing at a missing object. A Backup that already exists is returned without probing, as for the MariaDB and CNPG drivers. Assisted-by: LLM Signed-off-by: Andrey Kolkov <androndo@gmail.com>
This commit is contained in:
1 parent
1f35c26ece
commit
2b5fa1f0eb
5 files changed
+280
-3
No files matched your search
@@ -36,6 +36,7 @@ type s3ObjectInfo struct {
|
||||
|
||||
type s3ObjectLister interface {
|
||||
ListObjects(ctx context.Context, bucket, prefix string, recursive bool) ([]s3ObjectInfo, error)
|
||||
StatObject(ctx context.Context, bucket, key string) (int64, error)
|
||||
}
|
||||
|
||||
// s3Connection is what a driver resolves from its rendered strategy to
|
||||
@@ -148,6 +149,14 @@ func (l *minioObjectLister) ListObjects(ctx context.Context, bucket, prefix stri
|
||||
return collectObjects(ctx, l.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: recursive}))
|
||||
}
|
||||
|
||||
func (l *minioObjectLister) StatObject(ctx context.Context, bucket, key string) (int64, error) {
|
||||
info, err := l.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size, nil
|
||||
}
|
||||
|
||||
// collectObjects drains a minio listing. minio-go ends a listing whose
|
||||
// context is cancelled between pages by closing the channel without an
|
||||
// error item, so a cancelled context is checked once the channel closes:
|
||||
|
||||
@@ -37,6 +37,16 @@ type fakeS3Lister struct {
|
||||
bucket, prefix string
|
||||
recursive bool
|
||||
calls int
|
||||
|
||||
statSize int64
|
||||
statErr error
|
||||
statKey string
|
||||
}
|
||||
|
||||
func (f *fakeS3Lister) StatObject(_ context.Context, bucket, key string) (int64, error) {
|
||||
f.calls++
|
||||
f.bucket, f.statKey = bucket, key
|
||||
return f.statSize, f.statErr
|
||||
}
|
||||
|
||||
func (f *fakeS3Lister) ListObjects(_ context.Context, bucket, prefix string, recursive bool) ([]s3ObjectInfo, error) {
|
||||
@@ -167,6 +177,11 @@ func (blockingLister) ListObjects(ctx context.Context, _, _ string, _ bool) ([]s
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (blockingLister) StatObject(ctx context.Context, _, _ string) (int64, error) {
|
||||
<-ctx.Done()
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
|
||||
// withShortProbeTimeout shortens the probe timeout and fails the test if fn
|
||||
// is not back well before the default timeout would have expired.
|
||||
func withShortProbeTimeout(t *testing.T, fn func()) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
)
|
||||
|
||||
// redisArtifact locates the RDB snapshot the backup Job uploaded and returns
|
||||
// an Artifact carrying its s3:// URI and size. The default Redis strategy
|
||||
// takes its S3 coordinates from the projected backup credentials Secret, so
|
||||
// the object is looked up there. Returns nil - leaving Status.Artifact
|
||||
// absent - when the Secret cannot be read or the object is not found: a
|
||||
// custom strategy that writes elsewhere must not get a URI pointing at an
|
||||
// object that does not exist.
|
||||
func (r *BackupJobReconciler) redisArtifact(ctx context.Context, namespace, objectKey string) *backupsv1alpha1.BackupArtifact {
|
||||
logger := getLogger(ctx)
|
||||
ctx, cancel := context.WithTimeout(ctx, artifactSizeProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
bucket, conn, err := r.projectedS3Connection(ctx, namespace)
|
||||
if err != nil {
|
||||
logger.Info("cannot resolve backup credentials to locate the Redis snapshot", "objectKey", objectKey, "error", err)
|
||||
return nil
|
||||
}
|
||||
lister, err := r.s3Lister(conn)
|
||||
if err != nil {
|
||||
logger.Info("cannot build S3 client to locate the Redis snapshot", "objectKey", objectKey, "error", err)
|
||||
return nil
|
||||
}
|
||||
size, err := lister.StatObject(ctx, bucket, objectKey)
|
||||
if err != nil {
|
||||
logger.Info("cannot stat the Redis snapshot; leaving the artifact unset", "bucket", bucket, "objectKey", objectKey, "error", err)
|
||||
return nil
|
||||
}
|
||||
return &backupsv1alpha1.BackupArtifact{
|
||||
URI: fmt.Sprintf("s3://%s/%s", bucket, objectKey),
|
||||
SizeBytes: size,
|
||||
}
|
||||
}
|
||||
|
||||
// projectedS3Connection reads the backup credentials Secret the controller
|
||||
// projects into the application namespace. Its endpoint is a bare host,
|
||||
// which the strategy scripts reach over https.
|
||||
func (r *BackupJobReconciler) projectedS3Connection(ctx context.Context, namespace string) (string, s3Connection, error) {
|
||||
var conn s3Connection
|
||||
name := r.CredentialsConfig.TargetSecretName
|
||||
if name == "" {
|
||||
return "", conn, errors.New("no projected credentials Secret is configured")
|
||||
}
|
||||
secret := &corev1.Secret{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, secret); err != nil {
|
||||
return "", conn, err
|
||||
}
|
||||
value := func(key string) string { return string(secret.Data[key]) }
|
||||
|
||||
bucket := value("bucketName")
|
||||
conn.AccessKey, conn.SecretKey = value("AWS_ACCESS_KEY_ID"), value("AWS_SECRET_ACCESS_KEY")
|
||||
if bucket == "" || value("endpoint") == "" || conn.AccessKey == "" || conn.SecretKey == "" {
|
||||
return "", conn, fmt.Errorf("secret %s/%s lacks bucketName, endpoint or access keys", namespace, name)
|
||||
}
|
||||
conn.Endpoint, conn.Secure = value("endpoint"), true
|
||||
if strings.Contains(conn.Endpoint, "://") {
|
||||
u, err := url.Parse(conn.Endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", conn, fmt.Errorf("invalid endpoint %q in secret %s/%s", conn.Endpoint, namespace, name)
|
||||
}
|
||||
conn.Endpoint, conn.Secure = u.Host, u.Scheme == "https"
|
||||
}
|
||||
conn.Region = value("region")
|
||||
return bucket, conn, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
)
|
||||
|
||||
const redisTestKey = "tenant/cache/bj-1.rdb"
|
||||
|
||||
func projectedCredsSecret(data map[string]string) *corev1.Secret {
|
||||
s := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "cozy-backups-creds"},
|
||||
Data: map[string][]byte{},
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
"endpoint": "s3.example.org",
|
||||
"bucketName": "cozy-backups",
|
||||
"region": "us-east-1",
|
||||
"AWS_ACCESS_KEY_ID": "AKID",
|
||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
||||
} {
|
||||
s.Data[k] = []byte(v)
|
||||
}
|
||||
for k, v := range data {
|
||||
if v == "" {
|
||||
delete(s.Data, k)
|
||||
continue
|
||||
}
|
||||
s.Data[k] = []byte(v)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func redisReconciler(t *testing.T, l *fakeS3Lister, objs ...client.Object) *BackupJobReconciler {
|
||||
r := reconcilerWithLister(t, l, objs...)
|
||||
r.CredentialsConfig.TargetSecretName = "cozy-backups-creds"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRedisArtifact(t *testing.T) {
|
||||
t.Run("stats the snapshot with the projected credentials", func(t *testing.T) {
|
||||
l := &fakeS3Lister{statSize: 2048}
|
||||
r := redisReconciler(t, l, projectedCredsSecret(nil))
|
||||
got := r.redisArtifact(context.Background(), "tenant", redisTestKey)
|
||||
if got == nil || got.URI != "s3://cozy-backups/"+redisTestKey || got.SizeBytes != 2048 {
|
||||
t.Fatalf("artifact = %#v", got)
|
||||
}
|
||||
if l.bucket != "cozy-backups" || l.statKey != redisTestKey {
|
||||
t.Errorf("stat %q/%q, want cozy-backups/%q", l.bucket, l.statKey, redisTestKey)
|
||||
}
|
||||
if l.conn.Endpoint != "s3.example.org" || !l.conn.Secure || l.conn.AccessKey != "AKID" || l.conn.Region != "us-east-1" {
|
||||
t.Errorf("connection = %+v", l.conn)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an endpoint with a scheme sets the transport", func(t *testing.T) {
|
||||
l := &fakeS3Lister{statSize: 1}
|
||||
r := redisReconciler(t, l, projectedCredsSecret(map[string]string{"endpoint": "http://seaweedfs:8333"}))
|
||||
if got := r.redisArtifact(context.Background(), "tenant", redisTestKey); got == nil {
|
||||
t.Fatal("artifact = nil")
|
||||
}
|
||||
if l.conn.Endpoint != "seaweedfs:8333" || l.conn.Secure {
|
||||
t.Errorf("connection = %+v", l.conn)
|
||||
}
|
||||
})
|
||||
|
||||
unset := map[string]struct {
|
||||
lister *fakeS3Lister
|
||||
objs []client.Object
|
||||
noTarget bool
|
||||
}{
|
||||
"missing object": {lister: &fakeS3Lister{statErr: errors.New("NoSuchKey")}, objs: []client.Object{projectedCredsSecret(nil)}},
|
||||
"missing Secret": {lister: &fakeS3Lister{statSize: 1}},
|
||||
"missing bucket": {lister: &fakeS3Lister{statSize: 1}, objs: []client.Object{projectedCredsSecret(map[string]string{"bucketName": ""})}},
|
||||
"missing keys": {lister: &fakeS3Lister{statSize: 1}, objs: []client.Object{projectedCredsSecret(map[string]string{"AWS_SECRET_ACCESS_KEY": ""})}},
|
||||
"no target name": {lister: &fakeS3Lister{statSize: 1}, objs: []client.Object{projectedCredsSecret(nil)}, noTarget: true},
|
||||
"broken endpoint": {lister: &fakeS3Lister{statSize: 1}, objs: []client.Object{projectedCredsSecret(map[string]string{"endpoint": "https://"})}},
|
||||
}
|
||||
for name, tc := range unset {
|
||||
t.Run(name+" leaves the artifact unset", func(t *testing.T) {
|
||||
r := redisReconciler(t, tc.lister, tc.objs...)
|
||||
if tc.noTarget {
|
||||
r.CredentialsConfig.TargetSecretName = ""
|
||||
}
|
||||
if got := r.redisArtifact(context.Background(), "tenant", redisTestKey); got != nil {
|
||||
t.Errorf("artifact = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedisArtifact_MinioClient drives the real minio client against a stub
|
||||
// that answers the HEAD request, so addressing, signing and the
|
||||
// Content-Length parsing are exercised together.
|
||||
func TestRedisArtifact_MinioClient(t *testing.T) {
|
||||
var gotMethod, gotPath, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
gotMethod, gotPath, gotAuth = req.Method, req.URL.Path, req.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Length", "4096")
|
||||
w.Header().Set("ETag", `"abc"`)
|
||||
w.Header().Set("Last-Modified", "Wed, 21 Oct 2026 10:00:00 GMT")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
creds := projectedCredsSecret(map[string]string{"endpoint": srv.URL})
|
||||
c := newMariaDBStrategyTestClient(t, creds)
|
||||
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
|
||||
r.CredentialsConfig.TargetSecretName = "cozy-backups-creds"
|
||||
|
||||
got := r.redisArtifact(context.Background(), "tenant", redisTestKey)
|
||||
if got == nil || got.SizeBytes != 4096 {
|
||||
t.Fatalf("artifact = %#v, want SizeBytes 4096", got)
|
||||
}
|
||||
if gotMethod != http.MethodHead || gotPath != "/cozy-backups/"+redisTestKey {
|
||||
t.Errorf("request %s %s, want HEAD /cozy-backups/%s", gotMethod, gotPath, redisTestKey)
|
||||
}
|
||||
if !strings.Contains(gotAuth, "Credential=AKID/") {
|
||||
t.Errorf("request not signed with the Secret's access key: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRedisBackupArtifact_Artifact(t *testing.T) {
|
||||
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
|
||||
j := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-1"},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: "Redis", Name: "cache"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("records the snapshot location and size", func(t *testing.T) {
|
||||
l := &fakeS3Lister{statSize: 777}
|
||||
r := redisReconciler(t, l, j, projectedCredsSecret(nil))
|
||||
got, err := r.createRedisBackupArtifact(context.Background(), j, &ResolvedBackupConfig{}, redisTestKey)
|
||||
if err != nil {
|
||||
t.Fatalf("createRedisBackupArtifact: %v", err)
|
||||
}
|
||||
if got.Status.Artifact == nil || got.Status.Artifact.SizeBytes != 777 || got.Status.Artifact.URI != "s3://cozy-backups/"+redisTestKey {
|
||||
t.Fatalf("Artifact = %#v", got.Status.Artifact)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an existing Backup is returned without probing", func(t *testing.T) {
|
||||
existing := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-1"}}
|
||||
l := &fakeS3Lister{statSize: 777}
|
||||
r := redisReconciler(t, l, j, existing, projectedCredsSecret(nil))
|
||||
if _, err := r.createRedisBackupArtifact(context.Background(), j, &ResolvedBackupConfig{}, redisTestKey); err != nil {
|
||||
t.Fatalf("createRedisBackupArtifact: %v", err)
|
||||
}
|
||||
if l.calls != 0 {
|
||||
t.Errorf("S3 probed %d times for a Backup that already exists", l.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func (r *BackupJobReconciler) reconcileRedis(ctx context.Context, j *backupsv1al
|
||||
}
|
||||
artifact, err := r.createRedisBackupArtifact(ctx, j, resolved, objectKey)
|
||||
if err != nil {
|
||||
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to create Backup artifact: %v", err))
|
||||
return r.failBackupArtifact(ctx, j, err)
|
||||
}
|
||||
now := metav1.Now()
|
||||
j.Status.BackupRef = &corev1.LocalObjectReference{Name: artifact.Name}
|
||||
@@ -472,6 +472,10 @@ func (r *BackupJobReconciler) createRedisBackupArtifact(
|
||||
resolved *ResolvedBackupConfig,
|
||||
objectKey string,
|
||||
) (*backupsv1alpha1.Backup, error) {
|
||||
existing, probe := r.existingBackup(ctx, j.Namespace, j.Name)
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
driverMD := map[string]string{redisObjectMetaKey: objectKey}
|
||||
for k, v := range resolved.Parameters {
|
||||
driverMD[redisParamPrefix+k] = v
|
||||
@@ -492,6 +496,9 @@ func (r *BackupJobReconciler) createRedisBackupArtifact(
|
||||
Phase: backupsv1alpha1.BackupPhaseReady,
|
||||
},
|
||||
}
|
||||
if probe {
|
||||
backup.Status.Artifact = r.redisArtifact(ctx, j.Namespace, objectKey)
|
||||
}
|
||||
if j.Spec.PlanRef != nil {
|
||||
backup.Spec.PlanRef = j.Spec.PlanRef
|
||||
}
|
||||
@@ -500,8 +507,8 @@ func (r *BackupJobReconciler) createRedisBackupArtifact(
|
||||
return nil, err
|
||||
}
|
||||
existing := &backupsv1alpha1.Backup{}
|
||||
if getErr := r.Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: backup.Name}, existing); getErr != nil {
|
||||
return nil, getErr
|
||||
if getErr := r.apiReader().Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: backup.Name}, existing); getErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errBackupNotReadBack, getErr)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user