Skip to content

Conversation

@nfebe
Copy link
Contributor

@nfebe nfebe commented Jan 1, 2026

Implement complete backup and scheduled task functionality:

  • Add scheduler package with cron-based task execution
  • Add backup package for deployment backup/restore
  • Add API endpoints for managing backups and scheduled tasks
  • Extend ServiceMetadata model with backup specifications
  • Update WordPress and Laravel templates with backup definitions

Backup features:

  • Backup compose files, env files, and metadata
  • Export container data using docker cp
  • Dump MySQL/PostgreSQL databases
  • Pre/post backup hooks for maintenance mode
  • Retention-based cleanup

Scheduler features:

  • Cron expression support for task scheduling
  • Backup and command task types
  • Task execution history tracking
  • On-demand task execution

Implement complete backup and scheduled task functionality:

- Add scheduler package with cron-based task execution
- Add backup package for deployment backup/restore
- Add API endpoints for managing backups and scheduled tasks
- Extend ServiceMetadata model with backup specifications
- Update WordPress and Laravel templates with backup definitions

Backup features:
- Backup compose files, env files, and metadata
- Export container data using docker cp
- Dump MySQL/PostgreSQL databases
- Pre/post backup hooks for maintenance mode
- Retention-based cleanup

Scheduler features:
- Cron expression support for task scheduling
- Backup and command task types
- Task execution history tracking
- On-demand task execution

Signed-off-by: nfebe <fenn25.fn@gmail.com>
@sourceant
Copy link

sourceant bot commented Jan 1, 2026

Code Review Summary

This pull request introduces comprehensive backup and scheduling functionalities to the Flatrun agent, including API endpoints, a job tracker, and an executor for tasks. It significantly enhances the agent's capabilities for managing deployments and their data lifecycle. The changes involve new files for API handlers, backup management, scheduler logic, and related tests.

🚀 Key Improvements

  • Added robust backup functionality, including support for compose files, environment variables, metadata, mounted data, container data, and databases.
  • Introduced a flexible scheduling system for automated tasks like backups and custom commands using cron expressions.
  • Integrated job tracking for backup and restore operations, providing status, progress, and error details.
  • Expanded API endpoints to manage backups, backup configurations, scheduled tasks, and task executions.
  • Improved deployment metadata to include backup specifications for granular control.

💡 Minor Suggestions

  • Refactor repetitive manager availability checks in API handlers into Gin middleware for cleaner code.
  • Standardize error messages to avoid leaking potentially sensitive deployment names.
  • Extract common logic for parsing limit query parameters into a helper function.
  • Consider using time.Duration directly for timeout fields in configuration structs for better type clarity.
  • Review the aliasing of backup-related types from pkg/models in internal/backup/types.go for clearer type hierarchy.
  • Ensure an Nginx reload is explicitly triggered after security settings affecting Nginx configuration are updated.

🚨 Critical Issues

  • The use of a fixed time.Sleep duration in the RestoreBackup function to wait for containers to start is brittle and could lead to restore failures. Implement a more robust container readiness check using health checks or polling.

@sourceant
Copy link

sourceant bot commented Jan 1, 2026

🔍 Code Review

💡 1. **internal/api/server.go** (Lines 801-808) - SECURITY

The generateRandomPassword function uses time.Now().UnixNano() for entropy, which is not cryptographically secure. For generating sensitive data like database passwords, it's crucial to use a cryptographically strong random number generator to prevent predictability and potential security breaches. Using crypto/rand is the standard and recommended approach in Go for such purposes.

Suggested Code:

func generateRandomPassword(length int) string {
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	b := make([]byte, length)
	_, err := rand.Read(b)
	if err != nil {
		// Fallback or panic, depending on application needs. Logging and returning an error is safer.
		log.Printf("Error generating random bytes: %v, falling back to non-cryptographic random", err)
		for i := range b {
			b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
		}
		return string(b)
	}

	for i := range b {
		b[i] = charset[int(b[i])%len(charset)]
	}
	return string(b)
}

Current Code:

func generateRandomPassword(length int) string {
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	b := make([]byte, length)
	for i := range b {
		b[i] = charset[time.Now().UnixNano()%int64(len(charset))]
		time.Sleep(time.Nanosecond)
	}
	return string(b)
}
💡 2. **internal/backup/manager.go** (Lines 313-316) - SECURITY

Passing MySQL password directly in the mysqldump command arguments (e.g., -pPASSWORD) can expose the password in process lists, which is a security risk. A more secure method is to use the MYSQL_PWD environment variable, similar to how PGPASSWORD is used for PostgreSQL.

Suggested Code:

		cmd := exec.CommandContext(ctx, "docker", args...)
		if db.Password != "" {
			cmd.Env = append(cmd.Env, fmt.Sprintf("MYSQL_PWD=%s", db.Password))
		}
		output, err := cmd.Output()

Current Code:

		cmd := exec.CommandContext(ctx, "docker", args...)
		output, err := cmd.Output()
		if err != nil {
			return "", fmt.Errorf("mysqldump failed: %w", err)
		}
💡 3. **internal/backup/manager.go** (Line 386) - SECURITY

The sh -c command used with docker exec can be vulnerable to command injection if hook.Command contains unsanitized user input. While hooks are typically defined by template authors, it's a good practice to be explicit about the execution model. Consider validating or escaping the command if any part of it might originate from untrusted sources, or if it's meant to be a single command rather than a shell script.

Suggested Code:

		cmd := exec.CommandContext(hookCtx, "docker", "exec", containerName, "bash", "-c", hook.Command) // Use bash if available for stricter shell parsing or explicit error handling

Current Code:

		cmd := exec.CommandContext(hookCtx, "docker", "exec", containerName, "sh", "-c", hook.Command)
💡 4. **internal/scheduler/executor.go** (Line 76) - SECURITY

Similar to backup hooks, the command executed via docker exec in ExecuteCommand is passed to sh -c. If config.Command is constructed from or influenced by user input without proper sanitization, it could lead to command injection. Ensure all components of config.Command are trusted or appropriately escaped.

Suggested Code:

		cmd := exec.CommandContext(cmdCtx, "docker", "exec", containerName, "bash", "-c", config.Command) // Use bash if available for stricter shell parsing or explicit error handling

Current Code:

		cmd := exec.CommandContext(cmdCtx, "docker", "exec", containerName, "sh", "-c", config.Command)
💡 5. **internal/api/server.go** (Lines 154-163) - REFACTOR

The initialization of the schedulerManager within New includes a conditional check for backupManager != nil. This creates a dependency where the scheduler won't be initialized if the backup manager fails. While understandable for backup tasks, the scheduler could potentially manage other task types (e.g., command tasks) independently. Consider if the scheduler should have a more resilient initialization that allows it to function even if one of its executors (like backup) is unavailable, perhaps by having a list of executors it attempts to enable.

Suggested Code:

	if backupManager != nil {
		executor := scheduler.NewExecutor(backupManager, manager)
		schedulerManager, err := scheduler.NewManager(cfg.DeploymentsPath, executor)
		if err != nil {
			log.Printf("Warning: Failed to initialize scheduler manager: %v", err)
		} else {
			s.schedulerManager = schedulerManager
			s.schedulerManager.Start()
		}
	} else {
		log.Println("Info: Backup manager not available, scheduler will only support command tasks if configured")
		executor := scheduler.NewExecutor(nil, manager) // Pass nil for backupManager, scheduler can still run command tasks
		schedulerManager, err := scheduler.NewManager(cfg.DeploymentsPath, executor)
		if err != nil {
			log.Printf("Warning: Failed to initialize scheduler manager: %v", err)
		} else {
			s.schedulerManager = schedulerManager
			s.schedulerManager.Start()
		}
	}

Current Code:

	if backupManager != nil {
		executor := scheduler.NewExecutor(backupManager, manager)
		schedulerManager, err := scheduler.NewManager(cfg.DeploymentsPath, executor)
		if err != nil {
			log.Printf("Warning: Failed to initialize scheduler manager: %v", err)
		} else {
			s.schedulerManager = schedulerManager
			s.schedulerManager.Start()
		}
	}
💡 6. **internal/api/backup_handlers.go** (Lines 12-16) - REFACTOR

The repeated if s.backupManager == nil checks in multiple handlers are boilerplate. This logic can be extracted into a Gin middleware, making the handlers cleaner and ensuring consistency across all backup-related endpoints. The same applies to scheduler handlers.

Suggested Code:

func backupManagerRequired(s *Server) gin.HandlerFunc {
	return func(c *gin.Context) {
		if s.backupManager == nil {
			c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
			c.Abort()
			return
		}
		c.Next()
	}
}

// In setupRoutes:
// protected.Use(backupManagerRequired(s))
// ... (individual handlers no longer need the check)

Current Code:

func (s *Server) listBackups(c *gin.Context) {
	if s.backupManager == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
		return
	}
💡 7. **internal/backup/manager.go** (Line 44) - IMPROVEMENT

The backupID is constructed using deploymentName_timestamp. While functional, relying on this format for parsing in GetBackup and DeleteBackup introduces tight coupling. Using a UUID for the backupID and storing deploymentName as a separate field in the BackupMetadata (which is already done) would make the ID opaque, more robust to format changes, and still allow for efficient lookup if an index on deploymentName is maintained.

Suggested Code:

	backupID := uuid.New().String() // Requires import "github.com/google/uuid"

Current Code:

	backupID := fmt.Sprintf("%s_%s", deploymentName, time.Now().Format("20060102_150405"))
💡 8. **internal/scheduler/scheduler.go** (Lines 119-120) - PERFORMANCE

The context.WithTimeout for executeTask is hardcoded to 30*time.Minute. While this is a reasonable default, task execution times can vary significantly (e.g., a simple command vs. a large database backup). It would be more flexible and robust to allow CommandTaskConfig and BackupTaskConfig to specify their own timeouts, falling back to a default if not provided.

Suggested Code:

	var taskTimeout = 30 * time.Minute // Default
	// Determine specific timeout from task.Config if available
	if task.Type == TaskTypeCommand && task.Config.CommandConfig != nil && task.Config.CommandConfig.Timeout > 0 {
		taskTimeout = time.Duration(task.Config.CommandConfig.Timeout) * time.Second
	} else if task.Type == TaskTypeBackup && task.Config.BackupConfig != nil { // Add a timeout for backup tasks if needed
		// taskTimeout = time.Duration(task.Config.BackupConfig.Timeout) * time.Second
	}

	ctx, cancel := context.WithTimeout(context.Background(), taskTimeout)

Current Code:

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
	defer cancel()

Verdict: APPROVE

Posted as a comment because posting a review failed.

- Add JobTracker for managing async backup/restore jobs
- Refactor backup creation to return job ID immediately
- Add RestoreBackup functionality with async execution
- Add job status endpoints (GET /backups/jobs, GET /backups/jobs/:id)
- Update API handlers to use StartBackupJob/StartRestoreJob
Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

return jobID
}

func (m *Manager) StartRestoreJob(ctx context.Context, req *RestoreBackupRequest) string {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to StartBackupJob, StartRestoreJob takes ctx context.Context but uses context.Background() for the actual restore operation. This is inconsistent with the function signature. It's either explicitly not needed in the signature, or should be used for the underlying restore logic.

Suggested change
func (m *Manager) StartRestoreJob(ctx context.Context, req *RestoreBackupRequest) string {
func (m *Manager) StartRestoreJob(req *RestoreBackupRequest) string {

Comment on lines +216 to +217
req = backup.RestoreBackupRequest{}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If c.ShouldBindJSON(&req) fails, the req struct is completely reinitialized. This means if any JSON fields were successfully bound before the error (e.g., RestoreData), they would be reset to their zero values. It's generally better to handle binding errors more granularly or check for Content-Length before attempting JSON binding if the body is optional.

Suggested change
req = backup.RestoreBackupRequest{}
}
var req backup.RestoreBackupRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
req.BackupID = backupID

Comment on lines +12 to +15
if s.schedulerManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to backup handlers, the repeated check for s.schedulerManager == nil can be refactored into a middleware to reduce code duplication and improve handler readability.

Suggested change
if s.schedulerManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})
return
}
func schedulerManagerRequired(s *Server) gin.HandlerFunc {
return func(c *gin.Context) {
if s.schedulerManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})
c.Abort()
return
}
c.Next()
}
}
// Then in setupRoutes:
// protectedScheduler := protected.Group("/scheduler").Use(schedulerManagerRequired(s))
// protectedScheduler.GET("/tasks", s.listScheduledTasks)

Add comprehensive tests for:
- JobTracker operations (create, get, update, list, cleanup)
- Manager backup operations (create, list, get, delete, cleanup)
- Async job handling

Signed-off-by: nfebe <fenn25.fn@gmail.com>
@nfebe nfebe force-pushed the feat/backup-and-scheduler branch from 140dc2d to 6bd1778 Compare January 3, 2026 13:20
Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment on lines +13 to +16
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check for s.backupManager == nil is repeated at the beginning of almost every handler in this file. It would be more idiomatic and cleaner to extract this into a Gin middleware function. This reduces boilerplate and centralizes the logic.

Suggested change
if s.backupManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})
return
}
func requireBackupManager(s *Server) gin.HandlerFunc {return func(c *gin.Context) {if s.backupManager == nil {c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Backup manager not enabled"})c.Abort()return}c.Next()}}

Comment on lines +12 to +15
if s.schedulerManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})
return
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the backupManager checks, the s.schedulerManager == nil check is repeated in all scheduler handlers. This should also be refactored into a middleware to improve code quality and reduce duplication.

Suggested change
if s.schedulerManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})
return
}
func requireSchedulerManager(s *Server) gin.HandlerFunc {return func(c *gin.Context) {if s.schedulerManager == nil {c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Scheduler not enabled"})c.Abort()return}c.Next()}}

Comment on lines +67 to +68
c.JSON(http.StatusBadRequest, gin.H{"error": "Deployment not found: " + req.DeploymentName})
return
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning req.DeploymentName directly in the error message for a Deployment not found can be an information disclosure risk, especially if deployment names are sensitive. It's better to keep error messages generic for StatusNotFound to avoid leaking internal system details.

Suggested change
c.JSON(http.StatusBadRequest, gin.H{"error": "Deployment not found: " + req.DeploymentName})
return
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})

@nfebe nfebe merged commit f913338 into main Jan 3, 2026
5 checks passed
@nfebe nfebe deleted the feat/backup-and-scheduler branch January 3, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants