diff --git a/internal/docker/client.go b/internal/docker/client.go index 9299e81..7e9ec0f 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -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:" 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:" 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, diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 42a0aee..2887139 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -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" diff --git a/internal/server/routes.go b/internal/server/routes.go index 196a4b3..3fe3e4a 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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()) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index da364a0..f088c54 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -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///__.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) +} diff --git a/templates/deployments.html b/templates/deployments.html index f21b112..11a2c2c 100644 --- a/templates/deployments.html +++ b/templates/deployments.html @@ -68,17 +68,27 @@ {{if or .Logs.Valid (eq .Status "building") (eq .Status "deploying")}} -
- - - +
+
+ + + + + View Logs + +
+
{{if .Logs.Valid}}{{.Logs.String}}{{else}}Loading...{{end}}
+
+
+ {{if or (eq .Status "success") (eq .Status "failed")}} + + + - View Logs -
-
-
{{if .Logs.Valid}}{{.Logs.String}}{{else}}Loading...{{end}}
-
-
+ Download + + {{end}} + {{end}} {{end}}