Add build log file storage and download functionality

- Write deployment logs to files when deployment finishes (success or failure)
- Log files stored in DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt
- Capture commit SHA for manual deploys by parsing git rev-parse HEAD after clone
- Add download endpoint for log files at /apps/{id}/deployments/{deploymentID}/download
- Add download link in deployment history view for finished deployments
This commit is contained in:
Jeffrey Paul 2026-01-01 06:08:00 -08:00
parent c4362c3143
commit 2cbcd3d72a
5 changed files with 226 additions and 23 deletions

View File

@ -11,6 +11,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
@ -417,7 +418,8 @@ type cloneConfig struct {
// CloneResult contains the result of a git clone operation. // CloneResult contains the result of a git clone operation.
type CloneResult struct { type CloneResult struct {
Output string // Combined stdout/stderr from git clone Output string // Combined stdout/stderr from git clone
CommitSHA string // The HEAD commit SHA after clone/checkout
} }
// CloneRepo clones a git repository using SSH and optionally checks out a specific commit. // CloneRepo clones a git repository using SSH and optionally checks out a specific commit.
@ -590,16 +592,22 @@ func (c *Client) createGitContainer(
if cfg.commitSHA != "" { if cfg.commitSHA != "" {
// Clone without depth limit so we can checkout any commit, then checkout specific SHA // Clone without depth limit so we can checkout any commit, then checkout specific SHA
// Using sh -c to run multiple commands - need to clear entrypoint // Using sh -c to run multiple commands - need to clear entrypoint
// Output "COMMIT:<sha>" marker at end for parsing
script := fmt.Sprintf( script := fmt.Sprintf(
"git clone --branch %s %s /repo && cd /repo && git checkout %s", "git clone --branch %s %s /repo && cd /repo && git checkout %s && echo COMMIT:$(git rev-parse HEAD)",
cfg.branch, cfg.repoURL, cfg.commitSHA, cfg.branch, cfg.repoURL, cfg.commitSHA,
) )
entrypoint = []string{} entrypoint = []string{}
cmd = []string{"sh", "-c", script} cmd = []string{"sh", "-c", script}
} else { } else {
// Shallow clone of branch HEAD - use default git entrypoint // Shallow clone of branch HEAD, then output commit SHA
entrypoint = nil // Using sh -c to run multiple commands
cmd = []string{"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo"} script := fmt.Sprintf(
"git clone --depth 1 --branch %s %s /repo && cd /repo && echo COMMIT:$(git rev-parse HEAD)",
cfg.branch, cfg.repoURL,
)
entrypoint = []string{}
cmd = []string{"sh", "-c", script}
} }
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container) // Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
@ -657,10 +665,31 @@ func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneRes
) )
} }
return &CloneResult{Output: logs}, nil // Parse commit SHA from output (looks for "COMMIT:<sha>" line)
commitSHA := parseCommitSHA(logs)
return &CloneResult{Output: logs, CommitSHA: commitSHA}, nil
} }
} }
// commitMarker is the prefix used to identify commit SHA in clone output.
const commitMarker = "COMMIT:"
// parseCommitSHA extracts the commit SHA from git clone output.
// It looks for a line starting with "COMMIT:" and returns the SHA after it.
func parseCommitSHA(output string) string {
for line := range strings.SplitSeq(output, "\n") {
line = strings.TrimSpace(line)
sha, found := strings.CutPrefix(line, commitMarker)
if found {
return strings.TrimSpace(sha)
}
}
return ""
}
func (c *Client) connect(ctx context.Context) error { func (c *Client) connect(ctx context.Context) error {
opts := []client.Opt{ opts := []client.Opt{
client.FromEnv, client.FromEnv,

View File

@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -438,6 +440,66 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
} }
} }
// 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
}
// Get the log file path from deploy service
logPath := h.deploy.GetLogFilePath(application, deployment)
if logPath == "" {
http.NotFound(writer, request)
return
}
// Check if file exists
_, err := os.Stat(logPath)
if os.IsNotExist(err) {
http.NotFound(writer, request)
return
}
if err != nil {
h.log.Error("failed to stat log file", "error", err, "path", logPath)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
// Extract filename for Content-Disposition header
filename := filepath.Base(logPath)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath)
}
}
// 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"

View File

@ -66,6 +66,7 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())

View File

@ -52,6 +52,18 @@ var (
// logFlushInterval is how often to flush buffered logs to the database. // logFlushInterval is how often to flush buffered logs to the database.
const logFlushInterval = time.Second const logFlushInterval = time.Second
// logsDirPermissions is the permission mode for the logs directory.
const logsDirPermissions = 0o750
// logFilePermissions is the permission mode for log files.
const logFilePermissions = 0o640
// logTimestampFormat is the format for log file timestamps.
const logTimestampFormat = "20060102T150405Z"
// logFileShortSHALength is the number of characters to use for commit SHA in log filenames.
const logFileShortSHALength = 12
// dockerLogMessage represents a Docker build log message. // dockerLogMessage represents a Docker build log message.
type dockerLogMessage struct { type dockerLogMessage struct {
Stream string `json:"stream"` Stream string `json:"stream"`
@ -225,6 +237,37 @@ func (svc *Service) GetBuildDir(appID string) string {
return filepath.Join(svc.config.DataDir, "builds", appID) return filepath.Join(svc.config.DataDir, "builds", appID)
} }
// GetLogFilePath returns the path to the log file for a deployment.
// Returns empty string if the path cannot be determined.
func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deployment) string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Get commit SHA
sha := ""
if deployment.CommitSHA.Valid && deployment.CommitSHA.String != "" {
sha = deployment.CommitSHA.String
if len(sha) > logFileShortSHALength {
sha = sha[:logFileShortSHALength]
}
}
// Use started_at timestamp
timestamp := deployment.StartedAt.UTC().Format(logTimestampFormat)
// Build filename: appname_sha_timestamp.log.txt (or appname_timestamp.log.txt if no SHA)
var filename string
if sha != "" {
filename = fmt.Sprintf("%s_%s_%s.log.txt", app.Name, sha, timestamp)
} else {
filename = fmt.Sprintf("%s_%s.log.txt", app.Name, timestamp)
}
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
}
// Deploy deploys an app. // Deploy deploys an app.
func (svc *Service) Deploy( func (svc *Service) Deploy(
ctx context.Context, ctx context.Context,
@ -575,9 +618,37 @@ func (svc *Service) cloneRepository(
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr) return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
} }
svc.processCloneResult(ctx, app, deployment, cloneResult, commitSHA)
// Return the 'work' subdirectory where the repo was cloned
workDir := filepath.Join(buildDir, "work")
return workDir, cleanup, nil
}
// processCloneResult handles the result of a git clone operation.
// It sets the commit SHA on the deployment if not already set, and logs the result.
func (svc *Service) processCloneResult(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
cloneResult *docker.CloneResult,
originalCommitSHA string,
) {
// Set commit SHA from clone result if not already set (e.g., manual deploys)
if cloneResult != nil && cloneResult.CommitSHA != "" && !deployment.CommitSHA.Valid {
deployment.CommitSHA = sql.NullString{String: cloneResult.CommitSHA, Valid: true}
_ = deployment.Save(ctx)
}
// Log clone success with git output // Log clone success with git output
if commitSHA != "" { actualCommitSHA := originalCommitSHA
_ = deployment.AppendLog(ctx, "Repository cloned at commit "+commitSHA) if cloneResult != nil && cloneResult.CommitSHA != "" {
actualCommitSHA = cloneResult.CommitSHA
}
if actualCommitSHA != "" {
_ = deployment.AppendLog(ctx, "Repository cloned at commit "+actualCommitSHA)
} else { } else {
_ = deployment.AppendLog(ctx, "Repository cloned (branch: "+app.Branch+")") _ = deployment.AppendLog(ctx, "Repository cloned (branch: "+app.Branch+")")
} }
@ -585,11 +656,6 @@ func (svc *Service) cloneRepository(
if cloneResult != nil && cloneResult.Output != "" { if cloneResult != nil && cloneResult.Output != "" {
_ = deployment.AppendLog(ctx, cloneResult.Output) _ = deployment.AppendLog(ctx, cloneResult.Output)
} }
// Return the 'work' subdirectory where the repo was cloned
workDir := filepath.Join(buildDir, "work")
return workDir, cleanup, nil
} }
// containerToHostPath converts a container-local path to the equivalent host path. // containerToHostPath converts a container-local path to the equivalent host path.
@ -835,6 +901,7 @@ func (svc *Service) checkHealthAfterDelay(
svc.log.Info("container healthy after 60 seconds", "app", reloadedApp.Name) svc.log.Info("container healthy after 60 seconds", "app", reloadedApp.Name)
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment) svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess) _ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
svc.writeLogsToFile(reloadedApp, deployment)
} else { } else {
svc.log.Warn( svc.log.Warn(
"container unhealthy after 60 seconds", "container unhealthy after 60 seconds",
@ -842,6 +909,7 @@ func (svc *Service) checkHealthAfterDelay(
) )
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy) svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment)
reloadedApp.Status = models.AppStatusError reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx) _ = reloadedApp.Save(ctx)
} }
@ -856,6 +924,39 @@ func (svc *Service) failDeployment(
svc.log.Error("deployment failed", "app", app.Name, "error", deployErr) svc.log.Error("deployment failed", "app", app.Name, "error", deployErr)
_ = deployment.AppendLog(ctx, "ERROR: "+deployErr.Error()) _ = deployment.AppendLog(ctx, "ERROR: "+deployErr.Error())
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed) _ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(app, deployment)
app.Status = models.AppStatusError app.Status = models.AppStatusError
_ = app.Save(ctx) _ = app.Save(ctx)
} }
// writeLogsToFile writes the deployment logs to a file on disk.
// Structure: DataDir/logs/<hostname>/<appname>/<appname>_<sha>_<timestamp>.log.txt
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
if !deployment.Logs.Valid || deployment.Logs.String == "" {
return
}
logPath := svc.GetLogFilePath(app, deployment)
if logPath == "" {
return
}
// Ensure directory exists
logDir := filepath.Dir(logPath)
err := os.MkdirAll(logDir, logsDirPermissions)
if err != nil {
svc.log.Error("failed to create logs directory", "error", err, "path", logDir)
return
}
err = os.WriteFile(logPath, []byte(deployment.Logs.String), logFilePermissions)
if err != nil {
svc.log.Error("failed to write log file", "error", err, "path", logPath)
return
}
svc.log.Info("wrote deployment logs to file", "path", logPath)
}

View File

@ -68,17 +68,27 @@
</div> </div>
{{if or .Logs.Valid (eq .Status "building") (eq .Status "deploying")}} {{if or .Logs.Valid (eq .Status "building") (eq .Status "deploying")}}
<details class="mt-4 deployment-logs" {{if or (eq .Status "building") (eq .Status "deploying")}}open{{end}}> <div class="mt-4 flex items-center gap-4">
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center"> <details class="deployment-logs flex-1" {{if or (eq .Status "building") (eq .Status "deploying")}}open{{end}}>
<svg class="w-4 h-4 mr-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <svg class="w-4 h-4 mr-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
View Logs
</summary>
<div class="logs-wrapper mt-3 overflow-auto" style="max-height: 400px;">
<pre class="logs-content p-4 bg-gray-900 text-gray-100 rounded-lg text-xs font-mono leading-relaxed whitespace-pre-wrap">{{if .Logs.Valid}}{{.Logs.String}}{{else}}Loading...{{end}}</pre>
</div>
</details>
{{if or (eq .Status "success") (eq .Status "failed")}}
<a href="/apps/{{$.App.ID}}/deployments/{{.ID}}/download" class="text-sm text-primary-600 hover:text-primary-800 inline-flex items-center" title="Download logs">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg> </svg>
View Logs Download
</summary> </a>
<div class="logs-wrapper mt-3 overflow-auto" style="max-height: 400px;"> {{end}}
<pre class="logs-content p-4 bg-gray-900 text-gray-100 rounded-lg text-xs font-mono leading-relaxed whitespace-pre-wrap">{{if .Logs.Valid}}{{.Logs.String}}{{else}}Loading...{{end}}</pre> </div>
</div>
</details>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}