From 8b253d0c8a9e18623d226f3a6cd67a5b46a159f3 Mon Sep 17 00:00:00 2001 From: Girish Ramnani Date: Fri, 20 Sep 2019 22:21:50 +0530 Subject: [PATCH] odo experimental debug port-forward command (#2043) * add port-forward code from oc * added command for debug and experimental * integrating with odo - in progress * add build portfoward request in occlient * resolved all references * extract the port forwarder into a different package * some cleanup * integrating the portforward command in odo cli * removed i18n * add debug port support in the config * use the debug port in portforward cli * resolved failing unit test * added debug_port in the env var list * remove the pod timeout option * resolved long desc for port forward * sending corrupt debug port * better logging * add tests for odo debug * minor cleanup * removed focus * resolved failing tests * added comments * small error in tests * removed experimental, changed the tests and add warning for tech preview * added logging for debugging * added more comments * removed the port forward interface * use init:0.12.0 bootstrap image * made port forwarder more independent and added comment * major refactor to resolve mrinal's comments * moved logging * doc typo * doc typo --- .travis.yml | 3 +- Makefile | 5 + docs/getting-started.adoc | 67 ++++++++++ pkg/component/component.go | 36 ++++-- pkg/config/config.go | 24 ++++ pkg/config/config_test.go | 2 +- pkg/debug/portforward.go | 82 ++++++++++++ pkg/occlient/occlient.go | 13 +- pkg/odo/cli/cli.go | 3 + pkg/odo/cli/config/set.go | 11 +- pkg/odo/cli/config/unset.go | 3 +- pkg/odo/cli/config/view.go | 1 + pkg/odo/cli/debug/debug.go | 31 +++++ pkg/odo/cli/debug/portforward.go | 122 ++++++++++++++++++ .../openshiftci-presubmit-integrationtests.sh | 1 + tests/helper/helper_http.go | 20 ++- tests/helper/helper_run.go | 9 ++ tests/integration/cmd_app_test.go | 2 +- tests/integration/cmd_debug_test.go | 68 ++++++++++ tests/integration/component.go | 4 +- 20 files changed, 476 insertions(+), 31 deletions(-) create mode 100644 pkg/debug/portforward.go create mode 100644 pkg/odo/cli/debug/debug.go create mode 100644 pkg/odo/cli/debug/portforward.go create mode 100644 tests/integration/cmd_debug_test.go diff --git a/.travis.yml b/.travis.yml index 66a9ef8a0fb..dd86ccf4eb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,7 +40,7 @@ jobs: - <<: *base-test stage: test - name: "preference, config and url command integration tests" + name: "preference, config, url and debug command integration tests" script: - ./scripts/oc-cluster.sh - make bin @@ -48,6 +48,7 @@ jobs: - odo login -u developer - travis_wait make test-cmd-pref-config - travis_wait make test-cmd-url + - travis_wait make test-cmd-debug - odo logout # Run service-catalog e2e tests diff --git a/Makefile b/Makefile index 4a61925f536..2e4106dde57 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,11 @@ test-cmd-watch: ginkgo -v -nodes=$(TEST_EXEC_NODES) -focus="odo watch command tests" \ slowSpecThreshold=$(SLOW_SPEC_THRESHOLD) -randomizeAllSpecs tests/integration/ -timeout $(TIMEOUT) +# Run odo debug command tests +test-cmd-debug: + ginkgo -v -nodes=$(TEST_EXEC_NODES) -focus="odo debug command tests" \ + slowSpecThreshold=$(SLOW_SPEC_THRESHOLD) -randomizeAllSpecs tests/integration/ -timeout $(TIMEOUT) + # Run command's integration tests irrespective of service catalog status in the cluster. # Service, link and login/logout command tests are not the part of this test run .PHONY: test-integration diff --git a/docs/getting-started.adoc b/docs/getting-started.adoc index e46c58cc48c..fcb07e9e282 100644 --- a/docs/getting-started.adoc +++ b/docs/getting-started.adoc @@ -219,3 +219,70 @@ To list all enviroment variables in the current local configuration: ---- odo config view ---- + +== Using debug mode to interactively debug (Tech Preview) + +Odo allows the user to attach a debugger for remotely debugging your application. Currently this feature is only supported by nodejs and java. + +Components created using odo by default run in debug mode (kind of debug mode which doesn't break into debug session unless attached to). This means that a debugger agent is already running on the component side on a specific port. So user just needs to start the port forwarding using +---- +odo debug port-forward +---- +and attach their local debugger (usually bundled in an IDE). + +Diagram to explain how it works - +---- + Nodejs Component + + +-------------------------------------------+ + | | + | | + | node --inspect server.js | + | | + | | + | Runs a debugging agent on port 5858 | + | | + | | + +-----------------------+-------------------+ + ^ + | + | + | + | + | + | ++-------------------+ | +| | Connects to +--------------+------------------+ +| | localhost port | | +| VSCode/ | 5858 | | +| Intellij/ | | Odo debug port-forward | +| Netbeans +-------------------> | +| | | Forwards the localhost 5858 | +| | | port to the component's 5858 | +| | | | +| | | | ++-------------------+ +---------------------------------+ + + User's Machine +---- + +=== Odo provides you ways to configure some of parameters like + +To set remote port on which debugging agent should run: +---- +odo config set DebugPort 9292 +---- +This value gets persisted in the local config and stays same between runs. +Note - A re-deployment is needed for this value to be refected on the component + + +To set the local port which would be port forwarded: +---- +odo debug port-forward --local-port 9292 +---- +This value is not persisted and hence needs to provided whenever the port needs to be changed. + +=== References to attaching debugger for different IDEs + +- link:https://stackify.com/java-remote-debugging/[Java Debugging using CLI and VS Code] +- link:https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_attaching-to-nodejs[NodeJs Debugging using VS Code] \ No newline at end of file diff --git a/pkg/component/component.go b/pkg/component/component.go index bb6aaa5d110..17961af8efc 100644 --- a/pkg/component/component.go +++ b/pkg/component/component.go @@ -343,6 +343,7 @@ func CreateComponent(client *occlient.Client, componentConfig config.LocalConfig cmpSrcRef := componentConfig.GetRef() appName := componentConfig.GetApplication() envVarsList := componentConfig.GetEnvVars() + addDebugPortToEnv(&envVarsList, componentConfig) // create and get the storage to be created/mounted during the component creation storageList := getStorageFromConfig(&componentConfig) @@ -1018,29 +1019,30 @@ func GetComponentSource(client *occlient.Client, componentName string, applicati // Update updates the requested component // Parameters: // client: occlient instance -// componentSettings: Component configuration +// componentConfig: Component configuration // newSource: Location of component source resolved to absolute path // stdout: io pipe to write logs to // Returns: // errors if any -func Update(client *occlient.Client, componentSettings config.LocalConfigInfo, newSource string, stdout io.Writer) error { +func Update(client *occlient.Client, componentConfig config.LocalConfigInfo, newSource string, stdout io.Writer) error { retrievingSpinner := log.Spinner("Retrieving component data") defer retrievingSpinner.End(false) // STEP 1. Create the common Object Meta for updating. - componentName := componentSettings.GetName() - applicationName := componentSettings.GetApplication() - newSourceType := componentSettings.GetSourceType() - newSourceRef := componentSettings.GetRef() - componentImageType := componentSettings.GetType() - cmpPorts := componentSettings.GetPorts() - envVarsList := componentSettings.GetEnvVars() + componentName := componentConfig.GetName() + applicationName := componentConfig.GetApplication() + newSourceType := componentConfig.GetSourceType() + newSourceRef := componentConfig.GetRef() + componentImageType := componentConfig.GetType() + cmpPorts := componentConfig.GetPorts() + envVarsList := componentConfig.GetEnvVars() + addDebugPortToEnv(&envVarsList, componentConfig) // retrieve the list of storages to create/mount and unmount - storageList := getStorageFromConfig(&componentSettings) - storageToMount, storageToUnMount, err := storage.Push(client, storageList, componentSettings.GetName(), componentSettings.GetApplication(), true) + storageList := getStorageFromConfig(&componentConfig) + storageToMount, storageToUnMount, err := storage.Push(client, storageList, componentConfig.GetName(), componentConfig.GetApplication(), true) if err != nil { return errors.Wrapf(err, "unable to get storage to mount and unmount") } @@ -1096,7 +1098,7 @@ func Update(client *occlient.Client, componentSettings config.LocalConfigInfo, n if len(cmpPorts) > 0 { ports, err = util.GetContainerPortsFromStrings(cmpPorts) if err != nil { - return errors.Wrapf(err, "failed to apply component config %+v to component %s", componentSettings, commonObjectMeta.Name) + return errors.Wrapf(err, "failed to apply component config %+v to component %s", componentConfig, commonObjectMeta.Name) } } @@ -1109,7 +1111,7 @@ func Update(client *occlient.Client, componentSettings config.LocalConfigInfo, n // Generate the new DeploymentConfig resourceLimits := occlient.FetchContainerResourceLimits(foundCurrentDCContainer) - resLts, err := occlient.GetResourceRequirementsFromCmpSettings(componentSettings) + resLts, err := occlient.GetResourceRequirementsFromCmpSettings(componentConfig) if err != nil { return errors.Wrap(err, "failed to update component") } @@ -1487,3 +1489,11 @@ func checkIfURLChangesWillBeMade(client *occlient.Client, componentConfig config return false, nil } + +func addDebugPortToEnv(envVarList *config.EnvVarList, componentConfig config.LocalConfigInfo) { + // adding the debug port as an env variable + *envVarList = append(*envVarList, config.EnvVar{ + Name: "DEBUG_PORT", + Value: fmt.Sprint(componentConfig.GetDebugPort()), + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5ed52047132..cc86757b205 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,6 +21,8 @@ const ( configFileName = "config.yaml" localConfigKind = "LocalConfig" localConfigAPIVersion = "odo.openshift.io/v1alpha1" + // DefaultDebugPort is the default port used for debugging on remote pod + DefaultDebugPort = 5858 ) type ComponentStorageSettings struct { @@ -58,6 +60,9 @@ type ComponentSettings struct { MaxMemory *string `yaml:"MaxMemory,omitempty"` + // DebugPort controls the port used by the pod to run the debugging agent on + DebugPort *int `yaml:"DebugPort,omitempty"` + Storage *[]ComponentStorageSettings `yaml:"Storage,omitempty"` // Ignore if set to true then odoignore file should be considered @@ -213,6 +218,12 @@ func (lci *LocalConfigInfo) SetConfiguration(parameter string, value interface{} case "memory": lci.componentSettings.MaxMemory = &strValue lci.componentSettings.MinMemory = &strValue + case "debugport": + dbgPort, err := strconv.Atoi(strValue) + if err != nil { + return err + } + lci.componentSettings.DebugPort = &dbgPort case "ignore": val, err := strconv.ParseBool(strings.ToLower(strValue)) if err != nil { @@ -438,6 +449,14 @@ func (lc *LocalConfig) GetMaxMemory() string { return *lc.componentSettings.MaxMemory } +// GetDebugPort returns the DebugPort, returns default if nil +func (lc *LocalConfig) GetDebugPort() int { + if lc.componentSettings.DebugPort == nil { + return DefaultDebugPort + } + return *lc.componentSettings.DebugPort +} + // GetIgnore returns the Ignore, returns default if nil func (lc *LocalConfig) GetIgnore() bool { if lc.componentSettings.Ignore == nil { @@ -507,6 +526,10 @@ const ( Memory = "Memory" // MemoryDescription is the description of the setting controlling the min and max memory to same value MemoryDescription = "The minimum and maximum memory a component can consume" + // DebugPort is the port where the application is set to listen for debugger + DebugPort = "DebugPort" + // DebugPortDescription is the description for debug port + DebugPortDescription = "The port on which the debugger will listen on the component" // Ignore is the name of the setting controlling the min memory a component consumes Ignore = "Ignore" // IgnoreDescription is the description of the setting controlling the use of .odoignore file @@ -570,6 +593,7 @@ var ( MinMemory: MinMemoryDescription, MaxMemory: MaxMemoryDescription, Memory: MemoryDescription, + DebugPort: DebugPortDescription, Ignore: IgnoreDescription, MinCPU: MinCPUDescription, MaxCPU: MaxCPUDescription, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index acf4f1cd6d8..23630e7f9f0 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -279,7 +279,7 @@ func TestLocalUnsetConfiguration(t *testing.T) { func TestLowerCaseParameterForLocalParameters(t *testing.T) { expected := map[string]bool{"name": true, "minmemory": true, "ignore": true, "project": true, "application": true, "type": true, "ref": true, "mincpu": true, "cpu": true, "ports": true, "maxmemory": true, - "maxcpu": true, "sourcetype": true, "sourcelocation": true, "memory": true, "storage": true, "url": true} + "maxcpu": true, "sourcetype": true, "sourcelocation": true, "memory": true, "storage": true, "url": true, "debugport": true} actual := util.GetLowerCaseParameters(GetLocallySupportedParameters()) if !reflect.DeepEqual(expected, actual) { t.Errorf("expected '%v', got '%v'", expected, actual) diff --git a/pkg/debug/portforward.go b/pkg/debug/portforward.go new file mode 100644 index 00000000000..d0c6f72a372 --- /dev/null +++ b/pkg/debug/portforward.go @@ -0,0 +1,82 @@ +package debug + +import ( + "github.com/openshift/odo/pkg/occlient" + + componentlabels "github.com/openshift/odo/pkg/component/labels" + + "fmt" + "net/http" + + "github.com/openshift/odo/pkg/log" + "github.com/openshift/odo/pkg/util" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" + k8sgenclioptions "k8s.io/kubernetes/pkg/kubectl/genericclioptions" +) + +// DefaultPortForwarder implements the SPDY based port forwarder +type DefaultPortForwarder struct { + client *occlient.Client + k8sgenclioptions.IOStreams + componentName string + appName string +} + +func NewDefaultPortForwarder(componentName, appName string, client *occlient.Client, streams k8sgenclioptions.IOStreams) *DefaultPortForwarder { + return &DefaultPortForwarder{ + client: client, + IOStreams: streams, + componentName: componentName, + appName: appName, + } +} + +// ForwardPorts forwards the port using the url for the remote pod. +// portPair is a pair of port in format "localPort:RemotePort" that is to be forwarded +// stop Chan is used to stop port forwarding +// ready Chan is used to signal failure to the channel receiver +func (f *DefaultPortForwarder) ForwardPorts(portPair string, stopChan, readyChan chan struct{}) error { + conf, err := f.client.KubeConfig.ClientConfig() + if err != nil { + return err + } + + pod, err := f.getPodUsingComponentName() + if err != nil { + return err + } + + if pod.Status.Phase != corev1.PodRunning { + return fmt.Errorf("unable to forward port because pod is not running. Current status=%v", pod.Status.Phase) + } + + transport, upgrader, err := spdy.RoundTripperFor(conf) + if err != nil { + return err + } + req := f.client.BuildPortForwardReq(pod.Name) + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) + fw, err := portforward.New(dialer, []string{portPair}, stopChan, readyChan, f.Out, f.ErrOut) + if err != nil { + return err + } + log.Info("Started port forwarding at ports -", portPair) + return fw.ForwardPorts() +} + +func (f *DefaultPortForwarder) getPodUsingComponentName() (*corev1.Pod, error) { + componentLabels := componentlabels.GetLabels(f.componentName, f.appName, false) + componentSelector := util.ConvertLabelsToSelector(componentLabels) + dc, err := f.client.GetOneDeploymentConfigFromSelector(componentSelector) + if err != nil { + return nil, errors.Wrap(err, "unable to get deployment for component") + } + // Find Pod for component + podSelector := fmt.Sprintf("deploymentconfig=%s", dc.Name) + + return f.client.GetOnePodFromSelector(podSelector) +} diff --git a/pkg/occlient/occlient.go b/pkg/occlient/occlient.go index 239abffd208..7165728949a 100644 --- a/pkg/occlient/occlient.go +++ b/pkg/occlient/occlient.go @@ -58,6 +58,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/remotecommand" "k8s.io/client-go/util/retry" @@ -95,7 +96,7 @@ const ( // Default Image that will be used containing the supervisord binary and assembly scripts // use getBoostrapperImage() function instead of this variable - defaultBootstrapperImage = "quay.io/openshiftdo/init:0.11.0" + defaultBootstrapperImage = "quay.io/openshiftdo/init:0.12.0" // ENV variable to overwrite image used to bootstrap SupervisorD in S2I builder Image bootstrapperImageEnvName = "ODO_BOOTSTRAPPER_IMAGE" @@ -3016,6 +3017,16 @@ func (c *Client) ExecCMDInContainer(podName string, cmd []string, stdout io.Writ return nil } +// BuildPortForwardReq builds a port forward request +func (c *Client) BuildPortForwardReq(podName string) *rest.Request { + return c.kubeClient.CoreV1().RESTClient(). + Post(). + Resource("pods"). + Namespace(c.Namespace). + Name(podName). + SubResource("portforward") +} + // GetVolumeMountsFromDC returns a list of all volume mounts in the given DC func (c *Client) GetVolumeMountsFromDC(dc *appsv1.DeploymentConfig) []corev1.VolumeMount { var volumeMounts []corev1.VolumeMount diff --git a/pkg/odo/cli/cli.go b/pkg/odo/cli/cli.go index 50730f2ff92..2cb88718d05 100644 --- a/pkg/odo/cli/cli.go +++ b/pkg/odo/cli/cli.go @@ -9,12 +9,14 @@ import ( "github.com/openshift/odo/pkg/odo/cli/catalog" "github.com/openshift/odo/pkg/odo/cli/component" "github.com/openshift/odo/pkg/odo/cli/config" + "github.com/openshift/odo/pkg/odo/cli/debug" "github.com/openshift/odo/pkg/odo/cli/login" "github.com/openshift/odo/pkg/odo/cli/logout" "github.com/openshift/odo/pkg/odo/cli/preference" "github.com/openshift/odo/pkg/odo/cli/project" "github.com/openshift/odo/pkg/odo/cli/service" "github.com/openshift/odo/pkg/odo/cli/storage" + "github.com/openshift/odo/pkg/odo/cli/url" "github.com/openshift/odo/pkg/odo/cli/utils" "github.com/openshift/odo/pkg/odo/cli/version" @@ -133,6 +135,7 @@ func NewCmdOdo(name, fullName string) *cobra.Command { version.NewCmdVersion(version.RecommendedCommandName, util.GetFullName(fullName, version.RecommendedCommandName)), config.NewCmdConfiguration(config.RecommendedCommandName, util.GetFullName(fullName, config.RecommendedCommandName)), preference.NewCmdPreference(preference.RecommendedCommandName, util.GetFullName(fullName, preference.RecommendedCommandName)), + debug.NewCmdDebug(debug.RecommendedCommandName, util.GetFullName(fullName, debug.RecommendedCommandName)), ) odoutil.VisitCommands(rootCmd, reconfigureCmdWithSubcmd) diff --git a/pkg/odo/cli/config/set.go b/pkg/odo/cli/config/set.go index 9f563933f4c..bb803a99819 100644 --- a/pkg/odo/cli/config/set.go +++ b/pkg/odo/cli/config/set.go @@ -28,10 +28,11 @@ var ( %[1]s %[4]s 50M %[1]s %[5]s 500M %[1]s %[6]s 250M - %[1]s %[7]s false - %[1]s %[8]s 0.5 - %[1]s %[9]s 2 - %[1]s %[10]s 1 + %[1]s %[7]s 4040 + %[1]s %[8]s false + %[1]s %[9]s 0.5 + %[1]s %[10]s 2 + %[1]s %[11]s 1 # Set a env variable in the local config %[1]s --env KAFKA_HOST=kafka --env KAFKA_PORT=6639 @@ -127,7 +128,7 @@ func NewCmdSet(name, fullName string) *cobra.Command { Short: "Set a value in odo config file", Long: fmt.Sprintf(setLongDesc, config.FormatLocallySupportedParameters()), Example: fmt.Sprintf(fmt.Sprint("\n", setExample), fullName, config.Type, - config.Name, config.MinMemory, config.MaxMemory, config.Memory, config.Ignore, config.MinCPU, config.MaxCPU, config.CPU), + config.Name, config.MinMemory, config.MaxMemory, config.Memory, config.DebugPort, config.Ignore, config.MinCPU, config.MaxCPU, config.CPU), Args: func(cmd *cobra.Command, args []string) error { if o.envArray != nil { // no args are needed diff --git a/pkg/odo/cli/config/unset.go b/pkg/odo/cli/config/unset.go index 58565e6c681..743905b1a5a 100644 --- a/pkg/odo/cli/config/unset.go +++ b/pkg/odo/cli/config/unset.go @@ -34,6 +34,7 @@ var ( %[1]s %[8]s %[1]s %[9]s %[1]s %[10]s + %[1]s %[11]s # Unset a env variable in the local config %[1]s --env KAFKA_HOST --env KAFKA_PORT @@ -118,7 +119,7 @@ func NewCmdUnset(name, fullName string) *cobra.Command { Short: "Unset a value in odo config file", Long: fmt.Sprintf(unsetLongDesc, config.FormatLocallySupportedParameters()), Example: fmt.Sprintf(fmt.Sprint("\n", unsetExample), fullName, - config.Type, config.Name, config.MinMemory, config.MaxMemory, config.Memory, config.Ignore, config.MinCPU, config.MaxCPU, config.CPU), + config.Type, config.Name, config.MinMemory, config.MaxMemory, config.Memory, config.DebugPort, config.Ignore, config.MinCPU, config.MaxCPU, config.CPU), Args: func(cmd *cobra.Command, args []string) error { if o.envArray != nil { // no args are needed diff --git a/pkg/odo/cli/config/view.go b/pkg/odo/cli/config/view.go index 72dc2dcdfc1..e0b8006edae 100644 --- a/pkg/odo/cli/config/view.go +++ b/pkg/odo/cli/config/view.go @@ -81,6 +81,7 @@ func (o *ViewOptions) Run() (err error) { fmt.Fprintln(w, "Name", "\t", showBlankIfNil(cs.Name)) fmt.Fprintln(w, "MinMemory", "\t", showBlankIfNil(cs.MinMemory)) fmt.Fprintln(w, "MaxMemory", "\t", showBlankIfNil(cs.MaxMemory)) + fmt.Fprintln(w, "DebugPort", "\t", showBlankIfNil(cs.DebugPort)) fmt.Fprintln(w, "Ignore", "\t", showBlankIfNil(cs.Ignore)) fmt.Fprintln(w, "MinCPU", "\t", showBlankIfNil(cs.MinCPU)) fmt.Fprintln(w, "MaxCPU", "\t", showBlankIfNil(cs.MaxCPU)) diff --git a/pkg/odo/cli/debug/debug.go b/pkg/odo/cli/debug/debug.go new file mode 100644 index 00000000000..b95522184aa --- /dev/null +++ b/pkg/odo/cli/debug/debug.go @@ -0,0 +1,31 @@ +package debug + +import ( + "github.com/openshift/odo/pkg/odo/util" + "github.com/spf13/cobra" +) + +// RecommendedCommandName is the recommended debug command name +const RecommendedCommandName = "debug" + +var DebugLongDesc = `Warning - Debug is currently in tech preview and hence is subject to change in future. + +Debug allows you to remotely debug you application` + +func NewCmdDebug(name, fullName string) *cobra.Command { + + portforwardCmd := NewCmdPortForward(portforwardCommandName, util.GetFullName(fullName, portforwardCommandName)) + + debugCmd := &cobra.Command{ + Use: name, + Short: "Debug commands", + Long: DebugLongDesc, + Aliases: []string{"d"}, + } + + debugCmd.SetUsageTemplate(util.CmdUsageTemplate) + debugCmd.AddCommand(portforwardCmd) + debugCmd.Annotations = map[string]string{"command": "main"} + + return debugCmd +} diff --git a/pkg/odo/cli/debug/portforward.go b/pkg/odo/cli/debug/portforward.go new file mode 100644 index 00000000000..9aa3d8da837 --- /dev/null +++ b/pkg/odo/cli/debug/portforward.go @@ -0,0 +1,122 @@ +package debug + +import ( + "fmt" + "os" + "os/signal" + + "github.com/openshift/odo/pkg/config" + "github.com/openshift/odo/pkg/debug" + "github.com/openshift/odo/pkg/odo/genericclioptions" + + "github.com/spf13/cobra" + + "k8s.io/kubernetes/pkg/kubectl/cmd/templates" + k8sgenclioptions "k8s.io/kubernetes/pkg/kubectl/genericclioptions" +) + +// PortForwardOptions contains all the options for running the port-forward cli command. +type PortForwardOptions struct { + Namespace string + // PortPair is the combination of local and remote port in the format "local:remote" + PortPair string + + localPort int + contextDir string + + PortForwarder *debug.DefaultPortForwarder + // StopChannel is used to stop port forwarding + StopChannel chan struct{} + // ReadChannel is used to receive status of port forwarding ( ready or not ready ) + ReadyChannel chan struct{} + *genericclioptions.Context + localConfigInfo *config.LocalConfigInfo +} + +var ( + portforwardLong = templates.LongDesc(` + Forward a local port to a remote port on the pod where the application is listening for a debugger. + + By default the local port and the remote port will be same. To change the local port use can use --local-port argument and to change the remote port use "odo config set DebugPort " + `) + + portforwardExample = templates.Examples(` + # Listen on default port and forwarding to the default port in the pod + odo debug port-forward + + # Listen on the 5000 port locally, forwarding to default port in the pod + odo debug port-forward --local-port 5000 + + `) +) + +const ( + portforwardCommandName = "port-forward" +) + +func NewPortForwardOptions() *PortForwardOptions { + return &PortForwardOptions{} +} + +// Complete completes all the required options for port-forward cmd. +func (o *PortForwardOptions) Complete(name string, cmd *cobra.Command, args []string) (err error) { + + o.Context = genericclioptions.NewContext(cmd) + cfg, err := config.NewLocalConfigInfo(o.contextDir) + o.localConfigInfo = cfg + + remotePort := cfg.GetDebugPort() + o.PortPair = fmt.Sprintf("%d:%d", o.localPort, remotePort) + + // Using Discard streams because nothing important is logged + o.PortForwarder = debug.NewDefaultPortForwarder(cfg.GetName(), cfg.GetApplication(), o.Client, k8sgenclioptions.NewTestIOStreamsDiscard()) + + o.StopChannel = make(chan struct{}, 1) + o.ReadyChannel = make(chan struct{}) + return nil +} + +// Validate validates all the required options for port-forward cmd. +func (o PortForwardOptions) Validate() error { + + if len(o.PortPair) < 1 { + return fmt.Errorf("ports cannot be empty") + } + return nil +} + +// Run implements all the necessary functionality for port-forward cmd. +func (o PortForwardOptions) Run() error { + + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt) + defer signal.Stop(signals) + + go func() { + <-signals + if o.StopChannel != nil { + close(o.StopChannel) + } + }() + + return o.PortForwarder.ForwardPorts(o.PortPair, o.StopChannel, o.ReadyChannel) +} + +// NewCmdPortForward implements the port-forward odo command +func NewCmdPortForward(name, fullName string) *cobra.Command { + + opts := NewPortForwardOptions() + cmd := &cobra.Command{ + Use: name, + Short: "Forward one or more local ports to a pod", + Long: portforwardLong, + Example: portforwardExample, + Run: func(cmd *cobra.Command, args []string) { + genericclioptions.GenericRun(opts, cmd, args) + }, + } + genericclioptions.AddContextFlag(cmd, &opts.contextDir) + cmd.Flags().IntVarP(&opts.localPort, "local-port", "l", config.DefaultDebugPort, "Set the local port") + + return cmd +} diff --git a/scripts/openshiftci-presubmit-integrationtests.sh b/scripts/openshiftci-presubmit-integrationtests.sh index ee8f940c366..fe1ede4a11a 100755 --- a/scripts/openshiftci-presubmit-integrationtests.sh +++ b/scripts/openshiftci-presubmit-integrationtests.sh @@ -20,6 +20,7 @@ make test-cmd-cmp make test-cmd-cmp-sub make test-cmd-pref-config make test-cmd-watch +make test-cmd-debug make test-cmd-storage make test-cmd-app make test-cmd-project diff --git a/tests/helper/helper_http.go b/tests/helper/helper_http.go index e5df17e8275..3b6917e85c3 100644 --- a/tests/helper/helper_http.go +++ b/tests/helper/helper_http.go @@ -8,12 +8,11 @@ import ( "time" . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" ) -// HttpWaitFor periodically (every interval) calls GET to given url -// ends when result response contains match string, or after the maxRetry -func HttpWaitFor(url string, match string, maxRetry int, interval int) { +// HttpWaitForWithStatus periodically (every interval) calls GET to given url +// ends when result response contains match string and status code, or after the maxRetry +func HttpWaitForWithStatus(url string, match string, maxRetry int, interval int, expectedCode int) { fmt.Fprintf(GinkgoWriter, "Checking %s, for %s\n", url, match) var body []byte @@ -25,10 +24,13 @@ func HttpWaitFor(url string, match string, maxRetry int, interval int) { // gosec:G107 -> This is safe since it's just used for testing. resp, err := http.Get(url) if err != nil { - Expect(err).NotTo(HaveOccurred()) + // we log the error and sleep again because this could mean the component is not up yet + fmt.Fprintln(GinkgoWriter, "error while requesting:", err.Error()) + time.Sleep(time.Duration(interval) * time.Second) + continue } defer resp.Body.Close() - if resp.StatusCode == 200 { + if resp.StatusCode == expectedCode { body, _ = ioutil.ReadAll(resp.Body) if strings.Contains(string(body), match) { return @@ -40,3 +42,9 @@ func HttpWaitFor(url string, match string, maxRetry int, interval int) { fmt.Fprintf(GinkgoWriter, "Last output from %s: %s\n", url, string(body)) Fail(fmt.Sprintf("Failed after %d retries. Content in %s doesn't include '%s'.", maxRetry, url, match)) } + +// HttpWaitFor periodically (every interval) calls GET to given url +// ends when a 200 HTTP result response contains match string, or after the maxRetry +func HttpWaitFor(url string, match string, maxRetry int, interval int) { + HttpWaitForWithStatus(url, match, maxRetry, interval, 200) +} diff --git a/tests/helper/helper_run.go b/tests/helper/helper_run.go index e4bbf01296c..4f2a8ac5028 100644 --- a/tests/helper/helper_run.go +++ b/tests/helper/helper_run.go @@ -4,6 +4,7 @@ import ( "fmt" "os/exec" "path/filepath" + "time" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -33,6 +34,14 @@ func CmdShouldPass(program string, args ...string) string { return string(session.Wait().Out.Contents()) } +// CmdShouldRunWithTimeout waits for a certain duration and then returns stdout +func CmdShouldRunWithTimeout(timeout time.Duration, program string, args ...string) string { + session := CmdRunner(program, args...) + time.Sleep(timeout) + session.Terminate() + return string(session.Out.Contents()) +} + // CmdShouldFail returns stderr if command fails func CmdShouldFail(program string, args ...string) string { session := CmdRunner(program, args...) diff --git a/tests/integration/cmd_app_test.go b/tests/integration/cmd_app_test.go index d391fdabbe7..cb546eed081 100644 --- a/tests/integration/cmd_app_test.go +++ b/tests/integration/cmd_app_test.go @@ -82,7 +82,7 @@ var _ = Describe("odo app command tests", func() { } else { sourcePath = "file://./" } - desiredCompListJSON := fmt.Sprintf(`{"kind":"List","apiVersion":"odo.openshift.io/v1alpha1","metadata":{},"items":[{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"nodejs","creationTimestamp":null, "namespace":"%s"},"spec":{"type":"nodejs","app":"app","source":"%s"},"status":{"state":"Pushed"}}]}`, project, sourcePath) + desiredCompListJSON := fmt.Sprintf(`{"kind":"List","apiVersion":"odo.openshift.io/v1alpha1","metadata":{},"items":[{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"nodejs","creationTimestamp":null, "namespace":"%s"},"spec":{"type":"nodejs","app":"app","source":"%s","env":[{"name":"DEBUG_PORT","value":"5858"}]},"status":{"state":"Pushed"}}]}`, project, sourcePath) Expect(desiredCompListJSON).Should(MatchJSON(actualCompListJSON)) helper.CmdShouldPass("odo", "app", "describe") diff --git a/tests/integration/cmd_debug_test.go b/tests/integration/cmd_debug_test.go new file mode 100644 index 00000000000..9cb24d4c969 --- /dev/null +++ b/tests/integration/cmd_debug_test.go @@ -0,0 +1,68 @@ +package integration + +import ( + "os" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/openshift/odo/tests/helper" +) + +var _ = Describe("odo debug command tests", func() { + var project string + var context string + + // Setup up state for each test spec + // create new project (not set as active) and new context directory for each test spec + // This is before every spec (It) + BeforeEach(func() { + SetDefaultEventuallyTimeout(10 * time.Minute) + SetDefaultConsistentlyDuration(30 * time.Second) + context = helper.CreateNewContext() + project = helper.CreateRandProject() + os.Setenv("GLOBALODOCONFIG", filepath.Join(context, "config.yaml")) + }) + + // Clean up after the test + // This is run after every Spec (It) + AfterEach(func() { + helper.DeleteProject(project) + helper.DeleteDir(context) + os.Unsetenv("GLOBALODOCONFIG") + }) + + Context("odo debug on a nodejs component", func() { + It("should expect a ws connection when tried to connect on different debug port locally and remotely", func() { + helper.CopyExample(filepath.Join("source", "nodejs"), context) + helper.CmdShouldPass("odo", "component", "create", "nodejs", "--project", project, "--context", context) + helper.CmdShouldPass("odo", "config", "set", "--force", "DebugPort", "9292", "--context", context) + dbgPort := helper.GetConfigValueWithContext("DebugPort", context) + Expect(dbgPort).To(Equal("9292")) + helper.CmdShouldPass("odo", "push", "--context", context) + go func() { + helper.CmdShouldRunWithTimeout(60*time.Second, "odo", "debug", "port-forward", "--local-port", "5050", "--context", context) + }() + + // 400 response expected because the endpoint expects a websocket request and we are doing a HTTP GET + // We are just using this to validate if nodejs agent is listening on the other side + helper.HttpWaitForWithStatus("http://localhost:5050", "WebSockets request was expected", 12, 5, 400) + }) + + It("should expect a ws connection when tried to connect on default debug port locally", func() { + helper.CopyExample(filepath.Join("source", "nodejs"), context) + helper.CmdShouldPass("odo", "component", "create", "nodejs", "--project", project, "--context", context) + helper.CmdShouldPass("odo", "push", "--context", context) + go func() { + helper.CmdShouldRunWithTimeout(60*time.Second, "odo", "debug", "port-forward", "--context", context) + }() + + // 400 response expected because the endpoint expects a websocket request and we are doing a HTTP GET + // We are just using this to validate if nodejs agent is listening on the other side + helper.HttpWaitForWithStatus("http://localhost:5858", "WebSockets request was expected", 12, 5, 400) + }) + + }) +}) diff --git a/tests/integration/component.go b/tests/integration/component.go index 06f21d39e2a..83622e58842 100644 --- a/tests/integration/component.go +++ b/tests/integration/component.go @@ -127,7 +127,7 @@ func componentTests(args ...string) { cmpList := helper.CmdShouldPass("odo", append(args, "list", "--project", project)...) Expect(cmpList).To(ContainSubstring("cmp-git")) actualCompListJSON := helper.CmdShouldPass("odo", append(args, "list", "--project", project, "-o", "json")...) - desiredCompListJSON := fmt.Sprintf(`{"kind":"List","apiVersion":"odo.openshift.io/v1alpha1","metadata":{},"items":[{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"cmp-git","namespace":"%s","creationTimestamp":null},"spec":{"app":"testing","type":"nodejs","source":"https://github.com/openshift/nodejs-ex"},"status":{"state":"Pushed"}}]}`, project) + desiredCompListJSON := fmt.Sprintf(`{"kind":"List","apiVersion":"odo.openshift.io/v1alpha1","metadata":{},"items":[{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"cmp-git","namespace":"%s","creationTimestamp":null},"spec":{"app":"testing","type":"nodejs","source":"https://github.com/openshift/nodejs-ex","env":[{"name":"DEBUG_PORT","value":"5858"}]},"status":{"state":"Pushed"}}]}`, project) Expect(desiredCompListJSON).Should(MatchJSON(actualCompListJSON)) cmpAllList := helper.CmdShouldPass("odo", append(args, "list", "--all")...) Expect(cmpAllList).To(ContainSubstring("cmp-git")) @@ -537,7 +537,7 @@ func componentTests(args ...string) { } else { sourcePath = "file://./" } - desiredDesCompJSON := fmt.Sprintf(`{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"nodejs","namespace":"%s","creationTimestamp":null},"spec":{"app":"app","type":"nodejs","source":"%s"},"status":{"state":"Pushed"}}`, project, sourcePath) + desiredDesCompJSON := fmt.Sprintf(`{"kind":"Component","apiVersion":"odo.openshift.io/v1alpha1","metadata":{"name":"nodejs","namespace":"%s","creationTimestamp":null},"spec":{"app":"app","type":"nodejs","source":"%s","env":[{"name":"DEBUG_PORT","value":"5858"}]},"status":{"state":"Pushed"}}`, project, sourcePath) Expect(desiredDesCompJSON).Should(MatchJSON(actualDesCompJSON)) helper.CmdShouldPass("odo", append(args, "delete", cmpName, "--app", appName, "--project", project, "-f")...)