Add renderTemplate helper method on Handlers that renders templates to a bytes.Buffer first, then writes to the ResponseWriter only on success. This prevents partial/corrupt HTML when template execution fails partway through. Applied to all template rendering call sites in: - setup.go (HandleSetupGET, renderSetupError) - auth.go (HandleLoginGET, HandleLoginPOST error paths) - dashboard.go (HandleDashboard) - app.go (HandleAppNew, HandleAppCreate, HandleAppDetail, HandleAppEdit, HandleAppUpdate, HandleAppDeployments)
1086 lines
28 KiB
Go
1086 lines
28 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 {
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
application.Name = request.FormValue("name")
|
|
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)
|
|
|
|
http.Redirect(
|
|
writer,
|
|
request,
|
|
"/apps/"+application.ID+"/deployments",
|
|
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
|
|
}
|