feat: add process management commands for daemon control
Add systemd-style subcommands for managing KVS instances: - start <config> - Daemonize and run in background - stop <config> - Gracefully stop daemon - restart <config> - Restart daemon - status [config] - Show status of all or specific instances Key features: - PID files stored in ~/.kvs/pids/ (global across all directories) - Logs stored in ~/.kvs/logs/ - Config names support both 'node1' and 'node1.yaml' formats - Backward compatible: 'kvs config.yaml' still runs in foreground - Proper stale PID detection and cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
87
daemon/daemonize.go
Normal file
87
daemon/daemonize.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// GetLogFilePath returns the log file path for a given config file
|
||||
func GetLogFilePath(configPath string) (string, error) {
|
||||
logDir, err := getLogDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
absConfigPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get absolute config path: %w", err)
|
||||
}
|
||||
|
||||
basename := filepath.Base(configPath)
|
||||
name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
|
||||
return filepath.Join(logDir, name+".log"), nil
|
||||
}
|
||||
|
||||
// Daemonize spawns the process as a daemon and returns
|
||||
func Daemonize(configPath string) error {
|
||||
// Get absolute path to the current executable
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
|
||||
// Get absolute path to config
|
||||
absConfigPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get absolute config path: %w", err)
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
_, running, err := ReadPID(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if instance is running: %w", err)
|
||||
}
|
||||
if running {
|
||||
return fmt.Errorf("instance is already running")
|
||||
}
|
||||
|
||||
// Spawn the process in background with --daemon flag
|
||||
cmd := exec.Command(executable, "--daemon", absConfigPath)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true, // Create new session
|
||||
}
|
||||
|
||||
// Redirect stdout/stderr to log file
|
||||
logDir, err := getLogDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get log directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
|
||||
basename := filepath.Base(configPath)
|
||||
name := filepath.Base(filepath.Dir(absConfigPath)) + "_" + basename
|
||||
logFile := filepath.Join(logDir, name+".log")
|
||||
|
||||
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cmd.Stdout = f
|
||||
cmd.Stderr = f
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Started KVS instance '%s' (PID will be written by daemon)\n", filepath.Base(configPath))
|
||||
fmt.Printf("Logs: %s\n", logFile)
|
||||
|
||||
return nil
|
||||
}
|
171
daemon/pid.go
Normal file
171
daemon/pid.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// getPIDDir returns the absolute path to the PID directory
|
||||
func getPIDDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".kvs", "pids"), nil
|
||||
}
|
||||
|
||||
// getLogDir returns the absolute path to the log directory
|
||||
func getLogDir() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".kvs", "logs"), nil
|
||||
}
|
||||
|
||||
// GetPIDFilePath returns the PID file path for a given config file
|
||||
func GetPIDFilePath(configPath string) string {
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
// Fallback to local directory
|
||||
pidDir = ".kvs/pids"
|
||||
}
|
||||
|
||||
// Extract basename without extension
|
||||
basename := filepath.Base(configPath)
|
||||
name := strings.TrimSuffix(basename, filepath.Ext(basename))
|
||||
|
||||
return filepath.Join(pidDir, name+".pid")
|
||||
}
|
||||
|
||||
// EnsurePIDDir creates the PID directory if it doesn't exist
|
||||
func EnsurePIDDir() error {
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(pidDir, 0755)
|
||||
}
|
||||
|
||||
// WritePID writes the current process PID to a file
|
||||
func WritePID(configPath string) error {
|
||||
if err := EnsurePIDDir(); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
pid := os.Getpid()
|
||||
|
||||
return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", pid)), 0644)
|
||||
}
|
||||
|
||||
// ReadPID reads the PID from a file and checks if the process is running
|
||||
func ReadPID(configPath string) (int, bool, error) {
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("failed to read PID file: %w", err)
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
pid, err := strconv.Atoi(pidStr)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid PID in file: %w", err)
|
||||
}
|
||||
|
||||
// Check if process is actually running
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return pid, false, nil
|
||||
}
|
||||
|
||||
// Send signal 0 to check if process exists
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
return pid, false, nil
|
||||
}
|
||||
|
||||
return pid, true, nil
|
||||
}
|
||||
|
||||
// RemovePID removes the PID file
|
||||
func RemovePID(configPath string) error {
|
||||
pidFile := GetPIDFilePath(configPath)
|
||||
err := os.Remove(pidFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove PID file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRunningInstances returns a list of running KVS instances
|
||||
func ListRunningInstances() ([]InstanceInfo, error) {
|
||||
var instances []InstanceInfo
|
||||
|
||||
pidDir, err := getPIDDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if PID directory exists
|
||||
if _, err := os.Stat(pidDir); os.IsNotExist(err) {
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(pidDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read PID directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pid") {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(entry.Name(), ".pid")
|
||||
configPath := name + ".yaml" // Assume .yaml extension
|
||||
|
||||
pid, running, err := ReadPID(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
instances = append(instances, InstanceInfo{
|
||||
Name: name,
|
||||
PID: pid,
|
||||
Running: running,
|
||||
})
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// InstanceInfo holds information about a KVS instance
|
||||
type InstanceInfo struct {
|
||||
Name string
|
||||
PID int
|
||||
Running bool
|
||||
}
|
||||
|
||||
// StopProcess stops a process by PID
|
||||
func StopProcess(pid int) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find process: %w", err)
|
||||
}
|
||||
|
||||
// Try graceful shutdown first (SIGTERM)
|
||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to send SIGTERM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user