package iclient import ( "context" "fmt" "io" "net/http" "net/url" "sync" "github.com/gorilla/websocket" "github.com/lxc/incus/v7/shared/api" ) // execFDControl is the exec operation's control websocket, keyed by name where // the others are numbered. const execFDControl = "control" // InstanceExecArgs is where a non-interactive exec sends its output. A nil // writer discards that stream. // // The exit code is on the terminal operation, in Metadata["return"]. type InstanceExecArgs struct { Stdout io.Writer Stderr io.Writer } // ExecInstance runs a command in an instance and streams its output. // // The channel closes once the output has drained and the operation has // finished, so ranging over it to the end is enough. func (c *Connection) ExecInstance(ctx context.Context, project string, name string, exec api.InstanceExecPost, args *InstanceExecArgs) (<-chan api.Operation, error) { if args == nil { args = &InstanceExecArgs{} } if exec.Interactive { return nil, fmt.Errorf("an interactive exec: %w", ErrConnectionUnsupported) } // Without this the server runs the command detached, with no descriptors to read. exec.WaitForWS = true updates, err := c.asyncOperation(ctx, project, http.MethodPost, incusInstancePath(name, "/exec"), nil, exec, "") if err != nil { return nil, err } // The first value carries the fd secrets. started, ok := <-updates if !ok { return nil, fmt.Errorf("exec on %q: the operation reported nothing", name) } // Canceling this closes the streams; a websocket read only ends when the socket does. streamCtx, closeStreams := context.WithCancel(ctx) streams := &sync.WaitGroup{} // 0, 1 and 2 all have to be connected or the server times out waiting. writers := map[string]io.Writer{"1": args.Stdout, "2": args.Stderr} fds, ok := started.Metadata["fds"].(map[string]any) if !ok { closeStreams() return nil, fmt.Errorf("exec on %q: the operation advertised no file descriptors", name) } // Control first, and never from the map: a non-interactive exec does not // count it among the connections it waits for, so the command can run and // end the operation while a later dial is still in flight. order := []string{execFDControl} for fd := range fds { if fd != execFDControl { order = append(order, fd) } } for _, fd := range order { secret, ok := fds[fd].(string) if !ok || secret == "" { continue } socket, err := c.dialOperation(streamCtx, project, started.ID, secret) if err != nil { closeStreams() return nil, fmt.Errorf("attaching fd %s of the exec on %q: %w", fd, name, err) } // No stdin, so close it at once and let the command read EOF. if fd == "0" { _ = socket.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) _ = socket.Close() continue } streams.Add(1) go func() { defer streams.Done() // A websocket read only returns when the socket closes, so close it on ctx. go func() { <-streamCtx.Done() _ = socket.Close() }() // A nil writer, which is what control gets, discards. drainWebsocket(socket, writers[fd]) }() } return withDrain(updates, started, streams, closeStreams), nil } // dialOperation opens one of an operation's websockets. func (c *Connection) dialOperation(ctx context.Context, project string, id string, secret string) (*websocket.Conn, error) { transport, ok := c.http.Transport.(*http.Transport) if !ok { return nil, fmt.Errorf("unexpected transport %T", c.http.Transport) } query := url.Values{} query.Set("secret", secret) if project != "" { query.Set("project", project) } dialer := websocket.Dialer{ NetDialContext: transport.DialContext, TLSClientConfig: transport.TLSClientConfig, Proxy: transport.Proxy, HandshakeTimeout: incusTLSHandshakeTimeout, } uri := c.websocketURL("/1.0/operations/"+url.PathEscape(id)+"/websocket", query) socket, resp, err := dialer.DialContext(ctx, uri, nil) if resp != nil { _ = resp.Body.Close() } if err != nil { return nil, err } // The dialer's context covers the handshake only, so unblock a reader by closing. go func() { <-ctx.Done() _ = socket.Close() }() return socket, nil } // drainWebsocket copies one stream to its writer until the server closes it. func drainWebsocket(socket *websocket.Conn, writer io.Writer) { defer func() { _ = socket.Close() }() for { _, reader, err := socket.NextReader() if err != nil { return } if writer == nil { // Still read it: an undrained stream stalls the command. _, _ = io.Copy(io.Discard, reader) continue } _, err = io.Copy(writer, reader) if err != nil { return } } } // withDrain republishes an operation's updates, holding the close until the // output streams have finished. func withDrain(updates <-chan api.Operation, started api.Operation, streams *sync.WaitGroup, closeStreams context.CancelFunc) <-chan api.Operation { out := make(chan api.Operation, incusOperationBuffer) go func() { defer close(out) // The first value was consumed to reach the fd secrets. out <- started for update := range updates { out <- update } // The streams have normally drained by here; this covers the one that did not. closeStreams() streams.Wait() }() return out }