mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 22:13:18 +09:00
fix(backups): do not fail a backup on a transient retry-check read
The check for a Backup an earlier reconcile already created returned its read error, and the drivers turn any error from that path into a terminally Failed BackupJob. An apiserver timeout right after the operator backup completed therefore lost a backup whose data was safely in S3. The check only saves a probe, and the Create that follows already handles an existing Backup, so log the error and go on to Create. A trailing slash on the S3 endpoint is not a path prefix; the operators accept it, so do not refuse it. Pin the uncached AlreadyExists refetch, the upper slack of the MariaDB window and the probe timeout with tests. Assisted-by: LLM Signed-off-by: Andrey Kolkov <androndo@gmail.com>
This commit is contained in:
1 parent
a3d854739d
commit
2b21763577
6 files changed
+190
-22
No files matched your search
@@ -22,8 +22,8 @@ import (
|
||||
// makes to measure Backup.status.artifact.sizeBytes. The probe runs inside
|
||||
// the reconcile that creates the Backup, and the BackupJob controller has a
|
||||
// single worker, so an unreachable endpoint stalls every other BackupJob for
|
||||
// this long.
|
||||
const artifactSizeProbeTimeout = 10 * time.Second
|
||||
// this long. A variable so tests can shorten it.
|
||||
var artifactSizeProbeTimeout = 10 * time.Second
|
||||
|
||||
type s3ObjectInfo struct {
|
||||
Key string
|
||||
@@ -63,8 +63,10 @@ type minioObjectLister struct {
|
||||
func newMinioObjectLister(conn s3Connection) (s3ObjectLister, error) {
|
||||
// An endpoint behind a path prefix cannot be addressed: minio-go has no
|
||||
// such option, and the SigV4 signature covers the request path, so the
|
||||
// prefix cannot be added below the client either.
|
||||
if strings.Contains(conn.Endpoint, "/") {
|
||||
// prefix cannot be added below the client either. A bare trailing slash
|
||||
// is not a prefix, and the operators accept it.
|
||||
endpoint := strings.TrimSuffix(conn.Endpoint, "/")
|
||||
if strings.Contains(endpoint, "/") {
|
||||
return nil, fmt.Errorf("endpoint %q has a path, which the S3 client cannot address", conn.Endpoint)
|
||||
}
|
||||
opts := &minio.Options{
|
||||
@@ -90,7 +92,7 @@ func newMinioObjectLister(conn s3Connection) (s3ObjectLister, error) {
|
||||
tr.TLSClientConfig.RootCAs = pool
|
||||
opts.Transport = tr
|
||||
}
|
||||
client, err := minio.New(conn.Endpoint, opts)
|
||||
client, err := minio.New(endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,18 +123,20 @@ func collectObjects(ctx context.Context, objects <-chan minio.ObjectInfo) ([]s3O
|
||||
|
||||
// existingBackup returns the Backup a previous reconcile already created
|
||||
// for the job, so a retry skips the size probe whose result it would
|
||||
// discard. Returns (nil, nil) when there is none. It reads past the cache:
|
||||
// a retry typically follows a Create made moments earlier, which the cache
|
||||
// may not show yet.
|
||||
func (r *BackupJobReconciler) existingBackup(ctx context.Context, namespace, name string) (*backupsv1alpha1.Backup, error) {
|
||||
// discard. It reads past the cache: a retry typically follows a Create made
|
||||
// moments earlier, which the cache may not show yet. It returns nil both
|
||||
// when there is no Backup and when the read fails: it only saves a probe,
|
||||
// and the Create that follows handles an existing Backup on its own, so a
|
||||
// transient read error must not fail a backup that succeeded.
|
||||
func (r *BackupJobReconciler) existingBackup(ctx context.Context, namespace, name string) *backupsv1alpha1.Backup {
|
||||
existing := &backupsv1alpha1.Backup{}
|
||||
if err := r.apiReader().Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, existing); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
if !apierrors.IsNotFound(err) {
|
||||
getLogger(ctx).Info("cannot check for an existing Backup; creating it", "backup", name, "error", err)
|
||||
}
|
||||
return nil, err
|
||||
return nil
|
||||
}
|
||||
return existing, nil
|
||||
return existing
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) secretValue(ctx context.Context, namespace, name, key string) (string, error) {
|
||||
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
)
|
||||
@@ -87,8 +90,10 @@ func TestNewMinioObjectLister_RejectsEndpointPath(t *testing.T) {
|
||||
if _, err := newMinioObjectLister(s3Connection{Endpoint: "s3.example.org/prefix", Secure: true}); err == nil {
|
||||
t.Error("an endpoint with a path was accepted")
|
||||
}
|
||||
if _, err := newMinioObjectLister(s3Connection{Endpoint: "s3.example.org:9000", Secure: true}); err != nil {
|
||||
t.Errorf("host:port endpoint rejected: %v", err)
|
||||
for _, ok := range []string{"s3.example.org:9000", "s3.example.org/"} {
|
||||
if _, err := newMinioObjectLister(s3Connection{Endpoint: ok, Secure: true}); err != nil {
|
||||
t.Errorf("endpoint %q rejected: %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +106,78 @@ func TestExistingBackup_ReadsPastTheCache(t *testing.T) {
|
||||
Client: newMariaDBStrategyTestClient(t),
|
||||
APIReader: newMariaDBStrategyTestClient(t, backup),
|
||||
}
|
||||
got, err := r.existingBackup(context.Background(), "tenant", "bj")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("existingBackup = %v, %v; want the Backup the API server holds", got, err)
|
||||
if got := r.existingBackup(context.Background(), "tenant", "bj"); got == nil {
|
||||
t.Fatal("existingBackup = nil; want the Backup the API server holds")
|
||||
}
|
||||
}
|
||||
|
||||
// staleCache wraps a fake client so that Get reports NotFound for Backups,
|
||||
// the way an informer cache does right after another writer created one.
|
||||
func staleCache(t *testing.T, c client.Client) client.Client {
|
||||
t.Helper()
|
||||
return interceptor.NewClient(c.(client.WithWatch), interceptor.Funcs{
|
||||
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
||||
if _, ok := obj.(*backupsv1alpha1.Backup); ok {
|
||||
return apierrors.NewNotFound(backupsv1alpha1.GroupVersion.WithResource("backups").GroupResource(), key.Name)
|
||||
}
|
||||
return cl.Get(ctx, key, obj, opts...)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// notFoundOnce wraps a fake client whose first Backup Get reports NotFound:
|
||||
// the retry check runs before the Backup exists, the refetch after Create
|
||||
// collides with it.
|
||||
func notFoundOnce(t *testing.T, c client.Client) client.Client {
|
||||
t.Helper()
|
||||
missed := false
|
||||
return interceptor.NewClient(c.(client.WithWatch), interceptor.Funcs{
|
||||
Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
||||
if _, ok := obj.(*backupsv1alpha1.Backup); ok && !missed {
|
||||
missed = true
|
||||
return apierrors.NewNotFound(backupsv1alpha1.GroupVersion.WithResource("backups").GroupResource(), key.Name)
|
||||
}
|
||||
return cl.Get(ctx, key, obj, opts...)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestExistingBackup_ReadErrorIsNotFatal(t *testing.T) {
|
||||
failing := interceptor.NewClient(newMariaDBStrategyTestClient(t).(client.WithWatch), interceptor.Funcs{
|
||||
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
|
||||
return errors.New("etcdserver: request timed out")
|
||||
},
|
||||
})
|
||||
r := &BackupJobReconciler{Client: newMariaDBStrategyTestClient(t), APIReader: failing}
|
||||
if got := r.existingBackup(context.Background(), "tenant", "bj"); got != nil {
|
||||
t.Errorf("existingBackup = %#v, want nil on a read error", got)
|
||||
}
|
||||
}
|
||||
|
||||
// blockingLister answers nothing until the probe's context is done, the way
|
||||
// a black-holed endpoint does.
|
||||
type blockingLister struct{}
|
||||
|
||||
func (blockingLister) ListObjects(ctx context.Context, _, _ string, _ bool) ([]s3ObjectInfo, error) {
|
||||
<-ctx.Done()
|
||||
return nil, 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()) {
|
||||
t.Helper()
|
||||
saved := artifactSizeProbeTimeout
|
||||
artifactSizeProbeTimeout = 50 * time.Millisecond
|
||||
defer func() { artifactSizeProbeTimeout = saved }()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
fn()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the probe did not give up at its timeout")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,16 @@ func TestCNPGArtifactSizeBytes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a hanging endpoint is given up at the probe timeout", func(t *testing.T) {
|
||||
r := reconcilerWithLister(t, &fakeS3Lister{}, s3CredsSecret("tenant", "creds"))
|
||||
r.newS3ObjectLister = func(s3Connection) (s3ObjectLister, error) { return blockingLister{}, nil }
|
||||
withShortProbeTimeout(t, func() {
|
||||
if got := r.cnpgArtifactSizeBytes(context.Background(), "tenant", newSizedBarmanObjectStore(), "srv", "20261021T100000"); got != 0 {
|
||||
t.Errorf("size = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("an empty endpointURL means AWS", func(t *testing.T) {
|
||||
store := newSizedBarmanObjectStore()
|
||||
store.EndpointURL = ""
|
||||
@@ -306,3 +316,21 @@ func TestCreateCNPGBackupArtifact_ExistingBackupSkipsProbe(t *testing.T) {
|
||||
t.Errorf("S3 listed %d times for a Backup that already exists", l.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCNPGBackupArtifact_AlreadyExistsRefetchReadsPastTheCache(t *testing.T) {
|
||||
apiGroup := backupsv1alpha1.DefaultApplicationAPIGroup
|
||||
j := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-race"},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
ApplicationRef: corev1.TypedLocalObjectReference{APIGroup: &apiGroup, Kind: postgresAppKind, Name: "pg"},
|
||||
},
|
||||
}
|
||||
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-race"}}
|
||||
store := newCNPGStrategyTestClient(t, backup)
|
||||
r := &BackupJobReconciler{Client: staleCache(t, store), APIReader: notFoundOnce(t, store)}
|
||||
rendered := &strategyv1alpha1.CNPGTemplate{BarmanObjectStore: strategyv1alpha1.BarmanObjectStoreTemplate{DestinationPath: "s3://b/"}}
|
||||
got, err := r.createCNPGBackupArtifact(context.Background(), j, &ResolvedBackupConfig{}, &cnpgtypes.Backup{ObjectMeta: metav1.ObjectMeta{Name: "cnpg-bk"}}, "postgres-pg", "srv", "postgresql", rendered, newPostgresApp("pg", "tenant"))
|
||||
if err != nil || got == nil || got.Name != "bj-race" {
|
||||
t.Fatalf("createCNPGBackupArtifact = %v, %v; want the Backup that already exists", got, err)
|
||||
}
|
||||
}
|
||||
@@ -815,8 +815,8 @@ func (r *BackupJobReconciler) createCNPGBackupArtifact(
|
||||
rendered *strategyv1alpha1.CNPGTemplate,
|
||||
sourceApp *postgresapp.Postgres,
|
||||
) (*backupsv1alpha1.Backup, error) {
|
||||
if existing, err := r.existingBackup(ctx, j.Namespace, j.Name); err != nil || existing != nil {
|
||||
return existing, err
|
||||
if existing := r.existingBackup(ctx, j.Namespace, j.Name); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
takenAt := metav1.Now()
|
||||
if cnpgBackup.Status.StartedAt != nil && !cnpgBackup.Status.StartedAt.IsZero() {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
@@ -151,6 +153,25 @@ func TestMariaDBArtifactSizeBytes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a dump uploaded just after Complete is inside the slack", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, completed.Add(30*time.Second), "gzip"), Size: 5, LastModified: completed.Add(30 * time.Second)},
|
||||
}}
|
||||
if got := reconcilerWithLister(t, l, s3CredsSecret("tenant", "creds")).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 5 {
|
||||
t.Errorf("size = %d, want 5", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a hanging endpoint is given up at the probe timeout", func(t *testing.T) {
|
||||
r := reconcilerWithLister(t, &fakeS3Lister{}, s3CredsSecret("tenant", "creds"))
|
||||
r.newS3ObjectLister = func(s3Connection) (s3ObjectLister, error) { return blockingLister{}, nil }
|
||||
withShortProbeTimeout(t, func() {
|
||||
if got := r.mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 0 {
|
||||
t.Errorf("size = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("two dumps in the window leave size unset", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, created.Add(time.Minute), "gzip"), Size: 1, LastModified: created.Add(time.Minute)},
|
||||
@@ -298,3 +319,43 @@ func TestCreateMariaDBBackupArtifact_ExistingBackupSkipsProbe(t *testing.T) {
|
||||
t.Errorf("Artifact = %#v, want the existing one", got.Status.Artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMariaDBBackupArtifact_TransientRetryReadStillCreates(t *testing.T) {
|
||||
apps := mariadbapp.GroupName
|
||||
job := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-transient"},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MariaDB", Name: "src", APIGroup: &apps},
|
||||
},
|
||||
}
|
||||
r := reconcilerWithLister(t, &fakeS3Lister{}, job)
|
||||
r.APIReader = interceptor.NewClient(newMariaDBStrategyTestClient(t).(client.WithWatch), interceptor.Funcs{
|
||||
Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error {
|
||||
return errors.New("etcdserver: request timed out")
|
||||
},
|
||||
})
|
||||
got, err := r.createMariaDBBackupArtifact(context.Background(), job, &ResolvedBackupConfig{}, &mariadbtypes.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op"}}, newRenderedMariaDBTemplate())
|
||||
if err != nil || got == nil || got.Name != "bj-transient" {
|
||||
t.Fatalf("createMariaDBBackupArtifact = %v, %v; a transient read must not fail the backup", got, err)
|
||||
}
|
||||
if err := r.Get(context.Background(), client.ObjectKeyFromObject(got), &backupsv1alpha1.Backup{}); err != nil {
|
||||
t.Errorf("the Backup was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMariaDBBackupArtifact_AlreadyExistsRefetchReadsPastTheCache(t *testing.T) {
|
||||
apps := mariadbapp.GroupName
|
||||
job := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-race"},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MariaDB", Name: "src", APIGroup: &apps},
|
||||
},
|
||||
}
|
||||
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-race"}}
|
||||
store := newMariaDBStrategyTestClient(t, job, backup)
|
||||
r := &BackupJobReconciler{Client: staleCache(t, store), APIReader: notFoundOnce(t, store), Scheme: store.Scheme()}
|
||||
got, err := r.createMariaDBBackupArtifact(context.Background(), job, &ResolvedBackupConfig{}, &mariadbtypes.Backup{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "op"}}, &strategyv1alpha1.MariaDBTemplate{})
|
||||
if err != nil || got == nil || got.Name != "bj-race" {
|
||||
t.Fatalf("createMariaDBBackupArtifact = %v, %v; want the Backup that already exists", got, err)
|
||||
}
|
||||
}
|
||||
@@ -365,8 +365,8 @@ func (r *BackupJobReconciler) createMariaDBBackupArtifact(
|
||||
mdbBackup *mariadbtypes.Backup,
|
||||
rendered *strategyv1alpha1.MariaDBTemplate,
|
||||
) (*backupsv1alpha1.Backup, error) {
|
||||
if existing, err := r.existingBackup(ctx, j.Namespace, j.Name); err != nil || existing != nil {
|
||||
return existing, err
|
||||
if existing := r.existingBackup(ctx, j.Namespace, j.Name); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
takenAt := metav1.Now()
|
||||
if cond := apimeta.FindStatusCondition(mdbBackup.Status.Conditions, mariadbtypes.ConditionTypeComplete); cond != nil && !cond.LastTransitionTime.IsZero() {
|
||||
|
||||
Reference in new issue
Block a user