karmada/pkg/karmadactl/exec.go

385 lines
11 KiB
Go

package karmadactl
import (
"context"
"fmt"
"io"
"net/url"
"time"
dockerterm "github.com/moby/term"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
coreclient "k8s.io/client-go/kubernetes/typed/core/v1"
restclient "k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/cmd/util/podcmd"
"k8s.io/kubectl/pkg/polymorphichelpers"
"k8s.io/kubectl/pkg/scheme"
"k8s.io/kubectl/pkg/util/interrupt"
"k8s.io/kubectl/pkg/util/term"
"github.com/karmada-io/karmada/pkg/karmadactl/options"
)
const (
defaultPodExecTimeout = 60 * time.Second
)
// NewCmdExec new exec command.
func NewCmdExec(karmadaConfig KarmadaConfig, parentCommand string) *cobra.Command {
ioStreams := genericclioptions.IOStreams{In: getIn, Out: getOut, ErrOut: getErr}
o := &ExecOptions{
streamOptions: streamOptions{
IOStreams: ioStreams,
},
Executor: &DefaultRemoteExecutor{},
}
cmd := &cobra.Command{
Use: "exec (POD | TYPE/NAME) [-c CONTAINER] [flags] (-C CLUSTER) -- COMMAND [args...]",
DisableFlagsInUseLine: true,
Short: "Execute a command in a container in a cluster",
Long: "Execute a command in a container in a cluster",
Example: execExample(parentCommand),
RunE: func(cmd *cobra.Command, args []string) error {
argsLenAtDash := cmd.ArgsLenAtDash()
if err := o.Complete(karmadaConfig, args, argsLenAtDash); err != nil {
return err
}
if err := o.Validate(cmd); err != nil {
return err
}
if err := o.Run(); err != nil {
return err
}
return nil
},
}
o.GlobalCommandOptions.AddFlags(cmd.Flags())
cmdutil.AddPodRunningTimeoutFlag(cmd, defaultPodExecTimeout)
cmdutil.AddJsonFilenameFlag(cmd.Flags(), &o.FilenameOptions.Filenames, "to use to exec into the resource")
cmd.Flags().StringVarP(&o.Cluster, "cluster", "C", "", "Specify a member cluster")
cmdutil.AddContainerVarFlags(cmd, &o.ContainerName, o.ContainerName)
cmd.Flags().BoolVarP(&o.Stdin, "stdin", "i", o.Stdin, "Pass stdin to the container")
cmd.Flags().BoolVarP(&o.TTY, "tty", "t", o.TTY, "Stdin is a TTY")
cmd.Flags().BoolVarP(&o.Quiet, "quiet", "q", o.Quiet, "Only print output from the remote session")
return cmd
}
func execExample(parentCommand string) string {
example := `
# Get output from running the 'date' command from pod mypod, using the first container by default in cluster(member1)` + "\n" +
fmt.Sprintf("%s exec mypod -C=member1 -- date", parentCommand) + `
# Get output from running the 'date' command in ruby-container from pod mypod in cluster(member1)` + "\n" +
fmt.Sprintf("%s exec mypod -c ruby-container -C=member1 -- date", parentCommand) + `
# Switch to raw terminal mode; sends stdin to 'bash' in ruby-container from pod mypod in cluster(member1)
# and sends stdout/stderr from 'bash' back to the client` + "\n" +
fmt.Sprintf("%s exec mypod -c ruby-container -C=member1 -i -t -- bash -il", parentCommand) + `
# Get output from running 'date' command from the first pod of the deployment mydeployment, using the first container by default in cluster(member1)` + "\n" +
fmt.Sprintf("%s exec deploy/mydeployment -C=member1 -- date", parentCommand) + `
# Get output from running 'date' command from the first pod of the service myservice, using the first container by default in cluster(member1)` + "\n" +
fmt.Sprintf("%s exec svc/myservice -C=member1 -- date", parentCommand)
return example
}
// RemoteExecutor defines the interface accepted by the Exec command - provided for test stubbing
type RemoteExecutor interface {
Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error
}
// DefaultRemoteExecutor is the standard implementation of remote command execution
type DefaultRemoteExecutor struct{}
// Execute implement RemoteExecutor interface
func (*DefaultRemoteExecutor) Execute(method string, url *url.URL, config *restclient.Config, stdin io.Reader, stdout, stderr io.Writer, tty bool, terminalSizeQueue remotecommand.TerminalSizeQueue) error {
exec, err := remotecommand.NewSPDYExecutor(config, method, url)
if err != nil {
return err
}
return exec.Stream(remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
Tty: tty,
TerminalSizeQueue: terminalSizeQueue,
})
}
type streamOptions struct {
Cluster string
Namespace string
PodName string
ContainerName string
Stdin bool
TTY bool
// minimize unnecessary output
Quiet bool
// InterruptParent, if set, is used to handle interrupts while attached
InterruptParent *interrupt.Handler
genericclioptions.IOStreams
// for testing
overrideStreams func() (io.ReadCloser, io.Writer, io.Writer)
isTerminalIn func(t term.TTY) bool
}
// ExecOptions declare the arguments accepted by the Exec command
type ExecOptions struct {
// global flags
options.GlobalCommandOptions
streamOptions
resource.FilenameOptions
ResourceName string
Command []string
EnforceNamespace bool
Builder func() *resource.Builder
ExecutablePodFn polymorphichelpers.AttachablePodForObjectFunc
restClientGetter genericclioptions.RESTClientGetter
Pod *corev1.Pod
Executor RemoteExecutor
PodClient coreclient.PodsGetter
GetPodTimeout time.Duration
Config *restclient.Config
}
// Complete verifies command line arguments and loads data from the command environment
func (p *ExecOptions) Complete(karmadaConfig KarmadaConfig, argsIn []string, argsLenAtDash int) error {
if len(argsIn) > 0 && argsLenAtDash != 0 {
p.ResourceName = argsIn[0]
}
if argsLenAtDash > -1 {
p.Command = argsIn[argsLenAtDash:]
} else if len(argsIn) > 1 {
if !p.Quiet {
fmt.Fprint(p.ErrOut, "kubectl exec [POD] [COMMAND] is DEPRECATED and will be removed in a future version. Use kubectl exec [POD] -- [COMMAND] instead.\n")
}
p.Command = argsIn[1:]
} else if len(argsIn) > 0 && len(p.FilenameOptions.Filenames) != 0 {
if !p.Quiet {
fmt.Fprint(p.ErrOut, "kubectl exec [POD] [COMMAND] is DEPRECATED and will be removed in a future version. Use kubectl exec [POD] -- [COMMAND] instead.\n")
}
p.Command = argsIn[0:]
p.ResourceName = ""
}
var err error
if len(p.Cluster) == 0 {
return fmt.Errorf("must specify a cluster")
}
karmadaRestConfig, err := karmadaConfig.GetRestConfig(p.KarmadaContext, p.KubeConfig)
if err != nil {
return fmt.Errorf("failed to get control plane rest config. context: %s, kube-config: %s, error: %v",
p.KarmadaContext, p.KubeConfig, err)
}
clusterInfo, err := getClusterInfo(karmadaRestConfig, p.Cluster, p.KubeConfig, p.KarmadaContext)
if err != nil {
return err
}
f := getFactory(p.Cluster, clusterInfo)
p.Namespace, p.EnforceNamespace, err = f.ToRawKubeConfigLoader().Namespace()
if err != nil {
return err
}
p.ExecutablePodFn = polymorphichelpers.AttachablePodForObjectFn
p.Builder = f.NewBuilder
p.restClientGetter = f
p.Config, err = f.ToRESTConfig()
if err != nil {
return err
}
clientset, err := f.KubernetesClientSet()
if err != nil {
return err
}
p.PodClient = clientset.CoreV1()
return nil
}
// Validate checks that the provided exec options are specified.
func (p *ExecOptions) Validate(cmd *cobra.Command) error {
if len(p.PodName) == 0 && len(p.ResourceName) == 0 && len(p.FilenameOptions.Filenames) == 0 {
return fmt.Errorf("pod, type/name or --filename must be specified")
}
if len(p.Command) == 0 {
return fmt.Errorf("you must specify at least one command for the container")
}
if p.Out == nil || p.ErrOut == nil {
return fmt.Errorf("both output and error output must be provided")
}
var err error
p.GetPodTimeout, err = cmdutil.GetPodRunningTimeoutFlag(cmd)
if err != nil {
return cmdutil.UsageErrorf(cmd, err.Error())
}
return nil
}
func (o *streamOptions) setupTTY() term.TTY {
t := term.TTY{
Parent: o.InterruptParent,
Out: o.Out,
}
if !o.Stdin {
// need to nil out o.In to make sure we don't create a stream for stdin
o.In = nil
o.TTY = false
return t
}
t.In = o.In
if !o.TTY {
return t
}
if o.isTerminalIn == nil {
o.isTerminalIn = func(tty term.TTY) bool {
return tty.IsTerminalIn()
}
}
if !o.isTerminalIn(t) {
o.TTY = false
if !o.Quiet && o.ErrOut != nil {
fmt.Fprintln(o.ErrOut, "Unable to use a TTY - input is not a terminal or the right kind of file")
}
return t
}
// if we get to here, the user wants to attach stdin, wants a TTY, and o.In is a terminal, so we
// can safely set t.Raw to true
t.Raw = true
if o.overrideStreams == nil {
// use dockerterm.StdStreams() to get the right I/O handles on Windows
o.overrideStreams = dockerterm.StdStreams
}
stdin, stdout, _ := o.overrideStreams()
o.In = stdin
t.In = stdin
if o.Out != nil {
o.Out = stdout
t.Out = stdout
}
return t
}
// Run executes a validated remote execution against a pod.
func (p *ExecOptions) Run() error {
var err error
// we still need legacy pod getter when PodName in ExecOptions struct is provided,
// since there are any other command run this function by providing Podname with PodsGetter
// and without resource builder, eg: `kubectl cp`.
if len(p.PodName) != 0 {
p.Pod, err = p.PodClient.Pods(p.Namespace).Get(context.TODO(), p.PodName, metav1.GetOptions{})
if err != nil {
return err
}
} else {
builder := p.Builder().
WithScheme(scheme.Scheme, scheme.Scheme.PrioritizedVersionsAllGroups()...).
FilenameParam(p.EnforceNamespace, &p.FilenameOptions).
NamespaceParam(p.Namespace).DefaultNamespace()
if len(p.ResourceName) > 0 {
builder = builder.ResourceNames("pods", p.ResourceName)
}
obj, err := builder.Do().Object()
if err != nil {
return err
}
p.Pod, err = p.ExecutablePodFn(p.restClientGetter, obj, p.GetPodTimeout)
if err != nil {
return err
}
}
pod := p.Pod
if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
return fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase)
}
containerName := p.ContainerName
if len(containerName) == 0 {
container, err := podcmd.FindOrDefaultContainerByName(pod, containerName, p.Quiet, p.ErrOut)
if err != nil {
return err
}
containerName = container.Name
}
// ensure we can recover the terminal while attached
t := p.setupTTY()
var sizeQueue remotecommand.TerminalSizeQueue
if t.Raw {
// this call spawns a goroutine to monitor/update the terminal size
sizeQueue = t.MonitorSize(t.GetSize())
// unset p.Err if it was previously set because both stdout and stderr go over p.Out when tty is
// true
p.ErrOut = nil
}
fn := func() error {
restClient, err := restclient.RESTClientFor(p.Config)
if err != nil {
return err
}
// TODO: consider abstracting into a client invocation or client helper
req := restClient.Post().
Resource("pods").
Name(pod.Name).
Namespace(pod.Namespace).
SubResource("exec")
req.VersionedParams(&corev1.PodExecOptions{
Container: containerName,
Command: p.Command,
Stdin: p.Stdin,
Stdout: p.Out != nil,
Stderr: p.ErrOut != nil,
TTY: t.Raw,
}, scheme.ParameterCodec)
return p.Executor.Execute("POST", req.URL(), p.Config, p.In, p.Out, p.ErrOut, t.Raw, sizeQueue)
}
if err := t.Safe(fn); err != nil {
return err
}
return nil
}