From 7cb5c926684aa7f6e6652035e7757d413d8372c7 Mon Sep 17 00:00:00 2001 From: Guilherme Branco Date: Tue, 12 Mar 2024 17:43:28 -0300 Subject: [PATCH] OCM-6527 | feat: add describe ingress command --- cmd/describe/cmd.go | 26 +++--- cmd/describe/ingress/cmd.go | 87 +++++++++++++++++++ cmd/describe/ingress/cmd_test.go | 117 ++++++++++++++++++++++++++ cmd/describe/ingress/main_test.go | 13 +++ cmd/describe/ingress/options.go | 39 +++++++++ cmd/edit/ingress/cmd.go | 33 ++------ pkg/ingress/ingress.go | 133 ++++++++++++++++++++++++++++++ pkg/ingress/ingress_test.go | 46 +++++++++++ pkg/ocm/ingresses.go | 26 ++++++ 9 files changed, 481 insertions(+), 39 deletions(-) create mode 100644 cmd/describe/ingress/cmd.go create mode 100644 cmd/describe/ingress/cmd_test.go create mode 100644 cmd/describe/ingress/main_test.go create mode 100644 cmd/describe/ingress/options.go create mode 100644 pkg/ingress/ingress.go create mode 100644 pkg/ingress/ingress_test.go diff --git a/cmd/describe/cmd.go b/cmd/describe/cmd.go index 80afeea9ed..dc58ab4308 100644 --- a/cmd/describe/cmd.go +++ b/cmd/describe/cmd.go @@ -25,6 +25,7 @@ import ( "github.com/openshift/rosa/cmd/describe/breakglasscredential" "github.com/openshift/rosa/cmd/describe/cluster" "github.com/openshift/rosa/cmd/describe/externalauthprovider" + "github.com/openshift/rosa/cmd/describe/ingress" "github.com/openshift/rosa/cmd/describe/installation" "github.com/openshift/rosa/cmd/describe/kubeletconfig" "github.com/openshift/rosa/cmd/describe/machinepool" @@ -42,19 +43,18 @@ var Cmd = &cobra.Command{ } func init() { - Cmd.AddCommand(addon.Cmd) - Cmd.AddCommand(admin.Cmd) - Cmd.AddCommand(cluster.Cmd) - Cmd.AddCommand(service.Cmd) - Cmd.AddCommand(installation.Cmd) - Cmd.AddCommand(upgrade.Cmd) - Cmd.AddCommand(tuningconfigs.Cmd) machinePoolCommand := machinepool.NewDescribeMachinePoolCommand() - Cmd.AddCommand(machinePoolCommand) - Cmd.AddCommand(kubeletconfig.Cmd) - Cmd.AddCommand(autoscaler.NewDescribeAutoscalerCommand()) - Cmd.AddCommand(externalauthprovider.Cmd) - Cmd.AddCommand(breakglasscredential.Cmd) + ingressCommand := ingress.NewDescribeIngressCommand() + cmds := []*cobra.Command{ + addon.Cmd, admin.Cmd, cluster.Cmd, service.Cmd, + installation.Cmd, upgrade.Cmd, tuningconfigs.Cmd, + machinePoolCommand, kubeletconfig.Cmd, + autoscaler.NewDescribeAutoscalerCommand(), ingressCommand, + externalauthprovider.Cmd, breakglasscredential.Cmd, + } + for _, cmd := range cmds { + Cmd.AddCommand(cmd) + } flags := Cmd.PersistentFlags() arguments.AddProfileFlag(flags) @@ -65,7 +65,7 @@ func init() { machinePoolCommand, addon.Cmd, upgrade.Cmd, admin.Cmd, breakglasscredential.Cmd, externalauthprovider.Cmd, installation.Cmd, - kubeletconfig.Cmd, upgrade.Cmd, + kubeletconfig.Cmd, upgrade.Cmd, ingressCommand, } arguments.MarkRegionDeprecated(Cmd, globallyAvailableCommands) } diff --git a/cmd/describe/ingress/cmd.go b/cmd/describe/ingress/cmd.go new file mode 100644 index 0000000000..70a4da2ef2 --- /dev/null +++ b/cmd/describe/ingress/cmd.go @@ -0,0 +1,87 @@ +package ingress + +import ( + "context" + "fmt" + "regexp" + + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/spf13/cobra" + + "github.com/openshift/rosa/pkg/ingress" + "github.com/openshift/rosa/pkg/ocm" + "github.com/openshift/rosa/pkg/output" + "github.com/openshift/rosa/pkg/rosa" +) + +const ( + use = "ingress" + short = "Show details of the specified ingress within cluster" + example = `rosa describe ingress -c mycluster` +) + +// Regular expression to used to make sure that the identifier given by the +// user is safe and that it there is no risk of SQL injection: +var ingressKeyRE = regexp.MustCompile(`^[a-z0-9]{3,5}$`) + +func NewDescribeIngressCommand() *cobra.Command { + options := NewDescribeIngressUserOptions() + cmd := &cobra.Command{ + Use: use, + Short: short, + Example: example, + Run: rosa.DefaultRunner(rosa.RuntimeWithOCM(), DescribeIngressRunner(options)), + Args: func(_ *cobra.Command, argv []string) error { + if len(argv) != 1 { + return fmt.Errorf( + "Expected exactly one command line parameter containing the id of the ingress", + ) + } + return nil + }, + } + + flags := cmd.Flags() + flags.StringVar( + &options.ingress, + "ingress", + "", + "Ingress of the cluster to target", + ) + + ocm.AddClusterFlag(cmd) + output.AddFlag(cmd) + return cmd +} + +func DescribeIngressRunner(userOptions DescribeIngressUserOptions) rosa.CommandRunner { + return func(_ context.Context, runtime *rosa.Runtime, cmd *cobra.Command, argv []string) error { + options := NewDescribeIngressOptions() + if len(argv) == 1 && !cmd.Flag("ingress").Changed { + userOptions.ingress = argv[0] + } else { + err := cmd.ParseFlags(argv) + if err != nil { + return fmt.Errorf("unable to parse flags: %v", err) + } + } + err := options.Bind(userOptions) + if err != nil { + return err + } + ingressKey := options.args.ingress + if !ingressKeyRE.MatchString(ingressKey) { + return fmt.Errorf( + "ingress identifier %q isn't valid: it must contain only letters or digits", + ingressKey, + ) + } + clusterKey := runtime.GetClusterKey() + cluster := runtime.FetchCluster() + if cluster.State() != cmv1.ClusterStateReady { + return fmt.Errorf("cluster %q is not yet ready", clusterKey) + } + service := ingress.NewIngressService() + return service.DescribeIngress(runtime, cluster, ingressKey) + } +} diff --git a/cmd/describe/ingress/cmd_test.go b/cmd/describe/ingress/cmd_test.go new file mode 100644 index 0000000000..8ea9b01079 --- /dev/null +++ b/cmd/describe/ingress/cmd_test.go @@ -0,0 +1,117 @@ +package ingress + +import ( + "context" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + . "github.com/openshift-online/ocm-sdk-go/testing" + + "github.com/openshift/rosa/pkg/aws" + . "github.com/openshift/rosa/pkg/output" + "github.com/openshift/rosa/pkg/test" +) + +var _ = Describe("Describe ingress", func() { + const ( + ingressOutput = `Cluster ID: 123 +Component Routes: + console: + Hostname: console-hostname + TLS Secret Ref: console-secret + downloads: + Hostname: downloads-hostname + TLS Secret Ref: downloads-secret + oauth: + Hostname: oauth-hostname + TLS Secret Ref: oauth-secret +Default: true +Excluded Namespaces: [excluded-ns-1, excluded-ns-2] +ID: a1b1 +LB-Type: nlb +Namespace Ownership Policy: Strict +Private: false +Route Selectors: map[route-1:selector-1 route-2:selector-2] +Wildcard Policy: WildcardsAllowed +` + ) + Context("describe", func() { + // Full diff for long string to help debugging + format.TruncatedDiff = false + + mockReadyCluster := test.MockCluster(func(c *cmv1.ClusterBuilder) { + c.ID("123") + c.Region(cmv1.NewCloudRegion().ID(aws.DefaultRegion)) + c.State(cmv1.ClusterStateReady) + }) + classicClusterReady := test.FormatClusterList([]*cmv1.Cluster{mockReadyCluster}) + ingress, err := cmv1.NewIngress(). + ID("a1b1"). + Default(true). + Listening(cmv1.ListeningMethodExternal). + LoadBalancerType(cmv1.LoadBalancerFlavorNlb). + RouteWildcardPolicy(cmv1.WildcardPolicyWildcardsAllowed). + RouteNamespaceOwnershipPolicy(cmv1.NamespaceOwnershipPolicyStrict). + RouteSelectors(map[string]string{ + "route-1": "selector-1", + "route-2": "selector-2", + }). + ExcludedNamespaces("excluded-ns-1", "excluded-ns-2"). + ComponentRoutes(map[string]*cmv1.ComponentRouteBuilder{ + "oauth": cmv1.NewComponentRoute().Hostname("oauth-hostname").TlsSecretRef("oauth-secret"), + "downloads": cmv1.NewComponentRoute().Hostname("downloads-hostname").TlsSecretRef("downloads-secret"), + "console": cmv1.NewComponentRoute().Hostname("console-hostname").TlsSecretRef("console-secret"), + }).Build() + Expect(err).To(BeNil()) + ingressResponse := test.FormatIngressList([]*cmv1.Ingress{ingress}) + var t *test.TestingRuntime + BeforeEach(func() { + t = test.NewTestRuntime() + SetOutput("") + }) + + It("Fails if ingress ID/alias has not been specified", func() { + runner := DescribeIngressRunner(NewDescribeIngressUserOptions()) + err := t.StdOutReader.Record() + Expect(err).ToNot(HaveOccurred()) + err = runner(context.Background(), t.RosaRuntime, NewDescribeIngressCommand(), []string{}) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("you need to specify an ingress ID/alias")) + }) + + It("Ingress not found", func() { + t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, classicClusterReady)) + t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusNotFound, "")) + args := NewDescribeIngressUserOptions() + args.ingress = "apps" + runner := DescribeIngressRunner(args) + err := t.StdOutReader.Record() + Expect(err).ToNot(HaveOccurred()) + err = runner(context.Background(), t.RosaRuntime, NewDescribeIngressCommand(), []string{ + "-c", mockReadyCluster.ID(), + }) + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(Equal("Failed to get ingress 'apps' for cluster '123'")) + }) + + It("Ingress found", func() { + t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, classicClusterReady)) + t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, ingressResponse)) + args := NewDescribeIngressUserOptions() + args.ingress = "apps" + runner := DescribeIngressRunner(args) + err := t.StdOutReader.Record() + Expect(err).ToNot(HaveOccurred()) + err = runner(context.Background(), t.RosaRuntime, NewDescribeIngressCommand(), []string{ + "-c", mockReadyCluster.ID(), + }) + Expect(err).ToNot(HaveOccurred()) + stdout, err := t.StdOutReader.Read() + Expect(err).ToNot(HaveOccurred()) + Expect(stdout).To(Equal(ingressOutput)) + }) + }) +}) diff --git a/cmd/describe/ingress/main_test.go b/cmd/describe/ingress/main_test.go new file mode 100644 index 0000000000..df4ce79e65 --- /dev/null +++ b/cmd/describe/ingress/main_test.go @@ -0,0 +1,13 @@ +package ingress + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEditCluster(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Describe ingress suite") +} diff --git a/cmd/describe/ingress/options.go b/cmd/describe/ingress/options.go new file mode 100644 index 0000000000..aab593f872 --- /dev/null +++ b/cmd/describe/ingress/options.go @@ -0,0 +1,39 @@ +package ingress + +import ( + "fmt" + + "github.com/openshift/rosa/pkg/reporter" +) + +type DescribeIngressUserOptions struct { + ingress string +} + +type DescribeIngressOptions struct { + reporter *reporter.Object + args DescribeIngressUserOptions +} + +func NewDescribeIngressUserOptions() DescribeIngressUserOptions { + return DescribeIngressUserOptions{ingress: ""} +} + +func NewDescribeIngressOptions() *DescribeIngressOptions { + return &DescribeIngressOptions{ + reporter: reporter.CreateReporter(), + args: NewDescribeIngressUserOptions(), + } +} + +func (i *DescribeIngressOptions) Ingress() string { + return i.args.ingress +} + +func (i *DescribeIngressOptions) Bind(args DescribeIngressUserOptions) error { + if args.ingress == "" { + return fmt.Errorf("you need to specify an ingress ID/alias") + } + i.args.ingress = args.ingress + return nil +} diff --git a/cmd/edit/ingress/cmd.go b/cmd/edit/ingress/cmd.go index a955a1a063..564050c715 100644 --- a/cmd/edit/ingress/cmd.go +++ b/cmd/edit/ingress/cmd.go @@ -156,11 +156,11 @@ func run(cmd *cobra.Command, argv []string) { r := rosa.NewRuntime().WithAWS().WithOCM() defer r.Cleanup() - ingressID := argv[0] - if !ingressKeyRE.MatchString(ingressID) { + ingressKey := argv[0] + if !ingressKeyRE.MatchString(ingressKey) { r.Reporter.Errorf( "Ingress identifier '%s' isn't valid: it must contain only letters or digits", - ingressID, + ingressKey, ) os.Exit(1) } @@ -223,7 +223,7 @@ func run(cmd *cobra.Command, argv []string) { private = &privArg } // Edit API endpoint instead of ingresses - if ingressID == "api" { + if ingressKey == "api" { clusterConfig := ocm.Spec{ Private: private, } @@ -233,32 +233,13 @@ func run(cmd *cobra.Command, argv []string) { r.Reporter.Errorf("Failed to update cluster API on cluster '%s': %v", clusterKey, err) os.Exit(1) } - r.Reporter.Infof("Updated ingress '%s' on cluster '%s'", ingressID, clusterKey) + r.Reporter.Infof("Updated ingress '%s' on cluster '%s'", ingressKey, clusterKey) os.Exit(0) } - // Try to find the ingress: - r.Reporter.Debugf("Loading ingresses for cluster '%s'", clusterKey) - ingresses, err := r.OCMClient.GetIngresses(cluster.ID()) + ingress, err := r.OCMClient.GetIngress(cluster.ID(), ingressKey) if err != nil { - r.Reporter.Errorf("Failed to get ingresses for cluster '%s': %v", clusterKey, err) - os.Exit(1) - } - - var ingress *cmv1.Ingress - for _, item := range ingresses { - if ingressID == "apps" && item.Default() { - ingress = item - } - if ingressID == "apps2" && !item.Default() { - ingress = item - } - if item.ID() == ingressID { - ingress = item - } - } - if ingress == nil { - r.Reporter.Errorf("Failed to get ingress '%s' for cluster '%s'", ingressID, clusterKey) + r.Reporter.Errorf("Failed to fetch ingress: %v", err) os.Exit(1) } diff --git a/pkg/ingress/ingress.go b/pkg/ingress/ingress.go new file mode 100644 index 0000000000..8945e931aa --- /dev/null +++ b/pkg/ingress/ingress.go @@ -0,0 +1,133 @@ +package ingress + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + + "github.com/openshift/rosa/pkg/helper" + "github.com/openshift/rosa/pkg/output" + "github.com/openshift/rosa/pkg/rosa" +) + +type IngressService interface { + DescribeIngress(r *rosa.Runtime, cluster *cmv1.Cluster, ingressKey string) error +} + +type ingress struct{} + +var _ IngressService = &ingress{} + +func NewIngressService() IngressService { + return &ingress{} +} + +func (i ingress) DescribeIngress(r *rosa.Runtime, cluster *cmv1.Cluster, ingressKey string) error { + ingress, err := r.OCMClient.GetIngress(cluster.ID(), ingressKey) + if err != nil { + return err + } + if output.HasFlag() { + var b bytes.Buffer + err := cmv1.MarshalIngress(ingress, &b) + if err != nil { + return fmt.Errorf("failed to generate output for ingress '%s': %v", ingressKey, err) + } + ret := make(map[string]interface{}) + err = json.Unmarshal(b.Bytes(), &ret) + if err != nil { + return fmt.Errorf("failed to generate output for ingress '%s': %v", ingressKey, err) + } + err = output.Print(ret) + if err != nil { + return fmt.Errorf("Failed to output ingress '%s': %v", ingressKey, err) + } + return nil + } + entries := generateEntriesOutput(cluster, ingress) + ingressOutput := "" + keys := helper.MapKeys(entries) + sort.Strings(keys) + minWidth := getMinWidth(keys) + for _, key := range keys { + ingressOutput += fmt.Sprintf("%s: %s\n", key, strings.Repeat(" ", minWidth-len(key))+entries[key]) + } + fmt.Print(ingressOutput) + return nil +} + +// Min width is defined as the length of the longest string +func getMinWidth(keys []string) int { + minWidth := 0 + for _, key := range keys { + if len(key) > minWidth { + minWidth = len(key) + } + } + return minWidth +} + +func generateEntriesOutput(cluster *cmv1.Cluster, ingress *cmv1.Ingress) map[string]string { + private := false + if ingress.Listening() == cmv1.ListeningMethodInternal { + private = true + } + entries := map[string]string{ + "ID": ingress.ID(), + "Cluster ID": cluster.ID(), + "Default": strconv.FormatBool(ingress.Default()), + "Private": strconv.FormatBool(private), + "LB-Type": string(ingress.LoadBalancerType()), + } + // These are only available for ingress v2 + wildcardPolicy := string(ingress.RouteWildcardPolicy()) + if wildcardPolicy != "" { + entries["Wildcard Policy"] = string(ingress.RouteWildcardPolicy()) + } + namespaceOwnershipPolicy := string(ingress.RouteNamespaceOwnershipPolicy()) + if namespaceOwnershipPolicy != "" { + entries["Namespace Ownership Policy"] = namespaceOwnershipPolicy + } + routeSelectors := "" + if len(ingress.RouteSelectors()) > 0 { + routeSelectors = fmt.Sprintf("%v", ingress.RouteSelectors()) + } + if routeSelectors != "" { + entries["Route Selectors"] = routeSelectors + } + excludedNamespaces := helper.SliceToSortedString(ingress.ExcludedNamespaces()) + if excludedNamespaces != "" { + entries["Excluded Namespaces"] = excludedNamespaces + } + componentRoutes := "" + for component, value := range ingress.ComponentRoutes() { + keys := helper.MapKeys(entries) + minWidth := getMinWidth(keys) + depth := 4 + componentRouteEntries := map[string]string{ + "Hostname": value.Hostname(), + "TLS Secret Ref": value.TlsSecretRef(), + } + componentRoutes += fmt.Sprintf("%s: \n", strings.Repeat(" ", depth)+component) + depth *= 2 + for key, entry := range componentRouteEntries { + componentRoutes += fmt.Sprintf( + "%s: %s\n", + strings.Repeat(" ", depth)+key, + strings.Repeat(" ", minWidth-len(key)-depth)+entry, + ) + } + } + if componentRoutes != "" { + componentRoutes = fmt.Sprintf("\n%s", componentRoutes) + //remove extra \n at the end + componentRoutes = componentRoutes[:len(componentRoutes)-1] + entries["Component Routes"] = componentRoutes + } + return entries +} diff --git a/pkg/ingress/ingress_test.go b/pkg/ingress/ingress_test.go new file mode 100644 index 0000000000..1c22f707d5 --- /dev/null +++ b/pkg/ingress/ingress_test.go @@ -0,0 +1,46 @@ +package ingress + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" +) + +var _ = Describe("Get min width for output", func() { + It("retrieves the min width", func() { + minWidth := getMinWidth([]string{"a", "ab", "abc", "def"}) + Expect(minWidth).To(Equal(3)) + }) + When("empty slice", func() { + It("retrieves the min width as 0", func() { + minWidth := getMinWidth([]string{}) + Expect(minWidth).To(Equal(0)) + }) + }) +}) + +var _ = Describe("Retrieve map of entries for output", func() { + It("retrieves map", func() { + cluster, err := v1.NewCluster().ID("123").Build() + Expect(err).To(BeNil()) + ingress, err := v1.NewIngress(). + ID("123"). + Default(true). + Listening(v1.ListeningMethodExternal). + LoadBalancerType(v1.LoadBalancerFlavorNlb). + RouteWildcardPolicy(v1.WildcardPolicyWildcardsAllowed). + RouteNamespaceOwnershipPolicy(v1.NamespaceOwnershipPolicyStrict). + RouteSelectors(map[string]string{ + "test-route": "test-selector", + }). + ExcludedNamespaces("test", "test2"). + ComponentRoutes(map[string]*v1.ComponentRouteBuilder{ + string(v1.ComponentRouteTypeOauth): v1.NewComponentRoute(). + Hostname("oauth-hostname").TlsSecretRef("oauth-secret"), + }). + Build() + Expect(err).To(BeNil()) + mapOutput := generateEntriesOutput(cluster, ingress) + Expect(mapOutput).To(HaveLen(10)) + }) +}) diff --git a/pkg/ocm/ingresses.go b/pkg/ocm/ingresses.go index e9b2179880..beeafc6764 100644 --- a/pkg/ocm/ingresses.go +++ b/pkg/ocm/ingresses.go @@ -17,9 +17,35 @@ limitations under the License. package ocm import ( + "fmt" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" ) +func (c *Client) GetIngress(clusterId string, ingressKey string) (*cmv1.Ingress, error) { + ingresses, err := c.GetIngresses(clusterId) + if err != nil { + return nil, err + } + + var ingress *cmv1.Ingress + for _, item := range ingresses { + if ingressKey == "apps" && item.Default() { + ingress = item + } + if ingressKey == "apps2" && !item.Default() { + ingress = item + } + if item.ID() == ingressKey { + ingress = item + } + } + if ingress == nil { + return nil, fmt.Errorf("Failed to get ingress '%s' for cluster '%s'", ingressKey, clusterId) + } + return ingress, nil +} + func (c *Client) GetIngresses(clusterID string) ([]*cmv1.Ingress, error) { response, err := c.ocm.ClustersMgmt().V1(). Clusters().Cluster(clusterID).