- Replace UUID with ULID for app ID generation (lexicographically sortable) - Remove container_id column from apps table (migration 002) - Add upaas.id Docker label to identify containers by app ID - Implement FindContainerByAppID in Docker client to query by label - Update handlers and deploy service to use label-based container lookup - Show system-managed upaas.id label in UI with editing disabled Container association is now determined dynamically via Docker label rather than stored in the database, making the system more resilient to container recreation or external changes.
682 lines
18 KiB
Go
682 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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, _ *http.Request) {
|
|
data := map[string]any{}
|
|
|
|
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
|
if err != nil {
|
|
h.log.Error("template execution failed", "error", err)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := map[string]any{
|
|
"Name": name,
|
|
"RepoURL": repoURL,
|
|
"Branch": branch,
|
|
"DockerfilePath": dockerfilePath,
|
|
}
|
|
|
|
if name == "" || repoURL == "" {
|
|
data["Error"] = "Name and repository URL are required"
|
|
_ = 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()
|
|
_ = tmpl.ExecuteTemplate(writer, "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())
|
|
deployments, _ := application.GetDeployments(
|
|
request.Context(),
|
|
recentDeploymentsLimit,
|
|
)
|
|
|
|
webhookURL := "/webhook/" + application.WebhookSecret
|
|
|
|
data := map[string]any{
|
|
"App": application,
|
|
"EnvVars": envVars,
|
|
"Labels": labels,
|
|
"Volumes": volumes,
|
|
"Deployments": deployments,
|
|
"WebhookURL": webhookURL,
|
|
"Success": request.URL.Query().Get("success"),
|
|
}
|
|
|
|
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
|
if err != nil {
|
|
h.log.Error("template execution failed", "error", err)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := map[string]any{
|
|
"App": application,
|
|
}
|
|
|
|
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
|
if err != nil {
|
|
h.log.Error("template execution failed", "error", err)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := map[string]any{
|
|
"App": application,
|
|
"Error": "Failed to update app",
|
|
}
|
|
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
|
|
|
return
|
|
}
|
|
|
|
redirectURL := "/apps/" + application.ID + "?success=updated"
|
|
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 := map[string]any{
|
|
"App": application,
|
|
"Deployments": deployments,
|
|
}
|
|
|
|
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
|
if err != nil {
|
|
h.log.Error("template execution failed", "error", err)
|
|
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// defaultLogTail is the default number of log lines to fetch.
|
|
const defaultLogTail = "500"
|
|
|
|
// 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 := request.URL.Query().Get("tail")
|
|
if tail == "" {
|
|
tail = defaultLogTail
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|