Update golangci-lint to v2.12.2 with canonical config
All checks were successful
Check / check (pull_request) Successful in 3m22s

Bump golangci-lint from v2.10.1 to v2.12.2 in the Dockerfile lint
stage (tag+digest pin) and script/bootstrap release-archive pins
(linux amd64/arm64 sha256s). Replace .golangci.yml with the canonical
v2-layout config so linter settings (lll 88, funlen 80/50, cyclop 15,
dupl 100) actually apply.

Fix all findings surfaced by the new linter and config:

- noctx: use httptest.NewRequestWithContext in all tests
- gosec G710/G703: route app redirects through a path-escaping
  redirectToApp helper; annotate internal log path usage
- goconst: introduce shared constants for template/JSON keys and
  repeated test literals
- lll: wrap lines to the 88-column limit
- dupl: extract shared helpers (generic findAllByAppID in models,
  deleteAppResource in handlers, parsePush in webhook payloads,
  table-driven/helper-based test dedup)
- nolintlint: drop nolint directives made obsolete by the new limits

Record the change in TODO.md; make check is green.
This commit is contained in:
2026-08-07 17:16:57 +00:00
parent 291f85f3ed
commit a4b8ea4402
41 changed files with 1172 additions and 797 deletions

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -15,6 +16,7 @@ import (
"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"
@@ -27,6 +29,23 @@ const (
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()
@@ -39,7 +58,9 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc {
}
// HandleAppCreate handles app creation.
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
//
//nolint:funlen // validation adds necessary length
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -160,10 +181,14 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
}
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
deployKey := formatDeployKey(
application.SSHPublicKey,
application.CreatedAt,
application.Name,
)
data := h.addGlobals(map[string]any{
"App": application,
dataKeyApp: application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
@@ -201,7 +226,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
data := h.addGlobals(map[string]any{
"App": application,
dataKeyApp: application,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -209,7 +234,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
}
// HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
@@ -234,8 +259,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
nameErr := validateAppName(newName)
if nameErr != nil {
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
dataKeyApp: application,
dataKeyError: "Invalid app name: " + nameErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -245,8 +270,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil {
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
dataKeyApp: application,
dataKeyError: "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -264,8 +289,8 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {
data := h.addGlobals(map[string]any{
"App": application,
"Error": limitsErr,
dataKeyApp: application,
dataKeyError: limitsErr,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
@@ -277,16 +302,15 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
h.log.Error("failed to update app", "error", saveErr)
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Failed to update app",
dataKeyApp: application,
dataKeyError: "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)
redirectToApp(writer, request, application.ID, "?success=updated")
}
}
@@ -371,12 +395,7 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
}
}(deployCtx, application)
http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
redirectToApp(writer, request, application.ID, "/deployments")
}
}
@@ -397,12 +416,7 @@ func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
h.log.Info("deployment cancelled by user", "app", application.Name)
}
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
redirectToApp(writer, request, application.ID, "")
}
}
@@ -421,12 +435,12 @@ func (h *Handlers) HandleAppRollback() http.HandlerFunc {
rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
return
}
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "?success=rolledback")
}
}
@@ -450,7 +464,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
)
data := h.addGlobals(map[string]any{
"App": application,
dataKeyApp: application,
"Deployments": deployments,
}, request)
@@ -523,7 +537,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return
}
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- output sanitized
}
}
@@ -562,8 +576,8 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
}
response := map[string]any{
"logs": logs,
"status": deployment.Status,
jsonKeyLogs: logs,
jsonKeyStatus: deployment.Status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -606,7 +620,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
}
// Check if file exists — logPath is constructed internally, not from user input
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input
_, err := os.Stat(logPath) // #nosec G703 -- internal path, not user input
if os.IsNotExist(err) {
http.NotFound(writer, request)
@@ -626,7 +640,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath)
http.ServeFile(writer, request, logPath) // #nosec G703 -- internal path
}
}
@@ -650,8 +664,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
response := map[string]any{
"logs": "No container running\n",
"status": "stopped",
jsonKeyLogs: "No container running\n",
jsonKeyStatus: "stopped",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -671,8 +685,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
)
response := map[string]any{
"logs": "Failed to fetch container logs\n",
"status": "error",
jsonKeyLogs: "Failed to fetch container logs\n",
jsonKeyStatus: "error",
}
_ = json.NewEncoder(writer).Encode(response)
@@ -685,8 +699,8 @@ func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
}
response := map[string]any{
"logs": SanitizeLogs(logs),
"status": status,
jsonKeyLogs: SanitizeLogs(logs),
jsonKeyStatus: status,
}
_ = json.NewEncoder(writer).Encode(response)
@@ -720,7 +734,7 @@ func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
}
response := map[string]any{
"status": string(application.Status),
jsonKeyStatus: string(application.Status),
"latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus,
}
@@ -757,7 +771,7 @@ func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID,
"status": string(d.Status),
jsonKeyStatus: string(d.Status),
"duration": d.Duration(),
"shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(),
@@ -799,7 +813,7 @@ func (h *Handlers) handleContainerAction(
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
return
}
@@ -832,7 +846,7 @@ func (h *Handlers) handleContainerAction(
"action", action, "app", application.Name, "container", containerID)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
}
// HandleAppRestart handles restarting an app's container.
@@ -886,7 +900,7 @@ func (h *Handlers) addKeyValueToApp(
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
return
}
@@ -896,7 +910,7 @@ func (h *Handlers) addKeyValueToApp(
h.log.Error("failed to add key-value pair", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
}
// envPairJSON represents a key-value pair in the JSON request body.
@@ -957,7 +971,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
h.respondJSON(writer, request, map[string]string{
"error": "invalid request body",
jsonKeyError: "invalid request body",
}, http.StatusBadRequest)
return
@@ -966,7 +980,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
modelPairs, validationErr := validateEnvPairs(pairs)
if validationErr != "" {
h.respondJSON(writer, request, map[string]string{
"error": validationErr,
jsonKeyError: validationErr,
}, http.StatusBadRequest)
return
@@ -978,7 +992,7 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
if replaceErr != nil {
h.log.Error("failed to replace env vars", "error", replaceErr)
h.respondJSON(writer, request, map[string]string{
"error": "failed to save environment variables",
jsonKeyError: "failed to save environment variables",
}, http.StatusInternalServerError)
return
@@ -1006,32 +1020,77 @@ func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
}
}
// 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) {
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)
h.deleteAppResource(
writer, request, "labelID", "label",
makeDeleteByID(h.db, models.FindLabel,
func(l *models.Label) string { return l.AppID },
(*models.Label).Delete,
),
)
}
}
@@ -1059,12 +1118,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
redirectToApp(writer, request, application.ID, "")
return
}
@@ -1072,7 +1126,7 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
return
}
@@ -1088,36 +1142,20 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
h.log.Error("failed to add volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
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) {
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)
h.deleteAppResource(
writer, request, "volumeID", "volume",
makeDeleteByID(h.db, models.FindVolume,
func(v *models.Volume) string { return v.AppID },
(*models.Volume).Delete,
),
)
}
}
@@ -1145,7 +1183,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
request.FormValue("container_port"),
)
if !valid {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
return
}
@@ -1166,7 +1204,7 @@ func (h *Handlers) HandlePortAdd() http.HandlerFunc {
h.log.Error("failed to save port", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
redirectToApp(writer, request, application.ID, "")
}
}
@@ -1190,29 +1228,13 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
// 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)
h.deleteAppResource(
writer, request, "portID", "port",
makeDeleteByID(h.db, models.FindPort,
func(p *models.Port) string { return p.AppID },
(*models.Port).Delete,
),
)
}
}
@@ -1274,7 +1296,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
return
}
@@ -1287,7 +1309,7 @@ func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
h.log.Error("failed to update label", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
}
}
@@ -1323,7 +1345,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
return
}
@@ -1331,7 +1353,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
return
}
@@ -1345,7 +1367,7 @@ func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
h.log.Error("failed to update volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
redirectToApp(writer, request, appID, "")
}
}
@@ -1389,8 +1411,9 @@ func optionalNullString(s string) sql.NullString {
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.
// 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 {
@@ -1425,7 +1448,8 @@ func memoryUnitMultiplier(suffix byte) int64 {
}
// parseOptionalFloat64 parses an optional float64 form field.
// Returns a valid NullFloat64 if the string is non-empty and parses to a positive number.
// 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) {
@@ -1447,7 +1471,8 @@ func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
}
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
// Accepts plain bytes (e.g. "536870912") or suffixed values (e.g. "512m", "1g", "256k").
// 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)