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
5 changed files with 39 additions and 343 deletions

13
TODO.md
View File

@@ -53,7 +53,7 @@
- [x] Deployment logs storage
- [x] View deployment history per app
- [x] Container logs viewing
- [ ] Deployment rollback to previous image
- [x] Deployment rollback to previous image
- [x] Deployment cancellation
### Manual Container Controls
@@ -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,13 +200,13 @@ 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
- [x] Add cancellation context to deploy service
@@ -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

@@ -4,8 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -1142,207 +1140,6 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
}
}
// ErrVolumePathEmpty is returned when a volume path is empty.
var ErrVolumePathEmpty = errors.New("path must not be empty")
// ErrVolumePathNotAbsolute is returned when a volume path is not absolute.
var ErrVolumePathNotAbsolute = errors.New("path must be absolute")
// ErrVolumePathNotClean is returned when a volume path is not clean.
var ErrVolumePathNotClean = errors.New("path must be clean")
// ValidateVolumePath checks that a path is absolute and clean.
func ValidateVolumePath(p string) error {
if p == "" {
return ErrVolumePathEmpty
}
if !filepath.IsAbs(p) {
return ErrVolumePathNotAbsolute
}
cleaned := filepath.Clean(p)
if cleaned != p {
return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned)
}
return nil
}
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
label.Key = key
label.Value = value
saveErr := label.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeEdit handles editing an existing volume mount.
func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPath := request.FormValue("host_path")
containerPath := request.FormValue("container_path")
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// validateVolumePaths validates both host and container paths for a volume.
func validateVolumePaths(hostPath, containerPath string) error {
hostErr := ValidateVolumePath(hostPath)
if hostErr != nil {
return fmt.Errorf("host path: %w", hostErr)
}
containerErr := ValidateVolumePath(containerPath)
if containerErr != nil {
return fmt.Errorf("container path: %w", containerErr)
}
return nil
}
// formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -1,34 +0,0 @@
package handlers //nolint:testpackage // tests exported ValidateVolumePath function
import "testing"
func TestValidateVolumePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
}{
{"valid absolute path", "/data/myapp", false},
{"root path", "/", false},
{"empty path", "", true},
{"relative path", "data/myapp", true},
{"path with dotdot", "/data/../etc", true},
{"path with trailing slash", "/data/", true},
{"path with double slash", "/data//myapp", true},
{"single dot path", ".", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateVolumePath(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v",
tt.path, err, tt.wantErr)
}
})
}
}

View File

@@ -83,17 +83,14 @@ func (s *Server) SetupRoutes() {
// Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
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}/edit", s.handlers.HandleLabelEdit())
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}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports

View File

@@ -112,34 +112,15 @@
</thead>
<tbody class="table-body">
{{range .EnvVars}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
<tr>
<td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
@@ -176,33 +157,15 @@
</td>
</tr>
{{range .Labels}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono font-medium">{{.Key}}</td>
</template>
<template x-if="!editing">
<td class="font-mono text-gray-500">{{.Value}}</td>
</template>
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
<tr>
<td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
@@ -232,46 +195,22 @@
</thead>
<tbody class="table-body">
{{range .Volumes}}
<tr x-data="{ editing: false }">
<template x-if="!editing">
<td class="font-mono">{{.HostPath}}</td>
</template>
<template x-if="!editing">
<td class="font-mono">{{.ContainerPath}}</td>
</template>
<template x-if="!editing">
<td>
{{if .ReadOnly}}
<span class="badge-neutral">Read-only</span>
{{else}}
<span class="badge-info">Read-write</span>
{{end}}
</td>
</template>
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</template>
<template x-if="editing">
<td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
RO
</label>
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
<tr>
<td class="font-mono">{{.HostPath}}</td>
<td class="font-mono">{{.ContainerPath}}</td>
<td>
{{if .ReadOnly}}
<span class="badge-neutral">Read-only</span>
{{else}}
<span class="badge-info">Read-write</span>
{{end}}
</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>