d202f8a94c
Switch direct YAML usage from gopkg.in/yaml.v3 to go.yaml.in/yaml/v4, the maintained YAML org fork of the archived go-yaml repository. Update README dependency and mirroring links so the documented source repository matches the module used by the application.
241 lines
6.4 KiB
Go
241 lines
6.4 KiB
Go
package core
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
type Store struct {
|
|
Paths Paths
|
|
Config Config
|
|
}
|
|
|
|
func OpenStore() (*Store, []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 []Job) error {
|
|
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
|
|
}
|
|
|
|
func loadOrCreateConfig(paths Paths) (Config, error) {
|
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
|
// executable, while logs are grouped under a dedicated subdirectory.
|
|
config := Config{
|
|
JobsDir: ".",
|
|
LogsDir: "logs",
|
|
MaxLogFiles: 100,
|
|
MaxLogAgeDays: 30,
|
|
StartOnLogin: false,
|
|
KeepRunningInTray: true,
|
|
NotifyOnFailure: true,
|
|
}
|
|
|
|
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
|
return config, writeYAML(paths.ConfigPath, config)
|
|
}
|
|
|
|
data, err := os.ReadFile(paths.ConfigPath)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
return 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) ([]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, JobsFile{Jobs: jobs})
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var file JobsFile
|
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
|
return nil, err
|
|
}
|
|
return file.Jobs, nil
|
|
}
|
|
|
|
func normalizeJobs(jobs []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("PySentry job ran")
|
|
}
|
|
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() []Job {
|
|
return []Job{
|
|
{
|
|
ID: 1,
|
|
Name: "Hello scheduler",
|
|
Folder: "Examples",
|
|
Schedule: "@every 1m",
|
|
Command: echoCommand("PySentry test job: scheduler is alive"),
|
|
Enabled: true,
|
|
},
|
|
{
|
|
ID: 2,
|
|
Name: "Write timestamp",
|
|
Folder: "Examples",
|
|
Schedule: "*/1 * * * *",
|
|
Command: echoCommand("PySentry 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, "'", "'\\''") + "'"
|
|
}
|