Skip to content

Commit c82b8e0

Browse files
committed
web-server: add '--remove' option to 'stop'
Add a '--remove' option to 'git-bundle-server web-server stop' to fully remove the service daemon and its resources on disk after stopping the daemon process. For launchd, this is done by first running 'launchctl bootout' on the appropriate service, then removing the plist file; for systemd, the service unit file is removed then 'systemctl daemon-reload' is run. Neither implementation fails if the web server was never loaded. Although it is unlikely that users will need this option in day-to-day use, this cleanup option will be needed to fully remove bundle server resources in the uninstall scripts implemented in later patches. Signed-off-by: Victoria Dye <[email protected]>
1 parent 8c6a468 commit c82b8e0

File tree

9 files changed

+285
-8
lines changed

9 files changed

+285
-8
lines changed

cmd/git-bundle-server/web-server.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ func (w *webServer) startServer(args []string) error {
145145

146146
func (w *webServer) stopServer(args []string) error {
147147
// Parse subcommand arguments
148-
parser := argparse.NewArgParser("git-bundle-server web-server stop")
148+
parser := argparse.NewArgParser("git-bundle-server web-server stop [--remove]")
149+
remove := parser.Bool("remove", false, "Remove the web server daemon configuration from the system after stopping")
149150
parser.Parse(args)
150151

151152
d, err := daemon.NewDaemonProvider(w.user, w.cmdExec, w.fileSystem)
@@ -163,6 +164,13 @@ func (w *webServer) stopServer(args []string) error {
163164
return err
164165
}
165166

167+
if *remove {
168+
err = d.Remove(config.Label)
169+
if err != nil {
170+
return err
171+
}
172+
}
173+
166174
return nil
167175
}
168176

internal/common/filesystem.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"fmt"
77
"os"
88
"path"
9+
"syscall"
910
)
1011

1112
type FileSystem interface {
1213
FileExists(filename string) (bool, error)
1314
WriteFile(filename string, content []byte) error
15+
DeleteFile(filename string) (bool, error)
1416
ReadFileLines(filename string) ([]string, error)
1517
}
1618

@@ -46,6 +48,20 @@ func (f *fileSystem) WriteFile(filename string, content []byte) error {
4648
return nil
4749
}
4850

51+
func (f *fileSystem) DeleteFile(filename string) (bool, error) {
52+
err := os.Remove(filename)
53+
if err == nil {
54+
return true, nil
55+
}
56+
57+
pathErr, ok := err.(*os.PathError)
58+
if ok && pathErr.Err == syscall.ENOENT {
59+
return false, nil
60+
} else {
61+
return false, err
62+
}
63+
}
64+
4965
func (f *fileSystem) ReadFileLines(filename string) ([]string, error) {
5066
file, err := os.OpenFile(filename, os.O_RDONLY|os.O_CREATE, 0o600)
5167
if err != nil {

internal/daemon/daemon.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type DaemonProvider interface {
2020
Start(label string) error
2121

2222
Stop(label string) error
23+
24+
Remove(label string) error
2325
}
2426

2527
func NewDaemonProvider(

internal/daemon/launchd.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,26 @@ func (l *launchd) Stop(label string) error {
265265

266266
return nil
267267
}
268+
269+
func (l *launchd) Remove(label string) error {
270+
user, err := l.user.CurrentUser()
271+
if err != nil {
272+
return fmt.Errorf("could not get current user for launchd service: %w", err)
273+
}
274+
275+
filename := filepath.Join(user.HomeDir, "Library", "LaunchAgents", fmt.Sprintf("%s.plist", label))
276+
domainTarget := fmt.Sprintf(domainFormat, user.Uid)
277+
serviceTarget := fmt.Sprintf("%s/%s", domainTarget, label)
278+
279+
_, err = l.bootout(serviceTarget)
280+
if err != nil {
281+
return fmt.Errorf("could not remove daemon process '%s': %w", label, err)
282+
}
283+
284+
_, err = l.fileSystem.DeleteFile(filename)
285+
if err != nil {
286+
return fmt.Errorf("could not delete launchd plist: %w", err)
287+
}
288+
289+
return nil
290+
}

internal/daemon/launchd_test.go

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ func TestLaunchd_Stop(t *testing.T) {
435435
// Reset the mock structure between tests
436436
testCommandExecutor.Mock = mock.Mock{}
437437

438-
// Test #3: launchctl fails with uncaught error
438+
// Test #3: launchctl fails with expected error
439439
t.Run("Exits without error if service not found", func(t *testing.T) {
440440
testCommandExecutor.On("Run",
441441
mock.AnythingOfType("string"),
@@ -447,3 +447,102 @@ func TestLaunchd_Stop(t *testing.T) {
447447
mock.AssertExpectationsForObjects(t, testCommandExecutor)
448448
})
449449
}
450+
451+
var launchdRemoveTests = []struct {
452+
title string
453+
454+
// Inputs
455+
label string
456+
457+
// Mocked responses
458+
launchctlBootout *Pair[int, error]
459+
deleteFile *Pair[bool, error]
460+
461+
// Expected values
462+
expectErr bool
463+
}{
464+
{
465+
"Unloads and deletes plist when service loaded",
466+
"com.test.service",
467+
PtrTo(NewPair[int, error](0, nil)), // launchctl bootout
468+
PtrTo(NewPair[bool, error](true, nil)), // delete file
469+
false,
470+
},
471+
{
472+
"Removes plist when service is missing",
473+
"com.test.service",
474+
PtrTo(NewPair[int, error](daemon.LaunchdNoSuchProcessErrorCode, nil)), // launchctl bootout
475+
PtrTo(NewPair[bool, error](true, nil)), // delete file
476+
false,
477+
},
478+
{
479+
"Removal of non-existent service succeeds",
480+
"com.test.service",
481+
PtrTo(NewPair[int, error](daemon.LaunchdNoSuchProcessErrorCode, nil)), // launchctl bootout
482+
PtrTo(NewPair[bool, error](false, nil)), // delete file
483+
false,
484+
},
485+
{
486+
"launchctl error code skips file deletion",
487+
"com.test.service",
488+
PtrTo(NewPair[int, error](-1, nil)), // launchctl bootout
489+
nil, // delete file
490+
true,
491+
},
492+
{
493+
"Unhandled launchctl error skips file deletion",
494+
"com.test.service",
495+
PtrTo(NewPair(0, fmt.Errorf("some unhandled error"))), // launchctl bootout
496+
nil, // delete file
497+
true,
498+
},
499+
}
500+
501+
func TestLaunchd_Remove(t *testing.T) {
502+
// Set up mocks
503+
testUser := &user.User{
504+
Uid: "123",
505+
Username: "testuser",
506+
HomeDir: "/my/test/dir",
507+
}
508+
testUserProvider := &MockUserProvider{}
509+
testUserProvider.On("CurrentUser").Return(testUser, nil)
510+
511+
testCommandExecutor := &MockCommandExecutor{}
512+
testFileSystem := &MockFileSystem{}
513+
514+
launchd := daemon.NewLaunchdProvider(testUserProvider, testCommandExecutor, testFileSystem)
515+
516+
for _, tt := range launchdRemoveTests {
517+
t.Run(tt.title, func(t *testing.T) {
518+
// Setup expected values
519+
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/Library/LaunchAgents/%s.plist", tt.label))
520+
521+
// Mock responses
522+
if tt.launchctlBootout != nil {
523+
testCommandExecutor.On("Run",
524+
"launchctl",
525+
[]string{"bootout", fmt.Sprintf("user/123/%s", tt.label)},
526+
).Return(tt.launchctlBootout.First, tt.launchctlBootout.Second).Once()
527+
}
528+
if tt.deleteFile != nil {
529+
testFileSystem.On("DeleteFile",
530+
expectedFilename,
531+
).Return(tt.deleteFile.First, tt.deleteFile.Second).Once()
532+
}
533+
534+
// Call function
535+
err := launchd.Remove(tt.label)
536+
mock.AssertExpectationsForObjects(t, testCommandExecutor)
537+
if tt.expectErr {
538+
assert.NotNil(t, err)
539+
} else {
540+
assert.Nil(t, err)
541+
}
542+
})
543+
544+
// Reset the mocks between tests
545+
testCommandExecutor.Mock = mock.Mock{}
546+
testFileSystem.Mock = mock.Mock{}
547+
}
548+
}

internal/daemon/systemd.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ func NewSystemdProvider(
3838
}
3939
}
4040

41+
func (s *systemd) reloadDaemon() error {
42+
exitCode, err := s.cmdExec.Run("systemctl", "--user", "daemon-reload")
43+
if err != nil {
44+
return err
45+
}
46+
47+
if exitCode != 0 {
48+
return fmt.Errorf("'systemctl --user daemon-reload' exited with status %d", exitCode)
49+
}
50+
51+
return nil
52+
}
53+
4154
func (s *systemd) Create(config *DaemonConfig, force bool) error {
4255
user, err := s.user.CurrentUser()
4356
if err != nil {
@@ -75,16 +88,12 @@ func (s *systemd) Create(config *DaemonConfig, force bool) error {
7588
return fmt.Errorf("unable to write service unit: %w", err)
7689
}
7790

78-
// Reload the user-scoped service units
79-
exitCode, err := s.cmdExec.Run("systemctl", "--user", "daemon-reload")
91+
// Reload the user-scoped service units after adding
92+
err = s.reloadDaemon()
8093
if err != nil {
8194
return err
8295
}
8396

84-
if exitCode != 0 {
85-
return fmt.Errorf("'systemctl --user daemon-reload' exited with status %d", exitCode)
86-
}
87-
8897
return nil
8998
}
9099

@@ -115,3 +124,24 @@ func (s *systemd) Stop(label string) error {
115124

116125
return nil
117126
}
127+
128+
func (s *systemd) Remove(label string) error {
129+
user, err := s.user.CurrentUser()
130+
if err != nil {
131+
return fmt.Errorf("could not get current user for launchd service: %w", err)
132+
}
133+
filename := filepath.Join(user.HomeDir, ".config", "systemd", "user", fmt.Sprintf("%s.service", label))
134+
135+
_, err = s.fileSystem.DeleteFile(filename)
136+
if err != nil {
137+
return fmt.Errorf("could not delete service unit: %w", err)
138+
}
139+
140+
// Reload the user-scoped service units after removing
141+
err = s.reloadDaemon()
142+
if err != nil {
143+
return err
144+
}
145+
146+
return nil
147+
}

internal/daemon/systemd_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,88 @@ func TestSystemd_Stop(t *testing.T) {
329329
mock.AssertExpectationsForObjects(t, testCommandExecutor)
330330
})
331331
}
332+
333+
var systemdRemoveTests = []struct {
334+
title string
335+
336+
// Inputs
337+
label string
338+
339+
// Mocked responses
340+
deleteFile *Pair[bool, error]
341+
systemctlDaemonReload *Pair[int, error]
342+
343+
// Expected values
344+
expectErr bool
345+
}{
346+
{
347+
"Unloads and deletes service unit",
348+
"com.test.service",
349+
PtrTo(NewPair[bool, error](true, nil)), // delete file
350+
PtrTo(NewPair[int, error](0, nil)), // systemctl daemon-reload
351+
false,
352+
},
353+
{
354+
"Reloads daemon even if service unit missing",
355+
"com.test.service",
356+
PtrTo(NewPair[bool, error](false, nil)), // delete file
357+
PtrTo(NewPair[int, error](0, nil)), // systemctl daemon-reload
358+
false,
359+
},
360+
{
361+
"Daemon not reloaded if file cannot be deleted",
362+
"com.test.service",
363+
PtrTo(NewPair(false, fmt.Errorf("unhandled error"))), // delete file
364+
nil, // systemctl daemon-reload
365+
true,
366+
},
367+
}
368+
369+
func TestSystemd_Remove(t *testing.T) {
370+
// Set up mocks
371+
testUser := &user.User{
372+
Uid: "123",
373+
Username: "testuser",
374+
HomeDir: "/my/test/dir",
375+
}
376+
testUserProvider := &MockUserProvider{}
377+
testUserProvider.On("CurrentUser").Return(testUser, nil)
378+
379+
testCommandExecutor := &MockCommandExecutor{}
380+
testFileSystem := &MockFileSystem{}
381+
382+
systemd := daemon.NewSystemdProvider(testUserProvider, testCommandExecutor, testFileSystem)
383+
384+
for _, tt := range systemdRemoveTests {
385+
t.Run(tt.title, func(t *testing.T) {
386+
// Setup expected values
387+
expectedFilename := filepath.Clean(fmt.Sprintf("/my/test/dir/.config/systemd/user/%s.service", tt.label))
388+
389+
// Mock responses
390+
if tt.deleteFile != nil {
391+
testFileSystem.On("DeleteFile",
392+
expectedFilename,
393+
).Return(tt.deleteFile.First, tt.deleteFile.Second).Once()
394+
}
395+
if tt.systemctlDaemonReload != nil {
396+
testCommandExecutor.On("Run",
397+
"systemctl",
398+
[]string{"--user", "daemon-reload"},
399+
).Return(tt.systemctlDaemonReload.First, tt.systemctlDaemonReload.Second).Once()
400+
}
401+
402+
// Call function
403+
err := systemd.Remove(tt.label)
404+
mock.AssertExpectationsForObjects(t, testCommandExecutor)
405+
if tt.expectErr {
406+
assert.NotNil(t, err)
407+
} else {
408+
assert.Nil(t, err)
409+
}
410+
})
411+
412+
// Reset the mocks between tests
413+
testCommandExecutor.Mock = mock.Mock{}
414+
testFileSystem.Mock = mock.Mock{}
415+
}
416+
}

internal/testhelpers/funcs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package testhelpers
2+
3+
/********************************************/
4+
/************* Helper Functions *************/
5+
/********************************************/
6+
7+
func PtrTo[T any](val T) *T {
8+
return &val
9+
}

internal/testhelpers/mocks.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ func (m *MockFileSystem) WriteFile(filename string, content []byte) error {
3838
return fnArgs.Error(0)
3939
}
4040

41+
func (m *MockFileSystem) DeleteFile(filename string) (bool, error) {
42+
fnArgs := m.Called(filename)
43+
return fnArgs.Bool(0), fnArgs.Error(1)
44+
}
45+
4146
func (m *MockFileSystem) ReadFileLines(filename string) ([]string, error) {
4247
fnArgs := m.Called(filename)
4348
return fnArgs.Get(0).([]string), fnArgs.Error(1)

0 commit comments

Comments
 (0)