diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 69fa447..fabec8a 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -235,9 +235,9 @@ func (m *Middleware) CSRF() func(http.Handler) http.Handler { // loginRateLimit configures the login rate limiter. const ( loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds - loginBurst = 5 // allow burst of 5 - limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes - limiterCleanupEvery = 1 * time.Minute // sweep interval + loginBurst = 5 // allow burst of 5 + limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes + limiterCleanupEvery = 1 * time.Minute // sweep interval ) // ipLimiterEntry stores a rate limiter with its last-seen timestamp. @@ -249,8 +249,8 @@ type ipLimiterEntry struct { // ipLimiter tracks per-IP rate limiters for login attempts with automatic // eviction of stale entries to prevent unbounded memory growth. type ipLimiter struct { - mu sync.Mutex - limiters map[string]*ipLimiterEntry + mu sync.Mutex + limiters map[string]*ipLimiterEntry lastSweep time.Time } diff --git a/internal/models/app.go b/internal/models/app.go index 03f5031..593cf8d 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -32,23 +32,23 @@ const ( type App struct { db *database.Database - ID string - Name string - RepoURL string - Branch string - DockerfilePath string + ID string + Name string + RepoURL string + Branch string + DockerfilePath string WebhookSecret string WebhookSecretHash string SSHPrivateKey string - SSHPublicKey string - ImageID sql.NullString - PreviousImageID sql.NullString - Status AppStatus - DockerNetwork sql.NullString - NtfyTopic sql.NullString - SlackWebhook sql.NullString - CreatedAt time.Time - UpdatedAt time.Time + SSHPublicKey string + ImageID sql.NullString + PreviousImageID sql.NullString + Status AppStatus + DockerNetwork sql.NullString + NtfyTopic sql.NullString + SlackWebhook sql.NullString + CreatedAt time.Time + UpdatedAt time.Time } // NewApp creates a new App with a database reference. diff --git a/internal/server/routes.go b/internal/server/routes.go index 11d4a0f..9dca5bc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -54,48 +54,48 @@ func (s *Server) SetupRoutes() { r.Group(func(r chi.Router) { r.Use(s.mw.SessionAuth()) - // Dashboard - r.Get("/", s.handlers.HandleDashboard()) + // Dashboard + r.Get("/", s.handlers.HandleDashboard()) - // Logout - r.Post("/logout", s.handlers.HandleLogout()) + // Logout + r.Post("/logout", s.handlers.HandleLogout()) - // App routes - r.Get("/apps/new", s.handlers.HandleAppNew()) - r.Post("/apps", s.handlers.HandleAppCreate()) - r.Get("/apps/{id}", s.handlers.HandleAppDetail()) - r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) - r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) - r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) - r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) - r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) - r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) - r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) - r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) - r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) - r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) - r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) - r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) - r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) - r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) - r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) - r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) + // App routes + r.Get("/apps/new", s.handlers.HandleAppNew()) + r.Post("/apps", s.handlers.HandleAppCreate()) + r.Get("/apps/{id}", s.handlers.HandleAppDetail()) + r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit()) + r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) + r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) + r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) + r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) + r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) + r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) + r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) + r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) + r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) + r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) + r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) + r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback()) + r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) + r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) + r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) - // Environment variables - r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) - r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) + // Environment variables + r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) + r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) - // Labels - r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) - r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) + // Labels + r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) + r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) - // Volumes - r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) - r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) + // Volumes + r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) + r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) - // Ports - r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) - r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) + // Ports + r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) + r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) }) }) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index c3f227c..a8d3751 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -82,7 +82,7 @@ type deploymentLogWriter struct { lineBuffer bytes.Buffer // buffer for incomplete lines mu sync.Mutex done chan struct{} - flushed sync.WaitGroup // waits for flush goroutine to finish + flushed sync.WaitGroup // waits for flush goroutine to finish flushCtx context.Context //nolint:containedctx // needed for async flush goroutine } diff --git a/internal/service/deploy/deploy_rollback_test.go b/internal/service/deploy/deploy_rollback_test.go new file mode 100644 index 0000000..fbdb511 --- /dev/null +++ b/internal/service/deploy/deploy_rollback_test.go @@ -0,0 +1,74 @@ +package deploy_test + +import ( + "context" + "database/sql" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + + "git.eeqj.de/sneak/upaas/internal/models" + "git.eeqj.de/sneak/upaas/internal/service/deploy" +) + +func TestRollback_NoPreviousImage(t *testing.T) { + t.Parallel() + + svc := deploy.NewTestService(slog.Default()) + app := &models.App{ + ID: "app-rollback-1", + PreviousImageID: sql.NullString{}, + } + + err := svc.Rollback(context.Background(), app) + assert.ErrorIs(t, err, deploy.ErrNoPreviousImage) +} + +func TestRollback_EmptyPreviousImage(t *testing.T) { + t.Parallel() + + svc := deploy.NewTestService(slog.Default()) + app := &models.App{ + ID: "app-rollback-2", + PreviousImageID: sql.NullString{String: "", Valid: true}, + } + + err := svc.Rollback(context.Background(), app) + assert.ErrorIs(t, err, deploy.ErrNoPreviousImage) +} + +func TestRollback_DeploymentLocked(t *testing.T) { + t.Parallel() + + svc := deploy.NewTestService(slog.Default()) + + // Simulate a deploy holding the lock + assert.True(t, svc.TryLockApp("app-rollback-3")) + defer svc.UnlockApp("app-rollback-3") + + app := &models.App{ + ID: "app-rollback-3", + PreviousImageID: sql.NullString{String: "sha256:abc123", Valid: true}, + } + + err := svc.Rollback(context.Background(), app) + assert.ErrorIs(t, err, deploy.ErrDeploymentInProgress) +} + +func TestRollback_LockedApp(t *testing.T) { + t.Parallel() + + svc := deploy.NewTestService(slog.Default()) + + assert.True(t, svc.TryLockApp("app-rollback-4")) + defer svc.UnlockApp("app-rollback-4") + + app := &models.App{ + ID: "app-rollback-4", + PreviousImageID: sql.NullString{String: "sha256:abc123", Valid: true}, + } + + err := svc.Rollback(context.Background(), app) + assert.ErrorIs(t, err, deploy.ErrDeploymentInProgress) +}