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:
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -417,7 +418,8 @@ type cloneConfig struct {
|
||||
|
||||
// CloneResult contains the result of a git clone operation.
|
||||
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.
|
||||
@@ -590,16 +592,22 @@ func (c *Client) createGitContainer(
|
||||
if cfg.commitSHA != "" {
|
||||
// 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
|
||||
// Output "COMMIT:<sha>" marker at end for parsing
|
||||
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,
|
||||
)
|
||||
entrypoint = []string{}
|
||||
cmd = []string{"sh", "-c", script}
|
||||
} else {
|
||||
// Shallow clone of branch HEAD - use default git entrypoint
|
||||
entrypoint = nil
|
||||
cmd = []string{"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo"}
|
||||
// Shallow clone of branch HEAD, then output commit SHA
|
||||
// Using sh -c to run multiple commands
|
||||
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)
|
||||
@@ -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 {
|
||||
opts := []client.Opt{
|
||||
client.FromEnv,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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.
|
||||
const containerLogsAPITail = "100"
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ func (s *Server) SetupRoutes() {
|
||||
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
|
||||
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
|
||||
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}/container-logs", s.handlers.HandleContainerLogsAPI())
|
||||
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
|
||||
|
||||
@@ -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