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..70a55397b --- /dev/null +++ b/docs/user/gen-docs/kyma_alpha_module_list.md @@ -0,0 +1,28 @@ +# kyma alpha module list + +Lists installed modules. + +## Synopsis + +Use this command to list the installed Kyma modules. + +WARNING: This functionality is still under construction. Community modules are 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..ebf9154bd --- /dev/null +++ b/internal/cmd/alpha/module/list.go @@ -0,0 +1,57 @@ +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. + +WARNING: This functionality is still under construction. Community modules are 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..964bab5a7 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,15 @@ func setupDIContainer(kymaConfig *cmdcommon.KymaConfig) *di.Container { return repository.NewClusterMetadataRepository(kubeClient), nil }) + di.RegisterTyped(container, func(c *di.Container) (repository.ModuleInstallationsRepository, error) { + kubeClient, err := di.GetTyped[kube.Client](c) + if err != nil { + return nil, err + } + + return repository.NewModuleInstallationsRepository(kubeClient), nil + }) + // Services: di.RegisterTyped(container, func(c *di.Container) (*CatalogService, error) { @@ -108,5 +124,14 @@ 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.ModuleInstallationsRepository](c) + if err != nil { + return nil, err + } + + return NewListService(installedModulesRepo), 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..b7b0813c7 --- /dev/null +++ b/internal/modulesv2/entities/moduleinstallation.go @@ -0,0 +1,45 @@ +package entities + +import "github.com/kyma-project/cli.v3/internal/kube/kyma" + +type ModuleInstallation struct { + Name string + Version string + Channel string + KymaModuleState string + ModuleState string + InstallationState string + Managed *bool + CustomResourcePolicy string + TemplateName string + TemplateNamespace string + specModuleName string + statusModuleName string +} + +func NewModuleInstallationFromRaw(raw kyma.KymaModuleInfo) *ModuleInstallation { + name := raw.Status.Name + if name == "" { + name = raw.Spec.Name + } + return &ModuleInstallation{ + Name: name, + Version: raw.Status.Version, + Channel: raw.Status.Channel, + KymaModuleState: raw.Status.State, + Managed: raw.Spec.Managed, + CustomResourcePolicy: raw.Spec.CustomResourcePolicy, + TemplateName: raw.Status.Template.GetName(), + TemplateNamespace: raw.Status.Template.GetNamespace(), + specModuleName: raw.Spec.Name, + statusModuleName: raw.Status.Name, + } +} + +func (m *ModuleInstallation) IsManaged() bool { + return m.Managed == nil || *m.Managed +} + +func (m *ModuleInstallation) IsBeingDeleted() bool { + return m.statusModuleName != "" && m.specModuleName == "" +} diff --git a/internal/modulesv2/entities/moduleinstallation_test.go b/internal/modulesv2/entities/moduleinstallation_test.go new file mode 100644 index 000000000..fd776de4e --- /dev/null +++ b/internal/modulesv2/entities/moduleinstallation_test.go @@ -0,0 +1,88 @@ +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_MapsAllFields(t *testing.T) { + managed := false + status := kyma.ModuleStatus{ + Name: "api-gateway", + Version: "3.5.1", + Channel: "regular", + State: "Ready", + } + status.Template.SetName("api-gateway-template") + status.Template.SetNamespace("kyma-system") + raw := kyma.KymaModuleInfo{ + Spec: kyma.Module{ + Name: "api-gateway", + Managed: &managed, + CustomResourcePolicy: "CreateAndDelete", + }, + Status: status, + } + + m := NewModuleInstallationFromRaw(raw) + + require.Equal(t, "api-gateway", m.Name) + require.Equal(t, "3.5.1", m.Version) + require.Equal(t, "regular", m.Channel) + require.Equal(t, "Ready", m.KymaModuleState) + require.NotNil(t, m.Managed) + require.False(t, *m.Managed) + require.Equal(t, "CreateAndDelete", m.CustomResourcePolicy) + require.Equal(t, "api-gateway-template", m.TemplateName) + require.Equal(t, "kyma-system", m.TemplateNamespace) +} + +func TestNewModuleInstallationFromRaw_UsesSpecNameWhenStatusNameIsEmpty(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Spec: kyma.Module{Name: "api-gateway"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.Equal(t, "api-gateway", m.Name) +} + +func TestModuleInstallation_IsBeingDeleted_TrueWhenOnlyInStatus(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Status: kyma.ModuleStatus{Name: "api-gateway", State: "Deleting"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.True(t, m.IsBeingDeleted()) +} + +func TestModuleInstallation_IsBeingDeleted_FalseWhenSpecPresent(t *testing.T) { + raw := kyma.KymaModuleInfo{ + Spec: kyma.Module{Name: "api-gateway"}, + Status: kyma.ModuleStatus{Name: "api-gateway", State: "Ready"}, + } + + m := NewModuleInstallationFromRaw(raw) + + require.False(t, m.IsBeingDeleted()) +} diff --git a/internal/modulesv2/fake/installedmodules.go b/internal/modulesv2/fake/installedmodules.go new file mode 100644 index 000000000..dde9388b4 --- /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 ModuleInstallationsRepository struct { + ListInstalledModulesResult []entities.ModuleInstallation + ListInstalledModulesError error +} + +func (f *ModuleInstallationsRepository) ListInstalledModules(_ context.Context) ([]entities.ModuleInstallation, error) { + return f.ListInstalledModulesResult, f.ListInstalledModulesError +} diff --git a/internal/modulesv2/list.go b/internal/modulesv2/list.go new file mode 100644 index 000000000..83dd5a384 --- /dev/null +++ b/internal/modulesv2/list.go @@ -0,0 +1,40 @@ +package modulesv2 + +import ( + "context" + + "github.com/kyma-project/cli.v3/internal/modulesv2/dtos" + "github.com/kyma-project/cli.v3/internal/modulesv2/repository" +) + +type ListService struct { + installedModulesRepository repository.ModuleInstallationsRepository +} + +func NewListService(installedModulesRepository repository.ModuleInstallationsRepository) *ListService { + return &ListService{ + installedModulesRepository: installedModulesRepository, + } +} + +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 { + results = append(results, dtos.ListResult{ + Name: module.Name, + Version: module.Version, + Channel: module.Channel, + ModuleState: module.ModuleState, + Managed: module.IsManaged(), + CustomResourcePolicy: module.CustomResourcePolicy, + InstallationState: module.InstallationState, + }) + } + + return results, nil +} diff --git a/internal/modulesv2/list_test.go b/internal/modulesv2/list_test.go new file mode 100644 index 000000000..20cf110e3 --- /dev/null +++ b/internal/modulesv2/list_test.go @@ -0,0 +1,84 @@ +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.ModuleInstallationsRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{}, + } + svc := NewListService(installedModulesRepo) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + require.Empty(t, result) +} + +func TestListService_Run_ReturnsCoreModules(t *testing.T) { + managed := true + installedModulesRepo := &modulesfake.ModuleInstallationsRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + { + Name: "api-gateway", + Version: "3.5.1", + Channel: "regular", + ModuleState: "Ready", + InstallationState: "Ready", + Managed: &managed, + CustomResourcePolicy: "CreateAndDelete", + }, + }, + } + svc := NewListService(installedModulesRepo) + + 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) + require.Equal(t, "Ready", module.InstallationState) + require.True(t, module.Managed) + require.Equal(t, "CreateAndDelete", module.CustomResourcePolicy) +} + +func TestListService_Run_ReturnsManagedTrueWhenManagedIsNil(t *testing.T) { + installedModulesRepo := &modulesfake.ModuleInstallationsRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Managed: nil}, + }, + } + svc := NewListService(installedModulesRepo) + + 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.ModuleInstallationsRepository{ + ListInstalledModulesResult: []entities.ModuleInstallation{ + {Name: "api-gateway", Managed: &managed}, + }, + } + svc := NewListService(installedModulesRepo) + + result, err := svc.Run(context.Background()) + + require.NoError(t, err) + module := result[0] + require.False(t, module.Managed) +} diff --git a/internal/modulesv2/render.go b/internal/modulesv2/render.go index 65caf291c..9cf0f6d24 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,86 @@ 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 { + 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..8569f4f52 --- /dev/null +++ b/internal/modulesv2/render_test.go @@ -0,0 +1,85 @@ +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_ShowsInstallationStateDirectly(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.NotRegexp(t, `Warning\(Deleting\)`, buf.String()) + 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..7f2a430e4 --- /dev/null +++ b/internal/modulesv2/repository/installedmodules.go @@ -0,0 +1,117 @@ +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" +) + +type ModuleInstallationsRepository interface { + ListInstalledModules(ctx context.Context) ([]entities.ModuleInstallation, error) +} + +type installedModulesRepository struct { + kymaClient kyma.Interface + moduleCRStateRepo *moduleCRStateRepository + installationStateRepo *moduleInstallationStateRepository +} + +func NewModuleInstallationsRepository(kubeClient kube.Client) ModuleInstallationsRepository { + return &installedModulesRepository{ + kymaClient: kubeClient.Kyma(), + moduleCRStateRepo: &moduleCRStateRepository{kubeClient: kubeClient}, + installationStateRepo: &moduleInstallationStateRepository{kubeClient: kubeClient}, + } +} + +func (r *installedModulesRepository) ListInstalledModules(ctx context.Context) ([]entities.ModuleInstallation, error) { + kymaCR, err := r.kymaClient.GetDefaultKyma(ctx) + if err != nil { + return nil, err + } + return r.resolveInstalledModules(ctx, kymaCR.Spec.Modules, kymaCR.Status.Modules) +} + +func (r *installedModulesRepository) resolveInstalledModules(ctx context.Context, specs []kyma.Module, statuses []kyma.ModuleStatus) ([]entities.ModuleInstallation, error) { + modules, err := r.buildModulesFromStatuses(ctx, statuses, specs) + if err != nil { + return nil, err + } + + specOnly, err := r.buildModulesFromSpecsOnly(ctx, specs, statuses) + if err != nil { + return nil, err + } + + return append(modules, specOnly...), nil +} + +func (r *installedModulesRepository) buildModulesFromStatuses(ctx context.Context, statuses []kyma.ModuleStatus, specs []kyma.Module) ([]entities.ModuleInstallation, error) { + specByName := make(map[string]kyma.Module, len(specs)) + for _, spec := range specs { + specByName[spec.Name] = spec + } + + var modules []entities.ModuleInstallation + for _, status := range statuses { + module, err := r.buildModule(ctx, kyma.KymaModuleInfo{Status: status, Spec: specByName[status.Name]}) + if err != nil { + return nil, err + } + modules = append(modules, module) + } + return modules, nil +} + +func (r *installedModulesRepository) buildModulesFromSpecsOnly(ctx context.Context, specs []kyma.Module, statuses []kyma.ModuleStatus) ([]entities.ModuleInstallation, error) { + statusByName := make(map[string]kyma.ModuleStatus, len(statuses)) + for _, status := range statuses { + statusByName[status.Name] = status + } + + var modules []entities.ModuleInstallation + for _, spec := range specs { + if _, inStatus := statusByName[spec.Name]; inStatus { + continue + } + module, err := r.buildModule(ctx, kyma.KymaModuleInfo{Spec: spec}) + if err != nil { + return nil, err + } + modules = append(modules, module) + } + return modules, nil +} + +func (r *installedModulesRepository) buildModule(ctx context.Context, raw kyma.KymaModuleInfo) (entities.ModuleInstallation, error) { + module := entities.NewModuleInstallationFromRaw(raw) + moduleState, err := r.moduleCRStateRepo.GetModuleCRState(ctx, *module) + if err != nil { + return entities.ModuleInstallation{}, err + } + module.ModuleState = moduleState + installationState, err := r.resolveInstallationState(ctx, *module) + if err != nil { + return entities.ModuleInstallation{}, err + } + module.InstallationState = installationState + return *module, nil +} + +func (r *installedModulesRepository) resolveInstallationState(ctx context.Context, module entities.ModuleInstallation) (string, error) { + if module.IsBeingDeleted() { + return module.KymaModuleState, nil + } + + if module.CustomResourcePolicy == "CreateAndDelete" { + return module.KymaModuleState, nil + } + + if !module.IsManaged() { + return module.KymaModuleState, nil + } + + return r.installationStateRepo.GetInstallationState(ctx, module) +} diff --git a/internal/modulesv2/repository/installedmodules_test.go b/internal/modulesv2/repository/installedmodules_test.go new file mode 100644 index 000000000..8462929b7 --- /dev/null +++ b/internal/modulesv2/repository/installedmodules_test.go @@ -0,0 +1,346 @@ +package repository_test + +import ( + "context" + "fmt" + "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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newKubeClient(defaultKyma kyma.Kyma, moduleTemplate kyma.ModuleTemplate, crList *unstructured.UnstructuredList) *kubefake.KubeClient { + kymaClient := &kubefake.KymaClient{ + ReturnDefaultKyma: defaultKyma, + ReturnModuleTemplate: moduleTemplate, + } + rootlessDynamic := &kubefake.RootlessDynamicClient{ + ReturnListObjs: crList, + } + return &kubefake.KubeClient{ + TestKymaInterface: kymaClient, + TestRootlessDynamicInterface: rootlessDynamic, + } +} + +func TestModuleInstallationsRepository_ListInstalledModules_NormalCase(t *testing.T) { + apiGatewayStatus := kyma.ModuleStatus{Name: "api-gateway", State: "Ready"} + apiGatewayStatus.Template.SetName("api-gateway-template") + apiGatewayStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{ + {Name: "api-gateway", CustomResourcePolicy: "CreateAndDelete"}, + }, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{apiGatewayStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Data: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "gateway.kyma-project.io/v1alpha1", + "kind": "APIGateway", + }, + }, + }, + } + crList := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{"status": map[string]interface{}{"state": "Ready"}}}, + }, + } + kubeClient := newKubeClient(defaultKyma, moduleTemplate, crList) + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 1) + module := result[0] + require.Equal(t, "api-gateway", module.Name) + require.Equal(t, "Ready", module.ModuleState) + require.Equal(t, "CreateAndDelete", module.CustomResourcePolicy) +} + +func TestModuleInstallationsRepository_ListInstalledModules_ModuleBeingAdded(t *testing.T) { + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{ + {Name: "api-gateway", CustomResourcePolicy: "CreateAndDelete"}, + }, + }, + Status: kyma.KymaStatus{}, + } + kubeClient := newKubeClient(defaultKyma, kyma.ModuleTemplate{}, nil) + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 1) + module := result[0] + require.Equal(t, "api-gateway", module.Name) + require.Equal(t, "", module.ModuleState) + require.Equal(t, "CreateAndDelete", module.CustomResourcePolicy) +} + +func TestModuleInstallationsRepository_ListInstalledModules_ModuleBeingDeleted(t *testing.T) { + deletingStatus := kyma.ModuleStatus{Name: "api-gateway", State: "Deleting"} + deletingStatus.Template.SetName("api-gateway-template") + deletingStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{}, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{deletingStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Data: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "gateway.kyma-project.io/v1alpha1", + "kind": "APIGateway", + }, + }, + }, + } + crList := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{"status": map[string]interface{}{"state": "Deleting"}}}, + }, + } + kubeClient := newKubeClient(defaultKyma, moduleTemplate, crList) + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + require.Len(t, result, 1) + module := result[0] + require.Equal(t, "api-gateway", module.Name) + require.Equal(t, "Deleting", module.KymaModuleState) +} + +func TestModuleInstallationsRepository_ListInstalledModules_SetsInstallationStateForCreateAndDelete(t *testing.T) { + warningStatus := kyma.ModuleStatus{Name: "api-gateway", State: "Warning"} + warningStatus.Template.SetName("api-gateway-template") + warningStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{ + {Name: "api-gateway", CustomResourcePolicy: "CreateAndDelete"}, + }, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{warningStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Data: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "gateway.kyma-project.io/v1alpha1", + "kind": "APIGateway", + }, + }, + }, + } + crList := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{"status": map[string]interface{}{"state": "Warning"}}}, + }, + } + kubeClient := newKubeClient(defaultKyma, moduleTemplate, crList) + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "Warning", module.InstallationState) +} + +func TestModuleInstallationsRepository_ListInstalledModules_ModuleCRState_ReturnsStateFromCR(t *testing.T) { + apiGatewayStatus := kyma.ModuleStatus{Name: "api-gateway", State: "Ready"} + apiGatewayStatus.Template.SetName("api-gateway-template") + apiGatewayStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{{Name: "api-gateway"}}, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{apiGatewayStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Data: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "operator.kyma-project.io/v1alpha1", + "kind": "APIGateway", + }, + }, + }, + } + crList := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{"status": map[string]interface{}{"state": "Warning"}}}, + }, + } + kubeClient := newKubeClient(defaultKyma, moduleTemplate, crList) + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "Warning", module.ModuleState) +} + +func TestModuleInstallationsRepository_ListInstalledModules_ModuleCRState_UnmanagedModule_FindsTemplateByNameAndVersion(t *testing.T) { + managed := false + matchingTemplate := kyma.ModuleTemplate{} + matchingTemplate.Spec.ModuleName = "api-gateway" + matchingTemplate.Spec.Version = "3.5.1" + matchingTemplate.Spec.Data = unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "operator.kyma-project.io/v1alpha1", + "kind": "APIGateway", + }, + } + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{{Name: "api-gateway", Managed: &managed}}, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{ + {Name: "api-gateway", Version: "3.5.1"}, + }, + }, + } + crList := &unstructured.UnstructuredList{ + Items: []unstructured.Unstructured{ + {Object: map[string]interface{}{"status": map[string]interface{}{"state": "Ready"}}}, + }, + } + kymaClient := &kubefake.KymaClient{ + ReturnDefaultKyma: defaultKyma, + ReturnModuleTemplateList: kyma.ModuleTemplateList{ + Items: []kyma.ModuleTemplate{matchingTemplate}, + }, + } + kubeClient := &kubefake.KubeClient{ + TestKymaInterface: kymaClient, + TestRootlessDynamicInterface: &kubefake.RootlessDynamicClient{ + ReturnListObjs: crList, + }, + } + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "Ready", module.ModuleState) +} + +func TestModuleInstallationsRepository_ListInstalledModules_ModuleCRState_ReturnsEmptyOnDiscoveryError(t *testing.T) { + eventingStatus := kyma.ModuleStatus{Name: "eventing", State: "Ready"} + eventingStatus.Template.SetName("eventing-template") + eventingStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{{Name: "eventing"}}, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{eventingStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Data: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "operator.kyma-project.io/v1alpha1", + "kind": "Eventing", + }, + }, + }, + } + kubeClient := &kubefake.KubeClient{ + TestKymaInterface: &kubefake.KymaClient{ + ReturnDefaultKyma: defaultKyma, + ReturnModuleTemplate: moduleTemplate, + }, + TestRootlessDynamicInterface: &kubefake.RootlessDynamicClient{ + ReturnErr: fmt.Errorf("failed to discover API resource using discovery client: resource 'Eventing' in group 'operator.kyma-project.io', and version 'v1alpha1' not registered on cluster"), + }, + } + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "", module.ModuleState) +} + +func TestModuleInstallationsRepository_ListInstalledModules_InstallationState_ManagedModuleUsesManagerState(t *testing.T) { + managed := true + apiGatewayStatus := kyma.ModuleStatus{Name: "api-gateway", State: "Warning"} + apiGatewayStatus.Template.SetName("api-gateway-template") + apiGatewayStatus.Template.SetNamespace("kyma-system") + defaultKyma := kyma.Kyma{ + Spec: kyma.KymaSpec{ + Modules: []kyma.Module{{Name: "api-gateway", Managed: &managed}}, + }, + Status: kyma.KymaStatus{ + Modules: []kyma.ModuleStatus{apiGatewayStatus}, + }, + } + moduleTemplate := kyma.ModuleTemplate{ + Spec: kyma.ModuleTemplateSpec{ + Manager: &kyma.Manager{ + GroupVersionKind: managerGVK("apps", "v1", "Deployment"), + Name: "api-gateway-manager", + Namespace: "kyma-system", + }, + }, + } + managerObj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "readyReplicas": int64(1), + }, + "spec": map[string]interface{}{ + "replicas": int64(1), + }, + }, + } + kubeClient := &kubefake.KubeClient{ + TestKymaInterface: &kubefake.KymaClient{ + ReturnDefaultKyma: defaultKyma, + ReturnModuleTemplate: moduleTemplate, + }, + TestRootlessDynamicInterface: &kubefake.RootlessDynamicClient{ + ReturnGetObj: managerObj, + }, + } + repo := repository.NewModuleInstallationsRepository(kubeClient) + + result, err := repo.ListInstalledModules(context.Background()) + + require.NoError(t, err) + module := result[0] + require.Equal(t, "Ready", module.InstallationState) +} + +func managerGVK(group, version, kind string) metav1.GroupVersionKind { + return metav1.GroupVersionKind{Group: group, Version: version, Kind: kind} +} diff --git a/internal/modulesv2/repository/modulecrstate.go b/internal/modulesv2/repository/modulecrstate.go new file mode 100644 index 000000000..b0e47fbc1 --- /dev/null +++ b/internal/modulesv2/repository/modulecrstate.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "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/kube/rootlessdynamic" + "github.com/kyma-project/cli.v3/internal/modulesv2/entities" + "github.com/kyma-project/cli.v3/internal/out" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type moduleCRStateRepository struct { + kubeClient kube.Client +} + +func (r *moduleCRStateRepository) GetModuleCRState(ctx context.Context, module entities.ModuleInstallation) (string, error) { + moduleTemplate, err := r.findModuleTemplate(ctx, module) + if err != nil { + return "", err + } + if moduleTemplate == nil { + return "", nil + } + + data := moduleTemplate.Spec.Data + if len(data.Object) == 0 { + return "", nil + } + + crList, err := r.kubeClient.RootlessDynamic().List(ctx, &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": data.GetAPIVersion(), + "kind": data.GetKind(), + }, + }, &rootlessdynamic.ListOptions{AllNamespaces: true}) + if err != nil { + if apierrors.IsNotFound(err) || isDiscoveryError(err) { + out.Debugfln("failed to get CR state for module %s: %v", module.Name, err) + return "", nil + } + return "", err + } + + return highestStateFromList(crList.Items), nil +} + +func (r *moduleCRStateRepository) findModuleTemplate(ctx context.Context, module entities.ModuleInstallation) (*kyma.ModuleTemplate, error) { + if module.TemplateName != "" { + mt, err := r.kubeClient.Kyma().GetModuleTemplate(ctx, module.TemplateNamespace, module.TemplateName) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return mt, nil + } + + templates, err := r.kubeClient.Kyma().ListModuleTemplate(ctx) + if err != nil { + return nil, err + } + for i, mt := range templates.Items { + if mt.Spec.ModuleName == module.Name && mt.Spec.Version == module.Version { + return &templates.Items[i], nil + } + } + return nil, nil +} + +func highestStateFromList(items []unstructured.Unstructured) string { + state := "" + for i := range items { + crState := extractStateFromObject(&items[i]) + state = highestState(state, crState) + } + return state +} + +var statesPrecedence = []string{"Ready", "Processing", "Deleting", "Error", "Warning"} + +func isDiscoveryError(err error) bool { + return strings.Contains(err.Error(), "failed to discover API resource using discovery client") +} + +func highestState(a, b string) string { + for _, s := range statesPrecedence { + if a == s { + return a + } + if b == s { + return b + } + } + if a == "" { + return b + } + return a +} diff --git a/internal/modulesv2/repository/moduleinstallationstate.go b/internal/modulesv2/repository/moduleinstallationstate.go new file mode 100644 index 000000000..fb3866b46 --- /dev/null +++ b/internal/modulesv2/repository/moduleinstallationstate.go @@ -0,0 +1,60 @@ +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 struct { + kubeClient kube.Client +} + +func (r *moduleInstallationStateRepository) GetInstallationState(ctx context.Context, module entities.ModuleInstallation) (string, error) { + moduleTemplate, err := r.kubeClient.Kyma().GetModuleTemplate(ctx, module.TemplateNamespace, module.TemplateName) + if err != nil { + if apierrors.IsNotFound(err) { + return "", nil + } + return "", errors.Wrapf(err, "failed to get ModuleTemplate %s/%s", module.TemplateNamespace, module.TemplateName) + } + + 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 + } + + return extractStateFromObject(result), nil +} diff --git a/internal/modulesv2/repository/moduleinstallationstate_test.go b/internal/modulesv2/repository/moduleinstallationstate_test.go new file mode 100644 index 000000000..01f0320a5 --- /dev/null +++ b/internal/modulesv2/repository/moduleinstallationstate_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestExtractStateFromObject_ReturnsStateField(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "state": "Ready", + }, + }, + } + + state := extractStateFromObject(obj) + + require.Equal(t, "Ready", state) +} + +func TestExtractStateFromObject_ReturnsStateFromConditions(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "True", + }, + }, + }, + }, + } + + state := extractStateFromObject(obj) + + require.Equal(t, "Ready", state) +} + +func TestExtractStateFromObject_ReturnsStateFromReplicas(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "replicas": int64(3), + }, + "status": map[string]interface{}{ + "readyReplicas": int64(3), + }, + }, + } + + state := extractStateFromObject(obj) + + require.Equal(t, "Ready", state) +} + +func TestExtractStateFromObject_ReturnsEmptyWhenNoStatus(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]interface{}{}, + } + + state := extractStateFromObject(obj) + + require.Equal(t, "", state) +} diff --git a/internal/modulesv2/repository/stateextractor.go b/internal/modulesv2/repository/stateextractor.go new file mode 100644 index 000000000..9966af3cd --- /dev/null +++ b/internal/modulesv2/repository/stateextractor.go @@ -0,0 +1,53 @@ +package repository + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +func extractStateFromObject(obj *unstructured.Unstructured) string { + statusRaw, ok := obj.Object["status"] + if !ok || statusRaw == nil { + return "" + } + status := statusRaw.(map[string]any) + if state, ok := status["state"]; ok { + return state.(string) + } + + if conditions, ok := status["conditions"]; ok { + return getStateFromConditions(conditions.([]any)) + } + + if readyReplicas, ok := status["readyReplicas"]; ok { + spec := obj.Object["spec"].(map[string]any) + if wantedReplicas, ok := spec["replicas"]; ok { + return resolveStateFromReplicas(readyReplicas.(int64), wantedReplicas.(int64)) + } + } + + return "" +} + +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" +}