Pull request 2607: AGDNS-3661-launchd-svc-status

Squashed commit of the following:

commit 6912e3a609f82457d1bca7121a309a3b9be84ed8
Merge: 9892a8258 d7b6dc0cf
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Fri Mar 20 11:33:34 2026 +0300

    Merge branch 'master' into AGDNS-3661-launchd-svc-status

commit 9892a825801b3793956b9f501481e1b7dd4d64d3
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Thu Mar 19 14:47:40 2026 +0300

    ossvc: imp tests

commit 962fdc9f1c7bd8615ef4f8a2e4879369678580d8
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Thu Mar 19 13:59:43 2026 +0300

    ossvc: add logs, tests

commit 3f8b678888c5d09314eb993b06abb162767b4ef2
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Mon Mar 16 18:48:58 2026 +0300

    ossvc: add warn, imp style

commit 115696220d0a5bb6ef620a4a5b1dc30fecdf866c
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Fri Mar 13 18:18:18 2026 +0300

    ossvc: use launchctl list

commit c1ae51859de33133d8bd461e131538e4e7f8b682
Merge: 02c752fc6 0078b8430
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Fri Mar 13 18:12:33 2026 +0300

    Merge branch 'master' into AGDNS-3661-launchd-svc-status

commit 02c752fc6625e283a9c422469d9da1ebf308c80c
Author: f.setrakov <f.setrakov@adguard.com>
Date:   Fri Mar 13 15:53:37 2026 +0300

    all: add darwin service
This commit is contained in:
Fedor Setrakov 2026-03-20 08:48:12 +00:00
parent d7b6dc0cf7
commit 1415014085
7 changed files with 312 additions and 11 deletions

View file

@ -26,6 +26,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
### Fixed
- Status reported by the launchd service implementation in cases of scheduled service restart.
- Fixed clients block/unblock when moving clients between allowed and disallowed lists.
<!--

View file

@ -27,10 +27,10 @@ type manager struct {
// newManager creates a new [Manager] that uses [service.Service].
//
// TODO(e.burkov): Return error.
func newManager(_ context.Context, conf *ManagerConfig) (mgr *manager) {
func newManager(ctx context.Context, conf *ManagerConfig) (mgr *manager) {
// Call chooseSystem explicitly to introduce platform-specific support for
// service package. It's a noop for other GOOS values.
chooseSystem()
chooseSystem(ctx, conf.Logger)
return &manager{
logger: conf.Logger,
@ -74,8 +74,8 @@ func (m *manager) Perform(ctx context.Context, action Action) (err error) {
return nil
}
// statusRestartOnFail is a custom status value used to indicate the service's
// state of restarting after failed start.
// statusRestartOnFail is a custom status value used to indicate the
// service's state of restarting after failed start.
const statusRestartOnFail = service.StatusStopped + 1
// Status implements the [Manager] interface for *manager.

View file

@ -0,0 +1,173 @@
//go:build darwin
package ossvc
import (
"bufio"
"bytes"
"context"
"io"
"log/slog"
"os"
"path"
"strings"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/logutil/slogutil"
"github.com/AdguardTeam/golibs/osutil/executil"
"github.com/kardianos/service"
)
// chooseSystem replaces the currently selected system with a wrapper. l must
// not be nil.
func chooseSystem(_ context.Context, l *slog.Logger) {
sys := service.ChosenSystem()
service.ChooseSystem(&darwinSystem{
System: sys,
logger: l,
})
}
// darwinSystem is the wrapper for [service.System] that returns the custom
// implementation of the [service.Service] interface.
type darwinSystem struct {
service.System
logger *slog.Logger
}
// type check
var _ service.System = (*darwinSystem)(nil)
// New implements the [service.System] interface for *darwinSystem. i and c
// must not be nil.
func (d *darwinSystem) New(i service.Interface, c *service.Config) (s service.Service, err error) {
s, err = d.System.New(i, c)
if err != nil {
// Don't wrap the error to keep it as close to the original one as
// possible.
return s, err
}
return newDarwinService(&darwinServiceConfig{
svc: s,
logger: d.logger,
cmdCons: executil.SystemCommandConstructor{},
name: c.Name,
plistDir: "/Library/LaunchDaemons",
}), nil
}
// darwinServiceConfig is the configuration structure for [*darwinService].
type darwinServiceConfig struct {
// svc is the base service to extend. It must not be nil.
svc service.Service
// logger is used for logging service operations. It must not be nil.
logger *slog.Logger
// cmdCons is used to create system commands. It must not be nil.
cmdCons executil.CommandConstructor
// name is the launchd service name.
name string
// plistDir is the path to the directory that contains launchd plist files.
plistDir string
}
// darwinService is a wrapper for a darwin [service.Service] that enriches the
// service status information.
type darwinService struct {
service.Service
logger *slog.Logger
cmdCons executil.CommandConstructor
name string
plistDir string
}
// newDarwinService returns properly initialized *darwinService. c must be
// non-nil and valid.
func newDarwinService(c *darwinServiceConfig) (d *darwinService) {
return &darwinService{
Service: c.svc,
logger: c.logger,
cmdCons: c.cmdCons,
name: c.name,
plistDir: c.plistDir,
}
}
// type check
var _ service.Service = (*darwinService)(nil)
// Status implements the [service.Service] interface for *darwinService.
func (d *darwinService) Status() (status service.Status, err error) {
// TODO(f.setrakov): Pass context.
ctx := context.TODO()
if !d.isInstalled(ctx) {
return service.StatusUnknown, service.ErrNotInstalled
}
const launchctlCmd = "launchctl"
var (
launchctlArgs = []string{"list", d.name}
launchctlStdout bytes.Buffer
)
err = executil.Run(ctx, d.cmdCons, &executil.CommandConfig{
Path: launchctlCmd,
Args: launchctlArgs,
Stdout: &launchctlStdout,
})
if err != nil {
return service.StatusStopped, nil
}
return parseLaunchctlList(&launchctlStdout)
}
// isInstalled returns true if there is actual service .plist file.
func (d *darwinService) isInstalled(ctx context.Context) (ok bool) {
plistPath := path.Join(d.plistDir, d.name+".plist")
_, err := os.Stat(plistPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
d.logger.WarnContext(ctx, "checking plist file", slogutil.KeyError, err)
}
return false
}
return true
}
// propNamePID represents the PID prop name in the launchctl list output.
const propNamePID = `"PID"`
// parseLaunchctlList parses the output of the launchctl list command. It
// expects that output contains default launchctl tree-like output with
// prop=value pairs.
func parseLaunchctlList(output io.Reader) (status service.Status, err error) {
scanner := bufio.NewScanner(output)
for scanner.Scan() {
line := scanner.Text()
propName, propValue, ok := strings.Cut(line, "=")
if !ok {
continue
}
propName = strings.TrimSpace(propName)
propValue = strings.TrimSpace(propValue)
if propName == propNamePID && propValue != "" {
return service.StatusRunning, nil
}
}
if err = scanner.Err(); err != nil {
return service.StatusUnknown, err
}
return statusRestartOnFail, nil
}

View file

@ -0,0 +1,113 @@
//go:build darwin
package ossvc
import (
"context"
"io"
"os"
"path"
"testing"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/logutil/slogutil"
"github.com/AdguardTeam/golibs/osutil/executil"
"github.com/AdguardTeam/golibs/testutil"
"github.com/AdguardTeam/golibs/testutil/fakeos/fakeexec"
"github.com/kardianos/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestCmdConstructor is a helper that creates a new command constructor. The
// returned constructor creates [fakeexec.Command] instances that print the
// given body to the command's standard output and return the error.
func newTestCmdConstructor(
tb testing.TB,
body string,
returnErr error,
) (c executil.CommandConstructor) {
tb.Helper()
onNew := func(
_ context.Context,
conf *executil.CommandConfig,
) (c executil.Command, err error) {
cmd := fakeexec.NewCommand()
cmd.OnStart = func(_ context.Context) (err error) {
_, err = io.WriteString(conf.Stdout, body)
require.NoError(tb, err)
return returnErr
}
cmd.OnWait = func(_ context.Context) (err error) { return nil }
return cmd, nil
}
return &fakeexec.CommandConstructor{
OnNew: onNew,
}
}
func TestDarwinService_Status(t *testing.T) {
name := "AdGuardHome"
plistDir := t.TempDir()
svc := newDarwinService(&darwinServiceConfig{
logger: slogutil.NewDiscardLogger(),
plistDir: plistDir,
name: name,
})
t.Run("not_installed", func(t *testing.T) {
status, err := svc.Status()
assert.Equal(t, service.StatusUnknown, status)
assert.ErrorIs(t, err, service.ErrNotInstalled)
})
plistPath := path.Join(plistDir, name+".plist")
file, err := os.Create(plistPath)
require.NoError(t, err)
testutil.CleanupAndRequireSuccess(t, file.Close)
testCases := []struct {
cmdErr error
name string
body string
wantStatus service.Status
}{{
name: "running",
body: `
{
"PID" = 12345;
};`,
cmdErr: nil,
wantStatus: service.StatusRunning,
}, {
name: "restarting",
body: `
{
"foo" = "bar";
};`,
cmdErr: nil,
wantStatus: statusRestartOnFail,
}, {
name: "stopped",
body: "",
cmdErr: errors.Error("launchctl error"),
wantStatus: service.StatusStopped,
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
svc.cmdCons = newTestCmdConstructor(t, tc.body, tc.cmdErr)
var status service.Status
status, err = svc.Status()
require.NoError(t, err)
assert.Equal(t, tc.wantStatus, status)
})
}
}

View file

@ -8,6 +8,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
@ -16,8 +17,8 @@ import (
)
// chooseSystem checks the current system detected and substitutes it with local
// implementation if needed.
func chooseSystem() {
// implementation if needed. l must not be nil.
func chooseSystem(ctx context.Context, l *slog.Logger) {
sys := service.ChosenSystem()
switch sys.String() {
case "unix-systemv":
@ -29,12 +30,18 @@ func chooseSystem() {
// https://github.com/AdguardTeam/AdGuardHome/issues/4677.
if !aghos.IsOpenWrt() {
service.ChooseSystem(&sysvSystem{System: sys})
l.DebugContext(ctx, "using custom SysV system")
return
}
case "linux-systemd":
service.ChooseSystem(&systemdSystem{System: sys})
default:
// Do nothing.
l.DebugContext(ctx, "using custom systemd system")
return
}
l.DebugContext(ctx, "using default system", "system_description", sys.String())
}
// sysvSystem is a wrapper for a [service.System] that returns the custom

View file

@ -6,6 +6,7 @@ import (
"cmp"
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
@ -34,7 +35,7 @@ const sysVersion = "openbsd-runcom"
// chooseSystem checks the current system detected and substitutes it with local
// implementation if needed.
func chooseSystem() {
func chooseSystem(_ context.Context, _ *slog.Logger) {
service.ChooseSystem(openbsdSystem{})
}

View file

@ -1,7 +1,12 @@
//go:build !openbsd && !linux
//go:build !openbsd && !linux && !darwin
package ossvc
import (
"context"
"log/slog"
)
// chooseSystem checks the current system detected and substitutes it with local
// implementation if needed.
func chooseSystem() {}
func chooseSystem(_ context.Context, _ *slog.Logger) {}