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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user