diff --git a/internal/kube/kyma/kyma.go b/internal/kube/kyma/kyma.go index 1f494ab44..ddb5ac038 100644 --- a/internal/kube/kyma/kyma.go +++ b/internal/kube/kyma/kyma.go @@ -12,8 +12,8 @@ import ( ) const ( - defaultKymaName = "default" - defaultKymaNamespace = "kyma-system" + DefaultKymaName = "default" + DefaultKymaNamespace = "kyma-system" ) type Interface interface { @@ -35,31 +35,21 @@ func NewClient(dynamic dynamic.Interface) Interface { } } +// ListModuleReleaseMeta lists ModuleReleaseMeta resources from across the whole cluster func (c *client) ListModuleReleaseMeta(ctx context.Context) (*ModuleReleaseMetaList, error) { return list[ModuleReleaseMetaList](ctx, c.dynamic, GVRModuleReleaseMeta) } +// ListModuleTemplate lists ModuleTemplate resources from across the whole cluster func (c *client) ListModuleTemplate(ctx context.Context) (*ModuleTemplateList, error) { return list[ModuleTemplateList](ctx, c.dynamic, GVRModuleTemplate) } -func list[T any](ctx context.Context, client dynamic.Interface, gvr schema.GroupVersionResource) (*T, error) { - list, err := client.Resource(gvr). - List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err - } - - structuredList := new(T) - err = runtime.DefaultUnstructuredConverter.FromUnstructured(list.UnstructuredContent(), structuredList) - return structuredList, err -} - // GetDefaultKyma gets the default Kyma CR from the kyma-system namespace and cast it to the Kyma structure func (c *client) GetDefaultKyma(ctx context.Context) (*Kyma, error) { u, err := c.dynamic.Resource(GVRKyma). - Namespace(defaultKymaNamespace). - Get(ctx, defaultKymaName, metav1.GetOptions{}) + Namespace(DefaultKymaNamespace). + Get(ctx, DefaultKymaName, metav1.GetOptions{}) if err != nil { return nil, err } @@ -78,7 +68,7 @@ func (c *client) UpdateDefaultKyma(ctx context.Context, obj *Kyma) error { } _, err = c.dynamic.Resource(GVRKyma). - Namespace(defaultKymaNamespace). + Namespace(DefaultKymaNamespace). Update(ctx, &unstructured.Unstructured{Object: u}, metav1.UpdateOptions{}) return err @@ -133,3 +123,15 @@ func disableModule(kymaCR *Kyma, moduleName string) *Kyma { return kymaCR } + +func list[T any](ctx context.Context, client dynamic.Interface, gvr schema.GroupVersionResource) (*T, error) { + list, err := client.Resource(gvr). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + structuredList := new(T) + err = runtime.DefaultUnstructuredConverter.FromUnstructured(list.UnstructuredContent(), structuredList) + return structuredList, err +} diff --git a/internal/kube/kyma/types.go b/internal/kube/kyma/types.go index d54d63968..117d628b7 100644 --- a/internal/kube/kyma/types.go +++ b/internal/kube/kyma/types.go @@ -114,6 +114,7 @@ type Module struct { ControllerName string `json:"controller,omitempty"` Channel string `json:"channel,omitempty"` CustomResourcePolicy string `json:"customResourcePolicy,omitempty"` + Managed bool `json:"managed,omitempty"` } // KymaStatus defines the observed state of Kyma @@ -125,6 +126,7 @@ type ModuleStatus struct { Name string `json:"name"` Channel string `json:"channel,omitempty"` Version string `json:"version,omitempty"` + State string `json:"state,omitempty"` } // ModuleFromInterface converts a map retrieved from the Unstructured kyma CR to a Module struct. diff --git a/internal/modules/modules.go b/internal/modules/modules.go index 9f02de905..085b780e7 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -2,13 +2,29 @@ package modules import ( "context" + "strconv" "github.com/kyma-project/cli.v3/internal/kube/kyma" + apierrors "k8s.io/apimachinery/pkg/api/errors" ) type Module struct { - Name string - Versions []ModuleVersion + Name string + Versions []ModuleVersion + InstallDetails ModuleInstallDetails +} + +type Managed string + +const ( + ManagedTrue Managed = "true" + ManagedFalse Managed = "false" +) + +type ModuleInstallDetails struct { + Version string + Channel string + Managed Managed } type ModuleVersion struct { @@ -30,25 +46,32 @@ func List(ctx context.Context, client kyma.Interface) (ModulesList, error) { return nil, err } + defaultKyma, err := client.GetDefaultKyma(ctx) + if err != nil && !apierrors.IsNotFound(err) { + return nil, err + } + modulesList := ModulesList{} for _, moduleTemplate := range moduleTemplates.Items { + moduleName := moduleTemplate.Spec.ModuleName version := ModuleVersion{ Version: moduleTemplate.Spec.Version, Repository: moduleTemplate.Spec.Info.Repository, Channel: getAssignedChannel( *modulereleasemetas, - moduleTemplate.Spec.ModuleName, + moduleName, moduleTemplate.Spec.Version, ), } - if i := getModuleIndex(modulesList, moduleTemplate.Spec.ModuleName); i != -1 { + if i := getModuleIndex(modulesList, moduleName); i != -1 { // append version if module with same name is in the list modulesList[i].Versions = append(modulesList[i].Versions, version) } else { // otherwise create anew record in the list modulesList = append(modulesList, Module{ - Name: moduleTemplate.Spec.ModuleName, + Name: moduleName, + InstallDetails: getInstallDetails(defaultKyma, *modulereleasemetas, moduleName), Versions: []ModuleVersion{ version, }, @@ -59,6 +82,37 @@ func List(ctx context.Context, client kyma.Interface) (ModulesList, error) { return modulesList, nil } +func getInstallDetails(kyma *kyma.Kyma, releaseMetas kyma.ModuleReleaseMetaList, moduleName string) ModuleInstallDetails { + if kyma != nil { + for _, module := range kyma.Status.Modules { + if module.Name == moduleName { + moduleVersion := module.Version + return ModuleInstallDetails{ + Channel: getAssignedChannel(releaseMetas, module.Name, moduleVersion), + Managed: getManaged(kyma.Spec.Modules, moduleName), + Version: moduleVersion, + } + } + } + } + + // TODO: support community modules + + // return empty struct because module is not installed + return ModuleInstallDetails{} +} + +// look for value of managed for specific moduleName +func getManaged(specModules []kyma.Module, moduleName string) Managed { + for _, module := range specModules { + if module.Name == moduleName { + return Managed(strconv.FormatBool(module.Managed)) + } + } + + return "" +} + // look for channel assigned to version with specified moduleName func getAssignedChannel(releaseMetas kyma.ModuleReleaseMetaList, moduleName, version string) string { for _, releaseMeta := range releaseMetas.Items { diff --git a/internal/modules/modules_test.go b/internal/modules/modules_test.go index be281487b..97682ecfc 100644 --- a/internal/modules/modules_test.go +++ b/internal/modules/modules_test.go @@ -124,31 +124,85 @@ var ( }, }, } -) - -func TestList(t *testing.T) { - t.Run("list modules from cluster", func(t *testing.T) { - scheme := runtime.NewScheme() - scheme.AddKnownTypes(kyma.GVRModuleTemplate.GroupVersion()) - scheme.AddKnownTypes(kyma.GVRModuleReleaseMeta.GroupVersion()) - dynamicClient := dynamic_fake.NewSimpleDynamicClient(scheme, - &testModuleTemplate1, - &testModuleTemplate2, - &testModuleTemplate3, - &testModuleTemplate4, - &testReleaseMeta1, - &testReleaseMeta2, - ) - modules, err := List(context.Background(), kyma.NewClient(dynamicClient)) + testKymaCR = unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "operator.kyma-project.io/v1beta2", + "kind": "Kyma", + "metadata": map[string]interface{}{ + "name": kyma.DefaultKymaName, + "namespace": kyma.DefaultKymaNamespace, + }, + "spec": map[string]interface{}{ + "channel": "fast", + "modules": []interface{}{ + map[string]interface{}{ + "name": "serverless", + "managed": false, + }, + map[string]interface{}{ + "name": "keda", + "managed": true, + }, + }, + }, + "status": map[string]interface{}{ + "modules": []interface{}{ + map[string]interface{}{ + "name": "serverless", + "version": "0.0.1", + }, + map[string]interface{}{ + "name": "keda", + "version": "0.2", + }, + }, + }, + }, + } - require.NoError(t, err) - require.Equal(t, ModulesList(fixModuleList()), modules) - }) -} + testManagedModuleList = []Module{ + { + Name: "keda", + InstallDetails: ModuleInstallDetails{ + Managed: ManagedTrue, + Channel: "fast", + Version: "0.2", + }, + Versions: []ModuleVersion{ + { + Repository: "url-3", + Version: "0.1", + Channel: "regular", + }, + { + Version: "0.2", + Channel: "fast", + }, + }, + }, + { + Name: "serverless", + InstallDetails: ModuleInstallDetails{ + Managed: ManagedFalse, + Channel: "fast", + Version: "0.0.1", + }, + Versions: []ModuleVersion{ + { + Repository: "url-1", + Version: "0.0.1", + Channel: "fast", + }, + { + Repository: "url-2", + Version: "0.0.2", + }, + }, + }, + } -func fixModuleList() []Module { - return []Module{ + testModuleList = []Module{ { Name: "keda", Versions: []ModuleVersion{ @@ -178,4 +232,46 @@ func fixModuleList() []Module { }, }, } +) + +func TestList(t *testing.T) { + t.Run("list modules from cluster without Kyma CR", func(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypes(kyma.GVRModuleTemplate.GroupVersion()) + scheme.AddKnownTypes(kyma.GVRModuleReleaseMeta.GroupVersion()) + dynamicClient := dynamic_fake.NewSimpleDynamicClient(scheme, + &testModuleTemplate1, + &testModuleTemplate2, + &testModuleTemplate3, + &testModuleTemplate4, + &testReleaseMeta1, + &testReleaseMeta2, + ) + + modules, err := List(context.Background(), kyma.NewClient(dynamicClient)) + + require.NoError(t, err) + require.Equal(t, ModulesList(testModuleList), modules) + }) + + t.Run("list managed modules from cluster", func(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypes(kyma.GVRModuleTemplate.GroupVersion()) + scheme.AddKnownTypes(kyma.GVRModuleReleaseMeta.GroupVersion()) + scheme.AddKnownTypes(kyma.GVRKyma.GroupVersion()) + dynamicClient := dynamic_fake.NewSimpleDynamicClient(scheme, + &testModuleTemplate1, + &testModuleTemplate2, + &testModuleTemplate3, + &testModuleTemplate4, + &testReleaseMeta1, + &testReleaseMeta2, + &testKymaCR, + ) + + modules, err := List(context.Background(), kyma.NewClient(dynamicClient)) + + require.NoError(t, err) + require.Equal(t, ModulesList(testManagedModuleList), modules) + }) } diff --git a/internal/modules/render.go b/internal/modules/render.go index 429345580..872414264 100644 --- a/internal/modules/render.go +++ b/internal/modules/render.go @@ -20,9 +20,14 @@ type TableInfo struct { var ( ModulesTableInfo = TableInfo{ - Header: []string{"NAME", "REPOSITORY", "VERSIONS"}, + Header: []string{"NAME", "VERSIONS", "INSTALLED", "MANAGED"}, RowConverter: func(m Module) []string { - return []string{m.Name, convertRepositories(m.Versions), convertVersions(m.Versions)} + return []string{ + m.Name, + convertVersions(m.Versions), + convertInstall(m.InstallDetails), + string(m.InstallDetails.Managed), + } }, } ) @@ -51,24 +56,21 @@ func convertModuleListToTable(modulesList ModulesList, rowConverter RowConverter return result } -// renderTable renders the table with the provided headers +// renderTable renders the table with the provided headers and data func renderTable(writer io.Writer, modulesData [][]string, headers []string) { - var table [][]string - table = append(table, modulesData...) - - twTable := setTable(writer, table) + twTable := setTable(writer) + twTable.AppendBulk(modulesData) twTable.SetHeader(headers) twTable.Render() } // setTable sets the table settings for the tablewriter -func setTable(writer io.Writer, inTable [][]string) *tablewriter.Table { +func setTable(writer io.Writer) *tablewriter.Table { table := tablewriter.NewWriter(writer) - table.AppendBulk(inTable) table.SetRowLine(false) table.SetHeaderLine(false) table.SetColumnSeparator("") - table.SetAlignment(tablewriter.ALIGN_CENTER) + table.SetAlignment(tablewriter.ALIGN_LEFT) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetColumnAlignment([]int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT, tablewriter.ALIGN_LEFT}) table.SetBorder(false) @@ -77,31 +79,13 @@ func setTable(writer io.Writer, inTable [][]string) *tablewriter.Table { return table } -// convert versions to string containing links to repositories without duplicates separated by '\n' -func convertRepositories(versions []ModuleVersion) string { - values := []string{} - for _, version := range versions { - if version.Repository == "" { - // ignore if repository is empty - continue - } - - if !contains(values, version.Repository) { - values = append(values, version.Repository) - } - } - - return strings.Join(values, ", ") -} - -func contains(in []string, value string) bool { - for _, inValue := range in { - if inValue == value { - return true - } +// convert version and channel into field in format 'version (channel)' for core modules and 'version' for community ones +func convertInstall(details ModuleInstallDetails) string { + if details.Channel != "" { + return fmt.Sprintf("%s(%s)", details.Version, details.Channel) } - return false + return details.Version } // convert versions to string containing values separated with '\n' @@ -111,7 +95,7 @@ func convertVersions(versions []ModuleVersion) string { for i, version := range versions { value := version.Version if version.Channel != "" { - value += fmt.Sprintf(" (%s)", version.Channel) + value += fmt.Sprintf("(%s)", version.Channel) } values[i] = value diff --git a/internal/modules/render_test.go b/internal/modules/render_test.go index b9c1441c1..264ae1553 100644 --- a/internal/modules/render_test.go +++ b/internal/modules/render_test.go @@ -9,17 +9,28 @@ import ( ) const ( - testModulesTableView = "NAME \tREPOSITORY \tVERSIONS \nkeda \turl-3 \t0.1 (regular), 0.2 (fast)\t\nserverless\turl-1, url-2\t0.0.1 (fast), 0.0.2 \t\n" + testModulesTableView = "NAME \tVERSIONS \tINSTALLED\tMANAGED \nkeda \t0.1(regular), 0.2(fast)\t \t \t\nserverless\t0.0.1(fast), 0.0.2 \t \t \t\n" + testManagedModulesTableView = "NAME \tVERSIONS \tINSTALLED \tMANAGED \nkeda \t0.1(regular), 0.2(fast)\t0.2(fast) \ttrue \t\nserverless\t0.0.1(fast), 0.0.2 \t0.0.1(fast)\tfalse \t\n" ) func TestRender(t *testing.T) { t.Run("render table from modules", func(t *testing.T) { buffer := bytes.NewBuffer([]byte{}) - render(buffer, fixModuleList(), ModulesTableInfo) + render(buffer, testModuleList, ModulesTableInfo) tableViewBytes, err := io.ReadAll(buffer) require.NoError(t, err) require.Equal(t, testModulesTableView, string(tableViewBytes)) }) + + t.Run("render table from managed modules", func(t *testing.T) { + buffer := bytes.NewBuffer([]byte{}) + + render(buffer, testManagedModuleList, ModulesTableInfo) + + tableViewBytes, err := io.ReadAll(buffer) + require.NoError(t, err) + require.Equal(t, testManagedModulesTableView, string(tableViewBytes)) + }) }