Skip to content

Commit 69518f0

Browse files
committed
web-server: add '--remove' option to 'stop'
Signed-off-by: Victoria Dye <[email protected]>
1 parent 852c7da commit 69518f0

File tree

9 files changed

+283
-8
lines changed

9 files changed

+283
-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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,24 @@ func (l *launchd) Stop(label string) error {
261261

262262
return nil
263263
}
264+
265+
func (l *launchd) Remove(label string) error {
266+
user, err := l.user.CurrentUser()
267+
if err != nil {
268+
return fmt.Errorf("could not get current user for launchd service: %w", err)
269+
}
270+
filename := filepath.Join(user.HomeDir, "Library", "LaunchAgents", fmt.Sprintf("%s.plist", label))
271+
domainTarget := fmt.Sprintf(domainFormat, user.Uid)
272+
273+
err = l.bootoutFile(domainTarget, filename)
274+
if err != nil {
275+
return fmt.Errorf("could not unload launchd service: %w", err)
276+
}
277+
278+
_, err = l.fileSystem.DeleteFile(filename)
279+
if err != nil {
280+
return fmt.Errorf("could not delete launchd plist: %w", err)
281+
}
282+
283+
return nil
284+
}

internal/daemon/launchd_test.go

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

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

internal/daemon/systemd.go

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

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

76-
// Reload the user-scoped service units
77-
exitCode, err := s.cmdExec.Run("systemctl", "--user", "daemon-reload")
89+
// Reload the user-scoped service units after adding
90+
err = s.reloadDaemon()
7891
if err != nil {
7992
return err
8093
}
8194

82-
if exitCode != 0 {
83-
return fmt.Errorf("'systemctl --user daemon-reload' exited with status %d", exitCode)
84-
}
85-
8695
return nil
8796
}
8897

@@ -113,3 +122,24 @@ func (s *systemd) Stop(label string) error {
113122

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

internal/daemon/systemd_test.go

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

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)