6 Commits

Author SHA1 Message Date
da8db0d3a3 Add unit reload support 2026-03-09 16:43:24 -04:00
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
7253c912ca ci: add test pipeline with codecov, staticcheck, and race detection (#10)
* ci: add test pipeline with codecov, staticcheck, and race detection

- Build/test matrix: Go 1.25 + 1.26
- Race detection enabled for all tests
- Coverage uploaded to Codecov (latest Go only)
- staticcheck lint step
- Go module caching via setup-go

* ci: drop Go 1.25 from matrix (go.mod requires 1.26)
2026-03-05 17:39:38 -05:00
adf3c36632 fix(helpers): return ErrValueNotSet for nonexistent units in GetNumRestarts (#9)
systemd returns NRestarts=0 for units with LoadState=not-found, making
it indistinguishable from a genuinely loaded unit with zero restarts.
GetNumRestarts now checks LoadState when NRestarts is 0 and returns
ErrValueNotSet for units that don't exist, matching GetMemoryUsage
behavior.

Also adds unit tests for filterErr (all stderr error mapping cases)
and HasValidUnitSuffix (all valid unit types + negative cases).

Updates syncthing test expectation from ErrValueNotSet to nil since
loaded-but-inactive units legitimately have NRestarts=0.
2026-03-05 12:26:12 -05:00
22132919e5 feat: add optional variadic args to all commands (#8)
Allow callers to pass additional systemctl flags (e.g. --no-block,
--force) via variadic string args on every exported function.

This is backward-compatible: existing callers without extra args
continue to work unchanged.

Introduces a prepareArgs helper to centralize argument construction,
replacing the duplicated args/UserMode pattern across all functions.

Closes #2
2026-02-26 11:03:53 -05:00
d38136c0dc refactor: clean up unused code, fix typos, improve docs (#7)
* refactor: clean up unused code, fix typos, improve docs

- Remove unused 'killed' const and 'unitTypes' var (staticcheck U1000)
- Replace regexp with strings.TrimSuffix+switch in isFailed for consistency
- Fix typo: 'programatically' -> 'programmatically'
- Fix typo: 'an an int' -> 'as an int' in README and helpers.go
- Add missing godoc comments on exported helper functions
- Bump minimum Go version from 1.18 to 1.21

* refactor: use unused constants instead of removing them

- Export unitTypes as UnitTypes and add HasValidUnitSuffix helper
- Use killed const (exit code 130) in execute() to detect SIGINT
- Update go.mod to go 1.26
2026-02-23 00:01:59 -05:00
14 changed files with 513 additions and 209 deletions

65
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
test:
name: Test (Go ${{ matrix.go-version }})
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.26"]
steps:
- uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Install and start nginx
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq nginx
sudo systemctl start nginx
sudo systemctl enable nginx
- name: Run tests (user)
run: go test -race -coverprofile=coverage-user.out -covermode=atomic ./...
- name: Run tests (root)
run: sudo go test -race -coverprofile=coverage-root.out -covermode=atomic ./...
- name: Upload coverage to Codecov
if: matrix.go-version == '1.26'
uses: codecov/codecov-action@v5
with:
files: coverage-user.out,coverage-root.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
- name: Run staticcheck
uses: dominikh/staticcheck-action@v1
with:
version: latest
install-go: false

View File

@@ -22,6 +22,7 @@ If your system isn't running (or targeting another system running) `systemctl`,
- [x] `systemctl is-enabled` - [x] `systemctl is-enabled`
- [x] `systemctl is-failed` - [x] `systemctl is-failed`
- [x] `systemctl mask` - [x] `systemctl mask`
- [x] `systemctl reload`
- [x] `systemctl restart` - [x] `systemctl restart`
- [x] `systemctl show` - [x] `systemctl show`
- [x] `systemctl start` - [x] `systemctl start`

View File

@@ -13,10 +13,10 @@ import (
func TestErrorFuncs(t *testing.T) { func TestErrorFuncs(t *testing.T) {
errFuncs := []func(ctx context.Context, unit string, opts Options) error{ errFuncs := []func(ctx context.Context, unit string, opts Options) error{
Enable, func(ctx context.Context, unit string, opts Options) error { return Enable(ctx, unit, opts) },
Disable, func(ctx context.Context, unit string, opts Options) error { return Disable(ctx, unit, opts) },
Restart, func(ctx context.Context, unit string, opts Options) error { return Restart(ctx, unit, opts) },
Start, func(ctx context.Context, unit string, opts Options) error { return Start(ctx, unit, opts) },
} }
errCases := []struct { errCases := []struct {
unit string unit string

134
filtererr_test.go Normal file
View File

@@ -0,0 +1,134 @@
package systemctl
import (
"errors"
"testing"
)
func TestFilterErr(t *testing.T) {
tests := []struct {
name string
stderr string
want error
}{
{
name: "empty stderr",
stderr: "",
want: nil,
},
{
name: "unit does not exist",
stderr: "Unit foo.service does not exist, proceeding anyway.",
want: ErrDoesNotExist,
},
{
name: "unit not found",
stderr: "Unit foo.service not found.",
want: ErrDoesNotExist,
},
{
name: "unit not loaded",
stderr: "Unit foo.service not loaded.",
want: ErrUnitNotLoaded,
},
{
name: "no such file or directory",
stderr: "No such file or directory",
want: ErrDoesNotExist,
},
{
name: "interactive authentication required",
stderr: "Interactive authentication required.",
want: ErrInsufficientPermissions,
},
{
name: "access denied",
stderr: "Access denied",
want: ErrInsufficientPermissions,
},
{
name: "dbus session bus address",
stderr: "Failed to connect to bus: $DBUS_SESSION_BUS_ADDRESS not set",
want: ErrBusFailure,
},
{
name: "unit is masked",
stderr: "Unit foo.service is masked.",
want: ErrMasked,
},
{
name: "generic failed",
stderr: "Failed to do something unknown",
want: ErrUnspecified,
},
{
name: "does not exist with auth required prioritizes permission error",
stderr: "Unit nginx.service does not exist, proceeding anyway.\nFailed to mask unit: Interactive authentication required.",
want: ErrInsufficientPermissions,
},
{
name: "does not exist with access denied prioritizes permission error",
stderr: "Unit foo.service does not exist, proceeding anyway.\nAccess denied",
want: ErrInsufficientPermissions,
},
{
name: "does not exist with bus failure prioritizes bus error",
stderr: "Unit foo.service does not exist, proceeding anyway.\n$DBUS_SESSION_BUS_ADDRESS not set",
want: ErrBusFailure,
},
{
name: "unrecognized warning",
stderr: "Warning: something benign happened",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterErr(tt.stderr)
if tt.want == nil {
if got != nil {
t.Errorf("filterErr(%q) = %v, want nil", tt.stderr, got)
}
return
}
if !errors.Is(got, tt.want) {
t.Errorf("filterErr(%q) = %v, want error wrapping %v", tt.stderr, got, tt.want)
}
})
}
}
func TestHasValidUnitSuffix(t *testing.T) {
tests := []struct {
unit string
want bool
}{
{"nginx.service", true},
{"sshd.socket", true},
{"backup.timer", true},
{"dev-sda1.device", true},
{"home.mount", true},
{"dev-sda1.swap", true},
{"user.slice", true},
{"multi-user.target", true},
{"session-1.scope", true},
{"foo.automount", true},
{"backup.path", true},
{"foo.snapshot", true},
{"nginx", false},
{"", false},
{"foo.bar", false},
{"foo.services", false},
{".service", true},
}
for _, tt := range tests {
t.Run(tt.unit, func(t *testing.T) {
got := HasValidUnitSuffix(tt.unit)
if got != tt.want {
t.Errorf("HasValidUnitSuffix(%q) = %v, want %v", tt.unit, got, tt.want)
}
})
}
}

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/taigrr/systemctl module github.com/taigrr/systemctl
go 1.26 go 1.26.1

View File

@@ -32,7 +32,23 @@ func GetNumRestarts(ctx context.Context, unit string, opts Options) (int, error)
if err != nil { if err != nil {
return -1, err return -1, err
} }
return strconv.Atoi(value) if value == "[not set]" {
return -1, ErrValueNotSet
}
restarts, err := strconv.Atoi(value)
if err != nil {
return -1, err
}
// systemd returns NRestarts=0 for both genuinely zero-restart units and
// nonexistent/unloaded units. Disambiguate by checking LoadState: if the
// unit isn't loaded, the value is meaningless.
if restarts == 0 {
loadState, loadErr := Show(ctx, unit, properties.LoadState, opts)
if loadErr == nil && loadState == "not-found" {
return -1, ErrValueNotSet
}
}
return restarts, nil
} }
// Get current memory in bytes (`systemctl show [unit] --property MemoryCurrent`) as an int // Get current memory in bytes (`systemctl show [unit] --property MemoryCurrent`) as an int

View File

@@ -105,8 +105,8 @@ func TestGetNumRestarts(t *testing.T) {
// try nonexistant unit in user mode as user // try nonexistant unit in user mode as user
{"nonexistant", ErrValueNotSet, Options{UserMode: false}, true}, {"nonexistant", ErrValueNotSet, Options{UserMode: false}, true},
// try existing unit in user mode as user // try existing unit in user mode as user (loaded, so NRestarts=0 is valid)
{"syncthing", ErrValueNotSet, Options{UserMode: true}, true}, {"syncthing", nil, Options{UserMode: true}, true},
// try existing unit in system mode as user // try existing unit in system mode as user
{"nginx", nil, Options{UserMode: false}, true}, {"nginx", nil, Options{UserMode: false}, true},

67
reload_linux_test.go Normal file
View File

@@ -0,0 +1,67 @@
//go:build linux
package systemctl
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestReloadBuildsExpectedCommand(t *testing.T) {
tests := []struct {
name string
opts Options
want []string
}{
{
name: "system mode",
opts: Options{},
want: []string{"reload", "--system", "nginx.service"},
},
{
name: "user mode",
opts: Options{UserMode: true},
want: []string{"reload", "--user", "nginx.service"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
logFile := filepath.Join(tempDir, "args.log")
fakeSystemctl := filepath.Join(tempDir, "systemctl")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > '" + logFile + "'\n"
if err := os.WriteFile(fakeSystemctl, []byte(script), 0o755); err != nil {
t.Fatalf("write fake systemctl: %v", err)
}
original := systemctl
systemctl = fakeSystemctl
t.Cleanup(func() {
systemctl = original
})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := Reload(ctx, "nginx.service", tt.opts); err != nil {
t.Fatalf("Reload returned error: %v", err)
}
gotBytes, err := os.ReadFile(logFile)
if err != nil {
t.Fatalf("read captured args: %v", err)
}
got := strings.Fields(strings.TrimSpace(string(gotBytes)))
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Reload args = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -12,8 +12,10 @@ import (
// files, and recreate the entire dependency tree. While the daemon is being // files, and recreate the entire dependency tree. While the daemon is being
// reloaded, all sockets systemd listens on behalf of user configuration will // reloaded, all sockets systemd listens on behalf of user configuration will
// stay accessible. // stay accessible.
func DaemonReload(ctx context.Context, opts Options) error { //
return daemonReload(ctx, opts) // Any additional arguments are passed directly to the systemctl command.
func DaemonReload(ctx context.Context, opts Options, args ...string) error {
return daemonReload(ctx, opts, args...)
} }
// Reenables one or more units. // Reenables one or more units.
@@ -21,8 +23,10 @@ func DaemonReload(ctx context.Context, opts Options) error {
// This removes all symlinks to the unit files backing the specified units from // This removes all symlinks to the unit files backing the specified units from
// the unit configuration directory, then recreates the symlink to the unit again, // the unit configuration directory, then recreates the symlink to the unit again,
// atomically. Can be used to change the symlink target. // atomically. Can be used to change the symlink target.
func Reenable(ctx context.Context, unit string, opts Options) error { //
return reenable(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Reenable(ctx context.Context, unit string, opts Options, args ...string) error {
return reenable(ctx, unit, opts, args...)
} }
// Disables one or more units. // Disables one or more units.
@@ -30,8 +34,10 @@ func Reenable(ctx context.Context, unit string, opts Options) error {
// This removes all symlinks to the unit files backing the specified units from // This removes all symlinks to the unit files backing the specified units from
// the unit configuration directory, and hence undoes any changes made by // the unit configuration directory, and hence undoes any changes made by
// enable or link. // enable or link.
func Disable(ctx context.Context, unit string, opts Options) error { //
return disable(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Disable(ctx context.Context, unit string, opts Options, args ...string) error {
return disable(ctx, unit, opts, args...)
} }
// Enable one or more units or unit instances. // Enable one or more units or unit instances.
@@ -40,17 +46,20 @@ func Disable(ctx context.Context, unit string, opts Options) error {
// the indicated unit files. After the symlinks have been created, the system // the indicated unit files. After the symlinks have been created, the system
// manager configuration is reloaded (in a way equivalent to daemon-reload), // manager configuration is reloaded (in a way equivalent to daemon-reload),
// in order to ensure the changes are taken into account immediately. // in order to ensure the changes are taken into account immediately.
func Enable(ctx context.Context, unit string, opts Options) error { //
return enable(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Enable(ctx context.Context, unit string, opts Options, args ...string) error {
return enable(ctx, unit, opts, args...)
} }
// Check whether any of the specified units are active (i.e. running). // Check whether any of the specified units are active (i.e. running).
// //
// Returns true if the unit is active, false if inactive or failed. // Returns true if the unit is active, false if inactive or failed.
// Also returns false in an error case. // Also returns false in an error case.
func IsActive(ctx context.Context, unit string, opts Options) (bool, error) { //
result, err := isActive(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
return result, err func IsActive(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
return isActive(ctx, unit, opts, args...)
} }
// Checks whether any of the specified unit files are enabled (as with enable). // Checks whether any of the specified unit files are enabled (as with enable).
@@ -62,15 +71,17 @@ func IsActive(ctx context.Context, unit string, opts Options) (bool, error) {
// //
// See https://www.freedesktop.org/software/systemd/man/systemctl.html#is-enabled%20UNIT%E2%80%A6 // See https://www.freedesktop.org/software/systemd/man/systemctl.html#is-enabled%20UNIT%E2%80%A6
// for more information // for more information
func IsEnabled(ctx context.Context, unit string, opts Options) (bool, error) { //
result, err := isEnabled(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
return result, err func IsEnabled(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
return isEnabled(ctx, unit, opts, args...)
} }
// Check whether any of the specified units are in a "failed" state. // Check whether any of the specified units are in a "failed" state.
func IsFailed(ctx context.Context, unit string, opts Options) (bool, error) { //
result, err := isFailed(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
return result, err func IsFailed(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
return isFailed(ctx, unit, opts, args...)
} }
// Mask one or more units, as specified on the command line. This will link // Mask one or more units, as specified on the command line. This will link
@@ -79,26 +90,40 @@ func IsFailed(ctx context.Context, unit string, opts Options) (bool, error) {
// Notably, Mask may return ErrDoesNotExist if a unit doesn't exist, but it will // Notably, Mask may return ErrDoesNotExist if a unit doesn't exist, but it will
// continue masking anyway. Calling Mask on a non-existing masked unit does not // continue masking anyway. Calling Mask on a non-existing masked unit does not
// return an error. Similarly, see Unmask. // return an error. Similarly, see Unmask.
func Mask(ctx context.Context, unit string, opts Options) error { //
return mask(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Mask(ctx context.Context, unit string, opts Options, args ...string) error {
return mask(ctx, unit, opts, args...)
} }
// Stop and then start one or more units specified on the command line. // Stop and then start one or more units specified on the command line.
// If the units are not running yet, they will be started. // If the units are not running yet, they will be started.
func Restart(ctx context.Context, unit string, opts Options) error { //
return restart(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Restart(ctx context.Context, unit string, opts Options, args ...string) error {
return restart(ctx, unit, opts, args...)
}
// Reload one or more units if they support reload.
//
// Any additional arguments are passed directly to the systemctl command.
func Reload(ctx context.Context, unit string, opts Options, args ...string) error {
return reload(ctx, unit, opts, args...)
} }
// Show a selected property of a unit. Accepted properties are predefined in the // Show a selected property of a unit. Accepted properties are predefined in the
// properties subpackage to guarantee properties are valid and assist code-completion. // properties subpackage to guarantee properties are valid and assist code-completion.
func Show(ctx context.Context, unit string, property properties.Property, opts Options) (string, error) { //
str, err := show(ctx, unit, property, opts) // Any additional arguments are passed directly to the systemctl command.
return str, err func Show(ctx context.Context, unit string, property properties.Property, opts Options, args ...string) (string, error) {
return show(ctx, unit, property, opts, args...)
} }
// Start (activate) a given unit // Start (activate) a given unit
func Start(ctx context.Context, unit string, opts Options) error { //
return start(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Start(ctx context.Context, unit string, opts Options, args ...string) error {
return start(ctx, unit, opts, args...)
} }
// Get back the status string which would be returned by running // Get back the status string which would be returned by running
@@ -106,14 +131,17 @@ func Start(ctx context.Context, unit string, opts Options) error {
// //
// Generally, it makes more sense to programmatically retrieve the properties // Generally, it makes more sense to programmatically retrieve the properties
// using Show, but this command is provided for the sake of completeness // using Show, but this command is provided for the sake of completeness
func Status(ctx context.Context, unit string, opts Options) (string, error) { //
stat, err := status(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
return stat, err func Status(ctx context.Context, unit string, opts Options, args ...string) (string, error) {
return status(ctx, unit, opts, args...)
} }
// Stop (deactivate) a given unit // Stop (deactivate) a given unit
func Stop(ctx context.Context, unit string, opts Options) error { //
return stop(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Stop(ctx context.Context, unit string, opts Options, args ...string) error {
return stop(ctx, unit, opts, args...)
} }
// Unmask one or more unit files, as specified on the command line. // Unmask one or more unit files, as specified on the command line.
@@ -123,6 +151,8 @@ func Stop(ctx context.Context, unit string, opts Options) error {
// doesn't exist, but only if it's not already masked. // doesn't exist, but only if it's not already masked.
// If the unit doesn't exist but it's masked anyway, no error will be // If the unit doesn't exist but it's masked anyway, no error will be
// returned. Gross, I know. Take it up with Poettering. // returned. Gross, I know. Take it up with Poettering.
func Unmask(ctx context.Context, unit string, opts Options) error { //
return unmask(ctx, unit, opts) // Any additional arguments are passed directly to the systemctl command.
func Unmask(ctx context.Context, unit string, opts Options, args ...string) error {
return unmask(ctx, unit, opts, args...)
} }

View File

@@ -8,58 +8,62 @@ import (
"github.com/taigrr/systemctl/properties" "github.com/taigrr/systemctl/properties"
) )
func daemonReload(ctx context.Context, opts Options) error { func daemonReload(_ context.Context, _ Options, _ ...string) error {
return nil return nil
} }
func reenable(ctx context.Context, unit string, opts Options) error { func reenable(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func disable(ctx context.Context, unit string, opts Options) error { func disable(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func enable(ctx context.Context, unit string, opts Options) error { func enable(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func isActive(ctx context.Context, unit string, opts Options) (bool, error) { func isActive(_ context.Context, _ string, _ Options, _ ...string) (bool, error) {
return false, nil return false, nil
} }
func isEnabled(ctx context.Context, unit string, opts Options) (bool, error) { func isEnabled(_ context.Context, _ string, _ Options, _ ...string) (bool, error) {
return false, nil return false, nil
} }
func isFailed(ctx context.Context, unit string, opts Options) (bool, error) { func isFailed(_ context.Context, _ string, _ Options, _ ...string) (bool, error) {
return false, nil return false, nil
} }
func mask(ctx context.Context, unit string, opts Options) error { func mask(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func restart(ctx context.Context, unit string, opts Options) error { func restart(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func show(ctx context.Context, unit string, property properties.Property, opts Options) (string, error) { func reload(_ context.Context, _ string, _ Options, _ ...string) error {
return nil
}
func show(_ context.Context, _ string, _ properties.Property, _ Options, _ ...string) (string, error) {
return "", nil return "", nil
} }
func start(ctx context.Context, unit string, opts Options) error { func start(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func status(ctx context.Context, unit string, opts Options) (string, error) { func status(_ context.Context, _ string, _ Options, _ ...string) (string, error) {
return "", nil return "", nil
} }
func stop(ctx context.Context, unit string, opts Options) error { func stop(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }
func unmask(ctx context.Context, unit string, opts Options) error { func unmask(_ context.Context, _ string, _ Options, _ ...string) error {
return nil return nil
} }

View File

@@ -9,74 +9,33 @@ import (
"github.com/taigrr/systemctl/properties" "github.com/taigrr/systemctl/properties"
) )
// Reload systemd manager configuration. func daemonReload(ctx context.Context, opts Options, args ...string) error {
// a := prepareArgs("daemon-reload", opts, args...)
// This will rerun all generators (see systemd. generator(7)), reload all unit _, _, _, err := execute(ctx, a)
// files, and recreate the entire dependency tree. While the daemon is being
// reloaded, all sockets systemd listens on behalf of user configuration will
// stay accessible.
func daemonReload(ctx context.Context, opts Options) error {
args := []string{"daemon-reload", "--system"}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Reenables one or more units. func reenable(ctx context.Context, unit string, opts Options, args ...string) error {
// a := prepareArgs("reenable", opts, append([]string{unit}, args...)...)
// This removes all symlinks to the unit files backing the specified units from _, _, _, err := execute(ctx, a)
// the unit configuration directory, then recreates the symlink to the unit again,
// atomically. Can be used to change the symlink target.
func reenable(ctx context.Context, unit string, opts Options) error {
args := []string{"reenable", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Disables one or more units. func disable(ctx context.Context, unit string, opts Options, args ...string) error {
// a := prepareArgs("disable", opts, append([]string{unit}, args...)...)
// This removes all symlinks to the unit files backing the specified units from _, _, _, err := execute(ctx, a)
// the unit configuration directory, and hence undoes any changes made by
// enable or link.
func disable(ctx context.Context, unit string, opts Options) error {
args := []string{"disable", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Enable one or more units or unit instances. func enable(ctx context.Context, unit string, opts Options, args ...string) error {
// a := prepareArgs("enable", opts, append([]string{unit}, args...)...)
// This will create a set of symlinks, as encoded in the [Install] sections of _, _, _, err := execute(ctx, a)
// the indicated unit files. After the symlinks have been created, the system
// manager configuration is reloaded (in a way equivalent to daemon-reload),
// in order to ensure the changes are taken into account immediately.
func enable(ctx context.Context, unit string, opts Options) error {
args := []string{"enable", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Check whether any of the specified units are active (i.e. running). func isActive(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
// a := prepareArgs("is-active", opts, append([]string{unit}, args...)...)
// Returns true if the unit is active, false if inactive or failed. stdout, _, _, err := execute(ctx, a)
// Also returns false in an error case.
func isActive(ctx context.Context, unit string, opts Options) (bool, error) {
args := []string{"is-active", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
stdout, _, _, err := execute(ctx, args)
stdout = strings.TrimSuffix(stdout, "\n") stdout = strings.TrimSuffix(stdout, "\n")
switch stdout { switch stdout {
case "inactive": case "inactive":
@@ -92,21 +51,9 @@ func isActive(ctx context.Context, unit string, opts Options) (bool, error) {
} }
} }
// Checks whether any of the specified unit files are enabled (as with enable). func isEnabled(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
// a := prepareArgs("is-enabled", opts, append([]string{unit}, args...)...)
// Returns true if the unit is enabled, aliased, static, indirect, generated stdout, _, _, err := execute(ctx, a)
// or transient.
//
// Returns false if disabled. Also returns an error if linked, masked, or bad.
//
// See https://www.freedesktop.org/software/systemd/man/systemctl.html#is-enabled%20UNIT%E2%80%A6
// for more information
func isEnabled(ctx context.Context, unit string, opts Options) (bool, error) {
args := []string{"is-enabled", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
stdout, _, _, err := execute(ctx, args)
stdout = strings.TrimSuffix(stdout, "\n") stdout = strings.TrimSuffix(stdout, "\n")
switch stdout { switch stdout {
case "enabled": case "enabled":
@@ -140,13 +87,9 @@ func isEnabled(ctx context.Context, unit string, opts Options) (bool, error) {
return false, ErrUnspecified return false, ErrUnspecified
} }
// Check whether any of the specified units are in a "failed" state. func isFailed(ctx context.Context, unit string, opts Options, args ...string) (bool, error) {
func isFailed(ctx context.Context, unit string, opts Options) (bool, error) { a := prepareArgs("is-failed", opts, append([]string{unit}, args...)...)
args := []string{"is-failed", "--system", unit} stdout, _, _, err := execute(ctx, a)
if opts.UserMode {
args[1] = "--user"
}
stdout, _, _, err := execute(ctx, args)
stdout = strings.TrimSuffix(stdout, "\n") stdout = strings.TrimSuffix(stdout, "\n")
switch stdout { switch stdout {
case "inactive": case "inactive":
@@ -160,91 +103,53 @@ func isFailed(ctx context.Context, unit string, opts Options) (bool, error) {
} }
} }
// Mask one or more units, as specified on the command line. This will link func mask(ctx context.Context, unit string, opts Options, args ...string) error {
// these unit files to /dev/null, making it impossible to start them. a := prepareArgs("mask", opts, append([]string{unit}, args...)...)
// _, _, _, err := execute(ctx, a)
// Notably, Mask may return ErrDoesNotExist if a unit doesn't exist, but it will
// continue masking anyway. Calling Mask on a non-existing masked unit does not
// return an error. Similarly, see Unmask.
func mask(ctx context.Context, unit string, opts Options) error {
args := []string{"mask", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Stop and then start one or more units specified on the command line. func restart(ctx context.Context, unit string, opts Options, args ...string) error {
// If the units are not running yet, they will be started. a := prepareArgs("restart", opts, append([]string{unit}, args...)...)
func restart(ctx context.Context, unit string, opts Options) error { _, _, _, err := execute(ctx, a)
args := []string{"restart", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Show a selected property of a unit. Accepted properties are predefined in the func reload(ctx context.Context, unit string, opts Options, args ...string) error {
// properties subpackage to guarantee properties are valid and assist code-completion. a := prepareArgs("reload", opts, append([]string{unit}, args...)...)
func show(ctx context.Context, unit string, property properties.Property, opts Options) (string, error) { _, _, _, err := execute(ctx, a)
args := []string{"show", "--system", unit, "--property", string(property)} return err
if opts.UserMode { }
args[1] = "--user"
} func show(ctx context.Context, unit string, property properties.Property, opts Options, args ...string) (string, error) {
stdout, _, _, err := execute(ctx, args) extra := append([]string{unit, "--property", string(property)}, args...)
a := prepareArgs("show", opts, extra...)
stdout, _, _, err := execute(ctx, a)
stdout = strings.TrimPrefix(stdout, string(property)+"=") stdout = strings.TrimPrefix(stdout, string(property)+"=")
stdout = strings.TrimSuffix(stdout, "\n") stdout = strings.TrimSuffix(stdout, "\n")
return stdout, err return stdout, err
} }
// Start (activate) a given unit func start(ctx context.Context, unit string, opts Options, args ...string) error {
func start(ctx context.Context, unit string, opts Options) error { a := prepareArgs("start", opts, append([]string{unit}, args...)...)
args := []string{"start", "--system", unit} _, _, _, err := execute(ctx, a)
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Get back the status string which would be returned by running func status(ctx context.Context, unit string, opts Options, args ...string) (string, error) {
// `systemctl status [unit]`. a := prepareArgs("status", opts, append([]string{unit}, args...)...)
// stdout, _, _, err := execute(ctx, a)
// Generally, it makes more sense to programmatically retrieve the properties
// using Show, but this command is provided for the sake of completeness
func status(ctx context.Context, unit string, opts Options) (string, error) {
args := []string{"status", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
stdout, _, _, err := execute(ctx, args)
return stdout, err return stdout, err
} }
// Stop (deactivate) a given unit func stop(ctx context.Context, unit string, opts Options, args ...string) error {
func stop(ctx context.Context, unit string, opts Options) error { a := prepareArgs("stop", opts, append([]string{unit}, args...)...)
args := []string{"stop", "--system", unit} _, _, _, err := execute(ctx, a)
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }
// Unmask one or more unit files, as specified on the command line. func unmask(ctx context.Context, unit string, opts Options, args ...string) error {
// This will undo the effect of Mask. a := prepareArgs("unmask", opts, append([]string{unit}, args...)...)
// _, _, _, err := execute(ctx, a)
// In line with systemd, Unmask will return ErrDoesNotExist if the unit
// doesn't exist, but only if it's not already masked.
// If the unit doesn't exist but it's masked anyway, no error will be
// returned. Gross, I know. Take it up with Poettering.
func unmask(ctx context.Context, unit string, opts Options) error {
args := []string{"unmask", "--system", unit}
if opts.UserMode {
args[1] = "--user"
}
_, _, _, err := execute(ctx, args)
return err return err
} }

View File

@@ -290,7 +290,7 @@ func TestMask(t *testing.T) {
// try existing unit in user mode as user // try existing unit in user mode as user
{"syncthing", nil, Options{UserMode: true}, true}, {"syncthing", nil, Options{UserMode: true}, true},
// try nonexisting unit in system mode as user // try nonexisting unit in system mode as user
{"nonexistant", ErrDoesNotExist, Options{UserMode: false}, true}, {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true},
// try existing unit in system mode as user // try existing unit in system mode as user
{"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true},
@@ -521,7 +521,7 @@ func TestUnmask(t *testing.T) {
// try existing unit in user mode as user // try existing unit in user mode as user
{"syncthing", nil, Options{UserMode: true}, true}, {"syncthing", nil, Options{UserMode: true}, true},
// try nonexisting unit in system mode as user // try nonexisting unit in system mode as user
{"nonexistant", ErrDoesNotExist, Options{UserMode: false}, true}, {"nonexistant", ErrInsufficientPermissions, Options{UserMode: false}, true},
// try existing unit in system mode as user // try existing unit in system mode as user
{"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true}, {"nginx", ErrInsufficientPermissions, Options{UserMode: false}, true},

36
util.go
View File

@@ -55,16 +55,28 @@ func execute(ctx context.Context, args []string) (string, string, int, error) {
return output, warnings, code, err 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 { 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 { switch {
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, `Interactive authentication required`): case strings.Contains(stderr, `Interactive authentication required`):
return errors.Join(ErrInsufficientPermissions, fmt.Errorf("stderr: %s", stderr)) return errors.Join(ErrInsufficientPermissions, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `Access denied`): case strings.Contains(stderr, `Access denied`):
@@ -73,6 +85,14 @@ func filterErr(stderr string) error {
return errors.Join(ErrBusFailure, fmt.Errorf("stderr: %s", stderr)) return errors.Join(ErrBusFailure, fmt.Errorf("stderr: %s", stderr))
case strings.Contains(stderr, `is masked`): case strings.Contains(stderr, `is masked`):
return errors.Join(ErrMasked, fmt.Errorf("stderr: %s", stderr)) 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`): case strings.Contains(stderr, `Failed`):
return errors.Join(ErrUnspecified, fmt.Errorf("stderr: %s", stderr)) return errors.Join(ErrUnspecified, fmt.Errorf("stderr: %s", stderr))
default: default:

62
util_test.go Normal file
View File

@@ -0,0 +1,62 @@
package systemctl
import (
"reflect"
"testing"
)
func TestPrepareArgs(t *testing.T) {
tests := []struct {
name string
base string
opts Options
extra []string
expected []string
}{
{
name: "system mode no extra",
base: "start",
opts: Options{},
extra: nil,
expected: []string{"start", "--system"},
},
{
name: "user mode no extra",
base: "start",
opts: Options{UserMode: true},
extra: nil,
expected: []string{"start", "--user"},
},
{
name: "system mode with unit",
base: "start",
opts: Options{},
extra: []string{"nginx.service"},
expected: []string{"start", "--system", "nginx.service"},
},
{
name: "user mode with unit and extra args",
base: "restart",
opts: Options{UserMode: true},
extra: []string{"foo.service", "--no-block"},
expected: []string{"restart", "--user", "foo.service", "--no-block"},
},
{
name: "daemon-reload no extra",
base: "daemon-reload",
opts: Options{},
extra: nil,
expected: []string{"daemon-reload", "--system"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := prepareArgs(tt.base, tt.opts, tt.extra...)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("prepareArgs(%q, %+v, %v) = %v, want %v",
tt.base, tt.opts, tt.extra, got, tt.expected)
}
})
}
}