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
1336 lines
35 KiB
Go
1336 lines
35 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.eeqj.de/sneak/upaas/internal/models"
|
|
"git.eeqj.de/sneak/upaas/internal/service/app"
|
|
"git.eeqj.de/sneak/upaas/templates"
|
|
)
|
|
|
|
const (
|
|
// recentDeploymentsLimit is the number of recent deployments to show.
|
|
recentDeploymentsLimit = 5
|
|
// deploymentsHistoryLimit is the number of deployments to show in history.
|
|
deploymentsHistoryLimit = 50
|
|
)
|
|
|
|
// HandleAppNew returns the new app form handler.
|
|
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
data := h.addGlobals(map[string]any{}, request)
|
|
|
|
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
|
}
|
|
}
|
|
|
|
// HandleAppCreate handles app creation.
|
|
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
parseErr := request.ParseForm()
|
|
if parseErr != nil {
|
|
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
name := request.FormValue("name")
|
|
repoURL := request.FormValue("repo_url")
|
|
branch := request.FormValue("branch")
|
|
dockerfilePath := request.FormValue("dockerfile_path")
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"Name": name,
|
|
"RepoURL": repoURL,
|
|
"Branch": branch,
|
|
"DockerfilePath": dockerfilePath,
|
|
}, request)
|
|
|
|
if name == "" || repoURL == "" {
|
|
data["Error"] = "Name and repository URL are required"
|
|
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
nameErr := validateAppName(name)
|
|
if nameErr != nil {
|
|
data["Error"] = "Invalid app name: " + nameErr.Error()
|
|
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
if branch == "" {
|
|
branch = "main"
|
|
}
|
|
|
|
if dockerfilePath == "" {
|
|
dockerfilePath = "Dockerfile"
|
|
}
|
|
|
|
createdApp, createErr := h.appService.CreateApp(
|
|
request.Context(),
|
|
app.CreateAppInput{
|
|
Name: name,
|
|
RepoURL: repoURL,
|
|
Branch: branch,
|
|
DockerfilePath: dockerfilePath,
|
|
},
|
|
)
|
|
if createErr != nil {
|
|
h.log.Error("failed to create app", "error", createErr)
|
|
data["Error"] = "Failed to create app: " + createErr.Error()
|
|
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandleAppDetail returns the app detail handler.
|
|
func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil {
|
|
h.log.Error("failed to find app", "error", findErr)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
envVars, _ := application.GetEnvVars(request.Context())
|
|
labels, _ := application.GetLabels(request.Context())
|
|
volumes, _ := application.GetVolumes(request.Context())
|
|
ports, _ := application.GetPorts(request.Context())
|
|
deployments, _ := application.GetDeployments(
|
|
request.Context(),
|
|
recentDeploymentsLimit,
|
|
)
|
|
|
|
// Get latest deployment for build logs pane
|
|
var latestDeployment *models.Deployment
|
|
if len(deployments) > 0 {
|
|
latestDeployment = deployments[0]
|
|
}
|
|
|
|
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
|
|
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"App": application,
|
|
"EnvVars": envVars,
|
|
"Labels": labels,
|
|
"Volumes": volumes,
|
|
"Ports": ports,
|
|
"Deployments": deployments,
|
|
"LatestDeployment": latestDeployment,
|
|
"WebhookURL": webhookURL,
|
|
"DeployKey": deployKey,
|
|
"Success": request.URL.Query().Get("success"),
|
|
}, request)
|
|
|
|
h.renderTemplate(writer, tmpl, "app_detail.html", data)
|
|
}
|
|
}
|
|
|
|
// HandleAppEdit returns the app edit form handler.
|
|
func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil {
|
|
h.log.Error("failed to find app", "error", findErr)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"App": application,
|
|
}, request)
|
|
|
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
}
|
|
}
|
|
|
|
// HandleAppUpdate handles app updates.
|
|
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
parseErr := request.ParseForm()
|
|
if parseErr != nil {
|
|
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
newName := request.FormValue("name")
|
|
|
|
nameErr := validateAppName(newName)
|
|
if nameErr != nil {
|
|
data := h.addGlobals(map[string]any{
|
|
"App": application,
|
|
"Error": "Invalid app name: " + nameErr.Error(),
|
|
}, request)
|
|
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
application.Name = newName
|
|
application.RepoURL = request.FormValue("repo_url")
|
|
application.Branch = request.FormValue("branch")
|
|
application.DockerfilePath = request.FormValue("dockerfile_path")
|
|
|
|
if network := request.FormValue("docker_network"); network != "" {
|
|
application.DockerNetwork = sql.NullString{String: network, Valid: true}
|
|
} else {
|
|
application.DockerNetwork = sql.NullString{}
|
|
}
|
|
|
|
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
|
|
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
|
|
} else {
|
|
application.NtfyTopic = sql.NullString{}
|
|
}
|
|
|
|
if slack := request.FormValue("slack_webhook"); slack != "" {
|
|
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
|
|
} else {
|
|
application.SlackWebhook = sql.NullString{}
|
|
}
|
|
|
|
saveErr := application.Save(request.Context())
|
|
if saveErr != nil {
|
|
h.log.Error("failed to update app", "error", saveErr)
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"App": application,
|
|
"Error": "Failed to update app",
|
|
}, request)
|
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
redirectURL := "/apps/" + application.ID + "?success=updated"
|
|
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// cleanupContainer stops and removes the Docker container for the given app.
|
|
func (h *Handlers) cleanupContainer(ctx context.Context, appID, appName string) {
|
|
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
|
|
if containerErr != nil || containerInfo == nil {
|
|
return
|
|
}
|
|
|
|
if containerInfo.Running {
|
|
stopErr := h.docker.StopContainer(ctx, containerInfo.ID)
|
|
if stopErr != nil {
|
|
h.log.Error("failed to stop container during app deletion",
|
|
"error", stopErr, "app", appName,
|
|
"container", containerInfo.ID)
|
|
}
|
|
}
|
|
|
|
removeErr := h.docker.RemoveContainer(ctx, containerInfo.ID, true)
|
|
if removeErr != nil {
|
|
h.log.Error("failed to remove container during app deletion",
|
|
"error", removeErr, "app", appName,
|
|
"container", containerInfo.ID)
|
|
} else {
|
|
h.log.Info("removed container during app deletion",
|
|
"app", appName, "container", containerInfo.ID)
|
|
}
|
|
}
|
|
|
|
// HandleAppDelete handles app deletion.
|
|
func (h *Handlers) HandleAppDelete() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
// Stop and remove the Docker container before deleting the DB record
|
|
h.cleanupContainer(request.Context(), appID, application.Name)
|
|
|
|
deleteErr := application.Delete(request.Context())
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete app", "error", deleteErr)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(writer, request, "/", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandleAppDeploy triggers a manual deployment.
|
|
func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
// Trigger deployment in background with a detached context
|
|
// so the deployment continues even if the HTTP request is cancelled
|
|
deployCtx := context.WithoutCancel(request.Context())
|
|
|
|
go func(ctx context.Context, appToDeploy *models.App) {
|
|
deployErr := h.deploy.Deploy(ctx, appToDeploy, nil, false)
|
|
if deployErr != nil {
|
|
h.log.Error(
|
|
"deployment failed",
|
|
"error", deployErr,
|
|
"app", appToDeploy.Name,
|
|
)
|
|
}
|
|
}(deployCtx, application)
|
|
|
|
http.Redirect(
|
|
writer,
|
|
request,
|
|
"/apps/"+application.ID+"/deployments",
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleCancelDeploy cancels an in-progress deployment for an app.
|
|
func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
cancelled := h.deploy.CancelDeploy(application.ID)
|
|
if cancelled {
|
|
h.log.Info("deployment cancelled by user", "app", application.Name)
|
|
}
|
|
|
|
http.Redirect(
|
|
writer,
|
|
request,
|
|
"/apps/"+application.ID,
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleAppDeployments returns the deployments history handler.
|
|
func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
|
tmpl := templates.GetParsed()
|
|
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deployments, _ := application.GetDeployments(
|
|
request.Context(),
|
|
deploymentsHistoryLimit,
|
|
)
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"App": application,
|
|
"Deployments": deployments,
|
|
}, request)
|
|
|
|
h.renderTemplate(writer, tmpl, "deployments.html", data)
|
|
}
|
|
}
|
|
|
|
// DefaultLogTail is the default number of log lines to fetch.
|
|
const DefaultLogTail = "500"
|
|
|
|
// maxLogTail is the maximum allowed value for the tail parameter.
|
|
const maxLogTail = 500
|
|
|
|
// SanitizeTail validates and clamps the tail query parameter.
|
|
// It returns a numeric string clamped to maxLogTail, or the default if invalid.
|
|
func SanitizeTail(raw string) string {
|
|
if raw == "" {
|
|
return DefaultLogTail
|
|
}
|
|
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 1 {
|
|
return DefaultLogTail
|
|
}
|
|
|
|
if n > maxLogTail {
|
|
n = maxLogTail
|
|
}
|
|
|
|
return strconv.Itoa(n)
|
|
}
|
|
|
|
// HandleAppLogs returns the container logs handler.
|
|
func (h *Handlers) HandleAppLogs() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
|
|
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
|
if containerErr != nil || containerInfo == nil {
|
|
_, _ = writer.Write([]byte("No container running\n"))
|
|
|
|
return
|
|
}
|
|
|
|
tail := SanitizeTail(request.URL.Query().Get("tail"))
|
|
|
|
logs, logsErr := h.docker.ContainerLogs(
|
|
request.Context(),
|
|
containerInfo.ID,
|
|
tail,
|
|
)
|
|
if logsErr != nil {
|
|
h.log.Error("failed to get container logs",
|
|
"error", logsErr,
|
|
"app", application.Name,
|
|
"container", containerInfo.ID,
|
|
)
|
|
|
|
_, _ = writer.Write([]byte("Failed to fetch container logs\n"))
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = writer.Write([]byte(logs))
|
|
}
|
|
}
|
|
|
|
// HandleDeploymentLogsAPI returns JSON with deployment logs.
|
|
func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
deploymentIDStr := chi.URLParam(request, "deploymentID")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
|
|
if parseErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
|
|
if deployErr != nil || deployment == nil || deployment.AppID != appID {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
logs := ""
|
|
if deployment.Logs.Valid {
|
|
logs = deployment.Logs.String
|
|
}
|
|
|
|
response := map[string]any{
|
|
"logs": logs,
|
|
"status": deployment.Status,
|
|
}
|
|
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
}
|
|
}
|
|
|
|
// HandleDeploymentLogDownload serves the log file for download.
|
|
func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
deploymentIDStr := chi.URLParam(request, "deploymentID")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
|
|
if parseErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
|
|
if deployErr != nil || deployment == nil || deployment.AppID != appID {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
// Get the log file path from deploy service
|
|
logPath := h.deploy.GetLogFilePath(application, deployment)
|
|
if logPath == "" {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
// Check if file exists
|
|
_, err := os.Stat(logPath)
|
|
if os.IsNotExist(err) {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
h.log.Error("failed to stat log file", "error", err, "path", logPath)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
// Extract filename for Content-Disposition header
|
|
filename := filepath.Base(logPath)
|
|
|
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
|
|
|
http.ServeFile(writer, request, logPath)
|
|
}
|
|
}
|
|
|
|
// containerLogsAPITail is the default number of log lines for the container logs API.
|
|
const containerLogsAPITail = "100"
|
|
|
|
// HandleContainerLogsAPI returns JSON with container logs.
|
|
func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
|
if containerErr != nil || containerInfo == nil {
|
|
response := map[string]any{
|
|
"logs": "No container running\n",
|
|
"status": "stopped",
|
|
}
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
|
|
return
|
|
}
|
|
|
|
logs, logsErr := h.docker.ContainerLogs(
|
|
request.Context(),
|
|
containerInfo.ID,
|
|
containerLogsAPITail,
|
|
)
|
|
if logsErr != nil {
|
|
h.log.Error("failed to get container logs",
|
|
"error", logsErr,
|
|
"app", application.Name,
|
|
"container", containerInfo.ID,
|
|
)
|
|
|
|
response := map[string]any{
|
|
"logs": "Failed to fetch container logs\n",
|
|
"status": "error",
|
|
}
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
|
|
return
|
|
}
|
|
|
|
status := "stopped"
|
|
if containerInfo.Running {
|
|
status = "running"
|
|
}
|
|
|
|
response := map[string]any{
|
|
"logs": logs,
|
|
"status": status,
|
|
}
|
|
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
}
|
|
}
|
|
|
|
// HandleAppStatusAPI returns JSON with app status and latest deployment info.
|
|
func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
// Get latest deployment
|
|
deployments, _ := application.GetDeployments(request.Context(), 1)
|
|
|
|
var latestDeploymentID int64
|
|
|
|
var latestDeploymentStatus string
|
|
|
|
if len(deployments) > 0 {
|
|
latestDeploymentID = deployments[0].ID
|
|
latestDeploymentStatus = string(deployments[0].Status)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"status": string(application.Status),
|
|
"latestDeploymentID": latestDeploymentID,
|
|
"latestDeploymentStatus": latestDeploymentStatus,
|
|
}
|
|
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
}
|
|
}
|
|
|
|
// HandleRecentDeploymentsAPI returns JSON with recent deployments.
|
|
func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deployments, deployErr := application.GetDeployments(
|
|
request.Context(),
|
|
recentDeploymentsLimit,
|
|
)
|
|
if deployErr != nil {
|
|
h.log.Error("failed to get deployments", "error", deployErr, "app", appID)
|
|
|
|
deployments = []*models.Deployment{}
|
|
}
|
|
|
|
// Build response with formatted data
|
|
deploymentsData := make([]map[string]any, 0, len(deployments))
|
|
|
|
for _, d := range deployments {
|
|
deploymentsData = append(deploymentsData, map[string]any{
|
|
"id": d.ID,
|
|
"status": string(d.Status),
|
|
"duration": d.Duration(),
|
|
"shortCommit": d.ShortCommit(),
|
|
"finishedAtISO": d.FinishedAtISO(),
|
|
"finishedAtLabel": d.FinishedAtFormatted(),
|
|
})
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(writer).Encode(map[string]any{
|
|
"deployments": deploymentsData,
|
|
})
|
|
}
|
|
}
|
|
|
|
// containerAction represents a container operation type.
|
|
type containerAction string
|
|
|
|
const (
|
|
actionRestart containerAction = "restart"
|
|
actionStop containerAction = "stop"
|
|
actionStart containerAction = "start"
|
|
)
|
|
|
|
// handleContainerAction is a helper for container control operations.
|
|
func (h *Handlers) handleContainerAction(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
action containerAction,
|
|
) {
|
|
appID := chi.URLParam(request, "id")
|
|
ctx := request.Context()
|
|
|
|
application, findErr := models.FindApp(ctx, h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
|
|
if containerErr != nil || containerInfo == nil {
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
|
|
return
|
|
}
|
|
|
|
containerID := containerInfo.ID
|
|
|
|
var actionErr error
|
|
|
|
switch action {
|
|
case actionRestart:
|
|
stopErr := h.docker.StopContainer(ctx, containerID)
|
|
if stopErr != nil {
|
|
h.log.Error("failed to stop container for restart",
|
|
"error", stopErr, "app", application.Name, "container", containerID)
|
|
}
|
|
|
|
actionErr = h.docker.StartContainer(ctx, containerID)
|
|
case actionStop:
|
|
actionErr = h.docker.StopContainer(ctx, containerID)
|
|
case actionStart:
|
|
actionErr = h.docker.StartContainer(ctx, containerID)
|
|
}
|
|
|
|
if actionErr != nil {
|
|
h.log.Error("container action failed",
|
|
"action", action, "error", actionErr,
|
|
"app", application.Name, "container", containerID)
|
|
} else {
|
|
h.log.Info("container action completed",
|
|
"action", action, "app", application.Name, "container", containerID)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleAppRestart handles restarting an app's container.
|
|
func (h *Handlers) HandleAppRestart() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.handleContainerAction(writer, request, actionRestart)
|
|
}
|
|
}
|
|
|
|
// HandleAppStop handles stopping an app's container.
|
|
func (h *Handlers) HandleAppStop() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.handleContainerAction(writer, request, actionStop)
|
|
}
|
|
}
|
|
|
|
// HandleAppStart handles starting an app's container.
|
|
func (h *Handlers) HandleAppStart() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.handleContainerAction(writer, request, actionStart)
|
|
}
|
|
}
|
|
|
|
// addKeyValueToApp is a helper for adding key-value pairs (env vars or labels).
|
|
func (h *Handlers) addKeyValueToApp(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
createAndSave func(
|
|
ctx context.Context,
|
|
application *models.App,
|
|
key, value string,
|
|
) error,
|
|
) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
parseErr := request.ParseForm()
|
|
if parseErr != 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/"+application.ID, http.StatusSeeOther)
|
|
|
|
return
|
|
}
|
|
|
|
saveErr := createAndSave(request.Context(), application, key, value)
|
|
if saveErr != nil {
|
|
h.log.Error("failed to add key-value pair", "error", saveErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleEnvVarAdd handles adding an environment variable.
|
|
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.addKeyValueToApp(
|
|
writer,
|
|
request,
|
|
func(ctx context.Context, application *models.App, key, value string) error {
|
|
envVar := models.NewEnvVar(h.db)
|
|
envVar.AppID = application.ID
|
|
envVar.Key = key
|
|
envVar.Value = value
|
|
|
|
return envVar.Save(ctx)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleEnvVarDelete handles deleting an environment variable.
|
|
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
envVarIDStr := chi.URLParam(request, "envID")
|
|
|
|
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
|
|
}
|
|
|
|
deleteErr := envVar.Delete(request.Context())
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete env var", "error", deleteErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandleLabelAdd handles adding a label.
|
|
func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.addKeyValueToApp(
|
|
writer,
|
|
request,
|
|
func(ctx context.Context, application *models.App, key, value string) error {
|
|
label := models.NewLabel(h.db)
|
|
label.AppID = application.ID
|
|
label.Key = key
|
|
label.Value = value
|
|
|
|
return label.Save(ctx)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// HandleLabelDelete handles deleting a label.
|
|
func (h *Handlers) HandleLabelDelete() 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
|
|
}
|
|
|
|
deleteErr := label.Delete(request.Context())
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete label", "error", deleteErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandleVolumeAdd handles adding a volume mount.
|
|
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
parseErr := request.ParseForm()
|
|
if parseErr != 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/"+application.ID,
|
|
http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
volume := models.NewVolume(h.db)
|
|
volume.AppID = application.ID
|
|
volume.HostPath = hostPath
|
|
volume.ContainerPath = containerPath
|
|
volume.ReadOnly = readOnly
|
|
|
|
saveErr := volume.Save(request.Context())
|
|
if saveErr != nil {
|
|
h.log.Error("failed to add volume", "error", saveErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandleVolumeDelete handles deleting a volume mount.
|
|
func (h *Handlers) HandleVolumeDelete() 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
|
|
}
|
|
|
|
deleteErr := volume.Delete(request.Context())
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete volume", "error", deleteErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// HandlePortAdd handles adding a port mapping.
|
|
func (h *Handlers) HandlePortAdd() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
|
|
application, findErr := models.FindApp(request.Context(), h.db, appID)
|
|
if findErr != nil || application == nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
parseErr := request.ParseForm()
|
|
if parseErr != nil {
|
|
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
hostPort, containerPort, valid := parsePortValues(
|
|
request.FormValue("host_port"),
|
|
request.FormValue("container_port"),
|
|
)
|
|
if !valid {
|
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
|
|
|
return
|
|
}
|
|
|
|
protocol := request.FormValue("protocol")
|
|
if protocol != "tcp" && protocol != "udp" {
|
|
protocol = "tcp"
|
|
}
|
|
|
|
port := models.NewPort(h.db)
|
|
port.AppID = application.ID
|
|
port.HostPort = hostPort
|
|
port.ContainerPort = containerPort
|
|
port.Protocol = models.PortProtocol(protocol)
|
|
|
|
saveErr := port.Save(request.Context())
|
|
if saveErr != nil {
|
|
h.log.Error("failed to save port", "error", saveErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// parsePortValues parses and validates host and container port strings.
|
|
func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
|
|
hostPort, hostErr := strconv.Atoi(hostPortStr)
|
|
containerPort, containerErr := strconv.Atoi(containerPortStr)
|
|
|
|
const maxPort = 65535
|
|
|
|
invalid := hostErr != nil || containerErr != nil ||
|
|
hostPort <= 0 || containerPort <= 0 ||
|
|
hostPort > maxPort || containerPort > maxPort
|
|
if invalid {
|
|
return 0, 0, false
|
|
}
|
|
|
|
return hostPort, containerPort, true
|
|
}
|
|
|
|
// HandlePortDelete handles deleting a port mapping.
|
|
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
appID := chi.URLParam(request, "id")
|
|
portIDStr := chi.URLParam(request, "portID")
|
|
|
|
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
|
|
if parseErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
port, findErr := models.FindPort(request.Context(), h.db, portID)
|
|
if findErr != nil || port == nil || port.AppID != appID {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
deleteErr := port.Delete(request.Context())
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete port", "error", deleteErr)
|
|
}
|
|
|
|
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
const minKeyParts = 2
|
|
|
|
parts := strings.Fields(pubKey)
|
|
if len(parts) < minKeyParts {
|
|
return pubKey
|
|
}
|
|
|
|
comment := "upaas_" + createdAt.Format("2006-01-02") + "_" + appName
|
|
|
|
return parts[0] + " " + parts[1] + " " + comment
|
|
}
|