diff --git a/internal/cmd/alpha/alpha.go b/internal/cmd/alpha/alpha.go index 5b231f254..c5e50466d 100644 --- a/internal/cmd/alpha/alpha.go +++ b/internal/cmd/alpha/alpha.go @@ -29,9 +29,9 @@ func NewAlphaCMD() (*cobra.Command, clierror.Error) { } cmd.AddCommand(hana.NewHanaCMD(config)) - cmd.AddCommand(imageimport.NewImportCMD(config)) cmd.AddCommand(provision.NewProvisionCMD()) cmd.AddCommand(referenceinstance.NewReferenceInstanceCMD(config)) + cmd.AddCommand(imageimport.NewImportCMD(config)) cmd.AddCommand(access.NewAccessCMD(config)) cmd.AddCommand(oidc.NewOIDCCMD(config)) cmd.AddCommand(modules.NewModulesCMD(config)) @@ -39,5 +39,8 @@ func NewAlphaCMD() (*cobra.Command, clierror.Error) { cmd.AddCommand(remove.NewRemoveCMD(config)) cmd.AddCommand(registry.NewRegistryCMD(config)) + cmds := cmdcommon.BuildExtensions(config) + cmd.AddCommand(cmds...) + return cmd, nil } diff --git a/internal/cmd/alpha/templates/explain.go b/internal/cmd/alpha/templates/explain.go new file mode 100644 index 000000000..019d5a587 --- /dev/null +++ b/internal/cmd/alpha/templates/explain.go @@ -0,0 +1,24 @@ +package templates + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +type ExplainOptions struct { + Short string + Long string + Output string +} + +func BuildExplainCommand(explainOptions *ExplainOptions) *cobra.Command { + return &cobra.Command{ + Use: "explain", + Short: explainOptions.Short, + Long: explainOptions.Long, + Run: func(_ *cobra.Command, _ []string) { + fmt.Println(explainOptions.Output) + }, + } +} diff --git a/internal/cmdcommon/extension.go b/internal/cmdcommon/extension.go new file mode 100644 index 000000000..e5d5f2166 --- /dev/null +++ b/internal/cmdcommon/extension.go @@ -0,0 +1,128 @@ +package cmdcommon + +import ( + "context" + "errors" + "fmt" + + "github.com/kyma-project/cli.v3/internal/cmd/alpha/templates" + pkgerrors "github.com/pkg/errors" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func BuildExtensions(config *KymaConfig) []*cobra.Command { + cmds := make([]*cobra.Command, len(config.Extensions)) + + for i, extension := range config.Extensions { + cmds[i] = buildCommandFromExtension(&extension) + } + + return cmds +} + +func buildCommandFromExtension(extension *Extension) *cobra.Command { + cmd := &cobra.Command{ + Use: extension.RootCommand.Name, + Short: extension.RootCommand.Description, + Long: extension.RootCommand.DescriptionLong, + Run: func(cmd *cobra.Command, _ []string) { + if err := cmd.Help(); err != nil { + _ = err + } + }, + } + + if extension.TemplateCommands != nil { + addGenericCommands(cmd, extension.TemplateCommands) + } + + return cmd +} + +func addGenericCommands(cmd *cobra.Command, genericCommands *TemplateCommands) { + if genericCommands.ExplainCommand != nil { + cmd.AddCommand(templates.BuildExplainCommand(&templates.ExplainOptions{ + Short: genericCommands.ExplainCommand.Description, + Long: genericCommands.ExplainCommand.DescriptionLong, + Output: genericCommands.ExplainCommand.Output, + })) + } +} + +func ListExtensions(ctx context.Context, client kubernetes.Interface) (ExtensionList, error) { + labelSelector := fmt.Sprintf("%s==%s", ExtensionLabelKey, ExtensionResourceLabelValue) + cms, err := client.CoreV1().ConfigMaps("").List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to load ConfigMaps from cluster with label %s", labelSelector) + } + + var extensions []Extension + var parseErr error + for _, cm := range cms.Items { + extension, err := parseResourceExtension(cm.Data) + if err != nil { + // if the parse failed add an error to the errors list to take another extension + // corrupted extension should not stop parsing the rest of the extensions + parseErr = errors.Join( + parseErr, + pkgerrors.Wrapf(err, "failed to parse configmap '%s/%s'", cm.GetNamespace(), cm.GetName()), + ) + continue + } + + extensions = append(extensions, *extension) + } + + return extensions, parseErr +} + +func parseResourceExtension(cmData map[string]string) (*Extension, error) { + rootCommand, err := parseRequiredField[RootCommand](cmData, ExtensionRootCommandKey) + if err != nil { + return nil, err + } + + resourceInfo, err := parseOptionalField[ResourceInfo](cmData, ExtensionResourceInfoKey) + if err != nil { + return nil, err + } + + genericCommands, err := parseOptionalField[TemplateCommands](cmData, ExtensionGenericCommandsKey) + if err != nil { + return nil, err + } + + return &Extension{ + RootCommand: *rootCommand, + Resource: resourceInfo, + TemplateCommands: genericCommands, + }, nil +} + +func parseRequiredField[T any](cmData map[string]string, cmKey string) (*T, error) { + dataBytes, ok := cmData[cmKey] + if !ok { + return nil, fmt.Errorf("missing .data.%s field", cmKey) + } + + var data T + err := yaml.Unmarshal([]byte(dataBytes), &data) + return &data, err +} + +func parseOptionalField[T any](cmData map[string]string, cmKey string) (*T, error) { + dataBytes, ok := cmData[cmKey] + if !ok { + // skip because field is not required + return nil, nil + } + + var data T + err := yaml.Unmarshal([]byte(dataBytes), &data) + return &data, err +} diff --git a/internal/cmdcommon/extension_test.go b/internal/cmdcommon/extension_test.go new file mode 100644 index 000000000..0e85998c2 --- /dev/null +++ b/internal/cmdcommon/extension_test.go @@ -0,0 +1,141 @@ +package cmdcommon + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8s_fake "k8s.io/client-go/kubernetes/fake" +) + +func TestListFromCluster(t *testing.T) { + t.Run("list extensions from cluster", func(t *testing.T) { + client := k8s_fake.NewSimpleClientset( + fixTestExtensionConfigmap("test-1"), + fixTestExtensionConfigmap("test-2"), + fixTestExtensionConfigmap("test-3"), + ) + + want := ExtensionList{ + fixTestExtension("test-1"), + fixTestExtension("test-2"), + fixTestExtension("test-3"), + } + + got, err := ListExtensions(context.Background(), client) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("missing rootCommand error", func(t *testing.T) { + client := k8s_fake.NewSimpleClientset( + &corev1.ConfigMap{ + ObjectMeta: v1.ObjectMeta{ + Name: "bad-data", + Labels: map[string]string{ + ExtensionLabelKey: ExtensionResourceLabelValue, + }, + }, + Data: map[string]string{}, + }, + ) + + got, err := ListExtensions(context.Background(), client) + require.ErrorContains(t, err, "failed to parse configmap '/bad-data': missing .data.rootCommand field") + require.Nil(t, got) + }) + + t.Run("skip optional fields", func(t *testing.T) { + client := k8s_fake.NewSimpleClientset( + &corev1.ConfigMap{ + ObjectMeta: v1.ObjectMeta{ + Name: "bad-data", + Labels: map[string]string{ + ExtensionLabelKey: ExtensionResourceLabelValue, + }, + }, + Data: map[string]string{ + ExtensionRootCommandKey: ` +name: test-command +description: test-description +descriptionLong: test-description-long +`, + }, + }, + ) + + want := ExtensionList{ + { + RootCommand: RootCommand{ + Name: "test-command", + Description: "test-description", + DescriptionLong: "test-description-long", + }, + }, + } + + got, err := ListExtensions(context.Background(), client) + require.NoError(t, err) + require.Equal(t, want, got) + }) +} + +func fixTestExtensionConfigmap(name string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + ExtensionLabelKey: ExtensionResourceLabelValue, + }, + }, + Data: map[string]string{ + ExtensionRootCommandKey: fmt.Sprintf(` +name: %s +description: test-description +descriptionLong: test-description-long +`, name), + ExtensionResourceInfoKey: ` +scope: namespace +kind: TestKind +group: test.group +version: v1 +singular: testkind +plural: testkinds +`, + ExtensionGenericCommandsKey: ` +explain: + description: test-description + descriptionLong: test-description-long + output: test-explain-output +`, + }, + } +} + +func fixTestExtension(name string) Extension { + return Extension{ + RootCommand: RootCommand{ + Name: name, + Description: "test-description", + DescriptionLong: "test-description-long", + }, + Resource: &ResourceInfo{ + Scope: NamespacedScope, + Kind: "TestKind", + Group: "test.group", + Version: "v1", + Singular: "testkind", + Plural: "testkinds", + }, + TemplateCommands: &TemplateCommands{ + ExplainCommand: &ExplainCommand{ + Description: "test-description", + DescriptionLong: "test-description-long", + Output: "test-explain-output", + }, + }, + } +} diff --git a/internal/cmdcommon/extension_types.go b/internal/cmdcommon/extension_types.go new file mode 100644 index 000000000..a71321a95 --- /dev/null +++ b/internal/cmdcommon/extension_types.go @@ -0,0 +1,62 @@ +package cmdcommon + +const ( + ExtensionLabelKey = "kyma-cli/extension" + ExtensionResourceLabelValue = "resource" + + ExtensionResourceInfoKey = "resource" + ExtensionRootCommandKey = "rootCommand" + ExtensionGenericCommandsKey = "templateCommands" +) + +type ExtensionList []Extension + +type Extension struct { + // main command of the command group + RootCommand RootCommand + // details about managed resource passed to every sub-command + Resource *ResourceInfo + // configuration of generic commands (like 'create', 'delete', 'get', ...) which implementation is provided by the cli + // most of these commands bases on the `Resource` field + TemplateCommands *TemplateCommands +} + +type Scope string + +const ( + ClusterScope Scope = "cluster" + NamespacedScope Scope = "namespace" +) + +type RootCommand struct { + // name of the command group + Name string `yaml:"name"` + // short description of the command group + Description string `yaml:"description"` + // long description of the command group + DescriptionLong string `yaml:"descriptionLong"` +} + +type ResourceInfo struct { + Scope Scope `yaml:"scope"` + Kind string `yaml:"kind"` + Group string `yaml:"group"` + Version string `yaml:"version"` + Singular string `yaml:"singular"` + Plural string `yaml:"plural"` +} + +type ExplainCommand struct { + // short description of the command + Description string `yaml:"description"` + // long description of the command group + DescriptionLong string `yaml:"descriptionLong"` + // text that will be printed after running the `explain` command + Output string `yaml:"output"` +} + +type TemplateCommands struct { + // allows to explaining command to the commands group in format: + // kyma explain + ExplainCommand *ExplainCommand `yaml:"explain"` +} diff --git a/internal/cmdcommon/kymaconfig.go b/internal/cmdcommon/kymaconfig.go index 81fe95412..150485bd7 100644 --- a/internal/cmdcommon/kymaconfig.go +++ b/internal/cmdcommon/kymaconfig.go @@ -2,6 +2,7 @@ package cmdcommon import ( "context" + "fmt" "github.com/kyma-project/cli.v3/internal/clierror" "github.com/spf13/cobra" @@ -11,17 +12,29 @@ import ( type KymaConfig struct { *KubeClientConfig - Ctx context.Context + Ctx context.Context + Extensions ExtensionList } func NewKymaConfig(cmd *cobra.Command) (*KymaConfig, clierror.Error) { - kubeClient, err := newKubeClientConfig(cmd) + ctx := context.Background() + + kubeClient, kubeClientErr := newKubeClientConfig(cmd) + if kubeClientErr != nil { + return nil, kubeClientErr + } + + extensions, err := ListExtensions(ctx, kubeClient.KubeClient.Static()) if err != nil { - return nil, err + fmt.Printf("DEBUG ERROR: %s\n", err.Error()) + // TODO: think about handling error later + // this error should not stop program + // but I'm not sure what we should do with such information due to it's internal value } return &KymaConfig{ - Ctx: context.Background(), + Ctx: ctx, KubeClientConfig: kubeClient, + Extensions: extensions, }, nil }