github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/commands.go (about)

     1  package sandbox
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"sync"
     7  	"sync/atomic"
     8  	"time"
     9  
    10  	"connectrpc.com/connect"
    11  
    12  	"github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/process"
    13  	"github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/process/processconnect"
    14  )
    15  
    16  // CommandResult 命令执行结果。
    17  type CommandResult struct {
    18  	ExitCode int
    19  	Stdout   string
    20  	Stderr   string
    21  	Error    string
    22  }
    23  
    24  // CommandHandle 后台命令句柄。
    25  type CommandHandle struct {
    26  	// pid 进程 ID。Start 路径下由后台事件流 goroutine 写入,
    27  	// 因此使用 atomic 保证与 PID()/Kill() 的并发读取之间无数据竞态。
    28  	pid atomic.Uint32
    29  
    30  	commands *Commands
    31  	cancel   context.CancelFunc
    32  	done     chan struct{}
    33  	pidCh    chan struct{}
    34  	pidOnce  sync.Once
    35  	result   *CommandResult
    36  
    37  	mu        sync.Mutex
    38  	onStdout  func(data []byte)
    39  	onStderr  func(data []byte)
    40  	onPtyData func(data []byte)
    41  }
    42  
    43  // markPIDReady 标记进程 PID 就绪:首次调用写入 pid 并关闭 pidCh,
    44  // 后续调用为 no-op。Start 路径在收到 Start 事件时调用;
    45  // Connect 路径在构造 handle 后立即以已知 PID 调用,
    46  // 避免共享事件流处理器后续再收到 Start 事件时重复 close(pidCh) 引发 panic。
    47  func (h *CommandHandle) markPIDReady(pid uint32) {
    48  	h.pidOnce.Do(func() {
    49  		h.pid.Store(pid)
    50  		close(h.pidCh)
    51  	})
    52  }
    53  
    54  // PID 返回进程 ID。
    55  func (h *CommandHandle) PID() uint32 {
    56  	return h.pid.Load()
    57  }
    58  
    59  // Wait 等待命令完成并返回结果。
    60  func (h *CommandHandle) Wait() (*CommandResult, error) {
    61  	<-h.done
    62  	if h.result == nil {
    63  		return nil, fmt.Errorf("command terminated without result")
    64  	}
    65  	return h.result, nil
    66  }
    67  
    68  // Kill 终止命令。
    69  func (h *CommandHandle) Kill(ctx context.Context) error {
    70  	return h.commands.Kill(ctx, h.pid.Load())
    71  }
    72  
    73  // WaitPID 等待进程 PID 被分配。
    74  // 当进程流收到 Start 事件后返回 PID;若 ctx 取消则返回错误。
    75  func (h *CommandHandle) WaitPID(ctx context.Context) (uint32, error) {
    76  	select {
    77  	case <-h.pidCh:
    78  		return h.pid.Load(), nil
    79  	case <-ctx.Done():
    80  		return 0, ctx.Err()
    81  	}
    82  }
    83  
    84  // ProcessInfo 进程信息。
    85  type ProcessInfo struct {
    86  	PID  uint32
    87  	Tag  *string
    88  	Cmd  string
    89  	Args []string
    90  	Envs map[string]string
    91  	Cwd  *string
    92  }
    93  
    94  // CommandOption 命令选项。
    95  type CommandOption func(*commandOpts)
    96  
    97  type commandOpts struct {
    98  	envs      map[string]string
    99  	cwd       string
   100  	user      string
   101  	tag       string
   102  	onStdout  func(data []byte)
   103  	onStderr  func(data []byte)
   104  	onPtyData func(data []byte)
   105  	timeout   time.Duration
   106  	stdin     bool
   107  }
   108  
   109  // WithEnvs 设置命令的环境变量。
   110  func WithEnvs(envs map[string]string) CommandOption {
   111  	return func(o *commandOpts) { o.envs = envs }
   112  }
   113  
   114  // WithCwd 设置命令的工作目录。
   115  func WithCwd(cwd string) CommandOption {
   116  	return func(o *commandOpts) { o.cwd = cwd }
   117  }
   118  
   119  // WithCommandUser 设置执行命令的用户。
   120  func WithCommandUser(user string) CommandOption {
   121  	return func(o *commandOpts) { o.user = user }
   122  }
   123  
   124  // WithTag 设置进程标签,用于后续通过标签连接进程。
   125  func WithTag(tag string) CommandOption {
   126  	return func(o *commandOpts) { o.tag = tag }
   127  }
   128  
   129  // WithOnStdout 设置 stdout 数据回调。仅用于标准命令的 stdout 输出。
   130  // PTY 会话应使用 WithOnPtyData 接收输出。
   131  func WithOnStdout(fn func(data []byte)) CommandOption {
   132  	return func(o *commandOpts) { o.onStdout = fn }
   133  }
   134  
   135  // WithOnStderr 设置 stderr 数据回调。
   136  func WithOnStderr(fn func(data []byte)) CommandOption {
   137  	return func(o *commandOpts) { o.onStderr = fn }
   138  }
   139  
   140  // WithOnPtyData 设置 PTY 数据回调。用于接收 PTY 会话的输出数据。
   141  // 若未设置,Pty.Create 会回退使用 WithOnStdout 回调以保持兼容。
   142  func WithOnPtyData(fn func(data []byte)) CommandOption {
   143  	return func(o *commandOpts) { o.onPtyData = fn }
   144  }
   145  
   146  // WithTimeout 设置命令超时时间。
   147  func WithTimeout(timeout time.Duration) CommandOption {
   148  	return func(o *commandOpts) { o.timeout = timeout }
   149  }
   150  
   151  // WithStdin 启用进程的标准输入。
   152  // 启用后可通过 Commands.SendStdin 向进程发送数据,通过 Commands.CloseStdin 发送 EOF。
   153  func WithStdin() CommandOption {
   154  	return func(o *commandOpts) { o.stdin = true }
   155  }
   156  
   157  func applyCommandOpts(opts []CommandOption) *commandOpts {
   158  	o := &commandOpts{user: DefaultUser}
   159  	for _, fn := range opts {
   160  		fn(o)
   161  	}
   162  	return o
   163  }
   164  
   165  // Commands 提供沙箱命令执行能力。
   166  type Commands struct {
   167  	sandbox *Sandbox
   168  	rpc     processconnect.ProcessClient
   169  }
   170  
   171  // newCommands 创建 Commands 实例。
   172  func newCommands(s *Sandbox, rpc processconnect.ProcessClient) *Commands {
   173  	return &Commands{sandbox: s, rpc: rpc}
   174  }
   175  
   176  // pidSelector 返回按 PID 选择进程的 ProcessSelector。
   177  func pidSelector(pid uint32) *process.ProcessSelector {
   178  	return &process.ProcessSelector{Selector: &process.ProcessSelector_Pid{Pid: pid}}
   179  }
   180  
   181  // Run 在沙箱中执行命令并等待完成。返回执行结果。
   182  // 注意: stdout 和 stderr 在内存中累积,长时间运行或大量输出的命令
   183  // 应使用 Start() + WithOnStdout/WithOnStderr 流式回调处理输出。
   184  func (c *Commands) Run(ctx context.Context, cmd string, opts ...CommandOption) (*CommandResult, error) {
   185  	handle, err := c.Start(ctx, cmd, opts...)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  	return handle.Wait()
   190  }
   191  
   192  // Start 在沙箱中后台启动命令。返回 CommandHandle 可用于等待完成。
   193  // cmd 以 /bin/bash -l -c <cmd> 形式执行,支持 shell 语法(管道、重定向等),
   194  // 会加载登录 shell 环境(/etc/profile 及用户 profile)。
   195  func (c *Commands) Start(ctx context.Context, cmd string, opts ...CommandOption) (*CommandHandle, error) {
   196  	o := applyCommandOpts(opts)
   197  
   198  	cmdCtx := ctx
   199  	var cmdCancel context.CancelFunc
   200  	if o.timeout > 0 {
   201  		cmdCtx, cmdCancel = context.WithTimeout(ctx, o.timeout)
   202  	} else {
   203  		cmdCtx, cmdCancel = context.WithCancel(ctx)
   204  	}
   205  
   206  	startReq := &process.StartRequest{
   207  		Process: &process.ProcessConfig{
   208  			Cmd:  "/bin/bash",
   209  			Args: []string{"-l", "-c", cmd},
   210  			Envs: o.envs,
   211  		},
   212  	}
   213  	if o.cwd != "" {
   214  		startReq.Process.Cwd = &o.cwd
   215  	}
   216  	if o.tag != "" {
   217  		startReq.Tag = &o.tag
   218  	}
   219  	startReq.Stdin = &o.stdin
   220  
   221  	req := connect.NewRequest(startReq)
   222  	c.sandbox.setEnvdAuth(req, o.user)
   223  
   224  	stream, err := c.rpc.Start(cmdCtx, req)
   225  	if err != nil {
   226  		cmdCancel()
   227  		return nil, fmt.Errorf("start command: %w", err)
   228  	}
   229  
   230  	handle := &CommandHandle{
   231  		commands: c,
   232  		cancel:   cmdCancel,
   233  		done:     make(chan struct{}),
   234  		pidCh:    make(chan struct{}),
   235  		onStdout: o.onStdout,
   236  		onStderr: o.onStderr,
   237  	}
   238  
   239  	go processEventStream(stream, handle)
   240  
   241  	return handle, nil
   242  }
   243  
   244  // eventMessage 是 StartResponse 和 ConnectResponse 的公共接口。
   245  type eventMessage interface {
   246  	GetEvent() *process.ProcessEvent
   247  }
   248  
   249  // streamReceiver 抽象 ConnectRPC 服务端流的读取操作。
   250  type streamReceiver[T eventMessage] interface {
   251  	Receive() bool
   252  	Msg() T
   253  	Err() error
   254  }
   255  
   256  // processEventStream 处理进程事件流(Start 和 Connect 共用)。
   257  func processEventStream[T eventMessage](stream streamReceiver[T], handle *CommandHandle) {
   258  	defer close(handle.done)
   259  
   260  	var stdout, stderr []byte
   261  	for stream.Receive() {
   262  		event := stream.Msg().GetEvent()
   263  		if event == nil {
   264  			continue
   265  		}
   266  		switch ev := event.Event.(type) {
   267  		case *process.ProcessEvent_Start:
   268  			handle.markPIDReady(ev.Start.Pid)
   269  		case *process.ProcessEvent_Data:
   270  			if data := ev.Data.GetStdout(); len(data) > 0 {
   271  				stdout = append(stdout, data...)
   272  				handle.mu.Lock()
   273  				fn := handle.onStdout
   274  				handle.mu.Unlock()
   275  				if fn != nil {
   276  					fn(data)
   277  				}
   278  			}
   279  			if data := ev.Data.GetStderr(); len(data) > 0 {
   280  				stderr = append(stderr, data...)
   281  				handle.mu.Lock()
   282  				fn := handle.onStderr
   283  				handle.mu.Unlock()
   284  				if fn != nil {
   285  					fn(data)
   286  				}
   287  			}
   288  			if data := ev.Data.GetPty(); len(data) > 0 {
   289  				handle.mu.Lock()
   290  				fn := handle.onPtyData
   291  				handle.mu.Unlock()
   292  				if fn != nil {
   293  					fn(data)
   294  				}
   295  			}
   296  		case *process.ProcessEvent_End:
   297  			handle.result = &CommandResult{
   298  				ExitCode: int(ev.End.ExitCode),
   299  				Stdout:   string(stdout),
   300  				Stderr:   string(stderr),
   301  			}
   302  			if ev.End.Error != nil {
   303  				handle.result.Error = *ev.End.Error
   304  			}
   305  		}
   306  	}
   307  
   308  	// 如果流结束但没有收到 EndEvent,创建一个错误结果
   309  	if handle.result == nil {
   310  		errMsg := ""
   311  		if err := stream.Err(); err != nil {
   312  			errMsg = err.Error()
   313  		}
   314  		handle.result = &CommandResult{
   315  			ExitCode: -1,
   316  			Stdout:   string(stdout),
   317  			Stderr:   string(stderr),
   318  			Error:    errMsg,
   319  		}
   320  	}
   321  }
   322  
   323  // Connect 连接到正在运行的进程。
   324  func (c *Commands) Connect(ctx context.Context, pid uint32) (*CommandHandle, error) {
   325  	connectCtx, connectCancel := context.WithCancel(ctx)
   326  
   327  	req := connect.NewRequest(&process.ConnectRequest{
   328  		Process: pidSelector(pid),
   329  	})
   330  	c.sandbox.setEnvdAuth(req, DefaultUser)
   331  
   332  	stream, err := c.rpc.Connect(connectCtx, req)
   333  	if err != nil {
   334  		connectCancel()
   335  		return nil, fmt.Errorf("connect to process: %w", err)
   336  	}
   337  
   338  	handle := &CommandHandle{
   339  		commands: c,
   340  		cancel:   connectCancel,
   341  		done:     make(chan struct{}),
   342  		pidCh:    make(chan struct{}),
   343  	}
   344  	// PID 已知,立即标记就绪;后续若服务端在流里再发 Start 事件,
   345  	// markPIDReady 内部的 sync.Once 会保证不会重复 close(pidCh)。
   346  	handle.markPIDReady(pid)
   347  
   348  	go processEventStream(stream, handle)
   349  
   350  	return handle, nil
   351  }
   352  
   353  // List 列出所有运行中的进程。
   354  func (c *Commands) List(ctx context.Context) ([]ProcessInfo, error) {
   355  	req := connect.NewRequest(&process.ListRequest{})
   356  	c.sandbox.setEnvdAuth(req, DefaultUser)
   357  
   358  	resp, err := c.rpc.List(ctx, req)
   359  	if err != nil {
   360  		return nil, fmt.Errorf("list processes: %w", err)
   361  	}
   362  
   363  	var infos []ProcessInfo
   364  	for _, p := range resp.Msg.Processes {
   365  		info := ProcessInfo{
   366  			PID: p.Pid,
   367  			Tag: p.Tag,
   368  		}
   369  		if p.Config != nil {
   370  			info.Cmd = p.Config.Cmd
   371  			info.Args = p.Config.Args
   372  			info.Envs = p.Config.Envs
   373  			info.Cwd = p.Config.Cwd
   374  		}
   375  		infos = append(infos, info)
   376  	}
   377  	return infos, nil
   378  }
   379  
   380  // SendStdin 向进程发送标准输入。
   381  func (c *Commands) SendStdin(ctx context.Context, pid uint32, data []byte) error {
   382  	req := connect.NewRequest(&process.SendInputRequest{
   383  		Process: pidSelector(pid),
   384  		Input: &process.ProcessInput{
   385  			Input: &process.ProcessInput_Stdin{Stdin: data},
   386  		},
   387  	})
   388  	c.sandbox.setEnvdAuth(req, DefaultUser)
   389  
   390  	_, err := c.rpc.SendInput(ctx, req)
   391  	if err != nil {
   392  		return fmt.Errorf("send stdin: %w", err)
   393  	}
   394  	return nil
   395  }
   396  
   397  // CloseStdin 关闭进程的标准输入管道,向进程发送 EOF 信号。
   398  // 仅适用于非 PTY 进程。PTY 进程应通过 SendStdin 发送 Ctrl+D (0x04)。
   399  // 若服务端尚未支持此 RPC,将返回 Unimplemented 错误,调用方可安全忽略。
   400  func (c *Commands) CloseStdin(ctx context.Context, pid uint32) error {
   401  	req := connect.NewRequest(&process.CloseStdinRequest{
   402  		Process: pidSelector(pid),
   403  	})
   404  	c.sandbox.setEnvdAuth(req, DefaultUser)
   405  
   406  	_, err := c.rpc.CloseStdin(ctx, req)
   407  	if err != nil {
   408  		return fmt.Errorf("close stdin: %w", err)
   409  	}
   410  	return nil
   411  }
   412  
   413  // Kill 终止指定进程。
   414  func (c *Commands) Kill(ctx context.Context, pid uint32) error {
   415  	req := connect.NewRequest(&process.SendSignalRequest{
   416  		Process: pidSelector(pid),
   417  		Signal:  process.Signal_SIGNAL_SIGKILL,
   418  	})
   419  	c.sandbox.setEnvdAuth(req, DefaultUser)
   420  
   421  	_, err := c.rpc.SendSignal(ctx, req)
   422  	if err != nil {
   423  		return fmt.Errorf("kill process: %w", err)
   424  	}
   425  	return nil
   426  }