diff --git a/docs/user/gen-docs/_sidebar.md b/docs/user/gen-docs/_sidebar.md index 990652a0b..c2ccd730a 100644 --- a/docs/user/gen-docs/_sidebar.md +++ b/docs/user/gen-docs/_sidebar.md @@ -11,6 +11,8 @@ * [kyma alpha hana map](/cli/user/gen-docs/kyma_alpha_hana_map.md) * [kyma alpha kubeconfig](/cli/user/gen-docs/kyma_alpha_kubeconfig.md) * [kyma alpha kubeconfig generate](/cli/user/gen-docs/kyma_alpha_kubeconfig_generate.md) +* [kyma alpha module](/cli/user/gen-docs/kyma_alpha_module.md) +* [kyma alpha module catalog](/cli/user/gen-docs/kyma_alpha_module_catalog.md) * [kyma alpha provision](/cli/user/gen-docs/kyma_alpha_provision.md) * [kyma alpha reference-instance](/cli/user/gen-docs/kyma_alpha_reference-instance.md) * [kyma app](/cli/user/gen-docs/kyma_app.md) diff --git a/docs/user/gen-docs/_sidebar.ts b/docs/user/gen-docs/_sidebar.ts index 70c7413e1..beb66ba9c 100755 --- a/docs/user/gen-docs/_sidebar.ts +++ b/docs/user/gen-docs/_sidebar.ts @@ -10,6 +10,8 @@ export default [ { text: 'kyma alpha hana map', link: './gen-docs/kyma_alpha_hana_map' }, { text: 'kyma alpha kubeconfig', link: './gen-docs/kyma_alpha_kubeconfig' }, { 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 provision', link: './gen-docs/kyma_alpha_provision' }, { text: 'kyma alpha reference-instance', link: './gen-docs/kyma_alpha_reference-instance' }, { text: 'kyma app', link: './gen-docs/kyma_app' }, diff --git a/docs/user/gen-docs/kyma_alpha.md b/docs/user/gen-docs/kyma_alpha.md index 1bd0d92a1..47aae21bd 100644 --- a/docs/user/gen-docs/kyma_alpha.md +++ b/docs/user/gen-docs/kyma_alpha.md @@ -17,6 +17,7 @@ kyma alpha [flags] diagnose - Diagnose cluster health and configuration hana - Manages an SAP HANA instance in the Kyma cluster kubeconfig - Manages access to the Kyma cluster + module - Manages Kyma modules provision - Provisions a Kyma cluster on SAP BTP reference-instance - Adds an instance reference to a shared service instance ``` @@ -38,5 +39,6 @@ kyma alpha [flags] * [kyma alpha diagnose](kyma_alpha_diagnose.md) - Diagnose cluster health and configuration * [kyma alpha hana](kyma_alpha_hana.md) - Manages an SAP HANA instance in the Kyma cluster * [kyma alpha kubeconfig](kyma_alpha_kubeconfig.md) - Manages access to the Kyma cluster +* [kyma alpha module](kyma_alpha_module.md) - Manages Kyma modules * [kyma alpha provision](kyma_alpha_provision.md) - Provisions a Kyma cluster on SAP BTP * [kyma alpha reference-instance](kyma_alpha_reference-instance.md) - Adds an instance reference to a shared service instance diff --git a/docs/user/gen-docs/kyma_alpha_module.md b/docs/user/gen-docs/kyma_alpha_module.md new file mode 100644 index 000000000..43891c1b0 --- /dev/null +++ b/docs/user/gen-docs/kyma_alpha_module.md @@ -0,0 +1,32 @@ +# kyma alpha module + +Manages Kyma modules. + +## Synopsis + +Use this command to manage modules in the Kyma cluster. + +```bash +kyma alpha module [flags] +``` + +## Available Commands + +```text + catalog - Lists modules catalog +``` + +## Flags + +```text + --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 Skip fetching extensions from the target Kyma environment +``` + +## See also + +* [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 diff --git a/docs/user/gen-docs/kyma_alpha_module_catalog.md b/docs/user/gen-docs/kyma_alpha_module_catalog.md new file mode 100644 index 000000000..63ed85670 --- /dev/null +++ b/docs/user/gen-docs/kyma_alpha_module_catalog.md @@ -0,0 +1,53 @@ +# kyma alpha module catalog + +Lists modules catalog. + +## Synopsis + +Use this command to list all available Kyma modules. + +```bash +kyma alpha module catalog [flags] +``` + +## Examples + +```bash + + # List all available modules from all origins + kyma module catalog + + # List only official Kyma modules managed by KLM with SLA + kyma module catalog --origin kyma + + # List only community modules (not officially supported) + kyma module catalog --origin community + + # List only community modules already available on the cluster + kyma module catalog --origin cluster + + # List modules from multiple origins + kyma module catalog --origin kyma,community + + # Output catalog as JSON + kyma module catalog -o json + + # List official Kyma modules in YAML format + kyma module catalog --origin kyma -o yaml +``` + +## Flags + +```text + --origin stringSlice Specifies the source of the module (default "[kyma,community,cluster]") + -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 Skip 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/alpha.go b/internal/cmd/alpha/alpha.go index 396e9a80a..2425df103 100644 --- a/internal/cmd/alpha/alpha.go +++ b/internal/cmd/alpha/alpha.go @@ -5,6 +5,7 @@ import ( "github.com/kyma-project/cli.v3/internal/cmd/alpha/diagnose" "github.com/kyma-project/cli.v3/internal/cmd/alpha/hana" "github.com/kyma-project/cli.v3/internal/cmd/alpha/kubeconfig" + "github.com/kyma-project/cli.v3/internal/cmd/alpha/module" "github.com/kyma-project/cli.v3/internal/cmd/alpha/provision" "github.com/kyma-project/cli.v3/internal/cmd/alpha/referenceinstance" "github.com/kyma-project/cli.v3/internal/cmdcommon" @@ -25,6 +26,7 @@ func NewAlphaCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { cmd.AddCommand(referenceinstance.NewReferenceInstanceCMD(kymaConfig)) cmd.AddCommand(kubeconfig.NewKubeconfigCMD(kymaConfig)) cmd.AddCommand(diagnose.NewDiagnoseCMD(kymaConfig)) + cmd.AddCommand(module.NewModuleCMD(kymaConfig)) return cmd } diff --git a/internal/cmd/alpha/module/catalog.go b/internal/cmd/alpha/module/catalog.go new file mode 100644 index 000000000..063f8a667 --- /dev/null +++ b/internal/cmd/alpha/module/catalog.go @@ -0,0 +1,79 @@ +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/modulesv2/dtos" + "github.com/spf13/cobra" +) + +type catalogV2Config struct { + *cmdcommon.KymaConfig + + origin []string + outputFormat types.Format +} + +func NewCatalogV2CMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { + cfg := catalogV2Config{ + KymaConfig: kymaConfig, + } + + cmd := &cobra.Command{ + Use: "catalog [flags]", + Short: "Lists modules catalog", + Long: `Use this command to list all available Kyma modules.`, + Example: ` + # List all available modules from all origins + kyma module catalog + + # List only official Kyma modules managed by KLM with SLA + kyma module catalog --origin kyma + + # List only community modules (not officially supported) + kyma module catalog --origin community + + # List only community modules already available on the cluster + kyma module catalog --origin cluster + + # List modules from multiple origins + kyma module catalog --origin kyma,community + + # Output catalog as JSON + kyma module catalog -o json + + # List official Kyma modules in YAML format + kyma module catalog --origin kyma -o yaml`, + Run: func(_ *cobra.Command, _ []string) { + clierror.Check(catalogModules(&cfg)) + }, + } + + cmd.Flags().StringSliceVar(&cfg.origin, "origin", []string{"kyma", "community", "cluster"}, "Specifies the source of the module") + cmd.Flags().VarP(&cfg.outputFormat, "output", "o", "Output format (Possible values: table, json, yaml)") + + return cmd +} + +func catalogModules(cfg *catalogV2Config) clierror.Error { + moduleOperations := modulesv2.NewModuleOperations(cmdcommon.NewKymaConfig()) + + catalogOperation, err := moduleOperations.Catalog() + if err != nil { + return clierror.Wrap(err, clierror.New("failed to execute the catalog command")) + } + + catalogResult, err := catalogOperation.Run(cfg.Ctx, dtos.NewCatalogConfigFromOriginsList(cfg.origin)) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to list available modules from the target Kyma environment")) + } + + err = modulesv2.RenderCatalog(catalogResult, cfg.outputFormat) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to render catalog")) + } + + return nil +} diff --git a/internal/cmd/alpha/module/module.go b/internal/cmd/alpha/module/module.go new file mode 100644 index 000000000..8fe5e7dbb --- /dev/null +++ b/internal/cmd/alpha/module/module.go @@ -0,0 +1,19 @@ +package module + +import ( + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/spf13/cobra" +) + +func NewModuleCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "module [flags]", + Aliases: []string{"modules"}, + Short: "Manages Kyma modules", + Long: `Use this command to manage modules in the Kyma cluster.`, + } + + cmd.AddCommand(NewCatalogV2CMD(kymaConfig)) + + return cmd +} diff --git a/internal/di/container.go b/internal/di/container.go new file mode 100644 index 000000000..39c07d31c --- /dev/null +++ b/internal/di/container.go @@ -0,0 +1,76 @@ +package di + +import ( + "fmt" + "reflect" +) + +// DIContainer is a simple dependency injection container for singleton instances +type DIContainer struct { + instances map[reflect.Type]interface{} + factories map[reflect.Type]Factory +} + +type Factory func(container *DIContainer) (interface{}, error) + +func NewDIContainer() *DIContainer { + return &DIContainer{ + instances: make(map[reflect.Type]interface{}), + factories: make(map[reflect.Type]Factory), + } +} + +// Get retrieves or creates a singleton instance of the specified type +func (c *DIContainer) Get(targetType reflect.Type) (interface{}, error) { + // Fast path: check if instance already exists + if instance, exists := c.instances[targetType]; exists { + return instance, nil + } + + factory, exists := c.factories[targetType] + + if !exists { + return nil, fmt.Errorf("no registration found for type %s", targetType.String()) + } + + // Create instance (without holding lock to allow nested dependency resolution) + instance, err := factory(c) + if err != nil { + return nil, fmt.Errorf("failed to create instance of type %s: %w", targetType.String(), err) + } + + // Check if another goroutine created it while we were creating ours + if existing, exists := c.instances[targetType]; exists { + return existing, nil + } + c.instances[targetType] = instance + + return instance, nil +} + +// GetTyped is a generic helper to get a singleton instance with type safety +func GetTyped[T any](c *DIContainer) (T, error) { + var zero T + targetType := reflect.TypeOf((*T)(nil)).Elem() + + instance, err := c.Get(targetType) + if err != nil { + return zero, err + } + + typed, ok := instance.(T) + if !ok { + return zero, fmt.Errorf("instance is not of expected type %T", zero) + } + + return typed, nil +} + +// RegisterTyped is a generic helper to register a factory with type safety +// Usage: RegisterTyped[MyInterface](container, func(c *DIContainer) (MyInterface, error) { ... }) +func RegisterTyped[T any](c *DIContainer, factory func(*DIContainer) (T, error)) { + targetType := reflect.TypeOf((*T)(nil)).Elem() + c.factories[targetType] = func(container *DIContainer) (interface{}, error) { + return factory(container) + } +} diff --git a/internal/modulesv2/catalog.go b/internal/modulesv2/catalog.go new file mode 100644 index 000000000..de0e7cff5 --- /dev/null +++ b/internal/modulesv2/catalog.go @@ -0,0 +1,57 @@ +package modulesv2 + +import ( + "context" + "fmt" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" +) + +type CatalogService struct { + moduleTemplatesRepository repository.ModuleTemplatesRepository + clusterMetadataRepository repository.ClusterMetadataRepository +} + +func NewCatalogService( + moduleTemplatesRepository repository.ModuleTemplatesRepository, + clusterMetadataRepository repository.ClusterMetadataRepository, +) *CatalogService { + return &CatalogService{ + moduleTemplatesRepository: moduleTemplatesRepository, + clusterMetadataRepository: clusterMetadataRepository, + } +} + +func (c *CatalogService) Run(ctx context.Context, catalogConfig *dtos.CatalogConfig) ([]dtos.CatalogResult, error) { + results := []dtos.CatalogResult{} + + if c.isClusterManagedByKLM(ctx) && catalogConfig.ListKyma { + coreModules, err := c.moduleTemplatesRepository.ListCore(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list core modules: %v", err) + } + results = append(results, dtos.CatalogResultFromCoreModuleTemplates(coreModules)...) + } + + if catalogConfig.ListCluster { + localCommunityModules, err := c.moduleTemplatesRepository.ListLocalCommunity(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list local community modules: %v", err) + } + results = append(results, dtos.CatalogResultFromCommunityModuleTemplates(localCommunityModules)...) + } + + externalCommunityModules, err := c.moduleTemplatesRepository.ListExternalCommunity(ctx, catalogConfig.ExternalUrls) + if err != nil { + return nil, fmt.Errorf("failed to list external community modules: %v", err) + } + results = append(results, dtos.CatalogResultFromCommunityModuleTemplates(externalCommunityModules)...) + + return results, nil +} + +func (c *CatalogService) isClusterManagedByKLM(ctx context.Context) bool { + clusterMetadata := c.clusterMetadataRepository.Get(ctx) + return clusterMetadata.IsManagedByKLM +} diff --git a/internal/modulesv2/catalog_test.go b/internal/modulesv2/catalog_test.go new file mode 100644 index 000000000..ddcd4ae35 --- /dev/null +++ b/internal/modulesv2/catalog_test.go @@ -0,0 +1,283 @@ +package modulesv2 + +import ( + "context" + "errors" + "testing" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + modulesfake "github.com/kyma-project/cli.v3/internal/modulesv2/fake" + "github.com/kyma-project/cli.v3/internal/modulesv2/testfactories" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCatalogService_Run(t *testing.T) { + tests := []struct { + name string + catalogConfig *dtos.CatalogConfig + clusterManagedByKLM bool + listCoreResult []*entities.CoreModuleTemplate + listCoreError error + listLocalCommunityResult []*entities.CommunityModuleTemplate + listLocalCommunityError error + listExternalCommunityResult []*entities.CommunityModuleTemplate + listExternalCommunityError error + expectedResults []dtos.CatalogResult + expectedError bool + }{ + { + name: "no results in cluster not managed by KLM", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: false, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: false, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{}, + expectedResults: []dtos.CatalogResult{}, + expectedError: false, + }, + { + name: "no results in cluster managed by KLM", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: false, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: true, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{}, + expectedResults: []dtos.CatalogResult{}, + expectedError: false, + }, + { + name: "successful core modules response", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: true, + ListCluster: false, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: true, + listCoreResult: []*entities.CoreModuleTemplate{ + testfactories.CoreModuleTemplate(&testfactories.Params{ + ModuleName: "module1", + Version: "1.0.0", + Channel: "fast", + }), + testfactories.CoreModuleTemplate(&testfactories.Params{ + ModuleName: "module2", + Version: "2.0.0", + Channel: "regular", + }), + }, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{}, + expectedResults: []dtos.CatalogResult{ + { + Name: "module1", + AvailableVersions: []string{"1.0.0(fast)"}, + Origin: "kyma", + }, + { + Name: "module2", + AvailableVersions: []string{"2.0.0(regular)"}, + Origin: "kyma", + }, + }, + expectedError: false, + }, + { + name: "core modules not listed when cluster not managed by KLM", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: true, + ListCluster: false, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: false, + listCoreResult: []*entities.CoreModuleTemplate{ + testfactories.CoreModuleTemplate(nil), + }, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{}, + expectedResults: []dtos.CatalogResult{}, + expectedError: false, + }, + { + name: "successful local community modules response", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: true, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: false, + listLocalCommunityResult: []*entities.CommunityModuleTemplate{ + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "community-module1", + Version: "1.0.0", + Namespace: "kyma-system", + }), + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "community-module2", + Version: "2.0.0", + Namespace: "default", + }), + }, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{}, + expectedResults: []dtos.CatalogResult{ + { + Name: "community-module1", + AvailableVersions: []string{"1.0.0"}, + Origin: "kyma-system/sample-community-template-1.0.0", + }, + { + Name: "community-module2", + AvailableVersions: []string{"2.0.0"}, + Origin: "default/sample-community-template-1.0.0", + }, + }, + expectedError: false, + }, + { + name: "successful external community modules response", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: false, + ExternalUrls: []string{"https://example.com/modules.json"}, + }, + clusterManagedByKLM: false, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{ + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "external-module1", + Version: "1.0.0", + }), + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "external-module2", + Version: "2.0.0", + }), + }, + expectedResults: []dtos.CatalogResult{ + { + Name: "external-module1", + AvailableVersions: []string{"1.0.0"}, + Origin: "community", + }, + { + Name: "external-module2", + AvailableVersions: []string{"2.0.0"}, + Origin: "community", + }, + }, + expectedError: false, + }, + { + name: "combined kyma, cluster, and external modules", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: true, + ListCluster: true, + ExternalUrls: []string{"https://example.com/modules.json"}, + }, + clusterManagedByKLM: true, + listCoreResult: []*entities.CoreModuleTemplate{ + testfactories.CoreModuleTemplate(&testfactories.Params{ + ModuleName: "core-module", + Version: "1.0.0", + }), + }, + listLocalCommunityResult: []*entities.CommunityModuleTemplate{ + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "local-community", + Version: "1.0.0", + Namespace: "kyma-system", + }), + }, + listExternalCommunityResult: []*entities.CommunityModuleTemplate{ + testfactories.CommunityModuleTemplate(&testfactories.CommunityParams{ + ModuleName: "external-community", + Version: "1.0.0", + }), + }, + expectedResults: []dtos.CatalogResult{ + { + Name: "core-module", + AvailableVersions: []string{"1.0.0(fast)"}, + Origin: "kyma", + }, + { + Name: "local-community", + AvailableVersions: []string{"1.0.0"}, + Origin: "kyma-system/sample-community-template-1.0.0", + }, + { + Name: "external-community", + AvailableVersions: []string{"1.0.0"}, + Origin: "community", + }, + }, + expectedError: false, + }, + { + name: "error listing core modules", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: true, + ListCluster: false, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: true, + listCoreError: errors.New("failed to connect to cluster"), + expectedResults: nil, + expectedError: true, + }, + { + name: "error listing local community modules", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: true, + ExternalUrls: []string{}, + }, + clusterManagedByKLM: false, + listLocalCommunityError: errors.New("failed to list local modules"), + expectedResults: nil, + expectedError: true, + }, + { + name: "error listing external community modules", + catalogConfig: &dtos.CatalogConfig{ + ListKyma: false, + ListCluster: false, + ExternalUrls: []string{"https://example.com/modules.json"}, + }, + clusterManagedByKLM: false, + listExternalCommunityError: errors.New("failed to fetch external modules"), + expectedResults: nil, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockModuleRepo := &modulesfake.ModuleTemplatesRepository{ + ListCoreResult: tt.listCoreResult, + ListCoreError: tt.listCoreError, + ListLocalCommunityResult: tt.listLocalCommunityResult, + ListLocalCommunityError: tt.listLocalCommunityError, + ListExternalCommunityResult: tt.listExternalCommunityResult, + ListExternalCommunityError: tt.listExternalCommunityError, + } + + mockMetadataRepo := &modulesfake.ClusterMetadataRepository{ + IsManagedByKLM: tt.clusterManagedByKLM, + } + + service := NewCatalogService(mockModuleRepo, mockMetadataRepo) + + ctx := context.Background() + results, err := service.Run(ctx, tt.catalogConfig) + + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedResults, results) + } + }) + } +} diff --git a/internal/modulesv2/dependencies.go b/internal/modulesv2/dependencies.go new file mode 100644 index 000000000..f844a97e4 --- /dev/null +++ b/internal/modulesv2/dependencies.go @@ -0,0 +1,90 @@ +package modulesv2 + +import ( + "errors" + + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/kyma-project/cli.v3/internal/di" + "github.com/kyma-project/cli.v3/internal/kube" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" +) + +type ModuleOperations interface { + Catalog() (*CatalogService, error) + + // TODO + // Add() (*AddService, error) + // Install() (*InstallService, error) + // Pull() (*PullService, error) + // etc. +} + +type moduleOperations struct { + kymaConfig *cmdcommon.KymaConfig +} + +func NewModuleOperations(kymaConfig *cmdcommon.KymaConfig) *moduleOperations { + return &moduleOperations{kymaConfig: kymaConfig} +} + +func (m *moduleOperations) Catalog() (*CatalogService, error) { + c := setupDIContainer(m.kymaConfig) + + catalogService, err := di.GetTyped[*CatalogService](c) + if err != nil { + return nil, errors.New("failed to execute the catalog command") + } + + return catalogService, nil +} + +func setupDIContainer(kymaConfig *cmdcommon.KymaConfig) *di.DIContainer { + container := di.NewDIContainer() + + di.RegisterTyped(container, func(c *di.DIContainer) (kube.Client, error) { + return kymaConfig.GetKubeClient() + }) + + di.RegisterTyped(container, func(c *di.DIContainer) (repository.ExternalModuleTemplateRepository, error) { + return repository.NewExternalModuleTemplateRepository(), nil + }) + + di.RegisterTyped(container, func(c *di.DIContainer) (repository.ModuleTemplatesRepository, error) { + kubeClient, err := di.GetTyped[kube.Client](c) + if err != nil { + return nil, err + } + + externalRepo, err := di.GetTyped[repository.ExternalModuleTemplateRepository](c) + if err != nil { + return nil, err + } + + return repository.NewModuleTemplatesRepository(kubeClient, externalRepo), nil + }) + + di.RegisterTyped(container, func(c *di.DIContainer) (repository.ClusterMetadataRepository, error) { + kubeClient, err := di.GetTyped[kube.Client](c) + if err != nil { + return nil, err + } + + return repository.NewClusterMetadataRepository(kubeClient), nil + }) + + di.RegisterTyped(container, func(c *di.DIContainer) (*CatalogService, error) { + moduleRepo, err := di.GetTyped[repository.ModuleTemplatesRepository](c) + if err != nil { + return nil, err + } + + metadataRepo, err := di.GetTyped[repository.ClusterMetadataRepository](c) + if err != nil { + return nil, err + } + + return NewCatalogService(moduleRepo, metadataRepo), nil + }) + + return container +} diff --git a/internal/modulesv2/dtos/catalogconfig.go b/internal/modulesv2/dtos/catalogconfig.go new file mode 100644 index 000000000..73984ee3d --- /dev/null +++ b/internal/modulesv2/dtos/catalogconfig.go @@ -0,0 +1,28 @@ +package dtos + +const KYMA_COMMUNITY_MODULES_REPOSITORY_URL = "https://kyma-project.github.io/community-modules/all-modules.json" + +type CatalogConfig struct { + ListKyma bool + ListCluster bool + ExternalUrls []string +} + +func NewCatalogConfigFromOriginsList(origins []string) *CatalogConfig { + catalogConfig := &CatalogConfig{} + + for _, origin := range origins { + switch origin { + case "kyma": + catalogConfig.ListKyma = true + case "cluster": + catalogConfig.ListCluster = true + case "community": + catalogConfig.ExternalUrls = append(catalogConfig.ExternalUrls, KYMA_COMMUNITY_MODULES_REPOSITORY_URL) + default: + catalogConfig.ExternalUrls = append(catalogConfig.ExternalUrls, origin) + } + } + + return catalogConfig +} diff --git a/internal/modulesv2/dtos/catalogconfig_test.go b/internal/modulesv2/dtos/catalogconfig_test.go new file mode 100644 index 000000000..66582b5fb --- /dev/null +++ b/internal/modulesv2/dtos/catalogconfig_test.go @@ -0,0 +1,52 @@ +package dtos_test + +import ( + "testing" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/stretchr/testify/require" +) + +func Test_NewCatalogConfigFromOriginsList(t *testing.T) { + tests := []struct { + origin []string + expectedConfig dtos.CatalogConfig + }{ + { + origin: []string{"kyma"}, + expectedConfig: dtos.CatalogConfig{ListKyma: true}, + }, + { + origin: []string{"cluster"}, + expectedConfig: dtos.CatalogConfig{ListCluster: true}, + }, + { + origin: []string{"community"}, + expectedConfig: dtos.CatalogConfig{ExternalUrls: []string{dtos.KYMA_COMMUNITY_MODULES_REPOSITORY_URL}}, + }, + { + origin: []string{"https://external-repo.co.uk", "https://example.com"}, + expectedConfig: dtos.CatalogConfig{ExternalUrls: []string{"https://external-repo.co.uk", "https://example.com"}}, + }, + { + origin: []string{"kyma", "cluster", "community", "https://external-repo.co.uk", "https://example.com"}, + expectedConfig: dtos.CatalogConfig{ + ListKyma: true, + ListCluster: true, + ExternalUrls: []string{ + dtos.KYMA_COMMUNITY_MODULES_REPOSITORY_URL, + "https://external-repo.co.uk", + "https://example.com", + }, + }, + }, + } + + for _, test := range tests { + result := dtos.NewCatalogConfigFromOriginsList(test.origin) + + require.Equal(t, test.expectedConfig.ListKyma, result.ListKyma) + require.Equal(t, test.expectedConfig.ListCluster, result.ListCluster) + require.Equal(t, test.expectedConfig.ExternalUrls, result.ExternalUrls) + } +} diff --git a/internal/modulesv2/dtos/catalogresult.go b/internal/modulesv2/dtos/catalogresult.go new file mode 100644 index 000000000..fd0dc2b4f --- /dev/null +++ b/internal/modulesv2/dtos/catalogresult.go @@ -0,0 +1,69 @@ +package dtos + +import "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + +const KYMA_ORIGIN = "kyma" +const COMMUNITY_ORIGIN = "community" + +type CatalogResult struct { + Name string + AvailableVersions []string + Origin string +} + +func CatalogResultFromCoreModuleTemplates(coreModuleTemplates []*entities.CoreModuleTemplate) []CatalogResult { + results := []CatalogResult{} + + // Cache to quickly get an index of module that's already present in the result set + resultsCache := map[string]int{} + + for _, coreModuleTemplate := range coreModuleTemplates { + if i, exists := resultsCache[coreModuleTemplate.ModuleName]; exists { + results[i].AvailableVersions = append(results[i].AvailableVersions, coreModuleTemplate.GetVersionWithChannel()) + } else { + newResult := CatalogResult{ + Name: coreModuleTemplate.ModuleName, + AvailableVersions: []string{coreModuleTemplate.GetVersionWithChannel()}, + Origin: KYMA_ORIGIN, + } + results = append(results, newResult) + resultsCache[coreModuleTemplate.ModuleName] = len(results) - 1 + } + } + + return results +} + +func CatalogResultFromCommunityModuleTemplates(communityModuleTemplates []*entities.CommunityModuleTemplate) []CatalogResult { + results := []CatalogResult{} + + // Cache key: moduleName + origin + resultsCache := map[string]int{} + + for _, communityModuleTemplate := range communityModuleTemplates { + origin := getOriginFor(communityModuleTemplate) + cacheKey := communityModuleTemplate.ModuleName + "|" + origin + + if i, exists := resultsCache[cacheKey]; exists { + results[i].AvailableVersions = append(results[i].AvailableVersions, communityModuleTemplate.Version) + } else { + newResult := CatalogResult{ + Name: communityModuleTemplate.ModuleName, + AvailableVersions: []string{communityModuleTemplate.Version}, + Origin: origin, + } + results = append(results, newResult) + resultsCache[cacheKey] = len(results) - 1 + } + } + + return results +} + +func getOriginFor(communityModuleTemplate *entities.CommunityModuleTemplate) string { + if communityModuleTemplate.IsExternal() { + return COMMUNITY_ORIGIN + } + + return communityModuleTemplate.GetNamespacedName() +} diff --git a/internal/modulesv2/dtos/catalogresult_test.go b/internal/modulesv2/dtos/catalogresult_test.go new file mode 100644 index 000000000..478dca61b --- /dev/null +++ b/internal/modulesv2/dtos/catalogresult_test.go @@ -0,0 +1,67 @@ +package dtos_test + +import ( + "testing" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + "github.com/stretchr/testify/require" +) + +func Test_CatalogResultFromCoreModuleTemplates(t *testing.T) { + entities := []*entities.CoreModuleTemplate{ + entities.NewCoreModuleTemplateFromParams("sample-template-0.0.3", "sample-template", "0.0.3", "experimental", "kyma-system"), + entities.NewCoreModuleTemplateFromParams("sample-template-0.0.2", "sample-template", "0.0.2", "fast", "kyma-system"), + entities.NewCoreModuleTemplateFromParams("sample-template-0.0.1", "sample-template", "0.0.1", "regular", "kyma-system"), + } + + expectedCatalogResult := []dtos.CatalogResult{ + { + Name: "sample-template", + AvailableVersions: []string{"0.0.3(experimental)", "0.0.2(fast)", "0.0.1(regular)"}, + Origin: "kyma", + }, + } + + require.Equal(t, expectedCatalogResult, dtos.CatalogResultFromCoreModuleTemplates(entities)) +} + +func Test_CatalogResultFromCommunityModuleTemplates(t *testing.T) { + entities := []*entities.CommunityModuleTemplate{ + entities.NewCommunityModuleTemplate( + entities.MapBaseModuleTemplateFromParams("local-template-0.0.1", "local-template", "0.0.1", "kyma-system"), + "https://source.url", + map[string]string{}, + ), + entities.NewCommunityModuleTemplate( + entities.MapBaseModuleTemplateFromParams("local-template-0.0.2", "local-template", "0.0.2", "kyma-system"), + "https://source.url", + map[string]string{}, + ), + entities.NewCommunityModuleTemplate( + entities.MapBaseModuleTemplateFromParams("community-template-0.0.1", "community-template", "0.0.1", ""), + "https://source.url", + map[string]string{}, + ), + } + + expectedCatalogResult := []dtos.CatalogResult{ + { + Name: "local-template", + AvailableVersions: []string{"0.0.1"}, + Origin: "kyma-system/local-template-0.0.1", + }, + { + Name: "local-template", + AvailableVersions: []string{"0.0.2"}, + Origin: "kyma-system/local-template-0.0.2", + }, + { + Name: "community-template", + AvailableVersions: []string{"0.0.1"}, + Origin: "community", + }, + } + + require.Equal(t, expectedCatalogResult, dtos.CatalogResultFromCommunityModuleTemplates(entities)) +} diff --git a/internal/modulesv2/entities/basemoduletemplate.go b/internal/modulesv2/entities/basemoduletemplate.go new file mode 100644 index 000000000..5d79f6269 --- /dev/null +++ b/internal/modulesv2/entities/basemoduletemplate.go @@ -0,0 +1,38 @@ +package entities + +import ( + "github.com/kyma-project/cli.v3/internal/kube/kyma" +) + +type BaseModuleTemplate struct { + ModuleName string + Version string + + name string + namespace string + + // not needed right now but will be needed in the future + // data *unstructured.Unstructured + // manager *kyma.Manager +} + +func MapBaseModuleTemplateFromRaw(rawModuleTemplate *kyma.ModuleTemplate) *BaseModuleTemplate { + entity := BaseModuleTemplate{} + + entity.ModuleName = rawModuleTemplate.Spec.ModuleName + entity.Version = rawModuleTemplate.Spec.Version + + entity.name = rawModuleTemplate.GetName() + entity.namespace = rawModuleTemplate.GetNamespace() + + return &entity +} + +func MapBaseModuleTemplateFromParams(templateName, moduleName, version, namespace string) *BaseModuleTemplate { + return &BaseModuleTemplate{ + ModuleName: moduleName, + Version: version, + name: templateName, + namespace: namespace, + } +} diff --git a/internal/modulesv2/entities/clustermetadata.go b/internal/modulesv2/entities/clustermetadata.go new file mode 100644 index 000000000..0aaee4643 --- /dev/null +++ b/internal/modulesv2/entities/clustermetadata.go @@ -0,0 +1,9 @@ +package entities + +type ClusterMetadata struct { + IsManagedByKLM bool +} + +func NewClusterMetadata(isManagedByKLM bool) *ClusterMetadata { + return &ClusterMetadata{isManagedByKLM} +} diff --git a/internal/modulesv2/entities/communitymoduletemplate.go b/internal/modulesv2/entities/communitymoduletemplate.go new file mode 100644 index 000000000..4d14acd74 --- /dev/null +++ b/internal/modulesv2/entities/communitymoduletemplate.go @@ -0,0 +1,25 @@ +package entities + +import "fmt" + +type CommunityModuleTemplate struct { + BaseModuleTemplate + sourceURL string + resources map[string]string +} + +func NewCommunityModuleTemplate(base *BaseModuleTemplate, sourceURL string, resources map[string]string) *CommunityModuleTemplate { + return &CommunityModuleTemplate{ + *base, + sourceURL, + resources, + } +} + +func (m *CommunityModuleTemplate) IsExternal() bool { + return m.namespace == "" +} + +func (m *CommunityModuleTemplate) GetNamespacedName() string { + return fmt.Sprintf("%s/%s", m.namespace, m.name) +} diff --git a/internal/modulesv2/entities/coremoduletemplate.go b/internal/modulesv2/entities/coremoduletemplate.go new file mode 100644 index 000000000..001a2cde3 --- /dev/null +++ b/internal/modulesv2/entities/coremoduletemplate.go @@ -0,0 +1,28 @@ +package entities + +import "fmt" + +type CoreModuleTemplate struct { + BaseModuleTemplate + Channel string +} + +func NewCoreModuleTemplate(base *BaseModuleTemplate, channel string) *CoreModuleTemplate { + return &CoreModuleTemplate{ + *base, + channel, + } +} + +func NewCoreModuleTemplateFromParams(templateName, moduleName, version, channel, namespace string) *CoreModuleTemplate { + base := MapBaseModuleTemplateFromParams(templateName, moduleName, version, namespace) + + return &CoreModuleTemplate{ + *base, + channel, + } +} + +func (m *CoreModuleTemplate) GetVersionWithChannel() string { + return fmt.Sprintf("%s(%s)", m.Version, m.Channel) +} diff --git a/internal/modulesv2/fake/clustermetadata.go b/internal/modulesv2/fake/clustermetadata.go new file mode 100644 index 000000000..f0a58966c --- /dev/null +++ b/internal/modulesv2/fake/clustermetadata.go @@ -0,0 +1,15 @@ +package fake + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type ClusterMetadataRepository struct { + IsManagedByKLM bool +} + +func (m *ClusterMetadataRepository) Get(ctx context.Context) entities.ClusterMetadata { + return entities.ClusterMetadata{IsManagedByKLM: m.IsManagedByKLM} +} diff --git a/internal/modulesv2/fake/externalmoduletemplates.go b/internal/modulesv2/fake/externalmoduletemplates.go new file mode 100644 index 000000000..ae86cab1a --- /dev/null +++ b/internal/modulesv2/fake/externalmoduletemplates.go @@ -0,0 +1,12 @@ +package fake + +import "github.com/kyma-project/cli.v3/internal/kube/kyma" + +type ExternalModuleTemplatesRepository struct { + Modules []kyma.ModuleTemplate + Err error +} + +func (r *ExternalModuleTemplatesRepository) Get(_ []string) ([]kyma.ModuleTemplate, error) { + return r.Modules, r.Err +} diff --git a/internal/modulesv2/fake/moduletemplates.go b/internal/modulesv2/fake/moduletemplates.go new file mode 100644 index 000000000..47186bd8f --- /dev/null +++ b/internal/modulesv2/fake/moduletemplates.go @@ -0,0 +1,28 @@ +package fake + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type ModuleTemplatesRepository struct { + ListCoreResult []*entities.CoreModuleTemplate + ListCoreError error + ListLocalCommunityResult []*entities.CommunityModuleTemplate + ListLocalCommunityError error + ListExternalCommunityResult []*entities.CommunityModuleTemplate + ListExternalCommunityError error +} + +func (m *ModuleTemplatesRepository) ListCore(ctx context.Context) ([]*entities.CoreModuleTemplate, error) { + return m.ListCoreResult, m.ListCoreError +} + +func (m *ModuleTemplatesRepository) ListLocalCommunity(ctx context.Context) ([]*entities.CommunityModuleTemplate, error) { + return m.ListLocalCommunityResult, m.ListLocalCommunityError +} + +func (m *ModuleTemplatesRepository) ListExternalCommunity(ctx context.Context, urls []string) ([]*entities.CommunityModuleTemplate, error) { + return m.ListExternalCommunityResult, m.ListExternalCommunityError +} diff --git a/internal/modulesv2/render.go b/internal/modulesv2/render.go new file mode 100644 index 000000000..65caf291c --- /dev/null +++ b/internal/modulesv2/render.go @@ -0,0 +1,103 @@ +package modulesv2 + +import ( + "encoding/json" + "sort" + "strings" + + "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/kyma-project/cli.v3/internal/render" + "gopkg.in/yaml.v3" +) + +func RenderCatalog(results []dtos.CatalogResult, format types.Format) error { + switch format { + case types.JSONFormat: + return renderCatalogJSON(results) + case types.YAMLFormat: + return renderCatalogYAML(results) + default: + return renderCatalogTable(results) + } +} + +func renderCatalogJSON(results []dtos.CatalogResult) error { + output := convertToOutputFormat(results) + obj, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + + out.Default.Msgln(string(obj)) + return nil +} + +func renderCatalogYAML(results []dtos.CatalogResult) error { + output := convertToOutputFormat(results) + obj, err := yaml.Marshal(output) + if err != nil { + return err + } + + out.Default.Msgln(string(obj)) + return nil +} + +func renderCatalogTable(results []dtos.CatalogResult) error { + sortCatalogResults(results) + + headers := []interface{}{"NAME", "AVAILABLE VERSIONS", "ORIGIN"} + rows := convertCatalogToRows(results) + + render.Table(out.Default, headers, rows) + return nil +} + +func convertToOutputFormat(results []dtos.CatalogResult) []map[string]interface{} { + output := make([]map[string]interface{}, len(results)) + for i, result := range results { + output[i] = map[string]interface{}{ + "name": result.Name, + "availableVersions": result.AvailableVersions, + "origin": result.Origin, + } + } + return output +} + +func convertCatalogToRows(results []dtos.CatalogResult) [][]interface{} { + rows := make([][]interface{}, len(results)) + for i, result := range results { + rows[i] = []interface{}{ + result.Name, + strings.Join(result.AvailableVersions, ", "), + result.Origin, + } + } + return rows +} + +func sortCatalogResults(results []dtos.CatalogResult) { + sort.Slice(results, func(i, j int) bool { + // First: kyma origin modules + if results[i].Origin == dtos.KYMA_ORIGIN && results[j].Origin != dtos.KYMA_ORIGIN { + return true + } + if results[i].Origin != dtos.KYMA_ORIGIN && results[j].Origin == dtos.KYMA_ORIGIN { + return false + } + + // Second: community origin modules + if results[i].Origin != dtos.COMMUNITY_ORIGIN && results[j].Origin == dtos.COMMUNITY_ORIGIN { + return false + } + if results[i].Origin == dtos.COMMUNITY_ORIGIN && results[j].Origin != dtos.COMMUNITY_ORIGIN { + return true + } + + // Within the same category, sort by name + return results[i].Name < results[j].Name + }) +} diff --git a/internal/modulesv2/repository/clustermetadata.go b/internal/modulesv2/repository/clustermetadata.go new file mode 100644 index 000000000..30f5d0720 --- /dev/null +++ b/internal/modulesv2/repository/clustermetadata.go @@ -0,0 +1,29 @@ +package repository + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/kube" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" +) + +type ClusterMetadataRepository interface { + Get(ctx context.Context) entities.ClusterMetadata +} + +type clusterMetadataRepository struct { + client kube.Client +} + +func NewClusterMetadataRepository(client kube.Client) *clusterMetadataRepository { + return &clusterMetadataRepository{client: client} +} + +func (r *clusterMetadataRepository) Get(ctx context.Context) entities.ClusterMetadata { + _, err := r.client.Kyma().GetDefaultKyma(ctx) + if err != nil { + return entities.ClusterMetadata{IsManagedByKLM: false} + } + + return entities.ClusterMetadata{IsManagedByKLM: true} +} diff --git a/internal/modulesv2/repository/externalmoduletemplates.go b/internal/modulesv2/repository/externalmoduletemplates.go new file mode 100644 index 000000000..cc1438edd --- /dev/null +++ b/internal/modulesv2/repository/externalmoduletemplates.go @@ -0,0 +1,55 @@ +package repository + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/kyma-project/cli.v3/internal/kube/kyma" +) + +type ExternalModuleTemplateRepository interface { + Get(urls []string) ([]kyma.ModuleTemplate, error) +} + +type externalModuleTemplateRepository struct{} + +func NewExternalModuleTemplateRepository() *externalModuleTemplateRepository { + return &externalModuleTemplateRepository{} +} + +func (r *externalModuleTemplateRepository) Get(urls []string) ([]kyma.ModuleTemplate, error) { + externalModules := []kyma.ModuleTemplate{} + + for _, url := range urls { + externalModuleTemplatesList, err := getFileFromURL(url) + if err != nil { + return nil, fmt.Errorf("failed to get community modules definitions: %v", err) + } + + var result []kyma.ModuleTemplate + if err := json.Unmarshal(externalModuleTemplatesList, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal module template: %w", err) + } + + externalModules = append(externalModules, result...) + } + + return externalModules, nil +} + +func getFileFromURL(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to download resource from %s: %w", url, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read resource body: %w", err) + } + + return body, nil +} diff --git a/internal/modulesv2/repository/moduletemplates.go b/internal/modulesv2/repository/moduletemplates.go new file mode 100644 index 000000000..d5c84b508 --- /dev/null +++ b/internal/modulesv2/repository/moduletemplates.go @@ -0,0 +1,186 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/kyma-project/cli.v3/internal/out" + "github.com/stretchr/testify/assert/yaml" +) + +type ModuleTemplatesRepository interface { + ListCore(ctx context.Context) ([]*entities.CoreModuleTemplate, error) + ListLocalCommunity(ctx context.Context) ([]*entities.CommunityModuleTemplate, error) + ListExternalCommunity(ctx context.Context, urls []string) ([]*entities.CommunityModuleTemplate, error) +} + +type moduleTemplatesRepository struct { + client kube.Client + externalModuleTemplateRepository ExternalModuleTemplateRepository +} + +func NewModuleTemplatesRepository(client kube.Client, externalModuleTemplateRepository ExternalModuleTemplateRepository) *moduleTemplatesRepository { + return &moduleTemplatesRepository{ + client: client, + externalModuleTemplateRepository: externalModuleTemplateRepository, + } +} + +func (r *moduleTemplatesRepository) getLocal(ctx context.Context) ([]kyma.ModuleTemplate, error) { + moduleTemplates, err := r.client.Kyma().ListModuleTemplate(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list module templates: %v", err) + } + + return moduleTemplates.Items, nil +} + +func (r *moduleTemplatesRepository) ListCore(ctx context.Context) ([]*entities.CoreModuleTemplate, error) { + rawModuleTemplates, err := r.getLocal(ctx) + if err != nil { + return nil, err + } + + coreModuleTemplates := []kyma.ModuleTemplate{} + for _, moduleTemplate := range rawModuleTemplates { + if !isCommunityModule(&moduleTemplate) { + coreModuleTemplates = append(coreModuleTemplates, moduleTemplate) + } + } + + rawModulesReleaseMeta, err := r.client.Kyma().ListModuleReleaseMeta(ctx) + if err != nil { + // support for legacy module templates + legacyModuleTemplates, err := r.mapToCoreEntityLegacy(coreModuleTemplates) + if err != nil { + return nil, err + } + return legacyModuleTemplates, nil + } + + return r.mapToCoreEntities(coreModuleTemplates, rawModulesReleaseMeta.Items), nil +} + +func (r *moduleTemplatesRepository) ListLocalCommunity(ctx context.Context) ([]*entities.CommunityModuleTemplate, error) { + rawModuleTemplates, err := r.getLocal(ctx) + if err != nil { + return nil, err + } + + communityModuleTemplates := []kyma.ModuleTemplate{} + for _, moduleTemplate := range rawModuleTemplates { + if isCommunityModule(&moduleTemplate) { + communityModuleTemplates = append(communityModuleTemplates, moduleTemplate) + } + } + + return r.mapToCommunityEntities(communityModuleTemplates), nil +} + +func (r *moduleTemplatesRepository) ListExternalCommunity(ctx context.Context, urls []string) ([]*entities.CommunityModuleTemplate, error) { + rawModuleTemplates, err := r.externalModuleTemplateRepository.Get(urls) + if err != nil { + return nil, err + } + + return r.mapToCommunityEntities(rawModuleTemplates), nil +} + +func (r *moduleTemplatesRepository) mapToCoreEntities(rawModuleTemplates []kyma.ModuleTemplate, rawReleaseMetas []kyma.ModuleReleaseMeta) []*entities.CoreModuleTemplate { + entities := []*entities.CoreModuleTemplate{} + + for _, rawModuleTemplate := range rawModuleTemplates { + assignments := getChannelVersionsAssignments(rawReleaseMetas, rawModuleTemplate.Spec.ModuleName) + for _, assignment := range assignments { + entities = append(entities, r.mapToCoreEntity(&rawModuleTemplate, assignment.Channel)) + } + } + + return entities +} + +func (r *moduleTemplatesRepository) mapToCoreEntity(rawModuleTemplate *kyma.ModuleTemplate, channel string) *entities.CoreModuleTemplate { + moduleTemplateEntity := entities.MapBaseModuleTemplateFromRaw(rawModuleTemplate) + + return entities.NewCoreModuleTemplate(moduleTemplateEntity, channel) +} + +func (r *moduleTemplatesRepository) mapToCommunityEntities(rawModuleTemplates []kyma.ModuleTemplate) []*entities.CommunityModuleTemplate { + entities := []*entities.CommunityModuleTemplate{} + + for _, rawModuleTemplate := range rawModuleTemplates { + entities = append(entities, r.mapToCommunityEntity(&rawModuleTemplate)) + } + + return entities +} + +func (r *moduleTemplatesRepository) mapToCommunityEntity(rawModuleTemplate *kyma.ModuleTemplate) *entities.CommunityModuleTemplate { + moduleTemplateEntity := entities.MapBaseModuleTemplateFromRaw(rawModuleTemplate) + sourceURL := rawModuleTemplate.Annotations["source"] + resources := map[string]string{} + + for _, rawResource := range rawModuleTemplate.Spec.Resources { + key := rawResource.Name + value := rawResource.Link + + resources[key] = value + } + + return entities.NewCommunityModuleTemplate(moduleTemplateEntity, sourceURL, resources) +} + +func (r *moduleTemplatesRepository) mapToCoreEntityLegacy(coreModuleTemplates []kyma.ModuleTemplate) ([]*entities.CoreModuleTemplate, error) { + coreModuleTemplateEntities := []*entities.CoreModuleTemplate{} + + for _, moduleTemplate := range coreModuleTemplates { + type component struct { + Version string `yaml:"version,omitempty"` + Name string `yaml:"name,omitempty"` + } + + type descriptor struct { + Component component `yaml:"component,omitempty"` + } + + d := descriptor{} + err := yaml.Unmarshal(moduleTemplate.Spec.Descriptor.Raw, &d) + if err != nil { + // unexpected error + out.Debugfln("failed to parse %s module descriptor: %v", moduleTemplate.Spec.ModuleName, err) + continue + } + + nameElems := strings.Split(d.Component.Name, "/") + componentName := nameElems[len(nameElems)-1] + + legacyModuleTemplate := entities.NewCoreModuleTemplateFromParams(moduleTemplate.Name, componentName, d.Component.Version, moduleTemplate.Spec.Channel, moduleTemplate.Namespace) + coreModuleTemplateEntities = append(coreModuleTemplateEntities, legacyModuleTemplate) + } + + if len(coreModuleTemplateEntities) != 0 { + return coreModuleTemplateEntities, nil + } + + return nil, errors.New("failed to list module catalog from the target Kyma environment") +} + +func getChannelVersionsAssignments(rawReleaseMetas []kyma.ModuleReleaseMeta, moduleName string) []kyma.ChannelVersionAssignment { + for _, rawReleaseMeta := range rawReleaseMetas { + if rawReleaseMeta.Spec.ModuleName == moduleName { + return rawReleaseMeta.Spec.Channels + } + } + + return []kyma.ChannelVersionAssignment{} +} + +func isCommunityModule(moduleTemplate *kyma.ModuleTemplate) bool { + managedBy, exist := moduleTemplate.ObjectMeta.Labels["operator.kyma-project.io/managed-by"] + return !exist || managedBy != "kyma" || moduleTemplate.Namespace != "kyma-system" +} diff --git a/internal/modulesv2/repository/moduletemplates_test.go b/internal/modulesv2/repository/moduletemplates_test.go new file mode 100644 index 000000000..58212ee9a --- /dev/null +++ b/internal/modulesv2/repository/moduletemplates_test.go @@ -0,0 +1,255 @@ +package repository_test + +import ( + "context" + "errors" + "testing" + + "github.com/kyma-project/cli.v3/internal/kube/fake" + "github.com/kyma-project/cli.v3/internal/kube/kyma" + modulesfake "github.com/kyma-project/cli.v3/internal/modulesv2/fake" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + testCoreModuleTemplate = kyma.ModuleTemplate{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.kyma-project.io/v1beta2", + Kind: "ModuleTemplate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-module-1", + Namespace: "kyma-system", + Labels: map[string]string{ + "operator.kyma-project.io/managed-by": "kyma", + }, + }, + Spec: kyma.ModuleTemplateSpec{ + ModuleName: "test-module", + Version: "0.0.1", + }, + } + testCoreModuleTemplateReleaseMeta = kyma.ModuleReleaseMeta{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.kyma-project.io/v1beta2", + Kind: "ModuleReleaseMeta", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-module-1", + Namespace: "kyma-system", + }, + Spec: kyma.ModuleReleaseMetaSpec{ + ModuleName: "test-module", + Channels: []kyma.ChannelVersionAssignment{ + { + Channel: "regular", + Version: "0.0.1", + }, + { + Channel: "fast", + Version: "0.0.2", + }, + { + Channel: "experimental", + Version: "0.0.3", + }, + }, + }, + } + testCommunityModuleTemplate = kyma.ModuleTemplate{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "operator.kyma-project.io/v1beta2", + Kind: "ModuleTemplate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-module-2", + Namespace: "test-module-namespace", + }, + Spec: kyma.ModuleTemplateSpec{ + ModuleName: "test-module", + Version: "0.0.2", + }, + } +) + +func TestModuleTemplatesRepository_ListCore(t *testing.T) { + t.Run("failed to list module templates", func(t *testing.T) { + fakeKymaClient := fake.KymaClient{ + ReturnErr: errors.New("test-error"), + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{}, + }, + } + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{}, + Err: nil, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + + result, err := repo.ListCore(context.Background()) + + require.Len(t, result, 0) + require.Error(t, err) + require.Equal(t, "failed to list module templates: test-error", err.Error()) + }) + + t.Run("lists all core module templates", func(t *testing.T) { + fakeKymaClient := fake.KymaClient{ + ReturnErr: nil, + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{ + testCoreModuleTemplate, + testCommunityModuleTemplate, + }, + }, + ReturnModuleReleaseMetaList: kyma.ModuleReleaseMetaList{ + Items: []kyma.ModuleReleaseMeta{ + testCoreModuleTemplateReleaseMeta, + }, + }, + } + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{}, + Err: nil, + } + + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + + result, err := repo.ListCore(context.Background()) + require.NoError(t, err) + require.Len(t, result, 3) + + require.Equal(t, "test-module", result[0].ModuleName) + require.Equal(t, "regular", result[0].Channel) + + require.Equal(t, "test-module", result[1].ModuleName) + require.Equal(t, "fast", result[1].Channel) + + require.Equal(t, "test-module", result[2].ModuleName) + require.Equal(t, "experimental", result[2].Channel) + }) +} + +func TestModuleTemplatesRepository_ListLocalCommunity(t *testing.T) { + t.Run("failed to list module templates", func(t *testing.T) { + fakeKymaClient := fake.KymaClient{ + ReturnErr: errors.New("test-error"), + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{}, + }, + } + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{}, + Err: nil, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + + result, err := repo.ListLocalCommunity(context.Background()) + + require.Len(t, result, 0) + require.Error(t, err) + require.Equal(t, "failed to list module templates: test-error", err.Error()) + }) + + t.Run("lists all core module templates", func(t *testing.T) { + fakeKymaClient := fake.KymaClient{ + ReturnErr: nil, + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{ + testCoreModuleTemplate, + testCommunityModuleTemplate, + }, + }, + ReturnModuleReleaseMetaList: kyma.ModuleReleaseMetaList{ + Items: []kyma.ModuleReleaseMeta{ + testCoreModuleTemplateReleaseMeta, + }, + }, + } + + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{}, + Err: nil, + } + + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + + result, err := repo.ListLocalCommunity(context.Background()) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "test-module", result[0].ModuleName) + require.Equal(t, "test-module-namespace/test-module-2", result[0].GetNamespacedName()) + }) +} + +func TestModuleTemplatesRepository_ListExternalCommunity(t *testing.T) { + t.Run("failed to list module templates", func(t *testing.T) { + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{}, + Err: errors.New("failed to list external modules"), + } + fakeKymaClient := fake.KymaClient{ + ReturnErr: nil, + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{}, + }, + ReturnModuleReleaseMetaList: kyma.ModuleReleaseMetaList{ + Items: []kyma.ModuleReleaseMeta{}, + }, + } + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + result, err := repo.ListExternalCommunity(context.Background(), []string{"https://irrelevant.url"}) + require.Error(t, err) + require.Len(t, result, 0) + require.Equal(t, "failed to list external modules", err.Error()) + }) + + t.Run("lists external community module templates", func(t *testing.T) { + fakeExternalCommunityRepository := &modulesfake.ExternalModuleTemplatesRepository{ + Modules: []kyma.ModuleTemplate{testCommunityModuleTemplate}, + Err: nil, + } + fakeKymaClient := fake.KymaClient{ + ReturnErr: nil, + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{}, + }, + ReturnModuleReleaseMetaList: kyma.ModuleReleaseMetaList{ + Items: []kyma.ModuleReleaseMeta{}, + }, + } + fakeKubeClient := fake.KubeClient{ + TestKymaInterface: &fakeKymaClient, + } + + repo := repository.NewModuleTemplatesRepository(&fakeKubeClient, fakeExternalCommunityRepository) + + result, err := repo.ListExternalCommunity(context.Background(), []string{"https://irrelevant.url"}) + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, "test-module", result[0].ModuleName) + require.Equal(t, "test-module-namespace/test-module-2", result[0].GetNamespacedName()) + }) +} diff --git a/internal/modulesv2/testfactories/communitymoduletemplate.go b/internal/modulesv2/testfactories/communitymoduletemplate.go new file mode 100644 index 000000000..84a436542 --- /dev/null +++ b/internal/modulesv2/testfactories/communitymoduletemplate.go @@ -0,0 +1,49 @@ +package testfactories + +import "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + +type CommunityParams struct { + TemplateName string + ModuleName string + Version string + Namespace string + SourceURL string + Resources map[string]string +} + +func CommunityModuleTemplate(params *CommunityParams) *entities.CommunityModuleTemplate { + defaults := defaultCommunityParams() + + if params == nil { + params = defaults + } + + base := entities.MapBaseModuleTemplateFromParams( + firstNonEmpty(params.TemplateName, defaults.TemplateName), + firstNonEmpty(params.ModuleName, defaults.ModuleName), + firstNonEmpty(params.Version, defaults.Version), + firstNonEmpty(params.Namespace, defaults.Namespace), + ) + + resources := params.Resources + if resources == nil { + resources = defaults.Resources + } + + return entities.NewCommunityModuleTemplate( + base, + firstNonEmpty(params.SourceURL, defaults.SourceURL), + resources, + ) +} + +func defaultCommunityParams() *CommunityParams { + return &CommunityParams{ + TemplateName: "sample-community-template-1.0.0", + ModuleName: "sample-community-template", + Version: "1.0.0", + Namespace: "", + SourceURL: "", + Resources: map[string]string{}, + } +} diff --git a/internal/modulesv2/testfactories/coremoduletemplate.go b/internal/modulesv2/testfactories/coremoduletemplate.go new file mode 100644 index 000000000..0ff0f2fbf --- /dev/null +++ b/internal/modulesv2/testfactories/coremoduletemplate.go @@ -0,0 +1,46 @@ +package testfactories + +import "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + +type Params struct { + TemplateName string + ModuleName string + Version string + Channel string + Namespace string +} + +func CoreModuleTemplate(params *Params) *entities.CoreModuleTemplate { + defaults := defaultParams() + + if params == nil { + params = defaults + } + + return entities.NewCoreModuleTemplateFromParams( + firstNonEmpty(params.TemplateName, defaults.TemplateName), + firstNonEmpty(params.ModuleName, defaults.ModuleName), + firstNonEmpty(params.Version, defaults.Version), + firstNonEmpty(params.Channel, defaults.Channel), + firstNonEmpty(params.Namespace, defaults.Namespace), + ) +} + +func defaultParams() *Params { + return &Params{ + TemplateName: "sample-template-0.0.1", + ModuleName: "sample-template", + Version: "0.0.1", + Channel: "fast", + Namespace: "kyma-system", + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +}