Skip to content

Commit 92237dd

Browse files
committed
systemd: implement 'Create()' function
Implement and test the 'Create()' function for creating a 'systemd' daemon service. The service unit created is user-scoped (hence the '--user' option in all 'systemctl' commands) and, like the equivalent 'launchd' implementation, does not overwrite an existing service file unless the 'force' arg is 'true'. Signed-off-by: Victoria Dye <[email protected]>
1 parent 9eac445 commit 92237dd

File tree

4 files changed

+242
-9
lines changed

4 files changed

+242
-9
lines changed

internal/daemon/daemon.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import (
88
)
99

1010
type DaemonConfig struct {
11-
Label string
12-
Program string
11+
Label string
12+
Description string
13+
Program string
1314
}
1415

1516
type DaemonProvider interface {
@@ -28,7 +29,7 @@ func NewDaemonProvider(
2829
switch thisOs := runtime.GOOS; thisOs {
2930
case "linux":
3031
// Use systemd/systemctl
31-
return NewSystemdProvider(), nil
32+
return NewSystemdProvider(u, c, fs), nil
3233
case "darwin":
3334
// Use launchd/launchctl
3435
return NewLaunchdProvider(u, c, fs), nil

internal/daemon/shared_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import (
1212
/*********************************************/
1313

1414
var basicDaemonConfig = daemon.DaemonConfig{
15-
Label: "com.example.testdaemon",
16-
Program: "/usr/local/bin/test/git-bundle-web-server",
15+
Label: "com.example.testdaemon",
16+
Description: "Test service",
17+
Program: "/usr/local/bin/test/git-bundle-web-server",
1718
}
1819

1920
/*********************************************/

internal/daemon/systemd.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,84 @@
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 systemd struct{}
12+
const serviceTemplate string = `[Unit]
13+
Description={{.Description}}
14+
15+
[Service]
16+
Type=simple
17+
ExecStart={{.Program}}
18+
`
19+
20+
type systemd struct {
21+
user common.UserProvider
22+
cmdExec common.CommandExecutor
23+
fileSystem common.FileSystem
24+
}
825

9-
func NewSystemdProvider() DaemonProvider {
10-
return &systemd{}
26+
func NewSystemdProvider(
27+
u common.UserProvider,
28+
c common.CommandExecutor,
29+
fs common.FileSystem,
30+
) DaemonProvider {
31+
return &systemd{
32+
user: u,
33+
cmdExec: c,
34+
fileSystem: fs,
35+
}
1136
}
1237

1338
func (s *systemd) Create(config *DaemonConfig, force bool) error {
14-
return fmt.Errorf("not implemented")
39+
user, err := s.user.CurrentUser()
40+
if err != nil {
41+
return fmt.Errorf("could not get current user for systemd service: %w", err)
42+
}
43+
44+
// Generate the configuration
45+
var newServiceUnit bytes.Buffer
46+
t, err := template.New(config.Label).Parse(serviceTemplate)
47+
if err != nil {
48+
return fmt.Errorf("unable to generate systemd configuration: %w", err)
49+
}
50+
t.Execute(&newServiceUnit, config)
51+
52+
filename := filepath.Join(user.HomeDir, ".config", "systemd", "user", fmt.Sprintf("%s.service", config.Label))
53+
54+
// Check whether the file exists
55+
fileExists, err := s.fileSystem.FileExists(filename)
56+
if err != nil {
57+
return fmt.Errorf("could not determine whether service unit '%s' exists: %w", config.Label, err)
58+
}
59+
60+
if !force && fileExists {
61+
// File already exists and we aren't forcing a refresh, so we do nothing
62+
return nil
63+
}
64+
65+
// Otherwise, write the new file
66+
err = s.fileSystem.WriteFile(filename, newServiceUnit.Bytes())
67+
if err != nil {
68+
return fmt.Errorf("unable to write service unit: %w", err)
69+
}
70+
71+
// Reload the user-scoped service units
72+
exitCode, err := s.cmdExec.Run("systemctl", "--user", "daemon-reload")
73+
if err != nil {
74+
return err
75+
}
76+
77+
if exitCode != 0 {
78+
return fmt.Errorf("'systemctl --user daemon-reload' exited with status %d", exitCode)
79+
}
80+
81+
return nil
1582
}
1683

1784
func (s *systemd) Start(label string) error {

internal/daemon/systemd_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package daemon_test
2+
3+
import (
4+
"fmt"
5+
"os/user"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/github/git-bundle-server/internal/daemon"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/mock"
12+
)
13+
14+
var systemdCreateTests = []struct {
15+
title string
16+
17+
// Inputs
18+
config *daemon.DaemonConfig
19+
force boolArg
20+
21+
// Mocked responses (ordered per list!)
22+
fileExists []pair[bool, error]
23+
writeFile []error
24+
systemctlDaemonReload []pair[int, error]
25+
26+
// Expected values
27+
expectErr bool
28+
}{
29+
{
30+
"Fresh service unit created if none exists, daemon reloaded",
31+
&daemon.DaemonConfig{
32+
Label: "com.example.testdaemon",
33+
Program: "/usr/local/bin/test/git-bundle-web-server",
34+
},
35+
Any,
36+
[]pair[bool, error]{newPair[bool, error](false, nil)}, // file exists
37+
[]error{nil}, // write file
38+
[]pair[int, error]{newPair[int, error](0, nil)}, // systemctl daemon-reload
39+
false,
40+
},
41+
{
42+
"Service unit exists, doesn't write file or reload",
43+
&daemon.DaemonConfig{
44+
Label: "com.example.testdaemon",
45+
Program: "/usr/local/bin/test/git-bundle-web-server",
46+
},
47+
False,
48+
[]pair[bool, error]{newPair[bool, error](true, nil)}, // file exists
49+
[]error{}, // write file
50+
[]pair[int, error]{}, // systemctl daemon-reload
51+
false,
52+
},
53+
{
54+
"'force' option overwrites service unit and reloads daemon",
55+
&daemon.DaemonConfig{
56+
Label: "com.example.testdaemon",
57+
Program: "/usr/local/bin/test/git-bundle-web-server",
58+
},
59+
True,
60+
[]pair[bool, error]{newPair[bool, error](true, nil)}, // file exists
61+
[]error{nil}, // write file
62+
[]pair[int, error]{newPair[int, error](0, nil)}, // systemctl daemon-reload
63+
false,
64+
},
65+
}
66+
67+
func TestSystemd_Create(t *testing.T) {
68+
// Set up mocks
69+
testUser := &user.User{
70+
Uid: "123",
71+
Username: "testuser",
72+
HomeDir: "/my/test/dir",
73+
}
74+
testUserProvider := &mockUserProvider{}
75+
testUserProvider.On("CurrentUser").Return(testUser, nil)
76+
77+
testCommandExecutor := &mockCommandExecutor{}
78+
79+
testFileSystem := &mockFileSystem{}
80+
81+
systemd := daemon.NewSystemdProvider(testUserProvider, testCommandExecutor, testFileSystem)
82+
83+
for _, tt := range systemdCreateTests {
84+
forceArg := tt.force.toBoolList()
85+
for _, force := range forceArg {
86+
t.Run(fmt.Sprintf("%s (force='%t')", tt.title, force), func(t *testing.T) {
87+
// Mock responses
88+
for _, retVal := range tt.fileExists {
89+
testFileSystem.On("FileExists",
90+
mock.AnythingOfType("string"),
91+
).Return(retVal.first, retVal.second).Once()
92+
}
93+
for _, retVal := range tt.writeFile {
94+
testFileSystem.On("WriteFile",
95+
mock.AnythingOfType("string"),
96+
mock.Anything,
97+
).Return(retVal).Once()
98+
}
99+
for _, retVal := range tt.systemctlDaemonReload {
100+
testCommandExecutor.On("Run",
101+
"systemctl",
102+
[]string{"--user", "daemon-reload"},
103+
).Return(retVal.first, retVal.second).Once()
104+
}
105+
106+
// Run "Create"
107+
err := systemd.Create(tt.config, force)
108+
109+
// Assert on expected values
110+
if tt.expectErr {
111+
assert.NotNil(t, err)
112+
} else {
113+
assert.Nil(t, err)
114+
}
115+
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
116+
117+
// Reset mocks
118+
testCommandExecutor.Mock = mock.Mock{}
119+
testFileSystem.Mock = mock.Mock{}
120+
})
121+
}
122+
}
123+
124+
// Verify content of created file
125+
t.Run("Created file content and path are correct", func(t *testing.T) {
126+
var actualFilename string
127+
var actualFileBytes []byte
128+
129+
// Mock responses for successful fresh write
130+
testCommandExecutor.On("Run",
131+
"systemctl",
132+
[]string{"--user", "daemon-reload"},
133+
).Return(0, nil).Once()
134+
testFileSystem.On("FileExists",
135+
mock.AnythingOfType("string"),
136+
).Return(false, nil).Once()
137+
138+
// Use mock to save off input args
139+
testFileSystem.On("WriteFile",
140+
mock.MatchedBy(func(filename string) bool {
141+
actualFilename = filename
142+
return true
143+
}),
144+
mock.MatchedBy(func(fileBytes any) bool {
145+
// Save off value and always match
146+
actualFileBytes = fileBytes.([]byte)
147+
return true
148+
}),
149+
).Return(nil).Once()
150+
151+
err := systemd.Create(&basicDaemonConfig, false)
152+
assert.Nil(t, err)
153+
mock.AssertExpectationsForObjects(t, testCommandExecutor, testFileSystem)
154+
155+
// Check filename
156+
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/.config/systemd/user/%s.service", basicDaemonConfig.Label))
157+
assert.Equal(t, expectedFilename, actualFilename)
158+
159+
// Check file contents
160+
fileContents := string(actualFileBytes)
161+
assert.Contains(t, fileContents, fmt.Sprintf("ExecStart=%s", basicDaemonConfig.Program))
162+
assert.Contains(t, fileContents, fmt.Sprintf("Description=%s", basicDaemonConfig.Description))
163+
})
164+
}

0 commit comments

Comments
 (0)