From bab26bce91c3c4e422b75d827b5b159f55dad027 Mon Sep 17 00:00:00 2001 From: Brian Goad Date: Tue, 21 Jan 2025 06:46:51 -0500 Subject: [PATCH] Fixes DEVTOOLING-1029 (#1463) * Fixed DEVTOOLING-1029 by adding support for detecting the binary executing the provider and including logical support for determining Terraform vs OpenTofu * Docs clarifications * Fixes for provider registry * Refactor things into a dedicated platform package with testing functions * Add platform to ProviderMeta * Amazon Q recommendations * Oops, small little bug --- genesyscloud/platform/platform.go | 340 ++++++++++++++++++ genesyscloud/platform/platform_test.go | 252 +++++++++++++ genesyscloud/provider/provider.go | 54 +++ .../genesyscloud_resource_exporter.go | 59 +-- genesyscloud/tfexporter/hcl_exporter.go | 12 +- genesyscloud/tfexporter/json_exporter.go | 12 +- genesyscloud/tfexporter/tfstate_exporter.go | 118 +++--- go.mod | 6 + go.sum | 16 +- 9 files changed, 780 insertions(+), 89 deletions(-) create mode 100644 genesyscloud/platform/platform.go create mode 100644 genesyscloud/platform/platform_test.go diff --git a/genesyscloud/platform/platform.go b/genesyscloud/platform/platform.go new file mode 100644 index 000000000..e0c635115 --- /dev/null +++ b/genesyscloud/platform/platform.go @@ -0,0 +1,340 @@ +package platform + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" + + "github.com/shirou/gopsutil/process" +) + +// The Platform package provides information as to which platform is executing the provider, namely, Terraform, OpenTofu, or a Debug Server. +// It also provides a mechanism to validate and execute a command against the appropriate binary for the platform + +type Platform int + +type platformConfig struct { + platform Platform + binaryPath string + providerAddr string +} + +var platformConfigSingleton *platformConfig + +const ( + PlatformUnknown Platform = iota + PlatformTerraform + PlatformOpenTofu + PlatformDebugServer +) + +func (p Platform) String() string { + switch p { + case PlatformTerraform: + return "terraform" + case PlatformOpenTofu: + return "tofu" + case PlatformDebugServer: + return "debug-server" + default: + return "unknown" + } +} + +func (p Platform) BinaryPath() string { + return platformConfigSingleton.binaryPath +} + +func (p Platform) Binary() string { + if platformConfigSingleton.binaryPath == "" { + return "" + } + pathSegments := strings.Split(platformConfigSingleton.binaryPath, string(os.PathSeparator)) + return pathSegments[len(pathSegments)-1] +} + +func (p Platform) IsDebugServer() bool { + return p == PlatformDebugServer +} + +func (p Platform) GetProviderRegistry() string { + switch p { + case PlatformTerraform: + return "registry.terraform.io" + case PlatformOpenTofu: + return "registry.opentofu.org" + default: + return "" + } +} + +func (p Platform) ExecuteCommand(ctx context.Context, args ...string) (commandOutput *CommandOutput, err error) { + // Validate platform + if p == PlatformDebugServer { + return nil, fmt.Errorf("cannot execute platform command against debug server") + } + // Validate binary path + if platformConfigSingleton.binaryPath == "" { + return nil, fmt.Errorf("binary path is empty") + } + return executePlatformCommand(ctx, platformConfigSingleton.binaryPath, args) +} + +func IsValidPlatform(p Platform) bool { + switch p { + case PlatformTerraform, PlatformOpenTofu, PlatformDebugServer: + return true + default: + return false + } +} + +func (p Platform) Validate() error { + if !IsValidPlatform(p) { + return fmt.Errorf("invalid platform value: %d", p) + } + if platformConfigSingleton == nil { + return fmt.Errorf("platform configuration not initialized") + } + if platformConfigSingleton.binaryPath == "" { + return fmt.Errorf("binary path not set") + } + return nil +} + +func GetPlatform() Platform { + return platformConfigSingleton.platform +} + +func init() { + // Initialize the config once + platformConfigSingleton = &platformConfig{} + + path, err := detectExecutingBinary() + if err != nil { + log.Printf(`Error detecting binary: %v`, err) + platformConfigSingleton.platform = PlatformUnknown + return + } + + platformConfigSingleton.binaryPath = path + defer detectedPlatformLog() + + // Verify binary exists and has proper permissions + if err := verifyBinary(platformConfigSingleton.binaryPath); err != nil { + log.Printf("binary verification failed: %v", err) + return + } + + debugPatterns := []string{ + "__debug_bin", + "dlv", // Delve debugger + "debug-server", + } + + for _, pattern := range debugPatterns { + if strings.Contains(platformConfigSingleton.binaryPath, pattern) { + platformConfigSingleton.platform = PlatformDebugServer + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + versionOutput, err := executePlatformCommand(ctx, platformConfigSingleton.binaryPath, []string{"version"}) + if err != nil { + log.Printf("Failed to execute version command: %v", err) + return + } + + if strings.Contains(strings.ToLower(versionOutput.Stdout), "tofu") { + platformConfigSingleton.platform = PlatformOpenTofu + } else { + platformConfigSingleton.platform = PlatformTerraform + } + +} + +func detectedPlatformLog() { + platform := GetPlatform() + log.Printf("Detected executing platform is: %v", platform.String()) +} + +// detectExecutingBinary returns the path of the currently executing binary by finding +// the parent process and determining its executable path (either `terraform“ or `tofu`) +// +// Returns: +// - string: The path to the executing binary +// - error: An error if the process cannot be found or if the executable path cannot be determined +func detectExecutingBinary() (string, error) { + ppid, err := os.FindProcess(os.Getppid()) + if err != nil { + return "", err + } + tfProcess, err := process.NewProcess(int32(ppid.Pid)) + if err != nil { + return "", err + } + + exe, err := tfProcess.Exe() + if err != nil { + return "", err + } + + return exe, nil +} + +// verifyBinary performs basic security checks on the provided binary path to ensure +// it exists, is a regular file (not a symlink or directory), and has proper execute permissions. +// +// Parameters: +// - path: The filesystem path to the binary to verify +// +// Returns: +// - error: An error if any verification check fails, nil if all checks pass +func verifyBinary(path string) error { + // Basic existence check + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat binary: %w", err) + } + + // Ensure it's a regular file, not a symlink or directory + if !info.Mode().IsRegular() { + return fmt.Errorf("binary path is not a regular file") + } + + // Check if we have execute permission + if info.Mode().Perm()&0111 == 0 { + return fmt.Errorf("binary is not executable") + } + + return nil +} + +// validateCommandArgs uses HashiCorp's flags parser to validate command arguments +// before they are passed to the platform binary (terraform/tofu). +// +// Parameters: +// - args: Slice of string arguments to validate +// +// Returns: +// - error: An error if any argument fails validation, nil if all arguments are valid +func validateCommandArgs(args []string) error { + if len(args) == 0 { + return fmt.Errorf("no arguments provided") + } + + // Additional custom validation if needed + command := args[0] + if !isAllowedCommand(command) { + return fmt.Errorf("command %q is not allowed", command) + } + + // TODO: If sub commands are intended to be used, consider + // adding extra validation for these commands. + return nil +} + +// isAllowedCommand checks if the given command is in the allowed list +func isAllowedCommand(cmd string) bool { + allowedCommands := map[string]bool{ + "init": true, + "plan": true, + "apply": true, + "destroy": true, + "validate": true, + "output": true, + "show": true, + "state": true, + "import": true, + "version": true, + "fmt": true, + "force-unlock": true, + "providers": true, + "login": true, + "logout": true, + "refresh": true, + "graph": true, + "taint": true, + "untaint": true, + "workspace": true, + "metadata": true, + "test": true, + "console": true, + } + + return allowedCommands[strings.TrimPrefix(cmd, "-")] +} + +type CommandOutput struct { + Stdout string + Stderr string + ExitCode int +} + +// ExecutePlatformCommand executes a command against the platform binary (`terraform` or `tofu`) with +// the provided arguments within the given context. It captures both stdout and stderr output from the +// command execution. +// +// Parameters: +// - ctx: Context for command execution and timeout control +// - args: Slice of string arguments to pass to the command +// +// Returns: +// - stdoutString: The stdout output from the command execution +// - stderrString: The stderr output from the command execution +// - error: An error if the command fails, times out, or if the platform binary cannot be detected +// +// The function will return an error if it cannot detect the executing binary path +func executePlatformCommand(ctx context.Context, binaryPath string, args []string) (commandOutput *CommandOutput, err error) { + var stdout, stderr bytes.Buffer + + // Validate context + if ctx == nil { + return nil, fmt.Errorf("nil context provided") + } + + // Validate arguments + if err := validateCommandArgs(args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + + // Verify binary exists and has proper permissions + if err := verifyBinary(binaryPath); err != nil { + return nil, fmt.Errorf("binary verification failed: %w", err) + } + + cmd := exec.CommandContext(ctx, binaryPath) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Args = append(cmd.Args, args...) + + log.Printf("Running command against platform binary: %s", cmd.String()) + err = cmd.Run() + output := &CommandOutput{ + Stdout: stdout.String(), + Stderr: stderr.String(), + } + + if cmd.ProcessState != nil { + output.ExitCode = cmd.ProcessState.ExitCode() + } else { + output.ExitCode = -1 + } + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return output, ctx.Err() + } + return output, err + } + + return output, nil + +} diff --git a/genesyscloud/platform/platform_test.go b/genesyscloud/platform/platform_test.go new file mode 100644 index 000000000..0c23ce7db --- /dev/null +++ b/genesyscloud/platform/platform_test.go @@ -0,0 +1,252 @@ +package platform + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPlatformString(t *testing.T) { + tests := []struct { + name string + platform Platform + want string + }{ + { + name: "terraform platform", + platform: PlatformTerraform, + want: "terraform", + }, + { + name: "opentofu platform", + platform: PlatformOpenTofu, + want: "tofu", + }, + { + name: "debug server platform", + platform: PlatformDebugServer, + want: "debug-server", + }, + { + name: "unknown platform", + platform: Platform(99), + want: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.platform.String(); got != tt.want { + t.Errorf("Platform.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPlatformValidate(t *testing.T) { + tests := []struct { + name string + platform Platform + setBinPath string + wantErr bool + errContains string + }{ + { + name: "valid terraform platform", + platform: PlatformTerraform, + setBinPath: "/usr/local/bin/terraform", + wantErr: false, + }, + { + name: "invalid platform", + platform: Platform(99), + setBinPath: "/usr/local/bin/terraform", + wantErr: true, + errContains: "invalid platform state", + }, + { + name: "empty binary path", + platform: PlatformTerraform, + setBinPath: "", + wantErr: true, + errContains: "binary path not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original and restore after test + origPath := platformConfigSingleton.binaryPath + defer func() { platformConfigSingleton.binaryPath = origPath }() + + platformConfigSingleton.binaryPath = tt.setBinPath + platformConfigSingleton.platform = tt.platform + + err := platformConfigSingleton.platform.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Platform.Validate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && tt.errContains != "" { + if !contains(err.Error(), tt.errContains) { + t.Errorf("error message '%v' does not contain '%v'", err.Error(), tt.errContains) + } + } + }) + } +} + +func TestGetProviderRegistry(t *testing.T) { + tests := []struct { + name string + platform Platform + want string + }{ + { + name: "terraform registry", + platform: PlatformTerraform, + want: "registry.terraform.io", + }, + { + name: "opentofu registry", + platform: PlatformOpenTofu, + want: "registry.opentofu.org", + }, + { + name: "debug server registry", + platform: PlatformDebugServer, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.platform.GetProviderRegistry(); got != tt.want { + t.Errorf("Platform.GetProviderRegistry() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExecuteCommand(t *testing.T) { + // Create a test binary + tmpDir := t.TempDir() + testBinary := filepath.Join(tmpDir, "test-binary") + + // Create a simple shell script that echoes its arguments + script := `#!/bin/sh +echo "stdout output" +echo "stderr output" >&2 +exit 0 +` + if err := os.WriteFile(testBinary, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + ctx context.Context + args []string + wantStdout string + wantStderr string + wantExitCode int + wantErr bool + }{ + { + name: "successful command", + ctx: context.Background(), + args: []string{"version"}, + wantStdout: "stdout output\n", + wantStderr: "stderr output\n", + wantExitCode: 0, + wantErr: false, + }, + { + name: "timeout context", + ctx: timeoutContext(t), + args: []string{"version"}, + wantExitCode: -1, + wantErr: true, + }, + { + name: "invalid command", + ctx: context.Background(), + args: []string{"invalid-command"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original and restore after test + origPath := platformConfigSingleton.binaryPath + defer func() { platformConfigSingleton.binaryPath = origPath }() + + platformConfigSingleton.binaryPath = testBinary + + output, err := executePlatformCommand(tt.ctx, platformConfigSingleton.binaryPath, tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteCommand() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if output.Stdout != tt.wantStdout { + t.Errorf("stdout = %v, want %v", output.Stdout, tt.wantStdout) + } + if output.Stderr != tt.wantStderr { + t.Errorf("stderr = %v, want %v", output.Stderr, tt.wantStderr) + } + if output.ExitCode != tt.wantExitCode { + t.Errorf("exit code = %v, want %v", output.ExitCode, tt.wantExitCode) + } + } + }) + } +} + +func TestIsDebugServer(t *testing.T) { + tests := []struct { + name string + platform Platform + want bool + }{ + { + name: "debug server", + platform: PlatformDebugServer, + want: true, + }, + { + name: "terraform", + platform: PlatformTerraform, + want: false, + }, + { + name: "opentofu", + platform: PlatformOpenTofu, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.platform.IsDebugServer(); got != tt.want { + t.Errorf("Platform.IsDebugServer() = %v, want %v", got, tt.want) + } + }) + } +} + +// Helper functions +func contains(s, substr string) bool { + return len(s) >= len(substr) && s[0:len(substr)] == substr +} + +func timeoutContext(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + t.Cleanup(cancel) + time.Sleep(time.Millisecond) // Ensure timeout + return ctx +} diff --git a/genesyscloud/provider/provider.go b/genesyscloud/provider/provider.go index 128b6c693..cb81229e4 100644 --- a/genesyscloud/provider/provider.go +++ b/genesyscloud/provider/provider.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "terraform-provider-genesyscloud/genesyscloud/platform" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -238,6 +240,8 @@ func New(version string, providerResources map[string]*schema.Resource, provider type ProviderMeta struct { Version string + Registry string + Platform *platform.Platform ClientConfig *platformclientv2.Configuration Domain string Organization *platformclientv2.Organization @@ -245,6 +249,15 @@ type ProviderMeta struct { func configure(version string) schema.ConfigureContextFunc { return func(context context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) { + + platform := platform.GetPlatform() + platformValidationErr := platform.Validate() + if platformValidationErr != nil { + return nil, diag.FromErr(platformValidationErr) + } + + providerSourceRegistry := getRegistry(&platform, version) + err := InitSDKClientPool(data.Get("token_pool_size").(int), version, data) if err != nil { return nil, err @@ -260,6 +273,8 @@ func configure(version string) schema.ConfigureContextFunc { return &ProviderMeta{ Version: version, + Platform: &platform, + Registry: providerSourceRegistry, ClientConfig: defaultConfig, Domain: getRegionDomain(data.Get("aws_region").(string)), Organization: currentOrg, @@ -267,6 +282,45 @@ func configure(version string) schema.ConfigureContextFunc { } } +// getRegistry determines the appropriate registry URL based on the platform and version. +// It handles special cases for developer versions (0.1.0) and platform-specific registries. +// +// Parameters: +// +// platform: *platform.Platform - The platform configuration (must not be nil) +// version: string - The version string in semver format (e.g., "1.2.3") +// +// Returns: +// +// string: The determined registry URL +// error: Any error encountered during processing +// +// Special cases: +// - Version "0.1.0" (development version) always returns "genesys.com" +// - If platform.GetProviderRegistry() returns empty, falls back to "registry.terraform.io" +func getRegistry(platform *platform.Platform, version string) string { + + defaultRegistry := "registry.terraform.io" + devRegistry := "genesys.com" + + if platform == nil { + return defaultRegistry // Default fallback + } + + // Accounting for custom builds, we return this convention + if version == "0.1.0" { + return devRegistry + } + + // Otherwise allow the platform to determine the registry as the registry is directly + // tied to the specific platform (i.e., terraform vs opentofu) + registry := platform.GetProviderRegistry() + if registry == "" { + registry = defaultRegistry + } + return registry +} + func getOrganizationMe(defaultConfig *platformclientv2.Configuration) (*platformclientv2.Organization, diag.Diagnostics) { orgApiClient := platformclientv2.NewOrganizationApiWithConfig(defaultConfig) me, _, err := orgApiClient.GetOrganizationsMe() diff --git a/genesyscloud/tfexporter/genesyscloud_resource_exporter.go b/genesyscloud/tfexporter/genesyscloud_resource_exporter.go index d6f9b9242..7edfba8c5 100644 --- a/genesyscloud/tfexporter/genesyscloud_resource_exporter.go +++ b/genesyscloud/tfexporter/genesyscloud_resource_exporter.go @@ -76,6 +76,7 @@ type GenesysCloudResourceExporter struct { replaceWithDatasource []string includeStateFile bool version string + providerRegistry string provider *schema.Provider exportDirPath string exporters *map[string]*resourceExporter.ResourceExporter @@ -146,6 +147,7 @@ func NewGenesysCloudResourceExporter(ctx context.Context, d *schema.ResourceData includeStateFile: d.Get("include_state_file").(bool), ignoreCyclicDeps: d.Get("ignore_cyclic_deps").(bool), version: meta.(*provider.ProviderMeta).Version, + providerRegistry: meta.(*provider.ProviderMeta).Registry, provider: provider.New(meta.(*provider.ProviderMeta).Version, providerResources, providerDataSources)(), d: d, ctx: ctx, @@ -462,38 +464,50 @@ func (g *GenesysCloudResourceExporter) instanceStateToMap(state *terraform.Insta // generateOutputFiles is used to generate the tfStateFile and either the tf export or the json based export func (g *GenesysCloudResourceExporter) generateOutputFiles() diag.Diagnostics { - providerSource := g.sourceForVersion(g.version) + + if g.resourceTypesMaps == nil || g.dataSourceTypesMaps == nil { + return diag.Errorf("required fields resourceTypesMaps or dataSourceTypesMaps are nil") + } + + // Ensure export directory exists and is writable + if err := os.MkdirAll(g.exportDirPath, 0755); err != nil { + return diag.FromErr(err) + } + if g.includeStateFile { - t := NewTFStateWriter(g.ctx, g.resources, g.d, providerSource) - if err := t.writeTfState(); err != nil { - return err + t, err := NewTFStateWriter(g.ctx, g.resources, g.d, g.providerRegistry) + if err != nil { + return diag.FromErr(err) + } + if diagErr := t.writeTfState(); diagErr != nil { + return diagErr } } - var err diag.Diagnostics + var errDiag diag.Diagnostics if g.exportAsHCL { - hclExporter := NewHClExporter(g.resourceTypesMaps, g.dataSourceTypesMaps, g.unresolvedAttrs, providerSource, g.version, g.exportDirPath, g.splitFilesByResource) - err = hclExporter.exportHCLConfig() + hclExporter := NewHClExporter(g.resourceTypesMaps, g.dataSourceTypesMaps, g.unresolvedAttrs, g.providerRegistry, g.version, g.exportDirPath, g.splitFilesByResource) + errDiag = hclExporter.exportHCLConfig() } else { - jsonExporter := NewJsonExporter(g.resourceTypesMaps, g.dataSourceTypesMaps, g.unresolvedAttrs, providerSource, g.version, g.exportDirPath, g.splitFilesByResource) - err = jsonExporter.exportJSONConfig() + jsonExporter := NewJsonExporter(g.resourceTypesMaps, g.dataSourceTypesMaps, g.unresolvedAttrs, g.providerRegistry, g.version, g.exportDirPath, g.splitFilesByResource) + errDiag = jsonExporter.exportJSONConfig() } - if err != nil { - return err + if errDiag != nil { + return errDiag } if g.cyclicDependsList != nil && len(g.cyclicDependsList) > 0 { - err = files.WriteToFile([]byte(strings.Join(g.cyclicDependsList, "\n")), filepath.Join(g.exportDirPath, "cyclicDepends.txt")) + errDiag = files.WriteToFile([]byte(strings.Join(g.cyclicDependsList, "\n")), filepath.Join(g.exportDirPath, "cyclicDepends.txt")) - if err != nil { - return err + if errDiag != nil { + return errDiag } } - err = g.generateZipForExporter() - if err != nil { - return err + errDiag = g.generateZipForExporter() + if errDiag != nil { + return errDiag } return nil @@ -842,15 +856,6 @@ func (g *GenesysCloudResourceExporter) chainDependencies( return nil } -func (g *GenesysCloudResourceExporter) sourceForVersion(version string) string { - providerSource := "registry.terraform.io/mypurecloud/genesyscloud" - if g.version == "0.1.0" { - // Force using local dev version by providing a unique repo URL - providerSource = "genesys.com/mypurecloud/genesyscloud" - } - return providerSource -} - func (g *GenesysCloudResourceExporter) appendResources(resourcesToAdd []resourceExporter.ResourceInfo) { existingResources := g.copyResource() @@ -1071,7 +1076,7 @@ func (g *GenesysCloudResourceExporter) getResourcesForType(resType string, schem attributes[key] = val } instanceState.Attributes = attributes - blockType = "data." + blockType = "data" } for resAttribute, resSchema := range res.Schema { diff --git a/genesyscloud/tfexporter/hcl_exporter.go b/genesyscloud/tfexporter/hcl_exporter.go index 900b5540b..4c1af2a20 100644 --- a/genesyscloud/tfexporter/hcl_exporter.go +++ b/genesyscloud/tfexporter/hcl_exporter.go @@ -23,18 +23,18 @@ type HCLExporter struct { resourceTypesJSONMaps map[string]resourceJSONMaps dataSourceTypesMaps map[string]resourceJSONMaps unresolvedAttrs []unresolvableAttributeInfo - providerSource string + providerRegistry string version string dirPath string splitFilesByResource bool } -func NewHClExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSourceTypesMaps map[string]resourceJSONMaps, unresolvedAttrs []unresolvableAttributeInfo, providerSource string, version string, dirPath string, splitFilesByResource bool) *HCLExporter { +func NewHClExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSourceTypesMaps map[string]resourceJSONMaps, unresolvedAttrs []unresolvableAttributeInfo, providerRegistry string, version string, dirPath string, splitFilesByResource bool) *HCLExporter { hclExporter := &HCLExporter{ resourceTypesJSONMaps: resourceTypesJSONMaps, dataSourceTypesMaps: dataSourceTypesMaps, unresolvedAttrs: unresolvedAttrs, - providerSource: providerSource, + providerRegistry: providerRegistry, version: version, dirPath: dirPath, splitFilesByResource: splitFilesByResource, @@ -43,7 +43,7 @@ func NewHClExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSourc } func (h *HCLExporter) exportHCLConfig() diag.Diagnostics { - providerBlock := createHCLProviderBlock(h.providerSource, h.version) + providerBlock := createHCLProviderBlock(h.providerRegistry, h.version) variablesBlock := createHCLVariablesBlock(h.unresolvedAttrs) hclBlocks := make(map[string][][]byte, 0) @@ -164,13 +164,13 @@ func (h *HCLExporter) exportHCLConfig() diag.Diagnostics { } // Create the HCL block for terraform and the genesyscloud provider -func createHCLProviderBlock(providerSource string, version string) []byte { +func createHCLProviderBlock(providerRegistry string, version string) []byte { rootFile := hclwrite.NewEmptyFile() rootBody := rootFile.Body() tfBlock := rootBody.AppendNewBlock("terraform", nil) requiredProvidersBlock := tfBlock.Body().AppendNewBlock("required_providers", nil) requiredProvidersBlock.Body().SetAttributeValue("genesyscloud", zclconfCty.ObjectVal(map[string]zclconfCty.Value{ - "source": zclconfCty.StringVal(providerSource), + "source": zclconfCty.StringVal(fmt.Sprintf("%s/mypurecloud/genesyscloud", providerRegistry)), "version": zclconfCty.StringVal(version), })) diff --git a/genesyscloud/tfexporter/json_exporter.go b/genesyscloud/tfexporter/json_exporter.go index 0d11835d2..bd9629ace 100644 --- a/genesyscloud/tfexporter/json_exporter.go +++ b/genesyscloud/tfexporter/json_exporter.go @@ -23,18 +23,18 @@ type JsonExporter struct { resourceTypesJSONMaps map[string]resourceJSONMaps dataSourceTypesMaps map[string]resourceJSONMaps unresolvedAttrs []unresolvableAttributeInfo - providerSource string + providerRegistry string version string dirPath string splitFilesByResource bool } -func NewJsonExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSourceTypesMaps map[string]resourceJSONMaps, unresolvedAttrs []unresolvableAttributeInfo, providerSource string, version string, dirPath string, splitFilesByResource bool) *JsonExporter { +func NewJsonExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSourceTypesMaps map[string]resourceJSONMaps, unresolvedAttrs []unresolvableAttributeInfo, providerRegistry string, version string, dirPath string, splitFilesByResource bool) *JsonExporter { jsonExporter := &JsonExporter{ resourceTypesJSONMaps: resourceTypesJSONMaps, dataSourceTypesMaps: dataSourceTypesMaps, unresolvedAttrs: unresolvedAttrs, - providerSource: providerSource, + providerRegistry: providerRegistry, version: version, dirPath: dirPath, splitFilesByResource: splitFilesByResource, @@ -46,7 +46,7 @@ func NewJsonExporter(resourceTypesJSONMaps map[string]resourceJSONMaps, dataSour This file contains all of the functions used to generate the JSON export. */ func (j *JsonExporter) exportJSONConfig() diag.Diagnostics { - providerJsonMap := createProviderJsonMap(j.providerSource, j.version) + providerJsonMap := createProviderJsonMap(j.providerRegistry, j.version) variablesJsonMap := createVariablesJsonMap(j.unresolvedAttrs) if j.splitFilesByResource { @@ -149,11 +149,11 @@ func (j *JsonExporter) exportJSONConfig() diag.Diagnostics { return nil } -func createProviderJsonMap(providerSource string, version string) util.JsonMap { +func createProviderJsonMap(providerRegistry string, version string) util.JsonMap { return util.JsonMap{ "required_providers": util.JsonMap{ "genesyscloud": util.JsonMap{ - "source": providerSource, + "source": fmt.Sprintf("%s/mypurecloud/genesyscloud", providerRegistry), "version": version, }, }, diff --git a/genesyscloud/tfexporter/tfstate_exporter.go b/genesyscloud/tfexporter/tfstate_exporter.go index df1f772fb..ed899a89f 100644 --- a/genesyscloud/tfexporter/tfstate_exporter.go +++ b/genesyscloud/tfexporter/tfstate_exporter.go @@ -5,10 +5,8 @@ import ( "encoding/json" "fmt" "log" - "os" - "os/exec" - "path/filepath" "strings" + "terraform-provider-genesyscloud/genesyscloud/platform" resourceExporter "terraform-provider-genesyscloud/genesyscloud/resource_exporter" "terraform-provider-genesyscloud/genesyscloud/util/files" @@ -22,37 +20,75 @@ This files contains all of the code used to create an export's Terraform state f The other functions in this file deal with how to generate the TFVars we create during the export. */ type TFStateFileWriter struct { - ctx context.Context - resources []resourceExporter.ResourceInfo - d *schema.ResourceData - providerSource string + ctx context.Context + resources []resourceExporter.ResourceInfo + d *schema.ResourceData + providerRegistry string } -func NewTFStateWriter(ctx context.Context, resources []resourceExporter.ResourceInfo, d *schema.ResourceData, providerSource string) *TFStateFileWriter { +func NewTFStateWriter(ctx context.Context, resources []resourceExporter.ResourceInfo, d *schema.ResourceData, providerRegistry string) (*TFStateFileWriter, error) { + if ctx == nil { + return nil, fmt.Errorf("context cannot be nil") + } + if d == nil { + return nil, fmt.Errorf("schema.ResourceData cannot be nil") + } + if len(resources) == 0 { + return nil, fmt.Errorf("resources cannot be empty") + } tfwriter := &TFStateFileWriter{ - ctx: ctx, - resources: resources, - d: d, - providerSource: providerSource, + ctx: ctx, + resources: resources, + d: d, + providerRegistry: providerRegistry, } - return tfwriter + return tfwriter, nil } func (t *TFStateFileWriter) writeTfState() diag.Diagnostics { + + platform := platform.GetPlatform() + platformErr := platform.Validate() + if platformErr != nil { + return diag.Errorf("Failed to validate platform: %v", platformErr) + } + stateFilePath, diagErr := getFilePath(t.d, defaultTfStateFile) if diagErr != nil { return diagErr } tfstate := terraform.NewState() + if tfstate == nil { + return diag.Errorf("failed to create new terraform state") + } + + // Ensure the root module and resources map are initialized + rootModule := tfstate.RootModule() + if rootModule == nil { + return diag.Errorf("failed to get root module") + } + if rootModule.Resources == nil { + rootModule.Resources = make(map[string]*terraform.ResourceState) + } + for _, resource := range t.resources { + resourceKey := "" + if resource.BlockType != "" { + resourceKey = resource.BlockType + "." + } + resourceKey += resource.Type + "." + resource.BlockLabel + if resourceKey == ".." || resourceKey == "." { // This would catch the worst case of all empty strings + return diag.Errorf("invalid resource key generated for resource: %+v", resource) + } + resourceState := &terraform.ResourceState{ Type: resource.Type, Primary: resource.State, Provider: "provider.genesyscloud", } - tfstate.RootModule().Resources[resource.BlockType+resource.Type+"."+resource.BlockLabel] = resourceState + rootModule.Resources[resourceKey] = resourceState } data, err := json.MarshalIndent(tfstate, "", " ") @@ -61,55 +97,41 @@ func (t *TFStateFileWriter) writeTfState() diag.Diagnostics { } log.Printf("Writing export state file to %s", stateFilePath) - if err := files.WriteToFile(data, stateFilePath); err != nil { - return err + if diagErr := files.WriteToFile(data, stateFilePath); diagErr != nil { + return diagErr } // This outputs terraform state v3, and there is currently no public lib to generate v4 which is required for terraform 0.13+. // However, the state can be upgraded automatically by calling the terraform CLI. If this fails, just print a warning indicating // that the state likely needs to be upgraded manually. - cliError := `Failed to run the terraform CLI to upgrade the generated state file. - The generated tfstate file will need to be upgraded manually by running the - following in the state file's directory: - 'terraform state replace-provider registry.terraform.io/-/genesyscloud registry.terraform.io/mypurecloud/genesyscloud'` - - tfpath, err := exec.LookPath("terraform") - if err != nil { - log.Println("Failed to find terraform path:", err) - log.Println(cliError) - return nil - } + cliErrorPostscript := fmt.Sprintf(`The generated tfstate file will need to be upgraded manually by running the following in the state file's directory: + '%s state replace-provider %s/-/genesyscloud %s/mypurecloud/genesyscloud'`, platform.Binary(), platform.GetProviderRegistry(), t.providerRegistry) - // exec.CommandContext does not auto-resolve symlinks - fileInfo, err := os.Lstat(tfpath) - if err != nil { - log.Println("Failed to Lstat terraform path:", err) - log.Println(cliError) + if platform.IsDebugServer() { + cliErrorPostscript = `The current process is running via a debug server (debug binary detected), and so it is unable to run the proper command to replace the state. Please run this command outside of a debug session.` + cliErrorPostscript + log.Print(cliErrorPostscript) return nil } - if fileInfo.Mode()&os.ModeSymlink != 0 { - tfpath, err = filepath.EvalSymlinks(tfpath) - if err != nil { - log.Println("Failed to resolve terraform path symlink:", err) - log.Println(cliError) - return nil - } - } - cmd := exec.CommandContext(t.ctx, tfpath) - cmd.Args = append(cmd.Args, []string{ + replaceProviderOutput, err := platform.ExecuteCommand(t.ctx, []string{ "state", "replace-provider", "-auto-approve", "-state=" + stateFilePath, - "registry.terraform.io/-/genesyscloud", - t.providerSource, + // This is the provider determined by the platform (terraform vs tofu) + fmt.Sprintf("%s/-/genesyscloud", platform.GetProviderRegistry()), + // This is the platform that accounts for custom builds + fmt.Sprintf("%s/mypurecloud/genesyscloud", t.providerRegistry), }...) + log.Print(replaceProviderOutput.Stdout) + + if err != nil { + cliErrorPostscript = fmt.Sprintf(`Failed to run the terraform CLI to upgrade the generated state file: + Error: %v - log.Printf("Running 'terraform state replace-provider' on %s", stateFilePath) - if err = cmd.Run(); err != nil { - log.Println("Failed to run command:", err) - log.Println(cliError) + %s`, err, cliErrorPostscript) + log.Print(cliErrorPostscript) + // Don't fail everything even if this errors. return nil } return nil diff --git a/go.mod b/go.mod index 8e9cb06d7..3019ac09b 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect github.com/cloudflare/circl v1.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/hashicorp/cli v1.1.6 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect @@ -34,16 +35,20 @@ require ( github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.8.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yuin/goldmark v1.7.7 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/tools v0.26.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -89,6 +94,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect github.com/posener/complete v1.2.3 // indirect + github.com/shirou/gopsutil v3.21.11+incompatible github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index c29705c5f..c0ced3a81 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M= github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= @@ -224,6 +226,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -320,6 +323,8 @@ github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWR github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -361,6 +366,10 @@ github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSW github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= +github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= @@ -377,6 +386,8 @@ github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zclconf/go-cty v1.16.1 h1:a5TZEPzBFFR53udlIKApXzj8JIF4ZNQ6abH79z5R1S0= github.com/zclconf/go-cty v1.16.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -466,6 +477,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -553,8 +565,8 @@ google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=