Deployment logs were stored under a directory named after the container's hostname, and the path was worked out again from the current hostname on download. Docker gives a recreated container a new hostname, so every older log download returned 404. New logs now go to `logs/<appname>/` with no hostname in the path. Logs written by older versions under an old hostname directory are still found by looking one directory deeper, inside the same confined log root. Tests cover both the hostname-free path and downloading an old-layout log. Model: opus-5-5
1570 lines
42 KiB
Go
1570 lines
42 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"sneak.berlin/go/upaas/internal/database"
|
|
"sneak.berlin/go/upaas/internal/models"
|
|
"sneak.berlin/go/upaas/internal/service/app"
|
|
"sneak.berlin/go/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
|
|
)
|
|
|
|
// redirectToApp issues a SeeOther redirect to the page for the given
|
|
// app ID, with an optional suffix such as "/deployments" or
|
|
// "?success=updated". The ID is path-escaped so the target is always
|
|
// a relative application URL.
|
|
func redirectToApp(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
appID, suffix string,
|
|
) {
|
|
http.Redirect(
|
|
writer,
|
|
request,
|
|
"/apps/"+url.PathEscape(appID)+suffix,
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
//nolint:funlen // validation adds necessary length
|
|
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")
|
|
dockerNetwork := request.FormValue("docker_network")
|
|
ntfyTopic := request.FormValue("ntfy_topic")
|
|
slackWebhook := request.FormValue("slack_webhook")
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
"Name": name,
|
|
"RepoURL": repoURL,
|
|
"Branch": branch,
|
|
"DockerfilePath": dockerfilePath,
|
|
"DockerNetwork": dockerNetwork,
|
|
"NtfyTopic": ntfyTopic,
|
|
"SlackWebhook": slackWebhook,
|
|
}, 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()
|
|
h.renderTemplate(writer, tmpl, "app_new.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
repoURLErr := validateRepoURL(repoURL)
|
|
if repoURLErr != nil {
|
|
data["Error"] = "Invalid repository URL: " + repoURLErr.Error()
|
|
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,
|
|
DockerNetwork: dockerNetwork,
|
|
NtfyTopic: ntfyTopic,
|
|
SlackWebhook: slackWebhook,
|
|
},
|
|
)
|
|
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{
|
|
dataKeyApp: 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{
|
|
dataKeyApp: 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
|
|
}
|
|
|
|
newName := request.FormValue("name")
|
|
|
|
nameErr := validateAppName(newName)
|
|
if nameErr != nil {
|
|
data := h.addGlobals(map[string]any{
|
|
dataKeyApp: application,
|
|
dataKeyError: "Invalid app name: " + nameErr.Error(),
|
|
}, request)
|
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
|
|
if repoURLErr != nil {
|
|
data := h.addGlobals(map[string]any{
|
|
dataKeyApp: application,
|
|
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
|
|
}, request)
|
|
h.renderTemplate(writer, tmpl, "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")
|
|
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
|
|
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
|
|
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
|
|
|
|
limitsErr := applyResourceLimits(application, request)
|
|
if limitsErr != "" {
|
|
data := h.addGlobals(map[string]any{
|
|
dataKeyApp: application,
|
|
dataKeyError: limitsErr,
|
|
}, request)
|
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
saveErr := application.Save(request.Context())
|
|
if saveErr != nil {
|
|
h.log.Error("failed to update app", "error", saveErr)
|
|
|
|
data := h.addGlobals(map[string]any{
|
|
dataKeyApp: application,
|
|
dataKeyError: "Failed to update app",
|
|
}, request)
|
|
h.renderTemplate(writer, tmpl, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "?success=updated")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
redirectToApp(writer, request, application.ID, "/deployments")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "")
|
|
}
|
|
}
|
|
|
|
// HandleAppRollback handles rolling back to the previous deployment image.
|
|
func (h *Handlers) HandleAppRollback() 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
|
|
}
|
|
|
|
rollbackErr := h.deploy.Rollback(request.Context(), application)
|
|
if rollbackErr != nil {
|
|
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
|
|
redirectToApp(writer, request, application.ID, "")
|
|
|
|
return
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "?success=rolledback")
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
dataKeyApp: 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(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
|
|
}
|
|
}
|
|
|
|
// 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 = SanitizeLogs(deployment.Logs.String)
|
|
}
|
|
|
|
response := map[string]any{
|
|
jsonKeyLogs: logs,
|
|
jsonKeyStatus: 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
|
|
}
|
|
|
|
// The log path is derived from request data (the app is looked
|
|
// up by a URL parameter), so open it through an os.Root confined
|
|
// to the deploy log directory. Root.Open rejects any path that
|
|
// escapes the root, so a traversal attempt fails rather than
|
|
// serving an arbitrary file.
|
|
logDir := h.deploy.GetLogDir()
|
|
|
|
logPath := h.deploy.GetLogFilePath(application, deployment)
|
|
if logPath == "" {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
relPath, relErr := filepath.Rel(logDir, logPath)
|
|
if relErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
root, rootErr := os.OpenRoot(logDir)
|
|
if rootErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
|
|
file, openErr := openDeploymentLog(root, relPath)
|
|
if openErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
info, statErr := file.Stat()
|
|
if statErr != nil {
|
|
h.log.Error("failed to stat log file", "error", statErr, "path", logPath)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
filename := filepath.Base(logPath)
|
|
|
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
|
|
|
http.ServeContent(writer, request, filename, info.ModTime(), file)
|
|
}
|
|
}
|
|
|
|
// openDeploymentLog opens a deployment log file inside the log root.
|
|
// Logs written by older versions sit one directory deeper, under the
|
|
// hostname of the container that wrote them, so when the file is not at
|
|
// relPath it is looked for under any directory directly below the root.
|
|
func openDeploymentLog(root *os.Root, relPath string) (*os.File, error) {
|
|
file, err := root.Open(relPath)
|
|
if err == nil {
|
|
return file, nil
|
|
}
|
|
|
|
matches, globErr := fs.Glob(root.FS(), path.Join("*", filepath.ToSlash(relPath)))
|
|
if globErr != nil || len(matches) == 0 {
|
|
return nil, err
|
|
}
|
|
|
|
return root.Open(matches[0])
|
|
}
|
|
|
|
// 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{
|
|
jsonKeyLogs: "No container running\n",
|
|
jsonKeyStatus: "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{
|
|
jsonKeyLogs: "Failed to fetch container logs\n",
|
|
jsonKeyStatus: "error",
|
|
}
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
|
|
return
|
|
}
|
|
|
|
status := "stopped"
|
|
if containerInfo.Running {
|
|
status = "running"
|
|
}
|
|
|
|
response := map[string]any{
|
|
jsonKeyLogs: SanitizeLogs(logs),
|
|
jsonKeyStatus: 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{
|
|
jsonKeyStatus: 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,
|
|
jsonKeyStatus: 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 {
|
|
redirectToApp(writer, request, appID, "")
|
|
|
|
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)
|
|
}
|
|
|
|
redirectToApp(writer, request, appID, "")
|
|
}
|
|
|
|
// 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 == "" {
|
|
redirectToApp(writer, request, application.ID, "")
|
|
|
|
return
|
|
}
|
|
|
|
saveErr := createAndSave(request.Context(), application, key, value)
|
|
if saveErr != nil {
|
|
h.log.Error("failed to add key-value pair", "error", saveErr)
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "")
|
|
}
|
|
|
|
// envPairJSON represents a key-value pair in the JSON request body.
|
|
type envPairJSON struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// envVarMaxBodyBytes is the maximum allowed request body size for env var saves (1 MB).
|
|
const envVarMaxBodyBytes = 1 << 20
|
|
|
|
// validateEnvPairs validates env var pairs.
|
|
// It rejects empty keys and duplicate keys (returns a non-empty error string).
|
|
func validateEnvPairs(pairs []envPairJSON) ([]models.EnvVarPair, string) {
|
|
seen := make(map[string]bool, len(pairs))
|
|
|
|
result := make([]models.EnvVarPair, 0, len(pairs))
|
|
|
|
for _, p := range pairs {
|
|
trimmedKey := strings.TrimSpace(p.Key)
|
|
if trimmedKey == "" {
|
|
return nil, "empty environment variable key is not allowed"
|
|
}
|
|
|
|
if seen[trimmedKey] {
|
|
return nil, "duplicate environment variable key: " + trimmedKey
|
|
}
|
|
|
|
seen[trimmedKey] = true
|
|
|
|
result = append(result, models.EnvVarPair{Key: trimmedKey, Value: p.Value})
|
|
}
|
|
|
|
return result, ""
|
|
}
|
|
|
|
// HandleEnvVarSave handles bulk saving of all environment variables.
|
|
// It reads a JSON array of {key, value} objects from the request body,
|
|
// deletes all existing env vars for the app, and inserts the full
|
|
// submitted set atomically within a database transaction.
|
|
// Duplicate keys are rejected with a 400 Bad Request error.
|
|
func (h *Handlers) HandleEnvVarSave() 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
|
|
}
|
|
|
|
// Limit request body size to prevent abuse
|
|
request.Body = http.MaxBytesReader(writer, request.Body, envVarMaxBodyBytes)
|
|
|
|
var pairs []envPairJSON
|
|
|
|
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
|
|
if decodeErr != nil {
|
|
h.respondJSON(writer, request, map[string]string{
|
|
jsonKeyError: "invalid request body",
|
|
}, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
modelPairs, validationErr := validateEnvPairs(pairs)
|
|
if validationErr != "" {
|
|
h.respondJSON(writer, request, map[string]string{
|
|
jsonKeyError: validationErr,
|
|
}, http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
replaceErr := models.ReplaceEnvVarsByAppID(
|
|
request.Context(), h.db, application.ID, modelPairs,
|
|
)
|
|
if replaceErr != nil {
|
|
h.log.Error("failed to replace env vars", "error", replaceErr)
|
|
h.respondJSON(writer, request, map[string]string{
|
|
jsonKeyError: "failed to save environment variables",
|
|
}, http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
// deleteAppResource handles deletion of an app-owned resource (label,
|
|
// volume, or port) identified by an int64 URL parameter. The
|
|
// deleteByID closure reports whether the resource was found to belong
|
|
// to the app, and returns the deletion error if one occurred.
|
|
func (h *Handlers) deleteAppResource(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
idParam, logName string,
|
|
deleteByID deleteByIDFunc,
|
|
) {
|
|
appID := chi.URLParam(request, "id")
|
|
idStr := chi.URLParam(request, idParam)
|
|
|
|
id, parseErr := strconv.ParseInt(idStr, 10, 64)
|
|
if parseErr != nil {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
found, deleteErr := deleteByID(request.Context(), appID, id)
|
|
if !found {
|
|
http.NotFound(writer, request)
|
|
|
|
return
|
|
}
|
|
|
|
if deleteErr != nil {
|
|
h.log.Error("failed to delete "+logName, "error", deleteErr)
|
|
}
|
|
|
|
redirectToApp(writer, request, appID, "")
|
|
}
|
|
|
|
// deleteByIDFunc looks up an app-owned resource by ID and deletes it
|
|
// when it belongs to the given app. It reports whether the resource
|
|
// was found, and returns lookup or deletion errors.
|
|
type deleteByIDFunc func(ctx context.Context, appID string, id int64) (bool, error)
|
|
|
|
// makeDeleteByID builds a deleteByIDFunc from a model's find
|
|
// function, its app-ID accessor, and its delete method.
|
|
func makeDeleteByID[T any](
|
|
db *database.Database,
|
|
find func(context.Context, *database.Database, int64) (*T, error),
|
|
appIDOf func(*T) string,
|
|
del func(*T, context.Context) error,
|
|
) deleteByIDFunc {
|
|
return func(ctx context.Context, appID string, id int64) (bool, error) {
|
|
resource, findErr := find(ctx, db, id)
|
|
if findErr != nil {
|
|
return false, findErr
|
|
}
|
|
|
|
if resource == nil || appIDOf(resource) != appID {
|
|
return false, nil
|
|
}
|
|
|
|
return true, del(resource, ctx)
|
|
}
|
|
}
|
|
|
|
// HandleLabelDelete handles deleting a label.
|
|
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.deleteAppResource(
|
|
writer, request, "labelID", "label",
|
|
makeDeleteByID(h.db, models.FindLabel,
|
|
func(l *models.Label) string { return l.AppID },
|
|
(*models.Label).Delete,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 == "" {
|
|
redirectToApp(writer, request, application.ID, "")
|
|
|
|
return
|
|
}
|
|
|
|
pathErr := validateVolumePaths(hostPath, containerPath)
|
|
if pathErr != nil {
|
|
h.log.Error("invalid volume path", "error", pathErr)
|
|
redirectToApp(writer, request, application.ID, "")
|
|
|
|
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)
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "")
|
|
}
|
|
}
|
|
|
|
// HandleVolumeDelete handles deleting a volume mount.
|
|
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
|
return func(writer http.ResponseWriter, request *http.Request) {
|
|
h.deleteAppResource(
|
|
writer, request, "volumeID", "volume",
|
|
makeDeleteByID(h.db, models.FindVolume,
|
|
func(v *models.Volume) string { return v.AppID },
|
|
(*models.Volume).Delete,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
redirectToApp(writer, request, application.ID, "")
|
|
|
|
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)
|
|
}
|
|
|
|
redirectToApp(writer, request, application.ID, "")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
h.deleteAppResource(
|
|
writer, request, "portID", "port",
|
|
makeDeleteByID(h.db, models.FindPort,
|
|
func(p *models.Port) string { return p.AppID },
|
|
(*models.Port).Delete,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 == "" {
|
|
redirectToApp(writer, request, appID, "")
|
|
|
|
return
|
|
}
|
|
|
|
label.Key = key
|
|
label.Value = value
|
|
|
|
saveErr := label.Save(request.Context())
|
|
if saveErr != nil {
|
|
h.log.Error("failed to update label", "error", saveErr)
|
|
}
|
|
|
|
redirectToApp(writer, request, appID, "")
|
|
}
|
|
}
|
|
|
|
// 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 == "" {
|
|
redirectToApp(writer, request, appID, "")
|
|
|
|
return
|
|
}
|
|
|
|
pathErr := validateVolumePaths(hostPath, containerPath)
|
|
if pathErr != nil {
|
|
h.log.Error("invalid volume path", "error", pathErr)
|
|
redirectToApp(writer, request, appID, "")
|
|
|
|
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)
|
|
}
|
|
|
|
redirectToApp(writer, request, appID, "")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ErrInvalidMemoryFormat is returned when a memory limit string cannot be parsed.
|
|
var ErrInvalidMemoryFormat = errors.New(
|
|
"must be a number with optional unit suffix (e.g. 256m, 1g, 512000000)",
|
|
)
|
|
|
|
// ErrNegativeValue is returned when a resource limit is negative.
|
|
var ErrNegativeValue = errors.New("value must be positive")
|
|
|
|
// Memory unit byte multipliers.
|
|
const (
|
|
kilobyte = 1024
|
|
megabyte = 1024 * 1024
|
|
gigabyte = 1024 * 1024 * 1024
|
|
)
|
|
|
|
// optionalNullString converts a form value to a sql.NullString.
|
|
// Returns a valid NullString if non-empty, invalid (NULL) if empty.
|
|
func optionalNullString(s string) sql.NullString {
|
|
if s != "" {
|
|
return sql.NullString{String: s, Valid: true}
|
|
}
|
|
|
|
return sql.NullString{}
|
|
}
|
|
|
|
// applyResourceLimits parses CPU and memory limit form values and
|
|
// applies them to the app. Returns an error message string if
|
|
// validation fails, or empty string on success.
|
|
func applyResourceLimits(application *models.App, request *http.Request) string {
|
|
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
|
|
if cpuErr != nil {
|
|
return "Invalid CPU limit: must be a positive number (e.g. 0.5, 1, 2)"
|
|
}
|
|
|
|
application.CPULimit = cpuLimit
|
|
|
|
memoryLimit, memErr := parseOptionalMemoryBytes(request.FormValue("memory_limit"))
|
|
if memErr != nil {
|
|
return "Invalid memory limit: " + memErr.Error()
|
|
}
|
|
|
|
application.MemoryLimit = memoryLimit
|
|
|
|
return ""
|
|
}
|
|
|
|
// memoryUnitMultiplier returns the byte multiplier for a memory unit suffix.
|
|
// Returns 0 if the suffix is not recognized.
|
|
func memoryUnitMultiplier(suffix byte) int64 {
|
|
switch suffix {
|
|
case 'k':
|
|
return kilobyte
|
|
case 'm':
|
|
return megabyte
|
|
case 'g':
|
|
return gigabyte
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// parseOptionalFloat64 parses an optional float64 form field.
|
|
// Returns a valid NullFloat64 if the string is non-empty and parses
|
|
// to a positive number.
|
|
// Returns an empty NullFloat64 if the string is empty.
|
|
// Returns an error if the string is non-empty but invalid or non-positive.
|
|
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return sql.NullFloat64{}, nil
|
|
}
|
|
|
|
val, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return sql.NullFloat64{}, fmt.Errorf("invalid number: %w", err)
|
|
}
|
|
|
|
if val <= 0 {
|
|
return sql.NullFloat64{}, ErrNegativeValue
|
|
}
|
|
|
|
return sql.NullFloat64{Float64: val, Valid: true}, nil
|
|
}
|
|
|
|
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
|
|
// Accepts plain bytes (e.g. "536870912") or suffixed values
|
|
// (e.g. "512m", "1g", "256k").
|
|
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
|
|
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return sql.NullInt64{}, nil
|
|
}
|
|
|
|
s = strings.ToLower(s)
|
|
|
|
// Check for unit suffix
|
|
multiplier := memoryUnitMultiplier(s[len(s)-1])
|
|
if multiplier > 0 {
|
|
numStr := s[:len(s)-1]
|
|
|
|
val, err := strconv.ParseFloat(numStr, 64)
|
|
if err != nil {
|
|
return sql.NullInt64{}, ErrInvalidMemoryFormat
|
|
}
|
|
|
|
if val <= 0 {
|
|
return sql.NullInt64{}, ErrNegativeValue
|
|
}
|
|
|
|
return sql.NullInt64{Int64: int64(val * float64(multiplier)), Valid: true}, nil
|
|
}
|
|
|
|
// Plain bytes
|
|
val, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
return sql.NullInt64{}, ErrInvalidMemoryFormat
|
|
}
|
|
|
|
if val <= 0 {
|
|
return sql.NullInt64{}, ErrNegativeValue
|
|
}
|
|
|
|
return sql.NullInt64{Int64: val, Valid: true}, 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
|
|
}
|