diff --git a/internal/longhorn/client.go b/internal/longhorn/client.go index 3f0ea63..caac960 100644 --- a/internal/longhorn/client.go +++ b/internal/longhorn/client.go @@ -85,11 +85,25 @@ type snapshotInput struct { BackupMode string `json:"backupMode,omitempty"` } +type snapshotCreateInput struct { + Name string `json:"name,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + type pvcCreateInput struct { Namespace string `json:"namespace"` PVCName string `json:"pvcName"` } +func (c *Client) CreateSnapshot(ctx context.Context, volume, name string, labels map[string]string) error { + path := fmt.Sprintf("%s/v1/volumes/%s?action=snapshotCreate", c.baseURL, url.PathEscape(volume)) + input := snapshotCreateInput{ + Name: name, + Labels: labels, + } + return c.doJSON(ctx, http.MethodPost, path, input, nil) +} + func (c *Client) SnapshotBackup(ctx context.Context, volume, name string, labels map[string]string, backupMode string) (*Volume, error) { path := fmt.Sprintf("%s/v1/volumes/%s?action=snapshotBackup", c.baseURL, url.PathEscape(volume)) input := snapshotInput{ diff --git a/internal/server/server.go b/internal/server/server.go index 6bd2664..c21bd79 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -35,6 +35,7 @@ type kubeClient interface { } type longhornClient interface { + CreateSnapshot(ctx context.Context, volume, name string, labels map[string]string) error SnapshotBackup(ctx context.Context, volume, name string, labels map[string]string, backupMode string) (*longhorn.Volume, error) GetVolume(ctx context.Context, volume string) (*longhorn.Volume, error) CreateVolumeFromBackup(ctx context.Context, name, size string, replicas int, backupURL string) (*longhorn.Volume, error) @@ -620,6 +621,9 @@ func (s *Server) executeBackup(ctx context.Context, req api.BackupRequest, reque "soteria.bstein.dev/pvc": req.PVC, "soteria.bstein.dev/requested-by": requester, } + if err := s.longhorn.CreateSnapshot(ctx, volumeName, backupID, labels); err != nil { + return api.BackupResponse{}, "backend_error", err + } if _, err := s.longhorn.SnapshotBackup(ctx, volumeName, backupID, labels, s.cfg.LonghornBackupMode); err != nil { return api.BackupResponse{}, "backend_error", err } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9de80d2..24dc753 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -68,10 +68,18 @@ func (f *fakeKubeClient) SaveSecretData(_ context.Context, _, _, key string, val } type fakeLonghornClient struct { - backups []longhorn.Backup + backups []longhorn.Backup + createSnapshotName string + snapshotBackupName string +} + +func (f *fakeLonghornClient) CreateSnapshot(_ context.Context, volume, name string, labels map[string]string) error { + f.createSnapshotName = name + return nil } func (f *fakeLonghornClient) SnapshotBackup(_ context.Context, volume, name string, labels map[string]string, backupMode string) (*longhorn.Volume, error) { + f.snapshotBackupName = name return &longhorn.Volume{Name: volume}, nil } @@ -222,6 +230,38 @@ func TestRestoreRejectsSameSourceAndTarget(t *testing.T) { } } +func TestBackupCreatesSnapshotBeforeBackup(t *testing.T) { + lh := &fakeLonghornClient{} + srv := &Server{ + cfg: &config.Config{AuthRequired: false, BackupDriver: "longhorn", LonghornBackupMode: "incremental"}, + client: &fakeKubeClient{}, + longhorn: lh, + metrics: newTelemetry(), + } + srv.handler = http.HandlerFunc(srv.route) + + body := `{"namespace":"apps","pvc":"data","dry_run":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/backup", strings.NewReader(body)) + res := httptest.NewRecorder() + srv.Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String()) + } + if lh.createSnapshotName == "" { + t.Fatalf("expected snapshot creation call") + } + if lh.snapshotBackupName == "" { + t.Fatalf("expected snapshot backup call") + } + if lh.createSnapshotName != lh.snapshotBackupName { + t.Fatalf("expected same snapshot and backup name, got snapshot=%q backup=%q", lh.createSnapshotName, lh.snapshotBackupName) + } + if !strings.HasPrefix(lh.createSnapshotName, "soteria-backup-apps-data-") { + t.Fatalf("unexpected generated backup name %q", lh.createSnapshotName) + } +} + func TestMetricsStayPublic(t *testing.T) { srv := &Server{ cfg: &config.Config{AuthRequired: true, AllowedGroups: []string{"admin"}},