feat(gateway): add CLI stop/status controls
This commit is contained in:
parent
c3e7396a3d
commit
d38d9fade1
4 changed files with 204 additions and 1 deletions
|
|
@ -47,6 +47,10 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
false,
|
false,
|
||||||
"Continue starting even when no default model is configured",
|
"Continue starting even when no default model is configured",
|
||||||
)
|
)
|
||||||
|
cmd.AddCommand(
|
||||||
|
newStatusCommand(),
|
||||||
|
newStopCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -24,9 +25,20 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.Nil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
assert.False(t, cmd.HasSubCommands())
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"status",
|
||||||
|
"stop",
|
||||||
|
}
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
assert.True(t, slices.Contains(allowedCommands, subcmd.Name()))
|
||||||
|
assert.NotNil(t, subcmd.RunE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
cmd/picoclaw/internal/gateway/control.go
Normal file
75
cmd/picoclaw/internal/gateway/control.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/pid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newStatusCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show gateway process status",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return gatewayStatusCmd(internal.GetPicoclawHome())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStopCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop a running gateway process",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return gatewayStopCmd(internal.GetPicoclawHome())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatewayStatusCmd(homePath string) error {
|
||||||
|
data := pid.ReadPidFileWithCheck(homePath)
|
||||||
|
if data == nil {
|
||||||
|
fmt.Println("Gateway status: stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"Gateway status: running (PID: %d, host: %s, port: %d)\n",
|
||||||
|
data.PID,
|
||||||
|
data.Host,
|
||||||
|
data.Port,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatewayStopCmd(homePath string) error {
|
||||||
|
data := pid.ReadPidFileWithCheck(homePath)
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("gateway is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
process, err := os.FindProcess(data.PID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find gateway process (PID: %d): %w", data.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
err = process.Kill()
|
||||||
|
} else {
|
||||||
|
err = process.Signal(syscall.SIGTERM)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop gateway (PID: %d): %w", data.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Sent stop signal to gateway (PID: %d)\n", data.PID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
112
cmd/picoclaw/internal/gateway/control_test.go
Normal file
112
cmd/picoclaw/internal/gateway/control_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func captureGatewayStdout(t *testing.T, fn func() error) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldStdout := os.Stdout
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
os.Stdout = w
|
||||||
|
|
||||||
|
runErr := fn()
|
||||||
|
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
os.Stdout = oldStdout
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, copyErr := io.Copy(&buf, r)
|
||||||
|
require.NoError(t, copyErr)
|
||||||
|
|
||||||
|
return buf.String(), runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGatewayPidFile(t *testing.T, homePath string, processID int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
"pid": processID,
|
||||||
|
"token": "test-token",
|
||||||
|
"version": "test",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18790,
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = os.WriteFile(filepath.Join(homePath, ".picoclaw.pid"), raw, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusCmdStopped(t *testing.T) {
|
||||||
|
homePath := t.TempDir()
|
||||||
|
|
||||||
|
output, err := captureGatewayStdout(t, func() error {
|
||||||
|
return gatewayStatusCmd(homePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "Gateway status: stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStopCmdNotRunning(t *testing.T) {
|
||||||
|
homePath := t.TempDir()
|
||||||
|
|
||||||
|
err := gatewayStopCmd(homePath)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "gateway is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStopCmdRunningProcess(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("requires POSIX signal semantics")
|
||||||
|
}
|
||||||
|
|
||||||
|
homePath := t.TempDir()
|
||||||
|
sleepCmd := exec.Command("sleep", "30")
|
||||||
|
require.NoError(t, sleepCmd.Start())
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if sleepCmd.Process != nil {
|
||||||
|
_ = sleepCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
_ = sleepCmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
writeGatewayPidFile(t, homePath, sleepCmd.Process.Pid)
|
||||||
|
|
||||||
|
output, err := captureGatewayStdout(t, func() error {
|
||||||
|
return gatewayStopCmd(homePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "Sent stop signal to gateway")
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- sleepCmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case waitErr := <-done:
|
||||||
|
if waitErr != nil {
|
||||||
|
assert.True(t, strings.Contains(waitErr.Error(), "signal"))
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("gateway process did not exit after stop signal")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue