Skip to content
Merged
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
49 changes: 44 additions & 5 deletions config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"os"
"path"
"path/filepath"
"reflect"
Expand All @@ -10,13 +11,15 @@ import (
"time"

"github.com/Masterminds/semver/v3"
"github.com/creativeprojects/clog"
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/restic"
"github.com/creativeprojects/resticprofile/shell"
"github.com/creativeprojects/resticprofile/util"
"github.com/creativeprojects/resticprofile/util/bools"
"github.com/mitchellh/mapstructure"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)

// resticVersion14 is the semver of restic 0.14 (the version where several flag names were changed)
Expand Down Expand Up @@ -249,13 +252,19 @@ type ScheduleBaseSection struct {
SchedulePriority string `mapstructure:"schedule-priority" show:"noshow" default:"background" enum:"background;standard" description:"Set the priority at which the schedule is run"`
ScheduleLockMode string `mapstructure:"schedule-lock-mode" show:"noshow" default:"default" enum:"default;fail;ignore" description:"Specify how locks are used when running on schedule - see https://creativeprojects.github.io/resticprofile/schedules/configuration/"`
ScheduleLockWait time.Duration `mapstructure:"schedule-lock-wait" show:"noshow" examples:"150s;15m;30m;45m;1h;2h30m" description:"Set the maximum time to wait for acquiring locks when running on schedule"`
ScheduleEnvCapture []string `mapstructure:"schedule-capture-environment" show:"noshow" default:"RESTIC_*" description:"Set names (or glob expressions) of environment variables to capture during schedule creation. The captured environment is applied prior to \"profile.env\" when running the schedule. Whether capturing is supported depends on the type of scheduler being used (supported in \"systemd\" and \"launchd\")"`
}

func (s *ScheduleBaseSection) setRootPath(_ *Profile, _ string) {
s.ScheduleLog = fixPath(s.ScheduleLog, expandEnv, expandUserHome)
}

func (s *ScheduleBaseSection) GetSchedule() *ScheduleBaseSection { return s }
func (s *ScheduleBaseSection) GetSchedule() *ScheduleBaseSection {
if s != nil && s.ScheduleEnvCapture == nil {
s.ScheduleEnvCapture = []string{"RESTIC_*"}
}
return s
}

// CopySection contains the destination parameters for a copy command
type CopySection struct {
Expand Down Expand Up @@ -479,6 +488,16 @@ func (p *Profile) ResolveConfiguration() {
r.resolve(p)
}

// Resolve environment variable name case (p.Environment keys are all lower case due to config parser)
// Custom env variables (without a match in os.Environ) are changed to uppercase (like before in wrapper)
osEnv := util.NewFoldingEnvironment(os.Environ()...)
for name, value := range p.Environment {
if newName := osEnv.ResolveName(strings.ToUpper(name)); newName != name {
delete(p.Environment, name)
p.Environment[newName] = value
}
}

// Deal with "path" & "tag" flags
if p.Backup != nil {
// Copy tags from backup if tag is set to boolean true
Expand Down Expand Up @@ -736,17 +755,37 @@ func (p *Profile) Schedules() []*ScheduleConfig {

for name, section := range sections {
if s := section.GetSchedule(); len(s.Schedule) > 0 {
env := map[string]string{}
for key, value := range p.Environment {
env[key] = value.Value()
env := util.NewDefaultEnvironment()

if len(s.ScheduleEnvCapture) > 0 {
// Capture OS env
env.SetValues(os.Environ()...)

// Capture profile env
for key, value := range p.Environment {
env.Put(key, value.Value())
}

for index, key := range env.Names() {
matched := slices.ContainsFunc(s.ScheduleEnvCapture, func(pattern string) bool {
matched, err := filepath.Match(pattern, key)
if err != nil && index == 0 {
clog.Tracef("env not matched with invalid glob expression '%s': %s", pattern, err.Error())
}
return matched
})
if !matched {
env.Remove(key)
}
}
}

config := &ScheduleConfig{
Title: p.Name,
SubTitle: name,
Schedules: s.Schedule,
Permission: s.SchedulePermission,
Environment: env,
Environment: env.Values(),
Log: s.ScheduleLog,
LockMode: s.ScheduleLockMode,
LockWait: s.ScheduleLockWait,
Expand Down
39 changes: 27 additions & 12 deletions config/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,36 +791,51 @@ func TestSchedules(t *testing.T) {
testConfig := func(command string, scheduled bool) string {
schedule := ""
if scheduled {
schedule = `schedule = "@hourly"
schedule-log = "` + logFile + `"`
schedule = `
schedule = "@hourly"
schedule-log = "` + logFile + `"`
}

config := `
[profile]
initialize = true
[profile]
initialize = true

[profile.%s]
%s
[profile.env]
TEST_VAR="non-captured-test-value"
RESTIC_VAR="profile-only-value"
RESTIC_ANY2="123"

[profile.%s]
%s
`
return fmt.Sprintf(config, command, schedule)
}

sections := NewProfile(nil, "").SchedulableCommands()
require.GreaterOrEqual(t, len(sections), 6)

require.NoError(t, os.Setenv("RESTIC_ANY1", "xyz"))
require.NoError(t, os.Setenv("RESTIC_ANY2", "xyz"))

for _, command := range sections {
t.Run(command, func(t *testing.T) {
// Check that schedule is supported
profile, err := getProfile("toml", testConfig(command, true), "profile", "")
profile, err := getResolvedProfile("toml", testConfig(command, true), "profile")
require.NoError(t, err)
assert.NotNil(t, profile)

config := profile.Schedules()
assert.Len(t, config, 1)
assert.Equal(t, config[0].SubTitle, command)
assert.Len(t, config[0].Schedules, 1)
assert.Equal(t, config[0].Schedules[0], "@hourly")
assert.Equal(t, config[0].Log, path.Join(constants.TemporaryDirMarker, "rp.log"))
require.Len(t, config, 1)

schedule := config[0]
assert.Equal(t, command, schedule.SubTitle)
assert.Equal(t, []string{"@hourly"}, schedule.Schedules)
assert.Equal(t, path.Join(constants.TemporaryDirMarker, "rp.log"), schedule.Log)
assert.Equal(t, map[string]string{
"RESTIC_VAR": "profile-only-value",
"RESTIC_ANY1": "xyz",
"RESTIC_ANY2": "123",
}, util.NewDefaultEnvironment(schedule.Environment...).ValuesAsMap())

// Check that schedule is optional
profile, err = getProfile("toml", testConfig(command, false), "profile", "")
Expand Down
2 changes: 1 addition & 1 deletion config/schedule_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type ScheduleConfig struct {
WorkingDirectory string
Command string
Arguments []string
Environment map[string]string
Environment []string
JobDescription string
TimerDescription string
Priority string
Expand Down
4 changes: 2 additions & 2 deletions config/schedule_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestScheduleProperties(t *testing.T) {
WorkingDirectory: "home",
Command: "command",
Arguments: []string{"1", "2"},
Environment: map[string]string{"test": "dev"},
Environment: []string{"test=dev"},
JobDescription: "job",
TimerDescription: "timer",
Log: "log.txt",
Expand All @@ -36,7 +36,7 @@ func TestScheduleProperties(t *testing.T) {
assert.Equal(t, "command", schedule.Command)
assert.Equal(t, "home", schedule.WorkingDirectory)
assert.ElementsMatch(t, []string{"1", "2"}, schedule.Arguments)
assert.Equal(t, "dev", schedule.Environment["test"])
assert.Equal(t, []string{"test=dev"}, schedule.Environment)
assert.Equal(t, "background", schedule.GetPriority()) // default value
assert.Equal(t, "log.txt", schedule.Log)
assert.Equal(t, ScheduleLockModeDefault, schedule.GetLockMode())
Expand Down
11 changes: 6 additions & 5 deletions schedule/handler_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/dial"
"github.com/creativeprojects/resticprofile/term"
"github.com/creativeprojects/resticprofile/util"
"github.com/spf13/afero"
"golang.org/x/exp/slices"
"howett.net/plist"
Expand Down Expand Up @@ -167,10 +168,10 @@ func (h *HandlerLaunchd) getLaunchdJob(job *config.ScheduleConfig, schedules []*
logfile = name + ".log"
}

// Add path to env variables
env := make(map[string]string, 1)
if pathEnv := os.Getenv("PATH"); pathEnv != "" {
env["PATH"] = pathEnv
// 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
Expand All @@ -188,7 +189,7 @@ func (h *HandlerLaunchd) getLaunchdJob(job *config.ScheduleConfig, schedules []*
StandardErrorPath: logfile,
WorkingDirectory: job.WorkingDirectory,
StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules),
EnvironmentVariables: env,
EnvironmentVariables: env.ValuesAsMap(),
Nice: nice,
ProcessType: priorityValues[job.GetPriority()],
LowPriorityIO: lowPriorityIO,
Expand Down
23 changes: 23 additions & 0 deletions schedule/handler_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package schedule

import (
"bytes"
"fmt"
"os"
"path"
"testing"

Expand Down Expand Up @@ -162,6 +164,27 @@ func TestLaunchdJobLog(t *testing.T) {
}
}

func TestLaunchdJobPreservesEnv(t *testing.T) {
pathEnv := os.Getenv("PATH")
fixtures := []struct {
environment []string
expected map[string]string
}{
{expected: map[string]string{"PATH": pathEnv}},
{environment: []string{"path=extra-var"}, expected: map[string]string{"PATH": pathEnv, "path": "extra-var"}},
{environment: []string{"PATH=custom-path"}, expected: map[string]string{"PATH": "custom-path"}},
}

for i, fixture := range fixtures {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
handler := NewHandler(SchedulerLaunchd{})
cfg := &config.ScheduleConfig{Title: "t", SubTitle: "s", Environment: fixture.environment}
launchdJob := handler.getLaunchdJob(cfg, []*calendar.Event{})
assert.Equal(t, fixture.expected, launchdJob.EnvironmentVariables)
})
}
}

func TestCreateUserPlist(t *testing.T) {
handler := NewHandler(SchedulerLaunchd{})
handler.fs = afero.NewMemMapFs()
Expand Down
1 change: 1 addition & 0 deletions schedule/handler_systemd.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func (h *HandlerSystemd) CreateJob(job *config.ScheduleConfig, schedules []*cale
}
err := systemd.Generate(systemd.Config{
CommandLine: job.Command + " --no-prio " + strings.Join(job.Arguments, " "),
Environment: job.Environment,
WorkingDirectory: job.WorkingDirectory,
Title: job.Title,
SubTitle: job.SubTitle,
Expand Down
4 changes: 3 additions & 1 deletion systemd/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/creativeprojects/resticprofile/constants"
"github.com/creativeprojects/resticprofile/util/templates"
"github.com/spf13/afero"
"golang.org/x/exp/slices"
)

const (
Expand Down Expand Up @@ -78,6 +79,7 @@ type templateInfo struct {
// Config for generating systemd unit and timer files
type Config struct {
CommandLine string
Environment []string
WorkingDirectory string
Title string
SubTitle string
Expand Down Expand Up @@ -108,7 +110,7 @@ func Generate(config Config) error {
}
}

environment := make([]string, 0, 2)
environment := slices.Clone(config.Environment)
// add $HOME to the environment variables (as a fallback if not defined in profile)
if home, err := os.UserHomeDir(); err == nil {
environment = append(environment, fmt.Sprintf("HOME=%s", home))
Expand Down
Loading