Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/pr-25.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
PingOne SDK client requests now include a `User-Agent` suffix of `pingcli-plugin-terraformer/<version>` to identify the tool in PingOne server logs. Release builds report the real version tag; development builds report `dev`.
```
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions cmd/tf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
60 changes: 60 additions & 0 deletions cmd/tf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
5 changes: 4 additions & 1 deletion internal/platform/pingone/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>".
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")
}
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/platform/pingone/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
112 changes: 112 additions & 0 deletions internal/platform/pingone/client_useragent_test.go
Original file line number Diff line number Diff line change
@@ -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/<version>" 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)
}
}
5 changes: 4 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion tests/acceptance/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading