diff --git a/.changelog/pr-25.txt b/.changelog/pr-25.txt new file mode 100644 index 0000000..279d40e --- /dev/null +++ b/.changelog/pr-25.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +PingOne SDK client requests now include a `User-Agent` suffix of `pingcli-plugin-terraformer/` to identify the tool in PingOne server logs. Release builds report the real version tag; development builds report `dev`. +``` diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e731ef4..c326344 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,6 +9,8 @@ builds: binary: "pingcli-terraformer" env: - "CGO_ENABLED=0" + ldflags: + - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}" goos: - "darwin" - "linux" diff --git a/cmd/export.go b/cmd/export.go index 5f63561..b404070 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -112,7 +112,9 @@ Resource Filtering: // ExportCommand is the implementation of the export subcommand. // It encapsulates the logic for exporting PingOne environments to Terraform. -type ExportCommand struct{} +type ExportCommand struct { + version string +} // A compile-time check to ensure ExportCommand correctly implements the // grpc.PingCliCommand interface. @@ -174,12 +176,12 @@ func (c *ExportCommand) Run(args []string, logger grpc.Logger) error { } // Execute export - return c.runExport(logger, *workerEnvironmentID, *exportEnvironmentID, *regionCode, *clientID, *clientSecret, *out, *skipDependencies, *moduleDir, *moduleName, *includeImports, *includeValues, *outputFormat, *includeResources, *excludeResources, *listResources, *includeUpstream) + return c.runExport(logger, *workerEnvironmentID, *exportEnvironmentID, *regionCode, *clientID, *clientSecret, *out, *skipDependencies, *moduleDir, *moduleName, *includeImports, *includeValues, *outputFormat, *includeResources, *excludeResources, *listResources, *includeUpstream, c.version) } // runExport handles API export of all resources from an environment // All exports now generate Terraform module structure -func (c *ExportCommand) runExport(logger grpc.Logger, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret, out string, skipDeps bool, moduleDir string, moduleName string, includeImports bool, includeValues bool, outputFormat string, includeResources []string, excludeResources []string, listResources bool, includeUpstream bool) error { +func (c *ExportCommand) runExport(logger grpc.Logger, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret, out string, skipDeps bool, moduleDir string, moduleName string, includeImports bool, includeValues bool, outputFormat string, includeResources []string, excludeResources []string, listResources bool, includeUpstream bool, version string) error { // Get credentials from environment variables if not provided via flags if workerEnvironmentID == "" { workerEnvironmentID = os.Getenv("PINGCLI_PINGONE_ENVIRONMENT_ID") @@ -225,7 +227,7 @@ func (c *ExportCommand) runExport(logger grpc.Logger, workerEnvironmentID, expor // Create API client // Use NewFromCredentials to support two-environment model: worker environment for auth, export environment for resources ctx := context.Background() - client, err := pingoneplatform.NewFromCredentials(ctx, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret) + client, err := pingoneplatform.NewFromCredentials(ctx, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret, version) if err != nil { if logErr := logger.PluginError("Failed to create API client", map[string]string{ "worker_environment_id": workerEnvironmentID, diff --git a/cmd/tf.go b/cmd/tf.go index 8f95748..27a4ef1 100644 --- a/cmd/tf.go +++ b/cmd/tf.go @@ -28,7 +28,15 @@ Available subcommands: ) // TfCommand is the parent command that routes to subcommands -type TfCommand struct{} +type TfCommand struct { + version string +} + +// SetVersion stores the version string to be threaded into subcommands. +// It must be called from main before Run to ensure release builds report the correct version. +func (c *TfCommand) SetVersion(v string) { + c.version = v +} // Ensure TfCommand implements grpc.PingCliCommand var _ grpc.PingCliCommand = (*TfCommand)(nil) @@ -60,7 +68,7 @@ func (c *TfCommand) Run(args []string, logger grpc.Logger) error { // return cmd.Run(subArgs, logger) case "export": - cmd := &ExportCommand{} + cmd := &ExportCommand{version: c.version} return cmd.Run(subArgs, logger) case "--help", "-h", "help": diff --git a/cmd/tf_test.go b/cmd/tf_test.go index 08bc310..d077961 100644 --- a/cmd/tf_test.go +++ b/cmd/tf_test.go @@ -179,6 +179,66 @@ func TestTfCommand_Configuration(t *testing.T) { } } +// TestTfCommand_SetVersion confirms that SetVersion stores the value in the struct field. +func TestTfCommand_SetVersion(t *testing.T) { + c := &TfCommand{} + c.SetVersion("1.2.3") + if c.version != "1.2.3" { + t.Errorf("Expected version %q after SetVersion, got %q", "1.2.3", c.version) + } +} + +// TestTfCommand_Routing_VersionPropagation confirms that a version set via +// SetVersion is carried through TfCommand.Run into ExportCommand. The export +// run itself fails on missing credentials — the test asserts only that the +// version field is correctly wired from TfCommand to ExportCommand (observable +// via TfCommand.version remaining intact) and that the resulting error is the +// expected credential-validation failure, not a nil-version or panic. +func TestTfCommand_Routing_VersionPropagation(t *testing.T) { + // Clear credentials to ensure we get a known validation error + envVars := []string{ + "PINGCLI_PINGONE_ENVIRONMENT_ID", + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_ID", + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET", + "PINGCLI_PINGONE_REGION_CODE", + "PINGCLI_PINGONE_EXPORT_ENVIRONMENT_ID", + } + saved := make(map[string]string) + for _, key := range envVars { + saved[key] = os.Getenv(key) + _ = os.Unsetenv(key) + } + defer func() { + for key, val := range saved { + if val != "" { + _ = os.Setenv(key, val) + } + } + }() + + c := &TfCommand{} + c.SetVersion("1.2.3") + + // Version must be stored before Run is called + if c.version != "1.2.3" { + t.Fatalf("Expected version %q before Run, got %q", "1.2.3", c.version) + } + + logger := &mockLogger{} + err := c.Run([]string{"export"}, logger) + + // Expect a validation error from missing credentials, not a nil error or panic + if err == nil { + t.Error("Expected error from missing credentials, got nil") + } + + // Version field must remain intact after Run (it was passed by value to ExportCommand, + // so TfCommand.version is unchanged) + if c.version != "1.2.3" { + t.Errorf("Expected version %q to persist on TfCommand after Run, got %q", "1.2.3", c.version) + } +} + // contains checks if a string contains a substring func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) diff --git a/internal/platform/pingone/client.go b/internal/platform/pingone/client.go index 5d846b2..ccf90d5 100644 --- a/internal/platform/pingone/client.go +++ b/internal/platform/pingone/client.go @@ -72,7 +72,9 @@ func (c *Client) Warnings() []string { // NewFromCredentials creates a fully initialized Client from OAuth credentials. // workerEnvID is the environment where the OAuth client lives (used for token acquisition). // exportEnvID is the target environment whose resources will be exported. -func NewFromCredentials(ctx context.Context, workerEnvID, exportEnvID, region, clientID, clientSecret string) (*Client, error) { +// version is the tool version string (e.g. "dev" or "v1.2.3") appended to the SDK +// User-Agent header as "pingcli-plugin-terraformer/". +func NewFromCredentials(ctx context.Context, workerEnvID, exportEnvID, region, clientID, clientSecret, version string) (*Client, error) { if workerEnvID == "" { return nil, fmt.Errorf("auth environment ID is required") } @@ -101,6 +103,7 @@ func NewFromCredentials(ctx context.Context, workerEnvID, exportEnvID, region, c WithStorageType(config.StorageTypeNone) cfg := pingone.NewConfiguration(serviceCfg) + cfg.AppendUserAgent(fmt.Sprintf("pingcli-plugin-terraformer/%s", version)) apiClient, err := pingone.NewAPIClient(ctx, cfg) if err != nil { return nil, fmt.Errorf("failed to initialize API client: %w", err) diff --git a/internal/platform/pingone/client_test.go b/internal/platform/pingone/client_test.go index b3241cf..249bb03 100644 --- a/internal/platform/pingone/client_test.go +++ b/internal/platform/pingone/client_test.go @@ -144,7 +144,7 @@ func TestNewFromCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client, err := NewFromCredentials(ctx, tt.workerEnvID, tt.exportEnvID, tt.region, tt.clientID, tt.clientSecret) + client, err := NewFromCredentials(ctx, tt.workerEnvID, tt.exportEnvID, tt.region, tt.clientID, tt.clientSecret, "dev") if tt.expectError { require.Error(t, err) diff --git a/internal/platform/pingone/client_useragent_test.go b/internal/platform/pingone/client_useragent_test.go new file mode 100644 index 0000000..86f0f38 --- /dev/null +++ b/internal/platform/pingone/client_useragent_test.go @@ -0,0 +1,112 @@ +package pingone + +import ( + "strings" + "testing" + + "github.com/pingidentity/pingone-go-client/config" + "github.com/pingidentity/pingone-go-client/pingone" +) + +// TestUserAgentSuffix_AppendedCorrectly verifies that NewConfiguration followed +// by AppendUserAgent produces a User-Agent string that ends with +// "pingcli-plugin-terraformer/" and retains the SDK default prefix. +func TestUserAgentSuffix_AppendedCorrectly(t *testing.T) { + tests := []struct { + name string + version string + expectedSuffix string + expectedContain string + }{ + { + name: "dev version", + version: "dev", + expectedSuffix: "pingcli-plugin-terraformer/dev", + expectedContain: "pingtools pingone-go-client/", + }, + { + name: "semver release version", + version: "v1.2.3", + expectedSuffix: "pingcli-plugin-terraformer/v1.2.3", + expectedContain: "pingtools pingone-go-client/", + }, + { + name: "empty version produces trailing slash", + version: "", + expectedSuffix: "pingcli-plugin-terraformer/", + expectedContain: "pingtools", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serviceCfg := config.NewConfiguration() + cfg := pingone.NewConfiguration(serviceCfg) + + // Capture the base UserAgent before modification. + baseUA := cfg.UserAgent + if !strings.Contains(baseUA, "pingtools") { + t.Errorf("SDK default UserAgent does not contain 'pingtools': %q", baseUA) + } + + // Apply the same transformation as NewFromCredentials. + cfg.AppendUserAgent("pingcli-plugin-terraformer/" + tt.version) + + ua := cfg.UserAgent + + // Must still contain the SDK prefix. + if !strings.Contains(ua, tt.expectedContain) { + t.Errorf("UserAgent %q does not contain expected prefix %q", ua, tt.expectedContain) + } + + // Must end with the tool suffix. + if !strings.HasSuffix(ua, tt.expectedSuffix) { + t.Errorf("UserAgent %q does not end with expected suffix %q", ua, tt.expectedSuffix) + } + + // The suffix must be space-appended, not concatenated directly. + toolIdx := strings.Index(ua, "pingcli-plugin-terraformer/") + if toolIdx > 0 && ua[toolIdx-1] != ' ' { + t.Errorf("Expected space before 'pingcli-plugin-terraformer/' in UserAgent %q", ua) + } + }) + } +} + +// TestUserAgentSuffix_DoesNotOverwriteBase verifies that AppendUserAgent never +// overwrites the SDK default string — only appends to it. +func TestUserAgentSuffix_DoesNotOverwriteBase(t *testing.T) { + serviceCfg := config.NewConfiguration() + cfg := pingone.NewConfiguration(serviceCfg) + baseUA := cfg.UserAgent + + cfg.AppendUserAgent("pingcli-plugin-terraformer/dev") + + ua := cfg.UserAgent + + // The result must begin with the original base string. + if !strings.HasPrefix(ua, baseUA) { + t.Errorf("UserAgent after AppendUserAgent lost the original base.\nBase: %q\nResult: %q", baseUA, ua) + } + + // Length must strictly increase. + if len(ua) <= len(baseUA) { + t.Errorf("UserAgent length did not increase after AppendUserAgent.\nBefore: %d\nAfter: %d", len(baseUA), len(ua)) + } +} + +// TestNewFromCredentials_SignatureAcceptsVersionParam is a compile-time check +// encoded as a runtime test: it verifies that NewFromCredentials accepts a seventh +// string argument. If the signature ever regresses to 6 arguments this file will +// not compile. +func TestNewFromCredentials_SignatureAcceptsVersionParam(t *testing.T) { + // Passing an empty workerEnvID ensures we hit the first validation guard + // immediately, without making any network calls. + _, err := NewFromCredentials(nil, "", "target", "NA", "cid", "csecret", "v1.0.0") //nolint:staticcheck + if err == nil { + t.Error("Expected error for empty workerEnvID, got nil") + } + if !strings.Contains(err.Error(), "auth environment ID is required") { + t.Errorf("Expected 'auth environment ID is required' error, got: %v", err) + } +} diff --git a/main.go b/main.go index aff9df1..b64d1c2 100644 --- a/main.go +++ b/main.go @@ -96,11 +96,13 @@ func main() { // runAsPlugin starts the gRPC plugin server for pingcli integration func runAsPlugin() { + tfCmd := &cmd.TfCommand{} + tfCmd.SetVersion(version) plugin.Serve(&plugin.ServeConfig{ HandshakeConfig: grpc.HandshakeConfig, Plugins: map[string]plugin.Plugin{ grpc.ENUM_PINGCLI_COMMAND_GRPC: &grpc.PingCliCommandGrpcPlugin{ - Impl: &cmd.TfCommand{}, + Impl: tfCmd, }, }, GRPCServer: plugin.DefaultGRPCServer, @@ -134,6 +136,7 @@ func runAsStandalone() { logger := &simpleLogger{} tfCmd := &cmd.TfCommand{} + tfCmd.SetVersion(version) // Pass subcommand as first arg if err := tfCmd.Run(append([]string{subcommand}, args...), logger); err != nil { diff --git a/tests/acceptance/helpers.go b/tests/acceptance/helpers.go index b0a2e01..97dcd05 100644 --- a/tests/acceptance/helpers.go +++ b/tests/acceptance/helpers.go @@ -22,7 +22,7 @@ func createTestClient(t *testing.T) *pingoneplatform.Client { targetEnvID := getEnvOrDefault("PINGCLI_PINGONE_EXPORT_ENVIRONMENT_ID", authEnvID) // Default to auth env region := getEnvOrDefault("PINGONE_REGION", "NA") - client, err := pingoneplatform.NewFromCredentials(context.Background(), authEnvID, targetEnvID, region, clientID, clientSecret) + client, err := pingoneplatform.NewFromCredentials(context.Background(), authEnvID, targetEnvID, region, clientID, clientSecret, "dev") require.NoError(t, err, "Failed to create API client") return client }