Files
systemctl/util.go
Tai Groot 038fbe1a17 fix(errors): prioritize permission errors over 'does not exist' warnings (#11)
filterErr checked 'does not exist' before 'Interactive authentication
required', so when systemd printed both (common for mask/unmask on
non-installed units as a non-root user), the wrong error was returned.

Reorder checks so permission, bus, and masked errors take priority over
existence warnings. Add tests covering mixed-stderr scenarios.

Also:
- CI: install and start nginx so user + root tests pass
- CI: run tests as both user and root for full coverage
- Bump Go 1.26 → 1.26.1
2026-03-06 11:38:03 -05:00

102 lines
3.0 KiB
Go

package systemctl
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
var systemctl string
// killed is the exit code returned when a process is terminated by SIGINT.
const killed = 130
func init() {
path, _ := exec.LookPath("systemctl")
systemctl = path
}
func execute(ctx context.Context, args []string) (string, string, int, error) {
var (
err error
stderr bytes.Buffer
stdout bytes.Buffer
code int
output string
warnings string
)
if systemctl == "" {
return "", "", 1, ErrNotInstalled
}
cmd := exec.CommandContext(ctx, systemctl, args...)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
output = stdout.String()
warnings = stderr.String()
code = cmd.ProcessState.ExitCode()
if code == killed {
return output, warnings, code, ErrExecTimeout
}
customErr := filterErr(warnings)
if customErr != nil {
err = customErr
}
if code != 0 && err == nil {
err = fmt.Errorf("received error code %d for stderr `%s`: %w", code, warnings, ErrUnspecified)
}
return output, warnings, code, err
}
// prepareArgs builds the systemctl command arguments from a base command,
// options, and any additional arguments the caller wants to pass through.
func prepareArgs(base string, opts Options, extra ...string) []string {
args := make([]string, 0, 2+len(extra))
args = append(args, base)
if opts.UserMode {
args = append(args, "--user")
} else {
args = append(args, "--system")
}
args = append(args, extra...)
return args
}
func filterErr(stderr string) error {
// Order matters: check higher-priority errors first.
// For example, `systemctl mask nginx` as a non-root user on a system
// without nginx prints both "does not exist, proceeding anyway" (a
// warning) and "Interactive authentication required" (the real error).
// Permission and bus errors must be checked before "does not exist" so
// the actual failure reason is returned.
switch {
case strings.Contains(stderr, `Interactive authentication required`):
return errors.Join(ErrInsufficientPermissions, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `Access denied`):
return errors.Join(ErrInsufficientPermissions, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `DBUS_SESSION_BUS_ADDRESS`):
return errors.Join(ErrBusFailure, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `is masked`):
return errors.Join(ErrMasked, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `does not exist`):
return errors.Join(ErrDoesNotExist, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `not found.`):
return errors.Join(ErrDoesNotExist, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `not loaded.`):
return errors.Join(ErrUnitNotLoaded, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `No such file or directory`):
return errors.Join(ErrDoesNotExist, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `Failed`):
return errors.Join(ErrUnspecified, fmt.Errorf("stderr: %s", stderr))
default:
return nil
}
}