iop/apps/edge/internal/opsconsole/console.go

330 lines
9.9 KiB
Go

package opsconsole
import (
"bufio"
"context"
"fmt"
"io"
"strings"
"time"
"iop/apps/edge/internal/bootstrap"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
)
// TargetState holds the current console target: node selection, adapter execution
// target, session, and background mode. It mirrors what a single console session
// is operating against.
type TargetState struct {
NodeRef string
Adapter string
Target string
SessionID string
Background bool
TimeoutSec int
}
// Run boots an edge runtime and drives the interactive ops console loop.
// It is the body of `edge console` and is the only entry point exposed to cmd.
func Run(ctx context.Context, cfg *config.EdgeConfig, in io.Reader, out io.Writer) error {
rt, err := bootstrap.NewRuntime(cfg)
if err != nil {
return err
}
if err := rt.Start(ctx); err != nil {
return err
}
defer func() { _ = rt.Stop() }()
edgeSvc := rt.Service
eventBus := rt.EventBus
events := NewEventRouter(out, rt.Registry, rt.Logger)
consoleCtx, stopConsoleEvents := context.WithCancel(ctx)
defer stopConsoleEvents()
runEvents, unsubscribeRuns := eventBus.SubscribeAllRuns(4096)
defer unsubscribeRuns()
nodeEvents, unsubscribeNodes := eventBus.SubscribeAllNodes(64)
defer unsubscribeNodes()
go events.Drain(consoleCtx, runEvents, nodeEvents)
target := &TargetState{
Adapter: cfg.Console.Adapter,
Target: cfg.Console.ResolveTarget(),
SessionID: NormalizeSessionID(cfg.Console.SessionID),
Background: cfg.Console.Background,
TimeoutSec: cfg.Console.TimeoutSec,
}
fmt.Fprintf(out, "IOP Edge console listening on %s\n", cfg.Server.Listen)
fmt.Fprintf(out, "Console target node=%s adapter=%s target=%s session=%s background=%v\n",
target.NodeRef, target.Adapter, target.Target, target.SessionID, target.Background)
fmt.Fprintln(out, "Start node.sh on another host, then type a message here.")
fmt.Fprintln(out, "Commands: /nodes, /node <id|alias>, /session <id>, /background on|off, /terminate-session, /status, /capabilities, /sessions, /transport, /exit")
scanner := bufio.NewScanner(in)
for {
fmt.Fprint(out, "edge> ")
if !scanner.Scan() {
fmt.Fprintln(out)
return scanner.Err()
}
cmd := ParseCommand(scanner.Text())
switch cmd.Kind {
case CommandEmpty:
continue
case CommandExit:
fmt.Fprintln(out, "bye")
return nil
case CommandNodes:
PrintNodes(out, edgeSvc.ListNodeSnapshots(), target.NodeRef)
case CommandSetNode:
if cmd.UsageErr != "" {
fmt.Fprintln(out, cmd.UsageErr)
continue
}
if _, err := edgeSvc.ResolveNodeSnapshot(cmd.Arg); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
continue
}
target.NodeRef = cmd.Arg
fmt.Fprintf(out, "node → %s\n", target.NodeRef)
case CommandSetSession:
if cmd.UsageErr != "" {
fmt.Fprintln(out, cmd.UsageErr)
continue
}
target.SessionID = cmd.Arg
fmt.Fprintf(out, "session → %s\n", target.SessionID)
case CommandSetBackground:
if cmd.UsageErr != "" {
fmt.Fprintln(out, cmd.UsageErr)
continue
}
target.Background = cmd.Arg == "on"
if target.Background {
fmt.Fprintln(out, "background → on")
} else {
fmt.Fprintln(out, "background → off")
}
case CommandTerminateSession:
HandleTerminateSession(ctx, edgeSvc, out, target)
case CommandStatus:
if err := SendStatus(ctx, edgeSvc, out, target); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
case CommandCapabilities:
if err := SendCapabilities(ctx, edgeSvc, out, target); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
case CommandSessionList:
if err := SendSessionList(ctx, edgeSvc, out, target); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
case CommandTransportStatus:
if err := SendTransportStatus(ctx, edgeSvc, out, target); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
case CommandPrompt:
if err := SendRun(ctx, edgeSvc, events, out, target, cmd.Arg); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
}
}
}
}
// CommandKind enumerates the console slash commands and the implicit "prompt"
// command (anything not starting with `/`).
type CommandKind int
const (
CommandEmpty CommandKind = iota
CommandExit
CommandNodes
CommandSetNode
CommandSetSession
CommandSetBackground
CommandTerminateSession
CommandStatus
CommandCapabilities
CommandSessionList
CommandTransportStatus
CommandPrompt
)
// Command is the parsed result of a single console input line.
type Command struct {
Kind CommandKind
Arg string
UsageErr string
}
func ParseCommand(line string) Command {
msg := strings.TrimSpace(line)
if msg == "" {
return Command{Kind: CommandEmpty}
}
lower := strings.ToLower(msg)
switch {
case lower == "/exit" || lower == "/quit" || lower == "exit" || lower == "quit":
return Command{Kind: CommandExit}
case lower == "/nodes":
return Command{Kind: CommandNodes}
case lower == "/terminate-session":
return Command{Kind: CommandTerminateSession}
case lower == "/status":
return Command{Kind: CommandStatus}
case lower == "/capabilities":
return Command{Kind: CommandCapabilities}
case lower == "/sessions":
return Command{Kind: CommandSessionList}
case lower == "/transport":
return Command{Kind: CommandTransportStatus}
case lower == "/node" || strings.HasPrefix(lower, "/node "):
parts := strings.Fields(msg)
if len(parts) < 2 || parts[1] == "" {
return Command{Kind: CommandSetNode, UsageErr: "usage: /node <id|alias>"}
}
return Command{Kind: CommandSetNode, Arg: parts[1]}
case lower == "/session" || strings.HasPrefix(lower, "/session "):
parts := strings.Fields(msg)
if len(parts) < 2 || parts[1] == "" {
return Command{Kind: CommandSetSession, UsageErr: "usage: /session <id>"}
}
return Command{Kind: CommandSetSession, Arg: parts[1]}
case lower == "/background" || strings.HasPrefix(lower, "/background "):
parts := strings.Fields(lower)
if len(parts) < 2 || (parts[1] != "on" && parts[1] != "off") {
return Command{Kind: CommandSetBackground, UsageErr: "usage: /background on|off"}
}
return Command{Kind: CommandSetBackground, Arg: parts[1]}
default:
return Command{Kind: CommandPrompt, Arg: msg}
}
}
func PrintNodes(out io.Writer, nodes []edgeservice.NodeSnapshot, selectedRef string) {
if len(nodes) == 0 {
fmt.Fprintln(out, "no nodes connected")
return
}
for _, node := range nodes {
marker := " "
if selectedRef != "" && (node.NodeID == selectedRef || node.Alias == selectedRef || node.Label == selectedRef) {
marker = "* "
}
fmt.Fprintf(out, "%s%s = %s (%s)\n", marker, node.Label, node.NodeID, node.Alias)
}
}
func SendRun(ctx context.Context, edgeSvc *edgeservice.Service, events *EventRouter, out io.Writer, target *TargetState, message string) error {
runID := edgeservice.NewRunID()
var untrack func()
if !target.Background {
untrack = events.TrackForeground(runID)
defer untrack()
}
handle, err := edgeSvc.SubmitRun(ctx, edgeservice.SubmitRunRequest{
NodeRef: target.NodeRef,
RunID: runID,
Adapter: target.Adapter,
Target: target.Target,
SessionID: target.SessionID,
Prompt: message,
Background: target.Background,
TimeoutSec: target.TimeoutSec,
Metadata: map[string]string{
"source": "edge-ops-console",
},
})
if err != nil {
return err
}
defer handle.Close()
fmt.Fprintf(out, "[edge] sent run_id=%s node=%s adapter=%s target=%s session=%s background=%v\n",
handle.RunID, handle.NodeLabel, handle.Adapter, handle.Target, handle.SessionID, handle.Background)
if target.Background {
fmt.Fprintf(out, "[edge] background run dispatched, events will arrive asynchronously\n")
return nil
}
timer := time.NewTimer(handle.WaitTimeout())
defer timer.Stop()
var response *ResponseStream
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("timed out waiting for node response")
case event := <-handle.NodeEvents:
if edgeservice.IsNodeDisconnected(event) {
return fmt.Errorf("node disconnected: %s", event.GetReason())
}
case event := <-handle.Events:
if event.GetRunId() != handle.RunID {
continue
}
label := events.NodeLabel(event)
if response == nil {
response = NewResponseStream(out, nodeMessagePrefix(label))
}
switch event.GetType() {
case "start":
fmt.Fprintf(out, "%s start run_id=%s\n", nodeEventPrefix(label), handle.RunID)
case "delta":
response.Write(event.GetDelta())
case "complete":
response.Finish()
fmt.Fprintf(out, "%s complete run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetMessage())
return nil
case "cancelled":
response.FinishIfStarted()
fmt.Fprintf(out, "%s cancelled run_id=%s\n", nodeEventPrefix(label), handle.RunID)
return nil
case "error":
response.FinishIfStarted()
fmt.Fprintf(out, "%s error run_id=%s detail=%q\n", nodeEventPrefix(label), handle.RunID, event.GetError())
return fmt.Errorf("node reported error")
default:
fmt.Fprintf(out, "%s %s run_id=%s detail=%q\n", nodeEventPrefix(label), event.GetType(), handle.RunID, event.GetMessage())
}
}
}
}
// SendTerminateSessionFunc is overridable for tests of HandleTerminateSession.
var SendTerminateSessionFunc = SendTerminateSession
func HandleTerminateSession(ctx context.Context, edgeSvc *edgeservice.Service, out io.Writer, target *TargetState) {
if label, err := SendTerminateSessionFunc(ctx, edgeSvc, target); err != nil {
fmt.Fprintf(out, "error: %v\n", err)
} else {
fmt.Fprintf(out, "terminated session %s node=%s\n", target.SessionID, label)
}
}
func SendTerminateSession(ctx context.Context, edgeSvc *edgeservice.Service, target *TargetState) (string, error) {
result, err := edgeSvc.TerminateSession(ctx, edgeservice.TerminateSessionRequest{
NodeRef: target.NodeRef,
Adapter: target.Adapter,
Target: target.Target,
SessionID: target.SessionID,
})
if err != nil {
return "", err
}
return result.NodeLabel, nil
}
func NormalizeSessionID(id string) string {
return edgeservice.NormalizeSessionID(id)
}