Files
yms-daemon/internal/systemd/systemctl.go
T

135 lines
3.8 KiB
Go

package systemd
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
const (
loadStateProperty = "LoadState"
activeStateProperty = "ActiveState"
subStateProperty = "SubState"
loadedState = "loaded"
notFoundState = "not-found"
)
// Systemctl invokes one exact systemctl executable directly, never through a shell.
type Systemctl struct {
executable string
}
// NewSystemctl requires the absolute executable path supplied by local daemon configuration.
func NewSystemctl(executable string) (*Systemctl, error) {
if !filepath.IsAbs(executable) {
return nil, errors.New("systemctl executable path must be absolute")
}
return &Systemctl{executable: executable}, nil
}
func (s *Systemctl) Inspect(ctx context.Context, unitName string) (Unit, error) {
if err := validateUnitName(unitName); err != nil {
return Unit{}, err
}
command := exec.CommandContext(ctx, s.executable,
"show",
"--no-pager",
"--property="+loadStateProperty,
"--property="+activeStateProperty,
"--property="+subStateProperty,
"--",
unitName,
)
output, err := command.CombinedOutput()
if err != nil {
return Unit{}, commandError("inspect systemd unit "+unitName, output, err)
}
unit, err := parseUnitProperties(unitName, output)
if err != nil {
return Unit{}, err
}
if unit.LoadState == notFoundState {
return Unit{}, fmt.Errorf("inspect systemd unit %s: %w", unitName, ErrUnitNotFound)
}
if unit.LoadState != loadedState {
return Unit{}, fmt.Errorf("systemd unit %s has unsupported load state %q", unitName, unit.LoadState)
}
return unit, nil
}
func (s *Systemctl) Start(ctx context.Context, unitName string) error {
return s.changeState(ctx, "start", unitName)
}
func (s *Systemctl) Stop(ctx context.Context, unitName string) error {
return s.changeState(ctx, "stop", unitName)
}
func (s *Systemctl) changeState(ctx context.Context, action, unitName string) error {
if err := validateUnitName(unitName); err != nil {
return err
}
output, err := exec.CommandContext(ctx, s.executable, action, "--", unitName).CombinedOutput()
if err != nil {
return commandError(action+" systemd unit "+unitName, output, err)
}
return nil
}
func parseUnitProperties(unitName string, output []byte) (Unit, error) {
values := make(map[string]string, 3)
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
key, value, found := strings.Cut(line, "=")
if !found {
return Unit{}, fmt.Errorf("decode systemd unit %s property line %q", unitName, line)
}
switch key {
case loadStateProperty, activeStateProperty, subStateProperty:
if _, duplicate := values[key]; duplicate {
return Unit{}, fmt.Errorf("decode systemd unit %s duplicate property %s", unitName, key)
}
values[key] = value
default:
return Unit{}, fmt.Errorf("decode systemd unit %s unexpected property %q", unitName, key)
}
}
if err := scanner.Err(); err != nil {
return Unit{}, fmt.Errorf("decode systemd unit %s properties: %w", unitName, err)
}
for _, key := range []string{loadStateProperty, activeStateProperty, subStateProperty} {
if _, found := values[key]; !found {
return Unit{}, fmt.Errorf("decode systemd unit %s missing property %s", unitName, key)
}
}
return Unit{
Name: unitName,
LoadState: values[loadStateProperty],
ActiveState: values[activeStateProperty],
SubState: values[subStateProperty],
}, nil
}
func validateUnitName(unitName string) error {
if unitName == "" || strings.TrimSpace(unitName) != unitName {
return errors.New("exact systemd unit name is required")
}
return nil
}
func commandError(action string, output []byte, err error) error {
detail := strings.TrimSpace(string(output))
if detail == "" {
return fmt.Errorf("%s: %w", action, err)
}
return fmt.Errorf("%s: %w: %s", action, err, detail)
}
var _ Manager = (*Systemctl)(nil)