diff --git a/cmd/ocm/create/cluster/cmd.go b/cmd/ocm/create/cluster/cmd.go index 0ac972a5..8a914ebc 100644 --- a/cmd/ocm/create/cluster/cmd.go +++ b/cmd/ocm/create/cluster/cmd.go @@ -77,6 +77,8 @@ var args struct { clusterWideProxy c.ClusterWideProxy gcpServiceAccountFile arguments.FilePath gcpSecureBoot c.GcpSecurity + gcpAuthentication c.GcpAuthentication + gcpWifConfig string etcdEncryption bool subscriptionType string marketplaceGcpTerms bool @@ -125,6 +127,14 @@ func parseSubscriptionType(subscriptionTypeOption string) string { return strings.Split(subscriptionTypeOption, " ")[0] } +func setWifConfigOption(id, name string) string { + return fmt.Sprintf("%s (%s)", name, id) +} + +func parseWifConfigOption(wifConfigOption string) string { + return strings.Split(wifConfigOption, " ")[0] +} + // Cmd Constant: var Cmd = &cobra.Command{ Use: "cluster [flags] NAME", @@ -364,6 +374,24 @@ func init() { "Secure Boot enables the use of Shielded VMs in the Google Cloud Platform.", ) arguments.SetQuestion(fs, "secure-boot-for-shielded-vms", "Secure boot support for Shielded VMs:") + + fs.StringVar( + &args.gcpAuthentication.Type, + "gcp-auth-type", + c.AuthenticationWif, + "Method of authenticating GCP cluster", + ) + arguments.SetQuestion(fs, "gcp-auth-type", "Authentication method:") + fs.MarkHidden("gcp-auth-type") + + fs.StringVar( + &args.gcpWifConfig, + "wif-config", + "", + "Specifies the GCP Workload Identity Federation config used to authenticate.", + ) + arguments.SetQuestion(fs, "wif-config", "WIF Configuration:") + Cmd.RegisterFlagCompletionFunc("wif-config", arguments.MakeCompleteFunc(getWifConfigNameOptions)) } func osdProviderOptions(_ *sdk.Connection) ([]arguments.Option, error) { @@ -373,6 +401,13 @@ func osdProviderOptions(_ *sdk.Connection) ([]arguments.Option, error) { }, nil } +func gcpAuthenticationOptions(_ *sdk.Connection) ([]arguments.Option, error) { + return []arguments.Option{ + {Value: c.AuthenticationWif, Description: ""}, + {Value: c.AuthenticationKey, Description: ""}, + }, nil +} + func getRegionOptions(connection *sdk.Connection) ([]arguments.Option, error) { regions, err := provider.GetRegions(connection.ClustersMgmt().V1(), args.provider, args.ccs) if err != nil { @@ -499,6 +534,27 @@ func getMachineTypeOptions(connection *sdk.Connection) ([]arguments.Option, erro args.provider, args.ccs.Enabled) } +func getWifConfigOptions(wifConfigs []*cmv1.WifConfig) ([]arguments.Option, error) { + options := []arguments.Option{} + for _, wc := range wifConfigs { + option := wifConfigOption(wc.ID(), wc.DisplayName()) + options = append(options, option) + } + return options, nil +} + +func wifConfigOption(id string, name string) arguments.Option { + return arguments.Option{ + Value: setWifConfigOption(id, name), + } +} + +// getWifConfigNameOptions returns the wif config options for the cluster +// with display name as the value +func getWifConfigNameOptions(connection *sdk.Connection) ([]arguments.Option, error) { + return provider.GetWifConfigNameOptions(connection.ClustersMgmt().V1()) +} + func networkTypeCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{c.NetworkTypeSDN, c.NetworkTypeOVN}, cobra.ShellCompDirectiveDefault } @@ -589,6 +645,11 @@ func preRun(cmd *cobra.Command, argv []string) error { return err } + err = promptAuthentication(fs, connection) + if err != nil { + return err + } + err = arguments.PromptBool(fs, "multi-az") if err != nil { return err @@ -754,6 +815,7 @@ func run(cmd *cobra.Command, argv []string) error { DefaultIngress: defaultIngress, SubscriptionType: args.subscriptionType, GcpSecurity: args.gcpSecureBoot, + GcpAuthentication: args.gcpAuthentication, } cluster, err := c.CreateCluster(connection.ClustersMgmt().V1(), clusterConfig, args.dryRun) @@ -1258,43 +1320,114 @@ func promptCCS(fs *pflag.FlagSet, presetCCS bool) error { if err != nil { return err } - if args.ccs.Enabled { - switch args.provider { - case c.ProviderAWS: - err = arguments.PromptString(fs, "aws-account-id") - if err != nil { - return err - } - err = arguments.PromptString(fs, "aws-access-key-id") - if err != nil { - return err - } + err = arguments.CheckIgnoredCCSFlags(args.ccs) + if err != nil { + return err + } + return nil +} - err = arguments.PromptPassword(fs, "aws-secret-access-key") - if err != nil { - return err - } - case c.ProviderGCP: - // TODO: re-prompt when selected file is not readable / invalid JSON - err = arguments.PromptFilePath(fs, "service-account-file", true) - if err != nil { - return err - } +func promptAuthentication(fs *pflag.FlagSet, connection *sdk.Connection) error { + var err error + if !args.ccs.Enabled { + return nil + } + switch args.provider { + case c.ProviderAWS: + err = arguments.PromptString(fs, "aws-account-id") + if err != nil { + return err + } - if args.gcpServiceAccountFile == "" { - return fmt.Errorf("A valid GCP service account file must be specified for CCS clusters") - } - err = constructGCPCredentials(args.gcpServiceAccountFile, &args.ccs) - if err != nil { - return err - } + err = arguments.PromptString(fs, "aws-access-key-id") + if err != nil { + return err + } + + err = arguments.PromptPassword(fs, "aws-secret-access-key") + if err != nil { + return err + } + case c.ProviderGCP: + err = promptGcpAuth(fs, connection) + if err != nil { + return err } } - err = arguments.CheckIgnoredCCSFlags(args.ccs) + return nil +} + +func promptGcpAuth(fs *pflag.FlagSet, connection *sdk.Connection) error { + var err error + + isWif := fs.Changed("wif-config") + isNonWif := fs.Changed("service-account-file") + if isWif && isNonWif { + return fmt.Errorf("can't use both wif-config and GCP service account file at the same time") + } + + if !isWif && !isNonWif { + options, _ := gcpAuthenticationOptions(connection) + err = arguments.PromptOneOf(fs, "gcp-auth-type", options) + if err != nil { + return err + } + } + if isWif { + args.gcpAuthentication.Type = c.AuthenticationWif + } else if isNonWif { + args.gcpAuthentication.Type = c.AuthenticationKey + } + + switch args.gcpAuthentication.Type { + case c.AuthenticationWif: + err = promptWifConfig(fs, connection) + if err != nil { + return err + } + case c.AuthenticationKey: + // TODO: re-prompt when selected file is not readable / invalid JSON + err = arguments.PromptFilePath(fs, "service-account-file", true) + if err != nil { + return err + } + + if args.gcpServiceAccountFile == "" { + return fmt.Errorf("a valid GCP service account file must be specified for CCS clusters") + } + err = constructGCPCredentials(args.gcpServiceAccountFile, &args.ccs) + if err != nil { + return err + } + } + return nil +} + +func promptWifConfig(fs *pflag.FlagSet, connection *sdk.Connection) error { + wifConfigs, err := provider.GetWifConfigs(connection.ClustersMgmt().V1()) if err != nil { return err } + options, err := getWifConfigOptions(wifConfigs) + if err != nil { + return err + } + err = arguments.PromptOneOf(fs, "wif-config", options) + if err != nil { + return err + } + if args.interactive { + args.gcpWifConfig = parseWifConfigOption(args.gcpWifConfig) + } + + // map wif name to wif id + wifMapping := map[string]string{} + for _, wc := range wifConfigs { + wifMapping[wc.DisplayName()] = wc.ID() + } + + args.gcpAuthentication.Id = wifMapping[args.gcpWifConfig] return nil } diff --git a/go.mod b/go.mod index bb6772c8..500bedb7 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,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.422 + github.com/openshift-online/ocm-sdk-go v0.1.433 github.com/openshift/rosa v1.2.24 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index f0ce8af2..4de12fa3 100644 --- a/go.sum +++ b/go.sum @@ -361,8 +361,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.422 h1:NWXLNTg7sLgUJRM3tyuk/QuVbUCRuMH+aLlbCKNzXWc= -github.com/openshift-online/ocm-sdk-go v0.1.422/go.mod h1:CiAu2jwl3ITKOxkeV0Qnhzv4gs35AmpIzVABQLtcI2Y= +github.com/openshift-online/ocm-sdk-go v0.1.433 h1:8i0Si9KrFkMprMBGR1a4ppmgVezMBjjXXkvhv28OVUk= +github.com/openshift-online/ocm-sdk-go v0.1.433/go.mod h1:CiAu2jwl3ITKOxkeV0Qnhzv4gs35AmpIzVABQLtcI2Y= 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 c54f57ef..79cb1493 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -37,6 +37,9 @@ const ( NetworkTypeSDN = "OpenShiftSDN" NetworkTypeOVN = "OVNKubernetes" + + AuthenticationWif = "Workload Identity Federation (WIF)" + AuthenticationKey = "Service account" ) type DefaultIngressSpec struct { @@ -92,6 +95,9 @@ type Spec struct { // Gcp-specific settings GcpSecurity GcpSecurity + + // GCP Authentication settings + GcpAuthentication GcpAuthentication } type Autoscaling struct { @@ -151,6 +157,11 @@ type GcpSecurity struct { SecureBoot bool `json:"secure_boot,omitempty"` } +type GcpAuthentication struct { + Type string + Id string +} + type AddOnItem struct { ID string Name string @@ -404,21 +415,34 @@ func CreateCluster(cmv1Client *cmv1.Client, config Spec, dryRun bool) (*cmv1.Clu } clusterBuilder = clusterBuilder.AWS(awsBuilder) case ProviderGCP: - if config.CCS.GCP.Type == "" || config.CCS.GCP.ClientEmail == "" || - config.CCS.GCP.ProjectID == "" { - return nil, fmt.Errorf("Missing credentials for GCP CCS cluster") + switch config.GcpAuthentication.Type { + case AuthenticationWif: + if config.GcpAuthentication.Id == "" { + return nil, fmt.Errorf("missing WIF config ID") + } + gcpAuth := cmv1.NewGcpAuthentication(). + Kind(cmv1.WifConfigKind). + Id(config.GcpAuthentication.Id) + gcpBuilder.Authentication(gcpAuth) + case AuthenticationKey: + if config.CCS.GCP.Type == "" || config.CCS.GCP.ClientEmail == "" || + config.CCS.GCP.ProjectID == "" { + return nil, fmt.Errorf("missing credentials for GCP CCS cluster") + } + gcpBuilder. + Type(config.CCS.GCP.Type). + ProjectID(config.CCS.GCP.ProjectID). + PrivateKeyID(config.CCS.GCP.PrivateKeyID). + PrivateKey(config.CCS.GCP.PrivateKey). + ClientEmail(config.CCS.GCP.ClientEmail). + ClientID(config.CCS.GCP.ClientID). + AuthURI(config.CCS.GCP.AuthURI). + TokenURI(config.CCS.GCP.TokenURI). + AuthProviderX509CertURL(config.CCS.GCP.AuthProviderX509CertURL). + ClientX509CertURL(config.CCS.GCP.ClientX509CertURL) + default: + return nil, fmt.Errorf("unexpected GCP authentication method %q", config.GcpAuthentication.Type) } - gcpBuilder. - Type(config.CCS.GCP.Type). - ProjectID(config.CCS.GCP.ProjectID). - PrivateKeyID(config.CCS.GCP.PrivateKeyID). - PrivateKey(config.CCS.GCP.PrivateKey). - ClientEmail(config.CCS.GCP.ClientEmail). - ClientID(config.CCS.GCP.ClientID). - AuthURI(config.CCS.GCP.AuthURI). - TokenURI(config.CCS.GCP.TokenURI). - AuthProviderX509CertURL(config.CCS.GCP.AuthProviderX509CertURL). - ClientX509CertURL(config.CCS.GCP.ClientX509CertURL) if isGCPNetworkExists(config.ExistingVPC) { gcpNetwork := cmv1.NewGCPNetwork().VPCName(config.ExistingVPC.VPCName). diff --git a/pkg/provider/wif_configs.go b/pkg/provider/wif_configs.go new file mode 100644 index 00000000..ec9721d3 --- /dev/null +++ b/pkg/provider/wif_configs.go @@ -0,0 +1,56 @@ +package provider + +import ( + "fmt" + + "github.com/openshift-online/ocm-cli/pkg/arguments" + cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" +) + +func getWifConfigs(client *cmv1.Client) (wifConfigs []*cmv1.WifConfig, err error) { + collection := client.GCP().WifConfigs() + page := 1 + size := 100 + for { + var response *cmv1.WifConfigsListResponse + response, err = collection.List(). + Page(page). + Size(size). + Send() + if err != nil { + return + } + wifConfigs = append(wifConfigs, response.Items().Slice()...) + if response.Size() < size { + break + } + page++ + } + + if len(wifConfigs) == 0 { + return nil, fmt.Errorf("no WIF configurations available") + } + return +} + +func GetWifConfigs(client *cmv1.Client) (wifConfigs []*cmv1.WifConfig, err error) { + return getWifConfigs(client) +} + +// GetWifConfigNameOptions returns the wif config options for the cluster +// with display name as the value and id as the description +func GetWifConfigNameOptions(client *cmv1.Client) (options []arguments.Option, err error) { + wifConfigs, err := getWifConfigs(client) + if err != nil { + err = fmt.Errorf("failed to retrieve WIF configurations: %s", err) + return + } + + for _, wc := range wifConfigs { + options = append(options, arguments.Option{ + Value: wc.DisplayName(), + Description: wc.ID(), + }) + } + return +}