diff --git a/docs/user/gen-docs/_sidebar.ts b/docs/user/gen-docs/_sidebar.ts index 1e401f51f..816d595ed 100755 --- a/docs/user/gen-docs/_sidebar.ts +++ b/docs/user/gen-docs/_sidebar.ts @@ -19,6 +19,7 @@ export default [ { text: 'kyma alpha kubeconfig generate', link: './gen-docs/kyma_alpha_kubeconfig_generate' }, { text: 'kyma alpha module', link: './gen-docs/kyma_alpha_module' }, { text: 'kyma alpha module catalog', link: './gen-docs/kyma_alpha_module_catalog' }, + { text: 'kyma alpha module list', link: './gen-docs/kyma_alpha_module_list' }, { text: 'kyma alpha module pull', link: './gen-docs/kyma_alpha_module_pull' }, { text: 'kyma alpha provision', link: './gen-docs/kyma_alpha_provision' }, { text: 'kyma alpha reference-instance', link: './gen-docs/kyma_alpha_reference-instance' }, diff --git a/docs/user/gen-docs/kyma_alpha_module.md b/docs/user/gen-docs/kyma_alpha_module.md index 1627c087c..fb0a80dc8 100644 --- a/docs/user/gen-docs/kyma_alpha_module.md +++ b/docs/user/gen-docs/kyma_alpha_module.md @@ -14,6 +14,7 @@ kyma alpha module [flags] ```text catalog - Lists modules catalog + list - Lists installed modules pull - Pulls a module from a remote repository ``` @@ -31,4 +32,5 @@ kyma alpha module [flags] * [kyma alpha](kyma_alpha.md) - Groups command prototypes for which the API may still change * [kyma alpha module catalog](kyma_alpha_module_catalog.md) - Lists modules catalog +* [kyma alpha module list](kyma_alpha_module_list.md) - Lists installed modules * [kyma alpha module pull](kyma_alpha_module_pull.md) - Pulls a module from a remote repository diff --git a/docs/user/gen-docs/kyma_alpha_module_list.md b/docs/user/gen-docs/kyma_alpha_module_list.md new file mode 100644 index 000000000..cfa4216ab --- /dev/null +++ b/docs/user/gen-docs/kyma_alpha_module_list.md @@ -0,0 +1,29 @@ +# kyma alpha module list + +Lists installed modules. + +## Synopsis + +Use this command to list the installed Kyma modules. + +NOTE: functionality under construction + - community modules not yet supported + +```bash +kyma alpha module list [flags] +``` + +## Flags + +```text + -o, --output string Output format (Possible values: table, json, yaml) + --context string The name of the kubeconfig context to use + -h, --help Help for the command + --kubeconfig string Path to the Kyma kubeconfig file + --show-extensions-error Prints a possible error when fetching extensions fails + --skip-extensions Skips fetching extensions from the target Kyma environment +``` + +## See also + +* [kyma alpha module](kyma_alpha_module.md) - Manages Kyma modules diff --git a/internal/cmd/alpha/module/list.go b/internal/cmd/alpha/module/list.go new file mode 100644 index 000000000..da1f8290d --- /dev/null +++ b/internal/cmd/alpha/module/list.go @@ -0,0 +1,58 @@ +package module + +import ( + "github.com/kyma-project/cli.v3/internal/clierror" + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/kyma-project/cli.v3/internal/cmdcommon/types" + "github.com/kyma-project/cli.v3/internal/modulesv2" + "github.com/kyma-project/cli.v3/internal/out" + "github.com/spf13/cobra" +) + +type listConfig struct { + *cmdcommon.KymaConfig + outputFormat types.Format +} + +func NewListV2CMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { + cfg := listConfig{ + KymaConfig: kymaConfig, + } + + cmd := &cobra.Command{ + Use: "list [flags]", + Short: "Lists installed modules", + Long: `Use this command to list the installed Kyma modules. + +NOTE: functionality under construction + - community modules not yet supported`, + Run: func(_ *cobra.Command, _ []string) { + clierror.Check(listModulesV2(&cfg)) + }, + } + + cmd.Flags().VarP(&cfg.outputFormat, "output", "o", "Output format (Possible values: table, json, yaml)") + + return cmd +} + +func listModulesV2(cfg *listConfig) clierror.Error { + moduleOperations := modulesv2.NewModuleOperations(cfg.KymaConfig) + + listService, err := moduleOperations.List() + if err != nil { + return clierror.Wrap(err, clierror.New("failed to execute the list command")) + } + + results, err := listService.Run(cfg.Ctx) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to list installed modules")) + } + + err = modulesv2.RenderList(results, cfg.outputFormat, out.Default) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to render module list")) + } + + return nil +} diff --git a/internal/cmd/alpha/module/list_test.go b/internal/cmd/alpha/module/list_test.go new file mode 100644 index 000000000..5bbf74f10 --- /dev/null +++ b/internal/cmd/alpha/module/list_test.go @@ -0,0 +1,19 @@ +package module + +import ( + "testing" + + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/stretchr/testify/require" +) + +func TestListCmd_Exists(t *testing.T) { + cmd := NewListV2CMD(&cmdcommon.KymaConfig{}) + require.NotNil(t, cmd) + require.Equal(t, "list [flags]", cmd.Use) +} + +func TestListCmd_HasOutputFlag(t *testing.T) { + cmd := NewListV2CMD(&cmdcommon.KymaConfig{}) + require.NotNil(t, cmd.Flags().Lookup("output")) +} diff --git a/internal/cmd/alpha/module/module.go b/internal/cmd/alpha/module/module.go index ecb6f6f4d..3da7bdfed 100644 --- a/internal/cmd/alpha/module/module.go +++ b/internal/cmd/alpha/module/module.go @@ -15,6 +15,7 @@ func NewModuleCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { cmd.AddCommand(NewCatalogV2CMD(kymaConfig)) cmd.AddCommand(NewPullV2CMD(kymaConfig)) + cmd.AddCommand(NewListV2CMD(kymaConfig)) return cmd } diff --git a/internal/modulesv2/dependencies.go b/internal/modulesv2/dependencies.go index e0f3e6af8..a90fcce06 100644 --- a/internal/modulesv2/dependencies.go +++ b/internal/modulesv2/dependencies.go @@ -12,11 +12,7 @@ import ( type ModuleOperations interface { Catalog() (*CatalogService, error) Pull() (*PullService, error) - // TODO - // Add() (*AddService, error) - // Install() (*InstallService, error) - // Pull() (*PullService, error) - // etc. + List() (*ListService, error) } type moduleOperations struct { @@ -38,6 +34,17 @@ func (m *moduleOperations) Catalog() (*CatalogService, error) { return catalogService, nil } +func (m *moduleOperations) List() (*ListService, error) { + c := setupDIContainer(m.kymaConfig) + + listService, err := di.GetTyped[*ListService](c) + if err != nil { + return nil, errors.New("failed to execute the list command") + } + + return listService, nil +} + func (m *moduleOperations) Pull() (*PullService, error) { c := setupDIContainer(m.kymaConfig) @@ -83,6 +90,24 @@ func setupDIContainer(kymaConfig *cmdcommon.KymaConfig) *di.Container { return repository.NewClusterMetadataRepository(kubeClient), nil }) + di.RegisterTyped(container, func(c *di.Container) (repository.InstalledModulesRepository, error) { + kubeClient, err := di.GetTyped[kube.Client](c) + if err != nil { + return nil, err + } + + return repository.NewInstalledModulesRepository(kubeClient.Kyma()), nil + }) + + di.RegisterTyped(container, func(c *di.Container) (repository.ModuleInstallationStateRepository, error) { + kubeClient, err := di.GetTyped[kube.Client](c) + if err != nil { + return nil, err + } + + return repository.NewModuleInstallationStateRepository(kubeClient), nil + }) + // Services: di.RegisterTyped(container, func(c *di.Container) (*CatalogService, error) { @@ -108,5 +133,19 @@ func setupDIContainer(kymaConfig *cmdcommon.KymaConfig) *di.Container { return NewPullService(moduleRepo), nil }) + di.RegisterTyped(container, func(c *di.Container) (*ListService, error) { + installedModulesRepo, err := di.GetTyped[repository.InstalledModulesRepository](c) + if err != nil { + return nil, err + } + + installationStateRepo, err := di.GetTyped[repository.ModuleInstallationStateRepository](c) + if err != nil { + return nil, err + } + + return NewListService(installedModulesRepo, installationStateRepo), nil + }) + return container } diff --git a/internal/modulesv2/dtos/listresult.go b/internal/modulesv2/dtos/listresult.go new file mode 100644 index 000000000..a4630db40 --- /dev/null +++ b/internal/modulesv2/dtos/listresult.go @@ -0,0 +1,11 @@ +package dtos + +type ListResult struct { + Name string + Version string + Channel string + ModuleState string + Managed bool + CustomResourcePolicy string + InstallationState string +} diff --git a/internal/modulesv2/entities/moduleinstallation.go b/internal/modulesv2/entities/moduleinstallation.go new file mode 100644 index 000000000..177a5a57f --- /dev/null +++ b/internal/modulesv2/entities/moduleinstallation.go @@ -0,0 +1,29 @@ +package entities + +import "github.com/kyma-project/cli.v3/internal/kube/kyma" + +type ModuleInstallation struct { + Name string + Version string + Channel string + ModuleState string + Managed *bool + CustomResourcePolicy string + Template kyma.ModuleStatus +} + +func NewModuleInstallationFromRaw(raw kyma.KymaModuleInfo) *ModuleInstallation { + return &ModuleInstallation{ + Name: raw.Status.Name, + Version: raw.Status.Version, + Channel: raw.Status.Channel, + ModuleState: raw.Status.State, + Managed: raw.Spec.Managed, + CustomResourcePolicy: raw.Spec.CustomResourcePolicy, + Template: raw.Status, + } +} + +func (m *ModuleInstallation) IsManaged() bool { + return m.Managed == nil || *m.Managed +} diff --git a/internal/modulesv2/entities/moduleinstallation_test.go b/internal/modulesv2/entities/moduleinstallation_test.go new file mode 100644 index 000000000..48959d177 --- /dev/null +++ b/internal/modulesv2/entities/moduleinstallation_test.go @@ -0,0 +1,71 @@ +package entities + +import ( + "testing" + + "github.com/kyma-project/cli.v3/internal/kube/kyma" + "github.com/stretchr/testify/require" +) + +func TestModuleInstallation_IsManaged_TrueWhenManagedIsNil(t *testing.T) { + m := ModuleInstallation{Managed: nil} + require.True(t, m.IsManaged()) +} + +func TestModuleInstallation_IsManaged_TrueWhenManagedIsTrue(t *testing.T) { + managed := true + m := ModuleInstallation{Managed: &managed} + require.True(t, m.IsManaged()) +} + +func TestModuleInstallation_IsManaged_FalseWhenManagedIsFalse(t *testing.T) { + managed := false + m := ModuleInstallation{Managed: &managed} + require.False(t, m.IsManaged()) +} + +func TestNewModuleInstallationFromRaw_MapsNameVersionChannel(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Status: kyma.ModuleStatus{Name: "api-gateway", Version: "3.5.1", Channel: "regular"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.Equal(t, "api-gateway", m.Name) + require.Equal(t, "3.5.1", m.Version) + require.Equal(t, "regular", m.Channel) +} + +func TestNewModuleInstallationFromRaw_MapsModuleState(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Status: kyma.ModuleStatus{Name: "api-gateway", State: "Ready"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.Equal(t, "Ready", m.ModuleState) +} + +func TestNewModuleInstallationFromRaw_MapsManaged(t *testing.T) { + managed := false + raw := kyma.KymaModuleInfo{ + Spec: kyma.Module{Managed: &managed}, + Status: kyma.ModuleStatus{Name: "api-gateway"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.NotNil(t, m.Managed) + require.False(t, *m.Managed) +} + +func TestNewModuleInstallationFromRaw_MapsCustomResourcePolicy(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Spec: kyma.Module{CustomResourcePolicy: "CreateAndDelete"}, + Status: kyma.ModuleStatus{Name: "api-gateway"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.Equal(t, "CreateAndDelete", m.CustomResourcePolicy) +} diff --git a/internal/modulesv2/fake/installedmodules.go b/internal/modulesv2/fake/installedmodules.go new file mode 100644 index 000000000..01f0ced1f --- /dev/null +++ b/internal/modulesv2/fake/installedmodules.go @@ -0,0 +1,16 @@ +package fake + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type InstalledModulesRepository struct { + ListInstalledModulesResult []entities.ModuleInstallation + ListInstalledModulesError error +} + +func (f *InstalledModulesRepository) ListInstalledModules(_ context.Context) ([]entities.ModuleInstallation, error) { + return f.ListInstalledModulesResult, f.ListInstalledModulesError +} diff --git a/internal/modulesv2/fake/moduleinstallationstate.go b/internal/modulesv2/fake/moduleinstallationstate.go new file mode 100644 index 000000000..0d2ddce27 --- /dev/null +++ b/internal/modulesv2/fake/moduleinstallationstate.go @@ -0,0 +1,16 @@ +package fake + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type ModuleInstallationStateRepository struct { + GetInstallationStateResult string + GetInstallationStateError error +} + +func (f *ModuleInstallationStateRepository) GetInstallationState(_ context.Context, _ entities.ModuleInstallation) (string, error) { + return f.GetInstallationStateResult, f.GetInstallationStateError +} diff --git a/internal/modulesv2/list.go b/internal/modulesv2/list.go new file mode 100644 index 000000000..6ebae68bc --- /dev/null +++ b/internal/modulesv2/list.go @@ -0,0 +1,60 @@ +package modulesv2 + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" +) + +type ListService struct { + installedModulesRepository repository.InstalledModulesRepository + installationStateRepository repository.ModuleInstallationStateRepository +} + +func NewListService(installedModulesRepository repository.InstalledModulesRepository, installationStateRepository repository.ModuleInstallationStateRepository) *ListService { + return &ListService{ + installedModulesRepository: installedModulesRepository, + installationStateRepository: installationStateRepository, + } +} + +func (s *ListService) Run(ctx context.Context) ([]dtos.ListResult, error) { + installedModules, err := s.installedModulesRepository.ListInstalledModules(ctx) + if err != nil { + return nil, err + } + + results := make([]dtos.ListResult, 0, len(installedModules)) + for _, module := range installedModules { + installationState, err := s.resolveInstallationState(ctx, module) + if err != nil { + return nil, err + } + + results = append(results, dtos.ListResult{ + Name: module.Name, + Version: module.Version, + Channel: module.Channel, + ModuleState: module.ModuleState, + Managed: module.IsManaged(), + CustomResourcePolicy: module.CustomResourcePolicy, + InstallationState: installationState, + }) + } + + return results, nil +} + +func (s *ListService) resolveInstallationState(ctx context.Context, module entities.ModuleInstallation) (string, error) { + if module.CustomResourcePolicy == "CreateAndDelete" { + return module.ModuleState, nil + } + + if !module.IsManaged() { + return module.ModuleState, nil + } + + return s.installationStateRepository.GetInstallationState(ctx, module) +} diff --git a/internal/modulesv2/list_test.go b/internal/modulesv2/list_test.go new file mode 100644 index 000000000..7d4e4e999 --- /dev/null +++ b/internal/modulesv2/list_test.go @@ -0,0 +1,140 @@ +package modulesv2 + +import ( + "context" + "testing" + + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + modulesfake "github.com/kyma-project/cli.v3/internal/modulesv2/fake" + "github.com/stretchr/testify/require" +) + +func TestListService_Run_ReturnsEmptyWhenNoInstalledModules(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{}, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + require.Empty(t, result) +} + +func TestListService_Run_ReturnsCoreModules(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway"}, + {Name: "istio"}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, "api-gateway", result[0].Name) + require.Equal(t, "istio", result[1].Name) +} + +func TestListService_Run_ReturnsCoreModulesWithVersionAndChannel(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Version: "3.5.1", Channel: "regular", ModuleState: "Ready"}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 1) + module := result[0] + require.Equal(t, "api-gateway", module.Name) + require.Equal(t, "3.5.1", module.Version) + require.Equal(t, "regular", module.Channel) + require.Equal(t, "Ready", module.ModuleState) +} + +func TestListService_Run_ReturnsManaged(t *testing.T) { + managed := true + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Managed: &managed}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 1) + module := result[0] + require.Equal(t, "api-gateway", module.Name) + require.True(t, module.Managed) +} + +func TestListService_Run_ReturnsManagedTrueWhenManagedIsNil(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Managed: nil}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + module := result[0] + require.True(t, module.Managed) +} + +func TestListService_Run_ReturnsManagedFalseWhenUnmanaged(t *testing.T) { + managed := false + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Managed: &managed}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + module := result[0] + require.False(t, module.Managed) +} + +func TestListService_Run_ReturnsCustomResourcePolicy(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", CustomResourcePolicy: "CreateAndDelete"}, + }, + } + svc := NewListService(installedModulesRepo, &modulesfake.ModuleInstallationStateRepository{}) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "CreateAndDelete", module.CustomResourcePolicy) +} + +func TestListService_Run_ReturnsInstallationState(t *testing.T) { + installedModulesRepo := &modulesfake.InstalledModulesRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway"}, + }, + } + installationStateRepo := &modulesfake.ModuleInstallationStateRepository{ + GetInstallationStateResult: "Ready", + } + svc := NewListService(installedModulesRepo, installationStateRepo) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "Ready", module.InstallationState) +} diff --git a/internal/modulesv2/render.go b/internal/modulesv2/render.go index 65caf291c..b447322db 100644 --- a/internal/modulesv2/render.go +++ b/internal/modulesv2/render.go @@ -2,6 +2,7 @@ package modulesv2 import ( "encoding/json" + "fmt" "sort" "strings" @@ -12,6 +13,89 @@ import ( "gopkg.in/yaml.v3" ) +func RenderList(results []dtos.ListResult, format types.Format, printer *out.Printer) error { + switch format { + case types.JSONFormat: + return renderListJSON(results, printer) + case types.YAMLFormat: + return renderListYAML(results, printer) + default: + return renderListTable(results, printer) + } +} + +func renderListJSON(results []dtos.ListResult, printer *out.Printer) error { + output := convertListToOutputFormat(results) + obj, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + printer.Msgln(string(obj)) + return nil +} + +func renderListYAML(results []dtos.ListResult, printer *out.Printer) error { + output := convertListToOutputFormat(results) + obj, err := yaml.Marshal(output) + if err != nil { + return err + } + printer.Msgln(string(obj)) + return nil +} + +func convertListToOutputFormat(results []dtos.ListResult) []map[string]interface{} { + output := make([]map[string]interface{}, len(results)) + for i, r := range results { + output[i] = map[string]interface{}{ + "name": r.Name, + "version": r.Version, + "channel": r.Channel, + "moduleStatus": r.ModuleState, + "managed": r.Managed, + "crPolicy": r.CustomResourcePolicy, + "installationStatus": r.InstallationState, + } + } + return output +} + +func renderListTable(results []dtos.ListResult, printer *out.Printer) error { + sortListResults(results) + headers := []interface{}{"MODULE", "VERSION", "CR POLICY", "MANAGED", "MODULE STATUS", "INSTALLATION STATUS"} + rows := convertListToRows(results) + render.Table(printer, headers, rows) + return nil +} + +func sortListResults(results []dtos.ListResult) { + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) +} + +func convertListToRows(results []dtos.ListResult) [][]interface{} { + rows := make([][]interface{}, len(results)) + for i, r := range results { + rows[i] = []interface{}{r.Name, versionWithChannel(r), r.CustomResourcePolicy, r.Managed, r.ModuleState, installationStatus(r)} + } + return rows +} + +func installationStatus(r dtos.ListResult) string { + if r.InstallationState != "" && r.ModuleState != r.InstallationState { + return fmt.Sprintf("%s(%s)", r.ModuleState, r.InstallationState) + } + return r.InstallationState +} + +func versionWithChannel(r dtos.ListResult) string { + if r.Channel == "" { + return r.Version + } + return fmt.Sprintf("%s(%s)", r.Version, r.Channel) +} + func RenderCatalog(results []dtos.CatalogResult, format types.Format) error { switch format { case types.JSONFormat: diff --git a/internal/modulesv2/render_test.go b/internal/modulesv2/render_test.go new file mode 100644 index 000000000..f9920663c --- /dev/null +++ b/internal/modulesv2/render_test.go @@ -0,0 +1,84 @@ +package modulesv2 + +import ( + "bytes" + "testing" + + "github.com/kyma-project/cli.v3/internal/cmdcommon/types" + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/out" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestRenderList_Table(t *testing.T) { + results := []dtos.ListResult{ + {Name: "api-gateway", Version: "3.5.1", Channel: "regular", ModuleState: "Ready", Managed: true, CustomResourcePolicy: "CreateAndDelete", InstallationState: "Ready"}, + } + + var buf bytes.Buffer + err := RenderList(results, types.DefaultFormat, out.NewToWriter(&buf)) + + require.NoError(t, err) + require.Regexp(t, `MODULE.*VERSION.*CR POLICY.*MANAGED.*MODULE STATUS.*INSTALLATION STATUS`, buf.String()) + require.Regexp(t, `api-gateway.*3\.5\.1\(regular\).*CreateAndDelete.*true.*Ready.*Ready`, buf.String()) +} + +func TestRenderList_JSON(t *testing.T) { + results := []dtos.ListResult{ + {Name: "api-gateway", Version: "3.5.1", Channel: "regular", ModuleState: "Ready", Managed: true, CustomResourcePolicy: "CreateAndDelete", InstallationState: "Ready"}, + } + + var buf bytes.Buffer + err := RenderList(results, types.JSONFormat, out.NewToWriter(&buf)) + + require.NoError(t, err) + require.JSONEq(t, `[{"name":"api-gateway","version":"3.5.1","channel":"regular","moduleStatus":"Ready","managed":true,"crPolicy":"CreateAndDelete","installationStatus":"Ready"}]`, buf.String()) +} + +func TestRenderList_Table_SortedByName(t *testing.T) { + results := []dtos.ListResult{ + {Name: "istio"}, + {Name: "api-gateway"}, + } + + var buf bytes.Buffer + err := RenderList(results, types.DefaultFormat, out.NewToWriter(&buf)) + + require.NoError(t, err) + require.Regexp(t, `(?s)api-gateway.*istio`, buf.String()) +} + +func TestRenderList_Table_CombinedInstallationStatus(t *testing.T) { + results := []dtos.ListResult{ + {Name: "nats", ModuleState: "Warning", InstallationState: "Deleting"}, + } + + var buf bytes.Buffer + err := RenderList(results, types.DefaultFormat, out.NewToWriter(&buf)) + + require.NoError(t, err) + require.Regexp(t, `nats.*Warning\(Deleting\)`, buf.String()) +} + +func TestRenderList_YAML(t *testing.T) { + results := []dtos.ListResult{ + {Name: "api-gateway", Version: "3.5.1", Channel: "regular", ModuleState: "Ready", Managed: true, CustomResourcePolicy: "CreateAndDelete", InstallationState: "Ready"}, + } + + var buf bytes.Buffer + err := RenderList(results, types.YAMLFormat, out.NewToWriter(&buf)) + + require.NoError(t, err) + var parsed []map[string]interface{} + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &parsed)) + require.Len(t, parsed, 1) + module := parsed[0] + require.Equal(t, "api-gateway", module["name"]) + require.Equal(t, "3.5.1", module["version"]) + require.Equal(t, "regular", module["channel"]) + require.Equal(t, true, module["managed"]) + require.Equal(t, "CreateAndDelete", module["crPolicy"]) + require.Equal(t, "Ready", module["moduleStatus"]) + require.Equal(t, "Ready", module["installationStatus"]) +} diff --git a/internal/modulesv2/repository/installedmodules.go b/internal/modulesv2/repository/installedmodules.go new file mode 100644 index 000000000..cab79e2cd --- /dev/null +++ b/internal/modulesv2/repository/installedmodules.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/kube/kyma" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type InstalledModulesRepository interface { + ListInstalledModules(ctx context.Context) ([]entities.ModuleInstallation, error) +} + +type installedModulesRepository struct { + kymaClient kyma.Interface +} + +func NewInstalledModulesRepository(kymaClient kyma.Interface) InstalledModulesRepository { + return &installedModulesRepository{kymaClient: kymaClient} +} + +func (r *installedModulesRepository) ListInstalledModules(ctx context.Context) ([]entities.ModuleInstallation, error) { + kymaCR, err := r.kymaClient.GetDefaultKyma(ctx) + if err != nil { + return nil, err + } + + modules := make([]entities.ModuleInstallation, len(kymaCR.Status.Modules)) + for i, status := range kymaCR.Status.Modules { + raw := kyma.KymaModuleInfo{Status: status} + for _, spec := range kymaCR.Spec.Modules { + if spec.Name == status.Name { + raw.Spec = spec + break + } + } + modules[i] = *entities.NewModuleInstallationFromRaw(raw) + } + return modules, nil +} diff --git a/internal/modulesv2/repository/installedmodules_test.go b/internal/modulesv2/repository/installedmodules_test.go new file mode 100644 index 000000000..d56e437dd --- /dev/null +++ b/internal/modulesv2/repository/installedmodules_test.go @@ -0,0 +1,32 @@ +package repository_test + +import ( + "context" + "testing" + + kubefake "github.com/kyma-project/cli.v3/internal/kube/fake" + "github.com/kyma-project/cli.v3/internal/kube/kyma" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" + "github.com/stretchr/testify/require" +) + +func TestInstalledModulesRepository_ListInstalledModules(t *testing.T) { + kymaClient := &kubefake.KymaClient{ + ReturnDefaultKyma: kyma.Kyma{ + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{ + {Name: "api-gateway"}, + {Name: "istio"}, + }, + }, + }, + } + repo := repository.NewInstalledModulesRepository(kymaClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 2) + require.Equal(t, "api-gateway", result[0].Name) + require.Equal(t, "istio", result[1].Name) +} diff --git a/internal/modulesv2/repository/moduleinstallationstate.go b/internal/modulesv2/repository/moduleinstallationstate.go new file mode 100644 index 000000000..353504349 --- /dev/null +++ b/internal/modulesv2/repository/moduleinstallationstate.go @@ -0,0 +1,114 @@ +package repository + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/kube" + "github.com/kyma-project/cli.v3/internal/kube/kyma" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type ModuleInstallationStateRepository interface { + GetInstallationState(ctx context.Context, module entities.ModuleInstallation) (string, error) +} + +type moduleInstallationStateRepository struct { + kubeClient kube.Client +} + +func NewModuleInstallationStateRepository(kubeClient kube.Client) ModuleInstallationStateRepository { + return &moduleInstallationStateRepository{kubeClient: kubeClient} +} + +func (r *moduleInstallationStateRepository) GetInstallationState(ctx context.Context, module entities.ModuleInstallation) (string, error) { + moduleTemplate, err := r.kubeClient.Kyma().GetModuleTemplate(ctx, module.Template.Template.GetNamespace(), module.Template.Template.GetName()) + if err != nil { + if apierrors.IsNotFound(err) { + return "", nil + } + return "", errors.Wrapf(err, "failed to get ModuleTemplate %s/%s", module.Template.Template.GetNamespace(), module.Template.Template.GetName()) + } + + return getResourceState(ctx, r.kubeClient, moduleTemplate.Spec.Manager) +} + +func getResourceState(ctx context.Context, client kube.Client, manager *kyma.Manager) (string, error) { + if manager == nil { + return "", nil + } + namespace := "kyma-system" + if manager.Namespace != "" { + namespace = manager.Namespace + } + + apiVersion := manager.Group + "/" + manager.Version + unstruct := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": manager.Kind, + "metadata": map[string]interface{}{ + "name": manager.Name, + "namespace": namespace, + }, + }, + } + + result, err := client.RootlessDynamic().Get(ctx, &unstruct) + if err != nil { + if apierrors.IsNotFound(err) { + return "", nil + } + return "", err + } + + statusRaw, ok := result.Object["status"] + if !ok || statusRaw == nil { + return "", nil + } + status := statusRaw.(map[string]any) + if state, ok := status["state"]; ok { + return state.(string), nil + } + + if conditions, ok := status["conditions"]; ok { + return getStateFromConditions(conditions.([]any)), nil + } + + if readyReplicas, ok := status["readyReplicas"]; ok { + spec := result.Object["spec"].(map[string]any) + if wantedReplicas, ok := spec["replicas"]; ok { + return resolveStateFromReplicas(readyReplicas.(int64), wantedReplicas.(int64)), nil + } + } + + return "", nil +} + +func getStateFromConditions(conditions []interface{}) string { + for _, condition := range conditions { + c := condition.(map[string]interface{}) + if c["status"] != "True" { + continue + } + switch c["type"].(string) { + case "Available": + return "Ready" + case "Processing", "Error", "Warning": + return c["type"].(string) + } + } + return "" +} + +func resolveStateFromReplicas(ready, wanted int64) string { + if ready == wanted { + return "Ready" + } + if ready < wanted { + return "Processing" + } + return "Deleting" +}