safesight-edge/agent/internal/procctl/procctl.go
2026-01-17 16:57:51 +08:00

421 lines
11 KiB
Go

package procctl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"rk3588sys/agent/internal/config"
"rk3588sys/agent/internal/files"
)
var ErrNotSupported = errors.New("not supported")
var ErrConflict = errors.New("conflict")
var ErrInvalidConfig = errors.New("invalid config")
var ErrConfigNotFound = errors.New("config not found")
type Status struct {
Running bool `json:"running"`
Pid int `json:"pid"`
ConfigPath string `json:"config_path"`
StartedAtMS int64 `json:"started_at_ms"`
}
type BinaryUpdateResult struct {
Path string `json:"path"`
Sha256 string `json:"sha256"`
Size int64 `json:"size"`
MtimeMS int64 `json:"mtime_ms"`
BackupPath string `json:"backup_path"`
}
type pidFile struct {
Pid int `json:"pid"`
ConfigPath string `json:"config_path"`
StartedAtMS int64 `json:"started_at_ms"`
}
type Controller struct {
mu sync.Mutex
proc config.MediaServerProcessConfig
defCfg string
}
func New(agentCfg config.AgentConfig, baseDir string) *Controller {
p := agentCfg.MediaServerProcess
if baseDir != "" {
if p.ExecPath != "" && !filepath.IsAbs(p.ExecPath) {
p.ExecPath = filepath.Join(baseDir, p.ExecPath)
}
if p.WorkDir != "" && !filepath.IsAbs(p.WorkDir) {
p.WorkDir = filepath.Join(baseDir, p.WorkDir)
}
if p.ConfigsDir != "" && !filepath.IsAbs(p.ConfigsDir) {
p.ConfigsDir = filepath.Join(baseDir, p.ConfigsDir)
}
if p.PidFile != "" && !filepath.IsAbs(p.PidFile) {
p.PidFile = filepath.Join(baseDir, p.PidFile)
}
}
return &Controller{proc: p, defCfg: agentCfg.ConfigPath}
}
func (c *Controller) Enabled() bool { return c != nil && c.proc.Enable }
func (c *Controller) Status() (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
pf, err := c.readPidFile()
if err != nil {
return Status{}, err
}
if pf == nil {
return Status{Running: false}, nil
}
alive, aerr := isAlive(pf.Pid)
if errors.Is(aerr, ErrNotSupported) {
return Status{}, ErrNotSupported
}
if !alive {
_ = os.Remove(c.proc.PidFile)
return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil
}
return Status{Running: true, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil
}
func (c *Controller) Version() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if strings.TrimSpace(c.proc.ExecPath) == "" {
return "", errors.New("exec_path is empty")
}
cmd := exec.Command(c.proc.ExecPath, "--version")
if strings.TrimSpace(c.proc.WorkDir) != "" {
cmd.Dir = c.proc.WorkDir
}
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("get version failed: %w", err)
}
ver := strings.TrimSpace(string(out))
if ver == "" {
return "", errors.New("version output empty")
}
return ver, nil
}
func (c *Controller) Start(configName string) (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
resolved, err := c.resolveConfigPath(configName)
if err != nil {
return Status{}, err
}
pf, _ := c.readPidFile()
if pf != nil {
alive, _ := isAlive(pf.Pid)
if alive {
if filepath.Clean(pf.ConfigPath) == filepath.Clean(resolved) {
return Status{Running: true, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil
}
return Status{}, fmt.Errorf("%w: already running with config %s", ErrConflict, pf.ConfigPath)
}
_ = os.Remove(c.proc.PidFile)
}
pid, err := startProcess(c.proc.ExecPath, c.proc.WorkDir, resolved)
if err != nil {
return Status{}, err
}
pf2 := pidFile{Pid: pid, ConfigPath: resolved, StartedAtMS: time.Now().UnixMilli()}
b, _ := json.Marshal(pf2)
b = append(b, '\n')
if err := files.WriteFileAtomic(c.proc.PidFile, b, 0o644); err != nil {
_ = stopProcess(pid, 1*time.Second)
return Status{}, fmt.Errorf("write pid file: %w", err)
}
return Status{Running: true, Pid: pid, ConfigPath: resolved, StartedAtMS: pf2.StartedAtMS}, nil
}
func (c *Controller) Stop() (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
pf, err := c.readPidFile()
if err != nil {
return Status{}, err
}
if pf == nil {
return Status{Running: false}, nil
}
alive, _ := isAlive(pf.Pid)
if !alive {
_ = os.Remove(c.proc.PidFile)
return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil
}
if err := stopProcess(pf.Pid, time.Duration(c.proc.GracefulTimeoutMS)*time.Millisecond); err != nil {
return Status{}, err
}
_ = os.Remove(c.proc.PidFile)
return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil
}
func (c *Controller) Restart(configName string) (Status, error) {
_, _ = c.Stop()
return c.Start(configName)
}
func (c *Controller) resolveConfigPath(name string) (string, error) {
n := strings.TrimSpace(name)
if n == "" {
if strings.TrimSpace(c.defCfg) == "" {
return "", fmt.Errorf("%w: default config_path is empty", ErrInvalidConfig)
}
return c.defCfg, nil
}
if strings.Contains(n, "..") || strings.ContainsAny(n, "/\\") {
return "", fmt.Errorf("%w: contains invalid characters", ErrInvalidConfig)
}
if !strings.HasSuffix(n, ".json") {
n += ".json"
}
base := strings.TrimSpace(c.proc.ConfigsDir)
if base == "" {
return "", fmt.Errorf("%w: configs_dir is empty", ErrInvalidConfig)
}
p := filepath.Join(base, n)
st, err := os.Stat(p)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("%w: %s", ErrConfigNotFound, p)
}
return "", fmt.Errorf("stat config: %w", err)
}
if st.IsDir() {
return "", fmt.Errorf("%w: is a directory", ErrInvalidConfig)
}
return p, nil
}
func (c *Controller) UpdateBinary(r io.Reader, contentLength int64, expectedSha256 string) (BinaryUpdateResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if contentLength <= 0 {
return BinaryUpdateResult{}, errors.New("missing Content-Length")
}
if strings.TrimSpace(c.proc.ExecPath) == "" {
return BinaryUpdateResult{}, errors.New("exec_path is empty")
}
pf, _ := c.readPidFile()
if pf != nil {
alive, _ := isAlive(pf.Pid)
if alive {
return BinaryUpdateResult{}, fmt.Errorf("%w: media-server is running", ErrConflict)
}
_ = os.Remove(c.proc.PidFile)
}
dir := filepath.Dir(c.proc.ExecPath)
if err := files.EnsureDir(dir, 0o755); err != nil {
return BinaryUpdateResult{}, err
}
f, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return BinaryUpdateResult{}, fmt.Errorf("create temp: %w", err)
}
tmp := f.Name()
ok := false
defer func() {
_ = f.Close()
if !ok {
_ = os.Remove(tmp)
}
}()
h := sha256.New()
mw := io.MultiWriter(f, h)
if _, err := io.CopyN(mw, r, contentLength); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("read body: %w", err)
}
if err := f.Chmod(0o755); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("chmod temp: %w", err)
}
if err := f.Sync(); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("fsync temp: %w", err)
}
if err := f.Close(); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("close temp: %w", err)
}
sha := hex.EncodeToString(h.Sum(nil))
if expectedSha256 != "" && !strings.EqualFold(expectedSha256, sha) {
return BinaryUpdateResult{}, errors.New("sha256 mismatch")
}
backup := ""
if st, err := os.Stat(c.proc.ExecPath); err == nil && !st.IsDir() {
backup = fmt.Sprintf("%s.bak.%s", c.proc.ExecPath, time.Now().Format("20060102-150405"))
if err := os.Rename(c.proc.ExecPath, backup); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("backup old binary: %w", err)
}
}
if err := files.ReplaceFile(tmp, c.proc.ExecPath); err != nil {
if backup != "" {
_ = os.Rename(backup, c.proc.ExecPath)
}
return BinaryUpdateResult{}, err
}
st, err := os.Stat(c.proc.ExecPath)
if err != nil {
return BinaryUpdateResult{}, fmt.Errorf("stat binary: %w", err)
}
ok = true
return BinaryUpdateResult{
Path: filepath.ToSlash(c.proc.ExecPath),
Sha256: sha,
Size: st.Size(),
MtimeMS: st.ModTime().UnixMilli(),
BackupPath: filepath.ToSlash(backup),
}, nil
}
func (c *Controller) RollbackBinary(backupPath string) (BinaryUpdateResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if strings.TrimSpace(backupPath) == "" {
return BinaryUpdateResult{}, errors.New("backup_path is empty")
}
if strings.TrimSpace(c.proc.ExecPath) == "" {
return BinaryUpdateResult{}, errors.New("exec_path is empty")
}
pf, _ := c.readPidFile()
if pf != nil {
alive, _ := isAlive(pf.Pid)
if alive {
return BinaryUpdateResult{}, fmt.Errorf("%w: media-server is running", ErrConflict)
}
_ = os.Remove(c.proc.PidFile)
}
if st, err := os.Stat(backupPath); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("stat backup: %w", err)
} else if st.IsDir() {
return BinaryUpdateResult{}, errors.New("backup_path is a directory")
}
oldBackup := ""
if st, err := os.Stat(c.proc.ExecPath); err == nil && !st.IsDir() {
oldBackup = fmt.Sprintf("%s.bak.rollback.%s", c.proc.ExecPath, time.Now().Format("20060102-150405"))
if err := os.Rename(c.proc.ExecPath, oldBackup); err != nil {
return BinaryUpdateResult{}, fmt.Errorf("backup current binary: %w", err)
}
}
if err := files.ReplaceFile(backupPath, c.proc.ExecPath); err != nil {
if oldBackup != "" {
_ = os.Rename(oldBackup, c.proc.ExecPath)
}
return BinaryUpdateResult{}, err
}
st, err := os.Stat(c.proc.ExecPath)
if err != nil {
return BinaryUpdateResult{}, fmt.Errorf("stat binary: %w", err)
}
sha, err := sha256File(c.proc.ExecPath)
if err != nil {
return BinaryUpdateResult{}, err
}
return BinaryUpdateResult{
Path: filepath.ToSlash(c.proc.ExecPath),
Sha256: sha,
Size: st.Size(),
MtimeMS: st.ModTime().UnixMilli(),
BackupPath: filepath.ToSlash(oldBackup),
}, nil
}
func (c *Controller) BinaryInfo() (BinaryUpdateResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if strings.TrimSpace(c.proc.ExecPath) == "" {
return BinaryUpdateResult{}, errors.New("exec_path is empty")
}
st, err := os.Stat(c.proc.ExecPath)
if err != nil {
return BinaryUpdateResult{}, err
}
if st.IsDir() {
return BinaryUpdateResult{}, errors.New("exec_path is a directory")
}
sha, err := sha256File(c.proc.ExecPath)
if err != nil {
return BinaryUpdateResult{}, err
}
return BinaryUpdateResult{
Path: filepath.ToSlash(c.proc.ExecPath),
Sha256: sha,
Size: st.Size(),
MtimeMS: st.ModTime().UnixMilli(),
}, nil
}
func (c *Controller) readPidFile() (*pidFile, error) {
b, err := os.ReadFile(c.proc.PidFile)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read pid file: %w", err)
}
var pf pidFile
if err := json.Unmarshal(b, &pf); err != nil {
return nil, fmt.Errorf("parse pid file: %w", err)
}
if pf.Pid <= 0 {
return nil, fmt.Errorf("pid file invalid pid: %d", pf.Pid)
}
return &pf, nil
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
H := sha256.New()
if _, err := io.Copy(H, f); err != nil {
return "", err
}
return hex.EncodeToString(H.Sum(nil)), nil
}