Keep deployment logs findable after the container is recreated (closes #214)
Check / check (pull_request) Skipped

Deployment log files were stored under a directory named after the
container's hostname, which Docker changes whenever the container is
recreated, so every older log download returned 404. Logs now live under
logs/<appname>/. The download handler still finds logs written by older
versions under any hostname directory.

Model: opus-5-5
This commit is contained in:
2026-09-23 09:08:50 +00:00
parent 19619b1cd2
commit 23649a7c60
4 changed files with 60 additions and 8 deletions
+21 -1
View File
@@ -6,9 +6,11 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@@ -640,7 +642,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
}
defer func() { _ = root.Close() }()
file, openErr := root.Open(relPath)
file, openErr := openDeploymentLog(root, relPath)
if openErr != nil {
http.NotFound(writer, request)
@@ -665,6 +667,24 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
}
}
// 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"
+29
View File
@@ -69,6 +69,35 @@ func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "deploy log contents")
}
// TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir verifies a
// log written by an older version, under the hostname of a container that
// has since been recreated, can still be downloaded.
func TestHandleDeploymentLogDownloadServesLogFromOldHostnameDir(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "log-old-hostname-app")
deployment := models.NewDeployment(testCtx.database)
deployment.AppID = createdApp.ID
deployment.Status = models.DeploymentStatusSuccess
require.NoError(t, deployment.Save(context.Background()))
logDir := testCtx.deploySvc.GetLogDir()
newPath := testCtx.deploySvc.GetLogFilePath(createdApp, deployment)
relPath, relErr := filepath.Rel(logDir, newPath)
require.NoError(t, relErr)
oldPath := filepath.Join(logDir, "old-container-hostname", relPath)
require.NoError(t, os.MkdirAll(filepath.Dir(oldPath), 0o750))
require.NoError(t, os.WriteFile(oldPath, []byte("old deploy log contents"), 0o600))
recorder := doLogDownload(t, testCtx, createdApp.ID, deployment.ID)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "old deploy log contents")
}
// TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
// os.Root containment guard. A traversal-shaped app name drives the
// resolved log path out of the deploy log directory onto a sentinel