Skip to content

Commit

Permalink
base-profiles: implemented filters for run-schedule (v1 & v2)
Browse files Browse the repository at this point in the history
  • Loading branch information
jkellerer committed Feb 24, 2024
1 parent 54bdd2c commit fd342c8
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 82 deletions.
207 changes: 156 additions & 51 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/creativeprojects/resticprofile/schedule"
"github.com/creativeprojects/resticprofile/term"
"github.com/creativeprojects/resticprofile/util"
"github.com/creativeprojects/resticprofile/util/collect"
"github.com/creativeprojects/resticprofile/util/templates"
"github.com/creativeprojects/resticprofile/win"
)
Expand Down Expand Up @@ -428,11 +429,9 @@ func createSchedule(_ io.Writer, ctx commandContext) error {

// Step 1: Collect all jobs of all selected profiles
for _, profileName := range selectProfiles(c, flags, args) {
profileFlags := flagsForProfile(flags, profileName)

scheduler, profile, jobs, err := getScheduleJobs(c, profileFlags)
scheduler, profile, jobs, err := getSchedulesForProfile(c, profileName)
if err == nil {
err = requireScheduleJobs(jobs, profileFlags)
err = requireSchedules(jobs, profileName)

// Skip profile with no schedules when "--all" option is set.
if err != nil && slices.Contains(args, "--all") {
Expand Down Expand Up @@ -473,9 +472,7 @@ func removeSchedule(_ io.Writer, ctx commandContext) error {

// Unschedule all jobs of all selected profiles
for _, profileName := range selectProfiles(c, flags, args) {
profileFlags := flagsForProfile(flags, profileName)

scheduler, _, jobs, err := getRemovableScheduleJobs(c, profileFlags)
scheduler, _, jobs, err := getRemovableSchedulesForProfile(c, profileName)

Check warning on line 475 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L475

Added line #L475 was not covered by tests
if err != nil {
return err
}
Expand All @@ -498,20 +495,21 @@ func statusSchedule(w io.Writer, ctx commandContext) error {

if !slices.Contains(args, "--all") {
// simple case of displaying status for one profile
scheduler, profile, schedules, err := getScheduleJobs(c, flags)
profileName := flags.name
scheduler, profile, schedules, err := getSchedulesForProfile(c, profileName)

Check warning on line 499 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L498-L499

Added lines #L498 - L499 were not covered by tests
if err != nil {
return err
}
if len(schedules) == 0 {
clog.Warningf("profile %s has no schedule", flags.name)
clog.Warningf("profile %s has no schedule", profileName)

Check warning on line 504 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L504

Added line #L504 was not covered by tests
return nil
}
return statusScheduleProfile(scheduler, profile, schedules, flags)
}

for _, profileName := range selectProfiles(c, flags, args) {
profileFlags := flagsForProfile(flags, profileName)
scheduler, profile, schedules, err := getScheduleJobs(c, profileFlags)
scheduler, profile, schedules, err := getSchedulesForProfile(c, profileName)

Check warning on line 512 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L512

Added line #L512 was not covered by tests
if err != nil {
return err
}
Expand Down Expand Up @@ -539,32 +537,118 @@ func statusScheduleProfile(scheduler schedule.SchedulerConfig, profile *config.P
return nil
}

func getScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) {
// getProfilesForSchedule returns all profiles for the given V2 standalone schedule
func getProfilesForSchedule(c *config.Config, s *config.Schedule) (scheduler schedule.SchedulerConfig, profiles []*config.Profile, err error) {
var global *config.Global
if global, err = c.GetGlobalSection(); err != nil {
err = fmt.Errorf("cannot load global section: %w", err)
return

Check warning on line 545 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L541-L545

Added lines #L541 - L545 were not covered by tests
}

// resolve profile names
if len(s.Group) > 0 && len(s.Profiles) == 0 {
var group *config.Group
if group, err = c.GetProfileGroup(s.Group); err != nil {
err = fmt.Errorf("cannot load group section %q: %w", s.Group, err)
return

Check warning on line 553 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L549-L553

Added lines #L549 - L553 were not covered by tests
}
s.Profiles = slices.Clone(group.Profiles)

Check warning on line 555 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L555

Added line #L555 was not covered by tests
}

// resolve glob expressions in the profiles list, e.g. use "*" to run for all profiles that define a command
hasGlob := func(name string) bool { return strings.ContainsAny(name, "?*[]") }
if slices.ContainsFunc(s.Profiles, hasGlob) {

Check warning on line 560 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L559-L560

Added lines #L559 - L560 were not covered by tests
// glob matcher for expressions in s.Profiles, excluding literal matches in s.Profiles
globMatches := util.GlobMultiMatcher(s.Profiles...).NoLiteralMatchCondition()

Check warning on line 562 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L562

Added line #L562 was not covered by tests
// remove glob expressions from s.Profiles
s.Profiles = collect.All(s.Profiles, collect.Not(hasGlob))

Check warning on line 564 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L564

Added line #L564 was not covered by tests
// add newly matched names at the end to preserve declaration order
for _, name := range collect.All(c.GetProfileNames(), globMatches) {
if !slices.Contains(s.Profiles, name) {
s.Profiles = append(s.Profiles, name)

Check warning on line 568 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L566-L568

Added lines #L566 - L568 were not covered by tests
}
}
}

// collect profiles
profiles = collect.From(s.Profiles, func(name string) (profile *config.Profile) {
if err == nil {
profile, err = c.GetProfile(name)

Check warning on line 576 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L574-L576

Added lines #L574 - L576 were not covered by tests
}
return

Check warning on line 578 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L578

Added line #L578 was not covered by tests
})
if err != nil {
err = fmt.Errorf("cannot load profiles ['%s']: %w", strings.Join(s.Profiles, "', '"), err)
return

Check warning on line 582 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L580-L582

Added lines #L580 - L582 were not covered by tests
}

// remove profiles that cannot be used
profiles = validProfilesForSchedule(profiles, global, s)
scheduler = schedule.NewSchedulerConfig(global)
return

Check warning on line 588 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L586-L588

Added lines #L586 - L588 were not covered by tests
}

// validProfilesForSchedule returns all profiles that the given schedule can execute
func validProfilesForSchedule(profiles []*config.Profile, global *config.Global, schedule *config.Schedule) []*config.Profile {

Check warning on line 592 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L592

Added line #L592 was not covered by tests
// used in v2 schedules to find the profiles that can be executed
return collect.All(profiles, func(profile *config.Profile) bool {
return validScheduleFilter(profile, global)(schedule)
})

Check warning on line 596 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L594-L596

Added lines #L594 - L596 were not covered by tests
}

// validSchedulesFilter creates a filter func that accepts config.Schedule when it is allowed for the given profile
func validScheduleFilter(profile *config.Profile, global *config.Global) func(schedule *config.Schedule) (valid bool) {
acceptCommand := commandFilter(profile, global)
definedCommands := profile.DefinedCommands()

return func(schedule *config.Schedule) (accepted bool) {
if profile != nil && schedule != nil {
if accepted = slices.Contains(schedule.Profiles, profile.Name); !accepted {
clog.Debugf("not in schedule: profile '%s' has no schedule for %q", profile.Name, schedule.CommandName)
return

Check warning on line 608 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L607-L608

Added lines #L607 - L608 were not covered by tests
}
if accepted = slices.Contains(definedCommands, schedule.CommandName); !accepted {
clog.Debugf("undefined command: cannot schedule %q for profile '%s'", schedule.CommandName, profile.Name)
return

Check warning on line 612 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L611-L612

Added lines #L611 - L612 were not covered by tests
}
if accepted = acceptCommand(schedule.CommandName); !accepted {
clog.Debugf("disallowed command: cannot schedule %q for profile '%s'", schedule.CommandName, profile.Name)

Check warning on line 615 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L615

Added line #L615 was not covered by tests
}
}
return
}
}

// getSchedulesForProfile returns the profile and its inline schedules for a given profile name
func getSchedulesForProfile(c *config.Config, profileName string) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) {
global, err := c.GetGlobalSection()
if err != nil {
return nil, nil, nil, fmt.Errorf("cannot load global section: %w", err)
}

profile, err := c.GetProfile(flags.name)
profile, err := c.GetProfile(profileName)
if err != nil {
if errors.Is(err, config.ErrNotFound) {
return nil, nil, nil, fmt.Errorf("profile '%s' not found", flags.name)
return nil, nil, nil, fmt.Errorf("profile '%s' not found: %w", profileName, err)
}
return nil, nil, nil, fmt.Errorf("cannot load profile '%s': %w", flags.name, err)
return nil, nil, nil, fmt.Errorf("cannot load profile '%s': %w", profileName, err)

Check warning on line 634 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L634

Added line #L634 was not covered by tests
}

return schedule.NewSchedulerConfig(global), profile, profile.Schedules(), nil
// collecting schedules that can be run on this profile
schedules := collect.All(profile.Schedules(), validScheduleFilter(profile, global))

return schedule.NewSchedulerConfig(global), profile, schedules, nil
}

func requireScheduleJobs(schedules []*config.Schedule, flags commandLineFlags) error {
func requireSchedules(schedules []*config.Schedule, profileName string) error {
if len(schedules) == 0 {
return fmt.Errorf("no schedule found for profile '%s'", flags.name)
return fmt.Errorf("no schedule found for profile '%s'", profileName)
}
return nil
}

func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) {
scheduler, profile, schedules, err := getScheduleJobs(c, flags)
func getRemovableSchedulesForProfile(c *config.Config, profileName string) (schedule.SchedulerConfig, *config.Profile, []*config.Schedule, error) {
scheduler, profile, schedules, err := getSchedulesForProfile(c, profileName)
if err != nil {
return nil, nil, nil, err
}
Expand All @@ -585,45 +669,67 @@ func getRemovableScheduleJobs(c *config.Config, flags commandLineFlags) (schedul
return scheduler, profile, schedules, nil
}

func preRunSchedule(ctx *Context) error {
func preRunSchedule(ctx *Context) (err error) {
if len(ctx.request.arguments) < 1 {
return errors.New("run-schedule command expects one argument: schedule name")
}

// extract scheduleName and remove the parameter from the arguments
scheduleName := ctx.request.arguments[0]
// temporarily allow v2 configuration to run v1 schedules
// if ctx.config.GetVersion() < config.Version02
{
// schedule name is in the form "command@profile"
commandName, profileName, ok := strings.Cut(scheduleName, "@")
if !ok {
return errors.New("the expected format of the schedule name is <command>@<profile-name>")
}
ctx.request.arguments = ctx.request.arguments[1:]
ctx.schedule = nil

if commandName, profileName, ok := strings.Cut(scheduleName, "@"); ok {
// Inline schedules use a name in the form "command@profile"
ctx.request.profile = profileName
ctx.request.schedule = scheduleName
ctx.command = commandName
// remove the parameter from the arguments
ctx.request.arguments = ctx.request.arguments[1:]

// don't save the profile in the context now, it's only loaded but not prepared
profile, err := ctx.config.GetProfile(profileName)
if err != nil || profile == nil {
return fmt.Errorf("cannot load profile '%s': %w", profileName, err)
}
// get the list of all scheduled commands to find the current command
schedules := profile.Schedules()
for _, schedule := range schedules {
if schedule.CommandName == ctx.command {
ctx.schedule = schedule
prepareScheduledProfile(ctx)
break

// find the config.Schedule for the command in the profile
var schedules []*config.Schedule
if _, _, schedules, err = getSchedulesForProfile(ctx.config, profileName); err == nil {
matchesCommand := func(s *config.Schedule) bool { return s.CommandName == commandName }
if s := collect.First(schedules, matchesCommand); s != nil {
ctx.schedule = *s

Check warning on line 692 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L692

Added line #L692 was not covered by tests
}
}
} else {
// Standalone V2 schedules use a name that references the schedule section
var (
schedule *config.Schedule
schedules map[string]*config.Schedule
profiles []*config.Profile
)
if schedules, err = ctx.config.GetScheduleSections(); err == nil {
schedule = schedules[scheduleName]
}
if schedule != nil {
if _, profiles, err = getProfilesForSchedule(ctx.config, schedule); err == nil {
names := schedule.Profiles
schedule.Profiles = collect.From(profiles, func(p *config.Profile) string { return p.Name })
if len(schedule.Profiles) > 0 {
ctx.schedule = schedule
} else {
err = fmt.Errorf("none of the profiles ['%s'] in schedule %q can be used", strings.Join(names, "', '"), scheduleName)

Check warning on line 712 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L706-L712

Added lines #L706 - L712 were not covered by tests
}
}
}
}
return nil

if ctx.schedule != nil {
ctx.request.schedule = scheduleName
prepareContextForSchedule(ctx)

Check warning on line 720 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L719-L720

Added lines #L719 - L720 were not covered by tests
} else if err == nil {
err = fmt.Errorf("schedule %q not found, the expected format of the schedule name is <command>@<profile-name> or <schedule-section-name>", scheduleName)
}
return
}

func prepareScheduledProfile(ctx *Context) {
clog.Debugf("preparing scheduled profile %q", ctx.request.schedule)
func prepareContextForSchedule(ctx *Context) {
clog.Debugf("preparing schedule %q", ctx.request.schedule)

Check warning on line 728 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L727-L728

Added lines #L727 - L728 were not covered by tests
// requested profile
ctx.request.profile = ctx.schedule.Profiles[0]

Check warning on line 730 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L730

Added line #L730 was not covered by tests
// schedule command
ctx.command = ctx.schedule.CommandName

Check warning on line 732 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L732

Added line #L732 was not covered by tests
// log file
if len(ctx.schedule.Log) > 0 {
ctx.logTarget = ctx.schedule.Log
Expand All @@ -648,11 +754,10 @@ func prepareScheduledProfile(ctx *Context) {
}

func runSchedule(_ io.Writer, cmdCtx commandContext) error {
err := startProfileOrGroup(&cmdCtx.Context)
if err != nil {
return err
if cmdCtx.schedule == nil {
return fmt.Errorf("invalid state: schedule %q not initialized", cmdCtx.request.schedule)
}
return nil
return startContext(&cmdCtx.Context)

Check warning on line 760 in commands.go

View check run for this annotation

Codecov / codecov/patch

commands.go#L760

Added line #L760 was not covered by tests
}

func testElevationCommand(_ io.Writer, ctx commandContext) error {
Expand Down
57 changes: 33 additions & 24 deletions commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ schedule = "daily"
parsedConfig, err := config.Load(bytes.NewBufferString(testConfig), "toml")
assert.Nil(t, err)

// Test that errors from getScheduleJobs are passed through
_, _, _, notFoundErr := getRemovableScheduleJobs(parsedConfig, commandLineFlags{name: "non-existent"})
assert.EqualError(t, notFoundErr, "profile 'non-existent' not found")
// Test that errors from getSchedulesForProfile are passed through
_, _, _, notFoundErr := getRemovableSchedulesForProfile(parsedConfig, "non-existent")
assert.ErrorContains(t, notFoundErr, "profile 'non-existent' not found")
assert.ErrorIs(t, notFoundErr, config.ErrNotFound)

// Test that declared and declarable job configs are returned
_, profile, schedules, err := getRemovableScheduleJobs(parsedConfig, commandLineFlags{name: "default"})
_, profile, schedules, err := getRemovableSchedulesForProfile(parsedConfig, "default")
assert.Nil(t, err)
assert.NotNil(t, profile)
assert.NotEmpty(t, schedules)
Expand Down Expand Up @@ -89,26 +90,27 @@ schedule = "daily"
assert.Nil(t, err)

// Test that non-existent profiles causes an error
_, _, _, notFoundErr := getScheduleJobs(cfg, commandLineFlags{name: "non-existent"})
assert.EqualError(t, notFoundErr, "profile 'non-existent' not found")
_, _, _, notFoundErr := getSchedulesForProfile(cfg, "non-existent")
assert.ErrorContains(t, notFoundErr, "profile 'non-existent' not found")
assert.ErrorIs(t, notFoundErr, config.ErrNotFound)

// Test that non-existent schedule causes no error at first
{
flags := commandLineFlags{name: "other"}
_, _, schedules, err := getScheduleJobs(cfg, flags)
name := "other"
_, _, schedules, err := getSchedulesForProfile(cfg, name)
assert.Nil(t, err)

err = requireScheduleJobs(schedules, flags)
err = requireSchedules(schedules, name)
assert.EqualError(t, err, "no schedule found for profile 'other'")
}

// Test that only declared job configs are returned
{
flags := commandLineFlags{name: "default"}
_, profile, schedules, err := getScheduleJobs(cfg, flags)
name := "default"
_, profile, schedules, err := getSchedulesForProfile(cfg, name)
assert.Nil(t, err)

err = requireScheduleJobs(schedules, flags)
err = requireSchedules(schedules, name)
assert.Nil(t, err)

assert.NotNil(t, profile)
Expand Down Expand Up @@ -436,19 +438,26 @@ func TestPreRunScheduleNoScheduleName(t *testing.T) {
}

func TestPreRunScheduleWrongScheduleName(t *testing.T) {
runForConfig := func(t *testing.T, cfg, name string) error {
c, err := config.Load(bytes.NewBufferString(cfg), "toml")
assert.NoError(t, err)

err = preRunSchedule(&Context{
request: Request{arguments: []string{name}},
config: c,
flags: commandLineFlags{name: "default"},
})
t.Log(err)
return err
}

// loads an (almost) empty config
cfg, err := config.Load(bytes.NewBufferString("[default]"), "toml")
assert.NoError(t, err)
v1Config := "[default]"
v2Config := "version = 2\n[profiles.default]"

err = preRunSchedule(&Context{
request: Request{arguments: []string{"wrong"}},
config: cfg,
flags: commandLineFlags{
name: "default",
},
})
assert.Error(t, err)
t.Log(err)
assert.ErrorContains(t, runForConfig(t, v1Config, "wrong@default"), `schedule "wrong@default" not found`)
assert.Panics(t, func() { _ = runForConfig(t, v1Config, "wrong") })
assert.ErrorContains(t, runForConfig(t, v2Config, "wrong"), `schedule "wrong" not found`)
}

func TestPreRunScheduleProfileUnknown(t *testing.T) {
Expand Down Expand Up @@ -509,5 +518,5 @@ func TestRunScheduleProfileUnknown(t *testing.T) {
config: cfg,
},
})
assert.ErrorIs(t, err, ErrProfileNotFound)
assert.ErrorContains(t, err, `invalid state: schedule "" not initialized`)
}
Loading

0 comments on commit fd342c8

Please sign in to comment.