diff --git a/Makefile b/Makefile index 3db5c84b..ac628f91 100644 --- a/Makefile +++ b/Makefile @@ -95,7 +95,7 @@ importfmtlint: golangcilint: @echo -n "Running 'golangci-lint' to check for code quality issues..." @# Clear the cache for every run, so that the linter outputs the same results as the GH Actions workflow - @if golangci-lint cache clear && golangci-lint run --timeout 5m ./...; then \ + @if golangci-lint cache clean && golangci-lint run --timeout 5m ./...; then \ echo " SUCCESS"; \ else \ echo " FAILED"; \ diff --git a/cmd/config/list_keys_test.go b/cmd/config/list_keys_test.go index 9020d211..15b0bb7e 100644 --- a/cmd/config/list_keys_test.go +++ b/cmd/config/list_keys_test.go @@ -54,6 +54,7 @@ func Example_listKeysValue() { // - export.outputDirectory // - export.overwrite // - export.pingone.environmentID + // - export.serviceGroup // - export.services // - noColor // - outputFormat diff --git a/cmd/platform/export.go b/cmd/platform/export.go index 605e46b9..a364bec5 100644 --- a/cmd/platform/export.go +++ b/cmd/platform/export.go @@ -27,6 +27,9 @@ const ( Export configuration-as-code packages for PingOne (core platform and SSO services). pingcli platform export --services pingone-platform,pingone-sso + Export all configuration-as-code packages for PingOne. The --service-group flag can be used instead of listing all pingone-* packages in --services flag. + pingcli platform export --service-group pingone + Export configuration-as-code packages for PingOne (core platform), specifying the PingOne environment connection details. pingcli platform export --services pingone-platform --pingone-client-environment-id 3cf2... --pingone-worker-client-id a719... --pingone-worker-client-secret ey..... --pingone-region-code EU @@ -92,6 +95,7 @@ func exportRunE(cmd *cobra.Command, args []string) error { func initGeneralExportFlags(cmd *cobra.Command) { cmd.Flags().AddFlag(options.PlatformExportExportFormatOption.Flag) cmd.Flags().AddFlag(options.PlatformExportServiceOption.Flag) + cmd.Flags().AddFlag(options.PlatformExportServiceGroupOption.Flag) cmd.Flags().AddFlag(options.PlatformExportOutputDirectoryOption.Flag) cmd.Flags().AddFlag(options.PlatformExportOverwriteOption.Flag) cmd.Flags().AddFlag(options.PlatformExportPingOneEnvironmentIDOption.Flag) diff --git a/cmd/platform/export_test.go b/cmd/platform/export_test.go index cbb4f443..7d315c3b 100644 --- a/cmd/platform/export_test.go +++ b/cmd/platform/export_test.go @@ -48,8 +48,27 @@ func TestPlatformExportCmd_HelpFlag(t *testing.T) { testutils.CheckExpectedError(t, err, nil) } +// Test Platform Export Command --service-group, -g flag +func TestPlatformExportCmd_ServiceGroupFlag(t *testing.T) { + outputDir := t.TempDir() + + err := testutils_cobra.ExecutePingcli(t, "platform", "export", + "--"+options.PlatformExportOutputDirectoryOption.CobraParamName, outputDir, + "--"+options.PlatformExportOverwriteOption.CobraParamName, + "--"+options.PlatformExportServiceGroupOption.CobraParamName, "pingone") + testutils.CheckExpectedError(t, err, nil) +} + +// Test Platform Export Command --service-group with non-supported service group +func TestPlatformExportCmd_ServiceGroupFlagInvalidServiceGroup(t *testing.T) { + expectedErrorPattern := `^invalid argument ".*" for "-g, --service-group" flag: unrecognized service group '.*'\. Must be one of: .*$` + err := testutils_cobra.ExecutePingcli(t, "platform", "export", + "--"+options.PlatformExportServiceGroupOption.CobraParamName, "invalid") + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} + // Test Platform Export Command --services flag -func TestPlatformExportCmd_ServiceFlag(t *testing.T) { +func TestPlatformExportCmd_ServicesFlag(t *testing.T) { outputDir := t.TempDir() err := testutils_cobra.ExecutePingcli(t, "platform", "export", @@ -60,7 +79,7 @@ func TestPlatformExportCmd_ServiceFlag(t *testing.T) { } // Test Platform Export Command --services flag with invalid service -func TestPlatformExportCmd_ServiceFlagInvalidService(t *testing.T) { +func TestPlatformExportCmd_ServicesFlagInvalidService(t *testing.T) { expectedErrorPattern := `^invalid argument ".*" for "-s, --services" flag: failed to set ExportServices: Invalid service: .*\. Allowed services: .*$` err := testutils_cobra.ExecutePingcli(t, "platform", "export", "--"+options.PlatformExportServiceOption.CobraParamName, "invalid") diff --git a/docs/tool-configuration/configuration-key.md b/docs/tool-configuration/configuration-key.md index 50011f67..a9bfd442 100644 --- a/docs/tool-configuration/configuration-key.md +++ b/docs/tool-configuration/configuration-key.md @@ -42,6 +42,7 @@ The following parameters can be configured in Ping CLI's static configuration fi | export.outputDirectory | ENUM_STRING | --output-directory / -d | Specifies the output directory for export. Example: `$HOME/pingcli-export` | | export.overwrite | ENUM_BOOL | --overwrite / -o | Overwrite the existing generated exports in output directory. | | export.pingone.environmentID | ENUM_UUID | --pingone-export-environment-id | The ID of the PingOne environment to export. Must be a valid PingOne UUID. | +| export.serviceGroup | ENUM_EXPORT_SERVICE_GROUP | --service-group / -g | Specifies the service group to export.

Options are: pingone.

Example: `pingone` | | export.services | ENUM_EXPORT_SERVICES | --services / -s | Specifies the service(s) to export. Accepts a comma-separated string to delimit multiple services.

Options are: pingfederate, pingone-mfa, pingone-platform, pingone-protect, pingone-sso.

Example: `pingone-sso,pingone-mfa,pingfederate` | #### Custom Request Properties diff --git a/internal/commands/config/set_internal.go b/internal/commands/config/set_internal.go index 5418d35e..e8c0af25 100644 --- a/internal/commands/config/set_internal.go +++ b/internal/commands/config/set_internal.go @@ -123,6 +123,12 @@ func setValue(profileViper *viper.Viper, vKey, vValue string, valueType options. return fmt.Errorf("value for key '%s' must be a valid export format. Allowed [%s]: %v", vKey, strings.Join(customtypes.ExportFormatValidValues(), ", "), err) } profileViper.Set(vKey, exportFormat) + case options.ENUM_EXPORT_SERVICE_GROUP: + exportServiceGroup := new(customtypes.ExportServiceGroup) + if err = exportServiceGroup.Set(vValue); err != nil { + return fmt.Errorf("value for key '%s' must be valid export service group. Allowed [%s]: %v", vKey, strings.Join(customtypes.ExportServiceGroupValidValues(), ", "), err) + } + profileViper.Set(vKey, exportServiceGroup) case options.ENUM_EXPORT_SERVICES: exportServices := new(customtypes.ExportServices) if err = exportServices.Set(vValue); err != nil { diff --git a/internal/commands/platform/export_internal.go b/internal/commands/platform/export_internal.go index 927f8434..647d8f14 100644 --- a/internal/commands/platform/export_internal.go +++ b/internal/commands/platform/export_internal.go @@ -50,6 +50,10 @@ func RunInternalExport(ctx context.Context, commandVersion string) (err error) { if err != nil { return err } + exportServiceGroup, err := profiles.GetOptionValue(options.PlatformExportServiceGroupOption) + if err != nil { + return err + } exportServices, err := profiles.GetOptionValue(options.PlatformExportServiceOption) if err != nil { return err @@ -63,11 +67,26 @@ func RunInternalExport(ctx context.Context, commandVersion string) (err error) { return err } + var exportableConnectors *[]connector.Exportable es := new(customtypes.ExportServices) if err = es.Set(exportServices); err != nil { return err } + esg := new(customtypes.ExportServiceGroup) + if err = esg.Set(exportServiceGroup); err != nil { + return err + } + + es2 := new(customtypes.ExportServices) + if err = es2.SetServicesByServiceGroup(esg); err != nil { + return err + } + + if err = es.Merge(*es2); err != nil { + return err + } + if es.ContainsPingOneService() { if err = initPingOneServices(ctx, commandVersion); err != nil { return err @@ -80,6 +99,8 @@ func RunInternalExport(ctx context.Context, commandVersion string) (err error) { } } + exportableConnectors = getExportableConnectors(es) + overwriteExportBool, err := strconv.ParseBool(overwriteExport) if err != nil { return err @@ -88,8 +109,6 @@ func RunInternalExport(ctx context.Context, commandVersion string) (err error) { return err } - exportableConnectors := getExportableConnectors(es) - if err := exportConnectors(exportableConnectors, exportFormat, outputDir, overwriteExportBool); err != nil { return err } diff --git a/internal/configuration/options/options.go b/internal/configuration/options/options.go index ab8ce905..73181203 100644 --- a/internal/configuration/options/options.go +++ b/internal/configuration/options/options.go @@ -16,6 +16,7 @@ const ( ENUM_BOOL OptionType = "ENUM_BOOL" ENUM_EXPORT_FORMAT OptionType = "ENUM_EXPORT_FORMAT" ENUM_INT OptionType = "ENUM_INT" + ENUM_EXPORT_SERVICE_GROUP OptionType = "ENUM_EXPORT_SERVICE_GROUP" ENUM_EXPORT_SERVICES OptionType = "ENUM_EXPORT_SERVICES" ENUM_OUTPUT_FORMAT OptionType = "ENUM_OUTPUT_FORMAT" ENUM_PINGFEDERATE_AUTH_TYPE OptionType = "ENUM_PINGFEDERATE_AUTH_TYPE" @@ -48,6 +49,7 @@ func Options() []Option { PingOneRegionCodeOption, PlatformExportExportFormatOption, + PlatformExportServiceGroupOption, PlatformExportServiceOption, PlatformExportOutputDirectoryOption, PlatformExportOverwriteOption, @@ -142,6 +144,7 @@ var ( var ( PlatformExportExportFormatOption Option PlatformExportServiceOption Option + PlatformExportServiceGroupOption Option PlatformExportOutputDirectoryOption Option PlatformExportOverwriteOption Option PlatformExportPingOneEnvironmentIDOption Option diff --git a/internal/configuration/platform/export.go b/internal/configuration/platform/export.go index 325a90da..40609728 100644 --- a/internal/configuration/platform/export.go +++ b/internal/configuration/platform/export.go @@ -14,6 +14,7 @@ import ( func InitPlatformExportOptions() { initFormatOption() initServicesOption() + initServiceGroupOption() initOutputDirectoryOption() initOverwriteOption() initPingOneEnvironmentIDOption() @@ -47,12 +48,39 @@ func initFormatOption() { } } +func initServiceGroupOption() { + cobraParamName := "service-group" + cobraValue := new(customtypes.ExportServiceGroup) + defaultValue := customtypes.ExportServiceGroup("") + envVar := "PINGCLI_EXPORT_SERVICE_GROUP" + options.PlatformExportServiceGroupOption = options.Option{ + CobraParamName: cobraParamName, + CobraParamValue: cobraValue, + DefaultValue: &defaultValue, + EnvVar: envVar, + Flag: &pflag.Flag{ + Name: cobraParamName, + Shorthand: "g", + Usage: fmt.Sprintf( + "Specifies the service group to export. "+ + "\nOptions are: %s."+ + "\nExample: '%s'", + strings.Join(customtypes.ExportServiceGroupValidValues(), ", "), + string(customtypes.ENUM_EXPORT_SERVICE_GROUP_PINGONE), + ), + Value: cobraValue, + }, + Sensitive: false, + Type: options.ENUM_EXPORT_SERVICE_GROUP, + ViperKey: "export.serviceGroup", + } +} + func initServicesOption() { cobraParamName := "services" cobraValue := new(customtypes.ExportServices) - defaultValue := customtypes.ExportServices(customtypes.ExportServicesValidValues()) + defaultValue := customtypes.ExportServices([]string{}) envVar := "PINGCLI_EXPORT_SERVICES" - options.PlatformExportServiceOption = options.Option{ CobraParamName: cobraParamName, CobraParamValue: cobraValue, @@ -63,11 +91,9 @@ func initServicesOption() { Shorthand: "s", Usage: fmt.Sprintf( "Specifies the service(s) to export. Accepts a comma-separated string to delimit multiple services. "+ - "(default %s)"+ "\nOptions are: %s."+ "\nExample: '%s,%s,%s'", strings.Join(customtypes.ExportServicesValidValues(), ", "), - strings.Join(customtypes.ExportServicesValidValues(), ", "), string(customtypes.ENUM_EXPORT_SERVICE_PINGONE_SSO), string(customtypes.ENUM_EXPORT_SERVICE_PINGONE_MFA), string(customtypes.ENUM_EXPORT_SERVICE_PINGFEDERATE), diff --git a/internal/customtypes/export_service_group.go b/internal/customtypes/export_service_group.go new file mode 100644 index 00000000..3cb79b00 --- /dev/null +++ b/internal/customtypes/export_service_group.go @@ -0,0 +1,54 @@ +package customtypes + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/pflag" +) + +const ( + ENUM_EXPORT_SERVICE_GROUP_PINGONE string = "pingone" +) + +type ExportServiceGroup string + +// Verify that the custom type satisfies the pflag.Value interface +var _ pflag.Value = (*ExportServiceGroup)(nil) + +func (esg *ExportServiceGroup) Set(serviceGroup string) error { + if esg == nil { + return fmt.Errorf("failed to set ExportServiceGroup value: %s. ExportServiceGroup is nil", serviceGroup) + } + + if serviceGroup == "" { + return nil + } + + switch { + case strings.EqualFold(ENUM_EXPORT_SERVICE_GROUP_PINGONE, serviceGroup): + *esg = ExportServiceGroup(ENUM_EXPORT_SERVICE_GROUP_PINGONE) + default: + return fmt.Errorf("unrecognized service group '%s'. Must be one of: %s", serviceGroup, strings.Join(ExportServiceGroupValidValues(), ", ")) + } + return nil +} + +func (esg ExportServiceGroup) Type() string { + return "string" +} + +func (esg ExportServiceGroup) String() string { + return string(esg) +} + +func ExportServiceGroupValidValues() []string { + validServiceGroups := []string{ + ENUM_EXPORT_SERVICE_GROUP_PINGONE, + } + + slices.Sort(validServiceGroups) + + return validServiceGroups +} diff --git a/internal/customtypes/export_service_group_test.go b/internal/customtypes/export_service_group_test.go new file mode 100644 index 00000000..9d7783a3 --- /dev/null +++ b/internal/customtypes/export_service_group_test.go @@ -0,0 +1,52 @@ +package customtypes_test + +import ( + "testing" + + "github.com/pingidentity/pingcli/internal/customtypes" + "github.com/pingidentity/pingcli/internal/testing/testutils" +) + +// Test ExportServiceGroup Set function +func Test_ExportServiceGroup_Set(t *testing.T) { + // Create a new ExportServiceGroup + esg := new(customtypes.ExportServiceGroup) + + err := esg.Set(customtypes.ENUM_EXPORT_SERVICE_GROUP_PINGONE) + if err != nil { + t.Errorf("Set returned error: %v", err) + } +} + +// Test ExportServiceGroup Set function fails with invalid value +func Test_ExportServiceGroup_Set_InvalidValue(t *testing.T) { + // Create a new ExportServiceGroup + esg := new(customtypes.ExportServiceGroup) + + invalidValue := "invalid" + + expectedErrorPattern := `^unrecognized service group .*\. Must be one of: .*$` + err := esg.Set(invalidValue) + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} + +// Test ExportServiceGroup Set function fails with nil +func Test_ExportServiceGroup_Set_Nil(t *testing.T) { + var esg *customtypes.ExportServiceGroup + + val := customtypes.ENUM_EXPORT_SERVICE_GROUP_PINGONE + + expectedErrorPattern := `^failed to set ExportServiceGroup value: .* ExportServiceGroup is nil$` + err := esg.Set(val) + testutils.CheckExpectedError(t, err, &expectedErrorPattern) +} + +// Test ExportServiceGroup Valid Values returns expected amount +func Test_ExportServiceGroupValidValues(t *testing.T) { + serviceGroupEnum := customtypes.ENUM_EXPORT_SERVICE_GROUP_PINGONE + + serviceGroupValidValues := customtypes.ExportServiceGroupValidValues() + if serviceGroupValidValues[0] != serviceGroupEnum { + t.Errorf("ExportServiceGroupValidValues returned: %v, expected: %v", serviceGroupValidValues, serviceGroupEnum) + } +} diff --git a/internal/customtypes/export_services.go b/internal/customtypes/export_services.go index 098a8044..c3cfe2f8 100644 --- a/internal/customtypes/export_services.go +++ b/internal/customtypes/export_services.go @@ -41,11 +41,14 @@ func (es *ExportServices) Set(services string) error { validServices := ExportServicesValidValues() serviceList := strings.Split(services, ",") + returnServiceList := []string{} - for i, service := range serviceList { + for _, service := range serviceList { if !slices.ContainsFunc(validServices, func(validService string) bool { if strings.EqualFold(validService, service) { - serviceList[i] = validService + if !slices.Contains(returnServiceList, validService) { + returnServiceList = append(returnServiceList, validService) + } return true } return false @@ -54,24 +57,31 @@ func (es *ExportServices) Set(services string) error { } } - slices.Sort(serviceList) + slices.Sort(returnServiceList) - *es = ExportServices(serviceList) + *es = ExportServices(returnServiceList) return nil } +func (es *ExportServices) SetServicesByServiceGroup(serviceGroup *ExportServiceGroup) error { + if es == nil { + return fmt.Errorf("failed to set ExportServices value: %s. ExportServices is nil", serviceGroup) + } + + switch { + case strings.EqualFold(ENUM_EXPORT_SERVICE_GROUP_PINGONE, serviceGroup.String()): + return es.Set(strings.Join(ExportServicesPingOneValidValues(), ",")) + default: + return fmt.Errorf("failed to SetServicesByServiceGroup: Invalid service group: %s. Allowed services: %s", serviceGroup.String(), strings.Join(ExportServiceGroupValidValues(), ", ")) + } +} + func (es ExportServices) ContainsPingOneService() bool { if es == nil { return false } - pingoneServices := []string{ - ENUM_EXPORT_SERVICE_PINGONE_PLATFORM, - ENUM_EXPORT_SERVICE_PINGONE_AUTHORIZE, - ENUM_EXPORT_SERVICE_PINGONE_SSO, - ENUM_EXPORT_SERVICE_PINGONE_MFA, - ENUM_EXPORT_SERVICE_PINGONE_PROTECT, - } + pingoneServices := ExportServicesPingOneValidValues() for _, service := range es { if slices.ContainsFunc(pingoneServices, func(s string) bool { @@ -114,3 +124,30 @@ func ExportServicesValidValues() []string { return allServices } + +func ExportServicesPingOneValidValues() []string { + pingOneServices := []string{ + ENUM_EXPORT_SERVICE_PINGONE_PLATFORM, + ENUM_EXPORT_SERVICE_PINGONE_AUTHORIZE, + ENUM_EXPORT_SERVICE_PINGONE_SSO, + ENUM_EXPORT_SERVICE_PINGONE_MFA, + ENUM_EXPORT_SERVICE_PINGONE_PROTECT, + } + + slices.Sort(pingOneServices) + + return pingOneServices +} + +func (es *ExportServices) Merge(es2 ExportServices) error { + mergedServices := []string{} + + for _, service := range append(es.GetServices(), es2.GetServices()...) { + if !slices.Contains(mergedServices, service) { + mergedServices = append(mergedServices, service) + } + } + + slices.Sort(mergedServices) + return es.Set(strings.Join(mergedServices, ",")) +} diff --git a/internal/customtypes/export_services_test.go b/internal/customtypes/export_services_test.go index 1e5c0051..f78df857 100644 --- a/internal/customtypes/export_services_test.go +++ b/internal/customtypes/export_services_test.go @@ -95,3 +95,11 @@ func Test_ExportServices_String(t *testing.T) { t.Errorf("String returned: %s, expected: %s", actual, expected) } } + +// Test ExportServicePingOneValidValues +func Test_ExportServicesPingOneValidValues(t *testing.T) { + pingOneServiceGroupValidValues := customtypes.ExportServicesPingOneValidValues() + if len(pingOneServiceGroupValidValues) != 5 { + t.Errorf("ExportServicesPingOneValidValues returned: %v, expected: %v", len(pingOneServiceGroupValidValues), 5) + } +} diff --git a/internal/profiles/validate.go b/internal/profiles/validate.go index 865f6b5c..8f2ec38d 100644 --- a/internal/profiles/validate.go +++ b/internal/profiles/validate.go @@ -185,6 +185,18 @@ func validateProfileValues(pName string, profileViper *viper.Viper) (err error) default: return fmt.Errorf("profile '%s': variable type %T for key '%s' is not a string slice value", pName, typedValue, key) } + case options.ENUM_EXPORT_SERVICE_GROUP: + switch typedValue := vValue.(type) { + case *customtypes.ExportServiceGroup: + continue + case string: + esg := new(customtypes.ExportServiceGroup) + if err = esg.Set(typedValue); err != nil { + return fmt.Errorf("profile '%s': variable type '%T' for key '%s' is not a export service group value: %v", pName, typedValue, key, err) + } + default: + return fmt.Errorf("profile '%s': variable type %T for key '%s' is not a export service group value", pName, typedValue, key) + } case options.ENUM_EXPORT_SERVICES: switch typedValue := vValue.(type) { case *customtypes.ExportServices: diff --git a/internal/testing/testutils_viper/viper_utils.go b/internal/testing/testutils_viper/viper_utils.go index e61db85e..3ab6e60a 100644 --- a/internal/testing/testutils_viper/viper_utils.go +++ b/internal/testing/testutils_viper/viper_utils.go @@ -10,6 +10,7 @@ import ( "github.com/pingidentity/pingcli/internal/configuration" "github.com/pingidentity/pingcli/internal/configuration/options" + "github.com/pingidentity/pingcli/internal/customtypes" "github.com/pingidentity/pingcli/internal/profiles" ) @@ -26,6 +27,8 @@ default: outputFormat: text export: outputDirectory: %s + serviceGroup: %s + services: ["%s"] service: pingone: regionCode: %s @@ -110,6 +113,8 @@ func InitVipersCustomFile(t *testing.T, fileContents string) { func getDefaultConfigFileContents() string { return fmt.Sprintf(defaultConfigFileContentsPattern, outputDirectoryReplacement, + customtypes.ENUM_EXPORT_SERVICE_GROUP_PINGONE, + customtypes.ENUM_EXPORT_SERVICE_PINGFEDERATE, os.Getenv(options.PingOneRegionCodeOption.EnvVar), os.Getenv(options.PingOneAuthenticationWorkerClientIDOption.EnvVar), os.Getenv(options.PingOneAuthenticationWorkerClientSecretOption.EnvVar),