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
+4
View File
@@ -20,6 +20,10 @@ regress.
# Completed Steps # Completed Steps
- 2026-09-23: Deployment log files are now stored under `logs/<appname>/`
instead of `logs/<hostname>/<appname>/`, so downloads keep working after the
upaas container is recreated; logs written under an old hostname directory are
still found (#214).
- 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers` - 2026-09-23: Fixed the flaky `t.TempDir` cleanup race in `internal/handlers`
(the one fixed in `internal/service/webhook` by #198): (the one fixed in `internal/service/webhook` by #198):
`TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's `TestHandleWebhookProcessesValidWebhook` now waits with the webhook service's
+21 -1
View File
@@ -6,9 +6,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -640,7 +642,7 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
} }
defer func() { _ = root.Close() }() defer func() { _ = root.Close() }()
file, openErr := root.Open(relPath) file, openErr := openDeploymentLog(root, relPath)
if openErr != nil { if openErr != nil {
http.NotFound(writer, request) 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. // containerLogsAPITail is the default number of log lines for the container logs API.
const containerLogsAPITail = "100" const containerLogsAPITail = "100"
+29
View File
@@ -69,6 +69,35 @@ func TestHandleDeploymentLogDownloadServesLegitimateFile(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "deploy log contents") 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 // TestHandleDeploymentLogDownloadRejectsPathTraversal verifies the
// os.Root containment guard. A traversal-shaped app name drives the // os.Root containment guard. A traversal-shaped app name drives the
// resolved log path out of the deploy log directory onto a sentinel // resolved log path out of the deploy log directory onto a sentinel
+6 -7
View File
@@ -261,15 +261,14 @@ func (svc *Service) GetBuildDir(appName string) string {
// GetLogFilePath returns the path to the log file for a deployment. // GetLogFilePath returns the path to the log file for a deployment.
// Returns empty string if the path cannot be determined. // Returns empty string if the path cannot be determined.
//
// The path must not depend on the container's hostname: Docker assigns a
// new one whenever the container is recreated, and older logs would then
// no longer be found.
func (svc *Service) GetLogFilePath( func (svc *Service) GetLogFilePath(
app *models.App, app *models.App,
deployment *models.Deployment, deployment *models.Deployment,
) string { ) string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Get commit SHA // Get commit SHA
sha := "" sha := ""
if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" { if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" {
@@ -291,7 +290,7 @@ func (svc *Service) GetLogFilePath(
filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp) filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp)
} }
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename) return filepath.Join(svc.config.DataDir, "logs", app.Name, filename)
} }
// GetLogDir returns the root directory under which all deployment log // GetLogDir returns the root directory under which all deployment log
@@ -1294,7 +1293,7 @@ func (svc *Service) failDeployment(
} }
// writeLogsToFile writes the deployment logs to a file on disk. // writeLogsToFile writes the deployment logs to a file on disk.
// Structure: DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt // Structure: DataDir/logs/<appname>/<appname>_<sha>_<timestamp>.log.txt
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) { func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
if !deployment.Logs.Valid || deployment.Logs.String == "" { if !deployment.Logs.Valid || deployment.Logs.String == "" {
return return