Path: blob/main/components/supervisor/pkg/terminal/service.go
2500 views
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package terminal56import (7"context"8"fmt"9"io"10"os"11"os/exec"12"path/filepath"13"syscall"14"time"1516"github.com/creack/pty"17"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"18"google.golang.org/grpc"19"google.golang.org/grpc/codes"20"google.golang.org/grpc/credentials/insecure"21"google.golang.org/grpc/status"2223"github.com/gitpod-io/gitpod/common-go/log"24"github.com/gitpod-io/gitpod/supervisor/api"25)2627// NewMuxTerminalService creates a new terminal service.28func NewMuxTerminalService(m *Mux) *MuxTerminalService {29shell := os.Getenv("SHELL")30if shell == "" {31shell = "/bin/bash"32}33return &MuxTerminalService{34Mux: m,35DefaultWorkdir: "/workspace",36DefaultShell: shell,37Env: os.Environ(),38}39}4041// MuxTerminalService implements the terminal service API using a terminal Mux.42type MuxTerminalService struct {43Mux *Mux4445DefaultWorkdir string46// DefaultWorkdirProvider allows dynamically to compute workdir47// if returns empty string then DefaultWorkdir is used48DefaultWorkdirProvider func() string4950DefaultShell string51Env []string52DefaultCreds *syscall.Credential53DefaultAmbientCaps []uintptr5455api.UnimplementedTerminalServiceServer56}5758// RegisterGRPC registers a gRPC service.59func (srv *MuxTerminalService) RegisterGRPC(s *grpc.Server) {60api.RegisterTerminalServiceServer(s, srv)61}6263// RegisterREST registers a REST service.64func (srv *MuxTerminalService) RegisterREST(mux *runtime.ServeMux, grpcEndpoint string) error {65return api.RegisterTerminalServiceHandlerFromEndpoint(context.Background(), mux, grpcEndpoint, []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())})66}6768// Open opens a new terminal running the shell.69func (srv *MuxTerminalService) Open(ctx context.Context, req *api.OpenTerminalRequest) (*api.OpenTerminalResponse, error) {70return srv.OpenWithOptions(ctx, req, TermOptions{71ReadTimeout: 5 * time.Second,72Annotations: req.Annotations,73})74}7576// OpenWithOptions opens a new terminal running the shell with given options.77// req.Annotations override options.Annotations.78func (srv *MuxTerminalService) OpenWithOptions(ctx context.Context, req *api.OpenTerminalRequest, options TermOptions) (*api.OpenTerminalResponse, error) {79shell := req.Shell80if shell == "" {81shell = srv.DefaultShell82}83cmd := exec.Command(shell, req.ShellArgs...)84if srv.DefaultCreds != nil {85cmd.SysProcAttr = &syscall.SysProcAttr{86Credential: srv.DefaultCreds,87}88}89if req.Workdir != "" {90cmd.Dir = req.Workdir91} else if srv.DefaultWorkdirProvider != nil {92cmd.Dir = srv.DefaultWorkdirProvider()93}94if cmd.Dir == "" {95cmd.Dir = srv.DefaultWorkdir96}97cmd.Env = append(srv.Env, "TERM=xterm-256color")98for key, value := range req.Env {99cmd.Env = append(cmd.Env, fmt.Sprintf("%v=%v", key, value))100}101for k, v := range req.Annotations {102options.Annotations[k] = v103}104if req.Size != nil {105options.Size = &pty.Winsize{106Cols: uint16(req.Size.Cols),107Rows: uint16(req.Size.Rows),108X: uint16(req.Size.WidthPx),109Y: uint16(req.Size.HeightPx),110}111}112113if srv.DefaultAmbientCaps != nil {114if cmd.SysProcAttr == nil {115cmd.SysProcAttr = &syscall.SysProcAttr{}116}117cmd.SysProcAttr.AmbientCaps = srv.DefaultAmbientCaps118}119120alias, err := srv.Mux.Start(cmd, options)121if err != nil {122return nil, status.Error(codes.Internal, err.Error())123}124125// starterToken is just relevant for the service, hence it's not exposed at the Start() call126var starterToken string127term := srv.Mux.terms[alias]128if term != nil {129starterToken = term.StarterToken130}131132terminal, found := srv.get(alias)133if !found {134return nil, status.Error(codes.NotFound, "terminal not found")135}136return &api.OpenTerminalResponse{137Terminal: terminal,138StarterToken: starterToken,139}, nil140}141142// Close closes a terminal for the given alias.143func (srv *MuxTerminalService) Shutdown(ctx context.Context, req *api.ShutdownTerminalRequest) (*api.ShutdownTerminalResponse, error) {144err := srv.Mux.CloseTerminal(ctx, req.Alias, req.ForceSuccess)145if err == ErrNotFound {146return nil, status.Error(codes.NotFound, err.Error())147}148if err != nil {149return nil, status.Error(codes.Internal, err.Error())150}151return &api.ShutdownTerminalResponse{}, nil152}153154// List lists all open terminals.155func (srv *MuxTerminalService) List(ctx context.Context, req *api.ListTerminalsRequest) (*api.ListTerminalsResponse, error) {156srv.Mux.mu.RLock()157defer srv.Mux.mu.RUnlock()158159res := make([]*api.Terminal, 0, len(srv.Mux.terms))160for _, alias := range srv.Mux.aliases {161term, ok := srv.get(alias)162if !ok {163continue164}165res = append(res, term)166}167168return &api.ListTerminalsResponse{169Terminals: res,170}, nil171}172173// Get returns an open terminal info.174func (srv *MuxTerminalService) Get(ctx context.Context, req *api.GetTerminalRequest) (*api.Terminal, error) {175srv.Mux.mu.RLock()176defer srv.Mux.mu.RUnlock()177term, ok := srv.get(req.Alias)178if !ok {179return nil, status.Error(codes.NotFound, "terminal not found")180}181return term, nil182}183184func (srv *MuxTerminalService) get(alias string) (*api.Terminal, bool) {185term, ok := srv.Mux.terms[alias]186if !ok {187return nil, false188}189190var (191pid int64192cwd string193err error194)195if proc := term.Command.Process; proc != nil {196pid = int64(proc.Pid)197cwd, err = filepath.EvalSymlinks(fmt.Sprintf("/proc/%d/cwd", pid))198if err != nil {199log.WithError(err).WithField("pid", pid).Warn("unable to resolve terminal's current working dir")200cwd = term.Command.Dir201}202}203204title, titleSource, err := term.GetTitle()205if err != nil {206log.WithError(err).WithField("pid", pid).Warn("unable to resolve terminal's title")207}208209return &api.Terminal{210Alias: alias,211Command: term.Command.Args,212Pid: pid,213InitialWorkdir: term.Command.Dir,214CurrentWorkdir: cwd,215Annotations: term.GetAnnotations(),216Title: title,217TitleSource: titleSource,218}, true219}220221// Listen listens to a terminal.222func (srv *MuxTerminalService) Listen(req *api.ListenTerminalRequest, resp api.TerminalService_ListenServer) error {223srv.Mux.mu.RLock()224term, ok := srv.Mux.terms[req.Alias]225srv.Mux.mu.RUnlock()226if !ok {227return status.Error(codes.NotFound, "terminal not found")228}229stdout := term.Stdout.Listen()230defer stdout.Close()231232log.WithField("alias", req.Alias).Info("new terminal client")233defer log.WithField("alias", req.Alias).Info("terminal client left")234235errchan := make(chan error, 1)236messages := make(chan *api.ListenTerminalResponse, 1)237go func() {238for {239buf := make([]byte, 4096)240n, err := stdout.Read(buf)241if err == io.EOF {242break243}244if err != nil {245errchan <- err246return247}248messages <- &api.ListenTerminalResponse{Output: &api.ListenTerminalResponse_Data{Data: buf[:n]}}249}250251state, err := term.Wait()252if err != nil {253errchan <- err254return255}256257messages <- &api.ListenTerminalResponse{Output: &api.ListenTerminalResponse_ExitCode{ExitCode: int32(state.ExitCode())}}258errchan <- io.EOF259}()260go func() {261title, titleSource, _ := term.GetTitle()262messages <- &api.ListenTerminalResponse{Output: &api.ListenTerminalResponse_Title{Title: title}, TitleSource: titleSource}263264t := time.NewTicker(200 * time.Millisecond)265defer t.Stop()266for {267select {268case <-resp.Context().Done():269return270case <-t.C:271newTitle, newTitleSource, _ := term.GetTitle()272if title == newTitle && titleSource == newTitleSource {273continue274}275title = newTitle276titleSource = newTitleSource277messages <- &api.ListenTerminalResponse{Output: &api.ListenTerminalResponse_Title{Title: title}, TitleSource: titleSource}278}279}280}()281for {282var err error283select {284case message := <-messages:285err = resp.Send(message)286case err = <-errchan:287case <-resp.Context().Done():288return nil289}290if err == io.EOF {291// EOF isn't really an error here292return nil293}294if err != nil {295return status.Error(codes.Internal, err.Error())296}297}298}299300// Write writes to a terminal.301func (srv *MuxTerminalService) Write(ctx context.Context, req *api.WriteTerminalRequest) (*api.WriteTerminalResponse, error) {302srv.Mux.mu.RLock()303term, ok := srv.Mux.terms[req.Alias]304srv.Mux.mu.RUnlock()305if !ok {306return nil, status.Error(codes.NotFound, "terminal not found")307}308309n, err := term.PTY.Write(req.Stdin)310if err != nil {311return nil, status.Error(codes.Internal, err.Error())312}313return &api.WriteTerminalResponse{BytesWritten: uint32(n)}, nil314}315316// SetSize sets the terminal's size.317func (srv *MuxTerminalService) SetSize(ctx context.Context, req *api.SetTerminalSizeRequest) (*api.SetTerminalSizeResponse, error) {318srv.Mux.mu.RLock()319term, ok := srv.Mux.terms[req.Alias]320srv.Mux.mu.RUnlock()321if !ok {322return nil, status.Error(codes.NotFound, "terminal not found")323}324325// Setting the size only works with the starter token or when forcing it.326// This protects us from multiple listener mangling the terminal.327if !(req.GetForce() || req.GetToken() == term.StarterToken) {328return nil, status.Error(codes.FailedPrecondition, "wrong token or force not set")329}330331err := pty.Setsize(term.PTY, &pty.Winsize{332Cols: uint16(req.Size.Cols),333Rows: uint16(req.Size.Rows),334X: uint16(req.Size.WidthPx),335Y: uint16(req.Size.HeightPx),336})337if err != nil {338return nil, status.Error(codes.Internal, err.Error())339}340341return &api.SetTerminalSizeResponse{}, nil342}343344// SetTitle sets the terminal's title.345func (srv *MuxTerminalService) SetTitle(ctx context.Context, req *api.SetTerminalTitleRequest) (*api.SetTerminalTitleResponse, error) {346srv.Mux.mu.RLock()347term, ok := srv.Mux.terms[req.Alias]348srv.Mux.mu.RUnlock()349if !ok {350return nil, status.Error(codes.NotFound, "terminal not found")351}352term.SetTitle(req.Title)353return &api.SetTerminalTitleResponse{}, nil354}355356// UpdateAnnotations sets the terminal's title.357func (srv *MuxTerminalService) UpdateAnnotations(ctx context.Context, req *api.UpdateTerminalAnnotationsRequest) (*api.UpdateTerminalAnnotationsResponse, error) {358srv.Mux.mu.RLock()359term, ok := srv.Mux.terms[req.Alias]360srv.Mux.mu.RUnlock()361if !ok {362return nil, status.Error(codes.NotFound, "terminal not found")363}364term.UpdateAnnotations(req.Changed, req.Deleted)365return &api.UpdateTerminalAnnotationsResponse{}, nil366}367368369