1242b22e4f
Implements items 1-3 of the whole-project review's suggested order (docs/PROJECT_REVIEW_PLAN.md): - Restore TestJobListViewIsCompact, accidentally dropped by 5b0e6fe; drop the redundant TestDefaultConfigUsesDetailedJobList row from TESTS.md and document the two other doc gaps the review found. - Fix quoteLeadingWindowsProgramPath to find the earliest file-extension match at a word boundary instead of the first extension in list order, so a .bat/.cmd command whose argument ends in .exe no longer has its whole command line mistaken for the program path. - Write gosentry.json, jobs.json, and run log files atomically (temp file + rename) so a crash or power loss mid-write can no longer leave a truncated file. Wire Service.Stop() into the app shutdown path so it actually runs, cancelling the run context for in-flight runs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
2.6 KiB
Go
95 lines
2.6 KiB
Go
package runner
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
)
|
|
|
|
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) (string, error) {
|
|
if strings.TrimSpace(logsDir) == "" {
|
|
return "", errors.New("logs directory is empty")
|
|
}
|
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
|
return "", fmt.Errorf("create logs directory: %w", err)
|
|
}
|
|
// The timestamp comes first so a plain directory listing is naturally sorted
|
|
// by run time. The job name is included for human scanning, but sanitized to
|
|
// avoid characters that are invalid on Windows or awkward on shells.
|
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
|
path := filepath.Join(logsDir, fileName)
|
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
|
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
|
|
return "", fmt.Errorf("write log file: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// writeFileAtomic writes data to a temp file in dir, then renames it over
|
|
// path. Rename is atomic within a volume on both supported platforms, so a
|
|
// crash or a killed process mid-write can never leave path holding a
|
|
// truncated log file the way a direct os.WriteFile could.
|
|
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
|
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmpPath, perm); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
return err
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
func sanitizeFileName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "job"
|
|
}
|
|
var builder strings.Builder
|
|
for _, r := range name {
|
|
switch {
|
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
|
builder.WriteRune(r)
|
|
case r == '-', r == '_':
|
|
builder.WriteRune(r)
|
|
default:
|
|
builder.WriteRune('_')
|
|
}
|
|
}
|
|
result := strings.Trim(builder.String(), "_")
|
|
if result == "" {
|
|
return "job"
|
|
}
|
|
return result
|
|
}
|