feat: backend native executor implement
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
// Package systemd defines the exact systemd boundary used by native executors.
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrUnitNotFound = errors.New("systemd unit not found")
|
||||
|
||||
// Unit is the systemd state required for idempotent start and stop inspection.
|
||||
type Unit struct {
|
||||
Name string
|
||||
LoadState string
|
||||
ActiveState string
|
||||
SubState string
|
||||
}
|
||||
|
||||
// Manager performs direct systemd operations without invoking a shell.
|
||||
type Manager interface {
|
||||
Inspect(context.Context, string) (Unit, error)
|
||||
Start(context.Context, string) error
|
||||
Stop(context.Context, string) error
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
@@ -0,0 +1,50 @@
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseUnitPropertiesRequiresExactProperties(t *testing.T) {
|
||||
t.Parallel()
|
||||
unit, err := parseUnitProperties("yms-green.service", []byte("LoadState=loaded\nActiveState=inactive\nSubState=dead\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse systemd properties: %v", err)
|
||||
}
|
||||
if unit.Name != "yms-green.service" || unit.LoadState != "loaded" || unit.ActiveState != "inactive" || unit.SubState != "dead" {
|
||||
t.Fatalf("unexpected unit: %+v", unit)
|
||||
}
|
||||
|
||||
invalid := [][]byte{
|
||||
[]byte("LoadState=loaded\nActiveState=inactive\n"),
|
||||
[]byte("LoadState=loaded\nActiveState=inactive\nSubState=dead\nDescription=YMS\n"),
|
||||
[]byte("LoadState=loaded\nLoadState=loaded\nActiveState=inactive\nSubState=dead\n"),
|
||||
}
|
||||
for _, output := range invalid {
|
||||
if _, err := parseUnitProperties("yms-green.service", output); err == nil {
|
||||
t.Fatalf("expected exact property validation failure for %q", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSystemctlRequiresAbsoluteExecutable(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NewSystemctl("systemctl"); err == nil {
|
||||
t.Fatal("expected relative executable path rejection")
|
||||
}
|
||||
manager, err := NewSystemctl("/usr/bin/systemctl")
|
||||
if err != nil || manager.executable != "/usr/bin/systemctl" {
|
||||
t.Fatalf("unexpected manager: manager=%+v err=%v", manager, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUnitNamePreservesOpaqueValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := validateUnitName("backend-green.service"); err != nil {
|
||||
t.Fatalf("validate exact unit name: %v", err)
|
||||
}
|
||||
for _, name := range []string{"", " backend-green.service", "backend-green.service "} {
|
||||
if err := validateUnitName(name); err == nil {
|
||||
t.Fatalf("expected unit name rejection: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user