Skip to content

Commit 126af8a

Browse files
committed
feat: refuse to let two Repositories manage one BorgBase repository
Adopting a repository that another Repository already manages leaves both pushing their own spec to it. Each reconcile overwrites the other's settings and the repository flaps between them, quietly: nothing is unhealthy, the values just keep changing. Found on fennec, where an adopted Repository with no quota cleared the 5GB limit its owner had set. The newcomer is now held back with a RepositoryConflict condition naming the incumbent, and its settings are left alone. Only a resource created earlier counts as the incumbent, so the one already managing the repository keeps working -- the same rule the healthchecks slug conflict uses, for the same reason. The check spans namespaces, because a BorgBase repository belongs to the account rather than to any one namespace, and it matches on the repository a resource has recorded or the one it was told to adopt, so a conflict is caught before the newcomer's first edit rather than after it. Sharing one repository between several backups is still perfectly fine: that is one Repository with several ScheduledBackups pointing at it.
1 parent 314dffe commit 126af8a

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

‎internal/controller/repository_controller.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,25 @@ func (r *RepositoryReconciler) reconcile(
204204
"%w: repository %s has format %q", borgbase.ErrNotRestic, remote.ID, remote.Format)
205205
}
206206

207+
// Two Repository resources pointing at one BorgBase repository each push
208+
// their own spec to it, so every reconcile overwrites the other's settings
209+
// and the repository flaps between them. Hold the newcomer back rather than
210+
// letting them fight.
211+
if other, err := r.repositoryConflict(ctx, repo, remote.ID); err != nil {
212+
return ctrl.Result{}, err
213+
} else if other != "" {
214+
r.setCondition(repo, metav1.Condition{
215+
Type: borgbasev1.RepositoryConditionReady,
216+
Status: metav1.ConditionFalse,
217+
Reason: "RepositoryConflict",
218+
Message: fmt.Sprintf(
219+
"BorgBase repository %s is already managed by Repository %s; "+
220+
"point one of them elsewhere, or delete this one", remote.ID, other),
221+
})
222+
// Its settings are deliberately left alone: the incumbent owns them.
223+
return ctrl.Result{RequeueAfter: r.interval(repo)}, nil
224+
}
225+
207226
if remote, err = r.reconcileSettings(ctx, repo, remote, api); err != nil {
208227
return ctrl.Result{}, err
209228
}
@@ -325,6 +344,60 @@ func (r *RepositoryReconciler) resolveRepo(
325344
return remote, nil
326345
}
327346

347+
// repositoryConflict returns the Repository that already manages this BorgBase
348+
// repository, or "" when there is none.
349+
//
350+
// The check is cluster-wide, because a BorgBase repository belongs to the
351+
// account rather than to a namespace. Only a resource created earlier counts as
352+
// the incumbent, so the one already managing it keeps working and the newcomer
353+
// is the one held back -- the same rule the healthchecks slug conflict uses.
354+
func (r *RepositoryReconciler) repositoryConflict(
355+
ctx context.Context, repo *borgbasev1.Repository, id string,
356+
) (string, error) {
357+
if id == "" {
358+
return "", nil
359+
}
360+
361+
var list borgbasev1.RepositoryList
362+
if err := r.List(ctx, &list); err != nil {
363+
return "", fmt.Errorf("listing repositories: %w", err)
364+
}
365+
366+
for i := range list.Items {
367+
other := &list.Items[i]
368+
if !other.DeletionTimestamp.IsZero() ||
369+
(other.Namespace == repo.Namespace && other.Name == repo.Name) {
370+
continue
371+
}
372+
if managedID(other) != id {
373+
continue
374+
}
375+
if olderRepository(other, repo) {
376+
return other.Namespace + "/" + other.Name, nil
377+
}
378+
}
379+
return "", nil
380+
}
381+
382+
// managedID is the BorgBase repository a resource manages: the one it has
383+
// recorded, or the one it was told to adopt before it has reconciled.
384+
func managedID(repo *borgbasev1.Repository) string {
385+
if repo.Status.RepositoryID != "" {
386+
return repo.Status.RepositoryID
387+
}
388+
return repo.Spec.ExistingRepositoryID
389+
}
390+
391+
func olderRepository(a, b *borgbasev1.Repository) bool {
392+
if !a.CreationTimestamp.Equal(&b.CreationTimestamp) {
393+
return a.CreationTimestamp.Before(&b.CreationTimestamp)
394+
}
395+
if a.Namespace != b.Namespace {
396+
return a.Namespace < b.Namespace
397+
}
398+
return a.Name < b.Name
399+
}
400+
328401
// reconcileSettings brings the repository's mutable settings in line with the
329402
// spec, and returns the repository as it stands afterwards.
330403
//

‎internal/controller/repository_controller_test.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"testing"
7+
"time"
78

89
batchv1 "k8s.io/api/batch/v1"
910
corev1 "k8s.io/api/core/v1"
@@ -783,3 +784,99 @@ func derefInt64(p *int64) any {
783784
}
784785
return *p
785786
}
787+
788+
// Two Repository resources pointing at one BorgBase repository each push their
789+
// own spec to it, so they overwrite each other on every reconcile and the
790+
// repository flaps. The newcomer is held back instead.
791+
func TestConflictingRepositoriesDoNotFight(t *testing.T) {
792+
incumbent := &borgbasev1.Repository{
793+
Name: resticName, Namespace: testNS,
794+
CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)),
795+
Status: borgbasev1.RepositoryStatus{RepositoryID: testRepoID, Initialized: true},
796+
}
797+
newcomer := &borgbasev1.Repository{
798+
Name: "adopted", Namespace: testNS,
799+
CreationTimestamp: metav1.NewTime(time.Now()),
800+
Spec: borgbasev1.RepositorySpec{ExistingRepositoryID: testRepoID},
801+
}
802+
803+
api := newFakeAPI(&borgbase.Repo{
804+
ID: testRepoID, Name: testNS, Format: borgbase.FormatRestic, Htpasswd: "t",
805+
Quota: 100, QuotaEnabled: true,
806+
})
807+
r, _ := newHarness(t, api, incumbent, newcomer)
808+
809+
other, err := r.repositoryConflict(context.Background(), newcomer, testRepoID)
810+
if err != nil {
811+
t.Fatalf("repositoryConflict: %v", err)
812+
}
813+
if other != testNS+"/"+resticName {
814+
t.Errorf("newcomer should defer to the incumbent, got %q", other)
815+
}
816+
817+
// The incumbent carries on: it was there first.
818+
other, err = r.repositoryConflict(context.Background(), incumbent, testRepoID)
819+
if err != nil {
820+
t.Fatalf("repositoryConflict: %v", err)
821+
}
822+
if other != "" {
823+
t.Errorf("the incumbent must keep managing the repository, got conflict with %q", other)
824+
}
825+
}
826+
827+
// A repository nothing else claims is not a conflict, and neither is one whose
828+
// only other claimant is being deleted.
829+
func TestRepositoryConflictIgnoresUnrelatedAndDeleted(t *testing.T) {
830+
deleting := &borgbasev1.Repository{
831+
Name: "old", Namespace: testNS,
832+
CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)),
833+
DeletionTimestamp: ptr.To(metav1.Now()),
834+
Finalizers: []string{FinalizerName},
835+
Status: borgbasev1.RepositoryStatus{RepositoryID: testRepoID},
836+
}
837+
elsewhere := &borgbasev1.Repository{
838+
Name: "other", Namespace: "somewhere-else",
839+
CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)),
840+
Status: borgbasev1.RepositoryStatus{RepositoryID: "different"},
841+
}
842+
mine := &borgbasev1.Repository{
843+
Name: resticName, Namespace: testNS,
844+
CreationTimestamp: metav1.NewTime(time.Now()),
845+
Status: borgbasev1.RepositoryStatus{RepositoryID: testRepoID},
846+
}
847+
848+
r, _ := newHarness(t, newFakeAPI(), deleting, elsewhere, mine)
849+
850+
other, err := r.repositoryConflict(context.Background(), mine, testRepoID)
851+
if err != nil {
852+
t.Fatalf("repositoryConflict: %v", err)
853+
}
854+
if other != "" {
855+
t.Errorf("expected no conflict, got %q", other)
856+
}
857+
}
858+
859+
// The check spans namespaces, because a BorgBase repository belongs to the
860+
// account rather than to any one namespace.
861+
func TestRepositoryConflictIsClusterWide(t *testing.T) {
862+
incumbent := &borgbasev1.Repository{
863+
Name: resticName, Namespace: "team-a",
864+
CreationTimestamp: metav1.NewTime(time.Now().Add(-time.Hour)),
865+
Status: borgbasev1.RepositoryStatus{RepositoryID: testRepoID},
866+
}
867+
newcomer := &borgbasev1.Repository{
868+
Name: resticName, Namespace: "team-b",
869+
CreationTimestamp: metav1.NewTime(time.Now()),
870+
Spec: borgbasev1.RepositorySpec{ExistingRepositoryID: testRepoID},
871+
}
872+
873+
r, _ := newHarness(t, newFakeAPI(), incumbent, newcomer)
874+
875+
other, err := r.repositoryConflict(context.Background(), newcomer, testRepoID)
876+
if err != nil {
877+
t.Fatalf("repositoryConflict: %v", err)
878+
}
879+
if other != "team-a/"+resticName {
880+
t.Errorf("expected the conflict to be found across namespaces, got %q", other)
881+
}
882+
}

0 commit comments

Comments
 (0)