feat: execute durable client commands through supervisor
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// Package windowsservice owns the machine-wide Windows service contract. The
|
||||
// policy and transition model are platform-neutral so they can be exercised
|
||||
// on Linux; service manager calls live in build-tagged adapters.
|
||||
package windowsservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
winlaunch "github.com/rvbox/rvbox/internal/client/supervisor/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "RVBoxClient"
|
||||
DisplayName = "RVBox Client"
|
||||
Description = "RVBox Windows client daemon and command supervisor"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInstallSpec = errors.New("invalid Windows service install specification")
|
||||
ErrUnsupported = errors.New("Windows service management is unavailable on this platform")
|
||||
ErrInvalidTransition = errors.New("invalid Windows service lifecycle transition")
|
||||
)
|
||||
|
||||
type StartupMode string
|
||||
|
||||
const (
|
||||
StartupAutomatic StartupMode = "automatic"
|
||||
StartupManual StartupMode = "manual"
|
||||
)
|
||||
|
||||
// InstallSpec is the complete immutable service image contract. The service
|
||||
// is always one machine-wide LocalSystem process; config selection is explicit
|
||||
// and never delegated to PATH, the current directory, or Task Scheduler.
|
||||
type InstallSpec struct {
|
||||
ExecutablePath string
|
||||
ConfigPath string
|
||||
Startup StartupMode
|
||||
}
|
||||
|
||||
func (spec InstallSpec) Validate() error {
|
||||
if !winlaunch.ValidAbsoluteWindowsPath(spec.ExecutablePath) || !winlaunch.ValidAbsoluteWindowsPath(spec.ConfigPath) {
|
||||
return ErrInvalidInstallSpec
|
||||
}
|
||||
if strings.ContainsRune(spec.ExecutablePath, 0) || strings.ContainsRune(spec.ConfigPath, 0) {
|
||||
return ErrInvalidInstallSpec
|
||||
}
|
||||
if spec.Startup != StartupAutomatic && spec.Startup != StartupManual {
|
||||
return ErrInvalidInstallSpec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (spec InstallSpec) String() string {
|
||||
return fmt.Sprintf("%s config=%s startup=%s", spec.ExecutablePath, spec.ConfigPath, spec.Startup)
|
||||
}
|
||||
|
||||
type State uint8
|
||||
|
||||
const (
|
||||
StateUnknown State = iota
|
||||
StateStopped
|
||||
StateStartPending
|
||||
StateRunning
|
||||
StateStopPending
|
||||
StatePaused
|
||||
)
|
||||
|
||||
type Command uint8
|
||||
|
||||
const (
|
||||
CommandStart Command = iota + 1
|
||||
CommandStop
|
||||
CommandRestart
|
||||
)
|
||||
|
||||
// Transition validates only state changes that the service control adapter is
|
||||
// allowed to request. Pending states are intentionally not collapsed into a
|
||||
// successful result: callers must poll SCM and observe the final state.
|
||||
func Transition(state State, command Command) (State, error) {
|
||||
switch command {
|
||||
case CommandStart:
|
||||
switch state {
|
||||
case StateStopped:
|
||||
return StateStartPending, nil
|
||||
case StateRunning, StateStartPending:
|
||||
return state, nil
|
||||
default:
|
||||
return StateUnknown, ErrInvalidTransition
|
||||
}
|
||||
case CommandStop:
|
||||
switch state {
|
||||
case StateRunning, StatePaused:
|
||||
return StateStopPending, nil
|
||||
case StateStopped, StateStopPending:
|
||||
return state, nil
|
||||
default:
|
||||
return StateUnknown, ErrInvalidTransition
|
||||
}
|
||||
case CommandRestart:
|
||||
switch state {
|
||||
case StateStopped:
|
||||
return StateStartPending, nil
|
||||
case StateRunning, StatePaused:
|
||||
return StateStopPending, nil
|
||||
case StateStartPending, StateStopPending:
|
||||
return state, nil
|
||||
default:
|
||||
return StateUnknown, ErrInvalidTransition
|
||||
}
|
||||
default:
|
||||
return StateUnknown, ErrInvalidTransition
|
||||
}
|
||||
}
|
||||
|
||||
// Install, Uninstall, Start, and Stop are intentionally narrow. Their
|
||||
// platform implementations return ErrUnsupported on non-Windows builds.
|
||||
func Install(spec InstallSpec) error { return installNative(spec) }
|
||||
func Uninstall() error { return uninstallNative() }
|
||||
func Start() error { return startNative() }
|
||||
func Stop(timeoutSeconds uint32) error { return stopNative(timeoutSeconds) }
|
||||
func Run(run func(context.Context) error) error { return runNative(run) }
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package windowsservice
|
||||
|
||||
import "context"
|
||||
|
||||
func installNative(InstallSpec) error { return ErrUnsupported }
|
||||
func uninstallNative() error { return ErrUnsupported }
|
||||
func startNative() error { return ErrUnsupported }
|
||||
func stopNative(uint32) error { return ErrUnsupported }
|
||||
func runNative(func(context.Context) error) error { return ErrUnsupported }
|
||||
@@ -0,0 +1,59 @@
|
||||
package windowsservice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallSpecValidation_HP_WINSVC_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := InstallSpec{ExecutablePath: `C:\Program Files\RVBox\rvbox.exe`, ConfigPath: `C:\ProgramData\RVBox\client.toml`, Startup: StartupAutomatic}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("valid install spec rejected: %v", err)
|
||||
}
|
||||
for _, invalid := range []InstallSpec{
|
||||
{ExecutablePath: `rvbox.exe`, ConfigPath: valid.ConfigPath, Startup: StartupAutomatic},
|
||||
{ExecutablePath: valid.ExecutablePath, ConfigPath: `client.toml`, Startup: StartupAutomatic},
|
||||
{ExecutablePath: valid.ExecutablePath, ConfigPath: valid.ConfigPath, Startup: StartupMode("disabled")},
|
||||
} {
|
||||
if !errors.Is(invalid.Validate(), ErrInvalidInstallSpec) {
|
||||
t.Fatalf("invalid install spec accepted: %#v", invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTransitionsAreIdempotentAndExplicit_HP_WINSVC_02(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
state State
|
||||
command Command
|
||||
want State
|
||||
}{
|
||||
{StateStopped, CommandStart, StateStartPending},
|
||||
{StateStartPending, CommandStart, StateStartPending},
|
||||
{StateRunning, CommandStop, StateStopPending},
|
||||
{StateStopPending, CommandStop, StateStopPending},
|
||||
{StateStopped, CommandRestart, StateStartPending},
|
||||
{StateRunning, CommandRestart, StateStopPending},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := Transition(test.state, test.command)
|
||||
if err != nil || got != test.want {
|
||||
t.Errorf("Transition(%v,%v) = %v,%v; want %v,nil", test.state, test.command, got, err, test.want)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
state State
|
||||
command Command
|
||||
}{{StateUnknown, CommandStart}, {StateStartPending, CommandStop}} {
|
||||
if test.state == StateStopped && test.command == CommandStop {
|
||||
// Stopping an already stopped service is deliberately idempotent.
|
||||
continue
|
||||
}
|
||||
if _, err := Transition(test.state, test.command); test.state == StateStopped && test.command == CommandStop {
|
||||
t.Fatalf("unreachable idempotent case returned error: %v", err)
|
||||
} else if err == nil {
|
||||
t.Errorf("Transition(%v,%v) unexpectedly accepted", test.state, test.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//go:build windows
|
||||
|
||||
package windowsservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/mgr"
|
||||
)
|
||||
|
||||
func connect() (*mgr.Mgr, error) {
|
||||
return mgr.Connect()
|
||||
}
|
||||
|
||||
func installNative(spec InstallSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
manager, err := connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to service control manager: %w", err)
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
startup := uint32(mgr.StartAutomatic)
|
||||
if spec.Startup == StartupManual {
|
||||
startup = mgr.StartManual
|
||||
}
|
||||
configuration := mgr.Config{
|
||||
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
|
||||
StartType: startup,
|
||||
ErrorControl: mgr.ErrorNormal,
|
||||
DisplayName: DisplayName,
|
||||
Description: Description,
|
||||
ServiceStartName: "LocalSystem",
|
||||
}
|
||||
service, openErr := manager.OpenService(Name)
|
||||
if errors.Is(openErr, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
service, err = manager.CreateService(Name, spec.ExecutablePath, configuration, "--service", "--config", spec.ConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s service: %w", Name, err)
|
||||
}
|
||||
} else if openErr != nil {
|
||||
return fmt.Errorf("open %s service: %w", Name, openErr)
|
||||
} else {
|
||||
configuration, err = service.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query %s service configuration: %w", Name, err)
|
||||
}
|
||||
configuration.ServiceType = windows.SERVICE_WIN32_OWN_PROCESS
|
||||
configuration.StartType = startup
|
||||
configuration.ErrorControl = mgr.ErrorNormal
|
||||
configuration.BinaryPathName = serviceImage(spec)
|
||||
configuration.DisplayName = DisplayName
|
||||
configuration.Description = Description
|
||||
configuration.ServiceStartName = "LocalSystem"
|
||||
if err := service.UpdateConfig(configuration); err != nil {
|
||||
return fmt.Errorf("update %s service configuration: %w", Name, err)
|
||||
}
|
||||
}
|
||||
defer service.Close()
|
||||
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
|
||||
return fmt.Errorf("start %s service: %w", Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceImage(spec InstallSpec) string {
|
||||
return syscall.EscapeArg(spec.ExecutablePath) + " --service --config " + syscall.EscapeArg(spec.ConfigPath)
|
||||
}
|
||||
|
||||
func uninstallNative() error {
|
||||
manager, err := connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to service control manager: %w", err)
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(Name)
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s service: %w", Name, err)
|
||||
}
|
||||
defer service.Close()
|
||||
if err := stopService(service, 30*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
|
||||
return fmt.Errorf("delete %s service: %w", Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startNative() error {
|
||||
manager, err := connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(Name)
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return fmt.Errorf("%s service is not installed", Name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer service.Close()
|
||||
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopNative(timeoutSeconds uint32) error {
|
||||
manager, err := connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer manager.Disconnect()
|
||||
service, err := manager.OpenService(Name)
|
||||
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer service.Close()
|
||||
timeout := 30 * time.Second
|
||||
if timeoutSeconds > 0 {
|
||||
timeout = time.Duration(timeoutSeconds) * time.Second
|
||||
}
|
||||
return stopService(service, timeout)
|
||||
}
|
||||
|
||||
func stopService(service *mgr.Service, timeout time.Duration) error {
|
||||
status, err := service.Query()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query %s service: %w", Name, err)
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
return nil
|
||||
}
|
||||
if _, err := service.Control(svc.Stop); err != nil && !errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) {
|
||||
return fmt.Errorf("stop %s service: %w", Name, err)
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
status, err = service.Query()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query %s service while stopping: %w", Name, err)
|
||||
}
|
||||
if status.State == svc.Stopped {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out stopping %s service", Name)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
run func(context.Context) error
|
||||
}
|
||||
|
||||
func (serviceHandler handler) Execute(_ []string, changes <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||
if serviceHandler.run == nil {
|
||||
return false, 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
status <- svc.Status{State: svc.StartPending, WaitHint: 10_000}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- serviceHandler.run(ctx) }()
|
||||
status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||
for {
|
||||
select {
|
||||
case request := <-changes:
|
||||
if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown {
|
||||
status <- svc.Status{State: svc.StopPending, WaitHint: 30_000}
|
||||
cancel()
|
||||
err := <-done
|
||||
if err != nil {
|
||||
return true, 1
|
||||
}
|
||||
status <- svc.Status{State: svc.Stopped}
|
||||
return false, 0
|
||||
}
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return true, 1
|
||||
}
|
||||
status <- svc.Status{State: svc.Stopped}
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runNative(run func(context.Context) error) error {
|
||||
return svc.Run(Name, handler{run: run})
|
||||
}
|
||||
Reference in New Issue
Block a user