From ef333b492d57ee60550e36e0da98ddc2fa022630 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 19:50:54 +0000 Subject: [PATCH 01/38] merge display methods --- schedule/handler.go | 3 +- schedule/handler_crond.go | 12 +-- schedule/handler_darwin.go | 12 +-- schedule/handler_fake_test.go | 91 ------------------- schedule/handler_systemd.go | 7 +- schedule/handler_windows.go | 14 +-- schedule/job.go | 30 ++---- schedule/job_test.go | 166 ++++++++++++---------------------- schedule/mocks/Handler.go | 55 +++-------- schedule/schedules.go | 22 +++-- schedule/schedules_test.go | 35 +++---- schedule_jobs_test.go | 7 +- 12 files changed, 132 insertions(+), 322 deletions(-) delete mode 100644 schedule/handler_fake_test.go diff --git a/schedule/handler.go b/schedule/handler.go index 56980687..10350c9e 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -12,8 +12,7 @@ type Handler interface { Init() error Close() ParseSchedules(schedules []string) ([]*calendar.Event, error) - DisplayParsedSchedules(command string, events []*calendar.Event) - DisplaySchedules(command string, schedules []string) error + DisplaySchedules(profile, command string, schedules []string) error DisplayStatus(profileName string) error CreateJob(job *Config, schedules []*calendar.Event, permission string) error RemoveJob(job *Config, permission string) error diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 2d69f103..571482cd 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -43,12 +43,12 @@ func (h *HandlerCrond) ParseSchedules(schedules []string) ([]*calendar.Event, er return parseSchedules(schedules) } -func (h *HandlerCrond) DisplayParsedSchedules(command string, events []*calendar.Event) { - displayParsedSchedules(command, events) -} - -// DisplaySchedules does nothing with crond -func (h *HandlerCrond) DisplaySchedules(command string, schedules []string) error { +func (h *HandlerCrond) DisplaySchedules(profile, command string, schedules []string) error { + events, err := parseSchedules(schedules) + if err != nil { + return err + } + displayParsedSchedules(profile, command, events) return nil } diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index d5604d0e..4d9664f1 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -84,12 +84,12 @@ func (h *HandlerLaunchd) ParseSchedules(schedules []string) ([]*calendar.Event, return parseSchedules(schedules) } -func (h *HandlerLaunchd) DisplayParsedSchedules(command string, events []*calendar.Event) { - displayParsedSchedules(command, events) -} - -// DisplaySchedules does nothing with launchd -func (h *HandlerLaunchd) DisplaySchedules(command string, schedules []string) error { +func (h *HandlerLaunchd) DisplaySchedules(profile, command string, schedules []string) error { + events, err := parseSchedules(schedules) + if err != nil { + return err + } + displayParsedSchedules(profile, command, events) return nil } diff --git a/schedule/handler_fake_test.go b/schedule/handler_fake_test.go deleted file mode 100644 index cebb64d6..00000000 --- a/schedule/handler_fake_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package schedule - -import ( - "testing" - - "github.com/creativeprojects/resticprofile/calendar" -) - -const ( - errorMethodNotRegistered = "method not registered in mock" -) - -type mockHandler struct { - t *testing.T - init func() error - close func() - parseSchedules func(schedules []string) ([]*calendar.Event, error) - displayParsedSchedules func(command string, events []*calendar.Event) - displaySchedules func(command string, schedules []string) error - displayStatus func(profileName string) error - createJob func(job *Config, schedules []*calendar.Event, permission string) error - removeJob func(job *Config, permission string) error - displayJobStatus func(job *Config) error -} - -func (h mockHandler) Init() error { - if h.init == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.init() -} - -func (h mockHandler) Close() { - if h.close == nil { - h.t.Fatal(errorMethodNotRegistered) - } - h.close() -} - -func (h mockHandler) ParseSchedules(schedules []string) ([]*calendar.Event, error) { - if h.parseSchedules == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.parseSchedules(schedules) -} - -func (h mockHandler) DisplayParsedSchedules(command string, events []*calendar.Event) { - if h.displayParsedSchedules == nil { - h.t.Fatal(errorMethodNotRegistered) - } - h.displayParsedSchedules(command, events) -} - -func (h mockHandler) DisplaySchedules(command string, schedules []string) error { - if h.displaySchedules == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.displaySchedules(command, schedules) -} - -func (h mockHandler) DisplayStatus(profileName string) error { - if h.displayStatus == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.displayStatus(profileName) -} - -func (h mockHandler) CreateJob(job *Config, schedules []*calendar.Event, permission string) error { - if h.createJob == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.createJob(job, schedules, permission) -} - -func (h mockHandler) RemoveJob(job *Config, permission string) error { - if h.removeJob == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.removeJob(job, permission) -} - -func (h mockHandler) DisplayJobStatus(job *Config) error { - if h.displayJobStatus == nil { - h.t.Fatal(errorMethodNotRegistered) - } - return h.displayJobStatus(job) -} - -var ( - _ Handler = &mockHandler{} -) diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index bc6b1eb9..279a588c 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -66,12 +66,9 @@ func (h *HandlerSystemd) ParseSchedules(schedules []string) ([]*calendar.Event, return nil, nil } -// DisplayParsedSchedules does nothing with systemd -func (h *HandlerSystemd) DisplayParsedSchedules(command string, events []*calendar.Event) {} - // DisplaySchedules displays the schedules through the systemd-analyze command -func (h *HandlerSystemd) DisplaySchedules(command string, schedules []string) error { - return displaySystemdSchedules(command, schedules) +func (h *HandlerSystemd) DisplaySchedules(profile, command string, schedules []string) error { + return displaySystemdSchedules(profile, command, schedules) } func (h *HandlerSystemd) DisplayStatus(profileName string) error { diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index cc3af507..c6ea5fe3 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -30,13 +30,13 @@ func (h *HandlerWindows) ParseSchedules(schedules []string) ([]*calendar.Event, return parseSchedules(schedules) } -// DisplayParsedSchedules via term output -func (h *HandlerWindows) DisplayParsedSchedules(command string, events []*calendar.Event) { - displayParsedSchedules(command, events) -} - -// DisplaySchedules does nothing on windows -func (h *HandlerWindows) DisplaySchedules(command string, schedules []string) error { +// DisplaySchedules via term output +func (h *HandlerWindows) DisplaySchedules(profile, command string, schedules []string) error { + events, err := parseSchedules(schedules) + if err != nil { + return err + } + displayParsedSchedules(profile, command, events) return nil } diff --git a/schedule/job.go b/schedule/job.go index 3af2392c..2ec1a208 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -34,22 +34,16 @@ func (j *Job) Create() error { return permissionError("create") } - schedules, err := j.handler.ParseSchedules(j.config.Schedules) - if err != nil { + if err := j.handler.DisplaySchedules(j.config.ProfileName, j.config.CommandName, j.config.Schedules); err != nil { return err } - if len(schedules) > 0 { - j.handler.DisplayParsedSchedules(j.config.CommandName, schedules) - } else { - err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules) - if err != nil { - return err - } + schedules, err := j.handler.ParseSchedules(j.config.Schedules) + if err != nil { + return err } - err = j.handler.CreateJob(j.config, schedules, permission) - if err != nil { + if err = j.handler.CreateJob(j.config, schedules, permission); err != nil { return err } @@ -83,21 +77,11 @@ func (j *Job) Status() error { return ErrJobCanBeRemovedOnly } - schedules, err := j.handler.ParseSchedules(j.config.Schedules) - if err != nil { + if err := j.handler.DisplaySchedules(j.config.ProfileName, j.config.CommandName, j.config.Schedules); err != nil { return err } - if len(schedules) > 0 { - j.handler.DisplayParsedSchedules(j.config.CommandName, schedules) - } else { - if err := j.handler.DisplaySchedules(j.config.CommandName, j.config.Schedules); err != nil { - return err - } - } - - err = j.handler.DisplayJobStatus(j.config) - if err != nil { + if err := j.handler.DisplayJobStatus(j.config); err != nil { return err } return nil diff --git a/schedule/job_test.go b/schedule/job_test.go index 87d3e182..aba0c6ac 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -1,131 +1,77 @@ -package schedule +package schedule_test import ( "errors" "testing" "github.com/creativeprojects/resticprofile/calendar" - "github.com/stretchr/testify/assert" + "github.com/creativeprojects/resticprofile/schedule" + "github.com/creativeprojects/resticprofile/schedule/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) -func TestCreateJobHappyPathSystemd(t *testing.T) { - counter := 0 - handler := mockHandler{ - t: t, - parseSchedules: func(schedules []string) ([]*calendar.Event, error) { - counter |= 1 - return nil, nil - }, - displaySchedules: func(command string, schedules []string) error { - counter |= 2 - return nil - }, - createJob: func(job *Config, schedules []*calendar.Event, permission string) error { - counter |= 4 - return nil - }, - } - job := Job{ - config: &Config{}, - handler: handler, - } - err := job.Create() - assert.NoError(t, err) +func TestCreateJobHappyPath(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) + handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil) + handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(nil) - assert.Equal(t, 1|2|4, counter) -} + scheduler := schedule.NewScheduler(handler, "profile") + job := scheduler.NewJob(&schedule.Config{ + ProfileName: "profile", + CommandName: "backup", + Schedules: []string{}, + }) -func TestCreateJobHappyPathOther(t *testing.T) { - counter := 0 - handler := mockHandler{ - t: t, - parseSchedules: func(schedules []string) ([]*calendar.Event, error) { - counter |= 1 - return []*calendar.Event{calendar.NewEvent()}, nil - }, - displayParsedSchedules: func(command string, events []*calendar.Event) { - counter |= 2 - }, - createJob: func(job *Config, schedules []*calendar.Event, permission string) error { - counter |= 4 - return nil - }, - } - job := Job{ - config: &Config{}, - handler: handler, - } err := job.Create() - assert.NoError(t, err) - - assert.Equal(t, 1|2|4, counter) + require.NoError(t, err) } -func TestCreateJobSadPath1(t *testing.T) { - counter := 0 - handler := mockHandler{ - t: t, - parseSchedules: func(schedules []string) ([]*calendar.Event, error) { - counter |= 1 - return nil, errors.New("test!") - }, - } - job := Job{ - config: &Config{}, - handler: handler, - } - err := job.Create() - assert.Error(t, err) +func TestCreateJobErrorParseSchedules(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) + handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) - assert.Equal(t, 1, counter) -} + scheduler := schedule.NewScheduler(handler, "profile") + job := scheduler.NewJob(&schedule.Config{ + ProfileName: "profile", + CommandName: "backup", + Schedules: []string{}, + }) -func TestCreateJobSadPath2(t *testing.T) { - counter := 0 - handler := mockHandler{ - t: t, - parseSchedules: func(schedules []string) ([]*calendar.Event, error) { - counter |= 1 - return nil, nil - }, - displaySchedules: func(command string, schedules []string) error { - counter |= 2 - return errors.New("test!") - }, - } - job := Job{ - config: &Config{}, - handler: handler, - } err := job.Create() - assert.Error(t, err) - - assert.Equal(t, 1|2, counter) + require.Error(t, err) } -func TestCreateJobSadPath3(t *testing.T) { - counter := 0 - handler := mockHandler{ - t: t, - parseSchedules: func(schedules []string) ([]*calendar.Event, error) { - counter |= 1 - return nil, nil - }, - displaySchedules: func(command string, schedules []string) error { - counter |= 2 - return nil - }, - createJob: func(job *Config, schedules []*calendar.Event, permission string) error { - counter |= 4 - return errors.New("test!") - }, - } - job := Job{ - config: &Config{}, - handler: handler, - } +func TestCreateJobErrorDisplaySchedules(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) + + scheduler := schedule.NewScheduler(handler, "profile") + job := scheduler.NewJob(&schedule.Config{ + ProfileName: "profile", + CommandName: "backup", + Schedules: []string{}, + }) + err := job.Create() - assert.Error(t, err) + require.Error(t, err) +} - assert.Equal(t, 1|2|4, counter) +func TestCreateJobErrorCreate(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) + handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil) + handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(errors.New("test!")) + + scheduler := schedule.NewScheduler(handler, "profile") + job := scheduler.NewJob(&schedule.Config{ + ProfileName: "profile", + CommandName: "backup", + Schedules: []string{}, + }) + + err := job.Create() + require.Error(t, err) } diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index 37af71ec..0d37f918 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -148,51 +148,17 @@ func (_c *Handler_DisplayJobStatus_Call) RunAndReturn(run func(*schedule.Config) return _c } -// DisplayParsedSchedules provides a mock function with given fields: command, events -func (_m *Handler) DisplayParsedSchedules(command string, events []*calendar.Event) { - _m.Called(command, events) -} - -// Handler_DisplayParsedSchedules_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DisplayParsedSchedules' -type Handler_DisplayParsedSchedules_Call struct { - *mock.Call -} - -// DisplayParsedSchedules is a helper method to define mock.On call -// - command string -// - events []*calendar.Event -func (_e *Handler_Expecter) DisplayParsedSchedules(command interface{}, events interface{}) *Handler_DisplayParsedSchedules_Call { - return &Handler_DisplayParsedSchedules_Call{Call: _e.mock.On("DisplayParsedSchedules", command, events)} -} - -func (_c *Handler_DisplayParsedSchedules_Call) Run(run func(command string, events []*calendar.Event)) *Handler_DisplayParsedSchedules_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].([]*calendar.Event)) - }) - return _c -} - -func (_c *Handler_DisplayParsedSchedules_Call) Return() *Handler_DisplayParsedSchedules_Call { - _c.Call.Return() - return _c -} - -func (_c *Handler_DisplayParsedSchedules_Call) RunAndReturn(run func(string, []*calendar.Event)) *Handler_DisplayParsedSchedules_Call { - _c.Call.Return(run) - return _c -} - -// DisplaySchedules provides a mock function with given fields: command, schedules -func (_m *Handler) DisplaySchedules(command string, schedules []string) error { - ret := _m.Called(command, schedules) +// DisplaySchedules provides a mock function with given fields: profile, command, schedules +func (_m *Handler) DisplaySchedules(profile string, command string, schedules []string) error { + ret := _m.Called(profile, command, schedules) if len(ret) == 0 { panic("no return value specified for DisplaySchedules") } var r0 error - if rf, ok := ret.Get(0).(func(string, []string) error); ok { - r0 = rf(command, schedules) + if rf, ok := ret.Get(0).(func(string, string, []string) error); ok { + r0 = rf(profile, command, schedules) } else { r0 = ret.Error(0) } @@ -206,15 +172,16 @@ type Handler_DisplaySchedules_Call struct { } // DisplaySchedules is a helper method to define mock.On call +// - profile string // - command string // - schedules []string -func (_e *Handler_Expecter) DisplaySchedules(command interface{}, schedules interface{}) *Handler_DisplaySchedules_Call { - return &Handler_DisplaySchedules_Call{Call: _e.mock.On("DisplaySchedules", command, schedules)} +func (_e *Handler_Expecter) DisplaySchedules(profile interface{}, command interface{}, schedules interface{}) *Handler_DisplaySchedules_Call { + return &Handler_DisplaySchedules_Call{Call: _e.mock.On("DisplaySchedules", profile, command, schedules)} } -func (_c *Handler_DisplaySchedules_Call) Run(run func(command string, schedules []string)) *Handler_DisplaySchedules_Call { +func (_c *Handler_DisplaySchedules_Call) Run(run func(profile string, command string, schedules []string)) *Handler_DisplaySchedules_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string), args[1].([]string)) + run(args[0].(string), args[1].(string), args[2].([]string)) }) return _c } @@ -224,7 +191,7 @@ func (_c *Handler_DisplaySchedules_Call) Return(_a0 error) *Handler_DisplaySched return _c } -func (_c *Handler_DisplaySchedules_Call) RunAndReturn(run func(string, []string) error) *Handler_DisplaySchedules_Call { +func (_c *Handler_DisplaySchedules_Call) RunAndReturn(run func(string, string, []string) error) *Handler_DisplaySchedules_Call { _c.Call.Return(run) return _c } diff --git a/schedule/schedules.go b/schedule/schedules.go index 13cc0302..591ca87d 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -2,16 +2,24 @@ package schedule import ( "errors" + "fmt" "os/exec" + "strings" "time" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/term" ) -const ( - displayHeader = "\nAnalyzing %s schedule %d/%d\n=================================\n" -) +func displayHeader(profile, command string, index, total int) { + term.Print(platform.LineSeparator) + header := fmt.Sprintf("Profile (or Group) %s: %s schedule %d/%d", profile, command, index, total) + term.Print(header) + term.Print(platform.LineSeparator) + term.Print(strings.Repeat("=", len(header))) + term.Print(platform.LineSeparator) +} // parseSchedules creates a *calendar.Event from a string func parseSchedules(schedules []string) ([]*calendar.Event, error) { @@ -30,10 +38,10 @@ func parseSchedules(schedules []string) ([]*calendar.Event, error) { return events, nil } -func displayParsedSchedules(command string, events []*calendar.Event) { +func displayParsedSchedules(profile, command string, events []*calendar.Event) { now := time.Now().Round(time.Second) for index, event := range events { - term.Printf(displayHeader, command, index+1, len(events)) + displayHeader(profile, command, index+1, len(events)) next := event.Next(now) term.Printf(" Original form: %s\n", event.Input()) term.Printf("Normalized form: %s\n", event.String()) @@ -44,12 +52,12 @@ func displayParsedSchedules(command string, events []*calendar.Event) { term.Print("\n") } -func displaySystemdSchedules(command string, schedules []string) error { +func displaySystemdSchedules(profile, command string, schedules []string) error { for index, schedule := range schedules { if schedule == "" { return errors.New("empty schedule") } - term.Printf(displayHeader, command, index+1, len(schedules)) + displayHeader(profile, command, index+1, len(schedules)) cmd := exec.Command("systemd-analyze", "calendar", schedule) cmd.Stdout = term.GetOutput() cmd.Stderr = term.GetErrorOutput() diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go index 5d371247..3e30f77e 100644 --- a/schedule/schedules_test.go +++ b/schedule/schedules_test.go @@ -4,31 +4,32 @@ import ( "bytes" "os" "os/exec" - "runtime" "testing" + "github.com/creativeprojects/resticprofile/platform" "github.com/creativeprojects/resticprofile/term" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParseEmptySchedules(t *testing.T) { _, err := parseSchedules([]string{}) - assert.NoError(t, err) + require.NoError(t, err) } func TestParseSchedulesWithEmpty(t *testing.T) { _, err := parseSchedules([]string{""}) - assert.Error(t, err) + require.Error(t, err) } func TestParseSchedulesWithError(t *testing.T) { _, err := parseSchedules([]string{"parse error"}) - assert.Error(t, err) + require.Error(t, err) } func TestParseScheduleDaily(t *testing.T) { events, err := parseSchedules([]string{"daily"}) - assert.NoError(t, err) + require.NoError(t, err) assert.Len(t, events, 1) assert.Equal(t, "daily", events[0].Input()) assert.Equal(t, "*-*-* 00:00:00", events[0].String()) @@ -36,13 +37,13 @@ func TestParseScheduleDaily(t *testing.T) { func TestDisplayParseSchedules(t *testing.T) { events, err := parseSchedules([]string{"daily"}) - assert.NoError(t, err) + require.NoError(t, err) buffer := &bytes.Buffer{} term.SetOutput(buffer) defer term.SetOutput(os.Stdout) - displayParsedSchedules("command", events) + displayParsedSchedules("profile", "command", events) output := buffer.String() assert.Contains(t, output, "Original form: daily\n") assert.Contains(t, output, "Normalized form: *-*-* 00:00:00\n") @@ -50,13 +51,13 @@ func TestDisplayParseSchedules(t *testing.T) { func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { events, err := parseSchedules([]string{"daily", "monthly", "yearly"}) - assert.NoError(t, err) + require.NoError(t, err) buffer := &bytes.Buffer{} term.SetOutput(buffer) defer term.SetOutput(os.Stdout) - displayParsedSchedules("command", events) + displayParsedSchedules("profile", "command", events) output := buffer.String() assert.Contains(t, output, "schedule 1/3") assert.Contains(t, output, "schedule 2/3") @@ -64,8 +65,8 @@ func TestDisplayParseSchedulesIndexAndTotal(t *testing.T) { } func TestDisplaySystemdSchedulesWithEmpty(t *testing.T) { - err := displaySystemdSchedules("command", []string{""}) - assert.Error(t, err) + err := displaySystemdSchedules("profile", "command", []string{""}) + require.Error(t, err) } func TestDisplaySystemdSchedules(t *testing.T) { @@ -78,8 +79,8 @@ func TestDisplaySystemdSchedules(t *testing.T) { term.SetOutput(buffer) defer term.SetOutput(os.Stdout) - err = displaySystemdSchedules("command", []string{"daily"}) - assert.NoError(t, err) + err = displaySystemdSchedules("profile", "command", []string{"daily"}) + require.NoError(t, err) output := buffer.String() assert.Contains(t, output, "Original form: daily") @@ -87,9 +88,9 @@ func TestDisplaySystemdSchedules(t *testing.T) { } func TestDisplaySystemdSchedulesError(t *testing.T) { - if runtime.GOOS != "windows" && runtime.GOOS != "darwin" { - t.Skip() + if platform.IsWindows() || platform.IsDarwin() { + t.Skip("test only runs on linux") } - err := displaySystemdSchedules("command", []string{"daily"}) - assert.Error(t, err) + err := displaySystemdSchedules("profile", "command", []string{"daily"}) + require.Error(t, err) } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index a6e26829..4184e737 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -36,7 +36,7 @@ func TestSimpleScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) - handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), @@ -60,7 +60,7 @@ func TestFailScheduleJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) - handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().CreateJob( mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("[]*calendar.Event"), @@ -179,8 +179,7 @@ func TestStatusJob(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - handler.EXPECT().ParseSchedules([]string{"sched"}).Return([]*calendar.Event{{}}, nil) - handler.EXPECT().DisplayParsedSchedules("backup", []*calendar.Event{{}}) + handler.EXPECT().DisplaySchedules("profile", "backup", []string{"sched"}).Return(nil) handler.EXPECT().DisplayJobStatus(mock.AnythingOfType("*schedule.Config")).Return(nil) handler.EXPECT().DisplayStatus("profile").Return(nil) From 891081cad02ea45b8d44ba46e8eac3bc215edc2b Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 19:56:35 +0000 Subject: [PATCH 02/38] display only if more than one --- schedule/schedules.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/schedule/schedules.go b/schedule/schedules.go index 591ca87d..9abe12cd 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -14,7 +14,10 @@ import ( func displayHeader(profile, command string, index, total int) { term.Print(platform.LineSeparator) - header := fmt.Sprintf("Profile (or Group) %s: %s schedule %d/%d", profile, command, index, total) + header := fmt.Sprintf("Profile (or Group) %s: %s schedule", profile, command) + if total > 1 { + header += fmt.Sprintf(" %d/%d", index, total) + } term.Print(header) term.Print(platform.LineSeparator) term.Print(strings.Repeat("=", len(header))) From e214faa625c6e3feb97b63a2a0b7961311389c2c Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 21:15:09 +0000 Subject: [PATCH 03/38] quick fix for #378 --- calendar/event.go | 1 + calendar/event_test.go | 4 ++-- schedule/schedules.go | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/calendar/event.go b/calendar/event.go index 0360ddd9..63479d67 100644 --- a/calendar/event.go +++ b/calendar/event.go @@ -97,6 +97,7 @@ func (e *Event) Parse(input string) error { func (e *Event) Next(from time.Time) time.Time { // start from time and increment of 1 minute each time next := from.Truncate(time.Minute) // truncate all the seconds + next = next.Add(time.Minute) // it's too late for the current minute // should stop in 2 years time to avoid an infinite loop endYear := from.Year() + 2 for next.Year() <= endYear { diff --git a/calendar/event_test.go b/calendar/event_test.go index 9d505c01..9cd0d97a 100644 --- a/calendar/event_test.go +++ b/calendar/event_test.go @@ -163,10 +163,10 @@ func TestNextTrigger(t *testing.T) { require.NoError(t, err) testData := []struct{ event, trigger string }{ - {"*:*:*", "2006-01-02 15:04:00"}, // seconds are zeroed out + {"*:*:*", "2006-01-02 15:05:00"}, // seconds are zeroed out => take next minute {"03-*", "2006-03-01 00:00:00"}, {"*-01", "2006-02-01 00:00:00"}, - {"*:*:11", "2006-01-02 15:04:00"}, // again, seconds are zeroed out + {"*:*:11", "2006-01-02 15:05:00"}, // again, seconds are zeroed out {"*:11:*", "2006-01-02 15:11:00"}, {"11:*:*", "2006-01-03 11:00:00"}, {"tue", "2006-01-03 00:00:00"}, diff --git a/schedule/schedules.go b/schedule/schedules.go index 9abe12cd..582aeafc 100644 --- a/schedule/schedules.go +++ b/schedule/schedules.go @@ -52,7 +52,7 @@ func displayParsedSchedules(profile, command string, events []*calendar.Event) { term.Printf(" (in UTC): %s\n", next.UTC().Format(time.UnixDate)) term.Printf(" From now: %s left\n", next.Sub(now)) } - term.Print("\n") + term.Print(platform.LineSeparator) } func displaySystemdSchedules(profile, command string, schedules []string) error { @@ -69,6 +69,6 @@ func displaySystemdSchedules(profile, command string, schedules []string) error return err } } - term.Print("\n") + term.Print(platform.LineSeparator) return nil } From a3b4eefdf0a170ec474997f1777a91cef79cabca Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 21:36:33 +0000 Subject: [PATCH 04/38] keep trying to remove jobs --- commands.go | 9 +++++++-- commands_schedule.go | 9 ++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/commands.go b/commands.go index 8527e5d7..afac7f02 100644 --- a/commands.go +++ b/commands.go @@ -11,6 +11,7 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/creativeprojects/clog" @@ -25,6 +26,7 @@ import ( var ( ownCommands = NewOwnCommands() + elevation sync.Once ) func init() { @@ -323,8 +325,11 @@ func retryElevated(err error, flags commandLineFlags) error { } // maybe can find a better way than searching for the word "denied"? if platform.IsWindows() && !flags.isChild && strings.Contains(err.Error(), "denied") { - clog.Info("restarting resticprofile in elevated mode...") - err := elevated() + // we try only once, otherwise we return the original error + elevation.Do(func() { + clog.Info("restarting resticprofile in elevated mode...") + err = elevated() + }) if err != nil { return err } diff --git a/commands_schedule.go b/commands_schedule.go index 81a842b7..3e2b0a4e 100644 --- a/commands_schedule.go +++ b/commands_schedule.go @@ -85,7 +85,11 @@ func removeSchedule(_ io.Writer, ctx commandContext) error { err = removeJobs(schedule.NewHandler(scheduler), profileName, jobs) if err != nil { - return retryElevated(err, flags) + err = retryElevated(err, flags) + } + if err != nil { + // we keep trying to remove the other jobs + clog.Error(err) } } @@ -99,6 +103,7 @@ func statusSchedule(w io.Writer, ctx commandContext) error { defer c.DisplayConfigurationIssues() + // single profile or group if !slices.Contains(args, "--all") { scheduler, schedules, _, err := getScheduleJobs(c, flags) if err != nil { @@ -112,8 +117,10 @@ func statusSchedule(w io.Writer, ctx commandContext) error { if err != nil { return err } + return nil } + // all profiles and groups for _, profileName := range selectProfilesAndGroups(c, flags, args) { profileFlags := flagsForProfile(flags, profileName) scheduler, schedules, schedulable, err := getScheduleJobs(c, profileFlags) From 1160aa1f1819b3a291a9c33919af885d8abb9b62 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 21:43:10 +0000 Subject: [PATCH 05/38] improve message on windows --- schtasks/taskscheduler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 5de4dfb0..1b82b9b5 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -519,7 +519,7 @@ func Delete(title, subtitle string) error { taskName := getTaskPath(title, subtitle) err := taskService.DeleteTask(taskName) if err != nil { - if strings.Contains(err.Error(), "doesn't exist") { + if strings.Contains(err.Error(), "doesn't exist") || strings.Contains(err.Error(), "cannot find") { return fmt.Errorf("%w: %s", ErrNotRegistered, taskName) } return err From faf1c5f6de331482056d5d167882a2bc11048d1d Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 21:49:38 +0000 Subject: [PATCH 06/38] rename errors as schedule job --- schedule/errors.go | 4 ++-- schedule/handler_crond.go | 2 +- schedule/handler_darwin.go | 4 ++-- schedule/handler_systemd.go | 6 +++--- schedule/handler_windows.go | 4 ++-- schedule_jobs.go | 12 ++++++------ schedule_jobs_test.go | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/schedule/errors.go b/schedule/errors.go index c6ed980c..ada5bf8d 100644 --- a/schedule/errors.go +++ b/schedule/errors.go @@ -4,6 +4,6 @@ import "errors" // Generic errors var ( - ErrServiceNotFound = errors.New("service not found") - ErrServiceNotRunning = errors.New("service is not running") + ErrScheduledJobNotFound = errors.New("scheduled job not found") + ErrScheduledJobNotRunning = errors.New("scheduled job is not running") ) diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 571482cd..8b0884be 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -102,7 +102,7 @@ func (h *HandlerCrond) RemoveJob(job *Config, permission string) error { return err } if num == 0 { - return ErrServiceNotFound + return ErrScheduledJobNotFound } return nil } diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 4d9664f1..2884ea68 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -210,7 +210,7 @@ func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error { } if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) { - return ErrServiceNotFound + return ErrScheduledJobNotFound } // stop the service in case it's already running stop := exec.Command(launchctlBin, launchdStop, name) @@ -244,7 +244,7 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { cmd := exec.Command(launchctlBin, launchdList, getJobName(job.ProfileName, job.CommandName)) output, err := cmd.Output() if cmd.ProcessState.ExitCode() == codeServiceNotFound { - return ErrServiceNotFound + return ErrScheduledJobNotFound } if err != nil { return err diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 279a588c..e205a97e 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -265,13 +265,13 @@ func runSystemctlCommand(timerName, command string, unitType systemd.UnitType, s } err := cmd.Run() if command == systemctlStatus && cmd.ProcessState.ExitCode() == codeStatusUnitNotFound { - return ErrServiceNotFound + return ErrScheduledJobNotFound } if command == systemctlStatus && cmd.ProcessState.ExitCode() == codeStatusNotRunning { - return ErrServiceNotRunning + return ErrScheduledJobNotRunning } if command == systemctlStop && cmd.ProcessState.ExitCode() == codeStopUnitNotFound { - return ErrServiceNotFound + return ErrScheduledJobNotFound } return err } diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index c6ea5fe3..b602a6e2 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -74,7 +74,7 @@ func (h *HandlerWindows) RemoveJob(job *Config, permission string) error { err := schtasks.Delete(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrNotRegistered) { - return ErrServiceNotFound + return ErrScheduledJobNotFound } return err } @@ -86,7 +86,7 @@ func (h *HandlerWindows) DisplayJobStatus(job *Config) error { err := schtasks.Status(job.ProfileName, job.CommandName) if err != nil { if errors.Is(err, schtasks.ErrNotRegistered) { - return ErrServiceNotFound + return ErrScheduledJobNotFound } return err } diff --git a/schedule_jobs.go b/schedule_jobs.go index 2ca0440a..60d7a959 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -77,10 +77,10 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config. // Try to remove the job err := job.Remove() if err != nil { - if errors.Is(err, schedule.ErrServiceNotFound) { + if errors.Is(err, schedule.ErrScheduledJobNotFound) { // Display a warning and keep going. Skip message for RemoveOnly jobs since they may not exist if !job.RemoveOnly() { - clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) + clog.Warningf("scheduled job %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) } continue } @@ -108,14 +108,14 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. job := scheduler.NewJob(scheduleConfig) err := job.Status() if err != nil { - if errors.Is(err, schedule.ErrServiceNotFound) { + if errors.Is(err, schedule.ErrScheduledJobNotFound) { // Display a warning and keep going - clog.Warningf("service %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) + clog.Warningf("scheduled job %s/%s not found", scheduleConfig.ProfileName, scheduleConfig.CommandName) continue } - if errors.Is(err, schedule.ErrServiceNotRunning) { + if errors.Is(err, schedule.ErrScheduledJobNotRunning) { // Display a warning and keep going - clog.Warningf("service %s/%s is not running", scheduleConfig.ProfileName, scheduleConfig.CommandName) + clog.Warningf("scheduled job %s/%s is not running", scheduleConfig.ProfileName, scheduleConfig.CommandName) continue } return fmt.Errorf("error querying status of job %s/%s: %w", diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 4184e737..9ef46f5f 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -140,7 +140,7 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). - Return(schedule.ErrServiceNotFound) + Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup", "sched") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) @@ -154,7 +154,7 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() handler.EXPECT().RemoveJob(mock.AnythingOfType("*schedule.Config"), mock.AnythingOfType("string")). - Return(schedule.ErrServiceNotFound) + Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup") err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) From af9f96a4a00c15081246b2dfdb4fe0890c3a4e38 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 28 Oct 2024 22:02:30 +0000 Subject: [PATCH 07/38] don't ask to start the job on darwin --- commands.go | 3 ++- complete_test.go | 14 +++++++------- schedule/handler_darwin.go | 28 +++++++++------------------- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/commands.go b/commands.go index afac7f02..f76d3d6e 100644 --- a/commands.go +++ b/commands.go @@ -101,7 +101,8 @@ func getOwnCommands() []ownCommand { needConfiguration: true, hide: false, flags: map[string]string{ - "--no-start": "don't start the timer/service (systemd/launch only)", + "--no-start": "don't start the job after installing (systemd/launch only)", + "--start": "start the job after installing (systemd/launch only)", "--all": "add all scheduled jobs of all profiles and groups", }, }, diff --git a/complete_test.go b/complete_test.go index 53e1d0d1..cf34264d 100644 --- a/complete_test.go +++ b/complete_test.go @@ -271,15 +271,15 @@ func TestCompleter(t *testing.T) { {args: []string{"self-update", "-q"}, expected: nil}, // Can completion commands after flags - {args: []string{"--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}}, - {args: []string{"--log", "file", "schedule", "-"}, expected: []string{"--all", "--no-start"}}, + {args: []string{"--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}}, + {args: []string{"--log", "file", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}}, // Flags are returned only once {args: []string{"--verb"}, expected: []string{"--verbose"}}, {args: []string{"--verb", "--verb"}, expected: []string{"--verbose"}}, {args: []string{"--verbose", "--verb"}, expected: nil}, - {args: []string{"schedule", "-"}, expected: []string{"--all", "--no-start"}}, - {args: []string{"schedule", "--all", "-"}, expected: []string{"--no-start"}}, + {args: []string{"schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}}, + {args: []string{"schedule", "--all", "-"}, expected: []string{"--no-start", "--start"}}, // Exact command match returns nothing (no duplication) {args: []string{"schedule"}, expected: nil}, @@ -295,9 +295,9 @@ func TestCompleter(t *testing.T) { {args: []string{"__POS:2", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{RequestFileCompletion}}, {args: []string{"__POS:4", "--log", "out.log", "--verbose", "schedule", "-"}, expected: nil}, {args: []string{"__POS:4", "--log", "out.log", "--verbose", "schedule"}, expected: nil}, - {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}}, - {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule"}, expected: []string{"--all", "--no-start"}}, - {args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start"}}, + {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}}, + {args: []string{"__POS:5", "--log", "out.log", "--verbose", "schedule"}, expected: []string{"--all", "--no-start", "--start"}}, + {args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule", "-"}, expected: []string{"--all", "--no-start", "--start"}}, {args: []string{"__POS:INVALID", "--log", "out.log", "--verbose", "schedule"}, expected: nil}, // Unknown is delegated to restic diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 2884ea68..c75e5087 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -117,26 +117,16 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - if _, noStart := job.GetFlag("no-start"); !noStart { - // ask the user if he wants to start the service now + if _, start := job.GetFlag("start"); start { name := getJobName(job.ProfileName, job.CommandName) - message := ` -By default, a macOS agent access is restricted. If you leave it to start in the background it's likely to fail. -You have to start it manually the first time to accept the requests for access: - -%% %s %s %s - -Do you want to start it now?` - answer := term.AskYesNo(os.Stdin, fmt.Sprintf(message, launchctlBin, launchdStart, name), true) - if answer { - // start the service - cmd := exec.Command(launchctlBin, launchdStart, name) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return err - } + + // start the service + cmd := exec.Command(launchctlBin, launchdStart, name) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return err } } return nil From 7d8348bbedf77e7bd84754b39bd761c7a82a219a Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 29 Oct 2024 21:58:32 +0000 Subject: [PATCH 08/38] improve launchd and systemd handlers --- schedule/handler_darwin.go | 208 ++++++++++++++++++++++---------- schedule/handler_darwin_test.go | 58 +++++++++ schedule/handler_systemd.go | 111 ++++++++++++++--- schedule/schedules_test.go | 4 +- schedule/systemd_unit.go | 11 ++ 5 files changed, 310 insertions(+), 82 deletions(-) create mode 100644 schedule/systemd_unit.go diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index c75e5087..093a39c2 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -12,6 +12,7 @@ import ( "strings" "text/tabwriter" + "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/term" @@ -36,7 +37,7 @@ const ( GlobalAgentPath = "/Library/LaunchAgents" GlobalDaemons = "/Library/LaunchDaemons" - namePrefix = "local.resticprofile" + namePrefix = "local.resticprofile." agentExtension = ".agent.plist" daemonExtension = ".plist" @@ -132,65 +133,6 @@ func (h *HandlerLaunchd) CreateJob(job *Config, schedules []*calendar.Event, per return nil } -func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob { - name := getJobName(job.ProfileName, job.CommandName) - // we always set the log file in the job settings as a default - // if changed in the configuration via schedule-log the standard output will be empty anyway - logfile := name + ".log" - - // Format schedule env, adding PATH if not yet provided by the schedule config - env := util.NewDefaultEnvironment(job.Environment...) - if !env.Has("PATH") { - env.Put("PATH", os.Getenv("PATH")) - } - - lowPriorityIO := true - nice := constants.DefaultBackgroundNiceFlag - if job.GetPriority() == constants.SchedulePriorityStandard { - lowPriorityIO = false - nice = constants.DefaultStandardNiceFlag - } - - launchdJob := &LaunchdJob{ - Label: name, - Program: job.Command, - ProgramArguments: append([]string{job.Command, "--no-prio"}, job.Arguments.RawArgs()...), - StandardOutPath: logfile, - StandardErrorPath: logfile, - WorkingDirectory: job.WorkingDirectory, - StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules), - EnvironmentVariables: env.ValuesAsMap(), - Nice: nice, - ProcessType: priorityValues[job.GetPriority()], - LowPriorityIO: lowPriorityIO, - } - return launchdJob -} - -func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) { - filename, err := getFilename(launchdJob.Label, permission) - if err != nil { - return "", err - } - if permission != constants.SchedulePermissionSystem { - // in some very recent installations of macOS, the user's LaunchAgents folder may not exist - _ = h.fs.MkdirAll(path.Dir(filename), 0o700) - } - file, err := h.fs.Create(filename) - if err != nil { - return "", err - } - defer file.Close() - - encoder := plist.NewEncoder(file) - encoder.Indent("\t") - err = encoder.Encode(launchdJob) - if err != nil { - return filename, err - } - return filename, nil -} - // RemoveJob stops and unloads the agent from launchd, then removes the configuration file func (h *HandlerLaunchd) RemoveJob(job *Config, permission string) error { name := getJobName(job.ProfileName, job.CommandName) @@ -260,6 +202,150 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { return nil } +func (h *HandlerLaunchd) Scheduled(profileName string) ([]Config, error) { + jobs := make([]Config, 0) + if profileName == "" { + profileName = "*" + } else { + profileName = strings.ToLower(profileName) + } + // system jobs + prefix := path.Join(GlobalDaemons, namePrefix) + matches, err := afero.Glob(h.fs, fmt.Sprintf("%s%s.*%s", prefix, profileName, daemonExtension)) + if err != nil { + clog.Warningf("Error while listing system jobs: %s", err) + } + for _, match := range matches { + extract := strings.TrimSuffix(strings.TrimPrefix(match, prefix), daemonExtension) + job, err := h.getJobConfig(match, extract) + if err != nil { + clog.Warning(err) + continue + } + if job != nil { + job.Permission = constants.SchedulePermissionSystem + jobs = append(jobs, *job) + } + } + // user jobs + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + prefix = path.Join(home, UserAgentPath, namePrefix) + matches, err = afero.Glob(h.fs, fmt.Sprintf("%s%s.*%s", prefix, profileName, agentExtension)) + if err != nil { + clog.Warningf("Error while listing user jobs: %s", err) + } + for _, match := range matches { + extract := strings.TrimSuffix(strings.TrimPrefix(match, prefix), agentExtension) + job, err := h.getJobConfig(match, extract) + if err != nil { + clog.Warning(err) + continue + } + if job != nil { + job.Permission = constants.SchedulePermissionUser + jobs = append(jobs, *job) + } + } + return jobs, nil +} + +func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) *LaunchdJob { + name := getJobName(job.ProfileName, job.CommandName) + // we always set the log file in the job settings as a default + // if changed in the configuration via schedule-log the standard output will be empty anyway + logfile := name + ".log" + + // Format schedule env, adding PATH if not yet provided by the schedule config + env := util.NewDefaultEnvironment(job.Environment...) + if !env.Has("PATH") { + env.Put("PATH", os.Getenv("PATH")) + } + + lowPriorityIO := true + nice := constants.DefaultBackgroundNiceFlag + if job.GetPriority() == constants.SchedulePriorityStandard { + lowPriorityIO = false + nice = constants.DefaultStandardNiceFlag + } + + launchdJob := &LaunchdJob{ + Label: name, + Program: job.Command, + ProgramArguments: append([]string{job.Command, "--no-prio"}, job.Arguments.RawArgs()...), + StandardOutPath: logfile, + StandardErrorPath: logfile, + WorkingDirectory: job.WorkingDirectory, + StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules), + EnvironmentVariables: env.ValuesAsMap(), + Nice: nice, + ProcessType: priorityValues[job.GetPriority()], + LowPriorityIO: lowPriorityIO, + } + return launchdJob +} + +func (h *HandlerLaunchd) getJobConfig(filename, name string) (*Config, error) { + parts := strings.Split(name, ".") + commandName := parts[len(parts)-1] + profileName := strings.Join(parts[:len(parts)-1], ".") + + launchdJob, err := h.readPlistFile(filename) + if err != nil { + return nil, fmt.Errorf("error reading plist file: %w", err) + } + job := &Config{ + ProfileName: profileName, + CommandName: commandName, + Command: launchdJob.Program, + Arguments: NewCommandArguments(launchdJob.ProgramArguments[2:]), // first is binary, second is --no-prio + WorkingDirectory: launchdJob.WorkingDirectory, + } + return job, nil +} + +func (h *HandlerLaunchd) createPlistFile(launchdJob *LaunchdJob, permission string) (string, error) { + filename, err := getFilename(launchdJob.Label, permission) + if err != nil { + return "", err + } + if permission != constants.SchedulePermissionSystem { + // in some very recent installations of macOS, the user's LaunchAgents folder may not exist + _ = h.fs.MkdirAll(path.Dir(filename), 0o700) + } + file, err := h.fs.Create(filename) + if err != nil { + return "", err + } + defer file.Close() + + encoder := plist.NewEncoder(file) + encoder.Indent("\t") + err = encoder.Encode(launchdJob) + if err != nil { + return filename, err + } + return filename, nil +} + +func (h *HandlerLaunchd) readPlistFile(filename string) (*LaunchdJob, error) { + file, err := h.fs.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + decoder := plist.NewDecoder(file) + launchdJob := new(LaunchdJob) + err = decoder.Decode(launchdJob) + if err != nil { + return nil, err + } + return launchdJob, nil +} + var ( _ Handler = &HandlerLaunchd{} ) @@ -289,7 +375,7 @@ func (c *CalendarInterval) clone() *CalendarInterval { } func getJobName(profileName, command string) string { - return fmt.Sprintf("%s.%s.%s", namePrefix, strings.ToLower(profileName), command) + return fmt.Sprintf("%s%s.%s", namePrefix, strings.ToLower(profileName), command) } func getFilename(name, permission string) (string, error) { diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index c309c42e..247ebf43 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/constants" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -180,3 +181,60 @@ func TestCreateSystemPlist(t *testing.T) { _, err = handler.fs.Stat(filename) assert.NoError(t, err) } + +func TestReadingScheduled(t *testing.T) { + testCases := []struct { + job Config + schedules []*calendar.Event + }{ + { + job: Config{ + ProfileName: "testscheduled", + CommandName: "backup", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), + WorkingDirectory: "/resticprofile", + Permission: constants.SchedulePermissionSystem, + }, + schedules: []*calendar.Event{}, + }, + { + job: Config{ + ProfileName: "test.scheduled", + CommandName: "backup", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}), + WorkingDirectory: "/resticprofile", + Permission: constants.SchedulePermissionSystem, + }, + schedules: []*calendar.Event{}, + }, + { + job: Config{ + ProfileName: "testscheduled", + CommandName: "backup", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), + WorkingDirectory: "/resticprofile", + Permission: constants.SchedulePermissionUser, + }, + schedules: []*calendar.Event{}, + }, + } + + handler := NewHandler(SchedulerLaunchd{}).(*HandlerLaunchd) + handler.fs = afero.NewMemMapFs() + + expectedJobs := []Config{} + for _, testCase := range testCases { + expectedJobs = append(expectedJobs, testCase.job) + + _, err := handler.createPlistFile(handler.getLaunchdJob(&testCase.job, testCase.schedules), testCase.job.Permission) + require.NoError(t, err) + } + + scheduled, err := handler.Scheduled("") + require.NoError(t, err) + + assert.ElementsMatch(t, expectedJobs, scheduled) +} diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index e205a97e..5a2ddf53 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -3,10 +3,13 @@ package schedule import ( + "bytes" + "encoding/json" "fmt" "os" "os/exec" "path" + "slices" "strings" "github.com/creativeprojects/clog" @@ -71,6 +74,12 @@ func (h *HandlerSystemd) DisplaySchedules(profile, command string, schedules []s return displaySystemdSchedules(profile, command, schedules) } +// Timers summary +// =============== +// NEXT LEFT LAST PASSED UNIT ACTIVATES +// Tue 2024-10-29 18:45:00 GMT 28min left Tue 2024-10-29 18:16:09 GMT 38s ago resticprofile-copy@profile-self.timer resticprofile-copy@profile-self.service +// +// 1 timers listed. func (h *HandlerSystemd) DisplayStatus(profileName string) error { var ( status string @@ -83,7 +92,7 @@ func (h *HandlerSystemd) DisplayStatus(profileName string) error { // otherwise user timers status, err = getSystemdStatus(profileName, systemd.UserUnit) } - if err != nil || status == "" || strings.HasPrefix(status, "0 timers") { + if err != nil || status == "" || strings.Contains(status, "0 timers") { // fail silently return nil } @@ -126,15 +135,10 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per return err } - if unitType == systemd.SystemUnit { - // tell systemd we've changed some system configuration files - cmd := exec.Command(systemctlBinary, systemctlReload) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - return err - } + // tell systemd we've changed some system configuration files + err = runSystemctlReload(unitType) + if err != nil { + return err } timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) @@ -167,6 +171,15 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { unitType = systemd.SystemUnit } var err error + serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) + unitLoaded, err := unitLoaded(serviceFile, unitType) + if err != nil { + return err + } + if !unitLoaded { + return ErrScheduledJobNotFound + } + timerFile := systemd.GetTimerFile(job.ProfileName, job.CommandName) // stop the job @@ -189,7 +202,6 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { } } - serviceFile := systemd.GetServiceFile(job.ProfileName, job.CommandName) dropInDir := systemd.GetServiceFileDropInDir(job.ProfileName, job.CommandName) timerDropInDir := systemd.GetTimerFileDropInDir(job.ProfileName, job.CommandName) @@ -205,25 +217,30 @@ func (h *HandlerSystemd) RemoveJob(job *Config, permission string) error { clog.Errorf("failed removing %q, error: %s. Please remove this path", pathToRemove, err.Error()) } } + + // tell systemd we've changed some system configuration files + _ = runSystemctlReload(unitType) return nil } // DisplayJobStatus displays information of a systemd service/timer func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { + serviceName := systemd.GetServiceFile(job.ProfileName, job.CommandName) timerName := systemd.GetTimerFile(job.ProfileName, job.CommandName) permission := getSchedulePermission(job.Permission) + systemdType := systemd.UserUnit if permission == constants.SchedulePermissionSystem { - err := runJournalCtlCommand(timerName, systemd.SystemUnit) - if err != nil { - clog.Warningf("cannot read system logs: %v", err) - } - return runSystemctlCommand(timerName, systemctlStatus, systemd.SystemUnit, false) + systemdType = systemd.SystemUnit } - err := runJournalCtlCommand(timerName, systemd.UserUnit) + unitLoaded, err := unitLoaded(serviceName, systemdType) if err != nil { - clog.Warningf("cannot read user logs: %v", err) + return err } - return runSystemctlCommand(timerName, systemctlStatus, systemd.UserUnit, false) + if !unitLoaded { + return ErrScheduledJobNotFound + } + _ = runJournalCtlCommand(timerName, systemdType) + return runSystemctlCommand(timerName, systemctlStatus, systemdType, false) } var ( @@ -292,6 +309,62 @@ func runJournalCtlCommand(timerName string, unitType systemd.UnitType) error { return err } +func runSystemctlReload(unitType systemd.UnitType) error { + args := []string{systemctlReload} + if unitType == systemd.UserUnit { + args = append(args, flagUserUnit) + } + clog.Debugf("starting command \"%s %s\"", journalctlBinary, strings.Join(args, " ")) + cmd := exec.Command(systemctlBinary, args...) + cmd.Stdout = term.GetOutput() + cmd.Stderr = term.GetErrorOutput() + err := cmd.Run() + if err != nil { + return err + } + return nil +} + +func listUnits(profile string, unitType systemd.UnitType) ([]SystemdUnit, error) { + if profile == "" { + profile = "*" + } + pattern := fmt.Sprintf("resticprofile-*@profile-%s.service", profile) + args := []string{"list-units", "--all", flagNoPager, "--output", "json"} + if unitType == systemd.UserUnit { + args = append(args, flagUserUnit) + } + args = append(args, pattern) + + clog.Debugf("starting command \"%s %s\"", systemctlBinary, strings.Join(args, " ")) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd := exec.Command(systemctlBinary, args...) + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + if err != nil { + return nil, fmt.Errorf("error running command: %w\n%s", err, stderr.String()) + } + var units []SystemdUnit + decoder := json.NewDecoder(stdout) + err = decoder.Decode(&units) + if err != nil { + return nil, fmt.Errorf("error decoding JSON: %w\n%s", err, stdout.String()) + } + return units, err +} + +func unitLoaded(serviceName string, unitType systemd.UnitType) (bool, error) { + units, err := listUnits("", unitType) + if err != nil { + return false, err + } + return slices.ContainsFunc(units, func(unit SystemdUnit) bool { + return unit.Unit == serviceName && unit.Load != "not-found" + }), nil +} + // init registers HandlerSystemd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/schedules_test.go b/schedule/schedules_test.go index 3e30f77e..3277a7ca 100644 --- a/schedule/schedules_test.go +++ b/schedule/schedules_test.go @@ -88,8 +88,8 @@ func TestDisplaySystemdSchedules(t *testing.T) { } func TestDisplaySystemdSchedulesError(t *testing.T) { - if platform.IsWindows() || platform.IsDarwin() { - t.Skip("test only runs on linux") + if !platform.IsWindows() && !platform.IsDarwin() { + t.Skip() } err := displaySystemdSchedules("profile", "command", []string{"daily"}) require.Error(t, err) diff --git a/schedule/systemd_unit.go b/schedule/systemd_unit.go new file mode 100644 index 00000000..54a43ae7 --- /dev/null +++ b/schedule/systemd_unit.go @@ -0,0 +1,11 @@ +//go:build !darwin && !windows + +package schedule + +type SystemdUnit struct { + Unit string `json:"unit"` + Load string `json:"load"` + Active string `json:"active"` + Sub string `json:"sub"` + Description string `json:"description"` +} From 8658345afde54c2280b57366f1182e00e6f902f4 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 30 Oct 2024 22:21:52 +0000 Subject: [PATCH 09/38] add cron entry parser --- crond/crontab.go | 86 ++++++++++++++++++++++++++++++++++++++- crond/crontab_test.go | 68 +++++++++++++++++++++++++++++++ schedule/handler_crond.go | 5 +++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crond/crontab.go b/crond/crontab.go index 03d4924b..ceeafc3b 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,6 +1,7 @@ package crond import ( + "errors" "fmt" "io" "os/user" @@ -9,10 +10,22 @@ import ( ) const ( - startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n" - endMarker = "### end of resticprofile content, please leave this line intact ###\n" + startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n" + endMarker = "### end of resticprofile content, please leave this line intact ###\n" + timeAndConfig = `^(([\d,\/\-\*]+[ \t]?){5})[\t]+(\w+[\t]+)?(cd .+ && )?([^\s]+.+--config[ =]"?([^"\n]+)"? ` + legacy = `[^\n]*--name[ =]([^\s]+)( --.+)? ([a-z]+))$` + runSchedule = `run-schedule ([^\s]+)@([^\s]+))$` ) +var ( + legacyPattern, runSchedulePattern *regexp.Regexp +) + +func init() { + legacyPattern = regexp.MustCompile(timeAndConfig + legacy) + runSchedulePattern = regexp.MustCompile(timeAndConfig + runSchedule) +} + type Crontab struct { file, binary, charset, user string entries []Entry @@ -188,6 +201,25 @@ func (c *Crontab) Remove() (int, error) { return num, err } +func (c *Crontab) GetEntries() ([]Entry, error) { + crontab, err := c.LoadCurrent() + if err != nil { + return nil, err + } + + _, ownSection, _, sectionFound := extractOwnSection(crontab) + if !sectionFound { + return nil, nil + } + + entries, err := parseEntries(ownSection) + if err != nil { + return nil, err + } + + return entries, nil +} + func cleanupCrontab(crontab string) string { // this pattern detects if a header has been added to the output of "crontab -l" pattern := regexp.MustCompile(`^# DO NOT EDIT THIS FILE[^\n]*\n#[^\n]*\n#[^\n]*\n`) @@ -275,3 +307,53 @@ func deleteLine(crontab string, entry Entry) (string, bool, error) { } return crontab, false, nil } + +func parseEntries(crontab string) ([]Entry, error) { + lines := strings.Split(crontab, "\n") + entries := make([]Entry, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if len(line) == 0 || strings.HasPrefix(line, "#") { + continue + } + entry, err := parseEntry(line) + if err != nil { + return nil, err + } + entries = append(entries, *entry) + } + return entries, nil +} + +func parseEntry(line string) (*Entry, error) { + // should match lines like: + // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup + // 00,15,30,45 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup + // or a line like: + // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile + // also with quotes around the config file: + // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config "config.yaml" run-schedule backup@profile + matches := legacyPattern.FindStringSubmatch(line) + if len(matches) == 10 { + return &Entry{ + user: strings.TrimSpace(matches[3]), + workDir: strings.TrimSuffix(strings.TrimPrefix(matches[4], "cd "), " && "), + commandLine: matches[5], + configFile: matches[6], + profileName: matches[7], + commandName: matches[9], + }, nil + } + matches = runSchedulePattern.FindStringSubmatch(line) + if len(matches) == 9 { + return &Entry{ + user: strings.TrimSpace(matches[3]), + workDir: strings.TrimSuffix(strings.TrimPrefix(matches[4], "cd "), " && "), + commandLine: matches[5], + configFile: matches[6], + commandName: matches[7], + profileName: matches[8], + }, nil + } + return nil, errors.New("invalid crontab entry") +} diff --git a/crond/crontab_test.go b/crond/crontab_test.go index 1c36467c..bc9487fb 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -76,6 +76,14 @@ func TestDeleteLine(t *testing.T) { {"#\n#\n#\n00,30 * * * * /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true}, {"#\n#\n#\n00,30 * * * * user /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, {"#\n#\n#\n00,30 * * * * user /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true}, + {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", false}, + {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup\n", true}, + {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", false}, + {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, + {"#\n#\n#\n# 00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", false}, + {"#\n#\n#\n00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true}, + {"#\n#\n#\n00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n", true}, + {"#\n#\n#\n00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config \"config.yaml\" run-schedule backup@profile\n", true}, } for _, testRun := range testData { @@ -344,3 +352,63 @@ func TestUseCrontabBinary(t *testing.T) { assert.NoError(t, crontab.Rewrite()) }) } + +func TestParseEntry(t *testing.T) { + testData := []struct { + source string + expectEntry *Entry + }{ + { + source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, + }, + { + source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, + }, + { + source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * user /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * user /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + }, + { + source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + }, + } + + for _, testRun := range testData { + t.Run("", func(t *testing.T) { + entry, err := parseEntry(testRun.source) + if testRun.expectEntry == nil { + assert.Nil(t, entry) + require.Error(t, err) + } + require.NoError(t, err) + assert.Equal(t, testRun.expectEntry, entry) + }) + } +} diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 8b0884be..7fb64fde 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -112,6 +112,11 @@ func (h *HandlerCrond) DisplayJobStatus(job *Config) error { return nil } +func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { + configs := []Config{} + return configs, nil +} + // init registers HandlerCrond func init() { AddHandlerProvider(func(config SchedulerConfig, fallback bool) (hr Handler) { From 146173c244f2473cca1443fe138be0f11da474d9 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 09:54:34 +0000 Subject: [PATCH 10/38] remove unused fields --- schedule/config.go | 34 ++++++++++++++++------------------ schedule/config_test.go | 30 ++++++++++++++---------------- schedule_jobs.go | 32 +++++++++++++++----------------- 3 files changed, 45 insertions(+), 51 deletions(-) diff --git a/schedule/config.go b/schedule/config.go index 434d6fc6..2479a4d3 100644 --- a/schedule/config.go +++ b/schedule/config.go @@ -8,24 +8,22 @@ import ( // Config contains all information to schedule a profile command type Config struct { - ProfileName string - CommandName string // restic command - Schedules []string - Permission string - WorkingDirectory string - Command string // path to resticprofile executable - Arguments CommandArguments - Environment []string - JobDescription string - TimerDescription string - Priority string // Priority is either "background" or "standard" - ConfigFile string - Flags map[string]string // flags added to the command line - IgnoreOnBattery bool - IgnoreOnBatteryLessThan int - AfterNetworkOnline bool - SystemdDropInFiles []string - removeOnly bool + ProfileName string + CommandName string // restic command + Schedules []string + Permission string + WorkingDirectory string + Command string // path to resticprofile executable + Arguments CommandArguments + Environment []string + JobDescription string + TimerDescription string + Priority string // Priority is either "background" or "standard" + ConfigFile string + Flags map[string]string // flags added to the command line + AfterNetworkOnline bool + SystemdDropInFiles []string + removeOnly bool } // NewRemoveOnlyConfig creates a job config that may be used to call Job.Remove() on a scheduled job diff --git a/schedule/config_test.go b/schedule/config_test.go index b0fc5d6b..ec7f8f6f 100644 --- a/schedule/config_test.go +++ b/schedule/config_test.go @@ -8,22 +8,20 @@ import ( func TestScheduleProperties(t *testing.T) { schedule := Config{ - ProfileName: "profile", - CommandName: "command name", - Schedules: []string{"1", "2", "3"}, - Permission: "admin", - WorkingDirectory: "home", - Command: "command", - Arguments: NewCommandArguments([]string{"1", "2"}), - Environment: []string{"test=dev"}, - JobDescription: "job", - TimerDescription: "timer", - Priority: "", - ConfigFile: "config", - Flags: map[string]string{}, - removeOnly: false, - IgnoreOnBattery: false, - IgnoreOnBatteryLessThan: 0, + ProfileName: "profile", + CommandName: "command name", + Schedules: []string{"1", "2", "3"}, + Permission: "admin", + WorkingDirectory: "home", + Command: "command", + Arguments: NewCommandArguments([]string{"1", "2"}), + Environment: []string{"test=dev"}, + JobDescription: "job", + TimerDescription: "timer", + Priority: "", + ConfigFile: "config", + Flags: map[string]string{}, + removeOnly: false, } assert.Equal(t, "config", schedule.ConfigFile) diff --git a/schedule_jobs.go b/schedule_jobs.go index 60d7a959..3da55fbf 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -135,22 +135,20 @@ func scheduleToConfig(sched *config.Schedule) *schedule.Config { return schedule.NewRemoveOnlyConfig(origin.Name, origin.Command) } return &schedule.Config{ - ProfileName: origin.Name, - CommandName: origin.Command, - Schedules: sched.Schedules, - Permission: sched.Permission, - WorkingDirectory: "", - Command: "", - Arguments: schedule.NewCommandArguments(nil), - Environment: sched.Environment, - JobDescription: "", - TimerDescription: "", - Priority: sched.Priority, - ConfigFile: sched.ConfigFile, - Flags: sched.Flags, - IgnoreOnBattery: sched.IgnoreOnBattery.IsTrue(), - IgnoreOnBatteryLessThan: sched.IgnoreOnBatteryLessThan, - AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(), - SystemdDropInFiles: sched.SystemdDropInFiles, + ProfileName: origin.Name, + CommandName: origin.Command, + Schedules: sched.Schedules, + Permission: sched.Permission, + WorkingDirectory: "", + Command: "", + Arguments: schedule.NewCommandArguments(nil), + Environment: sched.Environment, + JobDescription: "", + TimerDescription: "", + Priority: sched.Priority, + ConfigFile: sched.ConfigFile, + Flags: sched.Flags, + AfterNetworkOnline: sched.AfterNetworkOnline.IsTrue(), + SystemdDropInFiles: sched.SystemdDropInFiles, } } From b53dcb3b891b7e7e3d64ed8fcbeeb055ebbc03f7 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 10:23:25 +0000 Subject: [PATCH 11/38] simplify line parsing --- crond/crontab.go | 44 ++++++++++++++++++++++++------------------- crond/crontab_test.go | 7 +------ 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/crond/crontab.go b/crond/crontab.go index ceeafc3b..c4b231f0 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,7 +1,6 @@ package crond import ( - "errors" "fmt" "io" "os/user" @@ -212,11 +211,7 @@ func (c *Crontab) GetEntries() ([]Entry, error) { return nil, nil } - entries, err := parseEntries(ownSection) - if err != nil { - return nil, err - } - + entries := parseEntries(ownSection) return entries, nil } @@ -308,7 +303,7 @@ func deleteLine(crontab string, entry Entry) (string, bool, error) { return crontab, false, nil } -func parseEntries(crontab string) ([]Entry, error) { +func parseEntries(crontab string) []Entry { lines := strings.Split(crontab, "\n") entries := make([]Entry, 0, len(lines)) for _, line := range lines { @@ -316,16 +311,16 @@ func parseEntries(crontab string) ([]Entry, error) { if len(line) == 0 || strings.HasPrefix(line, "#") { continue } - entry, err := parseEntry(line) - if err != nil { - return nil, err + entry := parseEntry(line) + if entry == nil { + continue } entries = append(entries, *entry) } - return entries, nil + return entries } -func parseEntry(line string) (*Entry, error) { +func parseEntry(line string) *Entry { // should match lines like: // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup // 00,15,30,45 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup @@ -333,27 +328,38 @@ func parseEntry(line string) (*Entry, error) { // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile // also with quotes around the config file: // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config "config.yaml" run-schedule backup@profile + + // try legacy pattern first matches := legacyPattern.FindStringSubmatch(line) if len(matches) == 10 { return &Entry{ - user: strings.TrimSpace(matches[3]), - workDir: strings.TrimSuffix(strings.TrimPrefix(matches[4], "cd "), " && "), + user: getUserValue(matches[3]), + workDir: getWorkdirValue(matches[4]), commandLine: matches[5], configFile: matches[6], profileName: matches[7], commandName: matches[9], - }, nil + } } + // then try the current pattern matches = runSchedulePattern.FindStringSubmatch(line) if len(matches) == 9 { return &Entry{ - user: strings.TrimSpace(matches[3]), - workDir: strings.TrimSuffix(strings.TrimPrefix(matches[4], "cd "), " && "), + user: getUserValue(matches[3]), + workDir: getWorkdirValue(matches[4]), commandLine: matches[5], configFile: matches[6], commandName: matches[7], profileName: matches[8], - }, nil + } } - return nil, errors.New("invalid crontab entry") + return nil +} + +func getUserValue(user string) string { + return strings.TrimSpace(user) +} + +func getWorkdirValue(workdir string) string { + return strings.TrimSuffix(strings.TrimPrefix(workdir, "cd "), " && ") } diff --git a/crond/crontab_test.go b/crond/crontab_test.go index bc9487fb..fb6c9740 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -402,12 +402,7 @@ func TestParseEntry(t *testing.T) { for _, testRun := range testData { t.Run("", func(t *testing.T) { - entry, err := parseEntry(testRun.source) - if testRun.expectEntry == nil { - assert.Nil(t, entry) - require.Error(t, err) - } - require.NoError(t, err) + entry := parseEntry(testRun.source) assert.Equal(t, testRun.expectEntry, entry) }) } From a82c6eaf43435b383a548a5da13dd002d58ed687 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 16:32:54 +0000 Subject: [PATCH 12/38] add Scheduled method on systemd handler --- crond/crontab.go | 21 +++++----- schedule/handler.go | 1 + schedule/handler_systemd.go | 50 ++++++++++++++++++++++ schedule/handler_windows.go | 4 ++ schedule/mocks/Handler.go | 58 ++++++++++++++++++++++++++ systemd/drop_ins.go | 8 +++- systemd/generate.go | 4 +- systemd/read.go | 82 +++++++++++++++++++++++++++++++++++++ systemd/read_test.go | 69 +++++++++++++++++++++++++++++++ 9 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 systemd/read.go create mode 100644 systemd/read_test.go diff --git a/crond/crontab.go b/crond/crontab.go index c4b231f0..c0792733 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -9,22 +9,21 @@ import ( ) const ( - startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n" - endMarker = "### end of resticprofile content, please leave this line intact ###\n" - timeAndConfig = `^(([\d,\/\-\*]+[ \t]?){5})[\t]+(\w+[\t]+)?(cd .+ && )?([^\s]+.+--config[ =]"?([^"\n]+)"? ` - legacy = `[^\n]*--name[ =]([^\s]+)( --.+)? ([a-z]+))$` - runSchedule = `run-schedule ([^\s]+)@([^\s]+))$` + startMarker = "### this content was generated by resticprofile, please leave this line intact ###\n" + endMarker = "### end of resticprofile content, please leave this line intact ###\n" + timeExp = `^(([\d,\/\-\*]+[ \t]?){5})` + userExp = `[\t]+(\w+[\t]+)?` + workDirExp = `(cd .+ && )?` + configExp = `([^\s]+.+--config[ =]"?([^"\n]+)"? ` + legacyExp = `[^\n]*--name[ =]([^\s]+)( --.+)? ([a-z]+))$` + runScheduleExp = `run-schedule ([^\s]+)@([^\s]+))$` ) var ( - legacyPattern, runSchedulePattern *regexp.Regexp + legacyPattern = regexp.MustCompile(timeExp + userExp + workDirExp + configExp + legacyExp) + runSchedulePattern = regexp.MustCompile(timeExp + userExp + workDirExp + configExp + runScheduleExp) ) -func init() { - legacyPattern = regexp.MustCompile(timeAndConfig + legacy) - runSchedulePattern = regexp.MustCompile(timeAndConfig + runSchedule) -} - type Crontab struct { file, binary, charset, user string entries []Entry diff --git a/schedule/handler.go b/schedule/handler.go index 10350c9e..a3f1b1e1 100644 --- a/schedule/handler.go +++ b/schedule/handler.go @@ -17,6 +17,7 @@ type Handler interface { CreateJob(job *Config, schedules []*calendar.Event, permission string) error RemoveJob(job *Config, permission string) error DisplayJobStatus(job *Config) error + Scheduled(profileName string) ([]Config, error) } // FindHandler creates a schedule handler depending on the configuration or nil if the config is not supported diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 5a2ddf53..ee6e484d 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -243,6 +243,27 @@ func (h *HandlerSystemd) DisplayJobStatus(job *Config) error { return runSystemctlCommand(timerName, systemctlStatus, systemdType, false) } +func (h *HandlerSystemd) Scheduled(profileName string) ([]Config, error) { + configs := []Config{} + + cfgs, err := getConfigs(profileName, systemd.SystemUnit) + if err != nil { + clog.Errorf("cannot list system units: %s", err) + } + if len(cfgs) > 0 { + configs = append(configs, cfgs...) + } + + cfgs, err = getConfigs(profileName, systemd.UserUnit) + if err != nil { + clog.Errorf("cannot list user units: %s", err) + } + if len(cfgs) > 0 { + configs = append(configs, cfgs...) + } + return configs, nil +} + var ( _ Handler = &HandlerSystemd{} ) @@ -365,6 +386,35 @@ func unitLoaded(serviceName string, unitType systemd.UnitType) (bool, error) { }), nil } +func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) { + units, err := listUnits(profileName, unitType) + if err != nil { + return nil, err + } + configs := make([]Config, 0, len(units)) + for _, unit := range units { + cfg, err := systemd.Read(unit.Unit, systemd.SystemUnit) + if err != nil { + clog.Errorf("cannot read information from unit %q: %s", unit.Unit, err) + continue + } + if cfg == nil { + continue + } + configs = append(configs, toScheduleConfig(*cfg)) + } + return configs, nil +} + +func toScheduleConfig(systemdConfig systemd.Config) Config { + cfg := Config{ + ProfileName: systemdConfig.Title, + CommandName: systemdConfig.SubTitle, + WorkingDirectory: systemdConfig.WorkingDirectory, + } + return cfg +} + // init registers HandlerSystemd func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index b602a6e2..8b5141d0 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -93,6 +93,10 @@ func (h *HandlerWindows) DisplayJobStatus(job *Config) error { return nil } +func (h *HandlerWindows) Scheduled(profileName string) ([]Config, error) { + return nil, errors.New("not implemented") +} + // init registers HandlerWindows func init() { AddHandlerProvider(func(config SchedulerConfig, _ bool) (hr Handler) { diff --git a/schedule/mocks/Handler.go b/schedule/mocks/Handler.go index 0d37f918..58064d55 100644 --- a/schedule/mocks/Handler.go +++ b/schedule/mocks/Handler.go @@ -392,6 +392,64 @@ func (_c *Handler_RemoveJob_Call) RunAndReturn(run func(*schedule.Config, string return _c } +// Scheduled provides a mock function with given fields: profileName +func (_m *Handler) Scheduled(profileName string) ([]schedule.Config, error) { + ret := _m.Called(profileName) + + if len(ret) == 0 { + panic("no return value specified for Scheduled") + } + + var r0 []schedule.Config + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]schedule.Config, error)); ok { + return rf(profileName) + } + if rf, ok := ret.Get(0).(func(string) []schedule.Config); ok { + r0 = rf(profileName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schedule.Config) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(profileName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Handler_Scheduled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Scheduled' +type Handler_Scheduled_Call struct { + *mock.Call +} + +// Scheduled is a helper method to define mock.On call +// - profileName string +func (_e *Handler_Expecter) Scheduled(profileName interface{}) *Handler_Scheduled_Call { + return &Handler_Scheduled_Call{Call: _e.mock.On("Scheduled", profileName)} +} + +func (_c *Handler_Scheduled_Call) Run(run func(profileName string)) *Handler_Scheduled_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Handler_Scheduled_Call) Return(_a0 []schedule.Config, _a1 error) *Handler_Scheduled_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Handler_Scheduled_Call) RunAndReturn(run func(string) ([]schedule.Config, error)) *Handler_Scheduled_Call { + _c.Call.Return(run) + return _c +} + // NewHandler creates a new instance of Handler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewHandler(t interface { diff --git a/systemd/drop_ins.go b/systemd/drop_ins.go index 35a5d26d..589241b3 100644 --- a/systemd/drop_ins.go +++ b/systemd/drop_ins.go @@ -65,7 +65,7 @@ func CreateDropIns(dir string, files []string) error { _, notOrphaned := fileBasenamesOwned[f.Name()] if createdByUs && !notOrphaned { orphanPath := filepath.Join(dir, f.Name()) - clog.Infof("deleting orphaned drop-in file %v", orphanPath) + clog.Debugf("deleting orphaned drop-in file %v", orphanPath) if err := fs.Remove(orphanPath); err != nil { return err } @@ -78,15 +78,19 @@ func CreateDropIns(dir string, files []string) error { // to signify it wasn't created outside of resticprofile, i.e. we own it dropInFileOwned := getOwnedName(dropInFileBase) dstPath := filepath.Join(dir, dropInFileOwned) - clog.Infof("writing %v", dstPath) + clog.Debugf("writing %v", dstPath) dst, err := fs.Create(dstPath) if err != nil { return err } + defer dst.Close() + src, err := fs.Open(dropInFilePath) if err != nil { return err } + defer src.Close() + if _, err := io.Copy(dst, src); err != nil { return err } diff --git a/systemd/generate.go b/systemd/generate.go index 672b8cb7..d44912da 100644 --- a/systemd/generate.go +++ b/systemd/generate.go @@ -172,7 +172,7 @@ func Generate(config Config) error { return err } filePathName := filepath.Join(systemdUserDir, systemdProfile) - clog.Infof("writing %v", filePathName) + clog.Debugf("writing %v", filePathName) if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil { return err } @@ -190,7 +190,7 @@ func Generate(config Config) error { return err } filePathName = filepath.Join(systemdUserDir, timerProfile) - clog.Infof("writing %v", filePathName) + clog.Debugf("writing %v", filePathName) if err = afero.WriteFile(fs, filePathName, data.Bytes(), defaultPermission); err != nil { return err } diff --git a/systemd/read.go b/systemd/read.go new file mode 100644 index 00000000..d187973f --- /dev/null +++ b/systemd/read.go @@ -0,0 +1,82 @@ +package systemd + +import ( + "bytes" + "path" + "strings" + + "github.com/spf13/afero" +) + +func Read(unit string, unitType UnitType) (*Config, error) { + var err error + unitsDir := systemdSystemDir + if unitType == UserUnit { + unitsDir, err = GetUserDir() + if err != nil { + return nil, err + } + } + content, err := afero.ReadFile(fs, path.Join(unitsDir, unit)) + if err != nil { + return nil, err + } + currentSection := "" + sections := make(map[string][]string, 3) + lines := bytes.Split(content, []byte("\n")) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if bytes.HasPrefix(line, []byte("[")) && bytes.HasSuffix(line, []byte("]")) { + // start of a section + currentSection = string(bytes.TrimSpace(line[1 : len(line)-1])) + continue + } + if sections[currentSection] == nil { + sections[currentSection] = []string{string(line)} + } else { + sections[currentSection] = append(sections[currentSection], string(line)) + } + } + unitSection, serviceSection := sections["Unit"], sections["Service"] + description := getValue(unitSection, "Description") + workdir := getValue(serviceSection, "WorkingDirectory") + commandLine := getValue(serviceSection, "ExecStart") + profileName, commandName := parseServiceFile(unit) + cfg := &Config{ + Title: profileName, + SubTitle: commandName, + JobDescription: description, + WorkingDirectory: workdir, + CommandLine: commandLine, + UnitType: unitType, + } + return cfg, nil +} + +func getValue(lines []string, key string) string { + if len(lines) == 0 { + return "" + } + for _, line := range lines { + if k, v, found := strings.Cut(line, "="); found { + k = strings.TrimSpace(k) + if k == key { + return strings.TrimSpace(v) + } + } + } + return "" +} + +// parseServiceFile to detect profile and command names. +// format is: `resticprofile-backup@profile-name.service` +func parseServiceFile(filename string) (profileName, commandName string) { + filename = strings.TrimPrefix(filename, "resticprofile-") + filename = strings.TrimSuffix(filename, ".service") + commandName, profileName, _ = strings.Cut(filename, "@") + profileName = strings.TrimPrefix(profileName, "profile-") + return +} diff --git a/systemd/read_test.go b/systemd/read_test.go new file mode 100644 index 00000000..9d23745d --- /dev/null +++ b/systemd/read_test.go @@ -0,0 +1,69 @@ +package systemd + +import ( + "fmt" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadSystemUnit(t *testing.T) { + testCases := []struct { + config Config + }{ + { + config: Config{ + CommandLine: "/bin/resticprofile --config profiles.yaml run-schedule backup@profile1", + WorkingDirectory: "/workdir", + Title: "profile1", + SubTitle: "backup", + JobDescription: "job description", + TimerDescription: "timer description", + Schedules: []string{"daily"}, + UnitType: SystemUnit, + Priority: "low", + }, + }, + { + config: Config{ + CommandLine: "/bin/resticprofile --no-ansi --config profiles.yaml run-schedule check@profile2", + WorkingDirectory: "/workdir", + Title: "profile2", + SubTitle: "check", + JobDescription: "job description", + TimerDescription: "timer description", + Schedules: []string{"weekly"}, + UnitType: UserUnit, + Priority: "low", + }, + }, + } + + fs = afero.NewMemMapFs() + + for _, tc := range testCases { + t.Run("", func(t *testing.T) { + baseUnit := fmt.Sprintf("resticprofile-%s@profile-%s", tc.config.SubTitle, tc.config.Title) + serviceFile := baseUnit + ".service" + + err := Generate(tc.config) + require.NoError(t, err) + + readCfg, err := Read(serviceFile, tc.config.UnitType) + require.NoError(t, err) + assert.NotNil(t, readCfg) + + expected := &Config{ + Title: tc.config.Title, + SubTitle: tc.config.SubTitle, + JobDescription: tc.config.JobDescription, + WorkingDirectory: tc.config.WorkingDirectory, + CommandLine: tc.config.CommandLine, + UnitType: tc.config.UnitType, + } + assert.Equal(t, expected, readCfg) + }) + } +} From 3cc7fc7be925d1c1002e416c612e99849d230a94 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 17:21:24 +0000 Subject: [PATCH 13/38] run tests only on os supporting systemd --- systemd/read.go | 2 ++ systemd/read_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/systemd/read.go b/systemd/read.go index d187973f..dec56054 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -1,3 +1,5 @@ +//go:build !darwin && !windows + package systemd import ( diff --git a/systemd/read_test.go b/systemd/read_test.go index 9d23745d..9bd4ce30 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -1,3 +1,5 @@ +//go:build !darwin && !windows + package systemd import ( From 28b310945643dcb6f4962830aefd3cbf6a27e9b8 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 22:51:15 +0000 Subject: [PATCH 14/38] wire up Scheduled method in crond handler --- crond/crontab.go | 9 +++ crond/crontab_test.go | 1 + crond/entry.go | 22 +++++++ schedule/handler_crond.go | 64 ++++++++++++++++-- schedule/handler_crond_test.go | 112 ++++++++++++++++++++++++++++++++ schedule/handler_darwin_test.go | 2 +- 6 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 schedule/handler_crond_test.go diff --git a/crond/crontab.go b/crond/crontab.go index c0792733..a3c5a8aa 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -6,6 +6,8 @@ import ( "os/user" "regexp" "strings" + + "github.com/creativeprojects/resticprofile/calendar" ) const ( @@ -332,6 +334,7 @@ func parseEntry(line string) *Entry { matches := legacyPattern.FindStringSubmatch(line) if len(matches) == 10 { return &Entry{ + event: parseEvent(matches[1]), user: getUserValue(matches[3]), workDir: getWorkdirValue(matches[4]), commandLine: matches[5], @@ -344,6 +347,7 @@ func parseEntry(line string) *Entry { matches = runSchedulePattern.FindStringSubmatch(line) if len(matches) == 9 { return &Entry{ + event: parseEvent(matches[1]), user: getUserValue(matches[3]), workDir: getWorkdirValue(matches[4]), commandLine: matches[5], @@ -355,6 +359,11 @@ func parseEntry(line string) *Entry { return nil } +func parseEvent(_ string) *calendar.Event { + event := calendar.NewEvent() + return event +} + func getUserValue(user string) string { return strings.TrimSpace(user) } diff --git a/crond/crontab_test.go b/crond/crontab_test.go index fb6c9740..9f7883dd 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -403,6 +403,7 @@ func TestParseEntry(t *testing.T) { for _, testRun := range testData { t.Run("", func(t *testing.T) { entry := parseEntry(testRun.source) + testRun.expectEntry.event = calendar.NewEvent() assert.Equal(t, testRun.expectEntry, entry) }) } diff --git a/crond/entry.go b/crond/entry.go index a75d7737..0e98fc5d 100644 --- a/crond/entry.go +++ b/crond/entry.go @@ -78,6 +78,28 @@ func (e Entry) Generate(w io.StringWriter) error { return err } +func (e Entry) Event() *calendar.Event { + return e.event +} +func (e Entry) ConfigFile() string { + return e.configFile +} +func (e Entry) ProfileName() string { + return e.profileName +} +func (e Entry) CommandName() string { + return e.commandName +} +func (e Entry) CommandLine() string { + return e.commandLine +} +func (e Entry) WorkDir() string { + return e.workDir +} +func (e Entry) User() string { + return e.user +} + func formatWeekDay(weekDay int) string { if weekDay >= 7 { weekDay -= 7 diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 7fb64fde..eb08c14b 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -1,10 +1,14 @@ package schedule import ( + "slices" + "strings" + "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" "github.com/creativeprojects/resticprofile/platform" + "github.com/spf13/afero" ) var crontabBinary = "crontab" @@ -12,6 +16,7 @@ var crontabBinary = "crontab" // HandlerCrond is a handler for crond scheduling type HandlerCrond struct { config SchedulerCrond + fs afero.Fs } // NewHandlerCrond creates a new handler for crond scheduling @@ -113,17 +118,68 @@ func (h *HandlerCrond) DisplayJobStatus(job *Config) error { } func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { - configs := []Config{} + crontab := crond.NewCrontab(nil) + crontab.SetFile(h.config.CrontabFile) + crontab.SetBinary(h.config.CrontabBinary) + entries, err := crontab.GetEntries() + if err != nil { + return nil, err + } + configs := make([]Config, 0, len(entries)) + for _, entry := range entries { + profileName := entry.ProfileName() + commandName := entry.CommandName() + configFile := entry.ConfigFile() + if index := slices.IndexFunc(configs, func(cfg Config) bool { + return cfg.ProfileName == profileName && cfg.CommandName == commandName && cfg.ConfigFile == configFile + }); index >= 0 { + configs[index].Schedules = append(configs[index].Schedules, entry.Event().String()) + } else { + commandLine := entry.CommandLine() + args := splitArguments(commandLine) + configs = append(configs, Config{ + ProfileName: profileName, + CommandName: commandName, + ConfigFile: configFile, + Schedules: []string{entry.Event().String()}, + Command: args[0], + Arguments: NewCommandArguments(args[1:]), + WorkingDirectory: entry.WorkDir(), + }) + } + } return configs, nil } +func splitArguments(commandLine string) []string { + args := make([]string, 0) + sb := &strings.Builder{} + quoted := false + for _, r := range commandLine { + if r == '"' { + quoted = !quoted + } else if !quoted && r == ' ' { + args = append(args, sb.String()) + sb.Reset() + } else { + sb.WriteRune(r) + } + } + if sb.Len() > 0 { + args = append(args, sb.String()) + } + return args +} + // init registers HandlerCrond func init() { - AddHandlerProvider(func(config SchedulerConfig, fallback bool) (hr Handler) { + AddHandlerProvider(func(config SchedulerConfig, fallback bool) Handler { if config.Type() == constants.SchedulerCrond || (fallback && config.Type() == constants.SchedulerOSDefault) { - hr = NewHandlerCrond(config.Convert(constants.SchedulerCrond)) + handler := NewHandlerCrond(config.Convert(constants.SchedulerCrond)) + handler.fs = afero.NewOsFs() + return handler } - return + return nil }) } diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go new file mode 100644 index 00000000..b8aed4ca --- /dev/null +++ b/schedule/handler_crond_test.go @@ -0,0 +1,112 @@ +package schedule + +import ( + "path/filepath" + "testing" + + "github.com/creativeprojects/resticprofile/calendar" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadingCrondScheduled(t *testing.T) { + hourly := calendar.NewEvent(func(e *calendar.Event) { + e.Minute.MustAddValue(0) + e.Second.MustAddValue(0) + }) + + testCases := []struct { + job Config + schedules []*calendar.Event + }{ + { + job: Config{ + ProfileName: "self", + CommandName: "check", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), + WorkingDirectory: "/resticprofile", + Schedules: []string{"*-*-* *:*:*"}, // no parsing of crontab schedules + ConfigFile: "examples/dev.yaml", + }, + schedules: []*calendar.Event{ + hourly, + }, + }, + { + job: Config{ + ProfileName: "test.scheduled", + CommandName: "backup", + Command: "/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "test.scheduled", "backup"}), + WorkingDirectory: "/resticprofile", + Schedules: []string{"*-*-* *:*:*"}, // no parsing of crontab schedules + ConfigFile: "config file.yaml", + }, + schedules: []*calendar.Event{ + hourly, + }, + }, + } + + tempFile := filepath.Join(t.TempDir(), "crontab") + handler := NewHandler(SchedulerCrond{ + CrontabFile: tempFile, + }).(*HandlerCrond) + handler.fs = afero.NewMemMapFs() + + expectedJobs := []Config{} + for _, testCase := range testCases { + expectedJobs = append(expectedJobs, testCase.job) + + err := handler.CreateJob(&testCase.job, testCase.schedules, testCase.job.Permission) + require.NoError(t, err) + } + + scheduled, err := handler.Scheduled("") + require.NoError(t, err) + + assert.ElementsMatch(t, expectedJobs, scheduled) +} + +func TestSplitArguments(t *testing.T) { + testCases := []struct { + commandLine string + expectedArgs []string + }{ + { + commandLine: `cmd arg1 arg2`, + expectedArgs: []string{"cmd", "arg1", "arg2"}, + }, + { + commandLine: `cmd "arg with spaces" arg3`, + expectedArgs: []string{"cmd", "arg with spaces", "arg3"}, + }, + { + commandLine: `cmd "arg with spaces" "another arg"`, + expectedArgs: []string{"cmd", "arg with spaces", "another arg"}, + }, + { + commandLine: `cmd "arg with spaces"`, + expectedArgs: []string{"cmd", "arg with spaces"}, + }, + { + commandLine: `cmd`, + expectedArgs: []string{"cmd"}, + }, + { + commandLine: `"cmd file"`, + expectedArgs: []string{"cmd file"}, + }, + { + commandLine: `"cmd file" arg`, + expectedArgs: []string{"cmd file", "arg"}, + }, + } + + for _, testCase := range testCases { + args := splitArguments(testCase.commandLine) + assert.Equal(t, testCase.expectedArgs, args) + } +} diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 247ebf43..958926b4 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -182,7 +182,7 @@ func TestCreateSystemPlist(t *testing.T) { assert.NoError(t, err) } -func TestReadingScheduled(t *testing.T) { +func TestReadingLaunchdScheduled(t *testing.T) { testCases := []struct { job Config schedules []*calendar.Event From b3c4643a30836db7bc673f3d974c87698df6f996 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 31 Oct 2024 18:16:48 +0000 Subject: [PATCH 15/38] read env variables from systemd unit --- systemd/read.go | 55 +++++++++++++++++++++++++++----------------- systemd/read_test.go | 39 +++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/systemd/read.go b/systemd/read.go index dec56054..a2e68116 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -24,7 +24,7 @@ func Read(unit string, unitType UnitType) (*Config, error) { return nil, err } currentSection := "" - sections := make(map[string][]string, 3) + sections := make(map[string]map[string][]string, 3) lines := bytes.Split(content, []byte("\n")) for _, line := range lines { line = bytes.TrimSpace(line) @@ -36,17 +36,24 @@ func Read(unit string, unitType UnitType) (*Config, error) { currentSection = string(bytes.TrimSpace(line[1 : len(line)-1])) continue } - if sections[currentSection] == nil { - sections[currentSection] = []string{string(line)} - } else { - sections[currentSection] = append(sections[currentSection], string(line)) + if key, value, found := strings.Cut(string(line), "="); found { + value = strings.Trim(value, `"`) + if sections[currentSection] == nil { + sections[currentSection] = map[string][]string{ + key: {value}, + } + } else if sections[currentSection][key] == nil { + sections[currentSection][key] = []string{value} + } else { + sections[currentSection][key] = append(sections[currentSection][key], value) + } } } - unitSection, serviceSection := sections["Unit"], sections["Service"] - description := getValue(unitSection, "Description") - workdir := getValue(serviceSection, "WorkingDirectory") - commandLine := getValue(serviceSection, "ExecStart") - profileName, commandName := parseServiceFile(unit) + description := getSingleValue(sections, "Unit", "Description") + workdir := getSingleValue(sections, "Service", "WorkingDirectory") + commandLine := getSingleValue(sections, "Service", "ExecStart") + environment := getValues(sections, "Service", "Environment") + profileName, commandName := parseServiceFileName(unit) cfg := &Config{ Title: profileName, SubTitle: commandName, @@ -54,28 +61,34 @@ func Read(unit string, unitType UnitType) (*Config, error) { WorkingDirectory: workdir, CommandLine: commandLine, UnitType: unitType, + Environment: environment, } return cfg, nil } -func getValue(lines []string, key string) string { - if len(lines) == 0 { - return "" - } - for _, line := range lines { - if k, v, found := strings.Cut(line, "="); found { - k = strings.TrimSpace(k) - if k == key { - return strings.TrimSpace(v) +func getSingleValue(from map[string]map[string][]string, section, key string) string { + if section, found := from[section]; found { + if values, found := section[key]; found { + if len(values) > 0 { + return values[0] } } } return "" } -// parseServiceFile to detect profile and command names. +func getValues(from map[string]map[string][]string, section, key string) []string { + if section, found := from[section]; found { + if values, found := section[key]; found { + return values + } + } + return nil +} + +// parseServiceFileName to detect profile and command names from the file name. // format is: `resticprofile-backup@profile-name.service` -func parseServiceFile(filename string) (profileName, commandName string) { +func parseServiceFileName(filename string) (profileName, commandName string) { filename = strings.TrimPrefix(filename, "resticprofile-") filename = strings.TrimSuffix(filename, ".service") commandName, profileName, _ = strings.Cut(filename, "@") diff --git a/systemd/read_test.go b/systemd/read_test.go index 9bd4ce30..9c945023 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -4,6 +4,7 @@ package systemd import ( "fmt" + "os" "testing" "github.com/spf13/afero" @@ -11,6 +12,33 @@ import ( "github.com/stretchr/testify/require" ) +const ( + testServiceUnit = `[Unit] +Description=resticprofile copy for profile self in examples/linux.yaml +OnFailure=unit-status-mail@%n.service + +[Service] +Type=notify +WorkingDirectory=/home/linux/go/src/github.com/creativeprojects/resticprofile +ExecStart=/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self +Nice=19 +IOSchedulingClass=3 +IOSchedulingPriority=7 +Environment="RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self" +Environment="HOME=/home/linux" +` + testTimerUnit = `[Unit] +Description=copy timer for profile self in examples/linux.yaml + +[Timer] +OnCalendar=*:45 +Unit=resticprofile-copy@profile-self.service +Persistent=true + +[Install] +WantedBy=timers.target` +) + func TestReadSystemUnit(t *testing.T) { testCases := []struct { config Config @@ -34,11 +62,14 @@ func TestReadSystemUnit(t *testing.T) { WorkingDirectory: "/workdir", Title: "profile2", SubTitle: "check", - JobDescription: "job description", + JobDescription: "", TimerDescription: "timer description", - Schedules: []string{"weekly"}, + Schedules: []string{"daily", "weekly"}, UnitType: UserUnit, Priority: "low", + Environment: []string{ + "TMP=/tmp", + }, }, }, } @@ -57,6 +88,9 @@ func TestReadSystemUnit(t *testing.T) { require.NoError(t, err) assert.NotNil(t, readCfg) + home, err := os.UserHomeDir() + require.NoError(t, err) + expected := &Config{ Title: tc.config.Title, SubTitle: tc.config.SubTitle, @@ -64,6 +98,7 @@ func TestReadSystemUnit(t *testing.T) { WorkingDirectory: tc.config.WorkingDirectory, CommandLine: tc.config.CommandLine, UnitType: tc.config.UnitType, + Environment: append(tc.config.Environment, "HOME="+home), } assert.Equal(t, expected, readCfg) }) From 8394100a57d74e7c2edb2217310ccb1b3c8bbb60 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 17:16:05 +0000 Subject: [PATCH 16/38] read registered tasks --- schtasks/taskscheduler.go | 39 +++++++++++++++++++++++++ schtasks/taskscheduler_test.go | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/schtasks/taskscheduler.go b/schtasks/taskscheduler.go index 1b82b9b5..99a2e6e5 100644 --- a/schtasks/taskscheduler.go +++ b/schtasks/taskscheduler.go @@ -562,6 +562,45 @@ func getTaskPath(profileName, commandName string) string { return fmt.Sprintf("%s%s %s", tasksPath, profileName, commandName) } +func Registered() ([]Config, error) { + if !IsConnected() { + return nil, ErrNotConnected + } + + tasks, err := taskService.GetRegisteredTasks() + if err != nil { + return nil, err + } + configs := make([]Config, 0, len(tasks)) + for _, task := range tasks { + if !strings.HasPrefix(task.Path, tasksPath) { + continue + } + taskPath := strings.TrimPrefix(task.Path, tasksPath) + parts := strings.Split(taskPath, " ") + if len(parts) < 2 { + clog.Warningf("cannot parse task path: %s", task.Path) + continue + } + profileName := strings.Join(parts[:len(parts)-1], " ") + commandName := parts[len(parts)-1] + cfg := Config{ + ProfileName: profileName, + CommandName: commandName, + JobDescription: task.Definition.RegistrationInfo.Description, + } + if len(task.Definition.Actions) > 0 { + if action, ok := task.Definition.Actions[0].(taskmaster.ExecAction); ok { + cfg.WorkingDirectory = action.WorkingDir + cfg.Command = action.Path + cfg.Arguments = action.Args + } + } + configs = append(configs, cfg) + } + return configs, nil +} + // compileDifferences is creating two slices: the first one is the duration between each trigger, // the second one is a list of all the differences in between // diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index 0bedfcb9..514f5234 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -140,6 +140,8 @@ func TestTaskSchedulerConversion(t *testing.T) { task := taskmaster.Definition{} createSchedules(&task, schedules) + require.Len(t, task.Triggers, 5) + // 1st task should be a single event singleEvent, ok := task.Triggers[0].(taskmaster.TimeTrigger) require.True(t, ok) @@ -390,3 +392,54 @@ func exportTask(taskName string) (string, error) { err := cmd.Run() return buffer.String(), err } + +func TestRegisteredTasks(t *testing.T) { + tasks := []Config{ + { + ProfileName: "test1", + CommandName: "backup", + Command: "echo", + Arguments: "hello there", + WorkingDirectory: "C:\\", + JobDescription: "test1", + }, + { + ProfileName: "test 2", + CommandName: "check", + Command: "echo", + Arguments: "hello there", + WorkingDirectory: "C:\\", + JobDescription: "test 2", + }, + { + ProfileName: "test 3", + CommandName: "forget", + Command: "echo", + Arguments: "hello there", + WorkingDirectory: "C:\\", + JobDescription: "test 3", + }, + } + err := Connect() + defer Close() + assert.NoError(t, err) + + event := calendar.NewEvent() + err = event.Parse("2020-01-02 03:04") // will never get triggered + require.NoError(t, err) + + for _, task := range tasks { + // user logged in doesn't need a password + err = createUserLoggedOnTask(&task, []*calendar.Event{event}) + assert.NoError(t, err) + + defer func() { + _ = Delete(task.ProfileName, task.CommandName) + }() + } + + registeredTasks, err := Registered() + assert.NoError(t, err) + + assert.ElementsMatch(t, tasks, registeredTasks) +} From 066d99f0b5f6f313aa86377ea2c37094a6a11eae Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 17:42:01 +0000 Subject: [PATCH 17/38] fix interval test --- calendar/event.go | 4 +++- schtasks/taskscheduler_test.go | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/calendar/event.go b/calendar/event.go index 63479d67..cbfb0b9b 100644 --- a/calendar/event.go +++ b/calendar/event.go @@ -97,7 +97,9 @@ func (e *Event) Parse(input string) error { func (e *Event) Next(from time.Time) time.Time { // start from time and increment of 1 minute each time next := from.Truncate(time.Minute) // truncate all the seconds - next = next.Add(time.Minute) // it's too late for the current minute + if from.Second() > 0 { + next = next.Add(time.Minute) // it's too late for the current minute + } // should stop in 2 years time to avoid an infinite loop endYear := from.Year() + 2 for next.Year() <= endYear { diff --git a/schtasks/taskscheduler_test.go b/schtasks/taskscheduler_test.go index 514f5234..bd247189 100644 --- a/schtasks/taskscheduler_test.go +++ b/schtasks/taskscheduler_test.go @@ -114,8 +114,8 @@ func TestCompileDifferences(t *testing.T) { require.NoError(t, err) start := event.Next(ref) diff, uniques := compileDifferences(event.GetAllInBetween(start, start.Add(24*time.Hour))) - assert.ElementsMatch(t, testItem.differences, diff) - assert.ElementsMatch(t, testItem.unique, uniques) + assert.ElementsMatch(t, testItem.differences, diff, "duration between triggers") + assert.ElementsMatch(t, testItem.unique, uniques, "unique set of durations between triggers") } } From 2694b94bcf3b84e439ce1fe9312bb6d78f19feb0 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 18:15:43 +0000 Subject: [PATCH 18/38] add scheduled for windows --- calendar/event_test.go | 27 +++++++++++------- schedule/handler_crond.go | 24 ++-------------- schedule/handler_crond_test.go | 41 --------------------------- schedule/handler_windows.go | 20 ++++++++++++- shell/split_arguments.go | 27 ++++++++++++++++++ shell/split_arguments_test.go | 52 ++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 74 deletions(-) create mode 100644 shell/split_arguments.go create mode 100644 shell/split_arguments_test.go diff --git a/calendar/event_test.go b/calendar/event_test.go index 9cd0d97a..d472c10f 100644 --- a/calendar/event_test.go +++ b/calendar/event_test.go @@ -161,16 +161,23 @@ func TestNextTrigger(t *testing.T) { // the base time is the example in the Go documentation https://golang.org/pkg/time/ ref, err := time.Parse(time.ANSIC, "Mon Jan 2 15:04:05 2006") require.NoError(t, err) + refNoSecond, err := time.Parse(time.ANSIC, "Mon Jan 2 15:04:00 2006") + require.NoError(t, err) - testData := []struct{ event, trigger string }{ - {"*:*:*", "2006-01-02 15:05:00"}, // seconds are zeroed out => take next minute - {"03-*", "2006-03-01 00:00:00"}, - {"*-01", "2006-02-01 00:00:00"}, - {"*:*:11", "2006-01-02 15:05:00"}, // again, seconds are zeroed out - {"*:11:*", "2006-01-02 15:11:00"}, - {"11:*:*", "2006-01-03 11:00:00"}, - {"tue", "2006-01-03 00:00:00"}, - {"2003-*-*", "0001-01-01 00:00:00"}, + testData := []struct { + event, trigger string + ref time.Time + }{ + {"*:*:*", "2006-01-02 15:04:00", refNoSecond}, // at the exact same second + {"*:*:*", "2006-01-02 15:05:00", ref}, // seconds are zeroed out => take next minute + {"03-*", "2006-03-01 00:00:00", ref}, + {"*-01", "2006-02-01 00:00:00", ref}, + {"*:*:11", "2006-01-02 15:04:00", refNoSecond}, // at the exact same second + {"*:*:11", "2006-01-02 15:05:00", ref}, // seconds are zeroed out => take next minute + {"*:11:*", "2006-01-02 15:11:00", ref}, + {"11:*:*", "2006-01-03 11:00:00", ref}, + {"tue", "2006-01-03 00:00:00", ref}, + {"2003-*-*", "0001-01-01 00:00:00", ref}, } for _, testItem := range testData { @@ -178,7 +185,7 @@ func TestNextTrigger(t *testing.T) { event := NewEvent() err = event.Parse(testItem.event) assert.NoError(t, err) - assert.Equal(t, testItem.trigger, event.Next(ref).String()[0:len(testItem.trigger)]) + assert.Equal(t, testItem.trigger, event.Next(testItem.ref).String()[0:len(testItem.trigger)]) }) } } diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index eb08c14b..86262296 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -2,12 +2,12 @@ package schedule import ( "slices" - "strings" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/crond" "github.com/creativeprojects/resticprofile/platform" + "github.com/creativeprojects/resticprofile/shell" "github.com/spf13/afero" ) @@ -136,7 +136,7 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { configs[index].Schedules = append(configs[index].Schedules, entry.Event().String()) } else { commandLine := entry.CommandLine() - args := splitArguments(commandLine) + args := shell.SplitArguments(commandLine) configs = append(configs, Config{ ProfileName: profileName, CommandName: commandName, @@ -151,26 +151,6 @@ func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { return configs, nil } -func splitArguments(commandLine string) []string { - args := make([]string, 0) - sb := &strings.Builder{} - quoted := false - for _, r := range commandLine { - if r == '"' { - quoted = !quoted - } else if !quoted && r == ' ' { - args = append(args, sb.String()) - sb.Reset() - } else { - sb.WriteRune(r) - } - } - if sb.Len() > 0 { - args = append(args, sb.String()) - } - return args -} - // init registers HandlerCrond func init() { AddHandlerProvider(func(config SchedulerConfig, fallback bool) Handler { diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index b8aed4ca..4a87f93b 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -69,44 +69,3 @@ func TestReadingCrondScheduled(t *testing.T) { assert.ElementsMatch(t, expectedJobs, scheduled) } - -func TestSplitArguments(t *testing.T) { - testCases := []struct { - commandLine string - expectedArgs []string - }{ - { - commandLine: `cmd arg1 arg2`, - expectedArgs: []string{"cmd", "arg1", "arg2"}, - }, - { - commandLine: `cmd "arg with spaces" arg3`, - expectedArgs: []string{"cmd", "arg with spaces", "arg3"}, - }, - { - commandLine: `cmd "arg with spaces" "another arg"`, - expectedArgs: []string{"cmd", "arg with spaces", "another arg"}, - }, - { - commandLine: `cmd "arg with spaces"`, - expectedArgs: []string{"cmd", "arg with spaces"}, - }, - { - commandLine: `cmd`, - expectedArgs: []string{"cmd"}, - }, - { - commandLine: `"cmd file"`, - expectedArgs: []string{"cmd file"}, - }, - { - commandLine: `"cmd file" arg`, - expectedArgs: []string{"cmd file", "arg"}, - }, - } - - for _, testCase := range testCases { - args := splitArguments(testCase.commandLine) - assert.Equal(t, testCase.expectedArgs, args) - } -} diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 8b5141d0..5b47c071 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -8,6 +8,7 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" "github.com/creativeprojects/resticprofile/schtasks" + "github.com/creativeprojects/resticprofile/shell" ) // HandlerWindows is using windows task manager @@ -94,7 +95,24 @@ func (h *HandlerWindows) DisplayJobStatus(job *Config) error { } func (h *HandlerWindows) Scheduled(profileName string) ([]Config, error) { - return nil, errors.New("not implemented") + tasks, err := schtasks.Registered() + if err != nil { + return nil, err + } + configs := make([]Config, 0, len(tasks)) + for _, task := range tasks { + if profileName == "" || task.ProfileName == profileName { + configs = append(configs, Config{ + ProfileName: task.ProfileName, + CommandName: task.CommandName, + Command: task.Command, + Arguments: NewCommandArguments(shell.SplitArguments(task.Arguments)), + WorkingDirectory: task.WorkingDirectory, + JobDescription: task.JobDescription, + }) + } + } + return configs, nil } // init registers HandlerWindows diff --git a/shell/split_arguments.go b/shell/split_arguments.go new file mode 100644 index 00000000..748930cc --- /dev/null +++ b/shell/split_arguments.go @@ -0,0 +1,27 @@ +package shell + +import "strings" + +func SplitArguments(commandLine string) []string { + args := make([]string, 0) + sb := &strings.Builder{} + quoted := false + for _, r := range commandLine { + escape := false + if r == '\\' { + sb.WriteRune(r) + escape = true + } else if r == '"' && !escape { + quoted = !quoted + } else if !quoted && r == ' ' { + args = append(args, sb.String()) + sb.Reset() + } else { + sb.WriteRune(r) + } + } + if sb.Len() > 0 { + args = append(args, sb.String()) + } + return args +} diff --git a/shell/split_arguments_test.go b/shell/split_arguments_test.go new file mode 100644 index 00000000..1f9f328e --- /dev/null +++ b/shell/split_arguments_test.go @@ -0,0 +1,52 @@ +package shell + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitArguments(t *testing.T) { + testCases := []struct { + commandLine string + expectedArgs []string + }{ + { + commandLine: `cmd arg1 arg2`, + expectedArgs: []string{"cmd", "arg1", "arg2"}, + }, + { + commandLine: `cmd "arg with spaces" arg3`, + expectedArgs: []string{"cmd", "arg with spaces", "arg3"}, + }, + { + commandLine: `cmd "arg with spaces" "another arg"`, + expectedArgs: []string{"cmd", "arg with spaces", "another arg"}, + }, + { + commandLine: `cmd "arg with spaces"`, + expectedArgs: []string{"cmd", "arg with spaces"}, + }, + { + commandLine: `cmd`, + expectedArgs: []string{"cmd"}, + }, + { + commandLine: `"cmd file"`, + expectedArgs: []string{"cmd file"}, + }, + { + commandLine: `"cmd file" arg`, + expectedArgs: []string{"cmd file", "arg"}, + }, + { + commandLine: `cmd "arg \"with\" spaces"`, + expectedArgs: []string{"cmd", "arg \"with\" spaces"}, + }, + } + + for _, testCase := range testCases { + args := SplitArguments(testCase.commandLine) + assert.Equal(t, testCase.expectedArgs, args) + } +} From f5044b5f5cdad36f034e284e92f06023e214144b Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 18:15:43 +0000 Subject: [PATCH 19/38] add scheduled for windows --- shell/split_arguments.go | 13 +++++++------ shell/split_arguments_test.go | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/shell/split_arguments.go b/shell/split_arguments.go index 748930cc..edb0a81e 100644 --- a/shell/split_arguments.go +++ b/shell/split_arguments.go @@ -6,18 +6,19 @@ func SplitArguments(commandLine string) []string { args := make([]string, 0) sb := &strings.Builder{} quoted := false + escaped := false for _, r := range commandLine { - escape := false - if r == '\\' { - sb.WriteRune(r) - escape = true - } else if r == '"' && !escape { + if r == '\\' && !escaped { + escaped = true + } else if r == '"' && !escaped { quoted = !quoted - } else if !quoted && r == ' ' { + escaped = false + } else if !quoted && !escaped && r == ' ' { args = append(args, sb.String()) sb.Reset() } else { sb.WriteRune(r) + escaped = false } } if sb.Len() > 0 { diff --git a/shell/split_arguments_test.go b/shell/split_arguments_test.go index 1f9f328e..bef973bc 100644 --- a/shell/split_arguments_test.go +++ b/shell/split_arguments_test.go @@ -43,6 +43,10 @@ func TestSplitArguments(t *testing.T) { commandLine: `cmd "arg \"with\" spaces"`, expectedArgs: []string{"cmd", "arg \"with\" spaces"}, }, + { + commandLine: `cmd arg\ with\ spaces`, + expectedArgs: []string{"cmd", "arg with spaces"}, + }, } for _, testCase := range testCases { From 31358cbf18ed8986511b5c4793a0cf0ce055900e Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 21:05:11 +0000 Subject: [PATCH 20/38] use afero for crontab tests --- crond/crontab.go | 30 +++++++++---- crond/crontab_test.go | 40 ++++++++++------- crond/io.go | 94 +++++++++++++++++++++------------------ schedule/handler_crond.go | 21 +++++---- 4 files changed, 109 insertions(+), 76 deletions(-) diff --git a/crond/crontab.go b/crond/crontab.go index a3c5a8aa..cfe10fbd 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/creativeprojects/resticprofile/calendar" + "github.com/spf13/afero" ) const ( @@ -29,10 +30,14 @@ var ( type Crontab struct { file, binary, charset, user string entries []Entry + fs afero.Fs } func NewCrontab(entries []Entry) (c *Crontab) { - c = &Crontab{entries: entries} + c = &Crontab{ + entries: entries, + fs: afero.NewOsFs(), + } for i, entry := range c.entries { if entry.NeedsUser() { @@ -44,13 +49,22 @@ func NewCrontab(entries []Entry) (c *Crontab) { } // SetBinary sets the crontab binary to use for reading and writing the crontab (if empty, SetFile must be used) -func (c *Crontab) SetBinary(crontabBinary string) { +func (c *Crontab) SetBinary(crontabBinary string) *Crontab { c.binary = crontabBinary + return c } // SetFile toggles whether to read & write a crontab file instead of using the crontab binary -func (c *Crontab) SetFile(file string) { +func (c *Crontab) SetFile(file string) *Crontab { c.file = file + return c +} + +// SetFs sets the filesystem to use for reading and writing the crontab file. +// If not set, it will use the default filesystem. +func (c *Crontab) SetFs(fs afero.Fs) *Crontab { + c.fs = fs + return c } // update crontab entries: @@ -105,7 +119,7 @@ func (c *Crontab) update(source string, addEntries bool, w io.StringWriter) (int } if addEntries { - err = c.Generate(w) + err = c.generate(w) if err != nil { return deleted, err } @@ -125,7 +139,7 @@ func (c *Crontab) update(source string, addEntries bool, w io.StringWriter) (int return deleted, nil } -func (c *Crontab) Generate(w io.StringWriter) error { +func (c *Crontab) generate(w io.StringWriter) error { var err error if len(c.entries) > 0 { for _, entry := range c.entries { @@ -139,7 +153,7 @@ func (c *Crontab) Generate(w io.StringWriter) error { } func (c *Crontab) LoadCurrent() (content string, err error) { - content, c.charset, err = loadCrontab(c.file, c.binary) + content, c.charset, err = loadCrontab(c.fs, c.file, c.binary) if err == nil { if cleaned := cleanupCrontab(content); cleaned != content { if len(c.file) == 0 { @@ -184,7 +198,7 @@ func (c *Crontab) Rewrite() error { return err } - return saveCrontab(c.file, buffer.String(), c.charset, c.binary) + return saveCrontab(c.fs, c.file, buffer.String(), c.charset, c.binary) } func (c *Crontab) Remove() (int, error) { @@ -196,7 +210,7 @@ func (c *Crontab) Remove() (int, error) { buffer := new(strings.Builder) num, err := c.update(crontab, false, buffer) if err == nil { - err = saveCrontab(c.file, buffer.String(), c.charset, c.binary) + err = saveCrontab(c.fs, c.file, buffer.String(), c.charset, c.binary) } return num, err } diff --git a/crond/crontab_test.go b/crond/crontab_test.go index 9f7883dd..74ae2f71 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -11,6 +11,7 @@ import ( "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/platform" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,7 +19,7 @@ import ( func TestGenerateEmptyCrontab(t *testing.T) { crontab := NewCrontab(nil) buffer := &strings.Builder{} - err := crontab.Generate(buffer) + err := crontab.generate(buffer) require.NoError(t, err) assert.Equal(t, "", buffer.String()) } @@ -29,7 +30,7 @@ func TestGenerateSimpleCrontab(t *testing.T) { event.Hour.MustAddValue(1) }), "", "", "", "resticprofile backup", "")}) buffer := &strings.Builder{} - err := crontab.Generate(buffer) + err := crontab.generate(buffer) require.NoError(t, err) assert.Equal(t, "01 01 * * *\tresticprofile backup\n", buffer.String()) } @@ -40,7 +41,7 @@ func TestGenerateWorkDirCrontab(t *testing.T) { event.Hour.MustAddValue(1) }), "", "", "", "resticprofile backup", "workdir")}) buffer := &strings.Builder{} - err := crontab.Generate(buffer) + err := crontab.generate(buffer) require.NoError(t, err) assert.Equal(t, "01 01 * * *\tcd workdir && resticprofile backup\n", buffer.String()) } @@ -178,13 +179,20 @@ func TestFromFile(t *testing.T) { event.Hour.MustAddValue(1) }), "", "", "", "resticprofile backup", "")}) - assert.ErrorContains(t, crontab.Rewrite(), "no contrab file was specified") + assert.ErrorIs(t, crontab.Rewrite(), ErrNoCrontabFile) + fs := afero.NewMemMapFs() + crontab.SetFs(fs) crontab.SetFile(file) - assert.NoFileExists(t, file) + exist, err := afero.Exists(fs, file) + require.NoError(t, err) + assert.False(t, exist) + assert.NoError(t, crontab.Rewrite()) - assert.FileExists(t, file) + exist, err = afero.Exists(fs, file) + require.NoError(t, err) + assert.True(t, exist) result, err := crontab.LoadCurrent() assert.NoError(t, err) @@ -202,11 +210,11 @@ func getExpectedUser(crontab *Crontab) (expectedUser string) { } func TestFromFileDetectsUserColumn(t *testing.T) { - file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab")) - require.NoError(t, err) + fs := afero.NewMemMapFs() + file := "/var/spool/cron/crontabs/user" userLine := `17 * * * * root cd / && run-parts --report /etc/cron.hourly` - require.NoError(t, os.WriteFile(file, []byte("\n"+userLine+"\n"), 0600)) + require.NoError(t, afero.WriteFile(fs, file, []byte("\n"+userLine+"\n"), 0600)) cmdLine := "resticprofile --no-ansi --config config.yaml --name profile backup" crontab := NewCrontab([]Entry{NewEntry(calendar.NewEvent(func(event *calendar.Event) { @@ -214,6 +222,7 @@ func TestFromFileDetectsUserColumn(t *testing.T) { event.Hour.MustAddValue(1) }), "config.yaml", "profile", "backup", cmdLine, "")}) + crontab.SetFs(fs) crontab.SetFile(file) expectedUser := getExpectedUser(crontab) @@ -245,15 +254,16 @@ func TestNewCrontabWithCurrentUser(t *testing.T) { } func TestNoLoadCurrentFromNoEditFile(t *testing.T) { - file, err := filepath.Abs(filepath.Join(t.TempDir(), "crontab")) - require.NoError(t, err) + fs := afero.NewMemMapFs() + file := "/var/spool/cron/crontabs/user" - assert.NoError(t, os.WriteFile(file, []byte("# DO NOT EDIT THIS FILE \n#\n#\n"), 0600)) + assert.NoError(t, afero.WriteFile(fs, file, []byte("# DO NOT EDIT THIS FILE \n#\n#\n"), 0600)) - crontab := NewCrontab(nil) - crontab.SetFile(file) + crontab := NewCrontab(nil). + SetFile(file). + SetFs(fs) - _, err = crontab.LoadCurrent() + _, err := crontab.LoadCurrent() assert.ErrorContains(t, err, fmt.Sprintf(`refusing to change crontab with "DO NOT EDIT": %q`, file)) } diff --git a/crond/io.go b/crond/io.go index 85da19d4..4b7fae77 100644 --- a/crond/io.go +++ b/crond/io.go @@ -8,6 +8,8 @@ import ( "os" "os/exec" "strings" + + "github.com/spf13/afero" ) const ( @@ -15,19 +17,61 @@ const ( defaultCrontabFilePerms = fs.FileMode(0644) ) +var ( + ErrNoCrontabFile = errors.New("no crontab file was specified") +) + +func loadCrontab(fs afero.Fs, file, crontabBinary string) (content, charset string, err error) { + if file == "" && crontabBinary != "" { + buffer := new(strings.Builder) + { + cmd := exec.Command(crontabBinary, "-l") + cmd.Stdout = buffer + cmd.Stderr = buffer + err = cmd.Run() + } + if err != nil { + if strings.HasPrefix(buffer.String(), "no crontab for ") { + err = nil // it's ok to be empty + buffer.Reset() + } else { + err = fmt.Errorf("%w: %s", err, buffer.String()) + } + } + if err == nil { + content = buffer.String() + } + return + } else { + return loadCrontabFile(fs, file) + } +} + +func saveCrontab(fs afero.Fs, file, content, charset, crontabBinary string) (err error) { + if file == "" && crontabBinary != "" { + cmd := exec.Command(crontabBinary, "-") + cmd.Stdin = strings.NewReader(content) + cmd.Stderr = os.Stderr + err = cmd.Run() + } else { + err = saveCrontabFile(fs, file, content, charset) + } + return +} + func verifyCrontabFile(file string) error { if file == "" { - return fmt.Errorf("no contrab file was specified") + return ErrNoCrontabFile } return nil } -func loadCrontabFile(file string) (content, charset string, err error) { +func loadCrontabFile(fs afero.Fs, file string) (content, charset string, err error) { if err = verifyCrontabFile(file); err != nil { return } - var f *os.File - if f, err = os.Open(file); err == nil { + var f afero.File + if f, err = fs.Open(file); err == nil { defer func() { _ = f.Close() }() var bytes []byte @@ -47,7 +91,7 @@ func loadCrontabFile(file string) (content, charset string, err error) { } //nolint:unparam -func saveCrontabFile(file, content, charset string) (err error) { +func saveCrontabFile(fs afero.Fs, file, content, charset string) (err error) { if err = verifyCrontabFile(file); err != nil { return } @@ -58,45 +102,7 @@ func saveCrontabFile(file, content, charset string) (err error) { if len(bytes) >= maxCrontabFileSize { err = fmt.Errorf("max file size of %d bytes exceeded in new %q", maxCrontabFileSize, file) } else { - err = os.WriteFile(file, bytes, defaultCrontabFilePerms) - } - return -} - -func loadCrontab(file, crontabBinary string) (content, charset string, err error) { - if file == "" && crontabBinary != "" { - buffer := new(strings.Builder) - { - cmd := exec.Command(crontabBinary, "-l") - cmd.Stdout = buffer - cmd.Stderr = buffer - err = cmd.Run() - } - if err != nil { - if strings.HasPrefix(buffer.String(), "no crontab for ") { - err = nil // it's ok to be empty - buffer.Reset() - } else { - err = fmt.Errorf("%w: %s", err, buffer.String()) - } - } - if err == nil { - content = buffer.String() - } - return - } else { - return loadCrontabFile(file) - } -} - -func saveCrontab(file, content, charset, crontabBinary string) (err error) { - if file == "" && crontabBinary != "" { - cmd := exec.Command(crontabBinary, "-") - cmd.Stdin = strings.NewReader(content) - cmd.Stderr = os.Stderr - err = cmd.Run() - } else { - err = saveCrontabFile(file, content, charset) + err = afero.WriteFile(fs, file, bytes, defaultCrontabFilePerms) } return } diff --git a/schedule/handler_crond.go b/schedule/handler_crond.go index 86262296..e75b6ded 100644 --- a/schedule/handler_crond.go +++ b/schedule/handler_crond.go @@ -78,9 +78,10 @@ func (h *HandlerCrond) CreateJob(job *Config, schedules []*calendar.Event, permi entries[i] = entries[i].WithUser(h.config.Username) } } - crontab := crond.NewCrontab(entries) - crontab.SetFile(h.config.CrontabFile) - crontab.SetBinary(h.config.CrontabBinary) + crontab := crond.NewCrontab(entries). + SetFile(h.config.CrontabFile). + SetBinary(h.config.CrontabBinary). + SetFs(h.fs) err := crontab.Rewrite() if err != nil { return err @@ -99,9 +100,10 @@ func (h *HandlerCrond) RemoveJob(job *Config, permission string) error { job.WorkingDirectory, ), } - crontab := crond.NewCrontab(entries) - crontab.SetFile(h.config.CrontabFile) - crontab.SetBinary(h.config.CrontabBinary) + crontab := crond.NewCrontab(entries). + SetFile(h.config.CrontabFile). + SetBinary(h.config.CrontabBinary). + SetFs(h.fs) num, err := crontab.Remove() if err != nil { return err @@ -118,9 +120,10 @@ func (h *HandlerCrond) DisplayJobStatus(job *Config) error { } func (h *HandlerCrond) Scheduled(profileName string) ([]Config, error) { - crontab := crond.NewCrontab(nil) - crontab.SetFile(h.config.CrontabFile) - crontab.SetBinary(h.config.CrontabBinary) + crontab := crond.NewCrontab(nil). + SetFile(h.config.CrontabFile). + SetBinary(h.config.CrontabBinary). + SetFs(h.fs) entries, err := crontab.GetEntries() if err != nil { return nil, err From 73952ebe9a195c6fab313dd3ddd0bfc509679470 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 21:29:20 +0000 Subject: [PATCH 21/38] add config file to launchd Scheduled --- schedule/command_arguments.go | 15 +++++++++++++++ schedule/command_arguments_test.go | 25 +++++++++++++++++++++++++ schedule/handler_darwin.go | 4 +++- schedule/handler_darwin_test.go | 3 +++ schedule/handler_systemd.go | 12 ++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/schedule/command_arguments.go b/schedule/command_arguments.go index 9ad618f8..bde77c51 100644 --- a/schedule/command_arguments.go +++ b/schedule/command_arguments.go @@ -39,6 +39,21 @@ func (ca CommandArguments) String() string { return b.String() } +// ConfigFile returns the value of the --config argument, if present +func (ca CommandArguments) ConfigFile() string { + if len(ca.args) == 0 { + return "" + } + for i, arg := range ca.args { + if arg == "--config" { + if i+1 < len(ca.args) { + return ca.args[i+1] + } + } + } + return "" +} + func (ca CommandArguments) writeString(b *strings.Builder, str string) { if strings.Contains(str, " ") { b.WriteString(`"`) diff --git a/schedule/command_arguments_test.go b/schedule/command_arguments_test.go index c6c226f0..2d2f9098 100644 --- a/schedule/command_arguments_test.go +++ b/schedule/command_arguments_test.go @@ -57,3 +57,28 @@ func TestString(t *testing.T) { } } } + +func TestConfigFile(t *testing.T) { + tests := []struct { + args []string + expected string + }{ + {[]string{}, ""}, + {[]string{"--config"}, ""}, + {[]string{"--config", "config.toml"}, "config.toml"}, + {[]string{"--config", "C:\\Program Files\\config.toml"}, "C:\\Program Files\\config.toml"}, + {[]string{"--name", "backup", "--config", "config.toml"}, "config.toml"}, + {[]string{"--config", "config.toml", "--name", "backup"}, "config.toml"}, + {[]string{"--name", "backup", "--config", "config.toml", "--no-ansi"}, "config.toml"}, + {[]string{"--name", "backup", "--no-ansi", "--config", "config.toml"}, "config.toml"}, + {[]string{"--name", "backup", "--no-ansi"}, ""}, + } + + for _, test := range tests { + ca := NewCommandArguments(test.args) + result := ca.ConfigFile() + if result != test.expected { + t.Errorf("expected %s, got %s", test.expected, result) + } + } +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index 093a39c2..fca3948d 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -296,11 +296,13 @@ func (h *HandlerLaunchd) getJobConfig(filename, name string) (*Config, error) { if err != nil { return nil, fmt.Errorf("error reading plist file: %w", err) } + args := NewCommandArguments(launchdJob.ProgramArguments[2:]) // first is binary, second is --no-prio job := &Config{ ProfileName: profileName, CommandName: commandName, Command: launchdJob.Program, - Arguments: NewCommandArguments(launchdJob.ProgramArguments[2:]), // first is binary, second is --no-prio + ConfigFile: args.ConfigFile(), + Arguments: args, WorkingDirectory: launchdJob.WorkingDirectory, } return job, nil diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 958926b4..7e5e2de2 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -195,6 +195,7 @@ func TestReadingLaunchdScheduled(t *testing.T) { Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionSystem, + ConfigFile: "examples/dev.yaml", }, schedules: []*calendar.Event{}, }, @@ -206,6 +207,7 @@ func TestReadingLaunchdScheduled(t *testing.T) { Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}), WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionSystem, + ConfigFile: "config file.yaml", }, schedules: []*calendar.Event{}, }, @@ -217,6 +219,7 @@ func TestReadingLaunchdScheduled(t *testing.T) { Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionUser, + ConfigFile: "examples/dev.yaml", }, schedules: []*calendar.Event{}, }, diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index ee6e484d..de6140a9 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -15,6 +15,7 @@ import ( "github.com/creativeprojects/clog" "github.com/creativeprojects/resticprofile/calendar" "github.com/creativeprojects/resticprofile/constants" + "github.com/creativeprojects/resticprofile/shell" "github.com/creativeprojects/resticprofile/systemd" "github.com/creativeprojects/resticprofile/term" ) @@ -407,10 +408,21 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) } func toScheduleConfig(systemdConfig systemd.Config) Config { + var command string + cmdLine := shell.SplitArguments(systemdConfig.CommandLine) + if len(cmdLine) > 0 { + command = cmdLine[0] + } + args := NewCommandArguments(cmdLine[1:]) + cfg := Config{ ProfileName: systemdConfig.Title, CommandName: systemdConfig.SubTitle, WorkingDirectory: systemdConfig.WorkingDirectory, + Command: command, + Arguments: args, + JobDescription: systemdConfig.JobDescription, + Environment: systemdConfig.Environment, } return cfg } From c3a4a74043dc840e63bb448ea8acb160ec1fec78 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 21:42:18 +0000 Subject: [PATCH 22/38] fix job tests on Windows --- schedule/job_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/schedule/job_test.go b/schedule/job_test.go index aba0c6ac..f1aff8c6 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -22,6 +22,7 @@ func TestCreateJobHappyPath(t *testing.T) { ProfileName: "profile", CommandName: "backup", Schedules: []string{}, + Permission: "user", }) err := job.Create() @@ -70,6 +71,7 @@ func TestCreateJobErrorCreate(t *testing.T) { ProfileName: "profile", CommandName: "backup", Schedules: []string{}, + Permission: "user", }) err := job.Create() From a0fa13400e0edc57969771d99ce9b4d181bc0dfb Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 22:03:33 +0000 Subject: [PATCH 23/38] add tests for systemd --- schedule/handler_systemd_test.go | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 schedule/handler_systemd_test.go diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go new file mode 100644 index 00000000..f1ff6d94 --- /dev/null +++ b/schedule/handler_systemd_test.go @@ -0,0 +1,70 @@ +//go:build !darwin && !windows + +package schedule + +import ( + "testing" + + "github.com/creativeprojects/resticprofile/calendar" + "github.com/creativeprojects/resticprofile/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadingSystemdScheduled(t *testing.T) { + event := calendar.NewEvent() + require.NoError(t, event.Parse("2020-01-01")) + + schedulePermission := constants.SchedulePermissionUser + + testCases := []struct { + job Config + schedules []*calendar.Event + }{ + { + job: Config{ + ProfileName: "testscheduled", + CommandName: "backup", + Command: "/tmp/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), + WorkingDirectory: "/resticprofile", + Permission: schedulePermission, + ConfigFile: "examples/dev.yaml", + Schedules: []string{event.String()}, + }, + schedules: []*calendar.Event{event}, + }, + { + job: Config{ + ProfileName: "test.scheduled", + CommandName: "backup", + Command: "/tmp/bin/resticprofile", + Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "self", "backup"}), + WorkingDirectory: "/resticprofile", + Permission: schedulePermission, + ConfigFile: "config file.yaml", + Schedules: []string{event.String()}, + }, + schedules: []*calendar.Event{event}, + }, + } + + handler := NewHandler(SchedulerSystemd{}).(*HandlerSystemd) + + expectedJobs := []Config{} + for _, testCase := range testCases { + expectedJobs = append(expectedJobs, testCase.job) + toRemove := &testCase.job + + err := handler.CreateJob(&testCase.job, testCase.schedules, schedulePermission) + t.Cleanup(func() { + _ = handler.RemoveJob(toRemove, schedulePermission) + }) + require.NoError(t, err) + } + + scheduled, err := handler.Scheduled("") + require.NoError(t, err) + + assert.ElementsMatch(t, expectedJobs, scheduled) +} From 9f79f2aa78839b699054bcde316cd645b3e3b2c7 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 22:49:59 +0000 Subject: [PATCH 24/38] add test on Read --- .github/workflows/build.yml | 2 +- Makefile | 8 ++++++-- lock/lock.go | 2 +- priority/ioprio_linux.go | 4 ++-- schedule/handler_systemd.go | 2 +- systemd/read_test.go | 33 +++++++++++++++++++++++++++++++++ term/term.go | 2 +- 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 08c8cefb..1f7378d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v1.60 + version: v1.61 - name: Test run: make test-ci diff --git a/Makefile b/Makefile index 4ae9c6f8..14820178 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,10 @@ $(GOBIN)/mockery: verify $(GOBIN)/eget @echo "[*] $@" "$(GOBIN)/eget" vektra/mockery --upgrade-only --to '$(GOBIN)' +$(GOBIN)/golangci-lint: verify $(GOBIN)/eget + @echo "[*] $@" + "$(GOBIN)/eget" golangci/golangci-lint --tag v1.61.0 --asset=tar.gz --upgrade-only --to '$(GOBIN)' + prepare_build: verify download @echo "[*] $@" @@ -296,14 +300,14 @@ checklinks: muffet -b 8192 --exclude="(linux.die.net|stackoverflow.com)" http://localhost:1313/resticprofile/ .PHONY: lint -lint: +lint: $(GOBIN)/golangci-lint @echo "[*] $@" GOOS=darwin golangci-lint run GOOS=linux golangci-lint run GOOS=windows golangci-lint run .PHONY: fix -fix: +fix: $(GOBIN)/golangci-lint @echo "[*] $@" $(GOCMD) mod tidy $(GOCMD) fix ./... diff --git a/lock/lock.go b/lock/lock.go index fa1dd2a2..da600ba6 100644 --- a/lock/lock.go +++ b/lock/lock.go @@ -109,7 +109,7 @@ func (l *Lock) LastPID() (int32, error) { if contents[i] != "" { pid, err := strconv.ParseInt(contents[i], 10, 32) if err == nil { - return int32(pid), nil //nolint:gosec + return int32(pid), nil } } } diff --git a/priority/ioprio_linux.go b/priority/ioprio_linux.go index af992321..06034f9a 100644 --- a/priority/ioprio_linux.go +++ b/priority/ioprio_linux.go @@ -71,8 +71,8 @@ func getIOPrio(who IOPrioWho) (IOPrioClass, int, error) { if errno != 0 { return 0, 0, errnoToError(errno) } - class := IOPrioClass(r1 >> IOPrioClassShift) //nolint:gosec - value := int(r1 & IOPrioMask) //nolint:gosec + class := IOPrioClass(r1 >> IOPrioClassShift) + value := int(r1 & IOPrioMask) return class, value, nil } diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index de6140a9..4683929e 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -394,7 +394,7 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) } configs := make([]Config, 0, len(units)) for _, unit := range units { - cfg, err := systemd.Read(unit.Unit, systemd.SystemUnit) + cfg, err := systemd.Read(unit.Unit, unitType) if err != nil { clog.Errorf("cannot read information from unit %q: %s", unit.Unit, err) continue diff --git a/systemd/read_test.go b/systemd/read_test.go index 9c945023..09f2eecc 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -5,6 +5,7 @@ package systemd import ( "fmt" "os" + "path" "testing" "github.com/spf13/afero" @@ -39,6 +40,38 @@ Persistent=true WantedBy=timers.target` ) +func TestReadUnitFile(t *testing.T) { + fs = afero.NewMemMapFs() + unitFile := "resticprofile-copy@profile-self.service" + require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, unitFile), []byte(testServiceUnit), 0o600)) + + cfg, err := Read(unitFile, SystemUnit) + require.NoError(t, err) + assert.NotNil(t, cfg) + + expected := &Config{ + CommandLine: "/tmp/go-build982790897/b001/exe/resticprofile --no-prio --no-ansi --config examples/linux.yaml run-schedule copy@self", + Environment: []string{"RESTICPROFILE_SCHEDULE_ID=examples/linux.yaml:copy@self", "HOME=/home/linux"}, + WorkingDirectory: "/home/linux/go/src/github.com/creativeprojects/resticprofile", + Title: "self", + SubTitle: "copy", + JobDescription: "resticprofile copy for profile self in examples/linux.yaml", + TimerDescription: "", + Schedules: []string(nil), + UnitType: SystemUnit, + Priority: "", + UnitFile: "", + TimerFile: "", + DropInFiles: []string(nil), + AfterNetworkOnline: false, + Nice: 0, + CPUSchedulingPolicy: "", + IOSchedulingClass: 0, + IOSchedulingPriority: 0, + } + assert.Equal(t, expected, cfg) +} + func TestReadSystemUnit(t *testing.T) { testCases := []struct { config Config diff --git a/term/term.go b/term/term.go index 2ea2457e..f5debb69 100644 --- a/term/term.go +++ b/term/term.go @@ -98,7 +98,7 @@ func OsStdoutTerminalSize() (width, height int) { } func fdToInt(fd uintptr) int { - return int(fd) //nolint:gosec + return int(fd) } type LockedWriter struct { From d67f1c7a1d41530a523a4265905974e38ce5dcae Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 23:45:15 +0000 Subject: [PATCH 25/38] fix all tests on systemd handler --- schedule/command_arguments.go | 17 +++++++++- schedule/handler_systemd.go | 17 +++++++--- schedule/handler_systemd_test.go | 23 ++++++++++--- systemd/read.go | 55 ++++++++++++++++++++++---------- systemd/read_test.go | 18 +++++++---- 5 files changed, 98 insertions(+), 32 deletions(-) diff --git a/schedule/command_arguments.go b/schedule/command_arguments.go index bde77c51..8791b8fa 100644 --- a/schedule/command_arguments.go +++ b/schedule/command_arguments.go @@ -1,6 +1,9 @@ package schedule -import "strings" +import ( + "slices" + "strings" +) type CommandArguments struct { args []string @@ -12,6 +15,18 @@ func NewCommandArguments(args []string) CommandArguments { } } +// Trim returns a new CommandArguments with the specified flags removed from the arguments +func (ca CommandArguments) Trim(removeArgs []string) CommandArguments { + args := make([]string, 0, len(ca.args)) + for _, arg := range ca.args { + if slices.Contains(removeArgs, arg) { + continue + } + args = append(args, arg) + } + return NewCommandArguments(args) +} + func (ca CommandArguments) RawArgs() []string { result := make([]string, len(ca.args)) copy(result, ca.args) diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 4683929e..28be141e 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -114,7 +114,7 @@ func (h *HandlerSystemd) CreateJob(job *Config, schedules []*calendar.Event, per } err := systemd.Generate(systemd.Config{ - CommandLine: job.Command + " " + strings.Join(append([]string{"--no-prio"}, job.Arguments.RawArgs()...), " "), + CommandLine: job.Command + " --no-prio " + job.Arguments.String(), Environment: job.Environment, WorkingDirectory: job.WorkingDirectory, Title: job.ProfileName, @@ -402,12 +402,12 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) if cfg == nil { continue } - configs = append(configs, toScheduleConfig(*cfg)) + configs = append(configs, toScheduleConfig(*cfg, unitType)) } return configs, nil } -func toScheduleConfig(systemdConfig systemd.Config) Config { +func toScheduleConfig(systemdConfig systemd.Config, unitType systemd.UnitType) Config { var command string cmdLine := shell.SplitArguments(systemdConfig.CommandLine) if len(cmdLine) > 0 { @@ -415,14 +415,23 @@ func toScheduleConfig(systemdConfig systemd.Config) Config { } args := NewCommandArguments(cmdLine[1:]) + permission := constants.SchedulePermissionUser + if unitType == systemd.SystemUnit { + permission = constants.SchedulePermissionSystem + } + cfg := Config{ + ConfigFile: args.ConfigFile(), ProfileName: systemdConfig.Title, CommandName: systemdConfig.SubTitle, WorkingDirectory: systemdConfig.WorkingDirectory, Command: command, - Arguments: args, + Arguments: args.Trim([]string{"--no-prio"}), JobDescription: systemdConfig.JobDescription, Environment: systemdConfig.Environment, + Permission: permission, + Schedules: systemdConfig.Schedules, + Priority: systemdConfig.Priority, } return cfg } diff --git a/schedule/handler_systemd_test.go b/schedule/handler_systemd_test.go index f1ff6d94..826bea47 100644 --- a/schedule/handler_systemd_test.go +++ b/schedule/handler_systemd_test.go @@ -3,6 +3,7 @@ package schedule import ( + "os" "testing" "github.com/creativeprojects/resticprofile/calendar" @@ -48,23 +49,37 @@ func TestReadingSystemdScheduled(t *testing.T) { schedules: []*calendar.Event{event}, }, } + userHome, err := os.UserHomeDir() + require.NoError(t, err) handler := NewHandler(SchedulerSystemd{}).(*HandlerSystemd) expectedJobs := []Config{} for _, testCase := range testCases { - expectedJobs = append(expectedJobs, testCase.job) - toRemove := &testCase.job + job := testCase.job + err := handler.CreateJob(&job, testCase.schedules, schedulePermission) - err := handler.CreateJob(&testCase.job, testCase.schedules, schedulePermission) + toRemove := &job t.Cleanup(func() { _ = handler.RemoveJob(toRemove, schedulePermission) }) require.NoError(t, err) + + job.Environment = []string{"HOME=" + userHome} + expectedJobs = append(expectedJobs, job) } scheduled, err := handler.Scheduled("") require.NoError(t, err) - assert.ElementsMatch(t, expectedJobs, scheduled) + testScheduled := make([]Config, 0, len(scheduled)) + for _, s := range scheduled { + if s.ConfigFile != "config file.yaml" && s.ConfigFile != "examples/dev.yaml" { + t.Logf("Ignoring config file %s", s.ConfigFile) + continue + } + testScheduled = append(testScheduled, s) + } + + assert.ElementsMatch(t, expectedJobs, testScheduled) } diff --git a/systemd/read.go b/systemd/read.go index a2e68116..e8cbbc04 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -5,6 +5,7 @@ package systemd import ( "bytes" "path" + "strconv" "strings" "github.com/spf13/afero" @@ -19,7 +20,37 @@ func Read(unit string, unitType UnitType) (*Config, error) { return nil, err } } - content, err := afero.ReadFile(fs, path.Join(unitsDir, unit)) + filename := path.Join(unitsDir, unit) + serviceSections, err := readSystemdUnit(filename) + if err != nil { + return nil, err + } + filename = strings.Replace(filename, ".service", ".timer", 1) + timerSections, err := readSystemdUnit(filename) + if err != nil { + return nil, err + } + + profileName, commandName := parseServiceFileName(unit) + cfg := &Config{ + Title: profileName, + SubTitle: commandName, + JobDescription: getSingleValue(serviceSections, "Unit", "Description"), + WorkingDirectory: getSingleValue(serviceSections, "Service", "WorkingDirectory"), + CommandLine: getSingleValue(serviceSections, "Service", "ExecStart"), + UnitType: unitType, + Environment: getValues(serviceSections, "Service", "Environment"), + Nice: getIntegerValue(serviceSections, "Service", "Nice"), + IOSchedulingClass: getIntegerValue(serviceSections, "Service", "IOSchedulingClass"), + IOSchedulingPriority: getIntegerValue(serviceSections, "Service", "IOSchedulingPriority"), + Schedules: getValues(timerSections, "Timer", "OnCalendar"), + Priority: "background", //TODO fix this hard-coded value + } + return cfg, nil +} + +func readSystemdUnit(filename string) (map[string]map[string][]string, error) { + content, err := afero.ReadFile(fs, filename) if err != nil { return nil, err } @@ -49,21 +80,13 @@ func Read(unit string, unitType UnitType) (*Config, error) { } } } - description := getSingleValue(sections, "Unit", "Description") - workdir := getSingleValue(sections, "Service", "WorkingDirectory") - commandLine := getSingleValue(sections, "Service", "ExecStart") - environment := getValues(sections, "Service", "Environment") - profileName, commandName := parseServiceFileName(unit) - cfg := &Config{ - Title: profileName, - SubTitle: commandName, - JobDescription: description, - WorkingDirectory: workdir, - CommandLine: commandLine, - UnitType: unitType, - Environment: environment, - } - return cfg, nil + return sections, nil +} + +func getIntegerValue(from map[string]map[string][]string, section, key string) int { + str := getSingleValue(from, section, key) + value, _ := strconv.Atoi(str) + return value } func getSingleValue(from map[string]map[string][]string, section, key string) string { diff --git a/systemd/read_test.go b/systemd/read_test.go index 09f2eecc..3b40c3c9 100644 --- a/systemd/read_test.go +++ b/systemd/read_test.go @@ -43,7 +43,9 @@ WantedBy=timers.target` func TestReadUnitFile(t *testing.T) { fs = afero.NewMemMapFs() unitFile := "resticprofile-copy@profile-self.service" + timerFile := "resticprofile-copy@profile-self.timer" require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, unitFile), []byte(testServiceUnit), 0o600)) + require.NoError(t, afero.WriteFile(fs, path.Join(systemdSystemDir, timerFile), []byte(testTimerUnit), 0o600)) cfg, err := Read(unitFile, SystemUnit) require.NoError(t, err) @@ -57,17 +59,17 @@ func TestReadUnitFile(t *testing.T) { SubTitle: "copy", JobDescription: "resticprofile copy for profile self in examples/linux.yaml", TimerDescription: "", - Schedules: []string(nil), + Schedules: []string{"*:45"}, UnitType: SystemUnit, - Priority: "", + Priority: "background", UnitFile: "", TimerFile: "", DropInFiles: []string(nil), AfterNetworkOnline: false, - Nice: 0, + Nice: 19, CPUSchedulingPolicy: "", - IOSchedulingClass: 0, - IOSchedulingPriority: 0, + IOSchedulingClass: 3, + IOSchedulingPriority: 7, } assert.Equal(t, expected, cfg) } @@ -86,7 +88,7 @@ func TestReadSystemUnit(t *testing.T) { TimerDescription: "timer description", Schedules: []string{"daily"}, UnitType: SystemUnit, - Priority: "low", + Priority: "background", }, }, { @@ -99,7 +101,7 @@ func TestReadSystemUnit(t *testing.T) { TimerDescription: "timer description", Schedules: []string{"daily", "weekly"}, UnitType: UserUnit, - Priority: "low", + Priority: "background", Environment: []string{ "TMP=/tmp", }, @@ -132,6 +134,8 @@ func TestReadSystemUnit(t *testing.T) { CommandLine: tc.config.CommandLine, UnitType: tc.config.UnitType, Environment: append(tc.config.Environment, "HOME="+home), + Schedules: tc.config.Schedules, + Priority: tc.config.Priority, } assert.Equal(t, expected, readCfg) }) From 587490d7d7bc5a6f9020e2b105a2995ccf6570d9 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 1 Nov 2024 23:56:16 +0000 Subject: [PATCH 26/38] github windows agent is stupidely slow --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f7378d5..5d84c23d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,6 +45,7 @@ jobs: uses: golangci/golangci-lint-action@v6 with: version: v1.61 + args: --timeout=5m - name: Test run: make test-ci From 5355cdcc6789ee10825044c309981b5ff3e0ef67 Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 2 Nov 2024 19:39:24 +0000 Subject: [PATCH 27/38] refactoring launchd handler --- codecov.yml | 1 + schedule/handler_darwin.go | 91 ++++++++++++++++++++------------------ 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/codecov.yml b/codecov.yml index 8a6734e8..69c88912 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,6 +11,7 @@ ignore: - run_profile.go - syslog.go - syslog_windows.go + - "**/mocks/*.go" codecov: notify: diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index fca3948d..c6c4adf7 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -210,45 +210,11 @@ func (h *HandlerLaunchd) Scheduled(profileName string) ([]Config, error) { profileName = strings.ToLower(profileName) } // system jobs - prefix := path.Join(GlobalDaemons, namePrefix) - matches, err := afero.Glob(h.fs, fmt.Sprintf("%s%s.*%s", prefix, profileName, daemonExtension)) - if err != nil { - clog.Warningf("Error while listing system jobs: %s", err) - } - for _, match := range matches { - extract := strings.TrimSuffix(strings.TrimPrefix(match, prefix), daemonExtension) - job, err := h.getJobConfig(match, extract) - if err != nil { - clog.Warning(err) - continue - } - if job != nil { - job.Permission = constants.SchedulePermissionSystem - jobs = append(jobs, *job) - } - } + systemJobs := h.getScheduledJob(profileName, constants.SchedulePermissionSystem) + jobs = append(jobs, systemJobs...) // user jobs - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - prefix = path.Join(home, UserAgentPath, namePrefix) - matches, err = afero.Glob(h.fs, fmt.Sprintf("%s%s.*%s", prefix, profileName, agentExtension)) - if err != nil { - clog.Warningf("Error while listing user jobs: %s", err) - } - for _, match := range matches { - extract := strings.TrimSuffix(strings.TrimPrefix(match, prefix), agentExtension) - job, err := h.getJobConfig(match, extract) - if err != nil { - clog.Warning(err) - continue - } - if job != nil { - job.Permission = constants.SchedulePermissionUser - jobs = append(jobs, *job) - } - } + userJobs := h.getScheduledJob(profileName, constants.SchedulePermissionUser) + jobs = append(jobs, userJobs...) return jobs, nil } @@ -287,10 +253,51 @@ func (h *HandlerLaunchd) getLaunchdJob(job *Config, schedules []*calendar.Event) return launchdJob } -func (h *HandlerLaunchd) getJobConfig(filename, name string) (*Config, error) { - parts := strings.Split(name, ".") - commandName := parts[len(parts)-1] - profileName := strings.Join(parts[:len(parts)-1], ".") +func (h *HandlerLaunchd) getScheduledJob(profileName, permission string) []Config { + matches, err := afero.Glob(h.fs, getSchedulePattern(profileName, permission)) + if err != nil { + clog.Warningf("Error while listing %s jobs: %s", permission, err) + } + jobs := make([]Config, 0, len(matches)) + for _, match := range matches { + job, err := h.getJobConfig(match) + if err != nil { + clog.Warning(err) + continue + } + if job != nil { + job.Permission = permission + jobs = append(jobs, *job) + } + } + return jobs +} + +func getSchedulePattern(profileName, permission string) string { + pattern := "%s%s.*%s" + if permission == constants.SchedulePermissionSystem { + return fmt.Sprintf(pattern, path.Join(GlobalDaemons, namePrefix), profileName, daemonExtension) + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return fmt.Sprintf(pattern, path.Join(home, UserAgentPath, namePrefix), profileName, agentExtension) +} + +func getCommandAndProfileFromFilename(filename string) (command string, profile string) { + // try removing both daemon and agent extensions + filename = strings.TrimSuffix(filename, agentExtension) // longer one + filename = strings.TrimSuffix(filename, daemonExtension) // shorter one + filename = strings.TrimPrefix(path.Base(filename), namePrefix) + parts := strings.Split(filename, ".") + command = parts[len(parts)-1] + profile = strings.Join(parts[:len(parts)-1], ".") + return +} + +func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { + commandName, profileName := getCommandAndProfileFromFilename(filename) launchdJob, err := h.readPlistFile(filename) if err != nil { From 7a097d1743033bf80bc1d5c433ce3223213e462b Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 3 Nov 2024 13:28:49 +0000 Subject: [PATCH 28/38] add tests on crontab GetEntries --- crond/crontab_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ examples/v2.yaml | 20 +++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/crond/crontab_test.go b/crond/crontab_test.go index 74ae2f71..f790f3a5 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -418,3 +418,45 @@ func TestParseEntry(t *testing.T) { }) } } + +func TestGetEntries(t *testing.T) { + fs := afero.NewMemMapFs() + file := "/var/spool/cron/crontabs/user" + + t.Run("no crontab file", func(t *testing.T) { + crontab := NewCrontab(nil).SetFile(file).SetFs(fs) + entries, err := crontab.GetEntries() + require.NoError(t, err) + assert.Nil(t, entries) + }) + + t.Run("empty crontab file", func(t *testing.T) { + require.NoError(t, afero.WriteFile(fs, file, []byte(""), 0600)) + crontab := NewCrontab(nil).SetFile(file).SetFs(fs) + entries, err := crontab.GetEntries() + require.NoError(t, err) + assert.Nil(t, entries) + }) + + t.Run("crontab with no own section", func(t *testing.T) { + require.NoError(t, afero.WriteFile(fs, file, []byte("some other content\n"), 0600)) + crontab := NewCrontab(nil).SetFile(file).SetFs(fs) + entries, err := crontab.GetEntries() + require.NoError(t, err) + assert.Nil(t, entries) + }) + + t.Run("crontab with own section", func(t *testing.T) { + content := "some other content\n" + startMarker + "00,30 * * * *\tcd workdir && /some/bin/resticprofile --no-ansi --config config.yaml run-schedule backup@profile\n" + endMarker + "more content\n" + require.NoError(t, afero.WriteFile(fs, file, []byte(content), 0600)) + crontab := NewCrontab(nil).SetFile(file).SetFs(fs) + entries, err := crontab.GetEntries() + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "config.yaml", entries[0].configFile) + assert.Equal(t, "profile", entries[0].profileName) + assert.Equal(t, "backup", entries[0].commandName) + assert.Equal(t, "workdir", entries[0].workDir) + assert.Equal(t, "/some/bin/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", entries[0].commandLine) + }) +} diff --git a/examples/v2.yaml b/examples/v2.yaml index fbb1961e..d08743eb 100644 --- a/examples/v2.yaml +++ b/examples/v2.yaml @@ -7,12 +7,19 @@ global: priority: low # restic-binary: ~/fake_restic # legacy-arguments: true + scheduler: crontab:*:/tmp/crontab + groups: full-backup: description: Full Backup + continue-on-error: true profiles: - root - - src + - self + schedules: + backup: + at: "*:*" + profiles: default: description: Contains default parameters like repository and password file @@ -26,11 +33,13 @@ profiles: copy: password-file: key repository: "/Volumes/RAMDisk/{{ .Profile.Name }}-copy" + space: description: Repository contains space initialize: false password-file: key repository: "/Volumes/RAMDisk/with space" + documents: inherit: default backup: @@ -41,6 +50,7 @@ profiles: tag: - dev - "{{ .Profile.Name }}" + root: backup: schedule: "*:0,15,30,45" @@ -89,6 +99,7 @@ profiles: tag: - dev - "{{ .Profile.Name }}" + self: force-inactive-lock: true initialize: true @@ -123,6 +134,7 @@ profiles: initialize: true schedule: - "*:45" + prom: force-inactive-lock: true initialize: true @@ -142,6 +154,7 @@ profiles: - "{{ .Profile.Name }}" # exclude: # - examples/private + system: initialize: true no-cache: true @@ -153,6 +166,7 @@ profiles: schedule-permission: system forget: schedule: "weekly" + src: backup: check-before: true @@ -183,11 +197,13 @@ profiles: tag: - dev - "{{ .Profile.Name }}" + home: inherit: default # cache-dir: "${TMPDIR}.restic/" backup: source: "${HOME}/Projects" + stdin: backup: stdin: true @@ -200,6 +216,7 @@ profiles: tag: - dev - "{{ .Profile.Name }}" + dropbox: initialize: false inherit: default @@ -208,6 +225,7 @@ profiles: check-before: false no-error-on-warning: true source: "../../../../../Dropbox" + escape: initialize: true inherit: default From d51c9ba992768611880d66d95d5836a361dca6cf Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 3 Nov 2024 18:21:06 +0000 Subject: [PATCH 29/38] remove empty Scheduler struct and call Handler directly --- commands_schedule.go | 22 +++++++++--------- commands_test.go | 10 +++++---- schedule/job.go | 9 +++++++- schedule/job_test.go | 12 ++++------ schedule/removeonly_test.go | 8 ++++--- schedule/schedule_test.go | 30 ++++++++++++------------- schedule/scheduler.go | 45 ------------------------------------- schedule_jobs.go | 30 ++++++++++++------------- schedule_jobs_test.go | 18 +++++++-------- 9 files changed, 73 insertions(+), 111 deletions(-) delete mode 100644 schedule/scheduler.go diff --git a/commands_schedule.go b/commands_schedule.go index 3e2b0a4e..d1970dfc 100644 --- a/commands_schedule.go +++ b/commands_schedule.go @@ -24,9 +24,9 @@ func createSchedule(_ io.Writer, ctx commandContext) error { defer c.DisplayConfigurationIssues() type profileJobs struct { - scheduler schedule.SchedulerConfig - name string - jobs []*config.Schedule + schedulerConfig schedule.SchedulerConfig + name string + jobs []*config.Schedule } allJobs := make([]profileJobs, 0, 1) @@ -55,12 +55,12 @@ func createSchedule(_ io.Writer, ctx commandContext) error { } } - allJobs = append(allJobs, profileJobs{scheduler: scheduler, name: profileName, jobs: jobs}) + allJobs = append(allJobs, profileJobs{schedulerConfig: scheduler, name: profileName, jobs: jobs}) } // Step 2: Schedule all collected jobs for _, j := range allJobs { - err := scheduleJobs(schedule.NewHandler(j.scheduler), j.name, j.jobs) + err := scheduleJobs(schedule.NewHandler(j.schedulerConfig), j.jobs) if err != nil { return retryElevated(err, flags) } @@ -78,12 +78,12 @@ func removeSchedule(_ io.Writer, ctx commandContext) error { for _, profileName := range selectProfilesAndGroups(c, flags, args) { profileFlags := flagsForProfile(flags, profileName) - scheduler, jobs, err := getRemovableScheduleJobs(c, profileFlags) + schedulerConfig, jobs, err := getRemovableScheduleJobs(c, profileFlags) if err != nil { return err } - err = removeJobs(schedule.NewHandler(scheduler), profileName, jobs) + err = removeJobs(schedule.NewHandler(schedulerConfig), jobs) if err != nil { err = retryElevated(err, flags) } @@ -105,7 +105,7 @@ func statusSchedule(w io.Writer, ctx commandContext) error { // single profile or group if !slices.Contains(args, "--all") { - scheduler, schedules, _, err := getScheduleJobs(c, flags) + schedulerConfig, schedules, _, err := getScheduleJobs(c, flags) if err != nil { return err } @@ -113,7 +113,7 @@ func statusSchedule(w io.Writer, ctx commandContext) error { clog.Warningf("profile or group %s has no schedule", flags.name) return nil } - err = statusScheduleProfileOrGroup(scheduler, schedules, flags) + err = statusScheduleProfileOrGroup(schedulerConfig, schedules, flags) if err != nil { return err } @@ -166,8 +166,8 @@ func flagsForProfile(flags commandLineFlags, profileName string) commandLineFlag return flags } -func statusScheduleProfileOrGroup(scheduler schedule.SchedulerConfig, schedules []*config.Schedule, flags commandLineFlags) error { - err := statusJobs(schedule.NewHandler(scheduler), flags.name, schedules) +func statusScheduleProfileOrGroup(schedulerConfig schedule.SchedulerConfig, schedules []*config.Schedule, flags commandLineFlags) error { + err := statusJobs(schedule.NewHandler(schedulerConfig), flags.name, schedules) if err != nil { return retryElevated(err, flags) } diff --git a/commands_test.go b/commands_test.go index c10d8e1f..9c1105ef 100644 --- a/commands_test.go +++ b/commands_test.go @@ -14,6 +14,7 @@ import ( "github.com/creativeprojects/resticprofile/schedule" "github.com/creativeprojects/resticprofile/util/collect" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPanicCommand(t *testing.T) { @@ -66,14 +67,15 @@ schedule = "daily" for _, jobConfig := range schedules { configOrigin := jobConfig.ScheduleOrigin() - scheduler := schedule.NewScheduler(schedule.NewHandler(schedule.SchedulerDefaultOS{}), configOrigin.Name) - defer func(s *schedule.Scheduler) { s.Close() }(scheduler) // Capture current ref to scheduler to be able to close it when function returns. + handler := schedule.NewHandler(schedule.SchedulerDefaultOS{}) + require.NoError(t, handler.Init()) + defer func(s schedule.Handler) { s.Close() }(handler) // Capture current ref to scheduler to be able to close it when function returns. if configOrigin.Command == constants.CommandCheck { - assert.False(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly()) + assert.False(t, schedule.NewJob(handler, scheduleToConfig(jobConfig)).RemoveOnly()) declaredCount++ } else { - assert.True(t, scheduler.NewJob(scheduleToConfig(jobConfig)).RemoveOnly()) + assert.True(t, schedule.NewJob(handler, scheduleToConfig(jobConfig)).RemoveOnly()) } } diff --git a/schedule/job.go b/schedule/job.go index 2ec1a208..fab69003 100644 --- a/schedule/job.go +++ b/schedule/job.go @@ -8,13 +8,20 @@ import ( // Job: common code for all scheduling systems // +var ErrJobCanBeRemovedOnly = errors.New("job can be removed only") + // Job scheduler type Job struct { config *Config handler Handler } -var ErrJobCanBeRemovedOnly = errors.New("job can be removed only") +func NewJob(handler Handler, config *Config) *Job { + return &Job{ + config: config, + handler: handler, + } +} // Accessible checks if the current user is permitted to access the job func (j *Job) Accessible() bool { diff --git a/schedule/job_test.go b/schedule/job_test.go index f1aff8c6..2cf0ae78 100644 --- a/schedule/job_test.go +++ b/schedule/job_test.go @@ -17,8 +17,7 @@ func TestCreateJobHappyPath(t *testing.T) { handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil) handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(nil) - scheduler := schedule.NewScheduler(handler, "profile") - job := scheduler.NewJob(&schedule.Config{ + job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", CommandName: "backup", Schedules: []string{}, @@ -34,8 +33,7 @@ func TestCreateJobErrorParseSchedules(t *testing.T) { handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(nil) handler.EXPECT().ParseSchedules([]string{}).Return(nil, errors.New("test!")) - scheduler := schedule.NewScheduler(handler, "profile") - job := scheduler.NewJob(&schedule.Config{ + job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", CommandName: "backup", Schedules: []string{}, @@ -49,8 +47,7 @@ func TestCreateJobErrorDisplaySchedules(t *testing.T) { handler := mocks.NewHandler(t) handler.EXPECT().DisplaySchedules("profile", "backup", []string{}).Return(errors.New("test!")) - scheduler := schedule.NewScheduler(handler, "profile") - job := scheduler.NewJob(&schedule.Config{ + job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", CommandName: "backup", Schedules: []string{}, @@ -66,8 +63,7 @@ func TestCreateJobErrorCreate(t *testing.T) { handler.EXPECT().ParseSchedules([]string{}).Return([]*calendar.Event{}, nil) handler.EXPECT().CreateJob(mock.AnythingOfType("*schedule.Config"), []*calendar.Event{}, "user").Return(errors.New("test!")) - scheduler := schedule.NewScheduler(handler, "profile") - job := scheduler.NewJob(&schedule.Config{ + job := schedule.NewJob(handler, &schedule.Config{ ProfileName: "profile", CommandName: "backup", Schedules: []string{}, diff --git a/schedule/removeonly_test.go b/schedule/removeonly_test.go index 7abc9d70..91331f2a 100644 --- a/schedule/removeonly_test.go +++ b/schedule/removeonly_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewRemoveOnlyConfig(t *testing.T) { @@ -39,10 +40,11 @@ func TestDetectRemoveOnlyConfig(t *testing.T) { func TestRemoveOnlyJob(t *testing.T) { profile := "non-existent" - scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), profile) - defer scheduler.Close() + handler := NewHandler(SchedulerDefaultOS{}) + require.NoError(t, handler.Init()) + defer handler.Close() - job := scheduler.NewJob(NewRemoveOnlyConfig(profile, "check")) + job := NewJob(handler, NewRemoveOnlyConfig(profile, "check")) assert.Equal(t, ErrJobCanBeRemovedOnly, job.Create()) assert.Equal(t, ErrJobCanBeRemovedOnly, job.Status()) diff --git a/schedule/schedule_test.go b/schedule/schedule_test.go index 2112c0e4..0e96278c 100644 --- a/schedule/schedule_test.go +++ b/schedule/schedule_test.go @@ -31,9 +31,9 @@ func TestExecutableIsAbsoluteOnAllPlatforms(t *testing.T) { } func TestInit(t *testing.T) { - scheduler := NewScheduler(NewHandler(SchedulerDefaultOS{}), "profile") - err := scheduler.Init() - defer scheduler.Close() + handler := (NewHandler(SchedulerDefaultOS{})) + err := handler.Init() + defer handler.Close() require.NoError(t, err) } @@ -41,9 +41,9 @@ func TestCrondInit(t *testing.T) { if platform.IsWindows() { t.Skip("crond scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(SchedulerCrond{}), "profile") - err := scheduler.Init() - defer scheduler.Close() + handler := (NewHandler(SchedulerCrond{})) + err := handler.Init() + defer handler.Close() require.NoError(t, err) } @@ -51,26 +51,26 @@ func TestSystemdInit(t *testing.T) { if platform.IsWindows() || platform.IsDarwin() { t.Skip("systemd scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(SchedulerSystemd{}), "profile") - err := scheduler.Init() - defer scheduler.Close() + handler := (NewHandler(SchedulerSystemd{})) + err := handler.Init() + defer handler.Close() require.NoError(t, err) } func TestLaunchdInit(t *testing.T) { if !platform.IsDarwin() { t.Skip("launchd scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(SchedulerLaunchd{}), "profile") - err := scheduler.Init() - defer scheduler.Close() + handler := (NewHandler(SchedulerLaunchd{})) + err := handler.Init() + defer handler.Close() require.NoError(t, err) } func TestWindowsInit(t *testing.T) { if !platform.IsWindows() { t.Skip("windows scheduler is not supported on this platform") } - scheduler := NewScheduler(NewHandler(SchedulerWindows{}), "profile") - err := scheduler.Init() - defer scheduler.Close() + handler := (NewHandler(SchedulerWindows{})) + err := handler.Init() + defer handler.Close() require.NoError(t, err) } diff --git a/schedule/scheduler.go b/schedule/scheduler.go deleted file mode 100644 index 9bdaa7b3..00000000 --- a/schedule/scheduler.go +++ /dev/null @@ -1,45 +0,0 @@ -package schedule - -import ( - "github.com/creativeprojects/clog" -) - -// Scheduler -type Scheduler struct { - profileName string - handler Handler -} - -// NewScheduler creates a Scheduler object -func NewScheduler(handler Handler, profileName string) *Scheduler { - return &Scheduler{ - profileName: profileName, - handler: handler, - } -} - -// Init -func (s *Scheduler) Init() error { - return s.handler.Init() -} - -// Close -func (s *Scheduler) Close() { - s.handler.Close() -} - -// NewJob instantiates a Job object (of SchedulerJob interface) to schedule jobs -func (s *Scheduler) NewJob(config *Config) *Job { - return &Job{ - config: config, - handler: s.handler, - } -} - -// DisplayStatus -func (s *Scheduler) DisplayStatus() { - err := s.handler.DisplayStatus(s.profileName) - if err != nil { - clog.Error(err) - } -} diff --git a/schedule_jobs.go b/schedule_jobs.go index 3da55fbf..39072d48 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -10,7 +10,7 @@ import ( "github.com/creativeprojects/resticprofile/schedule" ) -func scheduleJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { +func scheduleJobs(handler schedule.Handler, configs []*config.Schedule) error { wd, err := os.Getwd() if err != nil { return err @@ -20,12 +20,11 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi return err } - scheduler := schedule.NewScheduler(handler, profileName) - err = scheduler.Init() + err = handler.Init() if err != nil { return err } - defer scheduler.Close() + defer handler.Close() for _, cfg := range configs { scheduleConfig := scheduleToConfig(cfg) @@ -44,7 +43,7 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi scheduleConfig.TimerDescription = fmt.Sprintf("%s timer for profile %s in %s", scheduleConfig.CommandName, scheduleConfig.ProfileName, scheduleConfig.ConfigFile) - job := scheduler.NewJob(scheduleConfig) + job := schedule.NewJob(handler, scheduleConfig) err = job.Create() if err != nil { return fmt.Errorf("error creating job %s/%s: %w", @@ -57,17 +56,16 @@ func scheduleJobs(handler schedule.Handler, profileName string, configs []*confi return nil } -func removeJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { - scheduler := schedule.NewScheduler(handler, profileName) - err := scheduler.Init() +func removeJobs(handler schedule.Handler, configs []*config.Schedule) error { + err := handler.Init() if err != nil { return err } - defer scheduler.Close() + defer handler.Close() for _, cfg := range configs { scheduleConfig := scheduleToConfig(cfg) - job := scheduler.NewJob(scheduleConfig) + job := schedule.NewJob(handler, scheduleConfig) // Skip over non-accessible, RemoveOnly jobs since they may not exist and must not causes errors if job.RemoveOnly() && !job.Accessible() { @@ -96,16 +94,15 @@ func removeJobs(handler schedule.Handler, profileName string, configs []*config. } func statusJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { - scheduler := schedule.NewScheduler(handler, profileName) - err := scheduler.Init() + err := handler.Init() if err != nil { return err } - defer scheduler.Close() + defer handler.Close() for _, cfg := range configs { scheduleConfig := scheduleToConfig(cfg) - job := scheduler.NewJob(scheduleConfig) + job := schedule.NewJob(handler, scheduleConfig) err := job.Status() if err != nil { if errors.Is(err, schedule.ErrScheduledJobNotFound) { @@ -124,7 +121,10 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. err) } } - scheduler.DisplayStatus() + err = handler.DisplayStatus(profileName) + if err != nil { + clog.Error(err) + } return nil } diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index 9ef46f5f..abe3e544 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -25,7 +25,7 @@ func TestScheduleNilJobs(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - err := scheduleJobs(handler, "profile", nil) + err := scheduleJobs(handler, nil) assert.NoError(t, err) } @@ -49,7 +49,7 @@ func TestSimpleScheduleJob(t *testing.T) { scheduleConfig := configForJob("backup", "sched") scheduleConfig.ConfigFile = "config file" - err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := scheduleJobs(handler, []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -68,7 +68,7 @@ func TestFailScheduleJob(t *testing.T) { Return(errors.New("error creating job")) scheduleConfig := configForJob("backup", "sched") - err := scheduleJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := scheduleJobs(handler, []*config.Schedule{scheduleConfig}) assert.Error(t, err) } @@ -79,7 +79,7 @@ func TestRemoveNilJobs(t *testing.T) { handler.EXPECT().Init().Return(nil) handler.EXPECT().Close() - err := removeJobs(handler, "profile", nil) + err := removeJobs(handler, nil) assert.NoError(t, err) } @@ -97,7 +97,7 @@ func TestRemoveJob(t *testing.T) { }) scheduleConfig := configForJob("backup", "sched") - err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := removeJobs(handler, []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -115,7 +115,7 @@ func TestRemoveJobNoConfig(t *testing.T) { }) scheduleConfig := configForJob("backup") - err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := removeJobs(handler, []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -129,7 +129,7 @@ func TestFailRemoveJob(t *testing.T) { Return(errors.New("error removing job")) scheduleConfig := configForJob("backup", "sched") - err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := removeJobs(handler, []*config.Schedule{scheduleConfig}) assert.Error(t, err) } @@ -143,7 +143,7 @@ func TestNoFailRemoveUnknownJob(t *testing.T) { Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup", "sched") - err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := removeJobs(handler, []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } @@ -157,7 +157,7 @@ func TestNoFailRemoveUnknownRemoveOnlyJob(t *testing.T) { Return(schedule.ErrScheduledJobNotFound) scheduleConfig := configForJob("backup") - err := removeJobs(handler, "profile", []*config.Schedule{scheduleConfig}) + err := removeJobs(handler, []*config.Schedule{scheduleConfig}) assert.NoError(t, err) } From c11d1050d2decb2b9f2604a0d1f9e6ad87e4d8ff Mon Sep 17 00:00:00 2001 From: Fred Date: Sun, 3 Nov 2024 22:14:41 +0000 Subject: [PATCH 30/38] removeJobs now searches for existing scheduled jobs to remove --- commands_schedule.go | 44 +++++++++++++++++---------- schedule_jobs.go | 30 +++++++++++++++++++ schedule_jobs_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++ systemd/read.go | 2 +- 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/commands_schedule.go b/commands_schedule.go index d1970dfc..9214de58 100644 --- a/commands_schedule.go +++ b/commands_schedule.go @@ -70,30 +70,44 @@ func createSchedule(_ io.Writer, ctx commandContext) error { } func removeSchedule(_ io.Writer, ctx commandContext) error { + var err error c := ctx.config flags := ctx.flags args := ctx.request.arguments - // Unschedule all jobs of all selected profiles - for _, profileName := range selectProfilesAndGroups(c, flags, args) { - profileFlags := flagsForProfile(flags, profileName) + if slices.Contains(args, "--legacy") { // TODO: remove this option in the future + // Unschedule all jobs of all selected profiles + for _, profileName := range selectProfilesAndGroups(c, flags, args) { + profileFlags := flagsForProfile(flags, profileName) - schedulerConfig, jobs, err := getRemovableScheduleJobs(c, profileFlags) - if err != nil { - return err - } + schedulerConfig, jobs, err := getRemovableScheduleJobs(c, profileFlags) + if err != nil { + return err + } - err = removeJobs(schedule.NewHandler(schedulerConfig), jobs) - if err != nil { - err = retryElevated(err, flags) - } - if err != nil { - // we keep trying to remove the other jobs - clog.Error(err) + err = removeJobs(schedule.NewHandler(schedulerConfig), jobs) + if err != nil { + err = retryElevated(err, flags) + } + if err != nil { + // we keep trying to remove the other jobs + clog.Error(err) + } } + return nil } - return nil + profileName := ctx.request.profile + if slices.Contains(args, "--all") { + // Unschedule all jobs of all profiles + profileName = "" + } + schedulerConfig := schedule.NewSchedulerConfig(ctx.global) + err = removeScheduledJobs(schedule.NewHandler(schedulerConfig), ctx.config.GetConfigFile(), profileName) + if err != nil { + err = retryElevated(err, flags) + } + return err } func statusSchedule(w io.Writer, ctx commandContext) error { diff --git a/schedule_jobs.go b/schedule_jobs.go index 39072d48..adf931cc 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -93,6 +93,36 @@ func removeJobs(handler schedule.Handler, configs []*config.Schedule) error { return nil } +func removeScheduledJobs(handler schedule.Handler, configFile, profileName string) error { + err := handler.Init() + if err != nil { + return err + } + defer handler.Close() + + clog.Debugf("looking up schedules from configuration file %s", configFile) + configs, err := handler.Scheduled(profileName) + if err != nil { + return err + } + if len(configs) == 0 { + clog.Info("no scheduled jobs found") + return nil + } + for _, cfg := range configs { + if cfg.ConfigFile != configFile { + clog.Debugf("skipping job %s/%s from configuration file %s", cfg.ProfileName, cfg.CommandName, cfg.ConfigFile) + continue + } + err = handler.RemoveJob(&cfg, cfg.Permission) + if err != nil { + clog.Error(err) + } + clog.Infof("scheduled job %s/%s removed", cfg.ProfileName, cfg.CommandName) + } + return nil +} + func statusJobs(handler schedule.Handler, profileName string, configs []*config.Schedule) error { err := handler.Init() if err != nil { diff --git a/schedule_jobs_test.go b/schedule_jobs_test.go index abe3e544..ef3d8ddf 100644 --- a/schedule_jobs_test.go +++ b/schedule_jobs_test.go @@ -199,3 +199,72 @@ func TestStatusRemoveOnlyJob(t *testing.T) { err := statusJobs(handler, "profile", []*config.Schedule{scheduleConfig}) assert.Error(t, err) } + +func TestRemoveScheduledJobs(t *testing.T) { + testCases := []struct { + removeProfileName string + fromConfigFile string + scheduledConfigs []schedule.Config + removedConfigs []schedule.Config + permission string + }{ + { + removeProfileName: "profile_no_config", + fromConfigFile: "configFile", + scheduledConfigs: []schedule.Config{}, + removedConfigs: []schedule.Config{}, + permission: "user", + }, + { + removeProfileName: "profile_one_config_to_remove", + fromConfigFile: "configFile", + scheduledConfigs: []schedule.Config{ + { + ProfileName: "profile_one_config_to_remove", + CommandName: "backup", + ConfigFile: "configFile", + Permission: "user", + }, + }, + removedConfigs: []schedule.Config{ + { + ProfileName: "profile_one_config_to_remove", + CommandName: "backup", + ConfigFile: "configFile", + Permission: "user", + }, + }, + permission: "user", + }, + { + removeProfileName: "profile_different_config_file", + fromConfigFile: "configFile", + scheduledConfigs: []schedule.Config{ + { + ProfileName: "profile_different_config_file", + CommandName: "backup", + ConfigFile: "other_configFile", + Permission: "user", + }, + }, + removedConfigs: []schedule.Config{}, + permission: "user", + }, + } + + for _, tc := range testCases { + t.Run(tc.removeProfileName, func(t *testing.T) { + handler := mocks.NewHandler(t) + handler.EXPECT().Init().Return(nil) + handler.EXPECT().Close() + + handler.EXPECT().Scheduled(tc.removeProfileName).Return(tc.scheduledConfigs, nil) + for _, cfg := range tc.removedConfigs { + handler.EXPECT().RemoveJob(&cfg, tc.permission).Return(nil) + } + + err := removeScheduledJobs(handler, tc.fromConfigFile, tc.removeProfileName) + assert.NoError(t, err) + }) + } +} diff --git a/systemd/read.go b/systemd/read.go index e8cbbc04..19e11935 100644 --- a/systemd/read.go +++ b/systemd/read.go @@ -44,7 +44,7 @@ func Read(unit string, unitType UnitType) (*Config, error) { IOSchedulingClass: getIntegerValue(serviceSections, "Service", "IOSchedulingClass"), IOSchedulingPriority: getIntegerValue(serviceSections, "Service", "IOSchedulingPriority"), Schedules: getValues(timerSections, "Timer", "OnCalendar"), - Priority: "background", //TODO fix this hard-coded value + Priority: "background", // TODO fix this hard-coded value } return cfg, nil } From 53a95785676f623e41111017f4495bed7e13363e Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 Nov 2024 13:25:31 +0000 Subject: [PATCH 31/38] only display scheduled jobs in status command --- commands_schedule.go | 72 +++++++++++++++++++++---------------- examples/linux.yaml | 5 +++ schedule/handler_systemd.go | 6 +++- schedule_jobs.go | 37 ++++++++++++++++++- 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/commands_schedule.go b/commands_schedule.go index 9214de58..79a74b21 100644 --- a/commands_schedule.go +++ b/commands_schedule.go @@ -117,41 +117,53 @@ func statusSchedule(w io.Writer, ctx commandContext) error { defer c.DisplayConfigurationIssues() - // single profile or group - if !slices.Contains(args, "--all") { - schedulerConfig, schedules, _, err := getScheduleJobs(c, flags) - if err != nil { - return err - } - if len(schedules) == 0 { - clog.Warningf("profile or group %s has no schedule", flags.name) + if slices.Contains(flags.resticArgs, "--legacy") { // TODO: remove this option in the future + // single profile or group + if !slices.Contains(args, "--all") { + schedulerConfig, schedules, _, err := getScheduleJobs(c, flags) + if err != nil { + return err + } + if len(schedules) == 0 { + clog.Warningf("profile or group %s has no schedule", flags.name) + return nil + } + err = statusScheduleProfileOrGroup(schedulerConfig, schedules, flags) + if err != nil { + return err + } return nil } - err = statusScheduleProfileOrGroup(schedulerConfig, schedules, flags) - if err != nil { - return err - } - return nil - } - // all profiles and groups - for _, profileName := range selectProfilesAndGroups(c, flags, args) { - profileFlags := flagsForProfile(flags, profileName) - scheduler, schedules, schedulable, err := getScheduleJobs(c, profileFlags) - if err != nil { - return err - } - // it's all fine if this profile has no schedule - if len(schedules) == 0 { - continue - } - clog.Infof("%s %q:", cases.Title(language.English).String(schedulable.Kind()), profileName) - err = statusScheduleProfileOrGroup(scheduler, schedules, profileFlags) - if err != nil { - // display the error but keep going with the other profiles - clog.Error(err) + // all profiles and groups + for _, profileName := range selectProfilesAndGroups(c, flags, args) { + profileFlags := flagsForProfile(flags, profileName) + scheduler, schedules, schedulable, err := getScheduleJobs(c, profileFlags) + if err != nil { + return err + } + // it's all fine if this profile has no schedule + if len(schedules) == 0 { + continue + } + clog.Infof("%s %q:", cases.Title(language.English).String(schedulable.Kind()), profileName) + err = statusScheduleProfileOrGroup(scheduler, schedules, profileFlags) + if err != nil { + // display the error but keep going with the other profiles + clog.Error(err) + } } } + profileName := ctx.request.profile + if slices.Contains(args, "--all") { + // Unschedule all jobs of all profiles + profileName = "" + } + schedulerConfig := schedule.NewSchedulerConfig(ctx.global) + err := statusScheduledJobs(schedule.NewHandler(schedulerConfig), ctx.config.GetConfigFile(), profileName) + if err != nil { + return retryElevated(err, flags) + } return nil } diff --git a/examples/linux.yaml b/examples/linux.yaml index 45c0bd28..8430fc83 100644 --- a/examples/linux.yaml +++ b/examples/linux.yaml @@ -12,6 +12,7 @@ global: ionice-class: 3 ionice-level: 7 nice: 19 + scheduler: crontab:*:/tmp/crontab default: password-file: key @@ -100,10 +101,14 @@ self: backup: extended-status: true source: ./ + # schedule: + # at: "*:15" + # permission: system copy: initialize: true schedule-permission: user schedule: + - "*:15" - "*:45" src: diff --git a/schedule/handler_systemd.go b/schedule/handler_systemd.go index 28be141e..99c90260 100644 --- a/schedule/handler_systemd.go +++ b/schedule/handler_systemd.go @@ -29,6 +29,7 @@ const ( systemctlReload = "daemon-reload" flagUserUnit = "--user" flagNoPager = "--no-pager" + unitNotFound = "not-found" // https://www.freedesktop.org/software/systemd/man/systemctl.html#Exit%20status codeStatusNotRunning = 3 @@ -383,7 +384,7 @@ func unitLoaded(serviceName string, unitType systemd.UnitType) (bool, error) { return false, err } return slices.ContainsFunc(units, func(unit SystemdUnit) bool { - return unit.Unit == serviceName && unit.Load != "not-found" + return unit.Unit == serviceName && unit.Load != unitNotFound }), nil } @@ -394,6 +395,9 @@ func getConfigs(profileName string, unitType systemd.UnitType) ([]Config, error) } configs := make([]Config, 0, len(units)) for _, unit := range units { + if unit.Load == unitNotFound { + continue + } cfg, err := systemd.Read(unit.Unit, unitType) if err != nil { clog.Errorf("cannot read information from unit %q: %s", unit.Unit, err) diff --git a/schedule_jobs.go b/schedule_jobs.go index adf931cc..bbebaa13 100644 --- a/schedule_jobs.go +++ b/schedule_jobs.go @@ -114,7 +114,8 @@ func removeScheduledJobs(handler schedule.Handler, configFile, profileName strin clog.Debugf("skipping job %s/%s from configuration file %s", cfg.ProfileName, cfg.CommandName, cfg.ConfigFile) continue } - err = handler.RemoveJob(&cfg, cfg.Permission) + job := schedule.NewJob(handler, &cfg) + err = job.Remove() if err != nil { clog.Error(err) } @@ -158,6 +159,40 @@ func statusJobs(handler schedule.Handler, profileName string, configs []*config. return nil } +func statusScheduledJobs(handler schedule.Handler, configFile, profileName string) error { + err := handler.Init() + if err != nil { + return err + } + defer handler.Close() + + clog.Debugf("looking up schedules from configuration file %s", configFile) + configs, err := handler.Scheduled(profileName) + if err != nil { + return err + } + if len(configs) == 0 { + clog.Info("no scheduled jobs found") + return nil + } + for _, cfg := range configs { + if cfg.ConfigFile != configFile { + clog.Debugf("skipping job %s/%s from configuration file %s", cfg.ProfileName, cfg.CommandName, cfg.ConfigFile) + continue + } + job := schedule.NewJob(handler, &cfg) + err := job.Status() + if err != nil { + clog.Error(err) + } + } + err = handler.DisplayStatus(profileName) + if err != nil { + clog.Error(err) + } + return nil +} + func scheduleToConfig(sched *config.Schedule) *schedule.Config { origin := sched.ScheduleOrigin() if !sched.HasSchedules() { From 3de60115fc903a46ae7bcfe1f72eafe929caf50d Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 Nov 2024 16:27:37 +0000 Subject: [PATCH 32/38] parse crontab line back into schedule event --- commands_schedule_test.go | 25 ++++++++-- config/config.go | 5 +- config/load_options.go | 7 +++ crond/crontab.go | 33 ++++++++----- crond/crontab_test.go | 37 ++++++++++----- crond/parse_event.go | 86 ++++++++++++++++++++++++++++++++++ crond/parse_event_test.go | 46 ++++++++++++++++++ schedule/handler_crond_test.go | 4 +- 8 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 config/load_options.go create mode 100644 crond/parse_event.go create mode 100644 crond/parse_event_test.go diff --git a/commands_schedule_test.go b/commands_schedule_test.go index defe4485..181aa5ba 100644 --- a/commands_schedule_test.go +++ b/commands_schedule_test.go @@ -37,24 +37,38 @@ profiles: profile-schedule-inline: backup: - schedule: daily + schedule: "*:00,30" profile-schedule-struct: backup: schedule: - at: daily + at: "*:00,30" ` +const scheduleIntegrationTestsCrontab = ` +### this content was generated by resticprofile, please leave this line intact ### +00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile +### end of resticprofile content, please leave this line intact ### +` + func TestCommandsIntegrationUsingCrontab(t *testing.T) { crontab := filepath.Join(t.TempDir(), "crontab") + err := os.WriteFile(crontab, []byte(scheduleIntegrationTestsCrontab), 0o600) + require.NoError(t, err) + cfg, err := config.Load( bytes.NewBufferString(fmt.Sprintf(scheduleIntegrationTestsConfiguration, crontab)), config.FormatYAML, + config.WithConfigFile("config.yaml"), ) require.NoError(t, err) require.NotNil(t, cfg) + global, err := cfg.GetGlobalSection() + require.NoError(t, err) + require.NotNil(t, global) + testCases := []struct { name string contains string @@ -62,15 +76,15 @@ func TestCommandsIntegrationUsingCrontab(t *testing.T) { }{ { name: "", - err: config.ErrNotFound, + err: nil, }, { name: "profile-schedule-inline", - contains: "Original form: daily", + contains: "Original form: *-*-* *:00,30:00", }, { name: "profile-schedule-struct", - contains: "Original form: daily", + contains: "Original form: *-*-* *:00,30:00", }, } @@ -82,6 +96,7 @@ func TestCommandsIntegrationUsingCrontab(t *testing.T) { flags: commandLineFlags{ name: tc.name, }, + global: global, }, } output := &bytes.Buffer{} diff --git a/config/config.go b/config/config.go index 46bf6e99..b35bdfca 100644 --- a/config/config.go +++ b/config/config.go @@ -131,8 +131,11 @@ func LoadFile(configFile, format string) (config *Config, err error) { // Load configuration from reader // This should only be used for unit tests -func Load(input io.Reader, format string) (config *Config, err error) { +func Load(input io.Reader, format string, options ...func(cfg *Config)) (config *Config, err error) { config = newConfig(format) + for _, option := range options { + option(config) + } err = config.addTemplate(input, config.configFile, true) return } diff --git a/config/load_options.go b/config/load_options.go new file mode 100644 index 00000000..2b1ecf92 --- /dev/null +++ b/config/load_options.go @@ -0,0 +1,7 @@ +package config + +func WithConfigFile(configFile string) func(cfg *Config) { + return func(cfg *Config) { + cfg.configFile = configFile + } +} diff --git a/crond/crontab.go b/crond/crontab.go index cfe10fbd..39a0fd00 100644 --- a/crond/crontab.go +++ b/crond/crontab.go @@ -1,13 +1,13 @@ package crond import ( + "errors" "fmt" "io" "os/user" "regexp" "strings" - "github.com/creativeprojects/resticprofile/calendar" "github.com/spf13/afero" ) @@ -27,6 +27,10 @@ var ( runSchedulePattern = regexp.MustCompile(timeExp + userExp + workDirExp + configExp + runScheduleExp) ) +var ( + ErrEntryNoMatch = errors.New("line doesn't match a typical resticprofile entry") +) + type Crontab struct { file, binary, charset, user string entries []Entry @@ -326,7 +330,7 @@ func parseEntries(crontab string) []Entry { if len(line) == 0 || strings.HasPrefix(line, "#") { continue } - entry := parseEntry(line) + entry, _ := parseEntry(line) if entry == nil { continue } @@ -335,7 +339,7 @@ func parseEntries(crontab string) []Entry { return entries } -func parseEntry(line string) *Entry { +func parseEntry(line string) (*Entry, error) { // should match lines like: // 00,15,30,45 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup // 00,15,30,45 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup @@ -347,35 +351,38 @@ func parseEntry(line string) *Entry { // try legacy pattern first matches := legacyPattern.FindStringSubmatch(line) if len(matches) == 10 { + event, err := parseEvent(matches[1]) + if err != nil { + return nil, fmt.Errorf("cannot parse %q: %w", matches[1], err) + } return &Entry{ - event: parseEvent(matches[1]), + event: event, user: getUserValue(matches[3]), workDir: getWorkdirValue(matches[4]), commandLine: matches[5], configFile: matches[6], profileName: matches[7], commandName: matches[9], - } + }, nil } // then try the current pattern matches = runSchedulePattern.FindStringSubmatch(line) if len(matches) == 9 { + event, err := parseEvent(matches[1]) + if err != nil { + return nil, fmt.Errorf("cannot parse %q: %w", matches[1], err) + } return &Entry{ - event: parseEvent(matches[1]), + event: event, user: getUserValue(matches[3]), workDir: getWorkdirValue(matches[4]), commandLine: matches[5], configFile: matches[6], commandName: matches[7], profileName: matches[8], - } + }, nil } - return nil -} - -func parseEvent(_ string) *calendar.Event { - event := calendar.NewEvent() - return event + return nil, ErrEntryNoMatch } func getUserValue(user string) string { diff --git a/crond/crontab_test.go b/crond/crontab_test.go index f790f3a5..e12bc34d 100644 --- a/crond/crontab_test.go +++ b/crond/crontab_test.go @@ -364,57 +364,68 @@ func TestUseCrontabBinary(t *testing.T) { } func TestParseEntry(t *testing.T) { + scheduledEvent := calendar.NewEvent(func(e *calendar.Event) { + _ = e.Second.AddValue(0) + _ = e.Minute.AddValue(0) + _ = e.Minute.AddValue(30) + }) testData := []struct { source string expectEntry *Entry }{ { source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, }, { source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml --name profile --log backup.log backup"}, }, { source: "00,30 * * * * /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, }, { source: "00,30 * * * * /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", - expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, }, { source: "00,30 * * * * user /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, }, { source: "00,30 * * * * user /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", - expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, }, { source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, }, { source: "00,30 * * * * cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", - expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, }, { source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile", - expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config config.yaml run-schedule backup@profile"}, }, { source: "00,30 * * * * user cd /workdir && /home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile", - expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, + expectEntry: &Entry{configFile: "config file.yaml", profileName: "profile", commandName: "backup", user: "user", workDir: "/workdir", event: scheduledEvent, commandLine: "/home/resticprofile --no-ansi --config \"config file.yaml\" run-schedule backup@profile"}, }, } for _, testRun := range testData { t.Run("", func(t *testing.T) { - entry := parseEntry(testRun.source) - testRun.expectEntry.event = calendar.NewEvent() - assert.Equal(t, testRun.expectEntry, entry) + entry, err := parseEntry(testRun.source) + require.NoError(t, err) + assert.Equal(t, testRun.expectEntry.CommandLine(), entry.CommandLine()) + assert.Equal(t, testRun.expectEntry.CommandName(), entry.CommandName()) + assert.Equal(t, testRun.expectEntry.ConfigFile(), entry.ConfigFile()) + assert.Equal(t, testRun.expectEntry.ProfileName(), entry.ProfileName()) + assert.Equal(t, testRun.expectEntry.User(), entry.User()) + assert.Equal(t, testRun.expectEntry.WorkDir(), entry.WorkDir()) + assert.Equal(t, testRun.expectEntry.Event().String(), entry.Event().String()) }) } } diff --git a/crond/parse_event.go b/crond/parse_event.go new file mode 100644 index 00000000..be345594 --- /dev/null +++ b/crond/parse_event.go @@ -0,0 +1,86 @@ +package crond + +import ( + "fmt" + "strconv" + "strings" + + "github.com/creativeprojects/resticprofile/calendar" +) + +func parseEvent(source string) (*calendar.Event, error) { + event := calendar.NewEvent() + source = strings.ReplaceAll(source, "\t", " ") + source = strings.ReplaceAll(source, " ", " ") + source = strings.TrimSpace(source) + parts := strings.Split(source, " ") + if len(parts) != 5 { + return nil, fmt.Errorf("expected 5 fields but found %d: %q", len(parts), source) + } + + err := event.Second.AddValue(0) + if err != nil { + return nil, err + } + + for index, eventField := range []*calendar.Value{ + event.Minute, + event.Hour, + event.Day, + event.Month, + event.WeekDay, + } { + err := parseField(parts[index], eventField) + if err != nil { + return event, fmt.Errorf("error parsing %q: %w", parts[index], err) + } + } + return event, nil +} + +func parseField(field string, eventField *calendar.Value) error { + if field == "*" { + return nil + } + // list of values + if strings.Contains(field, ",") { + parts := strings.Split(field, ",") + for _, part := range parts { + err := parseField(part, eventField) + if err != nil { + return err + } + } + return nil + } + // range of values + if strings.Contains(field, "-") { + parts := strings.Split(field, "-") + if len(parts) != 2 { + return fmt.Errorf("expecting 2 values, found %d: %q", len(parts), field) + } + start, err := strconv.Atoi(parts[0]) + if err != nil { + return err + } + end, err := strconv.Atoi(parts[1]) + if err != nil { + return err + } + err = eventField.AddRange(start, end) + if err != nil { + return err + } + return nil + } + // single value + value, err := strconv.Atoi(field) + if err != nil { + return err + } + err = eventField.AddValue(value) + if err != nil { + return err + } + return nil +} diff --git a/crond/parse_event_test.go b/crond/parse_event_test.go new file mode 100644 index 00000000..6a87cab2 --- /dev/null +++ b/crond/parse_event_test.go @@ -0,0 +1,46 @@ +package crond + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseEvents(t *testing.T) { + testData := []struct { + cron string + expected string + }{ + {"00 00 * * 1,2,3,4,6,0", "Sun..Thu,Sat *-*-* 00:00:00"}, + {"23 01,02 * * 1,0", "Sun,Mon *-*-* 01,02:23:00"}, + {"00 00 01 * 3", "Wed *-*-01 00:00:00"}, + {"00 00 01 * 3", "Wed *-*-01 00:00:00"}, + {"48 17 * * 3", "Wed *-*-* 17:48:00"}, + {"02 01 15 10 2,3,4,5,6", "Tue..Sat *-10-15 01:02:00"}, + {"00 00 07 * *", "*-*-07 00:00:00"}, + {"00 00 15 10 *", "*-10-15 00:00:00"}, + {"00 17 * 12 1", "Mon *-12-* 17:00:00"}, + {"00 17 * 12 0", "Sun *-12-* 17:00:00"}, + {"30 * 01-03 * 1,5", "Mon,Fri *-*-01..03 *:30:00"}, + {"10,20,30 12-14 * * *", "*-*-* 12..14:10,20,30:00"}, + {"05 08 05 03 *", "*-03-05 08:05:00"}, + {"05 08 * * *", "*-*-* 08:05:00"}, + {"40 05 * * *", "*-*-* 05:40:00"}, + {"05 08 05 12 6,0", "Sun,Sat *-12-05 08:05:00"}, + {"05 08 * * 6,0", "Sun,Sat *-*-* 08:05:00"}, + {"40 05 05 03 *", "*-03-05 05:40:00"}, + {"00 00 05 02-04 *", "*-02..04-05 00:00:00"}, + {"00 00 05 03 *", "*-03-05 00:00:00"}, + {"00 00 * * 1,2,3,4,5,6,0", "Sun..Sat *-*-* 00:00:00"}, + {"00 00 * * 0,1", "Sun,Mon *-*-* 00:00:00"}, + } + + for _, testRun := range testData { + t.Run(testRun.cron, func(t *testing.T) { + event, err := parseEvent(testRun.cron) + require.NoError(t, err) + assert.Equal(t, testRun.expected, event.String()) + }) + } +} diff --git a/schedule/handler_crond_test.go b/schedule/handler_crond_test.go index 4a87f93b..e8c277fd 100644 --- a/schedule/handler_crond_test.go +++ b/schedule/handler_crond_test.go @@ -27,7 +27,7 @@ func TestReadingCrondScheduled(t *testing.T) { Command: "/bin/resticprofile", Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "examples/dev.yaml", "--name", "self", "check"}), WorkingDirectory: "/resticprofile", - Schedules: []string{"*-*-* *:*:*"}, // no parsing of crontab schedules + Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "examples/dev.yaml", }, schedules: []*calendar.Event{ @@ -41,7 +41,7 @@ func TestReadingCrondScheduled(t *testing.T) { Command: "/bin/resticprofile", Arguments: NewCommandArguments([]string{"--no-ansi", "--config", "config file.yaml", "--name", "test.scheduled", "backup"}), WorkingDirectory: "/resticprofile", - Schedules: []string{"*-*-* *:*:*"}, // no parsing of crontab schedules + Schedules: []string{"*-*-* *:00:00"}, ConfigFile: "config file.yaml", }, schedules: []*calendar.Event{ From c80e0c803400ae32295c14db952600b49b8cb09a Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 Nov 2024 19:13:56 +0000 Subject: [PATCH 33/38] converts back launchd schedule into calendar event --- schedule/calendar_interval.go | 139 +++++++++++++++++++++++++++++ schedule/calendar_interval_test.go | 55 ++++++++++++ schedule/handler_darwin.go | 102 +-------------------- schedule/handler_darwin_test.go | 14 ++- schedule/tree_darwin.go | 2 +- 5 files changed, 207 insertions(+), 105 deletions(-) create mode 100644 schedule/calendar_interval.go create mode 100644 schedule/calendar_interval_test.go diff --git a/schedule/calendar_interval.go b/schedule/calendar_interval.go new file mode 100644 index 00000000..c99a3a73 --- /dev/null +++ b/schedule/calendar_interval.go @@ -0,0 +1,139 @@ +//go:build darwin + +package schedule + +import "github.com/creativeprojects/resticprofile/calendar" + +const ( + intervalMinute = "Minute" + intervalHour = "Hour" + intervalWeekday = "Weekday" + intervalDay = "Day" + intervalMonth = "Month" +) + +// CalendarInterval contains date and time trigger definition inside a map. +// keys of the map should be: +// +// "Month" Month of year (1..12, 1 being January) +// "Day" Day of month (1..31) +// "Weekday" Day of week (0..7, 0 and 7 being Sunday) +// "Hour" Hour of day (0..23) +// "Minute" Minute of hour (0..59) +type CalendarInterval map[string]int + +// newCalendarInterval creates a new map of 5 elements +func newCalendarInterval() *CalendarInterval { + var value CalendarInterval = make(map[string]int, 5) + return &value +} + +func (c *CalendarInterval) clone() *CalendarInterval { + clone := newCalendarInterval() + for key, value := range *c { + (*clone)[key] = value + } + return clone +} + +// getCalendarIntervalsFromSchedules converts schedules into launchd calendar events +// let's say we've setup these rules: +// +// Mon-Fri *-*-* *:0,30:00 = every half hour +// Sat *-*-* 0,12:00:00 = twice a day on saturday +// *-*-01 *:*:* = the first of each month +// +// it should translate as: +// 1st rule +// +// Weekday = Monday, Minute = 0 +// Weekday = Monday, Minute = 30 +// ... same from Tuesday to Thurday +// Weekday = Friday, Minute = 0 +// Weekday = Friday, Minute = 30 +// +// Total of 10 rules +// 2nd rule +// +// Weekday = Saturday, Hour = 0 +// Weekday = Saturday, Hour = 12 +// +// Total of 2 rules +// 3rd rule +// +// Day = 1 +// +// Total of 1 rule +func getCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval { + entries := make([]CalendarInterval, 0, len(schedules)) + for _, schedule := range schedules { + entries = append(entries, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(schedule))...) + } + return entries +} + +func getCalendarIntervalsFromScheduleTree(tree []*treeElement) []CalendarInterval { + entries := make([]CalendarInterval, 0) + for _, element := range tree { + // creates a new calendar entry for each tip of the branch + newEntry := newCalendarInterval() + fillInValueFromScheduleTreeElement(newEntry, element, &entries) + } + return entries +} + +func fillInValueFromScheduleTreeElement(currentEntry *CalendarInterval, element *treeElement, entries *[]CalendarInterval) { + setCalendarIntervalValueFromType(currentEntry, element.value, element.elementType) + if len(element.subElements) == 0 { + // end of the line, this entry is finished + *entries = append(*entries, *currentEntry) + return + } + for _, subElement := range element.subElements { + // new branch means new calendar entry + fillInValueFromScheduleTreeElement(currentEntry.clone(), subElement, entries) + } +} + +func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeValue calendar.TypeValue) { + if entry == nil { + entry = newCalendarInterval() + } + switch typeValue { + case calendar.TypeWeekDay: + (*entry)[intervalWeekday] = value + case calendar.TypeMonth: + (*entry)[intervalMonth] = value + case calendar.TypeDay: + (*entry)[intervalDay] = value + case calendar.TypeHour: + (*entry)[intervalHour] = value + case calendar.TypeMinute: + (*entry)[intervalMinute] = value + } +} + +// parseCalendarIntervals converts calendar intervals into a single calendar event. +// TODO: find a pattern on how to split into multiple events when needed +func parseCalendarIntervals(intervals []CalendarInterval) []string { + event := calendar.NewEvent(func(e *calendar.Event) { + _ = e.Second.AddValue(0) + }) + for _, interval := range intervals { + for key, value := range interval { + switch key { + case intervalMinute: + _ = event.Minute.AddValue(value) + case intervalHour: + _ = event.Hour.AddValue(value) + case intervalWeekday: + _ = event.WeekDay.AddValue(value) + case intervalDay: + _ = event.Day.AddValue(value) + case intervalMonth: + _ = event.Month.AddValue(value) + } + } + } + return []string{event.String()} +} diff --git a/schedule/calendar_interval_test.go b/schedule/calendar_interval_test.go new file mode 100644 index 00000000..8936de14 --- /dev/null +++ b/schedule/calendar_interval_test.go @@ -0,0 +1,55 @@ +//go:build darwin + +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseCalendarIntervals(t *testing.T) { + tests := []struct { + name string + intervals []CalendarInterval + expected []string + }{ + { + name: "Single interval", + intervals: []CalendarInterval{ + { + intervalMinute: 30, + intervalHour: 14, + intervalWeekday: 3, + intervalDay: 15, + intervalMonth: 6, + }, + }, + expected: []string{"Wed *-06-15 14:30:00"}, + }, + { + name: "Multiple intervals", + intervals: []CalendarInterval{ + { + intervalMinute: 0, + }, + { + intervalMinute: 30, + }, + }, + expected: []string{"*-*-* *:00,30:00"}, + }, + { + name: "Empty intervals", + intervals: []CalendarInterval{}, + expected: []string{"*-*-* *:*:00"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseCalendarIntervals(tt.intervals) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index c6c4adf7..e1775a5c 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -311,6 +311,7 @@ func (h *HandlerLaunchd) getJobConfig(filename string) (*Config, error) { ConfigFile: args.ConfigFile(), Arguments: args, WorkingDirectory: launchdJob.WorkingDirectory, + Schedules: parseCalendarIntervals(launchdJob.StartCalendarInterval), } return job, nil } @@ -359,30 +360,6 @@ var ( _ Handler = &HandlerLaunchd{} ) -// CalendarInterval contains date and time trigger definition inside a map. -// keys of the map should be: -// -// "Month" Month of year (1..12, 1 being January) -// "Day" Day of month (1..31) -// "Weekday" Day of week (0..7, 0 and 7 being Sunday) -// "Hour" Hour of day (0..23) -// "Minute" Minute of hour (0..59) -type CalendarInterval map[string]int - -// newCalendarInterval creates a new map of 5 elements -func newCalendarInterval() *CalendarInterval { - var value CalendarInterval = make(map[string]int, 5) - return &value -} - -func (c *CalendarInterval) clone() *CalendarInterval { - clone := newCalendarInterval() - for key, value := range *c { - (*clone)[key] = value - } - return clone -} - func getJobName(profileName, command string) string { return fmt.Sprintf("%s%s.%s", namePrefix, strings.ToLower(profileName), command) } @@ -398,83 +375,6 @@ func getFilename(name, permission string) (string, error) { return path.Join(home, UserAgentPath, name+agentExtension), nil } -// getCalendarIntervalsFromSchedules converts schedules into launchd calendar events -// let's say we've setup these rules: -// -// Mon-Fri *-*-* *:0,30:00 = every half hour -// Sat *-*-* 0,12:00:00 = twice a day on saturday -// *-*-01 *:*:* = the first of each month -// -// it should translate as: -// 1st rule -// -// Weekday = Monday, Minute = 0 -// Weekday = Monday, Minute = 30 -// ... same from Tuesday to Thurday -// Weekday = Friday, Minute = 0 -// Weekday = Friday, Minute = 30 -// -// Total of 10 rules -// 2nd rule -// -// Weekday = Saturday, Hour = 0 -// Weekday = Saturday, Hour = 12 -// -// Total of 2 rules -// 3rd rule -// -// Day = 1 -// -// Total of 1 rule -func getCalendarIntervalsFromSchedules(schedules []*calendar.Event) []CalendarInterval { - entries := make([]CalendarInterval, 0, len(schedules)) - for _, schedule := range schedules { - entries = append(entries, getCalendarIntervalsFromScheduleTree(generateTreeOfSchedules(schedule))...) - } - return entries -} - -func getCalendarIntervalsFromScheduleTree(tree []*treeElement) []CalendarInterval { - entries := make([]CalendarInterval, 0) - for _, element := range tree { - // creates a new calendar entry for each tip of the branch - newEntry := newCalendarInterval() - fillInValueFromScheduleTreeElement(newEntry, element, &entries) - } - return entries -} - -func fillInValueFromScheduleTreeElement(currentEntry *CalendarInterval, element *treeElement, entries *[]CalendarInterval) { - setCalendarIntervalValueFromType(currentEntry, element.value, element.elementType) - if len(element.subElements) == 0 { - // end of the line, this entry is finished - *entries = append(*entries, *currentEntry) - return - } - for _, subElement := range element.subElements { - // new branch means new calendar entry - fillInValueFromScheduleTreeElement(currentEntry.clone(), subElement, entries) - } -} - -func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeValue calendar.TypeValue) { - if entry == nil { - entry = newCalendarInterval() - } - switch typeValue { - case calendar.TypeWeekDay: - (*entry)["Weekday"] = value - case calendar.TypeMonth: - (*entry)["Month"] = value - case calendar.TypeDay: - (*entry)["Day"] = value - case calendar.TypeHour: - (*entry)["Hour"] = value - case calendar.TypeMinute: - (*entry)["Minute"] = value - } -} - func parseStatus(status string) map[string]string { expr := regexp.MustCompile(`^\s*"(\w+)"\s*=\s*(.*);$`) lines := strings.Split(status, "\n") diff --git a/schedule/handler_darwin_test.go b/schedule/handler_darwin_test.go index 7e5e2de2..6ad95ac0 100644 --- a/schedule/handler_darwin_test.go +++ b/schedule/handler_darwin_test.go @@ -183,6 +183,11 @@ func TestCreateSystemPlist(t *testing.T) { } func TestReadingLaunchdScheduled(t *testing.T) { + calendarEvent := calendar.NewEvent(func(e *calendar.Event) { + _ = e.Second.AddValue(0) + _ = e.Minute.AddValue(0) + _ = e.Minute.AddValue(30) + }) testCases := []struct { job Config schedules []*calendar.Event @@ -196,8 +201,9 @@ func TestReadingLaunchdScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionSystem, ConfigFile: "examples/dev.yaml", + Schedules: []string{"*-*-* *:00,30:00"}, }, - schedules: []*calendar.Event{}, + schedules: []*calendar.Event{calendarEvent}, }, { job: Config{ @@ -208,8 +214,9 @@ func TestReadingLaunchdScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionSystem, ConfigFile: "config file.yaml", + Schedules: []string{"*-*-* *:00,30:00"}, }, - schedules: []*calendar.Event{}, + schedules: []*calendar.Event{calendarEvent}, }, { job: Config{ @@ -220,8 +227,9 @@ func TestReadingLaunchdScheduled(t *testing.T) { WorkingDirectory: "/resticprofile", Permission: constants.SchedulePermissionUser, ConfigFile: "examples/dev.yaml", + Schedules: []string{"*-*-* *:00,30:00"}, }, - schedules: []*calendar.Event{}, + schedules: []*calendar.Event{calendarEvent}, }, } diff --git a/schedule/tree_darwin.go b/schedule/tree_darwin.go index 872a2d9d..89dcd204 100644 --- a/schedule/tree_darwin.go +++ b/schedule/tree_darwin.go @@ -36,7 +36,7 @@ func generateTreeOfSchedules(event *calendar.Event) []*treeElement { // add each new element to the child of all the current elements element.subElements = make([]*treeElement, len(subTree)) copy(element.subElements, subTree) - // the new current element is a slice concatening all the child slices into one big one + // the new current element is a slice concatenating all the child slices into one big one newCurrentElements = append(newCurrentElements, element.subElements...) } // full horizontal view of the current row of the tree From 201a3f88431ab82490bbd05be01e4e8b078e7ec2 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 Nov 2024 19:33:28 +0000 Subject: [PATCH 34/38] improve display of launchd job status --- schedule/handler_darwin.go | 6 +++++- schedule/spaced_title.go | 18 ++++++++++++++++++ schedule/spaced_title_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 schedule/spaced_title.go create mode 100644 schedule/spaced_title_test.go diff --git a/schedule/handler_darwin.go b/schedule/handler_darwin.go index e1775a5c..b7518a36 100644 --- a/schedule/handler_darwin.go +++ b/schedule/handler_darwin.go @@ -8,6 +8,7 @@ import ( "os/exec" "path" "regexp" + "slices" "sort" "strings" "text/tabwriter" @@ -189,12 +190,15 @@ func (h *HandlerLaunchd) DisplayJobStatus(job *Config) error { // order keys alphabetically keys := make([]string, 0, len(status)) for key := range status { + if slices.Contains([]string{"LimitLoadToSessionType", "OnDemand"}, key) { + continue + } keys = append(keys, key) } sort.Strings(keys) writer := tabwriter.NewWriter(term.GetOutput(), 0, 0, 0, ' ', tabwriter.AlignRight) for _, key := range keys { - fmt.Fprintf(writer, "%s:\t %s\n", key, status[key]) + fmt.Fprintf(writer, "%s:\t %s\n", spacedTitle(key), status[key]) } writer.Flush() fmt.Println("") diff --git a/schedule/spaced_title.go b/schedule/spaced_title.go new file mode 100644 index 00000000..c81421a9 --- /dev/null +++ b/schedule/spaced_title.go @@ -0,0 +1,18 @@ +//go:build darwin + +package schedule + +import "strings" + +func spacedTitle(title string) string { + var previous rune + sb := strings.Builder{} + for _, char := range title { + if char >= 'A' && char <= 'Z' && previous != ' ' && sb.Len() > 0 { + sb.WriteByte(' ') + } + sb.WriteRune(char) + previous = char + } + return sb.String() +} diff --git a/schedule/spaced_title_test.go b/schedule/spaced_title_test.go new file mode 100644 index 00000000..26e14199 --- /dev/null +++ b/schedule/spaced_title_test.go @@ -0,0 +1,25 @@ +//go:build darwin + +package schedule + +import "testing" + +func TestSpacedTitle(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"NoSpacesHere", "No Spaces Here"}, + {"Already Spaced", "Already Spaced"}, + {"", ""}, + {"lowercase", "lowercase"}, + {"ALLCAPS", "A L L C A P S"}, + } + + for _, test := range tests { + result := spacedTitle(test.input) + if result != test.expected { + t.Errorf("spacedTitle(%q) = %q; expected %q", test.input, result, test.expected) + } + } +} From 3e804505f2a231e25cd80f4dd764a5708b30ed27 Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 9 Nov 2024 22:04:17 +0000 Subject: [PATCH 35/38] GA is completely useless --- docs/layouts/partials/custom-header.html | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 docs/layouts/partials/custom-header.html diff --git a/docs/layouts/partials/custom-header.html b/docs/layouts/partials/custom-header.html deleted file mode 100644 index 43b17711..00000000 --- a/docs/layouts/partials/custom-header.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - From d3654bad622acf1bb409683e6d27feebada7bdf3 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 13 Nov 2024 21:14:31 +0000 Subject: [PATCH 36/38] windows scheduler: add config file --- schedule/handler_windows.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/schedule/handler_windows.go b/schedule/handler_windows.go index 5b47c071..5b3a24a9 100644 --- a/schedule/handler_windows.go +++ b/schedule/handler_windows.go @@ -102,11 +102,13 @@ func (h *HandlerWindows) Scheduled(profileName string) ([]Config, error) { configs := make([]Config, 0, len(tasks)) for _, task := range tasks { if profileName == "" || task.ProfileName == profileName { + args := NewCommandArguments(shell.SplitArguments(task.Arguments)) configs = append(configs, Config{ + ConfigFile: args.ConfigFile(), ProfileName: task.ProfileName, CommandName: task.CommandName, Command: task.Command, - Arguments: NewCommandArguments(shell.SplitArguments(task.Arguments)), + Arguments: args, WorkingDirectory: task.WorkingDirectory, JobDescription: task.JobDescription, }) From 087908495891e5252b47501e04ea427e62f27f3f Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 13 Nov 2024 21:25:01 +0000 Subject: [PATCH 37/38] fix SplitArgument to run with windows folders --- shell/split_arguments.go | 8 ++++++-- shell/split_arguments_test.go | 20 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/shell/split_arguments.go b/shell/split_arguments.go index edb0a81e..59a74144 100644 --- a/shell/split_arguments.go +++ b/shell/split_arguments.go @@ -1,6 +1,10 @@ package shell -import "strings" +import ( + "strings" + + "github.com/creativeprojects/resticprofile/platform" +) func SplitArguments(commandLine string) []string { args := make([]string, 0) @@ -8,7 +12,7 @@ func SplitArguments(commandLine string) []string { quoted := false escaped := false for _, r := range commandLine { - if r == '\\' && !escaped { + if r == '\\' && !escaped && !platform.IsWindows() { escaped = true } else if r == '"' && !escaped { quoted = !quoted diff --git a/shell/split_arguments_test.go b/shell/split_arguments_test.go index bef973bc..cc55ffe5 100644 --- a/shell/split_arguments_test.go +++ b/shell/split_arguments_test.go @@ -3,6 +3,7 @@ package shell import ( "testing" + "github.com/creativeprojects/resticprofile/platform" "github.com/stretchr/testify/assert" ) @@ -10,6 +11,7 @@ func TestSplitArguments(t *testing.T) { testCases := []struct { commandLine string expectedArgs []string + windowsMode bool }{ { commandLine: `cmd arg1 arg2`, @@ -47,10 +49,24 @@ func TestSplitArguments(t *testing.T) { commandLine: `cmd arg\ with\ spaces`, expectedArgs: []string{"cmd", "arg with spaces"}, }, + { + commandLine: `args --with folder/file.txt`, + expectedArgs: []string{"args", "--with", "folder/file.txt"}, + }, + { + commandLine: `args --with folder\file.txt`, + expectedArgs: []string{"args", "--with", "folder\\file.txt"}, + windowsMode: true, + }, } for _, testCase := range testCases { - args := SplitArguments(testCase.commandLine) - assert.Equal(t, testCase.expectedArgs, args) + if testCase.windowsMode && !platform.IsWindows() { + continue + } + t.Run(testCase.commandLine, func(t *testing.T) { + args := SplitArguments(testCase.commandLine) + assert.Equal(t, testCase.expectedArgs, args) + }) } } From 240601a429fb614de57f0904ca0713e632195dc2 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 13 Nov 2024 21:35:19 +0000 Subject: [PATCH 38/38] fix unix tests under windows --- shell/split_arguments_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shell/split_arguments_test.go b/shell/split_arguments_test.go index cc55ffe5..94838bbd 100644 --- a/shell/split_arguments_test.go +++ b/shell/split_arguments_test.go @@ -12,6 +12,7 @@ func TestSplitArguments(t *testing.T) { commandLine string expectedArgs []string windowsMode bool + unixMode bool }{ { commandLine: `cmd arg1 arg2`, @@ -44,10 +45,12 @@ func TestSplitArguments(t *testing.T) { { commandLine: `cmd "arg \"with\" spaces"`, expectedArgs: []string{"cmd", "arg \"with\" spaces"}, + unixMode: true, }, { commandLine: `cmd arg\ with\ spaces`, expectedArgs: []string{"cmd", "arg with spaces"}, + unixMode: true, }, { commandLine: `args --with folder/file.txt`, @@ -64,6 +67,9 @@ func TestSplitArguments(t *testing.T) { if testCase.windowsMode && !platform.IsWindows() { continue } + if testCase.unixMode && platform.IsWindows() { + continue + } t.Run(testCase.commandLine, func(t *testing.T) { args := SplitArguments(testCase.commandLine) assert.Equal(t, testCase.expectedArgs, args)