diff --git a/cmd/ocm/create/cluster/cmd.go b/cmd/ocm/create/cluster/cmd.go index 1633b64e..0ac972a5 100644 --- a/cmd/ocm/create/cluster/cmd.go +++ b/cmd/ocm/create/cluster/cmd.go @@ -56,7 +56,8 @@ const ( var args struct { // positional args - clusterName string + clusterName string + domainPrefix string // flags interactive bool @@ -99,7 +100,8 @@ var args struct { defaultIngressNamespaceOwnershipPolicy string } -const clusterNameHelp = "will be used when generating a sub-domain for your cluster on openshiftapps.com." +const clusterNameHelp = "The name can be used as the identifier of the cluster." + + " The maximum length is 54 characters. Once set, the cluster name cannot be changed" const subnetTemplate = "%s (%s)" @@ -174,6 +176,19 @@ func init() { arguments.SetQuestion(fs, "version", "OpenShift version:") Cmd.RegisterFlagCompletionFunc("version", arguments.MakeCompleteFunc(getVersionOptions)) + fs.StringVar( + &args.domainPrefix, + "domain-prefix", + "", + "An optional unique domain prefix of the cluster. If not provided, the cluster name will be "+ + "used if it contains at most 15 characters, otherwise a generated value will be used. This "+ + "will be used when generating a sub-domain for your cluster. It must be unique and consist "+ + "of lowercase alphanumeric,characters or '-', start with an alphabetic character, and end with "+ + "an alphanumeric character. The maximum length is 15 characters. Once set, the cluster domain "+ + "prefix cannot be changed", + ) + arguments.SetQuestion(fs, "domain-prefix", "Domain Prefix:") + fs.StringVar( &args.channelGroup, "channel-group", @@ -680,6 +695,11 @@ func preRun(cmd *cobra.Command, argv []string) error { return err } + err = arguments.PromptString(fs, "domain-prefix") + if err != nil { + return err + } + return nil } @@ -710,6 +730,7 @@ func run(cmd *cobra.Command, argv []string) error { clusterConfig := c.Spec{ Name: args.clusterName, + DomainPrefix: args.domainPrefix, Region: args.region, Provider: args.provider, CCS: args.ccs, diff --git a/cmd/ocm/edit/cluster/cmd.go b/cmd/ocm/edit/cluster/cmd.go index fff4b8ed..5ec4ab76 100644 --- a/cmd/ocm/edit/cluster/cmd.go +++ b/cmd/ocm/edit/cluster/cmd.go @@ -16,6 +16,7 @@ package cluster import ( "fmt" "os" + "reflect" "strings" "time" @@ -29,9 +30,9 @@ import ( var args struct { // Basic options - expirationTime string - expirationDuration time.Duration - + expirationTime string + expirationDuration time.Duration + enableDeleteProtection bool // Networking options private bool @@ -47,6 +48,7 @@ var Cmd = &cobra.Command{ Example: ` # Edit a cluster named "mycluster" to make it private ocm edit cluster mycluster --private`, RunE: run, + Args: cobra.MinimumNArgs(1), } func init() { @@ -119,6 +121,12 @@ func init() { "A file contains a PEM-encoded X.509 certificate bundle that will be "+ "added to the nodes' trusted certificate store.") + flags.BoolVar( + &args.enableDeleteProtection, + "enable-delete-protection", + false, + "Enable cluster delete protection against accidental cluster deletion.", + ) } func isGCPNetworkEmpty(network *cmv1.GCPNetwork) bool { @@ -132,7 +140,7 @@ func wasClusterWideProxyReceived(httpProxy, httpsProxy, noProxy, additionalTrust } func run(cmd *cobra.Command, argv []string) error { - // Check that there is exactly one cluster name, identifir or external identifier in the + // Check that there is exactly one cluster name, identifier or external identifier in the // command line arguments: if len(argv) != 1 { return fmt.Errorf( @@ -222,6 +230,15 @@ func run(cmd *cobra.Command, argv []string) error { noProxy = args.clusterWideProxy.NoProxy } + var enableDeleteProtection bool + if cmd.Flags().Changed("enable-delete-protection") { + enableDeleteProtection = args.enableDeleteProtection + err := c.UpdateDeleteProtection(clusterCollection, cluster.ID(), enableDeleteProtection) + if err != nil { + return err + } + } + var additionalTrustBundleFile *string var additionalTrustBundleFileValue string if cmd.Flags().Changed("additional-trust-bundle-file") { @@ -268,9 +285,11 @@ func run(cmd *cobra.Command, argv []string) error { } clusterConfig.ClusterWideProxy = clusterWideProxy - err = c.UpdateCluster(clusterCollection, cluster.ID(), clusterConfig) - if err != nil { - return fmt.Errorf("Failed to update cluster: %v", err) + if !reflect.ValueOf(clusterConfig).IsZero() { + err = c.UpdateCluster(clusterCollection, cluster.ID(), clusterConfig) + if err != nil { + return fmt.Errorf("Failed to update cluster: %v", err) + } } return nil diff --git a/cmd/ocm/edit/ingress/cmd.go b/cmd/ocm/edit/ingress/cmd.go index 470040ae..2c867c24 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" + + expectedLengthOfParsedComponent = 2 + hostnameParameter = "hostname" + //nolint:gosec + tlsSecretRefParameter = "tlsSecretRef" ) var Cmd = &cobra.Command{ @@ -139,14 +157,23 @@ func init() { &args.clusterRoutesHostname, clusterRoutesHostnameFlag, "", - "Components route hostname for oauth, console, download.", + "Components route hostname for oauth, console, downloads.", ) flags.StringVar( &args.clusterRoutesTlsSecretRef, clusterRoutesTlsSecretRefFlag, "", - "Components route TLS secret reference for oauth, console, download.", + "Components route TLS secret reference for oauth, console, downloads.", + ) + + flags.StringVar( + &args.componentRoutes, + componentRoutesFlag, + "", + //nolint:lll + "Component routes settings. Available keys [oauth, console, downloads]. 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:...", ) } @@ -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) != expectedLengthOfParsedComponent { + 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) != len(expectedParameters) { + return nil, fmt.Errorf( + "only %d parameters are expected for each component", + len(expectedParameters), + ) + } + 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/go.mod b/go.mod index 9e0b67b7..63c60517 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/nwidger/jsoncolor v0.3.2 github.com/onsi/ginkgo/v2 v2.11.0 github.com/onsi/gomega v1.27.8 - github.com/openshift-online/ocm-sdk-go v0.1.398 + github.com/openshift-online/ocm-sdk-go v0.1.407 github.com/openshift/rosa v1.2.24 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/spf13/cobra v1.7.0 diff --git a/go.sum b/go.sum index 7fdfe2e4..388d3a06 100644 --- a/go.sum +++ b/go.sum @@ -312,8 +312,8 @@ github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= github.com/onsi/gomega v1.27.8 h1:gegWiwZjBsf2DgiSbf5hpokZ98JVDMcWkUiigk6/KXc= github.com/onsi/gomega v1.27.8/go.mod h1:2J8vzI/s+2shY9XHRApDkdgPo1TKT7P2u6fXeJKFnNQ= -github.com/openshift-online/ocm-sdk-go v0.1.398 h1:6C1mDcPxzG4jSduOaWixTTI5gSEO+Jm7OW/00jVoWGI= -github.com/openshift-online/ocm-sdk-go v0.1.398/go.mod h1:tke8vKcE7eHKyRbkJv6qo4ljo919zhx04uyQTcgF5cQ= +github.com/openshift-online/ocm-sdk-go v0.1.407 h1:KAeTfpG4I3QWiDThJD3VBAi4APuVwlnouBFAeWJ7tHI= +github.com/openshift-online/ocm-sdk-go v0.1.407/go.mod h1:8ECJertR5BiblaX5f2siXHXpi+ydYZjoROlVMppmmV4= github.com/openshift/rosa v1.2.24 h1:vv0yYnWHx6CCPEAau/0rS54P2ksaf+uWXb1TQPWxiYE= github.com/openshift/rosa v1.2.24/go.mod h1:MVXB27O3PF8WoOic23I03mmq6/9kVxpFx6FKyLMCyrQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 1b03f3d8..c54f57ef 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -57,6 +57,7 @@ func NewDefaultIngressSpec() DefaultIngressSpec { type Spec struct { // Basic configs Name string + DomainPrefix string Region string Provider string CCS CCS @@ -327,6 +328,10 @@ func CreateCluster(cmv1Client *cmv1.Client, config Spec, dryRun bool) (*cmv1.Clu BillingModel(cmv1.BillingModel(config.SubscriptionType)). Properties(clusterProperties) + if config.DomainPrefix != "" { + clusterBuilder = clusterBuilder.DomainPrefix(config.DomainPrefix) + } + clusterBuilder = clusterBuilder.Version( cmv1.NewVersion(). ID(config.Version).ChannelGroup(config.ChannelGroup)) @@ -580,7 +585,6 @@ func UpdateCluster(client *cmv1.ClustersClient, clusterID string, config Spec) e if err != nil { return err } - _, err = client.Cluster(clusterID).Update().Body(clusterSpec).Send() if err != nil { return err @@ -589,6 +593,15 @@ func UpdateCluster(client *cmv1.ClustersClient, clusterID string, config Spec) e return nil } +func UpdateDeleteProtection(client *cmv1.ClustersClient, clusterID string, enable bool) error { + deleteProtection, _ := cmv1.NewDeleteProtection().Enabled(enable).Build() + _, err := client.Cluster(clusterID).DeleteProtection().Update().Body(deleteProtection).Send() + if err != nil { + return err + } + return nil +} + func buildCompute(config Spec, clusterNodesBuilder *cmv1.ClusterNodesBuilder) *cmv1.ClusterNodesBuilder { if config.Autoscaling.Enabled { autoscalingBuilder := cmv1.NewMachinePoolAutoscaling() diff --git a/pkg/cluster/describe.go b/pkg/cluster/describe.go index d03005fc..d91ba09a 100644 --- a/pkg/cluster/describe.go +++ b/pkg/cluster/describe.go @@ -166,11 +166,13 @@ func PrintClusterDescription(connection *sdk.Connection, cluster *cmv1.Cluster) "ID: %s\n"+ "External ID: %s\n"+ "Name: %s\n"+ + "Domain Prefix: %s\n"+ "Display Name: %s\n"+ "State: %s %s\n", cluster.ID(), cluster.ExternalID(), cluster.Name(), + cluster.DomainPrefix(), sub.DisplayName(), cluster.State(), provisioningStatus, diff --git a/pkg/output/table_test.go b/pkg/output/table_test.go index bcd336e4..be74c173 100644 --- a/pkg/output/table_test.go +++ b/pkg/output/table_test.go @@ -261,8 +261,8 @@ var _ = Describe("Table", func() { // Check the generated text: lines := strings.Split(buffer.String(), "\n") Expect(lines).To(HaveLen(3)) - Expect(lines[0]).To(Equal(`ID NAME `)) - Expect(lines[1]).To(Equal(`123 mycluster `)) + Expect(lines[0]).To(Equal(`ID NAME `)) + Expect(lines[1]).To(Equal(`123 mycluster `)) }) It("Honours learning limit", func() { diff --git a/pkg/output/tables/clusters.yaml b/pkg/output/tables/clusters.yaml index 6128be66..b058ddaa 100644 --- a/pkg/output/tables/clusters.yaml +++ b/pkg/output/tables/clusters.yaml @@ -20,7 +20,7 @@ columns: width: 32 - name: name header: NAME - width: 28 + width: 54 - name: api.url header: API URL width: 58 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)) + }) +})