From 3c87ecdc8f1f4a3a389368cb99e193149ce81f8b Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 6 Dec 2022 08:50:07 +0100 Subject: [PATCH] Support Alizer-based automatic port detection with odo init interactive mode (#6365) * Do not display "Port configuration using flag is currently not supported" This is confusing to users. * Display ports detected as part of the Alizer component detection logic This is because we specifically need to display the ports at the same moment when the language, project type and Devfile are displayed to the end user (when source code is present) * Update the loaded Devfile object with the application ports detected prior to asking for its customization The case of multi-container components will be handled in [1]. Otherwise, the container component ports (all but the Debug ports) are replaced with the right application ports. [1] https://github.com/redhat-developer/odo/issues/6264 * Add integration test case * Add application ports detected to "odo analyze -o json" output * Update documentation * Add utility functions for helping handle Debug endpoints * Make application ports detected appear first in the endpoint list This allows such ports to be port-forwarded first, before the Debug ones. --- docs/website/docs/command-reference/init.md | 67 +++++++- .../docs/command-reference/json-output.md | 28 ++-- .../user-guides/quickstart/_creating_app.mdx | 3 +- pkg/alizer/alizer.go | 30 +++- pkg/alizer/interface.go | 1 + pkg/alizer/mock.go | 15 ++ pkg/api/{devfile-location.go => analyze.go} | 10 +- pkg/init/backend/alizer.go | 27 +++- pkg/init/backend/alizer_test.go | 110 ++++++++++++- pkg/init/backend/applicationports.go | 66 ++++++++ pkg/init/backend/applicationports_test.go | 144 ++++++++++++++++++ pkg/init/backend/flags.go | 9 +- pkg/init/backend/flags_test.go | 4 +- pkg/init/backend/interactive.go | 8 +- pkg/init/backend/interactive_test.go | 6 +- pkg/init/backend/interface.go | 8 +- pkg/init/backend/mock.go | 19 ++- pkg/init/init.go | 32 +++- pkg/init/init_test.go | 2 +- pkg/init/interface.go | 9 +- pkg/init/mock.go | 25 ++- pkg/libdevfile/libdevfile.go | 30 ++++ pkg/libdevfile/libdevfile_test.go | 133 ++++++++++++++++ pkg/odo/cli/alizer/alizer.go | 10 +- pkg/odo/cli/init/init.go | 11 +- pkg/testingutil/devfile.go | 12 +- tests/integration/interactive_init_test.go | 53 +++++++ 27 files changed, 807 insertions(+), 65 deletions(-) rename pkg/api/{devfile-location.go => analyze.go} (53%) create mode 100644 pkg/init/backend/applicationports.go create mode 100644 pkg/init/backend/applicationports_test.go diff --git a/docs/website/docs/command-reference/init.md b/docs/website/docs/command-reference/init.md index 3e68d0bd576..578a79c3f20 100644 --- a/docs/website/docs/command-reference/init.md +++ b/docs/website/docs/command-reference/init.md @@ -5,16 +5,20 @@ title: odo init The `odo init` command is the first command to be executed when you want to bootstrap a new component, using `odo`. If sources already exist, the command `odo dev` should be considered instead. -This command must be executed from an empty directory, and as a result, the command will download a `devfile.yaml` file and, optionally, a starter project. +This command must be executed from a directory with no `devfile.yaml` file. The command can be executed in two flavors, either interactive or non-interactive. ## Running the command ### Interactive mode -In interactive mode, you will be guided to: +In interactive mode, the behavior of `odo init` depends on whether the current directory already contains source code or not. + +#### Empty directory + +If the directory is empty, you will be guided to: - choose a devfile from the list of devfiles present in the registry or registries referenced (using the `odo registry` command), -- configure the devfile if there is an existing project +- configure the devfile - choose a starter project referenced by the selected devfile, - choose a name for the component present in the devfile; this name must follow the [Kubernetes naming convention](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names) and not be all-numeric. @@ -41,6 +45,63 @@ To deploy your component to a cluster use "odo deploy". ``` +#### Directory with sources + +If the current directory is not empty, `odo init` will make its best to autodetect the type of application and propose you a Devfile that should suit your project. +It will try to detect the following, based on the files in the current directory: +- Language +- Project Type +- Ports used in your application +- A Devfile that should help you start with `odo` + +If the information detected does not seem correct to you, you are able to select a different Devfile. + +In all cases, you will be guided to: +- configure the devfile +- choose a name for the component present in the devfile; this name must follow the [Kubernetes naming convention](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names) and not be all-numeric. + +```console +odo init +``` + +
+Example + +```console +$ odo init + __ + / \__ Initializing a new component + \__/ \ Files: Source code detected, a Devfile will be determined based upon source code autodetection + / \__/ odo version: v3.3.0 + \__/ + +Interactive mode enabled, please answer the following questions: +Based on the files in the current directory odo detected +Language: JavaScript +Project type: Node.js +Application ports: 3000 +The devfile "nodejs" from the registry "DefaultDevfileRegistry" will be downloaded. +? Is this correct? Yes + ✓ Downloading devfile "nodejs" from registry "DefaultDevfileRegistry" [1s] + +↪ Container Configuration "runtime": + OPEN PORTS: + - 5858 + - 3000 + ENVIRONMENT VARIABLES: + - DEBUG_PORT = 5858 + +? Select container for which you want to change configuration? NONE - configuration is correct +? Enter component name: nodejs + +You can automate this command by executing: + odo init --name nodejs --devfile nodejs --devfile-registry DefaultDevfileRegistry + +Your new component 'nodejs' is ready in the current directory. +To start editing your component, use 'odo dev' and open this folder in your favorite IDE. +Changes will be directly reflected on the cluster. +``` +
### Non-interactive mode diff --git a/docs/website/docs/command-reference/json-output.md b/docs/website/docs/command-reference/json-output.md index b0122eb6c2d..dcdde9270ea 100644 --- a/docs/website/docs/command-reference/json-output.md +++ b/docs/website/docs/command-reference/json-output.md @@ -19,8 +19,9 @@ The structures used to return information using JSON output are defined in [the ## odo analyze -o json -The `analyze` command analyzes the files in the current directory to select the best devfiles to use, -from the devfiles in the registries defined in the list of preferred registries with the command `odo preference view`. +The `analyze` command analyzes the files in the current directory and returns the following information: +- the best devfiles to use, from the devfiles in the registries defined in the list of preferred registries with the command `odo preference view` +- the ports used in the application, if that was possible to determine. The output of this command contains a list of devfile name and registry name: @@ -31,14 +32,18 @@ odo analyze -o json [ { "devfile": "nodejs", - "devfileRegistry": "DefaultDevfileRegistry" + "devfileRegistry": "DefaultDevfileRegistry", + "ports": [ + 3000 + ] } ] ``` -```console -echo $? -``` -```console + +The exit code should be zero in this case: + +```bash +$ echo $? 0 ``` @@ -52,10 +57,11 @@ odo analyze -o json "message": "No valid devfile found for project in /home/user/my/empty/directory" } ``` -```console -echo $? -``` -```console + +The command should terminate with a non-zero exit code: + +```bash +$ echo $? 1 ``` diff --git a/docs/website/docs/user-guides/quickstart/_creating_app.mdx b/docs/website/docs/user-guides/quickstart/_creating_app.mdx index 55ce03bec0e..89ddaeacdda 100644 --- a/docs/website/docs/user-guides/quickstart/_creating_app.mdx +++ b/docs/website/docs/user-guides/quickstart/_creating_app.mdx @@ -24,7 +24,8 @@ Interactive mode enabled, please answer the following questions: Based on the files in the current directory odo detected Language: `}{props.language}{` Project type: `}{props.name}{` -The devfile "nodejs" from the registry "DefaultDevfileRegistry" will be downloaded. +Application ports: `}{props.port}{` +The devfile "`}{props.name}{`" from the registry "DefaultDevfileRegistry" will be downloaded. ? Is this correct? Yes ✓ Downloading devfile "`}{props.name}{`" from registry "DefaultDevfileRegistry" [501ms] Current component configuration: diff --git a/pkg/alizer/alizer.go b/pkg/alizer/alizer.go index af80ff89b37..cd135c14a17 100644 --- a/pkg/alizer/alizer.go +++ b/pkg/alizer/alizer.go @@ -49,7 +49,7 @@ func (o *Alizer) DetectFramework(ctx context.Context, path string) (model.DevFil return types[typ], components.Items[typ].Registry, nil } -// DetectName retrieves the name of the project (if available) +// DetectName retrieves the name of the project (if available). // If source code is detected: // 1. Detect the name (pom.xml for java, package.json for nodejs, etc.) // 2. If unable to detect the name, use the directory name @@ -58,11 +58,11 @@ func (o *Alizer) DetectFramework(ctx context.Context, path string) (model.DevFil // 1. Use the directory name // // Last step. Sanitize the name so it's valid for a component name - +// // Use: // import "github.com/redhat-developer/alizer/pkg/apis/recognizer" // components, err := recognizer.DetectComponents("./") - +// // In order to detect the name, the name will first try to find out the name based on the program (pom.xml, etc.) but then if not, it will use the dir name. func (o *Alizer) DetectName(path string) (string, error) { if path == "" { @@ -116,9 +116,25 @@ func (o *Alizer) DetectName(path string) (string, error) { return name, nil } -func GetDevfileLocationFromDetection(typ model.DevFileType, registry api.Registry) *api.DevfileLocation { - return &api.DevfileLocation{ - Devfile: typ.Name, - DevfileRegistry: registry.Name, +func (o *Alizer) DetectPorts(path string) ([]int, error) { + //TODO(rm3l): Find a better way not to call recognizer.DetectComponents multiple times (in DetectFramework, DetectName and DetectPorts) + components, err := recognizer.DetectComponents(path) + if err != nil { + return nil, err + } + + if len(components) == 0 { + klog.V(4).Infof("no components found at path %q", path) + return nil, nil + } + + return components[0].Ports, nil +} + +func NewDetectionResult(typ model.DevFileType, registry api.Registry, appPorts []int) *api.DetectionResult { + return &api.DetectionResult{ + Devfile: typ.Name, + DevfileRegistry: registry.Name, + ApplicationPorts: appPorts, } } diff --git a/pkg/alizer/interface.go b/pkg/alizer/interface.go index 3c55b5abe6f..bfdc99d8b95 100644 --- a/pkg/alizer/interface.go +++ b/pkg/alizer/interface.go @@ -10,4 +10,5 @@ import ( type Client interface { DetectFramework(ctx context.Context, path string) (model.DevFileType, api.Registry, error) DetectName(path string) (string, error) + DetectPorts(path string) ([]int, error) } diff --git a/pkg/alizer/mock.go b/pkg/alizer/mock.go index 4f73ee587a1..df77ec28e72 100644 --- a/pkg/alizer/mock.go +++ b/pkg/alizer/mock.go @@ -66,3 +66,18 @@ func (mr *MockClientMockRecorder) DetectName(path interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectName", reflect.TypeOf((*MockClient)(nil).DetectName), path) } + +// DetectPorts mocks base method. +func (m *MockClient) DetectPorts(path string) ([]int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DetectPorts", path) + ret0, _ := ret[0].([]int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DetectPorts indicates an expected call of DetectPorts. +func (mr *MockClientMockRecorder) DetectPorts(path interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectPorts", reflect.TypeOf((*MockClient)(nil).DetectPorts), path) +} diff --git a/pkg/api/devfile-location.go b/pkg/api/analyze.go similarity index 53% rename from pkg/api/devfile-location.go rename to pkg/api/analyze.go index 47afdd4f927..a93a98da7e9 100644 --- a/pkg/api/devfile-location.go +++ b/pkg/api/analyze.go @@ -1,7 +1,10 @@ package api -// DevfileLocation indicates the location of a devfile, either in a devfile registry or using a path or an URI -type DevfileLocation struct { +// DetectionResult indicates the result of an analysis against a given project. +// Analysis might be performed via the Alizer backend or non-interactively via the Flags backend. +// It contains detection analysis information such as the location of a devfile, +// either in a devfile registry or using a path or a URI or the application ports if any. +type DetectionResult struct { // name of the Devfile in Devfile registry (required if DevfilePath is not defined) Devfile string `json:"devfile,omitempty"` @@ -10,4 +13,7 @@ type DevfileLocation struct { // path to a devfile. This is alternative to using devfile from Devfile registry. It can be local filesystem path or http(s) URL (required if Devfile is not defined) DevfilePath string `json:"devfilePath,omitempty"` + + // ApplicationPorts represents the list of ports detected + ApplicationPorts []int `json:"ports,omitempty"` } diff --git a/pkg/init/backend/alizer.go b/pkg/init/backend/alizer.go index 96514400345..017240e1b1d 100644 --- a/pkg/init/backend/alizer.go +++ b/pkg/init/backend/alizer.go @@ -3,9 +3,12 @@ package backend import ( "context" "fmt" + "strconv" + "strings" "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/library/pkg/devfile/parser" + "github.com/redhat-developer/odo/pkg/alizer" "github.com/redhat-developer/odo/pkg/api" "github.com/redhat-developer/odo/pkg/init/asker" @@ -31,13 +34,27 @@ func (o *AlizerBackend) Validate(flags map[string]string, fs filesystem.Filesyst } // SelectDevfile calls thz Alizer to detect the devfile and asks for confirmation to the user -func (o *AlizerBackend) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (location *api.DevfileLocation, err error) { +func (o *AlizerBackend) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (location *api.DetectionResult, err error) { selected, registry, err := o.alizerClient.DetectFramework(ctx, dir) if err != nil { return nil, err } - fmt.Printf("Based on the files in the current directory odo detected\nLanguage: %s\nProject type: %s\n", selected.Language, selected.ProjectType) + msg := fmt.Sprintf("Based on the files in the current directory odo detected\nLanguage: %s\nProject type: %s", selected.Language, selected.ProjectType) + + appPorts, err := o.alizerClient.DetectPorts(dir) + if err != nil { + return nil, err + } + appPortsAsString := make([]string, 0, len(appPorts)) + for _, p := range appPorts { + appPortsAsString = append(appPortsAsString, strconv.Itoa(p)) + } + if len(appPorts) > 0 { + msg += fmt.Sprintf("\nApplication ports: %s", strings.Join(appPortsAsString, ", ")) + } + + fmt.Println(msg) fmt.Printf("The devfile %q from the registry %q will be downloaded.\n", selected.Name, registry.Name) confirm, err := o.askerClient.AskCorrect() if err != nil { @@ -46,7 +63,7 @@ func (o *AlizerBackend) SelectDevfile(ctx context.Context, flags map[string]stri if !confirm { return nil, nil } - return alizer.GetDevfileLocationFromDetection(selected, registry), nil + return alizer.NewDetectionResult(selected, registry, appPorts), nil } func (o *AlizerBackend) SelectStarterProject(devfile parser.DevfileObj, flags map[string]string) (starter *v1alpha2.StarterProject, err error) { @@ -65,3 +82,7 @@ func (o *AlizerBackend) PersonalizeName(devfile parser.DevfileObj, flags map[str func (o *AlizerBackend) PersonalizeDevfileConfig(devfile parser.DevfileObj) (parser.DevfileObj, error) { return devfile, nil } + +func (o *AlizerBackend) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string) (parser.DevfileObj, error) { + return devfileobj, nil +} diff --git a/pkg/init/backend/alizer_test.go b/pkg/init/backend/alizer_test.go index 78d8584e0da..2318a8b1713 100644 --- a/pkg/init/backend/alizer_test.go +++ b/pkg/init/backend/alizer_test.go @@ -2,6 +2,7 @@ package backend import ( "context" + "errors" "path/filepath" "runtime" "testing" @@ -38,9 +39,83 @@ func TestAlizerBackend_SelectDevfile(t *testing.T) { name string fields fields args args - wantLocation *api.DevfileLocation + wantLocation *api.DetectionResult wantErr bool }{ + { + name: "error while trying to detect devfile", + fields: fields{ + askerClient: func(ctrl *gomock.Controller) asker.Asker { + askerClient := asker.NewMockAsker(ctrl) + askerClient.EXPECT().AskCorrect().Return(true, nil).Times(0) + return askerClient + }, + alizerClient: func(ctrl *gomock.Controller) alizer.Client { + alizerClient := alizer.NewMockClient(ctrl) + alizerClient.EXPECT().DetectFramework(gomock.Any(), gomock.Any()).Return(model.DevFileType{ + Name: "a-devfile-name", + }, api.Registry{ + Name: "a-registry", + }, errors.New("unable to detect framework")) + return alizerClient + }, + }, + args: args{ + fs: filesystem.DefaultFs{}, + dir: GetTestProjectPath("nodejs"), + }, + wantErr: true, + }, + { + name: "error while trying to detect ports", + fields: fields{ + askerClient: func(ctrl *gomock.Controller) asker.Asker { + askerClient := asker.NewMockAsker(ctrl) + askerClient.EXPECT().AskCorrect().Return(true, nil).Times(0) + return askerClient + }, + alizerClient: func(ctrl *gomock.Controller) alizer.Client { + alizerClient := alizer.NewMockClient(ctrl) + alizerClient.EXPECT().DetectFramework(gomock.Any(), gomock.Any()).Return(model.DevFileType{ + Name: "a-devfile-name", + }, api.Registry{ + Name: "a-registry", + }, nil) + alizerClient.EXPECT().DetectPorts(gomock.Any()).Return(nil, errors.New("unable to detect ports")) + return alizerClient + }, + }, + args: args{ + fs: filesystem.DefaultFs{}, + dir: GetTestProjectPath("nodejs"), + }, + wantErr: true, + }, + { + name: "error while asking consent to user", + fields: fields{ + askerClient: func(ctrl *gomock.Controller) asker.Asker { + askerClient := asker.NewMockAsker(ctrl) + askerClient.EXPECT().AskCorrect().Return(false, errors.New("error while prompting user")) + return askerClient + }, + alizerClient: func(ctrl *gomock.Controller) alizer.Client { + alizerClient := alizer.NewMockClient(ctrl) + alizerClient.EXPECT().DetectFramework(gomock.Any(), gomock.Any()).Return(model.DevFileType{ + Name: "a-devfile-name", + }, api.Registry{ + Name: "a-registry", + }, nil) + alizerClient.EXPECT().DetectPorts(gomock.Any()).Return(nil, nil) + return alizerClient + }, + }, + args: args{ + fs: filesystem.DefaultFs{}, + dir: GetTestProjectPath("nodejs"), + }, + wantErr: true, + }, { name: "devfile found and accepted", fields: fields{ @@ -56,6 +131,7 @@ func TestAlizerBackend_SelectDevfile(t *testing.T) { }, api.Registry{ Name: "a-registry", }, nil) + alizerClient.EXPECT().DetectPorts(gomock.Any()).Return(nil, nil) return alizerClient }, }, @@ -63,7 +139,7 @@ func TestAlizerBackend_SelectDevfile(t *testing.T) { fs: filesystem.DefaultFs{}, dir: GetTestProjectPath("nodejs"), }, - wantLocation: &api.DevfileLocation{ + wantLocation: &api.DetectionResult{ Devfile: "a-devfile-name", DevfileRegistry: "a-registry", }, @@ -79,6 +155,7 @@ func TestAlizerBackend_SelectDevfile(t *testing.T) { alizerClient: func(ctrl *gomock.Controller) alizer.Client { alizerClient := alizer.NewMockClient(ctrl) alizerClient.EXPECT().DetectFramework(gomock.Any(), gomock.Any()).Return(model.DevFileType{}, api.Registry{}, nil) + alizerClient.EXPECT().DetectPorts(gomock.Any()).Return(nil, nil) return alizerClient }, }, @@ -88,6 +165,35 @@ func TestAlizerBackend_SelectDevfile(t *testing.T) { }, wantLocation: nil, }, + { + name: "devfile and ports detected and accepted", + fields: fields{ + askerClient: func(ctrl *gomock.Controller) asker.Asker { + askerClient := asker.NewMockAsker(ctrl) + askerClient.EXPECT().AskCorrect().Return(true, nil) + return askerClient + }, + alizerClient: func(ctrl *gomock.Controller) alizer.Client { + alizerClient := alizer.NewMockClient(ctrl) + alizerClient.EXPECT().DetectFramework(gomock.Any(), gomock.Any()).Return(model.DevFileType{ + Name: "a-devfile-name", + }, api.Registry{ + Name: "a-registry", + }, nil) + alizerClient.EXPECT().DetectPorts(gomock.Any()).Return([]int{1234, 5678}, nil) + return alizerClient + }, + }, + args: args{ + fs: filesystem.DefaultFs{}, + dir: GetTestProjectPath("nodejs"), + }, + wantLocation: &api.DetectionResult{ + Devfile: "a-devfile-name", + DevfileRegistry: "a-registry", + ApplicationPorts: []int{1234, 5678}, + }, + }, // TODO: Add test cases. } for _, tt := range tests { diff --git a/pkg/init/backend/applicationports.go b/pkg/init/backend/applicationports.go new file mode 100644 index 00000000000..efffe5aeb5d --- /dev/null +++ b/pkg/init/backend/applicationports.go @@ -0,0 +1,66 @@ +package backend + +import ( + "fmt" + "io" + "strconv" + + "github.com/devfile/library/pkg/devfile/parser" + parsercommon "github.com/devfile/library/pkg/devfile/parser/data/v2/common" + "k8s.io/klog" + + "github.com/redhat-developer/odo/pkg/libdevfile" +) + +// handleApplicationPorts updates the ports in the Devfile as needed. +// If there are multiple container components in the Devfile, nothing is done. This will be handled in https://github.com/redhat-developer/odo/issues/6264. +// Otherwise, all the container component endpoints/ports (other than Debug) are updated with the specified ports. +func handleApplicationPorts(w io.Writer, devfileobj parser.DevfileObj, ports []int) (parser.DevfileObj, error) { + if len(ports) == 0 { + return devfileobj, nil + } + + components, err := devfileobj.Data.GetDevfileContainerComponents(parsercommon.DevfileOptions{}) + if err != nil { + return parser.DevfileObj{}, err + } + nbContainerComponents := len(components) + klog.V(3).Infof("Found %d container components in Devfile at path %q", nbContainerComponents, devfileobj.Ctx.GetAbsPath()) + if nbContainerComponents == 0 { + // no container components => nothing to do + return devfileobj, nil + } + if nbContainerComponents > 1 { + klog.V(3).Infof("found more than 1 container components in Devfile at path %q => cannot find out which component needs to be updated."+ + "This case will be handled in https://github.com/redhat-developer/odo/issues/6264", devfileobj.Ctx.GetAbsPath()) + fmt.Fprintln(w, "\nApplication ports detected but the current Devfile contains multiple container components. Could not determine which component to update. "+ + "Please feel free to customize the Devfile configuration.") + return devfileobj, nil + } + + component := components[0] + + // Add the new ports at the beginning of the list (that is before any Debug endpoints). + // This way, application ports will be port-forwarded first. + portsToSet := make([]string, 0, len(ports)) + for _, p := range ports { + portsToSet = append(portsToSet, strconv.Itoa(p)) + } + debugEndpoints, err := libdevfile.GetDebugEndpointsForComponent(component) + if err != nil { + return parser.DevfileObj{}, err + } + // Clear the existing endpoint list + component.Container.Endpoints = nil + + // Add the new application ports first + err = devfileobj.Data.SetPorts(map[string][]string{component.Name: portsToSet}) + if err != nil { + return parser.DevfileObj{}, err + } + + // Append debug endpoints to the end of the list + component.Container.Endpoints = append(component.Container.Endpoints, debugEndpoints...) + + return devfileobj, nil +} diff --git a/pkg/init/backend/applicationports_test.go b/pkg/init/backend/applicationports_test.go new file mode 100644 index 00000000000..d32df542d30 --- /dev/null +++ b/pkg/init/backend/applicationports_test.go @@ -0,0 +1,144 @@ +package backend + +import ( + "bytes" + "testing" + + v1 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + devfilepkg "github.com/devfile/api/v2/pkg/devfile" + "github.com/devfile/library/pkg/devfile/parser" + devfileCtx "github.com/devfile/library/pkg/devfile/parser/context" + "github.com/devfile/library/pkg/devfile/parser/data" + devfilefs "github.com/devfile/library/pkg/testingutil/filesystem" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/redhat-developer/odo/pkg/testingutil" +) + +var fs = devfilefs.NewFakeFs() + +func buildDevfileObjWithComponents(components ...v1.Component) parser.DevfileObj { + devfileData, _ := data.NewDevfileData(string(data.APISchemaVersion220)) + devfileData.SetMetadata(devfilepkg.DevfileMetadata{Name: "my-nodejs-app"}) + _ = devfileData.AddComponents(components) + return parser.DevfileObj{ + Ctx: devfileCtx.FakeContext(fs, parser.OutputDevfileYamlPath), + Data: devfileData, + } +} + +func Test_handleApplicationPorts(t *testing.T) { + type devfileProvider func() parser.DevfileObj + type args struct { + devfileObjProvider devfileProvider + ports []int + } + + tests := []struct { + name string + args args + wantErr bool + wantProvider devfileProvider + }{ + { + name: "no component, no ports to set", + args: args{ + devfileObjProvider: func() parser.DevfileObj { return buildDevfileObjWithComponents() }, + }, + wantProvider: func() parser.DevfileObj { return buildDevfileObjWithComponents() }, + }, + { + name: "multiple container components, no ports to set", + args: args{ + devfileObjProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents( + testingutil.GetFakeContainerComponent("cont1", 8080, 8081, 8082), + testingutil.GetFakeContainerComponent("cont2", 9080, 9081, 9082), + ) + }, + }, + wantProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents( + testingutil.GetFakeContainerComponent("cont1", 8080, 8081, 8082), + testingutil.GetFakeContainerComponent("cont2", 9080, 9081, 9082), + ) + }, + }, + { + name: "no container components", + args: args{ + devfileObjProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents(testingutil.GetFakeVolumeComponent("vol1", "1Gi")) + }, + ports: []int{8888, 8889, 8890}, + }, + wantProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents(testingutil.GetFakeVolumeComponent("vol1", "1Gi")) + }, + }, + { + name: "more than one container components", + args: args{ + devfileObjProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents( + testingutil.GetFakeContainerComponent("cont1", 8080, 8081, 8082), + testingutil.GetFakeContainerComponent("cont2", 9080, 9081, 9082), + testingutil.GetFakeVolumeComponent("vol1", "1Gi"), + ) + }, + ports: []int{8888, 8889, 8890}, + }, + wantProvider: func() parser.DevfileObj { + return buildDevfileObjWithComponents( + testingutil.GetFakeContainerComponent("cont1", 8080, 8081, 8082), + testingutil.GetFakeContainerComponent("cont2", 9080, 9081, 9082), + testingutil.GetFakeVolumeComponent("vol1", "1Gi"), + ) + }, + }, + { + name: "single container component with both application and debug ports", + args: args{ + devfileObjProvider: func() parser.DevfileObj { + contWithDebug := testingutil.GetFakeContainerComponent("cont1", 18080, 18081, 18082) + contWithDebug.ComponentUnion.Container.Endpoints = append(contWithDebug.ComponentUnion.Container.Endpoints, + v1.Endpoint{Name: "debug", TargetPort: 5005}, + v1.Endpoint{Name: "debug-another", TargetPort: 5858}, + ) + return buildDevfileObjWithComponents( + contWithDebug, + testingutil.GetFakeVolumeComponent("vol1", "1Gi")) + }, + ports: []int{3000, 9000}, + }, + wantProvider: func() parser.DevfileObj { + newCont := testingutil.GetFakeContainerComponent("cont1") + newCont.ComponentUnion.Container.Endpoints = append(newCont.ComponentUnion.Container.Endpoints, + v1.Endpoint{Name: "port-3000-tcp", TargetPort: 3000, Protocol: v1.TCPEndpointProtocol}, + v1.Endpoint{Name: "port-9000-tcp", TargetPort: 9000, Protocol: v1.TCPEndpointProtocol}, + v1.Endpoint{Name: "debug", TargetPort: 5005}, + v1.Endpoint{Name: "debug-another", TargetPort: 5858}, + ) + return buildDevfileObjWithComponents( + newCont, + testingutil.GetFakeVolumeComponent("vol1", "1Gi")) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + got, err := handleApplicationPorts(&output, tt.args.devfileObjProvider(), tt.args.ports) + if (err != nil) != tt.wantErr { + t.Errorf("handleApplicationPorts() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.wantProvider(), got, + cmp.AllowUnexported(devfileCtx.DevfileCtx{}), + cmpopts.IgnoreInterfaces(struct{ devfilefs.Filesystem }{})); diff != "" { + t.Errorf("handleApplicationPorts() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/init/backend/flags.go b/pkg/init/backend/flags.go index 4f6f8fd0fa9..74b14b1a597 100644 --- a/pkg/init/backend/flags.go +++ b/pkg/init/backend/flags.go @@ -86,8 +86,8 @@ func (o *FlagsBackend) Validate(flags map[string]string, fs filesystem.Filesyste return nil } -func (o *FlagsBackend) SelectDevfile(ctx context.Context, flags map[string]string, _ filesystem.Filesystem, _ string) (*api.DevfileLocation, error) { - return &api.DevfileLocation{ +func (o *FlagsBackend) SelectDevfile(ctx context.Context, flags map[string]string, _ filesystem.Filesystem, _ string) (*api.DetectionResult, error) { + return &api.DetectionResult{ Devfile: flags[FLAG_DEVFILE], DevfileRegistry: flags[FLAG_DEVFILE_REGISTRY], DevfilePath: flags[FLAG_DEVFILE_PATH], @@ -123,3 +123,8 @@ func (o *FlagsBackend) PersonalizeName(_ parser.DevfileObj, flags map[string]str func (o FlagsBackend) PersonalizeDevfileConfig(devfileobj parser.DevfileObj) (parser.DevfileObj, error) { return devfileobj, nil } + +func (o FlagsBackend) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string) (parser.DevfileObj, error) { + // Currently not supported, but this will be done in a separate issue: https://github.com/redhat-developer/odo/issues/6211 + return devfileobj, nil +} diff --git a/pkg/init/backend/flags_test.go b/pkg/init/backend/flags_test.go index b2474c0deec..12f10267a78 100644 --- a/pkg/init/backend/flags_test.go +++ b/pkg/init/backend/flags_test.go @@ -25,7 +25,7 @@ func TestFlagsBackend_SelectDevfile(t *testing.T) { tests := []struct { name string fields fields - want *api.DevfileLocation + want *api.DetectionResult wantErr bool }{ { @@ -38,7 +38,7 @@ func TestFlagsBackend_SelectDevfile(t *testing.T) { }, }, wantErr: false, - want: &api.DevfileLocation{ + want: &api.DetectionResult{ Devfile: "adevfile", DevfilePath: "apath", DevfileRegistry: "aregistry", diff --git a/pkg/init/backend/interactive.go b/pkg/init/backend/interactive.go index 5bd7d7088f3..ada39bb50b5 100644 --- a/pkg/init/backend/interactive.go +++ b/pkg/init/backend/interactive.go @@ -48,8 +48,8 @@ func (o *InteractiveBackend) Validate(flags map[string]string, fs filesystem.Fil return nil } -func (o *InteractiveBackend) SelectDevfile(ctx context.Context, flags map[string]string, _ filesystem.Filesystem, _ string) (*api.DevfileLocation, error) { - result := &api.DevfileLocation{} +func (o *InteractiveBackend) SelectDevfile(ctx context.Context, flags map[string]string, _ filesystem.Filesystem, _ string) (*api.DetectionResult, error) { + result := &api.DetectionResult{} devfileEntries, _ := o.registryClient.ListDevfileStacks(ctx, "", "", "", false) langs := devfileEntries.GetLanguages() @@ -260,6 +260,10 @@ func (o *InteractiveBackend) PersonalizeDevfileConfig(devfileobj parser.DevfileO return devfileobj, nil } +func (o *InteractiveBackend) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string) (parser.DevfileObj, error) { + return handleApplicationPorts(log.GetStdout(), devfileobj, ports) +} + func PrintConfiguration(config asker.DevfileConfiguration) { var keys []string diff --git a/pkg/init/backend/interactive_test.go b/pkg/init/backend/interactive_test.go index 67c27d77ce7..c7bf87e0623 100644 --- a/pkg/init/backend/interactive_test.go +++ b/pkg/init/backend/interactive_test.go @@ -28,7 +28,7 @@ func TestInteractiveBackend_SelectDevfile(t *testing.T) { tests := []struct { name string fields fields - want *api.DevfileLocation + want *api.DetectionResult wantErr bool }{ { @@ -51,7 +51,7 @@ func TestInteractiveBackend_SelectDevfile(t *testing.T) { return client }, }, - want: &api.DevfileLocation{ + want: &api.DetectionResult{ Devfile: "a-devfile-name", DevfileRegistry: "MyRegistry1", }, @@ -78,7 +78,7 @@ func TestInteractiveBackend_SelectDevfile(t *testing.T) { return client }, }, - want: &api.DevfileLocation{ + want: &api.DetectionResult{ Devfile: "a-devfile-name", DevfileRegistry: "MyRegistry1", }, diff --git a/pkg/init/backend/interface.go b/pkg/init/backend/interface.go index f770c77754c..ee756dce619 100644 --- a/pkg/init/backend/interface.go +++ b/pkg/init/backend/interface.go @@ -1,4 +1,4 @@ -// package backend provides different backends to initiate projects. +// Package backend provides different backends to initiate projects. // - `Flags` backend gets needed information from command line flags. // - `Interactive` backend interacts with the user to get needed information. package backend @@ -8,6 +8,7 @@ import ( "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/library/pkg/devfile/parser" + "github.com/redhat-developer/odo/pkg/api" "github.com/redhat-developer/odo/pkg/testingutil/filesystem" ) @@ -18,7 +19,7 @@ type InitBackend interface { Validate(flags map[string]string, fs filesystem.Filesystem, dir string) error // SelectDevfile selects a devfile and returns its location information, depending on the flags - SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (location *api.DevfileLocation, err error) + SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (location *api.DetectionResult, err error) // SelectStarterProject selects a starter project from the devfile and returns information about the starter project, // depending on the flags. If not starter project is selected, a nil starter is returned @@ -30,4 +31,7 @@ type InitBackend interface { // PersonalizeDevfileConfig updates the devfile config for ports and environment variables PersonalizeDevfileConfig(devfileobj parser.DevfileObj) (parser.DevfileObj, error) + + // HandleApplicationPorts updates the ports in the Devfile accordingly. + HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string) (parser.DevfileObj, error) } diff --git a/pkg/init/backend/mock.go b/pkg/init/backend/mock.go index 898fbcf1103..017895f8074 100644 --- a/pkg/init/backend/mock.go +++ b/pkg/init/backend/mock.go @@ -38,6 +38,21 @@ func (m *MockInitBackend) EXPECT() *MockInitBackendMockRecorder { return m.recorder } +// HandleApplicationPorts mocks base method. +func (m *MockInitBackend) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string) (parser.DevfileObj, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleApplicationPorts", devfileobj, ports, flags) + ret0, _ := ret[0].(parser.DevfileObj) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleApplicationPorts indicates an expected call of HandleApplicationPorts. +func (mr *MockInitBackendMockRecorder) HandleApplicationPorts(devfileobj, ports, flags interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleApplicationPorts", reflect.TypeOf((*MockInitBackend)(nil).HandleApplicationPorts), devfileobj, ports, flags) +} + // PersonalizeDevfileConfig mocks base method. func (m *MockInitBackend) PersonalizeDevfileConfig(devfileobj parser.DevfileObj) (parser.DevfileObj, error) { m.ctrl.T.Helper() @@ -69,10 +84,10 @@ func (mr *MockInitBackendMockRecorder) PersonalizeName(devfile, flags interface{ } // SelectDevfile mocks base method. -func (m *MockInitBackend) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DevfileLocation, error) { +func (m *MockInitBackend) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DetectionResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SelectDevfile", ctx, flags, fs, dir) - ret0, _ := ret[0].(*api.DevfileLocation) + ret0, _ := ret[0].(*api.DetectionResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/pkg/init/init.go b/pkg/init/init.go index f536384dba5..ecf56deff8b 100644 --- a/pkg/init/init.go +++ b/pkg/init/init.go @@ -77,7 +77,7 @@ func (o *InitClient) Validate(flags map[string]string, fs filesystem.Filesystem, } // SelectDevfile calls SelectDevfile methods of the adequate backend -func (o *InitClient) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DevfileLocation, error) { +func (o *InitClient) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DetectionResult, error) { var backend backend.InitBackend empty, err := location.DirIsEmpty(fs, dir) @@ -109,7 +109,7 @@ func (o *InitClient) SelectDevfile(ctx context.Context, flags map[string]string, return location, err } -func (o *InitClient) DownloadDevfile(ctx context.Context, devfileLocation *api.DevfileLocation, destDir string) (string, error) { +func (o *InitClient) DownloadDevfile(ctx context.Context, devfileLocation *api.DetectionResult, destDir string) (string, error) { destDevfile := filepath.Join(destDir, "devfile.yaml") if devfileLocation.DevfilePath != "" { return destDevfile, o.downloadDirect(devfileLocation.DevfilePath, destDevfile) @@ -238,7 +238,24 @@ func (o *InitClient) PersonalizeName(devfile parser.DevfileObj, flags map[string return backend.PersonalizeName(devfile, flags) } -func (o InitClient) PersonalizeDevfileConfig(devfileobj parser.DevfileObj, flags map[string]string, fs filesystem.Filesystem, dir string) (parser.DevfileObj, error) { +func (o *InitClient) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string, fs filesystem.Filesystem, dir string) (parser.DevfileObj, error) { + var backend backend.InitBackend + onlyDevfile, err := location.DirContainsOnlyDevfile(fs, dir) + if err != nil { + return parser.DevfileObj{}, err + } + + // Interactive mode since no flags are provided + if len(flags) == 0 && !onlyDevfile { + // Other files present in the directory; hence alizer is run + backend = o.interactiveBackend + } else { + backend = o.flagsBackend + } + return backend.HandleApplicationPorts(devfileobj, ports, flags) +} + +func (o *InitClient) PersonalizeDevfileConfig(devfileobj parser.DevfileObj, flags map[string]string, fs filesystem.Filesystem, dir string) (parser.DevfileObj, error) { var backend backend.InitBackend // Interactive mode since no flags are provided @@ -250,7 +267,7 @@ func (o InitClient) PersonalizeDevfileConfig(devfileobj parser.DevfileObj, flags return backend.PersonalizeDevfileConfig(devfileobj) } -func (o InitClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DevfileLocation, error) { +func (o *InitClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DetectionResult, error) { devfileLocation, err := o.SelectDevfile(ctx, flags, o.fsys, contextDir) if err != nil { return parser.DevfileObj{}, "", nil, err @@ -266,6 +283,11 @@ func (o InitClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[s return parser.DevfileObj{}, "", nil, fmt.Errorf("unable to parse devfile: %w", err) } + devfileObj, err = o.HandleApplicationPorts(devfileObj, devfileLocation.ApplicationPorts, flags, o.fsys, contextDir) + if err != nil { + return parser.DevfileObj{}, "", nil, fmt.Errorf("unable to set application ports in devfile: %w", err) + } + devfileObj, err = o.PersonalizeDevfileConfig(devfileObj, flags, o.fsys, contextDir) if err != nil { return parser.DevfileObj{}, "", nil, fmt.Errorf("failed to configure devfile: %w", err) @@ -273,7 +295,7 @@ func (o InitClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[s return devfileObj, devfilePath, devfileLocation, nil } -func (o InitClient) InitDevfile(ctx context.Context, flags map[string]string, contextDir string, +func (o *InitClient) InitDevfile(ctx context.Context, flags map[string]string, contextDir string, preInitHandlerFunc func(interactiveMode bool), newDevfileHandlerFunc func(newDevfileObj parser.DevfileObj) error) error { containsDevfile, err := location.DirectoryContainsDevfile(o.fsys, contextDir) diff --git a/pkg/init/init_test.go b/pkg/init/init_test.go index 883656a3f90..247130bd6af 100644 --- a/pkg/init/init_test.go +++ b/pkg/init/init_test.go @@ -179,7 +179,7 @@ func TestInitClient_downloadDirect(t *testing.T) { type fields struct { fsys func(fs filesystem.Filesystem) filesystem.Filesystem registryClient func(ctrl *gomock.Controller) registry.Client - InitParams api.DevfileLocation + InitParams api.DetectionResult } type args struct { URL string diff --git a/pkg/init/interface.go b/pkg/init/interface.go index 9b9e5ed1e62..6de301ac9e1 100644 --- a/pkg/init/interface.go +++ b/pkg/init/interface.go @@ -37,11 +37,11 @@ type Client interface { // SelectDevfile returns information about a devfile selected based on Alizer if the directory content, // or based on the flags if the directory is empty, or // interactively if flags is empty - SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DevfileLocation, error) + SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DetectionResult, error) // DownloadDevfile downloads a devfile given its location information and a destination directory // and returns the path of the downloaded file - DownloadDevfile(ctx context.Context, devfileLocation *api.DevfileLocation, destDir string) (string, error) + DownloadDevfile(ctx context.Context, devfileLocation *api.DetectionResult, destDir string) (string, error) // SelectStarterProject selects a starter project from the devfile and returns information about the starter project, // depending on the flags. If not starter project is selected, a nil starter is returned @@ -60,5 +60,8 @@ type Client interface { // SelectAndPersonalizeDevfile selects a devfile, then downloads, parse and personalize it // Returns the devfile object, its path and pointer to *api.devfileLocation - SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DevfileLocation, error) + SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DetectionResult, error) + + // HandleApplicationPorts updates the ports in the Devfile accordingly. + HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string, fs filesystem.Filesystem, dir string) (parser.DevfileObj, error) } diff --git a/pkg/init/mock.go b/pkg/init/mock.go index 29362a5d588..7c04fa31d66 100644 --- a/pkg/init/mock.go +++ b/pkg/init/mock.go @@ -39,7 +39,7 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // DownloadDevfile mocks base method. -func (m *MockClient) DownloadDevfile(ctx context.Context, devfileLocation *api.DevfileLocation, destDir string) (string, error) { +func (m *MockClient) DownloadDevfile(ctx context.Context, devfileLocation *api.DetectionResult, destDir string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DownloadDevfile", ctx, devfileLocation, destDir) ret0, _ := ret[0].(string) @@ -81,6 +81,21 @@ func (mr *MockClientMockRecorder) GetFlags(flags interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFlags", reflect.TypeOf((*MockClient)(nil).GetFlags), flags) } +// HandleApplicationPorts mocks base method. +func (m *MockClient) HandleApplicationPorts(devfileobj parser.DevfileObj, ports []int, flags map[string]string, fs filesystem.Filesystem, dir string) (parser.DevfileObj, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleApplicationPorts", devfileobj, ports, flags, fs, dir) + ret0, _ := ret[0].(parser.DevfileObj) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleApplicationPorts indicates an expected call of HandleApplicationPorts. +func (mr *MockClientMockRecorder) HandleApplicationPorts(devfileobj, ports, flags, fs, dir interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleApplicationPorts", reflect.TypeOf((*MockClient)(nil).HandleApplicationPorts), devfileobj, ports, flags, fs, dir) +} + // InitDevfile mocks base method. func (m *MockClient) InitDevfile(ctx context.Context, flags map[string]string, contextDir string, preInitHandlerFunc func(bool), newDevfileHandlerFunc func(parser.DevfileObj) error) error { m.ctrl.T.Helper() @@ -126,12 +141,12 @@ func (mr *MockClientMockRecorder) PersonalizeName(devfile, flags interface{}) *g } // SelectAndPersonalizeDevfile mocks base method. -func (m *MockClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DevfileLocation, error) { +func (m *MockClient) SelectAndPersonalizeDevfile(ctx context.Context, flags map[string]string, contextDir string) (parser.DevfileObj, string, *api.DetectionResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SelectAndPersonalizeDevfile", ctx, flags, contextDir) ret0, _ := ret[0].(parser.DevfileObj) ret1, _ := ret[1].(string) - ret2, _ := ret[2].(*api.DevfileLocation) + ret2, _ := ret[2].(*api.DetectionResult) ret3, _ := ret[3].(error) return ret0, ret1, ret2, ret3 } @@ -143,10 +158,10 @@ func (mr *MockClientMockRecorder) SelectAndPersonalizeDevfile(ctx, flags, contex } // SelectDevfile mocks base method. -func (m *MockClient) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DevfileLocation, error) { +func (m *MockClient) SelectDevfile(ctx context.Context, flags map[string]string, fs filesystem.Filesystem, dir string) (*api.DetectionResult, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SelectDevfile", ctx, flags, fs, dir) - ret0, _ := ret[0].(*api.DevfileLocation) + ret0, _ := ret[0].(*api.DetectionResult) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/pkg/libdevfile/libdevfile.go b/pkg/libdevfile/libdevfile.go index 8de0686dfa3..d4294633e22 100644 --- a/pkg/libdevfile/libdevfile.go +++ b/pkg/libdevfile/libdevfile.go @@ -17,6 +17,8 @@ import ( "github.com/redhat-developer/odo/pkg/util" ) +const DebugEndpointNamePrefix = "debug" + type Handler interface { ApplyImage(image v1alpha2.Component) error ApplyKubernetes(kubernetes v1alpha2.Component) error @@ -324,6 +326,34 @@ func GetEndpointsFromDevfile(devfileObj parser.DevfileObj, ignoreExposures []v1a return endpoints, nil } +// GetDebugEndpointsForComponent returns all Debug endpoints for the specified component. +// It returns an error if the component specified is not a container component. +func GetDebugEndpointsForComponent(cmp v1alpha2.Component) ([]v1alpha2.Endpoint, error) { + if cmp.Container == nil { + return nil, fmt.Errorf("component %q is not a container component", cmp.Name) + } + + var result []v1alpha2.Endpoint + for _, ep := range cmp.Container.Endpoints { + if IsDebugEndpoint(ep) { + result = append(result, ep) + } + } + return result, nil +} + +// IsDebugEndpoint returns whether the specified endpoint represents a Debug endpoint, +// based on the following naming convention: it is considered a Debug endpoint if it's named "debug" or if its name starts with "debug-". +func IsDebugEndpoint(ep v1alpha2.Endpoint) bool { + return IsDebugPort(ep.Name) +} + +// IsDebugPort returns whether the specified string represents a Debug endpoint, +// based on the following naming convention: it is considered a Debug endpoint if it's named "debug" or if its name starts with "debug-". +func IsDebugPort(name string) bool { + return name == DebugEndpointNamePrefix || strings.HasPrefix(name, DebugEndpointNamePrefix+"-") +} + // GetContainerComponentsForCommand returns the list of container components that would get used if the specified command runs. func GetContainerComponentsForCommand(devfileObj parser.DevfileObj, cmd v1alpha2.Command) ([]string, error) { //No error if cmd is empty diff --git a/pkg/libdevfile/libdevfile_test.go b/pkg/libdevfile/libdevfile_test.go index 4c37f4d2997..a66ace4ab3a 100644 --- a/pkg/libdevfile/libdevfile_test.go +++ b/pkg/libdevfile/libdevfile_test.go @@ -807,6 +807,139 @@ func TestGetEndpointsFromDevfile(t *testing.T) { } } +func TestIsDebugEndpoint(t *testing.T) { + type args struct { + endpoint v1alpha2.Endpoint + } + for _, tt := range []struct { + name string + args args + want bool + }{ + { + name: "exactly debug", + args: args{endpoint: v1alpha2.Endpoint{Name: "debug", TargetPort: 5005}}, + want: true, + }, + { + name: "exactly debug - case-sensitive", + args: args{endpoint: v1alpha2.Endpoint{Name: "DEBUG", TargetPort: 5005}}, + }, + { + name: "starting with debug", + args: args{endpoint: v1alpha2.Endpoint{Name: "debug-port", TargetPort: 5005}}, + want: true, + }, + { + name: "starting with debug - case sensitive", + args: args{endpoint: v1alpha2.Endpoint{Name: "DEBUG-PORT", TargetPort: 5005}}, + }, + { + name: "containing debug", + args: args{endpoint: v1alpha2.Endpoint{Name: "my-debug", TargetPort: 5005}}, + }, + { + name: "containing debug prefix", + args: args{endpoint: v1alpha2.Endpoint{Name: "my-debug-port", TargetPort: 5005}}, + }, + { + name: "any other string", + args: args{endpoint: v1alpha2.Endpoint{Name: "lorem-ipsum", TargetPort: 5005}}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + got := IsDebugEndpoint(tt.args.endpoint) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("IsDebugEndpoint() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestGetDebugEndpointsForComponent(t *testing.T) { + type args struct { + cmpProvider func() v1alpha2.Component + } + + for _, tt := range []struct { + name string + args args + wantErr bool + want []v1alpha2.Endpoint + }{ + { + name: "not a container component", + args: args{ + cmpProvider: func() v1alpha2.Component { return testingutil.GetFakeVolumeComponent("vol-comp", "1Gi") }, + }, + wantErr: true, + }, + { + name: "no endpoints in container component", + args: args{ + cmpProvider: func() v1alpha2.Component { return testingutil.GetFakeContainerComponent("my-container-comp") }, + }, + }, + { + name: "mix of debug and non-debug endpoints in container component", + args: args{ + cmpProvider: func() v1alpha2.Component { + comp := testingutil.GetFakeContainerComponent("my-container-comp", 8080, 3000, 9090) + comp.Container.Endpoints = append(comp.Container.Endpoints, v1alpha2.Endpoint{ + Name: "debug", + TargetPort: 5005, + Exposure: v1alpha2.NoneEndpointExposure, + }) + comp.Container.Endpoints = append(comp.Container.Endpoints, v1alpha2.Endpoint{ + Name: "debug1", + TargetPort: 15005, + Exposure: v1alpha2.InternalEndpointExposure, + }) + comp.Container.Endpoints = append(comp.Container.Endpoints, v1alpha2.Endpoint{ + Name: "debug-port2", + TargetPort: 5006, + Exposure: v1alpha2.InternalEndpointExposure, + }) + comp.Container.Endpoints = append(comp.Container.Endpoints, v1alpha2.Endpoint{ + Name: "debug-port3-public", + TargetPort: 5007, + Exposure: v1alpha2.PublicEndpointExposure, + }) + return comp + }, + }, + want: []v1alpha2.Endpoint{ + { + Name: "debug", + TargetPort: 5005, + Exposure: v1alpha2.NoneEndpointExposure, + }, + { + Name: "debug-port2", + TargetPort: 5006, + Exposure: v1alpha2.InternalEndpointExposure, + }, + { + Name: "debug-port3-public", + TargetPort: 5007, + Exposure: v1alpha2.PublicEndpointExposure, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := GetDebugEndpointsForComponent(tt.args.cmpProvider()) + + if tt.wantErr != (err != nil) { + t.Errorf("GetDebugEndpointsForComponent(), wantErr: %v, err: %v", tt.wantErr, err) + } + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("GetDebugEndpointsForComponent() mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestGetK8sManifestWithVariablesSubstituted(t *testing.T) { fakeFs := devfileFileSystem.NewFakeFs() cmpName := "my-cmp-1" diff --git a/pkg/odo/cli/alizer/alizer.go b/pkg/odo/cli/alizer/alizer.go index b69701a7cb6..f5849263ba2 100644 --- a/pkg/odo/cli/alizer/alizer.go +++ b/pkg/odo/cli/alizer/alizer.go @@ -46,15 +46,19 @@ func (o *AlizerOptions) Run(ctx context.Context) (err error) { return errors.New("this command can be run with json output only, please use the flag: -o json") } -// Run contains the logic for the odo command +// RunForJsonOutput contains the logic for the odo command func (o *AlizerOptions) RunForJsonOutput(ctx context.Context) (out interface{}, err error) { workingDir := odocontext.GetWorkingDirectory(ctx) df, reg, err := o.clientset.AlizerClient.DetectFramework(ctx, workingDir) if err != nil { return nil, err } - result := alizer.GetDevfileLocationFromDetection(df, reg) - return []api.DevfileLocation{*result}, nil + appPorts, err := o.clientset.AlizerClient.DetectPorts(workingDir) + if err != nil { + return nil, err + } + result := alizer.NewDetectionResult(df, reg, appPorts) + return []api.DetectionResult{*result}, nil } func NewCmdAlizer(name, fullName string) *cobra.Command { diff --git a/pkg/odo/cli/init/init.go b/pkg/odo/cli/init/init.go index b914b5a3d0c..1679cf8215c 100644 --- a/pkg/odo/cli/init/init.go +++ b/pkg/odo/cli/init/init.go @@ -128,11 +128,12 @@ To start editing your component, use 'odo dev' and open this folder in your favo Changes will be directly reflected on the cluster.`, devfileObj.Data.GetMetadata().Name) if len(o.flags) == 0 { - automateCommad := fmt.Sprintf("odo init --name %s --devfile %s --devfile-registry %s", name, devfileLocation.Devfile, devfileLocation.DevfileRegistry) + automateCommand := fmt.Sprintf("odo init --name %s --devfile %s --devfile-registry %s", name, devfileLocation.Devfile, devfileLocation.DevfileRegistry) if starterInfo != nil { - automateCommad = fmt.Sprintf("%s --starter %s", automateCommad, starterInfo.Name) + automateCommand = fmt.Sprintf("%s --starter %s", automateCommand, starterInfo.Name) } - log.Infof("\nPort configuration using flag is currently not supported \n\nYou can automate this command by executing:\n %s\n", automateCommad) + klog.V(2).Infof("Port configuration using flag is currently not supported") + log.Infof("\nYou can automate this command by executing:\n %s", automateCommand) } if libdevfile.HasDeployCommand(devfileObj.Data) { @@ -158,8 +159,8 @@ func (o *InitOptions) RunForJsonOutput(ctx context.Context) (out interface{}, er }, nil } -// run downloads the devfile and starter project and returns the content of the devfile, path of the devfile, name of the component, api.DevfileLocation object for DevfileRegistry info and StarterProject object -func (o *InitOptions) run(ctx context.Context) (devfileObj parser.DevfileObj, path string, name string, devfileLocation *api.DevfileLocation, starterInfo *v1alpha2.StarterProject, err error) { +// run downloads the devfile and starter project and returns the content of the devfile, path of the devfile, name of the component, api.DetectionResult object for DevfileRegistry info and StarterProject object +func (o *InitOptions) run(ctx context.Context) (devfileObj parser.DevfileObj, path string, name string, devfileLocation *api.DetectionResult, starterInfo *v1alpha2.StarterProject, err error) { var starterDownloaded bool workingDir := odocontext.GetWorkingDirectory(ctx) diff --git a/pkg/testingutil/devfile.go b/pkg/testingutil/devfile.go index 4b8bc91e490..8f57f5170f0 100644 --- a/pkg/testingutil/devfile.go +++ b/pkg/testingutil/devfile.go @@ -1,6 +1,7 @@ package testingutil import ( + "fmt" "path/filepath" "runtime" "strings" @@ -15,13 +16,21 @@ import ( ) // GetFakeContainerComponent returns a fake container component for testing -func GetFakeContainerComponent(name string) v1.Component { +func GetFakeContainerComponent(name string, ports ...int) v1.Component { image := "docker.io/maven:latest" memoryLimit := "128Mi" volumeName := "myvolume1" volumePath := "/my/volume/mount/path1" mountSources := true + var endpoints []v1.Endpoint + for _, p := range ports { + endpoints = append(endpoints, v1.Endpoint{ + Name: fmt.Sprintf("port-%d", p), + TargetPort: p, + }) + } + return v1.Component{ Name: name, ComponentUnion: v1.ComponentUnion{ @@ -36,6 +45,7 @@ func GetFakeContainerComponent(name string) v1.Component { }}, MountSources: &mountSources, }, + Endpoints: endpoints, }}} } diff --git a/tests/integration/interactive_init_test.go b/tests/integration/interactive_init_test.go index b847da0a199..8245d5949b7 100644 --- a/tests/integration/interactive_init_test.go +++ b/tests/integration/interactive_init_test.go @@ -7,6 +7,11 @@ import ( "path/filepath" "strings" + "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/library/pkg/devfile/parser" + "github.com/devfile/library/pkg/devfile/parser/data/v2/common" + "k8s.io/utils/pointer" + "github.com/redhat-developer/odo/pkg/odo/cli/messages" "github.com/redhat-developer/odo/pkg/util" @@ -464,4 +469,52 @@ var _ = Describe("odo init interactive command tests", Label(helper.LabelNoClust Expect(helper.ListFilesInDir(commonVar.Context)).To(ContainElements("devfile.yaml")) }) + + Context("Automatic port detection via Alizer", func() { + + When("starting with an existing project", func() { + const appPort = 34567 + + BeforeEach(func() { + helper.CopyExample(filepath.Join("source", "nodejs"), commonVar.Context) + helper.ReplaceString(filepath.Join(commonVar.Context, "Dockerfile"), "EXPOSE 8080", fmt.Sprintf("EXPOSE %d", appPort)) + }) + + It("should display ports detected", func() { + _, err := helper.RunInteractive([]string{"odo", "init"}, nil, func(ctx helper.InteractiveContext) { + helper.ExpectString(ctx, fmt.Sprintf("Application ports: %d", appPort)) + + helper.SendLine(ctx, "Is this correct") + helper.SendLine(ctx, "") + + helper.ExpectString(ctx, "Select container for which you want to change configuration") + helper.SendLine(ctx, "") + + helper.ExpectString(ctx, "Enter component name") + helper.SendLine(ctx, "my-nodejs-app-with-port-detected") + + helper.ExpectString(ctx, "Your new component 'my-nodejs-app-with-port-detected' is ready in the current directory") + }) + Expect(err).ShouldNot(HaveOccurred()) + + // Now make sure the Devfile contains a single container component with the right endpoint + d, err := parser.ParseDevfile(parser.ParserArgs{Path: filepath.Join(commonVar.Context, "devfile.yaml"), FlattenedDevfile: pointer.BoolPtr(false)}) + Expect(err).ShouldNot(HaveOccurred()) + + containerComponents, err := d.Data.GetDevfileContainerComponents(common.DevfileOptions{}) + Expect(err).ShouldNot(HaveOccurred()) + + allPortsExtracter := func(comps []v1alpha2.Component) []int { + var ports []int + for _, c := range comps { + for _, ep := range c.Container.Endpoints { + ports = append(ports, ep.TargetPort) + } + } + return ports + } + Expect(containerComponents).Should(WithTransform(allPortsExtracter, ContainElements(appPort))) + }) + }) + }) })