Refactoring complete: v0.4.0 architectural milestone #1
+1
-1
@@ -248,7 +248,7 @@ Track progress here. Mark tasks complete as they land and pass review.
|
|||||||
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
|
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
|
||||||
- [x] T1.3 — Create `src/runner`; move runner logic
|
- [x] T1.3 — Create `src/runner`; move runner logic
|
||||||
- [x] T1.4 — Create `src/scheduler`; move scheduler
|
- [x] T1.4 — Create `src/scheduler`; move scheduler
|
||||||
- [ ] T1.5 — Create `src/storage`; move store/paths
|
- [x] T1.5 — Create `src/storage`; move store/paths
|
||||||
- [ ] T1.6 — Create `src/platform/autostart`; move autostart logic
|
- [ ] T1.6 — Create `src/platform/autostart`; move autostart logic
|
||||||
- [ ] T1.7 — Create `src/platform/desktop`; move desktop integration
|
- [ ] T1.7 — Create `src/platform/desktop`; move desktop integration
|
||||||
- [ ] T1.8 — Delete empty `src/core`; build + test both platforms
|
- [ ] T1.8 — Delete empty `src/core`; build + test both platforms
|
||||||
|
|||||||
@@ -1,54 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// The config file stays beside the executable so the portable build behaves
|
|
||||||
// predictably: moving the program folder moves its settings with it.
|
|
||||||
ConfigFileName = "gosentry.yaml"
|
|
||||||
// Older builds were named PySentry. Keep the old config name readable during
|
|
||||||
// the rename window so portable installations can start once and rewrite the
|
|
||||||
// settings to gosentry.yaml without manual file copying.
|
|
||||||
LegacyConfigFileName = "pysentry.yaml"
|
|
||||||
// Jobs are kept in a separate YAML file because the user can choose a
|
|
||||||
// different jobs directory, while application settings remain local to the
|
|
||||||
// installed/copied program.
|
|
||||||
JobsFileName = "jobs.yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Paths contains both the physical program location and the resolved runtime
|
|
||||||
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
|
||||||
// scheduler from interpreting relative directories differently.
|
|
||||||
type Paths struct {
|
|
||||||
ExecutablePath string
|
|
||||||
AppDir string
|
|
||||||
ConfigPath string
|
|
||||||
JobsDir string
|
|
||||||
JobsPath string
|
|
||||||
LogsDir string
|
|
||||||
DesktopIcon string
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolvePaths() (Paths, error) {
|
|
||||||
// os.Executable is used instead of the current working directory because GUI
|
|
||||||
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
|
|
||||||
// In those cases the working directory can be surprising, but the executable
|
|
||||||
// path is stable and matches the "portable app folder" storage model.
|
|
||||||
executable, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return Paths{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
appDir := filepath.Dir(executable)
|
|
||||||
configPath := filepath.Join(appDir, ConfigFileName)
|
|
||||||
return Paths{
|
|
||||||
ExecutablePath: executable,
|
|
||||||
AppDir: appDir,
|
|
||||||
ConfigPath: configPath,
|
|
||||||
JobsDir: appDir,
|
|
||||||
JobsPath: filepath.Join(appDir, JobsFileName),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,261 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Store struct {
|
|
||||||
Paths Paths
|
|
||||||
Config domain.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenStore() (*Store, []domain.Job, error) {
|
|
||||||
paths, err := ResolvePaths()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
store := &Store{Paths: paths}
|
|
||||||
config, err := loadOrCreateConfig(paths)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
store.Config = config
|
|
||||||
store.applyConfigPaths()
|
|
||||||
// Save the config after loading so missing defaults are written back. This
|
|
||||||
// rewrites old or hand-edited files into the current clean schema without
|
|
||||||
// forcing the user to delete them manually.
|
|
||||||
if err := store.SaveConfig(); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
normalizeJobs(jobs)
|
|
||||||
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
|
|
||||||
// only durable job definitions remain, because runtime fields are tagged
|
|
||||||
// yaml:"-" in the model.
|
|
||||||
if err := store.SaveJobs(jobs); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return store, jobs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SaveConfig() error {
|
|
||||||
s.applyConfigPaths()
|
|
||||||
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeYAML(s.Paths.ConfigPath, s.Config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
|
||||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|
||||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
|
||||||
// executable, while logs are grouped under a dedicated subdirectory.
|
|
||||||
config := domain.Config{
|
|
||||||
JobsDir: ".",
|
|
||||||
LogsDir: "logs",
|
|
||||||
MaxLogFiles: 100,
|
|
||||||
MaxLogAgeDays: 30,
|
|
||||||
StartOnLogin: false,
|
|
||||||
KeepRunningInTray: true,
|
|
||||||
NotifyOnFailure: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
configPath := paths.ConfigPath
|
|
||||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
|
|
||||||
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
|
|
||||||
// The rename from PySentry to GoSentry changed the preferred config
|
|
||||||
// filename. Read the old file once if it is still present so portable
|
|
||||||
// installs continue to start without a manual migration step. The
|
|
||||||
// caller later saves the loaded config back through SaveConfig, which
|
|
||||||
// naturally rewrites it under gosentry.yaml.
|
|
||||||
configPath = legacyPath
|
|
||||||
} else {
|
|
||||||
return config, writeYAML(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
|
||||||
return config, writeYAML(paths.ConfigPath, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
||||||
return domain.Config{}, err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.JobsDir) == "" {
|
|
||||||
// Empty paths are treated as missing values rather than intentional root
|
|
||||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
|
||||||
config.JobsDir = "."
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
|
||||||
config.LogsDir = "logs"
|
|
||||||
}
|
|
||||||
if config.MaxLogFiles <= 0 {
|
|
||||||
config.MaxLogFiles = 100
|
|
||||||
}
|
|
||||||
if config.MaxLogAgeDays <= 0 {
|
|
||||||
config.MaxLogAgeDays = 30
|
|
||||||
}
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
||||||
// The first run creates harmless sample jobs so a new user can immediately
|
|
||||||
// see scheduled and manual execution without inventing a command.
|
|
||||||
jobs := defaultJobs()
|
|
||||||
normalizeJobs(jobs)
|
|
||||||
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var file domain.JobsFile
|
|
||||||
if err := yaml.Unmarshal(data, &file); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return file.Jobs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeJobs(jobs []domain.Job) {
|
|
||||||
next := 1
|
|
||||||
for index := range jobs {
|
|
||||||
job := &jobs[index]
|
|
||||||
if job.ID <= 0 {
|
|
||||||
// IDs are assigned only when absent. Existing IDs stay stable because
|
|
||||||
// History and future log associations use them to identify jobs.
|
|
||||||
job.ID = next
|
|
||||||
}
|
|
||||||
if job.ID >= next {
|
|
||||||
next = job.ID + 1
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Name) == "" {
|
|
||||||
job.Name = "Untitled job"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Schedule) == "" {
|
|
||||||
job.Schedule = "@every 1m"
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(job.Command) == "" {
|
|
||||||
// An empty command would fail in a confusing way. A safe echo command
|
|
||||||
// gives the user something observable and harmless instead.
|
|
||||||
job.Command = echoCommand("GoSentry job ran")
|
|
||||||
}
|
|
||||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
|
||||||
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
|
||||||
if job.SuccessExitCodes == "" {
|
|
||||||
job.SuccessExitCodes = "0"
|
|
||||||
}
|
|
||||||
if job.LastRun == "" {
|
|
||||||
job.LastRun = "Never"
|
|
||||||
}
|
|
||||||
if job.Output == "" {
|
|
||||||
job.Output = "No command output captured yet."
|
|
||||||
}
|
|
||||||
if job.Enabled {
|
|
||||||
job.LastState = "Ready"
|
|
||||||
job.NextRun = "After start"
|
|
||||||
} else {
|
|
||||||
job.LastState = "Paused"
|
|
||||||
job.NextRun = "Paused"
|
|
||||||
}
|
|
||||||
// Runtime fields are reconstructed each time the app starts. Persisted run
|
|
||||||
// records live in log files, not in jobs.yaml, to keep the jobs file easy
|
|
||||||
// to review and edit by hand.
|
|
||||||
job.Logs = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
|
||||||
return resolveConfiguredDir(appDir, jobsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveConfiguredDir(appDir string, dir string) string {
|
|
||||||
if filepath.IsAbs(dir) {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
// Relative paths are resolved against the executable directory, not the
|
|
||||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
|
||||||
// Explorer launches, and terminal launches consistent.
|
|
||||||
return filepath.Clean(filepath.Join(appDir, dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) applyConfigPaths() {
|
|
||||||
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
|
||||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
|
||||||
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeYAML(path string, value any) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := yaml.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// WriteFile replaces the full file instead of patching it in place. For small
|
|
||||||
// YAML files this is simpler and prevents stale keys from older versions from
|
|
||||||
// lingering after the schema changes.
|
|
||||||
return os.WriteFile(path, data, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultJobs() []domain.Job {
|
|
||||||
return []domain.Job{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Hello scheduler",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Write timestamp",
|
|
||||||
Folder: "Examples",
|
|
||||||
Schedule: "*/1 * * * *",
|
|
||||||
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: 3,
|
|
||||||
Name: "Paused sample",
|
|
||||||
Schedule: "@every 1m",
|
|
||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
|
||||||
Enabled: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func echoCommand(message string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return "echo " + message
|
|
||||||
}
|
|
||||||
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
|
||||||
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
|
||||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,205 +1 @@
|
|||||||
package core
|
package core
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestJobsRoundTrip(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "jobs.yaml")
|
|
||||||
|
|
||||||
original := []domain.Job{
|
|
||||||
{
|
|
||||||
ID: 7,
|
|
||||||
Name: "Backup data",
|
|
||||||
Folder: "Maintenance",
|
|
||||||
Schedule: "0 2 * * *",
|
|
||||||
Command: "/usr/bin/backup",
|
|
||||||
Arguments: "--compress\n--verbose",
|
|
||||||
SuccessExitCodes: "0,1",
|
|
||||||
StartOnly: true,
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := loadOrCreateJobs(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 job, got %d", len(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
g, w := got[0], original[0]
|
|
||||||
if g.ID != w.ID {
|
|
||||||
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
|
|
||||||
}
|
|
||||||
if g.Name != w.Name {
|
|
||||||
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
|
|
||||||
}
|
|
||||||
if g.Folder != w.Folder {
|
|
||||||
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
|
|
||||||
}
|
|
||||||
if g.Schedule != w.Schedule {
|
|
||||||
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
|
|
||||||
}
|
|
||||||
if g.Command != w.Command {
|
|
||||||
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
|
|
||||||
}
|
|
||||||
if g.Arguments != w.Arguments {
|
|
||||||
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
|
|
||||||
}
|
|
||||||
if g.SuccessExitCodes != w.SuccessExitCodes {
|
|
||||||
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
|
|
||||||
}
|
|
||||||
if g.StartOnly != w.StartOnly {
|
|
||||||
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
|
|
||||||
}
|
|
||||||
if g.Enabled != w.Enabled {
|
|
||||||
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runtime fields must not survive the save→load round-trip.
|
|
||||||
if g.LastRun != "" {
|
|
||||||
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
|
|
||||||
}
|
|
||||||
if g.LastState != "" {
|
|
||||||
t.Errorf("LastState should be empty after load, got %q", g.LastState)
|
|
||||||
}
|
|
||||||
if g.Logs != nil {
|
|
||||||
t.Errorf("Logs should be nil after load, got %v", g.Logs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigRoundTrip(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
paths := Paths{
|
|
||||||
AppDir: dir,
|
|
||||||
ConfigPath: filepath.Join(dir, ConfigFileName),
|
|
||||||
}
|
|
||||||
|
|
||||||
want := domain.Config{
|
|
||||||
JobsDir: "/custom/jobs",
|
|
||||||
LogsDir: "/custom/logs",
|
|
||||||
MaxLogFiles: 50,
|
|
||||||
MaxLogAgeDays: 14,
|
|
||||||
StartOnLogin: true,
|
|
||||||
KeepRunningInTray: false,
|
|
||||||
NotifyOnFailure: false,
|
|
||||||
}
|
|
||||||
if err := writeYAML(paths.ConfigPath, want); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := loadOrCreateConfig(paths)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.JobsDir != want.JobsDir {
|
|
||||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
|
||||||
}
|
|
||||||
if got.LogsDir != want.LogsDir {
|
|
||||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
|
||||||
}
|
|
||||||
if got.MaxLogFiles != want.MaxLogFiles {
|
|
||||||
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
|
|
||||||
}
|
|
||||||
if got.MaxLogAgeDays != want.MaxLogAgeDays {
|
|
||||||
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
|
|
||||||
}
|
|
||||||
if got.StartOnLogin != want.StartOnLogin {
|
|
||||||
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
|
|
||||||
}
|
|
||||||
if got.KeepRunningInTray != want.KeepRunningInTray {
|
|
||||||
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
|
|
||||||
}
|
|
||||||
if got.NotifyOnFailure != want.NotifyOnFailure {
|
|
||||||
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
|
||||||
jobs := []domain.Job{
|
|
||||||
{Enabled: true},
|
|
||||||
{Enabled: false},
|
|
||||||
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeJobs(jobs)
|
|
||||||
|
|
||||||
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
|
|
||||||
if jobs[0].ID != 1 {
|
|
||||||
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
|
|
||||||
}
|
|
||||||
if jobs[0].Name != "Untitled job" {
|
|
||||||
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
|
|
||||||
}
|
|
||||||
if jobs[0].Schedule != "@every 1m" {
|
|
||||||
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
|
|
||||||
}
|
|
||||||
if jobs[0].SuccessExitCodes != "0" {
|
|
||||||
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
|
|
||||||
}
|
|
||||||
if jobs[0].LastState != "Ready" {
|
|
||||||
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
|
|
||||||
}
|
|
||||||
if jobs[0].NextRun != "After start" {
|
|
||||||
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disabled job is marked Paused.
|
|
||||||
if jobs[1].LastState != "Paused" {
|
|
||||||
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
|
|
||||||
}
|
|
||||||
if jobs[1].NextRun != "Paused" {
|
|
||||||
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-set fields survive normalization unchanged.
|
|
||||||
if jobs[2].ID != 5 {
|
|
||||||
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
|
|
||||||
}
|
|
||||||
if jobs[2].SuccessExitCodes != "0,1" {
|
|
||||||
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
|
||||||
jobs := []domain.Job{
|
|
||||||
{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Clean job",
|
|
||||||
Schedule: "@every 10s",
|
|
||||||
Command: echoCommand("ok"),
|
|
||||||
Enabled: true,
|
|
||||||
LastRun: "2026-06-14 12:00:00",
|
|
||||||
NextRun: "2026-06-14 12:00:10",
|
|
||||||
LastState: "OK",
|
|
||||||
Output: "stdout: ok",
|
|
||||||
Logs: []domain.RunRecord{
|
|
||||||
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
text := string(data)
|
|
||||||
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
|
|
||||||
if strings.Contains(text, unwanted) {
|
|
||||||
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-2
@@ -17,6 +17,7 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
@@ -162,7 +163,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||||
store, jobs, err := core.OpenStore()
|
store, jobs, err := storage.OpenStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
|
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
|
||||||
}
|
}
|
||||||
@@ -904,7 +905,7 @@ func logFileName(path string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
func settingsView(w fyne.Window, store *storage.Store, jobs *[]job) fyne.CanvasObject {
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/core"
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
@@ -20,7 +20,7 @@ var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month
|
|||||||
// this keeps the early architecture simple while storage and scheduling are
|
// this keeps the early architecture simple while storage and scheduling are
|
||||||
// still in one desktop process.
|
// still in one desktop process.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *core.Store
|
store *storage.Store
|
||||||
jobs *[]domain.Job
|
jobs *[]domain.Job
|
||||||
onChange func(domain.RunRecord)
|
onChange func(domain.RunRecord)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ type Scheduler struct {
|
|||||||
paused bool
|
paused bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewScheduler(store *core.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler {
|
func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s := &Scheduler{
|
s := &Scheduler{
|
||||||
store: store,
|
store: store,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The config file stays beside the executable so the portable build behaves
|
||||||
|
// predictably: moving the program folder moves its settings with it.
|
||||||
|
ConfigFileName = "gosentry.yaml"
|
||||||
|
// Older builds were named PySentry. Keep the old config name readable during
|
||||||
|
// the rename window so portable installations can start once and rewrite the
|
||||||
|
// settings to gosentry.yaml without manual file copying.
|
||||||
|
LegacyConfigFileName = "pysentry.yaml"
|
||||||
|
// Jobs are kept in a separate YAML file because the user can choose a
|
||||||
|
// different jobs directory, while application settings remain local to the
|
||||||
|
// installed/copied program.
|
||||||
|
JobsFileName = "jobs.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paths contains both the physical program location and the resolved runtime
|
||||||
|
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
||||||
|
// scheduler from interpreting relative directories differently.
|
||||||
|
type Paths struct {
|
||||||
|
ExecutablePath string
|
||||||
|
AppDir string
|
||||||
|
ConfigPath string
|
||||||
|
JobsDir string
|
||||||
|
JobsPath string
|
||||||
|
LogsDir string
|
||||||
|
DesktopIcon string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolvePaths() (Paths, error) {
|
||||||
|
// os.Executable is used instead of the current working directory because GUI
|
||||||
|
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
|
||||||
|
// In those cases the working directory can be surprising, but the executable
|
||||||
|
// path is stable and matches the "portable app folder" storage model.
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return Paths{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Dir(executable)
|
||||||
|
configPath := filepath.Join(appDir, ConfigFileName)
|
||||||
|
return Paths{
|
||||||
|
ExecutablePath: executable,
|
||||||
|
AppDir: appDir,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
JobsDir: appDir,
|
||||||
|
JobsPath: filepath.Join(appDir, JobsFileName),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
Paths Paths
|
||||||
|
Config domain.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenStore() (*Store, []domain.Job, error) {
|
||||||
|
paths, err := ResolvePaths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &Store{Paths: paths}
|
||||||
|
config, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
store.Config = config
|
||||||
|
store.applyConfigPaths()
|
||||||
|
// Save the config after loading so missing defaults are written back. This
|
||||||
|
// rewrites old or hand-edited files into the current clean schema without
|
||||||
|
// forcing the user to delete them manually.
|
||||||
|
if err := store.SaveConfig(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
|
||||||
|
// only durable job definitions remain, because runtime fields are tagged
|
||||||
|
// yaml:"-" in the model.
|
||||||
|
if err := store.SaveJobs(jobs); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return store, jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
s.applyConfigPaths()
|
||||||
|
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.ConfigPath, s.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||||
|
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
|
config := domain.Config{
|
||||||
|
JobsDir: ".",
|
||||||
|
LogsDir: "logs",
|
||||||
|
MaxLogFiles: 100,
|
||||||
|
MaxLogAgeDays: 30,
|
||||||
|
StartOnLogin: false,
|
||||||
|
KeepRunningInTray: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := paths.ConfigPath
|
||||||
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
|
||||||
|
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
|
||||||
|
// The rename from PySentry to GoSentry changed the preferred config
|
||||||
|
// filename. Read the old file once if it is still present so portable
|
||||||
|
// installs continue to start without a manual migration step. The
|
||||||
|
// caller later saves the loaded config back through SaveConfig, which
|
||||||
|
// naturally rewrites it under gosentry.yaml.
|
||||||
|
configPath = legacyPath
|
||||||
|
} else {
|
||||||
|
return config, writeYAML(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return config, writeYAML(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return domain.Config{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.JobsDir) == "" {
|
||||||
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
|
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||||
|
config.JobsDir = "."
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
config.LogsDir = "logs"
|
||||||
|
}
|
||||||
|
if config.MaxLogFiles <= 0 {
|
||||||
|
config.MaxLogFiles = 100
|
||||||
|
}
|
||||||
|
if config.MaxLogAgeDays <= 0 {
|
||||||
|
config.MaxLogAgeDays = 30
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
// The first run creates harmless sample jobs so a new user can immediately
|
||||||
|
// see scheduled and manual execution without inventing a command.
|
||||||
|
jobs := defaultJobs()
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file.Jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
|
next := 1
|
||||||
|
for index := range jobs {
|
||||||
|
job := &jobs[index]
|
||||||
|
if job.ID <= 0 {
|
||||||
|
// IDs are assigned only when absent. Existing IDs stay stable because
|
||||||
|
// History and future log associations use them to identify jobs.
|
||||||
|
job.ID = next
|
||||||
|
}
|
||||||
|
if job.ID >= next {
|
||||||
|
next = job.ID + 1
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Name) == "" {
|
||||||
|
job.Name = "Untitled job"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Schedule) == "" {
|
||||||
|
job.Schedule = "@every 1m"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Command) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
|
job.Command = echoCommand("GoSentry job ran")
|
||||||
|
}
|
||||||
|
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||||
|
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
||||||
|
if job.SuccessExitCodes == "" {
|
||||||
|
job.SuccessExitCodes = "0"
|
||||||
|
}
|
||||||
|
if job.LastRun == "" {
|
||||||
|
job.LastRun = "Never"
|
||||||
|
}
|
||||||
|
if job.Output == "" {
|
||||||
|
job.Output = "No command output captured yet."
|
||||||
|
}
|
||||||
|
if job.Enabled {
|
||||||
|
job.LastState = "Ready"
|
||||||
|
job.NextRun = "After start"
|
||||||
|
} else {
|
||||||
|
job.LastState = "Paused"
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
}
|
||||||
|
// Runtime fields are reconstructed each time the app starts. Persisted run
|
||||||
|
// records live in log files, not in jobs.yaml, to keep the jobs file easy
|
||||||
|
// to review and edit by hand.
|
||||||
|
job.Logs = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||||
|
return resolveConfiguredDir(appDir, jobsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfiguredDir(appDir string, dir string) string {
|
||||||
|
if filepath.IsAbs(dir) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
// Relative paths are resolved against the executable directory, not the
|
||||||
|
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||||
|
// Explorer launches, and terminal launches consistent.
|
||||||
|
return filepath.Clean(filepath.Join(appDir, dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyConfigPaths() {
|
||||||
|
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||||
|
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||||
|
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeYAML(path string, value any) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// WriteFile replaces the full file instead of patching it in place. For small
|
||||||
|
// YAML files this is simpler and prevents stale keys from older versions from
|
||||||
|
// lingering after the schema changes.
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultJobs() []domain.Job {
|
||||||
|
return []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Hello scheduler",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("GoSentry test job: scheduler is alive"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Write timestamp",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "*/1 * * * *",
|
||||||
|
Command: echoCommand("GoSentry test job: timestamp command ran"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Paused sample",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func echoCommand(message string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "echo " + message
|
||||||
|
}
|
||||||
|
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
||||||
|
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
||||||
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "jobs.yaml")
|
||||||
|
|
||||||
|
original := []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 7,
|
||||||
|
Name: "Backup data",
|
||||||
|
Folder: "Maintenance",
|
||||||
|
Schedule: "0 2 * * *",
|
||||||
|
Command: "/usr/bin/backup",
|
||||||
|
Arguments: "--compress\n--verbose",
|
||||||
|
SuccessExitCodes: "0,1",
|
||||||
|
StartOnly: true,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateJobs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
g, w := got[0], original[0]
|
||||||
|
if g.ID != w.ID {
|
||||||
|
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
|
||||||
|
}
|
||||||
|
if g.Name != w.Name {
|
||||||
|
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
|
||||||
|
}
|
||||||
|
if g.Folder != w.Folder {
|
||||||
|
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
|
||||||
|
}
|
||||||
|
if g.Schedule != w.Schedule {
|
||||||
|
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
|
||||||
|
}
|
||||||
|
if g.Command != w.Command {
|
||||||
|
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
|
||||||
|
}
|
||||||
|
if g.Arguments != w.Arguments {
|
||||||
|
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
|
||||||
|
}
|
||||||
|
if g.SuccessExitCodes != w.SuccessExitCodes {
|
||||||
|
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
|
||||||
|
}
|
||||||
|
if g.StartOnly != w.StartOnly {
|
||||||
|
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
|
||||||
|
}
|
||||||
|
if g.Enabled != w.Enabled {
|
||||||
|
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime fields must not survive the save→load round-trip.
|
||||||
|
if g.LastRun != "" {
|
||||||
|
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
|
||||||
|
}
|
||||||
|
if g.LastState != "" {
|
||||||
|
t.Errorf("LastState should be empty after load, got %q", g.LastState)
|
||||||
|
}
|
||||||
|
if g.Logs != nil {
|
||||||
|
t.Errorf("Logs should be nil after load, got %v", g.Logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
|
||||||
|
want := domain.Config{
|
||||||
|
JobsDir: "/custom/jobs",
|
||||||
|
LogsDir: "/custom/logs",
|
||||||
|
MaxLogFiles: 50,
|
||||||
|
MaxLogAgeDays: 14,
|
||||||
|
StartOnLogin: true,
|
||||||
|
KeepRunningInTray: false,
|
||||||
|
NotifyOnFailure: false,
|
||||||
|
}
|
||||||
|
if err := writeYAML(paths.ConfigPath, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.JobsDir != want.JobsDir {
|
||||||
|
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
||||||
|
}
|
||||||
|
if got.LogsDir != want.LogsDir {
|
||||||
|
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||||
|
}
|
||||||
|
if got.MaxLogFiles != want.MaxLogFiles {
|
||||||
|
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
|
||||||
|
}
|
||||||
|
if got.MaxLogAgeDays != want.MaxLogAgeDays {
|
||||||
|
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
|
||||||
|
}
|
||||||
|
if got.StartOnLogin != want.StartOnLogin {
|
||||||
|
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
|
||||||
|
}
|
||||||
|
if got.KeepRunningInTray != want.KeepRunningInTray {
|
||||||
|
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
|
||||||
|
}
|
||||||
|
if got.NotifyOnFailure != want.NotifyOnFailure {
|
||||||
|
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
||||||
|
jobs := []domain.Job{
|
||||||
|
{Enabled: true},
|
||||||
|
{Enabled: false},
|
||||||
|
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
|
||||||
|
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
|
||||||
|
if jobs[0].ID != 1 {
|
||||||
|
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
|
||||||
|
}
|
||||||
|
if jobs[0].Name != "Untitled job" {
|
||||||
|
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
|
||||||
|
}
|
||||||
|
if jobs[0].Schedule != "@every 1m" {
|
||||||
|
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
|
||||||
|
}
|
||||||
|
if jobs[0].SuccessExitCodes != "0" {
|
||||||
|
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
|
||||||
|
}
|
||||||
|
if jobs[0].LastState != "Ready" {
|
||||||
|
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
|
||||||
|
}
|
||||||
|
if jobs[0].NextRun != "After start" {
|
||||||
|
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled job is marked Paused.
|
||||||
|
if jobs[1].LastState != "Paused" {
|
||||||
|
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
|
||||||
|
}
|
||||||
|
if jobs[1].NextRun != "Paused" {
|
||||||
|
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-set fields survive normalization unchanged.
|
||||||
|
if jobs[2].ID != 5 {
|
||||||
|
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
|
||||||
|
}
|
||||||
|
if jobs[2].SuccessExitCodes != "0,1" {
|
||||||
|
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||||
|
jobs := []domain.Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Clean job",
|
||||||
|
Schedule: "@every 10s",
|
||||||
|
Command: echoCommand("ok"),
|
||||||
|
Enabled: true,
|
||||||
|
LastRun: "2026-06-14 12:00:00",
|
||||||
|
NextRun: "2026-06-14 12:00:10",
|
||||||
|
LastState: "OK",
|
||||||
|
Output: "stdout: ok",
|
||||||
|
Logs: []domain.RunRecord{
|
||||||
|
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user