upaas/internal/handlers/app.go
user 16640ef88e fix: cancel in-progress deploy on new webhook trigger
When a webhook-triggered deploy starts for an app that already has a deploy
in progress, the new deploy now cancels the existing one via context
cancellation, waits for the lock to be released, and then starts the new
deploy.

Changes:
- Add per-app context cancellation (appCancels sync.Map) to deploy.Service
- Deploy() creates a cancellable context and registers it for the app
- Add CancelAppDeploy() method to cancel an in-progress deploy
- Add ErrDeployCancelled sentinel error for cancelled deploys
- Handle context cancellation in build and deploy phases, marking
  deployments as failed with a clear cancellation message
- Webhook triggerDeployment() now cancels in-progress deploys and retries
  until the lock is released (up to 30 attempts with 2s delay)

fixes #38
2026-02-15 22:13:20 -08:00

1109 lines
29 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/json"
"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)
if deployErr != nil {
h.log.Error(
"deployment failed",
"error", deployErr,
"app", appToDeploy.Name,
)
}
}(deployCtx, application)
redirectURL := "/apps/" + application.ID + "/deployments"
http.Redirect(
writer,
request,
redirectURL,
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)
}
}
// 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
}