330 lines
9.9 KiB
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)
|
|
}
|