Refactoring complete: v0.4.0 architectural milestone (#1)
## Summary Completed Phase 5 refactoring and reached the target architecture. **Architectural milestone achieved:** - Service layer owns all state and is the sole writer - UI is a thin Fyne view, all widget updates marshaled via `fyne.Do` - Core engines are stateless and injectable - Domain types are pure (no `yaml:"-"` fields) - Full module builds and `go vet ./...` clean ## Changes - Bump version: 0.3.6 → 0.4.0 - Update CHANGELOG with Phase 5 summary - Add ROADMAP "Refactoring Follow-Ups" section ## Known follow-up work 1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag 2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines) See ROADMAP.md for details. --------- Co-authored-by: mixeme <mix.public@ya.ru> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type logFile struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
var logs []logFile
|
||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
for _, entry := range entries {
|
||||
// Only GoSentry run logs are managed here. Directories and non-.log files
|
||||
// are intentionally ignored so the user can keep notes or other artifacts
|
||||
// in the same folder without the cleanup policy deleting them.
|
||||
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(logsDir, entry.Name())
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
|
||||
// Cleanup is best-effort: failing to delete one file should not block
|
||||
// the scheduler from running future jobs.
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
|
||||
}
|
||||
|
||||
if maxFiles <= 0 || len(logs) <= maxFiles {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(logs, func(i int, j int) bool {
|
||||
// Newest files are kept first, then everything after maxFiles is removed.
|
||||
// This matches the user's expectation that the most recent failures and
|
||||
// command output remain available for investigation.
|
||||
return logs[i].modTime.After(logs[j].modTime)
|
||||
})
|
||||
for _, old := range logs[maxFiles:] {
|
||||
_ = os.Remove(old.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeLogFile(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("log"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func setModTime(t *testing.T, path string, age time.Duration) {
|
||||
t.Helper()
|
||||
mt := time.Now().Add(-age)
|
||||
if err := os.Chtimes(path, mt, mt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsMissingDirReturnsNil(t *testing.T) {
|
||||
err := CleanupLogs(filepath.Join(t.TempDir(), "nonexistent"), 100, 30)
|
||||
if err != nil {
|
||||
t.Errorf("missing dir should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := writeLogFile(t, dir, "old.log")
|
||||
recent := writeLogFile(t, dir, "recent.log")
|
||||
setModTime(t, old, 31*24*time.Hour) // 31 days old → past the 30-day limit
|
||||
setModTime(t, recent, 5*24*time.Hour) // 5 days old → within limit
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||
t.Error("file older than maxAgeDays should be deleted")
|
||||
}
|
||||
if _, err := os.Stat(recent); err != nil {
|
||||
t.Errorf("file within maxAgeDays should be kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsKeepsFilesWithinAgeLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 1; i <= 3; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
|
||||
setModTime(t, path, time.Duration(i)*24*time.Hour)
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("expected 3 files kept within age limit, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
|
||||
// than maxFiles log files exist the oldest (by modification time) are removed.
|
||||
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
||||
func TestCleanupLogsByCountDeletesOldest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create 5 files; i=0 is newest (1 day old), i=4 is oldest (5 days old).
|
||||
var paths []string
|
||||
for i := 0; i < 5; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%03d.log", i))
|
||||
setModTime(t, path, time.Duration(i+1)*24*time.Hour)
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("expected 3 files after count cleanup, got %d", len(entries))
|
||||
}
|
||||
// The 3 newest files (paths[0..2]) must survive.
|
||||
for _, kept := range paths[:3] {
|
||||
if _, err := os.Stat(kept); err != nil {
|
||||
t.Errorf("newest file %s should be kept: %v", filepath.Base(kept), err)
|
||||
}
|
||||
}
|
||||
// The 2 oldest files (paths[3..4]) must be removed.
|
||||
for _, deleted := range paths[3:] {
|
||||
if _, err := os.Stat(deleted); !os.IsNotExist(err) {
|
||||
t.Errorf("oldest file %s should have been deleted", filepath.Base(deleted))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsNonLogFilesNotDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := writeLogFile(t, dir, "job.log")
|
||||
notALog := writeLogFile(t, dir, "notes.txt")
|
||||
// Both are old enough that age-based cleanup would remove them if it applied.
|
||||
setModTime(t, logFile, 35*24*time.Hour)
|
||||
setModTime(t, notALog, 35*24*time.Hour)
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
|
||||
t.Error("old .log file should be deleted")
|
||||
}
|
||||
if _, err := os.Stat(notALog); err != nil {
|
||||
t.Errorf(".txt file should not be deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsSubdirsNotDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
subdir := filepath.Join(dir, "archive.log") // name looks like a log but is a dir
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setModTime(t, subdir, 60*24*time.Hour)
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(subdir); err != nil {
|
||||
t.Errorf("subdirectory should not be deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupLogsZeroLimitsDisableBothPolicies confirms that maxFiles=0 disables
|
||||
// count-based cleanup and maxAgeDays=0 disables age-based cleanup independently.
|
||||
func TestCleanupLogsZeroLimitsDisableBothPolicies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 0; i < 5; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
|
||||
setModTime(t, path, 60*24*time.Hour) // very old
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 5 {
|
||||
t.Errorf("expected all 5 files kept with both limits disabled, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
||||
for _, accepted := range parseExitCodes(successExitCodes) {
|
||||
if exitCode == accepted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseExitCodes(value string) []int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return []int{0}
|
||||
}
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
result := make([]int, 0, len(fields))
|
||||
seen := map[int]bool{}
|
||||
for _, field := range fields {
|
||||
code, err := strconv.Atoi(strings.TrimSpace(field))
|
||||
if err != nil || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
result = append(result, code)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []int{0}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func SuccessExitCodesText(job domain.Job) string {
|
||||
codes := parseExitCodes(job.SuccessExitCodes)
|
||||
parts := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
parts = append(parts, strconv.Itoa(code))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) }
|
||||
@@ -0,0 +1,68 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
type commandInvocation struct {
|
||||
command *exec.Cmd
|
||||
hideWindow bool
|
||||
}
|
||||
|
||||
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
|
||||
command := strings.TrimSpace(job.Command)
|
||||
arguments := commandArguments(job.Arguments)
|
||||
if len(arguments) > 0 || commandPathExists(command) {
|
||||
return commandInvocation{
|
||||
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
||||
hideWindow: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Shell mode remains for existing jobs and for commands that intentionally
|
||||
// use builtins, redirection, variables, or chained command syntax.
|
||||
return commandInvocation{
|
||||
command: shellCommand(ctx, command),
|
||||
hideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandArguments(arguments string) []string {
|
||||
var result []string
|
||||
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func commandPathExists(command string) bool {
|
||||
command = unquoteCommandPath(strings.TrimSpace(command))
|
||||
if command == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(command)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func unquoteCommandPath(command string) string {
|
||||
return strings.Trim(strings.TrimSpace(command), `"`)
|
||||
}
|
||||
|
||||
func LogArguments(arguments string) string {
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||
}
|
||||
|
||||
func logArguments(arguments string) string { return LogArguments(arguments) }
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||
// sh -c is the portable baseline for Linux builds. It keeps the runner small
|
||||
// and avoids a hard dependency on a larger shell such as bash.
|
||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||
// cmd.exe keeps Windows users' expectations for commands such as "dir",
|
||||
// "copy", variable expansion, redirection, and .bat/.cmd wrappers.
|
||||
//
|
||||
// Go's normal Windows argument escaping turns embedded quotes into literal
|
||||
// backslash-quote sequences for cmd.exe. Supplying the raw command line keeps
|
||||
// commands like `"C:\Program Files\App\App.exe" "D:\file.txt"` executable.
|
||||
result := exec.CommandContext(ctx, "cmd.exe")
|
||||
result.SysProcAttr = &syscall.SysProcAttr{CmdLine: windowsShellCommandLine(command)}
|
||||
return result
|
||||
}
|
||||
|
||||
func windowsShellCommandLine(command string) string {
|
||||
return `cmd.exe /S /C "` + quoteLeadingWindowsProgramPath(command) + `"`
|
||||
}
|
||||
|
||||
func quoteLeadingWindowsProgramPath(command string) string {
|
||||
trimmed := strings.TrimLeftFunc(command, unicode.IsSpace)
|
||||
leadingWhitespace := command[:len(command)-len(trimmed)]
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, `"`) || !startsWithWindowsRootedPath(trimmed) {
|
||||
return command
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
index := strings.Index(lower, extension)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
pathEnd := index + len(extension)
|
||||
programPath := trimmed[:pathEnd]
|
||||
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
||||
return command
|
||||
}
|
||||
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func startsWithWindowsRootedPath(command string) bool {
|
||||
if strings.HasPrefix(command, `\\`) {
|
||||
return true
|
||||
}
|
||||
return len(command) >= 3 &&
|
||||
((command[0] >= 'A' && command[0] <= 'Z') || (command[0] >= 'a' && command[0] <= 'z')) &&
|
||||
command[1] == ':' &&
|
||||
(command[2] == '\\' || command[2] == '/')
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"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, started time.Time) string {
|
||||
if strings.TrimSpace(logsDir) == "" {
|
||||
return ""
|
||||
}
|
||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||
return ""
|
||||
}
|
||||
// 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\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||
)
|
||||
|
||||
const commandTimeout = 30 * time.Second
|
||||
const commandWaitDelay = 2 * time.Second
|
||||
|
||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
|
||||
started := time.Now()
|
||||
// Commands can hang forever if a script waits for input or a child process
|
||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||
// scheduler; later it can become a per-job setting without changing the
|
||||
// runner contract.
|
||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
var output string
|
||||
var state string
|
||||
var detail string
|
||||
if job.StartOnly {
|
||||
invocation := jobInvocation(context.Background(), *job)
|
||||
state, detail, output = startJobOnly(invocation, *job, started)
|
||||
} else {
|
||||
var stdoutBuf strings.Builder
|
||||
var stderrBuf strings.Builder
|
||||
invocation := jobInvocation(runCtx, *job)
|
||||
command := invocation.command
|
||||
command.WaitDelay = commandWaitDelay
|
||||
if invocation.hideWindow {
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
}
|
||||
command.Stdout = &stdoutBuf
|
||||
command.Stderr = &stderrBuf
|
||||
|
||||
err := command.Run()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
||||
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timestamp := now.Format("2006-01-02 15:04:05")
|
||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
||||
|
||||
// The runner is now pure with respect to the job: it returns a RunRecord and
|
||||
// lets the caller fold that record into the job's JobRuntime. Run state no
|
||||
// longer lives on Job, so there is nothing on the job to mutate here.
|
||||
return domain.RunRecord{
|
||||
Time: timestamp,
|
||||
JobID: job.ID,
|
||||
JobName: job.Name,
|
||||
Trigger: trigger,
|
||||
State: state,
|
||||
Detail: detail,
|
||||
LogFile: logFile,
|
||||
Output: output,
|
||||
}
|
||||
}
|
||||
|
||||
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||
command := invocation.command
|
||||
if invocation.hideWindow {
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
}
|
||||
err := command.Start()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
if err != nil {
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
||||
}
|
||||
pid := command.Process.Pid
|
||||
if releaseErr := command.Process.Release(); releaseErr != nil {
|
||||
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
||||
}
|
||||
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
||||
}
|
||||
|
||||
func startOnlyOutput(job domain.Job, pid int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
if pid > 0 {
|
||||
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
||||
} else {
|
||||
builder.WriteString("Process did not start.\n\n")
|
||||
}
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(logArguments(job.Arguments))
|
||||
builder.WriteString("\n\nstart_only:\ntrue")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) {
|
||||
if err == nil {
|
||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||
}
|
||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||
}
|
||||
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
exitCode := exitError.ExitCode()
|
||||
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
||||
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
||||
}
|
||||
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
||||
}
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
||||
}
|
||||
|
||||
func formatOutput(stdout string, stderr string) string {
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
stderr = strings.TrimSpace(stderr)
|
||||
if stdout == "" {
|
||||
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||
// GUI: the user can tell that the command ran but produced no stream data.
|
||||
stdout = "<empty>"
|
||||
}
|
||||
if stderr == "" {
|
||||
stderr = "<empty>"
|
||||
}
|
||||
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||
)
|
||||
|
||||
func echoCommand(message string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "echo " + message
|
||||
}
|
||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func TestRunJobLogFileAllHeaders(t *testing.T) {
|
||||
logsDir := t.TempDir()
|
||||
job := domain.Job{
|
||||
ID: 99,
|
||||
Name: "Log Header Test",
|
||||
Command: echoCommand("header test output"),
|
||||
SuccessExitCodes: "0,1",
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||
if record.LogFile == "" {
|
||||
t.Fatal("expected log file to be written")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(record.LogFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"job_id: 99",
|
||||
"job_name: Log Header Test",
|
||||
"trigger: Schedule",
|
||||
"state: OK",
|
||||
"detail: ",
|
||||
"command: " + job.Command,
|
||||
"arguments: <empty>",
|
||||
"success_exit_codes: 0,1",
|
||||
"start_only: false",
|
||||
"stdout:",
|
||||
"stderr:",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("log file missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
|
||||
// The time header must use the documented format.
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "time: ") {
|
||||
ts := strings.TrimPrefix(line, "time: ")
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
|
||||
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRecordFields(t *testing.T) {
|
||||
job := domain.Job{
|
||||
ID: 55,
|
||||
Name: "Record Fields Test",
|
||||
Command: echoCommand("record field check"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||
|
||||
if record.JobID != job.ID {
|
||||
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
||||
}
|
||||
if record.JobName != job.Name {
|
||||
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
|
||||
}
|
||||
if record.Trigger != "Schedule" {
|
||||
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
|
||||
}
|
||||
if record.LogFile == "" {
|
||||
t.Error("LogFile should be a non-empty path")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
|
||||
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
|
||||
}
|
||||
if !strings.Contains(record.Output, "stdout:") {
|
||||
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
|
||||
}
|
||||
if !strings.Contains(record.Output, "stderr:") {
|
||||
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput(t *testing.T) {
|
||||
got := formatOutput("hello world", "some error")
|
||||
want := "stdout:\nhello world\n\nstderr:\nsome error"
|
||||
if got != want {
|
||||
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutputEmptyStreams(t *testing.T) {
|
||||
got := formatOutput("", "")
|
||||
if !strings.Contains(got, "stdout:\n<empty>") {
|
||||
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "stderr:\n<empty>") {
|
||||
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogArguments(t *testing.T) {
|
||||
cases := []struct{ input, want string }{
|
||||
{"", "<empty>"},
|
||||
{" ", "<empty>"},
|
||||
{"--flag", "--flag"},
|
||||
{"--flag\r\n--value", "--flag\n--value"},
|
||||
{"--flag\n--value", "--flag\n--value"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := logArguments(tc.input); got != tc.want {
|
||||
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFileName(t *testing.T) {
|
||||
cases := []struct{ input, want string }{
|
||||
{"Hello Test", "Hello_Test"},
|
||||
{"job-1_ok", "job-1_ok"},
|
||||
{"!!!", "job"},
|
||||
{"", "job"},
|
||||
{"A/B:C", "A_B_C"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := sanitizeFileName(tc.input); got != tc.want {
|
||||
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobWritesLogFile(t *testing.T) {
|
||||
logsDir := t.TempDir()
|
||||
job := domain.Job{
|
||||
ID: 42,
|
||||
Name: "Hello Test",
|
||||
Command: echoCommand("hello from test"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if record.LogFile == "" {
|
||||
t.Fatal("expected log file path")
|
||||
}
|
||||
if filepath.Dir(record.LogFile) != logsDir {
|
||||
t.Fatalf("expected log in %q, got %q", logsDir, record.LogFile)
|
||||
}
|
||||
if !strings.Contains(filepath.Base(record.LogFile), "Hello_Test") {
|
||||
t.Fatalf("expected job name in log filename, got %q", record.LogFile)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(record.LogFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{"trigger: Manual", "job_name: Hello Test", "hello from test"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected log content to contain %q, got:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows cmd.exe quoting only")
|
||||
}
|
||||
|
||||
logsDir := t.TempDir()
|
||||
job := domain.Job{
|
||||
ID: 43,
|
||||
Name: "Quoted Windows Command",
|
||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
if !strings.Contains(record.Output, "quoted command ok") {
|
||||
t.Fatalf("expected command output, got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows cmd.exe quoting only")
|
||||
}
|
||||
|
||||
logsDir := t.TempDir()
|
||||
scriptDir := filepath.Join(t.TempDir(), "Program Files", "GoSentry Test")
|
||||
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scriptPath := filepath.Join(scriptDir, "hello.cmd")
|
||||
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: 44,
|
||||
Name: "Unquoted Windows Command",
|
||||
Command: scriptPath,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
if !strings.Contains(record.Output, "unquoted command ok") {
|
||||
t.Fatalf("expected command output, got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows command arguments only")
|
||||
}
|
||||
|
||||
logsDir := t.TempDir()
|
||||
job := domain.Job{
|
||||
ID: 45,
|
||||
Name: "Separate Arguments",
|
||||
Command: `C:\Windows\System32\cmd.exe`,
|
||||
Arguments: "/C\necho separate arguments ok",
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
if !strings.Contains(record.Output, "separate arguments ok") {
|
||||
t.Fatalf("expected command output, got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
|
||||
command := `sh -c 'exit 1'`
|
||||
if runtime.GOOS == "windows" {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: 46,
|
||||
Name: "Accepted Exit Code",
|
||||
Command: command,
|
||||
SuccessExitCodes: "0,1",
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
job.Arguments = "/C\nexit /b 1"
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected accepted exit code to be OK, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
if !strings.Contains(record.Detail, "accepted exit code 1") {
|
||||
t.Fatalf("expected accepted exit code detail, got %q", record.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
|
||||
command := `sh -c 'exit 1'`
|
||||
if runtime.GOOS == "windows" {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: 47,
|
||||
Name: "Rejected Exit Code",
|
||||
Command: command,
|
||||
SuccessExitCodes: "0",
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
job.Arguments = "/C\nexit /b 1"
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if record.State != "Failed" {
|
||||
t.Fatalf("expected rejected exit code to fail, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
if !strings.Contains(record.Detail, "Exit code 1") {
|
||||
t.Fatalf("expected exit code detail, got %q", record.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
||||
command := "sh"
|
||||
arguments := "-c\nexit 7"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
arguments = "/C\nexit /b 7"
|
||||
}
|
||||
job := domain.Job{
|
||||
ID: 48,
|
||||
Name: "Start Only",
|
||||
Command: command,
|
||||
Arguments: arguments,
|
||||
StartOnly: true,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
if !strings.Contains(record.Detail, "not waiting for process exit") {
|
||||
t.Fatalf("expected start-only detail, got %q", record.Detail)
|
||||
}
|
||||
if !strings.Contains(record.Output, "start_only:\ntrue") {
|
||||
t.Fatalf("expected start-only output, got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
||||
job := domain.Job{
|
||||
ID: 49,
|
||||
Name: "Missing Start Only",
|
||||
Command: "definitely-missing-gosentry-command",
|
||||
Arguments: "--force-direct-start",
|
||||
StartOnly: true,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if record.State != "Failed" {
|
||||
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
if !strings.Contains(record.Output, "Process did not start") {
|
||||
t.Fatalf("expected start failure output, got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExitCodes(t *testing.T) {
|
||||
got := parseExitCodes("0, 1;2\n3")
|
||||
want := []int{0, 1, 2, 3}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectCommandDoesNotHideWindow(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows window visibility only")
|
||||
}
|
||||
|
||||
invocation := jobInvocation(context.Background(), domain.Job{
|
||||
Command: `C:\Windows\System32\cmd.exe`,
|
||||
Arguments: "/C\necho visible direct process",
|
||||
})
|
||||
if invocation.hideWindow {
|
||||
t.Fatal("direct command should not request hidden startup window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCommandHidesWindow(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows window visibility only")
|
||||
}
|
||||
|
||||
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
|
||||
if !invocation.hideWindow {
|
||||
t.Fatal("shell command should request hidden startup window")
|
||||
}
|
||||
winproc.ConfigureHiddenWindow(invocation.command)
|
||||
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
|
||||
t.Fatal("expected shell command to be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows cmd.exe quoting only")
|
||||
}
|
||||
|
||||
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
|
||||
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
|
||||
if command.SysProcAttr == nil {
|
||||
t.Fatal("expected SysProcAttr")
|
||||
}
|
||||
if command.SysProcAttr.CmdLine != want {
|
||||
t.Fatalf("expected command line %q, got %q", want, command.SysProcAttr.CmdLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows cmd.exe quoting only")
|
||||
}
|
||||
|
||||
got := windowsShellCommandLine(`C:\Program Files\Joplin\Joplin.exe --profile "D:\Joplin Profile"`)
|
||||
want := `cmd.exe /S /C ""C:\Program Files\Joplin\Joplin.exe" --profile "D:\Joplin Profile""`
|
||||
if got != want {
|
||||
t.Fatalf("expected command line %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user