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:
parent
c4362c3143
commit
2cbcd3d72a
@ -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"
|
||||||
@ -418,6 +419,7 @@ 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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -68,7 +68,8 @@
|
|||||||
</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">
|
||||||
|
<details class="deployment-logs flex-1" {{if or (eq .Status "building") (eq .Status "deploying")}}open{{end}}>
|
||||||
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center">
|
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
@ -79,6 +80,15 @@
|
|||||||
<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>
|
<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>
|
</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>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user