Skip to content

Commit 0465a19

Browse files
system daemon with launchd
1 parent 142a542 commit 0465a19

File tree

7 files changed

+199
-87
lines changed

7 files changed

+199
-87
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@
2121

2222
# test output
2323
/coverage.out
24+
25+
# log files
26+
/*.log

examples/dev.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ self:
8181
source: ./
8282
schedule:
8383
- "*:00,30"
84+
schedule-permission: user
8485
check:
8586
schedule:
8687
- "*:15,45"

main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"runtime"
1010
"runtime/debug"
11+
"syscall"
1112
"text/tabwriter"
1213
"time"
1314

@@ -314,7 +315,7 @@ func runProfile(
314315

315316
// Catch CTR-C keypress
316317
sigChan := make(chan os.Signal, 1)
317-
signal.Notify(sigChan, os.Interrupt, os.Kill)
318+
signal.Notify(sigChan, os.Interrupt, os.Kill, syscall.SIGTERM, syscall.SIGABRT)
318319

319320
wrapper := newResticWrapper(
320321
resticBinary,

schedule/schedule.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package schedule
22

33
import (
4+
"os"
5+
"runtime"
6+
7+
"github.com/creativeprojects/clog"
48
"github.com/creativeprojects/resticprofile/constants"
59
)
610

@@ -81,3 +85,45 @@ func (j *Job) Status() error {
8185
}
8286
return nil
8387
}
88+
89+
// getSchedulePermission returns the permission defined from the configuration,
90+
// or the best guess considering the current user permission.
91+
//
92+
// This method is for Unixes only
93+
func (j *Job) getSchedulePermission() string {
94+
const message = "you have not specified the permission for your schedule (system or user): assuming "
95+
if j.config.Permission() == constants.SchedulePermissionSystem ||
96+
j.config.Permission() == constants.SchedulePermissionUser {
97+
// well defined
98+
return j.config.Permission()
99+
}
100+
// best guess is depending on the user being root or not:
101+
if os.Geteuid() == 0 {
102+
if runtime.GOOS != "darwin" {
103+
// darwin can backup protected files without the need of a system task; no need to bother the user then
104+
clog.Warning(message, "system")
105+
}
106+
return constants.SchedulePermissionSystem
107+
}
108+
if runtime.GOOS != "darwin" {
109+
// darwin can backup protected files without the need of a system task; no need to bother the user then
110+
clog.Warning(message, "user")
111+
}
112+
return constants.SchedulePermissionUser
113+
}
114+
115+
// checkPermission returns true if the user is allowed.
116+
//
117+
// This method is for Unixes only
118+
func (j *Job) checkPermission(permission string) bool {
119+
if permission == constants.SchedulePermissionUser {
120+
// user mode is always available
121+
return true
122+
}
123+
if os.Geteuid() == 0 {
124+
// user has sudoed
125+
return true
126+
}
127+
// last case is system (or undefined) + no sudo
128+
return false
129+
}

schedule/schedule_darwin.go

Lines changed: 114 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import (
1111
"os"
1212
"os/exec"
1313
"path"
14+
"regexp"
15+
"sort"
1416
"strings"
17+
"text/tabwriter"
1518

1619
"github.com/creativeprojects/resticprofile/calendar"
1720
"github.com/creativeprojects/resticprofile/constants"
@@ -31,8 +34,9 @@ const (
3134
GlobalAgentPath = "/Library/LaunchAgents"
3235
GlobalDaemons = "/Library/LaunchDaemons"
3336

34-
namePrefix = "local.resticprofile"
35-
agentExtension = ".agent.plist"
37+
namePrefix = "local.resticprofile"
38+
agentExtension = ".agent.plist"
39+
daemonExtension = ".plist"
3640

3741
codeServiceNotFound = 113
3842
)
@@ -89,10 +93,37 @@ func Close() {
8993

9094
// createJob creates a plist file and register it with launchd
9195
func (j *Job) createJob(schedules []*calendar.Event) error {
92-
ok := j.checkPermission(j.config.Permission())
96+
permission := j.getSchedulePermission()
97+
ok := j.checkPermission(permission)
9398
if !ok {
9499
return errors.New("user is not allowed to create a system job: please restart resticprofile as root (with sudo)")
95100
}
101+
filename, err := j.createPlistFile(schedules)
102+
if err != nil {
103+
if filename != "" {
104+
os.Remove(filename)
105+
}
106+
return err
107+
}
108+
109+
j.fixFileOwner(filename)
110+
if err != nil {
111+
return err
112+
}
113+
114+
// load the service
115+
cmd := exec.Command(launchctlBin, commandLoad, filename)
116+
cmd.Stdout = os.Stdout
117+
cmd.Stderr = os.Stderr
118+
err = cmd.Run()
119+
if err != nil {
120+
return err
121+
}
122+
123+
return nil
124+
}
125+
126+
func (j *Job) createPlistFile(schedules []*calendar.Event) (string, error) {
96127
name := getJobName(j.config.Title(), j.config.SubTitle())
97128
job := &LaunchJob{
98129
Label: name,
@@ -105,94 +136,110 @@ func (j *Job) createJob(schedules []*calendar.Event) error {
105136
StartCalendarInterval: getCalendarIntervalsFromSchedules(schedules),
106137
}
107138

108-
filename, err := getFilename(name, j.config.Permission())
139+
filename, err := getFilename(name, j.getSchedulePermission())
109140
if err != nil {
110-
return err
141+
return "", err
111142
}
112143
file, err := os.Create(filename)
113144
if err != nil {
114-
return err
145+
return "", err
115146
}
116147
defer file.Close()
117148

118149
encoder := plist.NewEncoder(file)
119150
encoder.Indent("\t")
120151
err = encoder.Encode(job)
121152
if err != nil {
122-
return err
153+
return filename, err
123154
}
155+
return filename, nil
156+
}
124157

125-
// load the service
126-
cmd := exec.Command(launchctlBin, commandLoad, filename)
127-
cmd.Stdout = os.Stdout
128-
cmd.Stderr = os.Stderr
129-
err = cmd.Run()
130-
if err != nil {
131-
return err
158+
// fixFileOwner gives the owner back to the user
159+
func (j *Job) fixFileOwner(filename string) error {
160+
if j.getSchedulePermission() == constants.SchedulePermissionSystem {
161+
return nil
132162
}
133-
134-
return nil
163+
if os.Geteuid() != 0 {
164+
return nil
165+
}
166+
// this is the case of a launchd agent supposed to be of type user, but created by root
167+
// well it doesn't even seem to work anyway
168+
return os.Chown(filename, os.Getuid(), os.Getgid())
135169
}
136170

137171
// removeJob stops and unloads the agent from launchd, then removes the configuration file
138172
func (j *Job) removeJob() error {
139-
ok := j.checkPermission(j.config.Permission())
173+
permission := j.getSchedulePermission()
174+
ok := j.checkPermission(permission)
140175
if !ok {
141176
return errors.New("user is not allowed to remove a system job: please restart resticprofile as root (with sudo)")
142177
}
143178
name := getJobName(j.config.Title(), j.config.SubTitle())
144-
filename, err := getFilename(name, j.config.Permission())
179+
filename, err := getFilename(name, j.getSchedulePermission())
145180
if err != nil {
146181
return err
147182
}
148183

149-
if _, err := os.Stat(filename); err == nil || os.IsExist(err) {
150-
// stop the service in case it's already running
151-
stop := exec.Command(launchctlBin, commandStop, name)
152-
stop.Stdout = os.Stdout
153-
stop.Stderr = os.Stderr
154-
// keep going if there's an error here
155-
_ = stop.Run()
156-
157-
// unload the service
158-
unload := exec.Command(launchctlBin, commandUnload, filename)
159-
unload.Stdout = os.Stdout
160-
unload.Stderr = os.Stderr
161-
err = unload.Run()
162-
if err != nil {
163-
return err
164-
}
165-
err = os.Remove(filename)
166-
if err != nil {
167-
return err
168-
}
184+
if _, err := os.Stat(filename); err != nil && os.IsNotExist(err) {
185+
return ErrorServiceNotFound
186+
}
187+
// stop the service in case it's already running
188+
stop := exec.Command(launchctlBin, commandStop, name)
189+
stop.Stdout = os.Stdout
190+
stop.Stderr = os.Stderr
191+
// keep going if there's an error here
192+
_ = stop.Run()
193+
194+
// unload the service
195+
unload := exec.Command(launchctlBin, commandUnload, filename)
196+
unload.Stdout = os.Stdout
197+
unload.Stderr = os.Stderr
198+
err = unload.Run()
199+
if err != nil {
200+
return err
201+
}
202+
err = os.Remove(filename)
203+
if err != nil {
204+
return err
169205
}
170206

171207
return nil
172208
}
173209

174210
func (j *Job) displayStatus(command string) error {
211+
permission := j.getSchedulePermission()
212+
ok := j.checkPermission(permission)
213+
if !ok {
214+
return errors.New("user is not allowed view a system job: please restart resticprofile as root (with sudo)")
215+
}
175216
cmd := exec.Command(launchctlBin, commandList, getJobName(j.config.Title(), j.config.SubTitle()))
176-
cmd.Stdout = os.Stdout
177-
cmd.Stderr = os.Stderr
178-
err := cmd.Run()
217+
output, err := cmd.Output()
179218
if cmd.ProcessState.ExitCode() == codeServiceNotFound {
180219
return ErrorServiceNotFound
181220
}
182-
return err
183-
}
184-
185-
func (j *Job) checkPermission(permission string) bool {
186-
if permission == constants.SchedulePermissionUser {
187-
// user mode is always available
188-
return true
221+
if err != nil {
222+
return err
223+
}
224+
status := parseStatus(string(output))
225+
if len(status) == 0 {
226+
// output was not parsed, it could mean output format has changed
227+
fmt.Println(string(output))
189228
}
190-
if os.Geteuid() == 0 {
191-
// user has sudoed
192-
return true
229+
// order keys alphabetically
230+
keys := make([]string, 0, len(status))
231+
for key := range status {
232+
keys = append(keys, key)
193233
}
194-
// last case is system (or undefined) + no sudo
195-
return false
234+
sort.Strings(keys)
235+
writer := tabwriter.NewWriter(os.Stdout, 0, 0, 0, ' ', tabwriter.AlignRight)
236+
for _, key := range keys {
237+
fmt.Fprintf(writer, "%s:\t %s\n", key, status[key])
238+
}
239+
writer.Flush()
240+
fmt.Println("")
241+
242+
return nil
196243
}
197244

198245
func getJobName(profileName, command string) string {
@@ -201,7 +248,7 @@ func getJobName(profileName, command string) string {
201248

202249
func getFilename(name, permission string) (string, error) {
203250
if permission == constants.SchedulePermissionSystem {
204-
return path.Join(GlobalDaemons, name+agentExtension), nil
251+
return path.Join(GlobalDaemons, name+daemonExtension), nil
205252
}
206253
home, err := os.UserHomeDir()
207254
if err != nil {
@@ -279,3 +326,16 @@ func setCalendarIntervalValueFromType(entry *CalendarInterval, value int, typeVa
279326
(*entry)["Minute"] = value
280327
}
281328
}
329+
330+
func parseStatus(status string) map[string]string {
331+
expr := regexp.MustCompile(`^\s*"(\w+)"\s*=\s*(.*);$`)
332+
lines := strings.Split(status, "\n")
333+
output := make(map[string]string, len(lines))
334+
for _, line := range lines {
335+
match := expr.FindStringSubmatch(line)
336+
if len(match) == 3 {
337+
output[match[1]] = strings.Trim(match[2], "\"")
338+
}
339+
}
340+
return output
341+
}

schedule/schedule_darwin_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,36 @@ func TestGetCalendarIntervalsFromScheduleTree(t *testing.T) {
7171
})
7272
}
7373
}
74+
75+
func TestParseStatus(t *testing.T) {
76+
status := `{
77+
"StandardOutPath" = "local.resticprofile.self.check.log";
78+
"LimitLoadToSessionType" = "Aqua";
79+
"StandardErrorPath" = "local.resticprofile.self.check.log";
80+
"Label" = "local.resticprofile.self.check";
81+
"OnDemand" = true;
82+
"LastExitStatus" = 0;
83+
"Program" = "/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile";
84+
"ProgramArguments" = (
85+
"/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile";
86+
"--no-ansi";
87+
"--config";
88+
"examples/dev.yaml";
89+
"--name";
90+
"self";
91+
"check";
92+
);
93+
};`
94+
expected := map[string]string{
95+
"StandardOutPath": "local.resticprofile.self.check.log",
96+
"LimitLoadToSessionType": "Aqua",
97+
"StandardErrorPath": "local.resticprofile.self.check.log",
98+
"Label": "local.resticprofile.self.check",
99+
"OnDemand": "true",
100+
"LastExitStatus": "0",
101+
"Program": "/Users/go/src/github.com/creativeprojects/resticprofile/resticprofile",
102+
}
103+
104+
output := parseStatus(status)
105+
assert.Equal(t, expected, output)
106+
}

0 commit comments

Comments
 (0)