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:
2026-01-01 06:08:00 -08:00
parent c4362c3143
commit 2cbcd3d72a
5 changed files with 226 additions and 23 deletions

View File

@@ -52,6 +52,18 @@ var (
// logFlushInterval is how often to flush buffered logs to the database.
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.
type dockerLogMessage struct {
Stream string `json:"stream"`
@@ -225,6 +237,37 @@ func (svc *Service) GetBuildDir(appID string) string {
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.
func (svc *Service) Deploy(
ctx context.Context,
@@ -575,9 +618,37 @@ func (svc *Service) cloneRepository(
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
if commitSHA != "" {
_ = deployment.AppendLog(ctx, "Repository cloned at commit "+commitSHA)
actualCommitSHA := originalCommitSHA
if cloneResult != nil && cloneResult.CommitSHA != "" {
actualCommitSHA = cloneResult.CommitSHA
}
if actualCommitSHA != "" {
_ = deployment.AppendLog(ctx, "Repository cloned at commit "+actualCommitSHA)
} else {
_ = deployment.AppendLog(ctx, "Repository cloned (branch: "+app.Branch+")")
}
@@ -585,11 +656,6 @@ func (svc *Service) cloneRepository(
if cloneResult != nil && 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.
@@ -835,6 +901,7 @@ func (svc *Service) checkHealthAfterDelay(
svc.log.Info("container healthy after 60 seconds", "app", reloadedApp.Name)
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
svc.writeLogsToFile(reloadedApp, deployment)
} else {
svc.log.Warn(
"container unhealthy after 60 seconds",
@@ -842,6 +909,7 @@ func (svc *Service) checkHealthAfterDelay(
)
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(reloadedApp, deployment)
reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx)
}
@@ -856,6 +924,39 @@ func (svc *Service) failDeployment(
svc.log.Error("deployment failed", "app", app.Name, "error", deployErr)
_ = deployment.AppendLog(ctx, "ERROR: "+deployErr.Error())
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
svc.writeLogsToFile(app, deployment)
app.Status = models.AppStatusError
_ = 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)
}