diff --git a/.gitignore b/.gitignore index 6174009..c935c34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .claude/ +.kvs/ data/ data*/ *.yaml diff --git a/daemon/daemonize.go b/daemon/daemonize.go new file mode 100644 index 0000000..b8494bf --- /dev/null +++ b/daemon/daemonize.go @@ -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 +} diff --git a/daemon/pid.go b/daemon/pid.go new file mode 100644 index 0000000..8e5240d --- /dev/null +++ b/daemon/pid.go @@ -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 +} diff --git a/main.go b/main.go index 4d858ef..66c6665 100644 --- a/main.go +++ b/main.go @@ -6,25 +6,90 @@ import ( "os" "os/signal" "syscall" + "time" + + "path/filepath" + "strings" "kvs/config" + "kvs/daemon" "kvs/server" ) func main() { - configPath := "./config.yaml" - - // Simple CLI argument parsing - if len(os.Args) > 1 { - configPath = os.Args[1] + if len(os.Args) < 2 { + // No arguments - run in foreground with default config + runServer("./config.yaml", false) + return } + // Check if this is a daemon spawn + if os.Args[1] == "--daemon" { + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Error: --daemon flag requires config path\n") + os.Exit(1) + } + runServer(os.Args[2], true) + return + } + + // Parse subcommand + command := os.Args[1] + + switch command { + case "start": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Usage: kvs start \n") + os.Exit(1) + } + cmdStart(normalizeConfigPath(os.Args[2])) + + case "stop": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Usage: kvs stop \n") + os.Exit(1) + } + cmdStop(normalizeConfigPath(os.Args[2])) + + case "restart": + if len(os.Args) < 3 { + fmt.Fprintf(os.Stderr, "Usage: kvs restart \n") + os.Exit(1) + } + cmdRestart(normalizeConfigPath(os.Args[2])) + + case "status": + if len(os.Args) > 2 { + cmdStatusSingle(normalizeConfigPath(os.Args[2])) + } else { + cmdStatusAll() + } + + case "help", "--help", "-h": + printHelp() + + default: + // Backward compatibility: assume it's a config file path + runServer(command, false) + } +} + +func runServer(configPath string, isDaemon bool) { cfg, err := config.Load(configPath) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err) os.Exit(1) } + // Write PID file if running as daemon + if isDaemon { + if err := daemon.WritePID(configPath); err != nil { + fmt.Fprintf(os.Stderr, "Failed to write PID file: %v\n", err) + os.Exit(1) + } + defer daemon.RemovePID(configPath) + } + kvServer, err := server.NewServer(cfg) if err != nil { fmt.Fprintf(os.Stderr, "Failed to create server: %v\n", err) @@ -45,3 +110,135 @@ func main() { os.Exit(1) } } + +func cmdStart(configPath string) { + if err := daemon.Daemonize(configPath); err != nil { + fmt.Fprintf(os.Stderr, "Failed to start: %v\n", err) + os.Exit(1) + } +} + +func cmdStop(configPath string) { + pid, running, err := daemon.ReadPID(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err) + os.Exit(1) + } + + if !running { + fmt.Printf("Instance '%s' is not running\n", configPath) + // Clean up stale PID file + daemon.RemovePID(configPath) + return + } + + fmt.Printf("Stopping instance '%s' (PID %d)...\n", configPath, pid) + if err := daemon.StopProcess(pid); err != nil { + fmt.Fprintf(os.Stderr, "Failed to stop process: %v\n", err) + os.Exit(1) + } + + // Wait a bit and verify it stopped + time.Sleep(1 * time.Second) + _, stillRunning, _ := daemon.ReadPID(configPath) + if stillRunning { + fmt.Printf("Warning: Process may still be running\n") + } else { + daemon.RemovePID(configPath) + fmt.Printf("Stopped successfully\n") + } +} + +func cmdRestart(configPath string) { + // Check if running + _, running, err := daemon.ReadPID(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to check status: %v\n", err) + os.Exit(1) + } + + if running { + cmdStop(configPath) + // Wait a bit for clean shutdown + time.Sleep(2 * time.Second) + } + + cmdStart(configPath) +} + +func cmdStatusSingle(configPath string) { + pid, running, err := daemon.ReadPID(configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read PID: %v\n", err) + os.Exit(1) + } + + if running { + fmt.Printf("Instance '%s': RUNNING (PID %d)\n", configPath, pid) + } else if pid > 0 { + fmt.Printf("Instance '%s': STOPPED (stale PID %d)\n", configPath, pid) + } else { + fmt.Printf("Instance '%s': STOPPED\n", configPath) + } +} + +func cmdStatusAll() { + instances, err := daemon.ListRunningInstances() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to list instances: %v\n", err) + os.Exit(1) + } + + if len(instances) == 0 { + fmt.Println("No KVS instances found") + return + } + + fmt.Println("KVS Instances:") + for _, inst := range instances { + status := "STOPPED" + if inst.Running { + status = "RUNNING" + } + fmt.Printf(" %-20s %s (PID %d)\n", inst.Name, status, inst.PID) + } +} + +// normalizeConfigPath ensures config path has .yaml extension if not specified +func normalizeConfigPath(path string) string { + // If path doesn't have an extension, add .yaml + if filepath.Ext(path) == "" { + return path + ".yaml" + } + return path +} + +// getConfigIdentifier returns the identifier for a config (basename without extension) +// This is used for PID files and status display +func getConfigIdentifier(path string) string { + basename := filepath.Base(path) + return strings.TrimSuffix(basename, filepath.Ext(basename)) +} + +func printHelp() { + help := `KVS - Distributed Key-Value Store + +Usage: + kvs [config.yaml] Run in foreground (default: ./config.yaml) + kvs start Start as daemon (.yaml extension optional) + kvs stop Stop daemon (.yaml extension optional) + kvs restart Restart daemon (.yaml extension optional) + kvs status [config] Show status (all instances if no config given) + kvs help Show this help + +Examples: + kvs # Run with ./config.yaml in foreground + kvs node1.yaml # Run with node1.yaml in foreground + kvs start node1 # Start node1.yaml as daemon + kvs start node1.yaml # Same as above + kvs stop node1 # Stop node1 daemon + kvs status # Show all running instances + kvs status node1 # Show status of node1 +` + fmt.Print(help) +}