Files
yuan-lab-llm--clawmanager/backend/internal/services/instance_shell_service.go
T
wehub-resource-sync 6d978fe483
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:24 +08:00

239 lines
5.7 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"clawreef/internal/services/k8s"
"github.com/gorilla/websocket"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/remotecommand"
)
// InstanceShellService streams an interactive shell into an instance pod.
type InstanceShellService struct {
podService *k8s.PodService
upgrader websocket.Upgrader
}
type shellClientMessage struct {
Type string `json:"type"`
Data string `json:"data,omitempty"`
Cols uint16 `json:"cols,omitempty"`
Rows uint16 `json:"rows,omitempty"`
}
type shellTerminalSizeQueue struct {
sizes chan remotecommand.TerminalSize
mu sync.Mutex
closed bool
}
type shellWebSocketWriter struct {
conn *websocket.Conn
mu *sync.Mutex
done <-chan struct{}
}
func NewInstanceShellService() *InstanceShellService {
return &InstanceShellService{
podService: k8s.NewPodService(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
}
}
func (s *InstanceShellService) Stream(ctx context.Context, userID, instanceID int, w http.ResponseWriter, r *http.Request) error {
if s.podService == nil || s.podService.GetClient() == nil || s.podService.GetClient().Clientset == nil {
return fmt.Errorf("k8s client not initialized")
}
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return fmt.Errorf("failed to upgrade shell websocket: %w", err)
}
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
writeMu := &sync.Mutex{}
done := make(chan struct{})
defer close(done)
defer conn.Close()
stdinReader, stdinWriter := io.Pipe()
defer stdinReader.Close()
defer stdinWriter.Close()
sizeQueue := newShellTerminalSizeQueue()
defer sizeQueue.close()
sizeQueue.push(120, 32)
go readShellWebSocket(streamCtx, cancel, conn, stdinWriter, sizeQueue)
pod, err := s.podService.GetPod(streamCtx, userID, instanceID)
if err != nil {
_ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nfailed to get pod: %v\r\n", err)))
return fmt.Errorf("failed to get pod: %w", err)
}
req := s.podService.GetClient().Clientset.CoreV1().RESTClient().Post().
Resource("pods").
Name(pod.Name).
Namespace(pod.Namespace).
SubResource("exec")
req.VersionedParams(&corev1.PodExecOptions{
Container: "desktop",
Command: defaultShellCommand(),
Stdin: true,
Stdout: true,
Stderr: false,
TTY: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(s.podService.GetClient().Config, "POST", req.URL())
if err != nil {
_ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nfailed to initialize shell: %v\r\n", err)))
return fmt.Errorf("failed to initialize shell stream: %w", err)
}
writer := &shellWebSocketWriter{conn: conn, mu: writeMu, done: done}
if err := exec.StreamWithContext(streamCtx, remotecommand.StreamOptions{
Stdin: stdinReader,
Stdout: writer,
Tty: true,
TerminalSizeQueue: sizeQueue,
}); err != nil && streamCtx.Err() == nil {
_ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nshell closed: %v\r\n", err)))
return fmt.Errorf("shell stream failed: %w", err)
}
return nil
}
func defaultShellCommand() []string {
return []string{
"sh",
"-lc",
`export TERM="${TERM:-xterm-256color}" COLORTERM="${COLORTERM:-truecolor}"; if command -v tmux >/dev/null 2>&1; then session="${CLAWMANAGER_TMUX_SESSION:-openclaw-shell}"; exec tmux new-session -A -s "$session"; fi; if command -v bash >/dev/null 2>&1; then exec bash -l; fi; exec sh`,
}
}
func newShellTerminalSizeQueue() *shellTerminalSizeQueue {
return &shellTerminalSizeQueue{
sizes: make(chan remotecommand.TerminalSize, 4),
}
}
func (q *shellTerminalSizeQueue) Next() *remotecommand.TerminalSize {
size, ok := <-q.sizes
if !ok {
return nil
}
return &size
}
func (q *shellTerminalSizeQueue) push(cols, rows uint16) {
if q == nil || cols == 0 || rows == 0 {
return
}
q.mu.Lock()
defer q.mu.Unlock()
if q.closed {
return
}
size := remotecommand.TerminalSize{Width: cols, Height: rows}
select {
case q.sizes <- size:
default:
select {
case <-q.sizes:
default:
}
select {
case q.sizes <- size:
default:
}
}
}
func (q *shellTerminalSizeQueue) close() {
if q == nil {
return
}
q.mu.Lock()
defer q.mu.Unlock()
if !q.closed {
q.closed = true
close(q.sizes)
}
}
func readShellWebSocket(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, stdin *io.PipeWriter, sizeQueue *shellTerminalSizeQueue) {
defer cancel()
defer stdin.Close()
for {
select {
case <-ctx.Done():
return
default:
}
messageType, payload, err := conn.ReadMessage()
if err != nil {
_ = stdin.CloseWithError(err)
return
}
switch messageType {
case websocket.BinaryMessage:
if len(payload) > 0 {
_, _ = stdin.Write(payload)
}
case websocket.TextMessage:
var message shellClientMessage
if json.Unmarshal(payload, &message) == nil && message.Type != "" {
switch message.Type {
case "input":
if message.Data != "" {
_, _ = io.WriteString(stdin, message.Data)
}
case "resize":
sizeQueue.push(message.Cols, message.Rows)
}
continue
}
if len(payload) > 0 {
_, _ = stdin.Write(payload)
}
}
}
}
func (w *shellWebSocketWriter) Write(payload []byte) (int, error) {
if err := writeShellBytes(w.conn, w.mu, w.done, payload); err != nil {
return 0, err
}
return len(payload), nil
}
func writeShellBytes(conn *websocket.Conn, mu *sync.Mutex, done <-chan struct{}, payload []byte) error {
select {
case <-done:
return io.ErrClosedPipe
default:
}
mu.Lock()
defer mu.Unlock()
return conn.WriteMessage(websocket.BinaryMessage, payload)
}