feat: edit existing env vars, labels, and volume mounts
Add inline edit functionality for environment variables, labels, and
volume mounts on the app detail page. Each entity row now has an Edit
button that reveals an inline form using Alpine.js.
- POST /apps/{id}/env-vars/{varID}/edit
- POST /apps/{id}/labels/{labelID}/edit
- POST /apps/{id}/volumes/{volumeID}/edit
- Path validation for volume host and container paths
- Warning banner about container restart after env var changes
- Tests for ValidateVolumePath
fixes #67
This commit is contained in:
parent
e31666ab5c
commit
e9d284698a
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -1116,6 +1118,207 @@ 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.
|
// formatDeployKey formats an SSH public key with a descriptive comment.
|
||||||
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
|
||||||
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
|
||||||
|
|||||||
34
internal/handlers/volume_validation_test.go
Normal file
34
internal/handlers/volume_validation_test.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -82,14 +82,17 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
// Environment variables
|
// Environment variables
|
||||||
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
|
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())
|
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
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())
|
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
|
||||||
|
|
||||||
// Volumes
|
// Volumes
|
||||||
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
|
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())
|
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
|
||||||
|
|
||||||
// Ports
|
// Ports
|
||||||
|
|||||||
@ -106,15 +106,34 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .EnvVars}}
|
{{range .EnvVars}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="text-right">
|
<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)">
|
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
||||||
{{ .CSRFField }}
|
<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>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -151,15 +170,33 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{range .Labels}}
|
{{range .Labels}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono font-medium">{{.Key}}</td>
|
<td class="font-mono font-medium">{{.Key}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="text-right">
|
<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)">
|
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
|
||||||
{{ .CSRFField }}
|
{{ $.CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -189,9 +226,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-body">
|
<tbody class="table-body">
|
||||||
{{range .Volumes}}
|
{{range .Volumes}}
|
||||||
<tr>
|
<tr x-data="{ editing: false }">
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono">{{.HostPath}}</td>
|
<td class="font-mono">{{.HostPath}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="font-mono">{{.ContainerPath}}</td>
|
<td class="font-mono">{{.ContainerPath}}</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td>
|
<td>
|
||||||
{{if .ReadOnly}}
|
{{if .ReadOnly}}
|
||||||
<span class="badge-neutral">Read-only</span>
|
<span class="badge-neutral">Read-only</span>
|
||||||
@ -199,12 +241,31 @@
|
|||||||
<span class="badge-info">Read-write</span>
|
<span class="badge-info">Read-write</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
|
</template>
|
||||||
|
<template x-if="!editing">
|
||||||
<td class="text-right">
|
<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)">
|
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
|
||||||
{{ .CSRFField }}
|
{{ $.CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user