- Consolidate DialRemote/DialTunnel common logic into buildResources + dialEnv interface - Remove strict capability check that prevented ConnResources creation for host-exec-only nodes - Merge gRPC-discovered capabilities with registration-declared capabilities in DialTunnel - Replace requireKubeConfig hard-fail with graceful skip when kubeconfig is absent - Introduce tai/types package for shared Ports/Capabilities/SystemInfo/AuthInfo/NodeMeta - Add tai/conn.go (ConnResources) and tai/dial.go (DialRemote/DialTunnel/DialLocal) - Rename tai/sandbox → tai/runtime for clarity - Update sandbox/v2, workspace, agent/sandbox/v2 test utilities for build-tag isolation Made-with: Cursor
242 lines
5.8 KiB
Go
242 lines
5.8 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
|
|
hepb "github.com/yaoapp/yao/tai/hostexec/pb"
|
|
taiworkspace "github.com/yaoapp/yao/tai/workspace"
|
|
)
|
|
|
|
// Host represents a Tai host machine execution environment.
|
|
// Unlike Box (which wraps a container), Host executes commands directly on
|
|
// the Tai server's OS via HostExec gRPC and accesses files via Volume gRPC.
|
|
//
|
|
// Host implements the Computer interface.
|
|
type Host struct {
|
|
nodeID string
|
|
workplaceID string
|
|
system SystemInfo
|
|
manager *Manager
|
|
}
|
|
|
|
// Compile-time check: *Host implements Computer.
|
|
var _ Computer = (*Host)(nil)
|
|
|
|
// ComputerInfo returns identity and registry information for the host.
|
|
// Registry-level details (TaiID, System, etc.) are populated when the node
|
|
// is backed by a registered Tai node; otherwise only Kind and NodeID are set.
|
|
func (h *Host) ComputerInfo() ComputerInfo {
|
|
return ComputerInfo{
|
|
Kind: "host",
|
|
NodeID: h.nodeID,
|
|
System: h.system,
|
|
Status: "online",
|
|
}
|
|
}
|
|
|
|
// Exec runs a command on the Tai host machine via HostExec gRPC.
|
|
// cmd[0] is the program, cmd[1:] are arguments.
|
|
func (h *Host) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) {
|
|
if len(cmd) == 0 {
|
|
return nil, fmt.Errorf("sandbox: empty command")
|
|
}
|
|
|
|
res, err := h.manager.getNode(h.nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
he := res.HostExec
|
|
if he == nil {
|
|
return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID)
|
|
}
|
|
|
|
cfg := &execConfig{}
|
|
for _, o := range opts {
|
|
o(cfg)
|
|
}
|
|
|
|
req := &hepb.ExecRequest{
|
|
Command: cmd[0],
|
|
Args: cmd[1:],
|
|
Stdin: cfg.Stdin,
|
|
}
|
|
if cfg.WorkDir != "" {
|
|
req.WorkingDir = cfg.WorkDir
|
|
}
|
|
if cfg.Env != nil {
|
|
req.Env = cfg.Env
|
|
}
|
|
if cfg.Timeout > 0 {
|
|
req.TimeoutMs = cfg.Timeout.Milliseconds()
|
|
}
|
|
if cfg.MaxOutputBytes > 0 {
|
|
req.MaxOutputBytes = cfg.MaxOutputBytes
|
|
}
|
|
|
|
resp, err := he.Exec(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hostexec rpc: %w", err)
|
|
}
|
|
|
|
return &ExecResult{
|
|
ExitCode: int(resp.ExitCode),
|
|
Stdout: string(resp.Stdout),
|
|
Stderr: string(resp.Stderr),
|
|
DurationMs: resp.DurationMs,
|
|
Error: resp.Error,
|
|
Truncated: resp.Truncated,
|
|
}, nil
|
|
}
|
|
|
|
// Stream runs a command on the Tai host and streams stdout/stderr in real time
|
|
// via HostExec gRPC ExecStream. Returns a unified ExecStream with io.ReadCloser
|
|
// for stdout/stderr.
|
|
func (h *Host) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) {
|
|
if len(cmd) == 0 {
|
|
return nil, fmt.Errorf("sandbox: empty command")
|
|
}
|
|
|
|
res, err := h.manager.getNode(h.nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
he := res.HostExec
|
|
if he == nil {
|
|
return nil, fmt.Errorf("sandbox: host_exec not available on node %q", h.nodeID)
|
|
}
|
|
|
|
cfg := &execConfig{}
|
|
for _, o := range opts {
|
|
o(cfg)
|
|
}
|
|
|
|
req := &hepb.ExecRequest{
|
|
Command: cmd[0],
|
|
Args: cmd[1:],
|
|
Stdin: cfg.Stdin,
|
|
}
|
|
if cfg.WorkDir != "" {
|
|
req.WorkingDir = cfg.WorkDir
|
|
}
|
|
if cfg.Env != nil {
|
|
req.Env = cfg.Env
|
|
}
|
|
if cfg.Timeout > 0 {
|
|
req.TimeoutMs = cfg.Timeout.Milliseconds()
|
|
}
|
|
if cfg.MaxOutputBytes > 0 {
|
|
req.MaxOutputBytes = cfg.MaxOutputBytes
|
|
}
|
|
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
rpcStream, err := he.ExecStream(streamCtx, req)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("hostexec stream rpc: %w", err)
|
|
}
|
|
|
|
stdoutR, stdoutW := io.Pipe()
|
|
stderrR, stderrW := io.Pipe()
|
|
doneCh := make(chan struct{})
|
|
var exitCode int
|
|
var exitErr error
|
|
|
|
go func() {
|
|
defer stdoutW.Close()
|
|
defer stderrW.Close()
|
|
defer close(doneCh)
|
|
for {
|
|
msg, err := rpcStream.Recv()
|
|
if err != nil {
|
|
exitErr = fmt.Errorf("hostexec stream recv: %w", err)
|
|
return
|
|
}
|
|
if len(msg.Data) > 0 {
|
|
switch msg.Stream {
|
|
case hepb.ExecOutput_STDOUT:
|
|
stdoutW.Write(msg.Data)
|
|
case hepb.ExecOutput_STDERR:
|
|
stderrW.Write(msg.Data)
|
|
}
|
|
}
|
|
if msg.Done {
|
|
exitCode = int(msg.ExitCode)
|
|
if msg.Error != "" {
|
|
exitErr = fmt.Errorf("hostexec: %s", msg.Error)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
return &ExecStream{
|
|
Stdout: stdoutR,
|
|
Stderr: stderrR,
|
|
Stdin: nopWriteCloser{&bytes.Buffer{}},
|
|
Wait: func() (int, error) {
|
|
<-doneCh
|
|
return exitCode, exitErr
|
|
},
|
|
Cancel: cancel,
|
|
}, nil
|
|
}
|
|
|
|
// VNC returns the VNC WebSocket URL for the Tai host machine.
|
|
// Uses the special __host__ identifier to route to localhost:5900 on the Tai server.
|
|
func (h *Host) VNC(ctx context.Context) (string, error) {
|
|
res, err := h.manager.getNode(h.nodeID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if res.VNC == nil {
|
|
return "", fmt.Errorf("sandbox: vnc not available on node %q", h.nodeID)
|
|
}
|
|
return res.VNC.URL(ctx, "__host__")
|
|
}
|
|
|
|
// Proxy returns the HTTP URL for a service running on the Tai host machine.
|
|
// Uses the special __host__ identifier to route to localhost:{port} on the Tai server.
|
|
func (h *Host) Proxy(ctx context.Context, port int, path string) (string, error) {
|
|
res, err := h.manager.getNode(h.nodeID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if res.Proxy == nil {
|
|
return "", fmt.Errorf("sandbox: proxy not available on node %q", h.nodeID)
|
|
}
|
|
return res.Proxy.URL(ctx, "__host__", port, path)
|
|
}
|
|
|
|
// BindWorkplace binds a workspace to this host by ID. Subsequent calls to
|
|
// Workplace() will return the FS for this workspace. Call again to rebind.
|
|
func (h *Host) BindWorkplace(workspaceID string) {
|
|
h.workplaceID = workspaceID
|
|
}
|
|
|
|
// Workplace returns the workspace FS bound to this host, or nil if unbound.
|
|
func (h *Host) Workplace() taiworkspace.FS {
|
|
if h.workplaceID == "" {
|
|
return nil
|
|
}
|
|
res, err := h.manager.getNode(h.nodeID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if res.Volume == nil {
|
|
return nil
|
|
}
|
|
return taiworkspace.New(res.Volume, h.workplaceID)
|
|
}
|
|
|
|
// NodeID returns the node ID this Host belongs to.
|
|
func (h *Host) NodeID() string { return h.nodeID }
|
|
|
|
// nopWriteCloser wraps an io.Writer with a no-op Close.
|
|
type nopWriteCloser struct{ io.Writer }
|
|
|
|
func (nopWriteCloser) Close() error { return nil }
|