Skip to content

Commit a4d43a6

Browse files
committed
launchd: implement 'Create()' function
Implement and test the 'Create()' function for the 'launchd' implementation of 'DaemonProvider'. To create the service, this function first generates the 'plist' [1] defining service metadata (the name, program to execute, etc), then writes the file and loads - but does not start - the service. The exact behavior is dependent on the 'force' flag. If it is 'false', 'Create()' will not overwrite the existing service; if 'true', it unloads the old service (if applicable), writes the new file, and reloads. Finally, add unit tests covering various scenarios that may occur when creating the 'launchd' service. Add mocks and other common structures & functions to a dedicated 'shared_test.go' file, as they will be useful when testing other daemon provider implementations in later patches. [1] https://www.unix.com/man-page/osx/5/launchd.plist/ Signed-off-by: Victoria Dye <[email protected]>
1 parent f4e99f2 commit a4d43a6

File tree

6 files changed

+496
-6
lines changed

6 files changed

+496
-6
lines changed

go.mod

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
11
module github.com/github/git-bundle-server
22

33
go 1.19
4+
5+
require github.com/stretchr/testify v1.8.1
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.1 // indirect
9+
github.com/pmezard/go-difflib v1.0.0 // indirect
10+
github.com/stretchr/objx v0.5.0 // indirect
11+
gopkg.in/yaml.v3 v3.0.1 // indirect
12+
)

go.sum

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
7+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
8+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
9+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
10+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
11+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
12+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
13+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
14+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
15+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
16+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
17+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
18+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/daemon/daemon.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package daemon
33
import (
44
"fmt"
55
"runtime"
6+
7+
"github.com/github/git-bundle-server/internal/common"
68
)
79

810
type DaemonConfig struct {
@@ -18,14 +20,18 @@ type DaemonProvider interface {
1820
Stop(label string) error
1921
}
2022

21-
func NewDaemonProvider() (DaemonProvider, error) {
23+
func NewDaemonProvider(
24+
u common.UserProvider,
25+
c common.CommandExecutor,
26+
fs common.FileSystem,
27+
) (DaemonProvider, error) {
2228
switch thisOs := runtime.GOOS; thisOs {
2329
case "linux":
2430
// Use systemd/systemctl
2531
return NewSystemdProvider(), nil
2632
case "darwin":
2733
// Use launchd/launchctl
28-
return NewLaunchdProvider(), nil
34+
return NewLaunchdProvider(u, c, fs), nil
2935
default:
3036
return nil, fmt.Errorf("cannot configure daemon handler for OS '%s'", thisOs)
3137
}

internal/daemon/launchd.go

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,166 @@
11
package daemon
22

33
import (
4+
"bytes"
45
"fmt"
6+
"path/filepath"
7+
"text/template"
8+
9+
"github.com/github/git-bundle-server/internal/common"
510
)
611

7-
type launchd struct{}
12+
const launchTemplate string = `<?xml version="1.0" encoding="UTF-8"?>
13+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
14+
<plist version="1.0">
15+
<dict>
16+
<key>Label</key><string>{{.Label}}</string>
17+
<key>Program</key><string>{{.Program}}</string>
18+
<key>StandardOutPath</key><string>{{.StdOut}}</string>
19+
<key>StandardErrorPath</key><string>{{.StdErr}}</string>
20+
</dict>
21+
</plist>
22+
`
23+
24+
const domainFormat string = "gui/%s"
25+
26+
const LaunchdServiceNotFoundErrorCode int = 113
27+
28+
type launchdConfig struct {
29+
DaemonConfig
30+
StdOut string
31+
StdErr string
32+
}
33+
34+
type launchd struct {
35+
user common.UserProvider
36+
cmdExec common.CommandExecutor
37+
fileSystem common.FileSystem
38+
}
39+
40+
func NewLaunchdProvider(
41+
u common.UserProvider,
42+
c common.CommandExecutor,
43+
fs common.FileSystem,
44+
) DaemonProvider {
45+
return &launchd{
46+
user: u,
47+
cmdExec: c,
48+
fileSystem: fs,
49+
}
50+
}
51+
52+
func (l *launchd) isBootstrapped(serviceTarget string) (bool, error) {
53+
// run 'launchctl print' on given service target to see if it exists
54+
exitCode, err := l.cmdExec.Run("launchctl", "print", serviceTarget)
55+
if err != nil {
56+
return false, err
57+
}
58+
59+
if exitCode == 0 {
60+
return true, nil
61+
} else if exitCode == LaunchdServiceNotFoundErrorCode {
62+
return false, nil
63+
} else {
64+
return false, fmt.Errorf("could not determine if service '%s' is bootstrapped: "+
65+
"'launchctl print' exited with status '%d'", serviceTarget, exitCode)
66+
}
67+
}
68+
69+
func (l *launchd) bootstrapFile(domain string, filename string) error {
70+
// run 'launchctl bootstrap' on given domain & file
71+
exitCode, err := l.cmdExec.Run("launchctl", "bootstrap", domain, filename)
72+
if err != nil {
73+
return err
74+
}
75+
76+
if exitCode != 0 {
77+
return fmt.Errorf("'launchctl bootstrap' exited with status %d", exitCode)
78+
}
79+
80+
return nil
81+
}
82+
83+
func (l *launchd) bootoutFile(domain string, filename string) error {
84+
// run 'launchctl bootout' on given domain & file
85+
exitCode, err := l.cmdExec.Run("launchctl", "bootout", domain, filename)
86+
if err != nil {
87+
return err
88+
}
889

9-
func NewLaunchdProvider() DaemonProvider {
10-
return &launchd{}
90+
if exitCode != 0 {
91+
return fmt.Errorf("'launchctl bootout' exited with status %d", exitCode)
92+
}
93+
94+
return nil
1195
}
1296

1397
func (l *launchd) Create(config *DaemonConfig, force bool) error {
14-
return fmt.Errorf("not implemented")
98+
// Add launchd-specific config
99+
lConfig := &launchdConfig{
100+
DaemonConfig: *config,
101+
StdOut: "/dev/null",
102+
StdErr: "/dev/null",
103+
}
104+
105+
// Generate the configuration
106+
var newPlist bytes.Buffer
107+
t, err := template.New(config.Label).Parse(launchTemplate)
108+
if err != nil {
109+
return fmt.Errorf("unable to generate launchd configuration: %w", err)
110+
}
111+
t.Execute(&newPlist, lConfig)
112+
113+
// Check the existing file - if it's the same as the new content, do not overwrite
114+
user, err := l.user.CurrentUser()
115+
if err != nil {
116+
return fmt.Errorf("could not get current user for launchd service: %w", err)
117+
}
118+
119+
filename := filepath.Join(user.HomeDir, "Library", "LaunchAgents", fmt.Sprintf("%s.plist", config.Label))
120+
domainTarget := fmt.Sprintf(domainFormat, user.Uid)
121+
serviceTarget := fmt.Sprintf("%s/%s", domainTarget, config.Label)
122+
123+
alreadyLoaded, err := l.isBootstrapped(serviceTarget)
124+
if err != nil {
125+
return err
126+
}
127+
128+
// First, verify whether the file exists
129+
// TODO: only overwrite file if file contents have changed
130+
fileExists, err := l.fileSystem.FileExists(filename)
131+
if err != nil {
132+
return fmt.Errorf("could not determine whether plist '%s' exists: %w", filename, err)
133+
}
134+
135+
if alreadyLoaded && !fileExists {
136+
// Abort on corrupted configuration
137+
return fmt.Errorf("service target '%s' is bootstrapped, but its plist doesn't exist", serviceTarget)
138+
}
139+
140+
if !force && alreadyLoaded {
141+
// Not forcing a refresh of the file, so we do nothing
142+
return nil
143+
}
144+
145+
// Otherwise, write & bootstrap the file
146+
if alreadyLoaded {
147+
// Unload the old file, if necessary
148+
l.bootoutFile(domainTarget, filename)
149+
}
150+
151+
if !fileExists || force {
152+
err = l.fileSystem.WriteFile(filename, newPlist.Bytes())
153+
if err != nil {
154+
return fmt.Errorf("unable to overwrite plist file: %w", err)
155+
}
156+
}
157+
158+
err = l.bootstrapFile(domainTarget, filename)
159+
if err != nil {
160+
return fmt.Errorf("could not bootstrap daemon process '%s': %w", config.Label, err)
161+
}
162+
163+
return nil
15164
}
16165

17166
func (l *launchd) Start(label string) error {

0 commit comments

Comments
 (0)