backup: skip detached longhorn volumes
This commit is contained in:
parent
fdab56ed82
commit
97958d0520
@ -46,6 +46,9 @@ func (e *APIError) Error() string {
|
|||||||
type Volume struct {
|
type Volume struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Robustness string `json:"robustness"`
|
||||||
|
CurrentNodeID string `json:"currentNodeID"`
|
||||||
NumberOfReplicas int `json:"numberOfReplicas"`
|
NumberOfReplicas int `json:"numberOfReplicas"`
|
||||||
BackupStatus []BackupStatus `json:"backupStatus"`
|
BackupStatus []BackupStatus `json:"backupStatus"`
|
||||||
LastBackup string `json:"lastBackup"`
|
LastBackup string `json:"lastBackup"`
|
||||||
|
|||||||
@ -225,6 +225,13 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque
|
|||||||
if req.DryRun {
|
if req.DryRun {
|
||||||
return response, "dry_run", nil
|
return response, "dry_run", nil
|
||||||
}
|
}
|
||||||
|
ready, reason, err := s.longhornVolumeReady(ctx, volumeName)
|
||||||
|
if err != nil {
|
||||||
|
return api.BackupResponse{}, "backend_error", err
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
return api.BackupResponse{}, "not_ready", errors.New(reason)
|
||||||
|
}
|
||||||
|
|
||||||
labels := map[string]string{
|
labels := map[string]string{
|
||||||
"soteria.bstein.dev/namespace": req.Namespace,
|
"soteria.bstein.dev/namespace": req.Namespace,
|
||||||
@ -312,7 +319,7 @@ func backupStatusCode(result string) int {
|
|||||||
switch result {
|
switch result {
|
||||||
case "validation_error", "unsupported_driver":
|
case "validation_error", "unsupported_driver":
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case "in_progress":
|
case "in_progress", "not_ready":
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case "backend_error":
|
case "backend_error":
|
||||||
return http.StatusBadGateway
|
return http.StatusBadGateway
|
||||||
|
|||||||
@ -475,6 +475,24 @@ func TestExecuteBackupAndStatusHelpers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("longhorn detached volume", func(t *testing.T) {
|
||||||
|
srv := newBackupTestServer(
|
||||||
|
&config.Config{AuthRequired: false, BackupDriver: "longhorn"},
|
||||||
|
&backupTestKubeClient{restoreTestKubeClient: &restoreTestKubeClient{fakeKubeClient: &fakeKubeClient{}}},
|
||||||
|
&backupTestLonghornClient{
|
||||||
|
restoreTestLonghornClient: &restoreTestLonghornClient{fakeLonghornClient: &fakeLonghornClient{
|
||||||
|
volumes: map[string]longhorn.Volume{
|
||||||
|
"apps-data-pv": {Name: "apps-data-pv", State: "detached"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, result, err := srv.executeBackup(context.Background(), api.BackupRequest{Namespace: "apps", PVC: "data"}, "brad")
|
||||||
|
if result != "not_ready" || err == nil || !strings.Contains(err.Error(), "is detached") {
|
||||||
|
t.Fatalf("expected detached volume not_ready error, got result=%q err=%v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("restic backend error", func(t *testing.T) {
|
t.Run("restic backend error", func(t *testing.T) {
|
||||||
srv := newBackupTestServer(
|
srv := newBackupTestServer(
|
||||||
&config.Config{AuthRequired: false, BackupDriver: "restic"},
|
&config.Config{AuthRequired: false, BackupDriver: "restic"},
|
||||||
|
|||||||
@ -85,6 +85,18 @@ func pvcAccessModes(pvc *corev1.PersistentVolumeClaim) []string {
|
|||||||
return modes
|
return modes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) longhornVolumeReady(ctx context.Context, volumeName string) (bool, string, error) {
|
||||||
|
volume, err := s.longhorn.GetVolume(ctx, volumeName)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
state := strings.ToLower(strings.TrimSpace(volume.State))
|
||||||
|
if state != "" && state != "attached" {
|
||||||
|
return false, fmt.Sprintf("Longhorn volume %s is %s; backup waits until the volume is attached", volumeName, state), nil
|
||||||
|
}
|
||||||
|
return true, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) activeResticRepositories(ctx context.Context, namespaces []string) (map[string]struct{}, error) {
|
func (s *Server) activeResticRepositories(ctx context.Context, namespaces []string) (map[string]struct{}, error) {
|
||||||
active := map[string]struct{}{}
|
active := map[string]struct{}{}
|
||||||
for _, namespace := range uniqueSortedStrings(namespaces) {
|
for _, namespace := range uniqueSortedStrings(namespaces) {
|
||||||
|
|||||||
@ -3,12 +3,14 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"scm.bstein.dev/bstein/soteria/internal/api"
|
"scm.bstein.dev/bstein/soteria/internal/api"
|
||||||
"scm.bstein.dev/bstein/soteria/internal/config"
|
"scm.bstein.dev/bstein/soteria/internal/config"
|
||||||
"scm.bstein.dev/bstein/soteria/internal/k8s"
|
"scm.bstein.dev/bstein/soteria/internal/k8s"
|
||||||
|
"scm.bstein.dev/bstein/soteria/internal/longhorn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type policyCycleTestKubeClient struct {
|
type policyCycleTestKubeClient struct {
|
||||||
@ -279,3 +281,47 @@ func TestRunPolicyCycleSkipsLiveExclusivePVCs(t *testing.T) {
|
|||||||
t.Fatalf("expected one successful policy backup, got %f", got)
|
t.Fatalf("expected one successful policy backup, got %f", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunPolicyCycleSkipsDetachedLonghornVolumesWithoutConsumingLimit(t *testing.T) {
|
||||||
|
client := &policyCycleTestKubeClient{
|
||||||
|
inventoryTestKubeClient: &inventoryTestKubeClient{
|
||||||
|
fakeKubeClient: &fakeKubeClient{
|
||||||
|
pvcs: []k8s.PVCSummary{
|
||||||
|
{Namespace: "apps", Name: "detached", VolumeName: "vol-detached", Phase: "Bound"},
|
||||||
|
{Namespace: "apps", Name: "ready", VolumeName: "vol-ready", Phase: "Bound"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
longhornClient := &fakeLonghornClient{
|
||||||
|
volumes: map[string]longhorn.Volume{
|
||||||
|
"vol-detached": {Name: "vol-detached", State: "detached"},
|
||||||
|
"vol-ready": {Name: "vol-ready", State: "attached"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := &Server{
|
||||||
|
cfg: &config.Config{
|
||||||
|
BackupDriver: "longhorn",
|
||||||
|
BackupMaxAge: 24 * time.Hour,
|
||||||
|
PolicyBackupsPerCycle: 1,
|
||||||
|
},
|
||||||
|
client: client,
|
||||||
|
longhorn: longhornClient,
|
||||||
|
metrics: newTelemetry(),
|
||||||
|
policies: map[string]api.BackupPolicy{
|
||||||
|
"apps__all": {ID: "apps__all", Namespace: "apps", IntervalHours: 1, Enabled: true, Dedupe: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.runPolicyCycle(context.Background())
|
||||||
|
|
||||||
|
if longhornClient.createSnapshotName == "" || !strings.Contains(longhornClient.createSnapshotName, "apps-ready") {
|
||||||
|
t.Fatalf("expected ready volume snapshot backup after detached skip, got snapshot %q", longhornClient.createSnapshotName)
|
||||||
|
}
|
||||||
|
if got := metricCount(srv.metrics.policyBackups, map[string]string{"result": "volume_not_ready"}); got != 1 {
|
||||||
|
t.Fatalf("expected volume_not_ready metric, got %f", got)
|
||||||
|
}
|
||||||
|
if got := metricCount(srv.metrics.policyBackups, map[string]string{"result": "success"}); got != 1 {
|
||||||
|
t.Fatalf("expected one successful policy backup, got %f", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -131,6 +131,19 @@ func (s *Server) runPolicyCycle(ctx context.Context) {
|
|||||||
s.metrics.RecordPolicyBackup("not_due")
|
s.metrics.RecordPolicyBackup("not_due")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if s.cfg.BackupDriver == "longhorn" {
|
||||||
|
ready, reason, err := s.longhornVolumeReady(runCtx, pvc.Volume)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("policy cycle Longhorn volume lookup failed for %s/%s: %v", pvc.Namespace, pvc.PVC, err)
|
||||||
|
s.metrics.RecordPolicyBackup("active_lookup_error")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
log.Printf("policy backup skipped for %s/%s: %s", pvc.Namespace, pvc.PVC, reason)
|
||||||
|
s.metrics.RecordPolicyBackup("volume_not_ready")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if s.cfg.PolicyBackupsPerCycle > 0 && started >= s.cfg.PolicyBackupsPerCycle {
|
if s.cfg.PolicyBackupsPerCycle > 0 && started >= s.cfg.PolicyBackupsPerCycle {
|
||||||
s.metrics.RecordPolicyBackup("cycle_limit")
|
s.metrics.RecordPolicyBackup("cycle_limit")
|
||||||
break
|
break
|
||||||
|
|||||||
@ -175,6 +175,7 @@ func (f *secretErrorKubeClient) LoadSecretData(_ context.Context, _, _, _ string
|
|||||||
|
|
||||||
type fakeLonghornClient struct {
|
type fakeLonghornClient struct {
|
||||||
backups []longhorn.Backup
|
backups []longhorn.Backup
|
||||||
|
volumes map[string]longhorn.Volume
|
||||||
createSnapshotName string
|
createSnapshotName string
|
||||||
snapshotBackupName string
|
snapshotBackupName string
|
||||||
}
|
}
|
||||||
@ -190,6 +191,13 @@ func (f *fakeLonghornClient) SnapshotBackup(_ context.Context, volume, name stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeLonghornClient) GetVolume(_ context.Context, volume string) (*longhorn.Volume, error) {
|
func (f *fakeLonghornClient) GetVolume(_ context.Context, volume string) (*longhorn.Volume, error) {
|
||||||
|
if f.volumes != nil {
|
||||||
|
item := f.volumes[volume]
|
||||||
|
if item.Name == "" {
|
||||||
|
item.Name = volume
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
return &longhorn.Volume{Name: volume, Size: "1073741824", NumberOfReplicas: 2}, nil
|
return &longhorn.Volume{Name: volume, Size: "1073741824", NumberOfReplicas: 2}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user