Skip to content
Merged
50 changes: 50 additions & 0 deletions internal/api/authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,56 @@ func TestRestoreBackupRequiresWriteOnTargetDeployment(t *testing.T) {
}
}

func TestIsolatedRestoreRequiresNewDeploymentName(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
createTestDeployment(t, tmpDir, "source-app", &models.ServiceMetadata{Name: "source-app"})
backupManager, err := backup.NewManager(tmpDir)
if err != nil {
t.Fatal(err)
}
created, err := backupManager.CreateBackup(context.Background(), "source-app", nil)
if err != nil {
t.Fatal(err)
}
server := &Server{backupManager: backupManager}
router := gin.New()
router.POST("/backups/:id/restore", server.restoreBackup)
req := httptest.NewRequest(http.MethodPost, "/backups/"+created.ID+"/restore", bytes.NewBufferString("{\"isolated\":true}"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}

func TestIsolatedRestoreRejectsExistingTargetBeforeStarting(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
createTestDeployment(t, tmpDir, "source-app", &models.ServiceMetadata{Name: "source-app"})
createTestDeployment(t, tmpDir, "target-app", &models.ServiceMetadata{Name: "target-app"})
backupManager, err := backup.NewManager(tmpDir)
if err != nil {
t.Fatal(err)
}
created, err := backupManager.CreateBackup(context.Background(), "source-app", nil)
if err != nil {
t.Fatal(err)
}
server := &Server{backupManager: backupManager, manager: docker.NewManager(tmpDir)}
router := gin.New()
router.POST("/backups/:id/restore", server.restoreBackup)
body := bytes.NewBufferString(`{"isolated":true,"deployment_name":"target-app"}`)
req := httptest.NewRequest(http.MethodPost, "/backups/"+created.ID+"/restore", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
}

func TestCreateScheduledTaskRequiresWriteDeploymentAccess(t *testing.T) {
gin.SetMode(gin.TestMode)

Expand Down
81 changes: 81 additions & 0 deletions internal/api/backup_destination_selection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/flatrun/agent/internal/docker"
"github.com/flatrun/agent/pkg/config"
"github.com/flatrun/agent/pkg/models"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)

func TestDeploymentBackupDestinationsThroughHTTP(t *testing.T) {
root := t.TempDir()
deploymentDir := filepath.Join(root, "app")
if err := os.MkdirAll(deploymentDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deploymentDir, "service.yml"), []byte("name: app\n"), 0644); err != nil {
t.Fatal(err)
}
disabled := false
server := &Server{
manager: docker.NewManager(root),
config: &config.Config{Backup: config.BackupConfig{Destinations: []config.BackupDestination{
{Name: "primary", Kind: "external", CredentialID: "private-credential"},
{Name: "disabled", Kind: "external", Enabled: &disabled},
}}},
}
router := gin.New()
router.GET("/deployments/:name/backup-destinations", server.listDeploymentBackupDestinationOptions)
router.PUT("/deployments/:name/backup-config", server.updateDeploymentBackupConfig)

options := httptest.NewRecorder()
router.ServeHTTP(options, httptest.NewRequest(http.MethodGet, "/deployments/app/backup-destinations", nil))
if options.Code != http.StatusOK || strings.Contains(options.Body.String(), "private-credential") || strings.Contains(options.Body.String(), "disabled") {
t.Fatalf("options response = %d %s", options.Code, options.Body.String())
}

unknown := httptest.NewRecorder()
router.ServeHTTP(unknown, httptest.NewRequest(http.MethodPut, "/deployments/app/backup-config", strings.NewReader(`{"destinations":["missing"]}`)))
if unknown.Code != http.StatusBadRequest {
t.Fatalf("unknown destination status = %d: %s", unknown.Code, unknown.Body.String())
}

saved := httptest.NewRecorder()
router.ServeHTTP(saved, httptest.NewRequest(http.MethodPut, "/deployments/app/backup-config", strings.NewReader(`{"destinations":["primary"]}`)))
if saved.Code != http.StatusOK {
t.Fatalf("save status = %d: %s", saved.Code, saved.Body.String())
}
data, err := os.ReadFile(filepath.Join(deploymentDir, "service.yml"))
if err != nil {
t.Fatal(err)
}
var metadata models.ServiceMetadata
if err := yaml.Unmarshal(data, &metadata); err != nil {
t.Fatal(err)
}
if metadata.Backup == nil || len(metadata.Backup.Destinations) != 1 || metadata.Backup.Destinations[0] != "primary" {
t.Fatalf("saved backup config = %#v", metadata.Backup)
}

var response struct {
BackupConfig models.BackupSpec `json:"backup_config"`
}
if err := json.Unmarshal(saved.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.BackupConfig.Destinations) != 1 || response.BackupConfig.Destinations[0] != "primary" {
t.Fatalf("response backup config = %#v", response.BackupConfig)
}
}
14 changes: 14 additions & 0 deletions internal/api/backup_destinations.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ func (s *Server) listBackupDestinations(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"destinations": dests})
}

func (s *Server) listDeploymentBackupDestinationOptions(c *gin.Context) {
type option struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
options := make([]option, 0)
for _, destination := range s.config.Backup.Destinations {
if destination.IsEnabled() {
options = append(options, option{Name: destination.Name, Kind: destination.StoreKind()})
}
}
c.JSON(http.StatusOK, gin.H{"destinations": options})
}

func (s *Server) findDestinationByName(name string) (config.BackupDestination, bool) {
for _, d := range s.config.Backup.Destinations {
if d.Name == name {
Expand Down
144 changes: 142 additions & 2 deletions internal/api/backup_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,107 @@ package api

import (
"context"
"fmt"
"net/http"
"strconv"

"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/backup"
"github.com/flatrun/agent/internal/scheduler"
"github.com/flatrun/agent/pkg/models"
"github.com/gin-gonic/gin"
)

type deploymentBackupPolicy struct {
Config *backup.BackupSpec `json:"config"`
Schedules []scheduler.ScheduledTask `json:"schedules"`
BackupCount int `json:"backup_count"`
LocalBytes int64 `json:"local_bytes"`
FailedCount int `json:"failed_count"`
SizeAlert bool `json:"size_alert"`
CleanupPreview *backup.CleanupPreview `json:"cleanup_preview"`
}

func (s *Server) getDeploymentBackupPolicy(c *gin.Context) {
name := c.Param("name")
deployment, err := s.manager.GetDeployment(name)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
spec := s.effectiveBackupSpec(deployment)
backups, err := s.backupManager.ListBackups(&backup.BackupListFilter{DeploymentName: name})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
policy := deploymentBackupPolicy{Config: spec, Schedules: []scheduler.ScheduledTask{}, BackupCount: len(backups)}
for _, item := range backups {
if containsLocation(item.Locations, "local") {
policy.LocalBytes += item.Size
}
if item.Status == backup.BackupStatusFailed || item.Status == backup.BackupStatusPartial || item.Status == backup.BackupStatusLocalOnly {
policy.FailedCount++
}
}
if s.schedulerManager != nil {
tasks, taskErr := s.schedulerManager.GetTasksByDeployment(name)
if taskErr == nil {
for _, task := range tasks {
if task.Type == scheduler.TaskTypeBackup {
policy.Schedules = append(policy.Schedules, task)
}
}
}
}
keep := spec.RetentionCount
if keep < 1 {
keep = 7
}
policy.CleanupPreview, _ = s.backupManager.PreviewCleanup(name, keep)
policy.SizeAlert = spec.SizeAlertBytes > 0 && policy.LocalBytes >= spec.SizeAlertBytes
c.JSON(http.StatusOK, gin.H{"policy": policy})
}

func containsLocation(locations []string, wanted string) bool {
for _, location := range locations {
if location == wanted {
return true
}
}
return false
}

func (s *Server) previewDeploymentBackupCleanup(c *gin.Context) {
keep, err := strconv.Atoi(c.DefaultQuery("keep", "7"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid retention count"})
return
}
preview, err := s.backupManager.PreviewCleanup(c.Param("name"), keep)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"preview": preview})
}

func (s *Server) cleanupDeploymentBackups(c *gin.Context) {
var req struct {
Keep int `json:"keep" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
deleted, err := s.backupManager.CleanupOldBackups(c.Param("name"), req.Keep)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}

func (s *Server) retryBackupPublication(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
Expand Down Expand Up @@ -265,6 +357,14 @@ func (s *Server) updateDeploymentBackupConfig(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.validateBackupDestinations(spec.Destinations); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if spec.RetentionCount < 0 || spec.SizeAlertBytes < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Retention and size alert values cannot be negative"})
return
}

if deployment.Metadata == nil {
deployment.Metadata = &models.ServiceMetadata{}
Expand All @@ -279,6 +379,26 @@ func (s *Server) updateDeploymentBackupConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"backup_config": spec})
}

func (s *Server) validateBackupDestinations(names []string) error {
enabled := make(map[string]bool)
for _, destination := range s.config.Backup.Destinations {
if destination.IsEnabled() {
enabled[destination.Name] = true
}
}
seen := make(map[string]bool, len(names))
for _, name := range names {
if name == "" || !enabled[name] {
return fmt.Errorf("backup destination %q is unavailable", name)
}
if seen[name] {
return fmt.Errorf("backup destination %q is selected more than once", name)
}
seen[name] = true
}
return nil
}

func (s *Server) restoreBackup(c *gin.Context) {
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
Expand All @@ -305,11 +425,31 @@ func (s *Server) restoreBackup(c *gin.Context) {
if !s.requireDeploymentAccess(c, b.DeploymentName, auth.AccessLevelRead) {
return
}
if !s.requireDeploymentAccess(c, targetDeployment, auth.AccessLevelWrite) {
if req.Isolated {
if req.DeploymentName == "" || req.DeploymentName == b.DeploymentName {
c.JSON(http.StatusBadRequest, gin.H{"error": "Isolated restore requires a new deployment name"})
return
}
actor := auth.GetActorFromContext(c)
if actor != nil && !actor.HasPermission(auth.PermDeploymentsWrite) {
c.JSON(http.StatusForbidden, gin.H{"error": "Deployment write permission required"})
return
}
if _, lookupErr := s.manager.GetDeployment(targetDeployment); lookupErr == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Deployment already exists"})
return
}
} else if !s.requireDeploymentAccess(c, targetDeployment, auth.AccessLevelWrite) {
return
}

jobID := s.backupManager.StartRestoreJob(&req)
actor := auth.GetActorFromContext(c)
jobID := s.backupManager.StartRestoreJob(&req, func() error {
if !req.Isolated || s.authManager == nil || actor == nil || actor.User == nil || actor.Role == auth.RoleAdmin {
return nil
}
return s.authManager.AssignDeployment(actor.User.ID, targetDeployment, auth.AccessLevelAdmin, actor.User.ID)
})
c.JSON(http.StatusAccepted, gin.H{"job_id": jobID, "message": "Restore job started"})
}

Expand Down
Loading
Loading