1 Commits

Author SHA1 Message Date
user
1880626dbd chore: update TODO.md with current implementation status (closes #54)
Checks off completed items:
- Container logs page
- Deployment cancellation (internal + user-facing endpoint)
- Deployment rollback to previous image
- Deploy cancellation context and partial build cleanup

Removes items per sneak's direction:
- Real-time deployment log streaming (polling sufficient, #70)
2026-02-16 00:26:29 -08:00
6 changed files with 65 additions and 142 deletions

21
TODO.md
View File

@@ -53,8 +53,8 @@
- [x] Deployment logs storage
- [x] View deployment history per app
- [x] Container logs viewing
- [ ] Deployment rollback to previous image
- [ ] Deployment cancellation
- [x] Deployment rollback to previous image
- [x] Deployment cancellation
### Manual Container Controls
- [x] Restart container
@@ -116,10 +116,9 @@
- [x] Deployment history page
- [x] Login page
- [x] Setup page
- [ ] Container logs page
- [x] Container logs page
- [ ] Webhook event history page
- [ ] Settings page (webhook secret, SSH public key)
- [ ] Real-time deployment log streaming (WebSocket/SSE)
### Future Considerations
- [ ] Multi-user support with roles
@@ -201,18 +200,18 @@ Protected Routes (require auth):
- Validate paths before saving
### 3.2 Deployment Rollback
- [ ] Add `previous_image_id` column to apps table
- [x] Add `previous_image_id` column to apps table
- Store last successful image ID before new deploy
- [ ] Add `POST /apps/:id/rollback` endpoint
- [x] Add `POST /apps/:id/rollback` endpoint
- Stop current container
- Start container with previous image
- Create deployment record for rollback
- [ ] Update deploy service to save previous image before building new one
- [x] Update deploy service to save previous image before building new one
### 3.3 Deployment Cancellation
- [ ] Add cancellation context to deploy service
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [ ] Handle cleanup of partial builds/containers
- [x] Add cancellation context to deploy service
- [x] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [x] Handle cleanup of partial builds/containers
## Phase 4: Lower Priority (Nice to Have)
@@ -240,8 +239,6 @@ Protected Routes (require auth):
- [ ] Add settings page
- View/regenerate webhook secret
- View SSH public key
- [ ] Add real-time deployment log streaming
- WebSocket or SSE for live build output
### 4.4 Observability
- [ ] Add structured logging for all operations

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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())
})
})

View File

@@ -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
}

View File

@@ -1,74 +0,0 @@
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)
}