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 }