diff --git a/.changelog/pr-126.txt b/.changelog/pr-126.txt new file mode 100644 index 0000000..bb4b439 --- /dev/null +++ b/.changelog/pr-126.txt @@ -0,0 +1,15 @@ +```release-note:feature +`cmd/list-outputs`: New `list-outputs` subcommand that enumerates all possible output attribute paths (`resource_type.label.attr`) for exported resources. Supports `--depth` (default 1), `--include-resources`, `--exclude-resources`, and `--include-upstream`. Output is newline-delimited on stdout and pipeable directly to `--output-attribute-file`. +``` + +```release-note:feature +`cmd/export`: New `--output-attribute` (repeatable, glob `*` supported in label position) and `--output-attribute-file` flags that populate `outputs.tf` with Terraform output blocks for specified resource attribute paths. +``` + +```release-note:enhancement +`resource/pingone_davinci_application`: Added missing `api_key.value` computed attribute to schema definition. +``` + +```release-note:bug +`formatters/hcl`, `formatters/tfjson`: Computed-only attributes inside nested object blocks (e.g. `api_key.value`) were incorrectly written into resource configuration. The computed skip guard now applies at all nesting levels. +``` diff --git a/README.md b/README.md index a5bc67e..fc3bf07 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Export PingOne resources to Terraform configuration with automatic dependency re - **Automatic Dependency Resolution**: Generates Terraform references between resources - **Import Block Generation**: Terraform import blocks to manage existing resources (Terraform 1.5+) - **Module Structure**: Generates reusable Terraform modules with proper variable scaffolding +- **Output Generation**: Populate `outputs.tf` so parent modules can reference child module resources - **Dual Mode Operation**: Works as standalone CLI or Ping CLI plugin - **Two-Environment Authentication**: Isolate credentials from exported resources @@ -151,6 +152,24 @@ pingcli-terraformer export --out ./output | `--exclude-resources` | - | Exclude resources matching glob/regex pattern (repeatable) | | `--include-upstream` | `false` | Include upstream dependencies of filtered resources | | `--list-resources` | `false` | List resource addresses and exit | +| `--output-attribute` | - | Add an output block for `resource_type.label.attr` (repeatable; glob `*` supported in label position) | +| `--output-attribute-file` | - | File with one `resource_type.label.attr` path per line (same format as `--output-attribute`) | + +### List Outputs Command + +Enumerates all possible output attribute paths for exported resources without writing any files. Output is newline-delimited on stdout, suitable for piping or redirecting to a file for use with `--output-attribute-file`. + +| Flag | Default | Description | +|------|---------|-------------| +| `--pingone-worker-environment-id` | - | Worker environment ID | +| `--pingone-export-environment-id` | Worker env | Target environment ID | +| `--pingone-worker-client-id` | - | OAuth2 client ID | +| `--pingone-worker-client-secret` | - | OAuth2 client secret | +| `--pingone-region-code` | `NA` | Region: NA, EU, AP, CA, AU, SG | +| `--depth` | `1` | Attribute enumeration depth (1 = top-level only; 2 = one level of nesting) | +| `--include-resources` | - | Include resources matching glob/regex pattern (repeatable) | +| `--exclude-resources` | - | Exclude resources matching glob/regex pattern (repeatable) | +| `--include-upstream` | `false` | Include upstream dependencies of filtered resources | ### Output Formats @@ -270,6 +289,60 @@ pingcli-terraformer export \ --out ./output ``` +## Generating Module Outputs + +When the exported module is used as a child module in a root Terraform configuration, the root module may need to reference resource attributes (e.g. a flow ID) via output blocks. Use `list-outputs` to discover available paths and `--output-attribute` / `--output-attribute-file` to populate `outputs.tf`. + +### Discover available output paths + +```bash +pingcli-terraformer list-outputs \ + --include-resources "pingone_davinci_flow.*" \ + --pingone-export-environment-id +``` + +Use `--depth 2` to include nested attributes (e.g. `api_key.value`): + +```bash +pingcli-terraformer list-outputs --depth 2 ... +``` + +### Export with specific output attributes + +Using `--output-attribute` directly, with glob support in the label position: + +```bash +pingcli-terraformer export \ + --output-attribute "pingone_davinci_flow.*.id" \ + --output-attribute "pingone_davinci_flow.*.name" \ + --out ./output ... +``` + +### Pipe list-outputs into export + +```bash +# Capture all flow output paths, filter to just IDs +pingcli-terraformer list-outputs \ + --include-resources "pingone_davinci_flow.*" ... \ + | grep '\.id$' > flow-outputs.txt + +# Edit flow-outputs.txt as needed, then export +pingcli-terraformer export \ + --output-attribute-file flow-outputs.txt \ + --out ./output ... +``` + +The generated `outputs.tf` in the child module will contain one `output` block per path: + +```hcl +output "pingone_davinci_flow__pingcli__My-0020-Flow__id" { + description = "The id of pingone_davinci_flow pingcli__My-0020-Flow" + value = pingone_davinci_flow.pingcli__My-0020-Flow.id +} +``` + +The root module can then reference it as `module..pingone_davinci_flow__pingcli__My-0020-Flow__id`. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development guides, architecture documentation, and how to add new resources. diff --git a/cmd/export.go b/cmd/export.go index 5f63561..f115899 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -4,9 +4,12 @@ package cmd import ( + "bufio" "context" "fmt" "os" + "path/filepath" + "sort" "strings" "github.com/pingidentity/pingcli-plugin-terraformer/definitions" @@ -160,6 +163,10 @@ func (c *ExportCommand) Run(args []string, logger grpc.Logger) error { listResources := flags.Bool("list-resources", false, "List all resource addresses (resource_type.terraform_label) and exit") includeUpstream := flags.Bool("include-upstream", false, "Automatically include upstream dependencies of filtered resources") + // Output attribute flags + outputAttributes := flags.StringSlice("output-attribute", []string{}, "Add an output block for resource_type.label.attr (repeatable; glob * supported in label position)") + outputAttributeFile := flags.String("output-attribute-file", "", "File with one resource_type.label.attr path per line (same format as --output-attribute)") + // Parse the provided arguments if err := flags.Parse(args); err != nil { return err @@ -174,12 +181,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, *outputAttributes, *outputAttributeFile) } // 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, outputAttributes []string, outputAttributeFile string) error { // Get credentials from environment variables if not provided via flags if workerEnvironmentID == "" { workerEnvironmentID = os.Getenv("PINGCLI_PINGONE_ENVIRONMENT_ID") @@ -238,12 +245,12 @@ func (c *ExportCommand) runExport(logger grpc.Logger, workerEnvironmentID, expor return fmt.Errorf("failed to create API client: %w", err) } - return c.exportAsModule(ctx, client, logger, skipDeps, includeImports, includeValues, moduleDir, moduleName, out, exportEnvironmentID, outputFormat, includeResources, excludeResources, listResources, includeUpstream) + return c.exportAsModule(ctx, client, logger, skipDeps, includeImports, includeValues, moduleDir, moduleName, out, exportEnvironmentID, outputFormat, includeResources, excludeResources, listResources, includeUpstream, outputAttributes, outputAttributeFile) } // exportAsModule uses the schema-driven orchestrator pipeline to export // resources and generate a Terraform module. -func (c *ExportCommand) exportAsModule(ctx context.Context, client *pingoneplatform.Client, logger grpc.Logger, skipDeps, includeImports, includeValues bool, moduleDir, moduleName, out, environmentID, outputFormat string, includeResources []string, excludeResources []string, listResources bool, includeUpstream bool) error { +func (c *ExportCommand) exportAsModule(ctx context.Context, client *pingoneplatform.Client, logger grpc.Logger, skipDeps, includeImports, includeValues bool, moduleDir, moduleName, out, environmentID, outputFormat string, includeResources []string, excludeResources []string, listResources bool, includeUpstream bool, outputAttributes []string, outputAttributeFile string) error { outputDir := out if outputDir == "" { outputDir = "." @@ -422,7 +429,17 @@ func (c *ExportCommand) exportAsModule(ctx context.Context, client *pingoneplatf }) } - // 7. Build module structure. + // 7. Resolve --output-attribute / --output-attribute-file paths into output blocks. + outputPaths, pathErr := collectOutputPaths(outputAttributes, outputAttributeFile) + if pathErr != nil { + return pathErr + } + var allOutputs []module.Output + if len(outputPaths) > 0 { + allOutputs = buildOutputs(outputPaths, result, logger) + } + + // 8. Build module structure. moduleConfig := module.ModuleConfig{ OutputDir: outputDir, ModuleDirName: moduleDir, @@ -439,6 +456,7 @@ func (c *ExportCommand) exportAsModule(ctx context.Context, client *pingoneplatf structure := &module.ModuleStructure{ Config: moduleConfig, Variables: allVariables, + Outputs: allOutputs, Resources: resources, ImportBlocks: allImportBlocks, } @@ -498,6 +516,107 @@ func importResourceLabel(data *core.ResourceData, def *schema.ResourceDefinition return data.ID } +// outputAttrPath holds a parsed --output-attribute path. +type outputAttrPath struct { + resourceType string + labelPattern string + attrPath string // dot-notation, e.g. "id" or "settings.csp" +} + +// parseOutputPath splits "resource_type.label_or_glob.attr" or +// "resource_type.label_or_glob.nested.attr" into its three logical parts. +// The first dot-segment is resource_type, the second is label, and everything +// after the second dot is the attribute path. +func parseOutputPath(raw string) (outputAttrPath, bool) { + parts := strings.SplitN(raw, ".", 3) + if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return outputAttrPath{}, false + } + return outputAttrPath{ + resourceType: parts[0], + labelPattern: parts[1], + attrPath: parts[2], + }, true +} + +// collectOutputPaths merges --output-attribute values with lines read from +// --output-attribute-file, deduplicates, and returns the combined slice. +func collectOutputPaths(flags []string, filePath string) ([]string, error) { + seen := make(map[string]struct{}) + var paths []string + add := func(p string) { + p = strings.TrimSpace(p) + if p == "" || strings.HasPrefix(p, "#") { + return + } + if _, ok := seen[p]; !ok { + seen[p] = struct{}{} + paths = append(paths, p) + } + } + for _, f := range flags { + add(f) + } + if filePath != "" { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("cannot open --output-attribute-file %q: %w", filePath, err) + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + add(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading --output-attribute-file: %w", err) + } + } + return paths, nil +} + +// buildOutputs converts collected path strings into module.Output values by +// matching each (resourceType, labelPattern, attrPath) against exported results. +func buildOutputs(paths []string, result *core.ExportResult, logger grpc.Logger) []module.Output { + // Index results by resource type for O(1) lookup. + byType := make(map[string]*core.ExportedResourceData, len(result.ResourcesByType)) + for _, erd := range result.ResourcesByType { + byType[erd.ResourceType] = erd + } + + var outputs []module.Output + for _, raw := range paths { + p, ok := parseOutputPath(raw) + if !ok { + _ = logger.Warn(fmt.Sprintf("skipping malformed --output-attribute path %q: expected resource_type.label.attr", raw), nil) + continue + } + erd, found := byType[p.resourceType] + if !found { + _ = logger.Warn(fmt.Sprintf("--output-attribute: resource type %q not found in export results", p.resourceType), nil) + continue + } + matched := 0 + for _, rd := range erd.Resources { + ok, err := filepath.Match(strings.ToLower(p.labelPattern), strings.ToLower(rd.Label)) + if err != nil || !ok { + continue + } + matched++ + name := p.resourceType + "__" + rd.Label + "__" + strings.ReplaceAll(p.attrPath, ".", "__") + outputs = append(outputs, module.Output{ + Name: name, + Description: "The " + p.attrPath + " of " + p.resourceType + " " + rd.Label, + Value: p.resourceType + "." + rd.Label + "." + p.attrPath, + }) + } + if matched == 0 { + _ = logger.Warn(fmt.Sprintf("--output-attribute: pattern %q matched no %s resources", p.labelPattern, p.resourceType), nil) + } + } + sort.Slice(outputs, func(i, j int) bool { return outputs[i].Name < outputs[j].Name }) + return outputs +} + // buildImportID expands the definition's import ID format for a single resource. func buildImportID(def *schema.ResourceDefinition, rd *core.ResourceData, environmentID string) string { format := def.Dependencies.ImportIDFormat diff --git a/cmd/export_outputs_test.go b/cmd/export_outputs_test.go new file mode 100644 index 0000000..b340390 --- /dev/null +++ b/cmd/export_outputs_test.go @@ -0,0 +1,226 @@ +// Copyright © 2025 Ping Identity Corporation + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pingidentity/pingcli-plugin-terraformer/internal/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- parseOutputPath tests ---- + +func TestParseOutputPath(t *testing.T) { + tests := []struct { + name string + raw string + wantOk bool + wantResType string + wantLabel string + wantAttrPath string + }{ + { + name: "valid 3-segment path", + raw: "pingone_davinci_flow.my_flow.id", + wantOk: true, + wantResType: "pingone_davinci_flow", + wantLabel: "my_flow", + wantAttrPath: "id", + }, + { + name: "valid 4-segment nested path", + raw: "pingone_davinci_flow.my_flow.settings.csp", + wantOk: true, + wantResType: "pingone_davinci_flow", + wantLabel: "my_flow", + wantAttrPath: "settings.csp", + }, + { + name: "glob pattern preserved in label", + raw: "pingone_davinci_flow.*.id", + wantOk: true, + wantResType: "pingone_davinci_flow", + wantLabel: "*", + wantAttrPath: "id", + }, + { + name: "only 2 segments — invalid", + raw: "pingone_davinci_flow.id", + wantOk: false, + }, + { + name: "empty string — invalid", + raw: "", + wantOk: false, + }, + { + name: "single segment — invalid", + raw: "pingone_davinci_flow", + wantOk: false, + }, + { + name: "missing attr after second dot — invalid", + raw: "pingone_davinci_flow.my_flow.", + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseOutputPath(tt.raw) + assert.Equal(t, tt.wantOk, ok) + if tt.wantOk { + assert.Equal(t, tt.wantResType, got.resourceType) + assert.Equal(t, tt.wantLabel, got.labelPattern) + assert.Equal(t, tt.wantAttrPath, got.attrPath) + } + }) + } +} + +// ---- collectOutputPaths tests ---- + +func TestCollectOutputPaths_FlagsOnly(t *testing.T) { + paths, err := collectOutputPaths([]string{ + "pingone_davinci_flow.my_flow.id", + "pingone_davinci_flow.my_flow.id", // duplicate + "pingone_davinci_flow.other_flow.name", + }, "") + require.NoError(t, err) + assert.Equal(t, []string{ + "pingone_davinci_flow.my_flow.id", + "pingone_davinci_flow.other_flow.name", + }, paths) +} + +func TestCollectOutputPaths_File(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "outputs.txt") + content := "pingone_davinci_flow.flow_a.id\n# comment line\n\npingone_davinci_flow.flow_b.name\n" + require.NoError(t, os.WriteFile(tmp, []byte(content), 0600)) + + paths, err := collectOutputPaths(nil, tmp) + require.NoError(t, err) + assert.Equal(t, []string{ + "pingone_davinci_flow.flow_a.id", + "pingone_davinci_flow.flow_b.name", + }, paths) +} + +func TestCollectOutputPaths_MergeAndDedup(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "outputs.txt") + require.NoError(t, os.WriteFile(tmp, []byte("pingone_davinci_flow.flow_a.id\n"), 0600)) + + paths, err := collectOutputPaths([]string{ + "pingone_davinci_flow.flow_a.id", // already in file — should dedup + "pingone_davinci_flow.flow_b.name", + }, tmp) + require.NoError(t, err) + assert.Equal(t, []string{ + "pingone_davinci_flow.flow_a.id", + "pingone_davinci_flow.flow_b.name", + }, paths) +} + +func TestCollectOutputPaths_MissingFile(t *testing.T) { + _, err := collectOutputPaths(nil, "/nonexistent/path/outputs.txt") + assert.Error(t, err) +} + +// ---- buildOutputs tests ---- + +func makeResult(resourceType string, labels ...string) *core.ExportResult { + resources := make([]*core.ResourceData, len(labels)) + for i, l := range labels { + resources[i] = &core.ResourceData{Label: l} + } + return &core.ExportResult{ + ResourcesByType: []*core.ExportedResourceData{ + {ResourceType: resourceType, Resources: resources}, + }, + } +} + +func TestBuildOutputs_ExactMatch(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__my_flow") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_flow.pingcli__my_flow.id"}, result, logger) + require.Len(t, outputs, 1) + assert.Equal(t, "pingone_davinci_flow__pingcli__my_flow__id", outputs[0].Name) + assert.Equal(t, "pingone_davinci_flow.pingcli__my_flow.id", outputs[0].Value) + assert.Equal(t, "The id of pingone_davinci_flow pingcli__my_flow", outputs[0].Description) + assert.Empty(t, logger.warnings) +} + +func TestBuildOutputs_GlobMatchesAll(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__flow_a", "pingcli__flow_b") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_flow.*.id"}, result, logger) + require.Len(t, outputs, 2) + // Sorted by name + assert.Equal(t, "pingone_davinci_flow__pingcli__flow_a__id", outputs[0].Name) + assert.Equal(t, "pingone_davinci_flow__pingcli__flow_b__id", outputs[1].Name) + assert.Empty(t, logger.warnings) +} + +func TestBuildOutputs_NoMatchWarns(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__flow_a") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_flow.no_such_label.id"}, result, logger) + assert.Empty(t, outputs) + require.Len(t, logger.warnings, 1) + assert.Contains(t, logger.warnings[0], "matched no") +} + +func TestBuildOutputs_UnknownResourceTypeWarns(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__flow_a") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_variable.*.id"}, result, logger) + assert.Empty(t, outputs) + require.Len(t, logger.warnings, 1) + assert.Contains(t, logger.warnings[0], "not found in export results") +} + +func TestBuildOutputs_MalformedPathWarns(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__flow_a") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"bad_path"}, result, logger) + assert.Empty(t, outputs) + require.Len(t, logger.warnings, 1) + assert.Contains(t, logger.warnings[0], "malformed") +} + +func TestBuildOutputs_NestedAttrPath(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__my_flow") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_flow.pingcli__my_flow.settings.csp"}, result, logger) + require.Len(t, outputs, 1) + assert.Equal(t, "pingone_davinci_flow__pingcli__my_flow__settings__csp", outputs[0].Name) + assert.Equal(t, "pingone_davinci_flow.pingcli__my_flow.settings.csp", outputs[0].Value) +} + +func TestBuildOutputs_SortedOutput(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__z_flow", "pingcli__a_flow") + logger := &mockLogger{} + + outputs := buildOutputs([]string{"pingone_davinci_flow.*.id"}, result, logger) + require.Len(t, outputs, 2) + assert.True(t, outputs[0].Name < outputs[1].Name, "outputs should be sorted by name") +} + +func TestBuildOutputs_EmptyWhenNoPaths(t *testing.T) { + result := makeResult("pingone_davinci_flow", "pingcli__flow_a") + logger := &mockLogger{} + + outputs := buildOutputs([]string{}, result, logger) + assert.Empty(t, outputs) +} diff --git a/cmd/list_outputs.go b/cmd/list_outputs.go new file mode 100644 index 0000000..61f9add --- /dev/null +++ b/cmd/list_outputs.go @@ -0,0 +1,191 @@ +// Copyright © 2025 Ping Identity Corporation + +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "sort" + + "github.com/pingidentity/pingcli-plugin-terraformer/definitions" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/core" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/filter" + pingoneplatform "github.com/pingidentity/pingcli-plugin-terraformer/internal/platform/pingone" + "github.com/pingidentity/pingcli-plugin-terraformer/internal/schema" + "github.com/pingidentity/pingcli/shared/grpc" + "github.com/spf13/pflag" +) + +var ( + ListOutputsExample = ` # List all attribute paths across all exported resources + pingcli tf list-outputs \ + --pingone-worker-environment-id \ + --pingone-worker-client-id \ + --pingone-worker-client-secret \ + --pingone-region-code NA + + # List attribute paths for DaVinci flows only (piped to a file for later use) + pingcli tf list-outputs \ + --include-resources "pingone_davinci_flow.*" \ + --pingone-worker-environment-id \ + --pingone-worker-client-id \ + --pingone-worker-client-secret \ + --pingone-region-code NA > flow-outputs.txt + + # Use the output file with export + pingcli tf export \ + --output-attribute-file flow-outputs.txt \ + --pingone-worker-environment-id \ + --pingone-worker-client-id \ + --pingone-worker-client-secret \ + --out ./output + + # List two levels of nesting + pingcli tf list-outputs --depth 2 \ + --pingone-worker-environment-id \ + --pingone-worker-client-id \ + --pingone-worker-client-secret ` + + ListOutputsLong = `List all possible output attribute paths for exported resources. + +Connects to PingOne and fetches resource labels (same as --list-resources), then +enumerates schema-defined attribute paths up to the requested depth. + +Each line of output is a path in resource_type.label.attr format that can be +passed directly to --output-attribute or written to a file for --output-attribute-file. + +Computed attributes (e.g. id, current_version) are always included — these are +often the most useful for Terraform module outputs. + +Use --depth 2 to include one level of nested object attributes (e.g. settings.csp).` + + ListOutputsShort = "List all possible output attribute paths for exported resources" + + ListOutputsUse = "list-outputs [flags]" +) + +// ListOutputsCommand implements the list-outputs subcommand. +type ListOutputsCommand struct{} + +var _ grpc.PingCliCommand = (*ListOutputsCommand)(nil) + +func (c *ListOutputsCommand) Configuration() (*grpc.PingCliCommandConfiguration, error) { + return &grpc.PingCliCommandConfiguration{ + Use: ListOutputsUse, + Short: ListOutputsShort, + Long: ListOutputsLong, + Example: ListOutputsExample, + }, nil +} + +func (c *ListOutputsCommand) Run(args []string, logger grpc.Logger) error { + flags := pflag.NewFlagSet("list-outputs", pflag.ContinueOnError) + + workerEnvironmentID := flags.String("pingone-worker-environment-id", "", "PingOne environment ID containing the worker app") + exportEnvironmentID := flags.String("pingone-export-environment-id", "", "PingOne environment ID to export resources from (defaults to worker environment)") + regionCode := flags.String("pingone-region-code", "", "PingOne region code (NA, EU, AP, CA, AU, SG)") + clientID := flags.String("pingone-worker-client-id", "", "OAuth worker app client ID") + clientSecret := flags.String("pingone-worker-client-secret", "", "OAuth worker app client secret") + depth := flags.Int("depth", 1, "Attribute enumeration depth (1 = top-level only; 2 = one level of nesting)") + includeResources := flags.StringSlice("include-resources", []string{}, "Include resources matching glob pattern(s)") + excludeResources := flags.StringSlice("exclude-resources", []string{}, "Exclude resources matching glob pattern(s)") + includeUpstream := flags.Bool("include-upstream", false, "Include upstream dependencies of filtered resources") + + if err := flags.Parse(args); err != nil { + return err + } + + return c.runListOutputs(logger, *workerEnvironmentID, *exportEnvironmentID, *regionCode, *clientID, *clientSecret, *depth, *includeResources, *excludeResources, *includeUpstream) +} + +func (c *ListOutputsCommand) runListOutputs(logger grpc.Logger, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret string, depth int, includeResources, excludeResources []string, includeUpstream bool) error { + if workerEnvironmentID == "" { + workerEnvironmentID = os.Getenv("PINGCLI_PINGONE_ENVIRONMENT_ID") + } + if exportEnvironmentID == "" { + exportEnvironmentID = os.Getenv("PINGCLI_PINGONE_EXPORT_ENVIRONMENT_ID") + if exportEnvironmentID == "" { + exportEnvironmentID = workerEnvironmentID + } + } + if regionCode == "" { + regionCode = os.Getenv("PINGCLI_PINGONE_REGION_CODE") + } + if clientID == "" { + clientID = os.Getenv("PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_ID") + } + if clientSecret == "" { + clientSecret = os.Getenv("PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET") + } + + if workerEnvironmentID == "" { + return fmt.Errorf("worker environment ID is required: use --pingone-worker-environment-id flag or PINGCLI_PINGONE_ENVIRONMENT_ID env var") + } + if clientID == "" { + return fmt.Errorf("client ID is required: use --pingone-worker-client-id flag or PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_ID env var") + } + if clientSecret == "" { + return fmt.Errorf("client secret is required: use --pingone-worker-client-secret flag or PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET env var") + } + + if regionCode == "" { + regionCode = "NA" + } + + ctx := context.Background() + client, err := pingoneplatform.NewFromCredentials(ctx, workerEnvironmentID, exportEnvironmentID, regionCode, clientID, clientSecret) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + reg := schema.NewRegistry() + if err := reg.LoadFromFS(definitions.FS, "pingone"); err != nil { + return fmt.Errorf("failed to load definitions: %w", err) + } + + customReg := core.NewCustomHandlerRegistry() + pingoneplatform.RegisterCustomHandlers(customReg) + proc := core.NewProcessor(reg, core.WithCustomHandlers(customReg)) + + var resourceFilter *filter.ResourceFilter + if len(includeResources) > 0 || len(excludeResources) > 0 { + var err error + resourceFilter, err = filter.NewResourceFilter(includeResources, excludeResources) + if err != nil { + return fmt.Errorf("invalid resource filter pattern: %w", err) + } + } + + embeddedRefs := pingoneplatform.NewEmbeddedReferenceRegistry() + orch := core.NewExportOrchestrator(reg, proc, client, core.WithEmbeddedReferences(embeddedRefs)) + + result, err := orch.Export(ctx, core.ExportOptions{ + EnvironmentID: exportEnvironmentID, + ListOnly: true, + ResourceFilter: resourceFilter, + IncludeUpstream: includeUpstream, + }) + if err != nil { + return fmt.Errorf("failed to list resources: %w", err) + } + + var lines []string + for _, erd := range result.ResourcesByType { + for _, rd := range erd.Resources { + schema.WalkAttributes(erd.Definition.Attributes, depth, "", func(attrPath string, _ schema.AttributeDefinition) { + lines = append(lines, fmt.Sprintf("%s.%s.%s", erd.ResourceType, rd.Label, attrPath)) + }) + } + } + sort.Strings(lines) + + // Write paths to stdout so the output is pipeable (e.g. grep | file). + // Progress/error messages from the logger go to stderr, keeping the two streams separate. + w := bufio.NewWriter(os.Stdout) + for _, line := range lines { + fmt.Fprintln(w, line) + } + return w.Flush() +} diff --git a/cmd/list_outputs_test.go b/cmd/list_outputs_test.go new file mode 100644 index 0000000..f586866 --- /dev/null +++ b/cmd/list_outputs_test.go @@ -0,0 +1,120 @@ +// Copyright © 2025 Ping Identity Corporation + +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListOutputsCommand_Configuration(t *testing.T) { + cmd := &ListOutputsCommand{} + config, err := cmd.Configuration() + require.NoError(t, err) + require.NotNil(t, config) + + assert.Equal(t, ListOutputsUse, config.Use) + assert.Equal(t, ListOutputsShort, config.Short) + assert.NotEmpty(t, config.Long) + assert.NotEmpty(t, config.Example) +} + +func TestListOutputsCommand_MissingCredentials(t *testing.T) { + // Clear credentials so we hit the validation error, not a real API call. + for _, env := range []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", + } { + old := os.Getenv(env) + _ = os.Unsetenv(env) + defer func(k, v string) { + if v != "" { + _ = os.Setenv(k, v) + } + }(env, old) + } + + cmd := &ListOutputsCommand{} + logger := &mockLogger{} + err := cmd.Run([]string{}, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "worker environment ID is required") +} + +func TestListOutputsCommand_MissingClientID(t *testing.T) { + for _, env := range []string{ + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_ID", + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET", + } { + old := os.Getenv(env) + _ = os.Unsetenv(env) + defer func(k, v string) { + if v != "" { + _ = os.Setenv(k, v) + } + }(env, old) + } + + cmd := &ListOutputsCommand{} + logger := &mockLogger{} + err := cmd.Run([]string{"--pingone-worker-environment-id", "env-123"}, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "client ID is required") +} + +func TestListOutputsCommand_MissingClientSecret(t *testing.T) { + old := os.Getenv("PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET") + _ = os.Unsetenv("PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET") + defer func() { + if old != "" { + _ = os.Setenv("PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET", old) + } + }() + + cmd := &ListOutputsCommand{} + logger := &mockLogger{} + err := cmd.Run([]string{ + "--pingone-worker-environment-id", "env-123", + "--pingone-worker-client-id", "client-id", + }, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "client secret is required") +} + +func TestListOutputsCommand_UnknownFlag(t *testing.T) { + cmd := &ListOutputsCommand{} + logger := &mockLogger{} + err := cmd.Run([]string{"--unknown-flag"}, logger) + require.Error(t, err) +} + +// TestTfCommand_ListOutputsRouting verifies that "list-outputs" is dispatched. +func TestTfCommand_ListOutputsRouting(t *testing.T) { + // Clear credentials so the command reaches the credential-validation error, + // confirming the subcommand was routed correctly. + for _, env := range []string{ + "PINGCLI_PINGONE_ENVIRONMENT_ID", + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_ID", + "PINGCLI_PINGONE_CLIENT_CREDENTIALS_CLIENT_SECRET", + } { + old := os.Getenv(env) + _ = os.Unsetenv(env) + defer func(k, v string) { + if v != "" { + _ = os.Setenv(k, v) + } + }(env, old) + } + + tf := &TfCommand{} + logger := &mockLogger{} + err := tf.Run([]string{"list-outputs"}, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "worker environment ID is required") +} diff --git a/cmd/tf.go b/cmd/tf.go index 8f95748..1b96f9c 100644 --- a/cmd/tf.go +++ b/cmd/tf.go @@ -11,16 +11,21 @@ var ( TfExample = ` # Export PingOne resources to Terraform HCL pingcli tf export --out ./environment.tf + # List all possible output attribute paths + pingcli tf list-outputs + # Get help for subcommands - pingcli tf export --help` + pingcli tf export --help + pingcli tf list-outputs --help` TfLong = `Terraform utilities for Ping Identity resources. -Provides tools to export resources to Terraform HCL format +Provides tools to export resources to Terraform HCL format compatible with the PingOne Terraform Provider. Available subcommands: - export - Export PingOne resources from live environments to Terraform configuration` + export - Export PingOne resources from live environments to Terraform configuration + list-outputs - List all possible output attribute paths for exported resources` TfShort = "Terraform utilities for Ping Identity" @@ -63,6 +68,10 @@ func (c *TfCommand) Run(args []string, logger grpc.Logger) error { cmd := &ExportCommand{} return cmd.Run(subArgs, logger) + case "list-outputs": + cmd := &ListOutputsCommand{} + return cmd.Run(subArgs, logger) + case "--help", "-h", "help": // Show help text config, _ := c.Configuration() diff --git a/definitions/pingone/davinci/application.yaml b/definitions/pingone/davinci/application.yaml index fee2540..9d5a82a 100644 --- a/definitions/pingone/davinci/application.yaml +++ b/definitions/pingone/davinci/application.yaml @@ -51,6 +51,11 @@ attributes: terraform_name: enabled type: bool source_path: Enabled + - name: Value + terraform_name: value + type: string + source_path: Value + computed: true # OAuth - optional object block - name: OAuth diff --git a/internal/formatters/hcl/formatter.go b/internal/formatters/hcl/formatter.go index c98c215..35ce2d0 100644 --- a/internal/formatters/hcl/formatter.go +++ b/internal/formatters/hcl/formatter.go @@ -381,6 +381,11 @@ func nestedObjectTokens(indent, closingIndent string, nested []schema.AttributeD var tokens hclwrite.Tokens for _, attr := range nested { + // Skip computed-only nested attributes (same rule as top-level). + if attr.Computed && !attr.Required && attr.ReferencesType == "" { + continue + } + nName := terraformName(attr) nVal, nOk := valMap[nName] if !nOk || nVal == nil { diff --git a/internal/formatters/tfjson/formatter.go b/internal/formatters/tfjson/formatter.go index 062156c..4c1e0ea 100644 --- a/internal/formatters/tfjson/formatter.go +++ b/internal/formatters/tfjson/formatter.go @@ -310,6 +310,11 @@ func renderNestedObject(nested []schema.AttributeDefinition, valMap map[string]i result := make(map[string]interface{}) for _, attr := range nested { + // Skip computed-only nested attributes (same rule as top-level). + if attr.Computed && !attr.Required && attr.ReferencesType == "" { + continue + } + nName := terraformName(attr) nVal, nOk := valMap[nName] if !nOk || nVal == nil { diff --git a/internal/schema/walk.go b/internal/schema/walk.go new file mode 100644 index 0000000..4758e03 --- /dev/null +++ b/internal/schema/walk.go @@ -0,0 +1,52 @@ +package schema + +// WalkAttributes recurses attrs up to depth levels and calls fn for each qualifying leaf. +// +// A leaf qualifies if: +// - Its Transform is not "jsonencode_raw" +// - Its CustomTransform is empty +// - It is a scalar type (string, bool, number), OR it is computed (e.g. id), OR it is +// a container type (object/map/list/set) that has no NestedAttributes and is therefore +// treated as a terminal value. +// +// Container attributes (object/map/list/set) with NestedAttributes are not passed to fn; +// their children are recursed into when currentDepth < depth. +// +// The path argument accumulates the dot-notation prefix. Pass "" for top-level calls. +// At depth <= 0, no attributes are visited. +func WalkAttributes(attrs []AttributeDefinition, depth int, path string, fn func(attrPath string, attr AttributeDefinition)) { + if depth <= 0 { + return + } + for _, attr := range attrs { + if shouldSkip(attr) { + continue + } + full := attrPath(path, attr.TerraformName) + if isContainer(attr) && len(attr.NestedAttributes) > 0 { + WalkAttributes(attr.NestedAttributes, depth-1, full, fn) + } else { + fn(full, attr) + } + } +} + +func shouldSkip(attr AttributeDefinition) bool { + return attr.Transform == "jsonencode_raw" || attr.CustomTransform != "" +} + +func isContainer(attr AttributeDefinition) bool { + switch attr.Type { + case "object", "map", "list", "set": + return true + default: + return false + } +} + +func attrPath(prefix, name string) string { + if prefix == "" { + return name + } + return prefix + "." + name +} diff --git a/internal/schema/walk_test.go b/internal/schema/walk_test.go new file mode 100644 index 0000000..668e6e3 --- /dev/null +++ b/internal/schema/walk_test.go @@ -0,0 +1,156 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWalkAttributes(t *testing.T) { + tests := []struct { + name string + attrs []AttributeDefinition + depth int + expected []string // collected attrPath values + }{ + { + name: "depth 0 visits nothing", + attrs: []AttributeDefinition{{Name: "ID", TerraformName: "id", Type: "string"}}, + depth: 0, + expected: []string{}, + }, + { + name: "depth 1 flat scalars", + attrs: []AttributeDefinition{ + {Name: "ID", TerraformName: "id", Type: "string", Computed: true}, + {Name: "Name", TerraformName: "name", Type: "string"}, + {Name: "Enabled", TerraformName: "enabled", Type: "bool"}, + }, + depth: 1, + expected: []string{"id", "name", "enabled"}, + }, + { + name: "depth 1 does not descend into nested object", + attrs: []AttributeDefinition{ + {Name: "Name", TerraformName: "name", Type: "string"}, + { + Name: "Settings", + TerraformName: "settings", + Type: "object", + NestedAttributes: []AttributeDefinition{ + {Name: "CSP", TerraformName: "csp", Type: "string"}, + }, + }, + }, + depth: 1, + expected: []string{"name"}, + }, + { + name: "depth 2 descends one level into nested object", + attrs: []AttributeDefinition{ + {Name: "Name", TerraformName: "name", Type: "string"}, + { + Name: "Settings", + TerraformName: "settings", + Type: "object", + NestedAttributes: []AttributeDefinition{ + {Name: "CSP", TerraformName: "csp", Type: "string"}, + {Name: "Sandbox", TerraformName: "sandbox", Type: "bool"}, + }, + }, + }, + depth: 2, + expected: []string{"name", "settings.csp", "settings.sandbox"}, + }, + { + name: "depth 2 does not go three levels", + attrs: []AttributeDefinition{ + { + Name: "Outer", + TerraformName: "outer", + Type: "object", + NestedAttributes: []AttributeDefinition{ + { + Name: "Middle", + TerraformName: "middle", + Type: "object", + NestedAttributes: []AttributeDefinition{ + {Name: "Inner", TerraformName: "inner", Type: "string"}, + }, + }, + }, + }, + }, + depth: 2, + expected: []string{}, + }, + { + name: "computed scalar is included", + attrs: []AttributeDefinition{ + {Name: "ID", TerraformName: "id", Type: "string", Computed: true}, + }, + depth: 1, + expected: []string{"id"}, + }, + { + name: "jsonencode_raw transform is skipped", + attrs: []AttributeDefinition{ + {Name: "Name", TerraformName: "name", Type: "string"}, + {Name: "Data", TerraformName: "graph_data", Type: "object", Transform: "jsonencode_raw"}, + }, + depth: 1, + expected: []string{"name"}, + }, + { + name: "custom_transform is skipped", + attrs: []AttributeDefinition{ + {Name: "Name", TerraformName: "name", Type: "string"}, + {Name: "Special", TerraformName: "special", Type: "string", CustomTransform: "someHandler"}, + }, + depth: 1, + expected: []string{"name"}, + }, + { + name: "empty attrs returns nothing", + attrs: []AttributeDefinition{}, + depth: 1, + expected: []string{}, + }, + { + name: "container with no nested attrs is treated as leaf", + attrs: []AttributeDefinition{ + {Name: "Tags", TerraformName: "tags", Type: "map"}, + }, + depth: 1, + expected: []string{"tags"}, + }, + { + name: "list nested attrs descended at depth 2", + attrs: []AttributeDefinition{ + { + Name: "Items", + TerraformName: "items", + Type: "list", + NestedAttributes: []AttributeDefinition{ + {Name: "Value", TerraformName: "value", Type: "string"}, + }, + }, + }, + depth: 2, + expected: []string{"items.value"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got []string + WalkAttributes(tt.attrs, tt.depth, "", func(p string, _ AttributeDefinition) { + got = append(got, p) + }) + if got == nil { + got = []string{} + } + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/main.go b/main.go index aff9df1..e41d905 100644 --- a/main.go +++ b/main.go @@ -153,6 +153,7 @@ Usage: Available subcommands: export - Export Ping Identity resources to Terraform configuration + list-outputs - List all possible output attribute paths for exported resources help - Show this help message Examples: @@ -160,6 +161,9 @@ Examples: # Export PingOne resources to Terraform pingcli-terraformer export --out ./environment.tf + # List all possible output attribute paths + pingcli-terraformer list-outputs + Global Flags: -h, --help Show help message -v, --version Show version information