diff --git a/cmd/ocm/edit/ingress/cmd.go b/cmd/ocm/edit/ingress/cmd.go index 470040ae..fc3e806a 100644 --- a/cmd/ocm/edit/ingress/cmd.go +++ b/cmd/ocm/edit/ingress/cmd.go @@ -20,6 +20,7 @@ import ( c "github.com/openshift-online/ocm-cli/pkg/cluster" "github.com/openshift-online/ocm-cli/pkg/ocm" + "github.com/openshift-online/ocm-cli/pkg/utils" cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" "github.com/spf13/cobra" ) @@ -31,6 +32,15 @@ var ValidWildcardPolicies = []string{string(cmv1.WildcardPolicyWildcardsDisallow string(cmv1.WildcardPolicyWildcardsAllowed)} var ValidNamespaceOwnershipPolicies = []string{string(cmv1.NamespaceOwnershipPolicyStrict), string(cmv1.NamespaceOwnershipPolicyInterNamespaceAllowed)} +var expectedComponentRoutes = []string{ + string(cmv1.ComponentRouteTypeOauth), + string(cmv1.ComponentRouteTypeConsole), + string(cmv1.ComponentRouteTypeDownloads), +} +var expectedParameters = []string{ + hostnameParameter, + tlsSecretRefParameter, +} var args struct { clusterKey string @@ -43,6 +53,8 @@ var args struct { namespaceOwnershipPolicy string clusterRoutesHostname string clusterRoutesTlsSecretRef string + + componentRoutes string } const ( @@ -55,6 +67,12 @@ const ( namespaceOwnershipPolicyFlag = "namespace-ownership-policy" clusterRoutesHostnameFlag = "cluster-routes-hostname" clusterRoutesTlsSecretRefFlag = "cluster-routes-tls-secret-ref" + componentRoutesFlag = "component-routes" + + amountOfParametersPerComponentRoute = 2 + hostnameParameter = "hostname" + //nolint:gosec + tlsSecretRefParameter = "tlsSecretRef" ) var Cmd = &cobra.Command{ @@ -148,6 +166,15 @@ func init() { "", "Components route TLS secret reference for oauth, console, download.", ) + + flags.StringVar( + &args.componentRoutes, + componentRoutesFlag, + "", + //nolint:lll + "Component routes settings. Available keys [oauth, console, download]. For each key a pair of hostname and tlsSecretRef is expected to be supplied. "+ + "Format should be a comma separate list 'oauth: hostname=example-hostname;tlsSecretRef=example-secret-ref,downloads:...", + ) } func run(cmd *cobra.Command, argv []string) error { @@ -284,6 +311,17 @@ func run(cmd *cobra.Command, argv []string) error { ingressBuilder = ingressBuilder.ClusterRoutesTlsSecretRef(args.clusterRoutesTlsSecretRef) } + if cmd.Flags().Changed(componentRoutesFlag) { + if cluster.Hypershift().Enabled() { + return fmt.Errorf("Can't edit `%s` for Hosted Control Plane clusters", componentRoutesFlag) + } + componentRoutes, err := parseComponentRoutes(args.componentRoutes) + if err != nil { + return fmt.Errorf("An error occurred whilst parsing the supplied component routes: %s", err) + } + ingressBuilder = ingressBuilder.ComponentRoutes(componentRoutes) + } + ingress, err = ingressBuilder.Build() if err != nil { return fmt.Errorf("Failed to edit ingress for cluster '%s': %v", clusterKey, err) @@ -302,6 +340,66 @@ func run(cmd *cobra.Command, argv []string) error { return nil } +func parseComponentRoutes(input string) (map[string]*cmv1.ComponentRouteBuilder, error) { + result := map[string]*cmv1.ComponentRouteBuilder{} + input = strings.TrimSpace(input) + components := strings.Split(input, ",") + if len(components) != len(expectedComponentRoutes) { + return nil, fmt.Errorf( + "the expected amount of component routes is %d, but %d have been supplied", + len(expectedComponentRoutes), + len(components), + ) + } + for _, component := range components { + component = strings.TrimSpace(component) + parsedComponent := strings.Split(component, ":") + if len(parsedComponent) != amountOfParametersPerComponentRoute { + return nil, fmt.Errorf( + "only the name of the component should be followed by ':'", + ) + } + componentName := strings.TrimSpace(parsedComponent[0]) + if !utils.Contains(expectedComponentRoutes, componentName) { + return nil, fmt.Errorf( + "'%s' is not a valid component name. Expected include %s", + componentName, + utils.SliceToSortedString(expectedComponentRoutes), + ) + } + parameters := strings.TrimSpace(parsedComponent[1]) + componentRouteBuilder := new(cmv1.ComponentRouteBuilder) + parsedParameter := strings.Split(parameters, ";") + if len(parsedParameter) != amountOfParametersPerComponentRoute { + return nil, fmt.Errorf( + "only %d parameters are expected for each component", + amountOfParametersPerComponentRoute, + ) + } + for _, values := range parsedParameter { + values = strings.TrimSpace(values) + parsedValues := strings.Split(values, "=") + parameterName := strings.TrimSpace(parsedValues[0]) + if !utils.Contains(expectedParameters, parameterName) { + return nil, fmt.Errorf( + "'%s' is not a valid parameter for a component route. Expected include %s", + parameterName, + utils.SliceToSortedString(expectedParameters), + ) + } + parameterValue := strings.TrimSpace(parsedValues[1]) + // TODO: use reflection, couldn't get it to work + if parameterName == hostnameParameter { + componentRouteBuilder.Hostname(parameterValue) + } else if parameterName == tlsSecretRefParameter { + componentRouteBuilder.TlsSecretRef(parameterValue) + } + } + result[componentName] = componentRouteBuilder + } + return result, nil +} + func GetExcludedNamespaces(excludedNamespaces string) []string { if excludedNamespaces == "" { return []string{} diff --git a/cmd/ocm/edit/ingress/cmd_test.go b/cmd/ocm/edit/ingress/cmd_test.go new file mode 100644 index 00000000..8da3ab66 --- /dev/null +++ b/cmd/ocm/edit/ingress/cmd_test.go @@ -0,0 +1,78 @@ +package ingress + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Parse component routes", func() { + It("Parses input string for component routes", func() { + componentRouteBuilder, err := parseComponentRoutes( + //nolint:lll + "oauth: hostname=oauth-host;tlsSecretRef=oauth-secret,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret,console: hostname=console-host;tlsSecretRef=console-secret", + ) + Expect(err).To(BeNil()) + for key, builder := range componentRouteBuilder { + expectedHostname := fmt.Sprintf("%s-host", key) + expectedTlsRef := fmt.Sprintf("%s-secret", key) + componentRoute, err := builder.Build() + Expect(err).To(BeNil()) + Expect(componentRoute.Hostname()).To(Equal(expectedHostname)) + Expect(componentRoute.TlsSecretRef()).To(Equal(expectedTlsRef)) + } + }) + Context("Fails to parse input string for component routes", func() { + It("fails due to invalid component route", func() { + _, err := parseComponentRoutes( + //nolint:lll + "unknown: hostname=oauth-host;tlsSecretRef=oauth-secret,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret,console: hostname=console-host;tlsSecretRef=console-secret", + ) + Expect(err).ToNot(BeNil()) + Expect( + err.Error(), + ).To(Equal("'unknown' is not a valid component name. Expected include [oauth, console, downloads]")) + }) + It("fails due to wrong amount of component routes", func() { + _, err := parseComponentRoutes( + //nolint:lll + "oauth: hostname=oauth-host;tlsSecretRef=oauth-secret,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret", + ) + Expect(err).ToNot(BeNil()) + Expect( + err.Error(), + ).To(Equal("the expected amount of component routes is 3, but 2 have been supplied")) + }) + It("fails if it can split ':' in more than they key separation", func() { + _, err := parseComponentRoutes( + //nolint:lll + "oauth: hostname=oauth:-host;tlsSecretRef=oauth-secret,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret,", + ) + Expect(err).ToNot(BeNil()) + Expect( + err.Error(), + ).To(Equal("only the name of the component should be followed by ':'")) + }) + It("fails due to invalid parameter", func() { + _, err := parseComponentRoutes( + //nolint:lll + "oauth: unknown=oauth-host;tlsSecretRef=oauth-secret,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret,console: hostname=console-host;tlsSecretRef=console-secret", + ) + Expect(err).ToNot(BeNil()) + Expect( + err.Error(), + ).To(Equal("'unknown' is not a valid parameter for a component route. Expected include [hostname, tlsSecretRef]")) + }) + It("fails due to wrong amount of parameters", func() { + _, err := parseComponentRoutes( + //nolint:lll + "oauth: hostname=oauth-host,downloads: hostname=downloads-host;tlsSecretRef=downloads-secret,console: hostname=console-host;tlsSecretRef=console-secret", + ) + Expect(err).ToNot(BeNil()) + Expect( + err.Error(), + ).To(Equal("only 2 parameters are expected for each component")) + }) + }) +}) diff --git a/cmd/ocm/edit/ingress/main_test.go b/cmd/ocm/edit/ingress/main_test.go new file mode 100644 index 00000000..c4a330e4 --- /dev/null +++ b/cmd/ocm/edit/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, "Edit ingress suite") +} diff --git a/pkg/utils/main_test.go b/pkg/utils/main_test.go new file mode 100644 index 00000000..435fbb01 --- /dev/null +++ b/pkg/utils/main_test.go @@ -0,0 +1,13 @@ +package utils + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEditCluster(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils suite") +} diff --git a/pkg/utils/slices.go b/pkg/utils/slices.go new file mode 100644 index 00000000..c4d2f39b --- /dev/null +++ b/pkg/utils/slices.go @@ -0,0 +1,13 @@ +package utils + +import "reflect" + +func Contains[T comparable](slice []T, element T) bool { + for _, sliceElement := range slice { + if reflect.DeepEqual(sliceElement, element) { + return true + } + } + + return false +} diff --git a/pkg/utils/slices_test.go b/pkg/utils/slices_test.go new file mode 100644 index 00000000..bb2d6d7f --- /dev/null +++ b/pkg/utils/slices_test.go @@ -0,0 +1,22 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Slices", func() { + Context("Validates Contains", func() { + It("Return false when input is empty", func() { + Expect(false).To(Equal(Contains([]string{}, "any"))) + }) + + It("Return true when input is populated and present", func() { + Expect(true).To(Equal(Contains([]string{"test", "any"}, "any"))) + }) + + It("Return false when input is populated and not present", func() { + Expect(false).To(Equal(Contains([]string{"test", "any"}, "none"))) + }) + }) +}) diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go new file mode 100644 index 00000000..f121c783 --- /dev/null +++ b/pkg/utils/strings.go @@ -0,0 +1,24 @@ +package utils + +import ( + "sort" + "strings" +) + +func SliceToSortedString(s []string) string { + if len(s) == 0 { + return "" + } + SortStringRespectLength(s) + return "[" + strings.Join(s, ", ") + "]" +} + +func SortStringRespectLength(s []string) { + sort.Slice(s, func(i, j int) bool { + l1, l2 := len(s[i]), len(s[j]) + if l1 != l2 { + return l1 < l2 + } + return s[i] < s[j] + }) +} diff --git a/pkg/utils/strings_test.go b/pkg/utils/strings_test.go new file mode 100644 index 00000000..0bd64c77 --- /dev/null +++ b/pkg/utils/strings_test.go @@ -0,0 +1,18 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Validates SliceToSortedString", func() { + It("Empty when slice is empty", func() { + s := SliceToSortedString([]string{}) + Expect("").To(Equal(s)) + }) + + It("Sorted when slice is filled", func() { + s := SliceToSortedString([]string{"b", "a", "c", "a10", "a1", "a20", "a2", "1", "2", "10", "20"}) + Expect("[1, 2, a, b, c, 10, 20, a1, a2, a10, a20]").To(Equal(s)) + }) +})