github.com/telepresenceio/telepresence/v2@v2.20.0-pro.6.0.20240517030216-236ea954e789/pkg/client/cli/intercept/state.go (about)

     1  package intercept
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"runtime"
    12  	"sort"
    13  	"strconv"
    14  	"strings"
    15  
    16  	grpcCodes "google.golang.org/grpc/codes"
    17  	grpcStatus "google.golang.org/grpc/status"
    18  	empty "google.golang.org/protobuf/types/known/emptypb"
    19  	core "k8s.io/api/core/v1"
    20  
    21  	"github.com/datawire/dlib/dexec"
    22  	"github.com/datawire/dlib/dlog"
    23  	"github.com/telepresenceio/telepresence/rpc/v2/connector"
    24  	"github.com/telepresenceio/telepresence/rpc/v2/manager"
    25  	"github.com/telepresenceio/telepresence/v2/pkg/agentconfig"
    26  	"github.com/telepresenceio/telepresence/v2/pkg/client"
    27  	"github.com/telepresenceio/telepresence/v2/pkg/client/cli/daemon"
    28  	"github.com/telepresenceio/telepresence/v2/pkg/client/cli/output"
    29  	"github.com/telepresenceio/telepresence/v2/pkg/client/cli/spinner"
    30  	"github.com/telepresenceio/telepresence/v2/pkg/client/docker"
    31  	"github.com/telepresenceio/telepresence/v2/pkg/client/scout"
    32  	"github.com/telepresenceio/telepresence/v2/pkg/dnet"
    33  	"github.com/telepresenceio/telepresence/v2/pkg/dos"
    34  	"github.com/telepresenceio/telepresence/v2/pkg/errcat"
    35  	"github.com/telepresenceio/telepresence/v2/pkg/iputil"
    36  	"github.com/telepresenceio/telepresence/v2/pkg/proc"
    37  )
    38  
    39  type State interface {
    40  	CreateRequest(context.Context) (*connector.CreateInterceptRequest, error)
    41  	Name() string
    42  	Run(context.Context) (*Info, error)
    43  	RunAndLeave() bool
    44  }
    45  
    46  type state struct {
    47  	*Command
    48  	env           map[string]string
    49  	mountDisabled bool
    50  	mountPoint    string // if non-empty, this the final mount point of a successful mount
    51  	localPort     uint16 // the parsed <local port>
    52  	dockerPort    uint16
    53  	status        *connector.ConnectInfo
    54  	info          *Info // Info from the created intercept
    55  
    56  	// Possibly extended version of the state. Use when calling interface methods.
    57  	self State
    58  }
    59  
    60  func NewState(
    61  	args *Command,
    62  ) State {
    63  	s := &state{
    64  		Command: args,
    65  	}
    66  	s.self = s
    67  	return s
    68  }
    69  
    70  func (s *state) SetSelf(self State) {
    71  	s.self = self
    72  }
    73  
    74  func (s *state) CreateRequest(ctx context.Context) (*connector.CreateInterceptRequest, error) {
    75  	spec := &manager.InterceptSpec{
    76  		Name:    s.Name(),
    77  		Replace: s.Replace,
    78  	}
    79  	ir := &connector.CreateInterceptRequest{
    80  		Spec:         spec,
    81  		ExtendedInfo: s.ExtendedInfo,
    82  	}
    83  
    84  	if s.AgentName == "" {
    85  		// local-only
    86  		s.mountDisabled = true
    87  		return ir, nil
    88  	}
    89  
    90  	if s.ServiceName != "" {
    91  		spec.ServiceName = s.ServiceName
    92  	}
    93  
    94  	spec.Mechanism = s.Mechanism
    95  	spec.MechanismArgs = s.MechanismArgs
    96  	spec.Agent = s.AgentName
    97  	spec.TargetHost = "127.0.0.1"
    98  
    99  	ud := daemon.GetUserClient(ctx)
   100  
   101  	// Parse port into spec based on how it's formatted
   102  	var err error
   103  	s.localPort, s.dockerPort, spec.ServicePortIdentifier, err = parsePort(s.Port, s.DockerRun, ud.Containerized())
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  	spec.TargetPort = int32(s.localPort)
   108  	if iputil.Parse(s.Address) == nil {
   109  		return nil, fmt.Errorf("--address %s is not a valid IP address", s.Address)
   110  	}
   111  	spec.TargetHost = s.Address
   112  
   113  	mountEnabled, mountPoint := s.GetMountPoint()
   114  	if !mountEnabled {
   115  		s.mountDisabled = true
   116  	} else {
   117  		if ud.Containerized() && ir.LocalMountPort == 0 {
   118  			// No use having the remote container actually mount, so let's have it create a bridge
   119  			// to the remote sftp server instead.
   120  			lma, err := dnet.FreePortsTCP(1)
   121  			if err != nil {
   122  				return nil, err
   123  			}
   124  			s.LocalMountPort = uint16(lma[0].Port)
   125  			mountPoint = ""
   126  		}
   127  
   128  		if err = s.checkMountCapability(ctx); err != nil {
   129  			err = fmt.Errorf("remote volume mounts are disabled: %w", err)
   130  			if mountPoint != "" {
   131  				return nil, err
   132  			}
   133  			// Log a warning and disable, but continue
   134  			s.mountDisabled = true
   135  			dlog.Warning(ctx, err)
   136  		}
   137  
   138  		if !s.mountDisabled {
   139  			ir.LocalMountPort = int32(s.LocalMountPort)
   140  			if ir.LocalMountPort == 0 {
   141  				var cwd string
   142  				if cwd, err = os.Getwd(); err != nil {
   143  					return nil, err
   144  				}
   145  				if ir.MountPoint, err = PrepareMount(cwd, mountPoint); err != nil {
   146  					return nil, err
   147  				}
   148  			}
   149  		}
   150  	}
   151  
   152  	for _, toPod := range s.ToPod {
   153  		pp, err := agentconfig.NewPortAndProto(toPod)
   154  		if err != nil {
   155  			return nil, err
   156  		}
   157  		spec.LocalPorts = append(spec.LocalPorts, pp.String())
   158  		if pp.Proto == core.ProtocolTCP {
   159  			// For backward compatibility
   160  			spec.ExtraPorts = append(spec.ExtraPorts, int32(pp.Port))
   161  		}
   162  	}
   163  
   164  	if s.DockerMount != "" {
   165  		if !s.DockerRun {
   166  			return nil, errors.New("--docker-mount must be used together with --docker-run")
   167  		}
   168  		if s.mountDisabled {
   169  			return nil, errors.New("--docker-mount cannot be used with --mount=false")
   170  		}
   171  	}
   172  	return ir, nil
   173  }
   174  
   175  func (s *state) Name() string {
   176  	return s.Command.Name
   177  }
   178  
   179  func (s *state) RunAndLeave() bool {
   180  	return len(s.Cmdline) > 0 || s.DockerRun
   181  }
   182  
   183  func (s *state) Run(ctx context.Context) (*Info, error) {
   184  	ctx = scout.NewReporter(ctx, "cli")
   185  	scout.Start(ctx)
   186  	defer scout.Close(ctx)
   187  
   188  	if !s.RunAndLeave() {
   189  		err := client.WithEnsuredState(ctx, s.create, nil, nil)
   190  		if err != nil {
   191  			return nil, err
   192  		}
   193  		return s.info, nil
   194  	}
   195  
   196  	// start intercept, run command, then leave the intercept
   197  	if s.DockerRun {
   198  		if err := s.prepareDockerRun(docker.EnableClient(ctx)); err != nil {
   199  			return nil, err
   200  		}
   201  	}
   202  	err := client.WithEnsuredState(ctx, s.create, s.runCommand, s.leave)
   203  	if err != nil {
   204  		return nil, err
   205  	}
   206  	return s.info, nil
   207  }
   208  
   209  func (s *state) create(ctx context.Context) (acquired bool, err error) {
   210  	ud := daemon.GetUserClient(ctx)
   211  	s.status, err = ud.Status(ctx, &empty.Empty{})
   212  	if err != nil {
   213  		return false, err
   214  	}
   215  
   216  	// Add whatever metadata we already have to scout
   217  	scout.SetMetadatum(ctx, "service_name", s.AgentName)
   218  	scout.SetMetadatum(ctx, "manager_install_id", s.status.ManagerInstallId)
   219  	scout.SetMetadatum(ctx, "cluster_id", s.status.ClusterId)
   220  	scout.SetMetadatum(ctx, "intercept_mechanism", s.Mechanism)
   221  	scout.SetMetadatum(ctx, "intercept_mechanism_numargs", len(s.MechanismArgs))
   222  
   223  	ir, err := s.self.CreateRequest(ctx)
   224  	if err != nil {
   225  		scout.Report(ctx, "intercept_validation_fail", scout.Entry{Key: "error", Value: err.Error()})
   226  		return false, errcat.NoDaemonLogs.New(err)
   227  	}
   228  
   229  	if ir.MountPoint != "" {
   230  		defer func() {
   231  			if !acquired && runtime.GOOS != "windows" {
   232  				// remove if empty
   233  				_ = os.Remove(ir.MountPoint)
   234  			}
   235  		}()
   236  		s.mountPoint = ir.MountPoint
   237  	}
   238  
   239  	defer func() {
   240  		if err != nil {
   241  			scout.Report(ctx, "intercept_fail", scout.Entry{Key: "error", Value: err.Error()})
   242  		} else {
   243  			scout.Report(ctx, "intercept_success")
   244  		}
   245  	}()
   246  
   247  	// Submit the request
   248  	r, err := ud.CreateIntercept(ctx, ir)
   249  	if err = Result(r, err); err != nil {
   250  		return false, fmt.Errorf("connector.CreateIntercept: %w", err)
   251  	}
   252  
   253  	if s.AgentName == "" {
   254  		// local-only
   255  		return true, nil
   256  	}
   257  	detailedOutput := s.DetailedOutput && s.FormattedOutput
   258  	if !s.Silent && !detailedOutput {
   259  		fmt.Fprintf(dos.Stdout(ctx), "Using %s %s\n", r.WorkloadKind, s.AgentName)
   260  	}
   261  	var intercept *manager.InterceptInfo
   262  
   263  	// Add metadata to scout from InterceptResult
   264  	scout.SetMetadatum(ctx, "service_uid", r.GetServiceUid())
   265  	scout.SetMetadatum(ctx, "workload_kind", r.GetWorkloadKind())
   266  	// Since a user can create an intercept without specifying a namespace
   267  	// (thus using the default in their kubeconfig), we should be getting
   268  	// the namespace from the InterceptResult because that adds the namespace
   269  	// if it wasn't given on the cli by the user
   270  	scout.SetMetadatum(ctx, "service_namespace", r.GetInterceptInfo().GetSpec().GetNamespace())
   271  	intercept = r.InterceptInfo
   272  	scout.SetMetadatum(ctx, "intercept_id", intercept.Id)
   273  
   274  	s.env = intercept.Environment
   275  	if s.env == nil {
   276  		s.env = make(map[string]string)
   277  	}
   278  	s.env["TELEPRESENCE_INTERCEPT_ID"] = intercept.Id
   279  	s.env["TELEPRESENCE_ROOT"] = intercept.ClientMountPoint
   280  	if s.EnvFile != "" {
   281  		if err = s.writeEnvFile(); err != nil {
   282  			return true, err
   283  		}
   284  	}
   285  	if s.EnvJSON != "" {
   286  		if err = s.writeEnvJSON(); err != nil {
   287  			return true, err
   288  		}
   289  	}
   290  
   291  	var volumeMountProblem error
   292  	if ir.LocalMountPort != 0 {
   293  		intercept.PodIp = "127.0.0.1"
   294  		intercept.SftpPort = ir.LocalMountPort
   295  	} else {
   296  		doMount, err := strconv.ParseBool(s.Mount)
   297  		if doMount || err != nil {
   298  			volumeMountProblem = s.checkMountCapability(ctx)
   299  		}
   300  	}
   301  	mountError := ""
   302  	if volumeMountProblem != nil {
   303  		mountError = volumeMountProblem.Error()
   304  	}
   305  	s.info = NewInfo(ctx, intercept, mountError)
   306  	if !s.Silent {
   307  		if detailedOutput {
   308  			output.Object(ctx, s.info, true)
   309  		} else {
   310  			out := dos.Stdout(ctx)
   311  			_, _ = s.info.WriteTo(out)
   312  			_, _ = fmt.Fprintln(out)
   313  		}
   314  	}
   315  	return true, nil
   316  }
   317  
   318  func (s *state) leave(ctx context.Context) error {
   319  	n := strings.TrimSpace(s.Name())
   320  	dlog.Debugf(ctx, "Leaving intercept %s", n)
   321  	r, err := daemon.GetUserClient(ctx).RemoveIntercept(ctx, &manager.RemoveInterceptRequest2{Name: n})
   322  	if err != nil && grpcStatus.Code(err) == grpcCodes.Canceled {
   323  		// Deactivation was caused by a disconnect
   324  		err = nil
   325  	}
   326  	if err != nil {
   327  		dlog.Errorf(ctx, "Leaving intercept ended with error %v", err)
   328  	}
   329  	return Result(r, err)
   330  }
   331  
   332  func (s *state) runCommand(ctx context.Context) error {
   333  	// start the interceptor process
   334  	ud := daemon.GetUserClient(ctx)
   335  	if !s.DockerRun {
   336  		cmd, err := proc.Start(ctx, s.env, s.Cmdline[0], s.Cmdline[1:]...)
   337  		if err != nil {
   338  			dlog.Errorf(ctx, "error interceptor starting process: %v", err)
   339  			return errcat.NoDaemonLogs.New(err)
   340  		}
   341  		if cmd == nil {
   342  			return nil
   343  		}
   344  		if err = s.addInterceptorToDaemon(ctx, cmd, ""); err != nil {
   345  			return err
   346  		}
   347  
   348  		// The external command will not output anything to the logs. An error here
   349  		// is likely caused by the user hitting <ctrl>-C to terminate the process.
   350  		return errcat.NoDaemonLogs.New(proc.Wait(ctx, func() {}, cmd))
   351  	}
   352  
   353  	envFile := s.EnvFile
   354  	if envFile == "" {
   355  		file, err := os.CreateTemp("", "tel-*.env")
   356  		if err != nil {
   357  			return fmt.Errorf("failed to create temporary environment file. %w", err)
   358  		}
   359  		defer os.Remove(file.Name())
   360  
   361  		if err = s.writeEnvToFileAndClose(file); err != nil {
   362  			return err
   363  		}
   364  		envFile = file.Name()
   365  	}
   366  
   367  	// Ensure that the intercept handler is stopped properly if the daemon quits
   368  	procCtx, cancel := context.WithCancel(ctx)
   369  	go func() {
   370  		if err := daemon.CancelWhenRmFromCache(procCtx, cancel, ud.DaemonID.InfoFileName()); err != nil {
   371  			dlog.Error(ctx)
   372  		}
   373  	}()
   374  
   375  	errRdr, errWrt := io.Pipe()
   376  	procCtx = dos.WithStderr(procCtx, errWrt)
   377  	outRdr, outWrt := io.Pipe()
   378  	procCtx = dos.WithStdout(procCtx, outWrt)
   379  
   380  	name, args, err := s.getContainerName(s.Cmdline)
   381  	if err != nil {
   382  		return errcat.User.New(err)
   383  	}
   384  
   385  	spin := spinner.New(ctx, "container "+name)
   386  	spin.Message("starting")
   387  	dr := s.startInDocker(procCtx, name, envFile, args)
   388  	if dr.err == nil {
   389  		dr.err = s.addInterceptorToDaemon(ctx, dr.cmd, dr.name)
   390  		spin.Message("started")
   391  		spin.DoneMsg(s.WaitMessage)
   392  	} else if spin != nil {
   393  		_ = spin.Error(dr.err)
   394  	}
   395  	go func() {
   396  		_, _ = io.Copy(dos.Stdout(ctx), outRdr)
   397  	}()
   398  	go func() {
   399  		_, _ = io.Copy(dos.Stderr(ctx), errRdr)
   400  	}()
   401  
   402  	if err := dr.wait(procCtx); err != nil {
   403  		return spin.Error(err)
   404  	}
   405  	spin.Done()
   406  	return nil
   407  }
   408  
   409  func (s *state) addInterceptorToDaemon(ctx context.Context, cmd *dexec.Cmd, containerName string) error {
   410  	// setup cleanup for the interceptor process
   411  	ior := connector.Interceptor{
   412  		InterceptId:   s.env["TELEPRESENCE_INTERCEPT_ID"],
   413  		Pid:           int32(cmd.Process.Pid),
   414  		ContainerName: containerName,
   415  	}
   416  
   417  	// Send info about the pid and intercept id to the traffic-manager so that it kills
   418  	// the process if it receives a leave of quit call.
   419  	if _, err := daemon.GetUserClient(ctx).AddInterceptor(ctx, &ior); err != nil {
   420  		if grpcStatus.Code(err) == grpcCodes.Canceled {
   421  			// Deactivation was caused by a disconnect
   422  			err = nil
   423  		} else {
   424  			dlog.Errorf(ctx, "error adding process with pid %d as interceptor: %v", ior.Pid, err)
   425  		}
   426  		_ = cmd.Process.Kill()
   427  		return err
   428  	}
   429  	return nil
   430  }
   431  
   432  func (s *state) checkMountCapability(ctx context.Context) error {
   433  	r, err := daemon.GetUserClient(ctx).RemoteMountAvailability(ctx, &empty.Empty{})
   434  	if err != nil {
   435  		return err
   436  	}
   437  	return errcat.FromResult(r)
   438  }
   439  
   440  func (s *state) writeEnvFile() error {
   441  	file, err := os.Create(s.EnvFile)
   442  	if err != nil {
   443  		return errcat.NoDaemonLogs.Newf("failed to create environment file %q: %w", s.EnvFile, err)
   444  	}
   445  	return s.writeEnvToFileAndClose(file)
   446  }
   447  
   448  func (s *state) writeEnvToFileAndClose(file *os.File) (err error) {
   449  	defer file.Close()
   450  	w := bufio.NewWriter(file)
   451  
   452  	keys := make([]string, len(s.env))
   453  	i := 0
   454  	for k := range s.env {
   455  		keys[i] = k
   456  		i++
   457  	}
   458  	sort.Strings(keys)
   459  
   460  	for _, k := range keys {
   461  		if _, err = w.WriteString(k); err != nil {
   462  			return err
   463  		}
   464  		if err = w.WriteByte('='); err != nil {
   465  			return err
   466  		}
   467  		if _, err = w.WriteString(s.env[k]); err != nil {
   468  			return err
   469  		}
   470  		if err = w.WriteByte('\n'); err != nil {
   471  			return err
   472  		}
   473  	}
   474  	return w.Flush()
   475  }
   476  
   477  func (s *state) writeEnvJSON() error {
   478  	data, err := json.MarshalIndent(s.env, "", "  ")
   479  	if err != nil {
   480  		// Creating JSON from a map[string]string should never fail
   481  		panic(err)
   482  	}
   483  	return os.WriteFile(s.EnvJSON, data, 0o644)
   484  }
   485  
   486  // parsePort parses portSpec based on how it's formatted.
   487  func parsePort(portSpec string, dockerRun, remote bool) (local uint16, docker uint16, svcPortId string, err error) {
   488  	portMapping := strings.Split(portSpec, ":")
   489  	portError := func() (uint16, uint16, string, error) {
   490  		if dockerRun && !remote {
   491  			return 0, 0, "", errcat.User.New("port must be of the format --port <local-port>:<container-port>[:<svcPortIdentifier>]")
   492  		}
   493  		return 0, 0, "", errcat.User.New("port must be of the format --port <local-port>[:<svcPortIdentifier>]")
   494  	}
   495  
   496  	if local, err = agentconfig.ParseNumericPort(portMapping[0]); err != nil {
   497  		return portError()
   498  	}
   499  
   500  	switch len(portMapping) {
   501  	case 1:
   502  	case 2:
   503  		p := portMapping[1]
   504  		if dockerRun && !remote {
   505  			if docker, err = agentconfig.ParseNumericPort(p); err != nil {
   506  				return portError()
   507  			}
   508  		} else {
   509  			if err := agentconfig.ValidatePort(p); err != nil {
   510  				return portError()
   511  			}
   512  			svcPortId = p
   513  		}
   514  	case 3:
   515  		if remote && dockerRun {
   516  			return 0, 0, "", errcat.User.New(
   517  				"the format --port <local-port>:<container-port>:<svcPortIdentifier> cannot be used when the daemon runs in a container")
   518  		}
   519  		if !dockerRun {
   520  			return portError()
   521  		}
   522  		if docker, err = agentconfig.ParseNumericPort(portMapping[1]); err != nil {
   523  			return portError()
   524  		}
   525  		svcPortId = portMapping[2]
   526  		if err := agentconfig.ValidatePort(svcPortId); err != nil {
   527  			return portError()
   528  		}
   529  	default:
   530  		return portError()
   531  	}
   532  	if dockerRun && !remote && docker == 0 {
   533  		docker = local
   534  	}
   535  	return local, docker, svcPortId, nil
   536  }