Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

base-profiles: supports profiles with limited/no command usage #290

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 200 additions & 51 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"github.com/creativeprojects/resticprofile/restic"
"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 @@ -427,11 +429,9 @@

// 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 @@ -472,9 +472,7 @@

// 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 @@ -497,20 +495,21 @@

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 @@ -538,32 +537,118 @@
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 @@ -584,45 +669,67 @@
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 @@ -647,11 +754,10 @@
}

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 Expand Up @@ -707,3 +813,46 @@

return nil
}

func commandFilter(profile *config.Profile, global *config.Global) func(command string) bool {
var baseProfile, allowed, denied util.MultiPatternMatcher

if global == nil || global.BaseProfiles == nil {
baseProfile = util.GlobMultiMatcher(constants.DefaultBaseProfile)
} else {
baseProfile = util.GlobMultiMatcher(global.BaseProfiles...)
}

if profile != nil {
if match, _ := baseProfile(profile.Name); match {
allowed = util.GlobMultiMatcher()
denied = util.GlobMultiMatcher("*")
} else {
if len(profile.AllowedCommands) == 1 && profile.AllowedCommands[0] == "" {
profile.AllowedCommands = nil
}
allowed = util.GlobMultiMatcher(profile.AllowedCommands...)
denied = util.GlobMultiMatcher(profile.DeniedCommands...)
}
}

if allowed != nil && denied != nil {
return func(command string) bool {
if match, pattern := allowed(command); match {
if pattern != command {
if match, pattern = denied(command); match && pattern == command {
return false // allowed by wildcard and denied by direct match
}
}
return true
}
if match, _ := denied(command); match {
return false
}
// default true if no allowed commands are set
return len(profile.AllowedCommands) == 0
}
}

return func(command string) bool { return true }
}
Loading