From 68eee19ce9773de22a84746be2e6507d669f9df1 Mon Sep 17 00:00:00 2001 From: Nathanael DEMACON Date: Tue, 13 Jun 2023 18:40:35 +0200 Subject: [PATCH 1/3] feat: add terraform generator printer --- internal/core/bootstrap.go | 2 +- internal/core/printer.go | 156 ++++++++++++++++++++++++++++++ internal/terraform/association.go | 32 ++++++ internal/terraform/runner.go | 117 ++++++++++++++++++++++ 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 internal/terraform/association.go create mode 100644 internal/terraform/runner.go diff --git a/internal/core/bootstrap.go b/internal/core/bootstrap.go index 3436be8684..3f7b55160a 100644 --- a/internal/core/bootstrap.go +++ b/internal/core/bootstrap.go @@ -87,7 +87,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e flags := pflag.NewFlagSet(config.Args[0], pflag.ContinueOnError) flags.StringVarP(&profileFlag, "profile", "p", "", "The config profile to use") flags.StringVarP(&configPathFlag, "config", "c", "", "The path to the config file") - flags.StringVarP(&outputFlag, "output", "o", cliConfig.DefaultOutput, "Output format: json or human") + flags.StringVarP(&outputFlag, "output", "o", cliConfig.DefaultOutput, "Output format: json, yaml, terraform, human, wide or template") flags.BoolVarP(&debug, "debug", "D", os.Getenv("SCW_DEBUG") == "true", "Enable debug mode") // Ignore unknown flag flags.ParseErrorsWhitelist.UnknownFlags = true diff --git a/internal/core/printer.go b/internal/core/printer.go index 33ba38adb2..6dbe37151d 100644 --- a/internal/core/printer.go +++ b/internal/core/printer.go @@ -1,9 +1,12 @@ package core import ( + "bytes" "encoding/json" "fmt" "io" + "os" + "path/filepath" "reflect" "strings" "text/template" @@ -12,6 +15,7 @@ import ( "github.com/scaleway/scaleway-cli/v2/internal/gofields" "github.com/scaleway/scaleway-cli/v2/internal/human" + "github.com/scaleway/scaleway-cli/v2/internal/terraform" ) // Type defines an formatter format. @@ -28,6 +32,9 @@ const ( // PrinterTypeYAML defines a YAML formatter. PrinterTypeYAML = PrinterType("yaml") + // PrinterTypeYAML defines a Terraform formatter. + PrinterTypeTerraform = PrinterType("terraform") + // PrinterTypeHuman defines a human readable formatted formatter. PrinterTypeHuman = PrinterType("human") @@ -39,6 +46,9 @@ const ( // Option to enable pretty output on json printer. PrinterOptJSONPretty = "pretty" + + // Option to enable pretty output on json printer. + PrinterOptTerraformWithChildren = "with-children" ) type PrinterConfig struct { @@ -75,6 +85,11 @@ func NewPrinter(config *PrinterConfig) (*Printer, error) { } case PrinterTypeYAML.String(): printer.printerType = PrinterTypeYAML + case PrinterTypeTerraform.String(): + err := setupTerraformPrinter(printer, printerOpt) + if err != nil { + return nil, err + } case PrinterTypeTemplate.String(): err := setupTemplatePrinter(printer, printerOpt) if err != nil { @@ -100,6 +115,28 @@ func setupJSONPrinter(printer *Printer, opts string) error { return nil } +func setupTerraformPrinter(printer *Printer, opts string) error { + printer.printerType = PrinterTypeTerraform + switch opts { + case PrinterOptTerraformWithChildren: + printer.terraformWithChildren = true + case "": + default: + return fmt.Errorf("invalid option %s for terraform outout. Valid options are: %s", opts, PrinterOptTerraformWithChildren) + } + + terraformVersion, err := terraform.GetVersion() + if err != nil { + return err + } + + if terraformVersion.Major < 1 || (terraformVersion.Major == 1 && terraformVersion.Minor < 5) { + return fmt.Errorf("terraform version %s is not supported. Please upgrade to terraform >= 1.5.0", terraformVersion.String()) + } + + return nil +} + func setupTemplatePrinter(printer *Printer, opts string) error { printer.printerType = PrinterTypeTemplate if opts == "" { @@ -139,6 +176,9 @@ type Printer struct { // Enable pretty print on json output jsonPretty bool + // Enable children fetching on terraform output + terraformWithChildren bool + // go template to use on template output template *template.Template @@ -163,6 +203,8 @@ func (p *Printer) Print(data interface{}, opt *human.MarshalOpt) error { err = p.printJSON(data) case PrinterTypeYAML: err = p.printYAML(data) + case PrinterTypeTerraform: + err = p.printTerraform(data) case PrinterTypeTemplate: err = p.printTemplate(data) default: @@ -283,6 +325,120 @@ func (p *Printer) printYAML(data interface{}) error { return encoder.Encode(data) } +type TerraformImportTemplateData struct { + ResourceID string + ResourceName string +} + +const terraformImportTemplate = ` +terraform { + required_providers { + scaleway = { + source = "scaleway/scaleway" + } + } + required_version = ">= 0.13" +} + +import { + # ID of the cloud resource + # Check provider documentation for importable resources and format + id = "{{ .ResourceID }}" + + # Resource address + to = {{ .ResourceName }}.main +} +` + +func (p *Printer) printTerraform(data interface{}) error { + writer := p.stdout + if _, isError := data.(error); isError { + return p.printHuman(data, nil) + } + + dataValue := reflect.ValueOf(data) + dataType := dataValue.Type().Elem() + + for i, association := range terraform.Associations { + iValue := reflect.ValueOf(i) + iType := iValue.Type().Elem() + if dataType != iType { + continue + } + + tmpl, err := template.New("terraform").Parse(association.ImportFormat) + if err != nil { + return err + } + + var resourceID bytes.Buffer + err = tmpl.Execute(&resourceID, data) + if err != nil { + return err + } + + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "scw-*") + if err != nil { + return err + } + + tmplFile, err := os.CreateTemp(tmpDir, "*.tf") + if err != nil { + return err + } + defer os.Remove(tmplFile.Name()) + + tmpl, err = template.New("terraform").Parse(terraformImportTemplate) + if err != nil { + return err + } + // Write the terraform file + err = tmpl.Execute(tmplFile, TerraformImportTemplateData{ + ResourceID: resourceID.String(), + ResourceName: association.ResourceName, + }) + if err != nil { + return err + } + + // Close the file + err = tmplFile.Close() + if err != nil { + return err + } + + res, err := terraform.Init(tmpDir) + if err != nil { + return err + } + if res.ExitCode != 0 { + return fmt.Errorf("terraform init failed: %s", res.Stderr) + } + + res, err = terraform.GenerateConfig(tmpDir, "output.tf") + if err != nil { + return err + } + if res.ExitCode != 0 { + return fmt.Errorf("terraform generate failed: %s", res.Stderr) + } + + // Print the generated config + data, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) + if err != nil { + return err + } + + _, err = writer.Write(data) + return err + } + + return p.printHuman(&CliError{ + Err: fmt.Errorf("no terraform association found for this resource type (%s)", dataType), + }, nil) +} + func (p *Printer) printTemplate(data interface{}) error { writer := p.stdout if _, isError := data.(error); isError { diff --git a/internal/terraform/association.go b/internal/terraform/association.go new file mode 100644 index 0000000000..d30da74aa5 --- /dev/null +++ b/internal/terraform/association.go @@ -0,0 +1,32 @@ +package terraform + +import ( + "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" + container "github.com/scaleway/scaleway-sdk-go/api/container/v1beta1" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" +) + +type Association struct { + ResourceName string + ImportFormat string + SubResources []string +} + +const ImportFormatID = "{{ .Region }}/{{ .ID }}" +const ImportFormatZoneID = "{{ .Zone }}/{{ .ID }}" +const ImportFormatRegionID = "{{ .Region }}/{{ .ID }}" + +var Associations = map[interface{}]Association{ + &baremetal.Server{}: { + ResourceName: "scaleway_baremetal_server", + ImportFormat: ImportFormatZoneID, + }, + &instance.Server{}: { + ResourceName: "scaleway_instance_server", + ImportFormat: ImportFormatZoneID, + }, + &container.Container{}: { + ResourceName: "scaleway_container", + ImportFormat: ImportFormatRegionID, + }, +} diff --git a/internal/terraform/runner.go b/internal/terraform/runner.go new file mode 100644 index 0000000000..3164f80776 --- /dev/null +++ b/internal/terraform/runner.go @@ -0,0 +1,117 @@ +package terraform + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type RunResponse struct { + Stdout string `js:"stdout"` + Stderr string `js:"stderr"` + ExitCode int `js:"exitCode"` +} + +func runCommandInDir(dir string, command string, args ...string) (*RunResponse, error) { + cmd := exec.Command(command, args...) + cmd.Dir = dir + + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb + + err := cmd.Run() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + return &RunResponse{ + Stdout: outb.String(), + Stderr: errb.String(), + ExitCode: exitError.ExitCode(), + }, nil + } + + return nil, err + } + + return &RunResponse{ + Stdout: outb.String(), + Stderr: errb.String(), + ExitCode: 0, + }, nil +} + +func runTerraformCommand(dir string, args ...string) (*RunResponse, error) { + return runCommandInDir(dir, "terraform", args...) +} + +type Version struct { + Major int + Minor int + Patch int +} + +func (v *Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// GetClientVersion runs terraform version and returns the version string +func GetVersion() (*Version, error) { + response, err := runTerraformCommand("", "version", "-json") + if err != nil { + return nil, err + } + + var data map[string]interface{} + err = json.Unmarshal([]byte(response.Stdout), &data) + if err != nil { + return nil, err + } + + rawVersion, ok := data["terraform_version"] + if !ok { + return nil, errors.New("failed to get terraform version: terraform_version not found") + } + + version, ok := rawVersion.(string) + if !ok { + return nil, errors.New("failed to get terraform version: terraform_version is not a string") + } + + parts := strings.Split(version, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("failed to get terraform version: invalid version format '%s'", version) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid major version '%s'", parts[0]) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid minor version '%s'", parts[1]) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid patch version '%s'", parts[2]) + } + + return &Version{ + Major: major, + Minor: minor, + Patch: patch, + }, nil +} + +func Init(dir string) (*RunResponse, error) { + return runTerraformCommand(dir, "init") +} + +func GenerateConfig(dir string, target string) (*RunResponse, error) { + return runTerraformCommand(dir, "plan", fmt.Sprintf("-generate-config-out=%s", target)) +} From 4bbf6180e053794e55cb0fbbd19a03f93000c80d Mon Sep 17 00:00:00 2001 From: Nathanael DEMACON Date: Wed, 14 Jun 2023 11:37:21 +0200 Subject: [PATCH 2/3] refactor: cleaning --- internal/core/printer.go | 112 ++------------------------- internal/terraform/association.go | 55 +++++++++++--- internal/terraform/hcl.go | 121 ++++++++++++++++++++++++++++++ internal/terraform/runner.go | 78 ++----------------- internal/terraform/version.go | 69 +++++++++++++++++ 5 files changed, 248 insertions(+), 187 deletions(-) create mode 100644 internal/terraform/hcl.go create mode 100644 internal/terraform/version.go diff --git a/internal/core/printer.go b/internal/core/printer.go index 6dbe37151d..2c333c6e6f 100644 --- a/internal/core/printer.go +++ b/internal/core/printer.go @@ -1,12 +1,9 @@ package core import ( - "bytes" "encoding/json" "fmt" "io" - "os" - "path/filepath" "reflect" "strings" "text/template" @@ -125,7 +122,7 @@ func setupTerraformPrinter(printer *Printer, opts string) error { return fmt.Errorf("invalid option %s for terraform outout. Valid options are: %s", opts, PrinterOptTerraformWithChildren) } - terraformVersion, err := terraform.GetVersion() + terraformVersion, err := terraform.GetLocalClientVersion() if err != nil { return err } @@ -325,118 +322,19 @@ func (p *Printer) printYAML(data interface{}) error { return encoder.Encode(data) } -type TerraformImportTemplateData struct { - ResourceID string - ResourceName string -} - -const terraformImportTemplate = ` -terraform { - required_providers { - scaleway = { - source = "scaleway/scaleway" - } - } - required_version = ">= 0.13" -} - -import { - # ID of the cloud resource - # Check provider documentation for importable resources and format - id = "{{ .ResourceID }}" - - # Resource address - to = {{ .ResourceName }}.main -} -` - func (p *Printer) printTerraform(data interface{}) error { writer := p.stdout if _, isError := data.(error); isError { return p.printHuman(data, nil) } - dataValue := reflect.ValueOf(data) - dataType := dataValue.Type().Elem() - - for i, association := range terraform.Associations { - iValue := reflect.ValueOf(i) - iType := iValue.Type().Elem() - if dataType != iType { - continue - } - - tmpl, err := template.New("terraform").Parse(association.ImportFormat) - if err != nil { - return err - } - - var resourceID bytes.Buffer - err = tmpl.Execute(&resourceID, data) - if err != nil { - return err - } - - // Create temporary directory - tmpDir, err := os.MkdirTemp("", "scw-*") - if err != nil { - return err - } - - tmplFile, err := os.CreateTemp(tmpDir, "*.tf") - if err != nil { - return err - } - defer os.Remove(tmplFile.Name()) - - tmpl, err = template.New("terraform").Parse(terraformImportTemplate) - if err != nil { - return err - } - // Write the terraform file - err = tmpl.Execute(tmplFile, TerraformImportTemplateData{ - ResourceID: resourceID.String(), - ResourceName: association.ResourceName, - }) - if err != nil { - return err - } - - // Close the file - err = tmplFile.Close() - if err != nil { - return err - } - - res, err := terraform.Init(tmpDir) - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("terraform init failed: %s", res.Stderr) - } - - res, err = terraform.GenerateConfig(tmpDir, "output.tf") - if err != nil { - return err - } - if res.ExitCode != 0 { - return fmt.Errorf("terraform generate failed: %s", res.Stderr) - } - - // Print the generated config - data, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) - if err != nil { - return err - } - - _, err = writer.Write(data) + hcl, err := terraform.GetHCL(data) + if err != nil { return err } - return p.printHuman(&CliError{ - Err: fmt.Errorf("no terraform association found for this resource type (%s)", dataType), - }, nil) + _, err = writer.Write([]byte(hcl)) + return err } func (p *Printer) printTemplate(data interface{}) error { diff --git a/internal/terraform/association.go b/internal/terraform/association.go index d30da74aa5..8754829fc0 100644 --- a/internal/terraform/association.go +++ b/internal/terraform/association.go @@ -1,32 +1,69 @@ package terraform import ( + "reflect" + "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" container "github.com/scaleway/scaleway-sdk-go/api/container/v1beta1" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" ) -type Association struct { +type associationSubResource struct { + TerraformAttributeName string + Command string + AsDataSource bool +} + +type association struct { ResourceName string ImportFormat string - SubResources []string + SubResources map[string]*associationSubResource } -const ImportFormatID = "{{ .Region }}/{{ .ID }}" -const ImportFormatZoneID = "{{ .Zone }}/{{ .ID }}" -const ImportFormatRegionID = "{{ .Region }}/{{ .ID }}" +// const importFormatID = "{{ .Region }}/{{ .ID }}" +const importFormatZoneID = "{{ .Zone }}/{{ .ID }}" +const importFormatRegionID = "{{ .Region }}/{{ .ID }}" -var Associations = map[interface{}]Association{ +var associations = map[interface{}]*association{ &baremetal.Server{}: { ResourceName: "scaleway_baremetal_server", - ImportFormat: ImportFormatZoneID, + ImportFormat: importFormatZoneID, }, &instance.Server{}: { ResourceName: "scaleway_instance_server", - ImportFormat: ImportFormatZoneID, + ImportFormat: importFormatZoneID, }, &container.Container{}: { ResourceName: "scaleway_container", - ImportFormat: ImportFormatRegionID, + ImportFormat: importFormatRegionID, + SubResources: map[string]*associationSubResource{ + "NamespaceID": { + TerraformAttributeName: "namespace_id", + Command: "container namespace get {{ .NamespaceID }}", + }, + }, + }, + &container.Namespace{}: { + ResourceName: "scaleway_container_namespace", + ImportFormat: importFormatRegionID, + SubResources: map[string]*associationSubResource{ + "ProjectID": { + TerraformAttributeName: "project_id", + Command: "container project get project-id={{ .ProjectID }}", + AsDataSource: true, + }, + }, }, } + +func getAssociation(data interface{}) (*association, bool) { + dataType := reflect.TypeOf(data) + + for i, association := range associations { + if dataType == reflect.TypeOf(i) { + return association, true + } + } + + return nil, false +} diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go new file mode 100644 index 0000000000..6f92a68855 --- /dev/null +++ b/internal/terraform/hcl.go @@ -0,0 +1,121 @@ +package terraform + +import ( + "bytes" + "fmt" + "html/template" + "os" + "path/filepath" + "reflect" +) + +func getResourceID(format string, data interface{}) (string, error) { + tmpl, err := template.New("terraform").Parse(format) + if err != nil { + return "", err + } + + var resourceID bytes.Buffer + err = tmpl.Execute(&resourceID, data) + if err != nil { + return "", err + } + + return resourceID.String(), nil +} + +type hclImportTemplateData struct { + ResourceID string + ResourceName string +} + +const hclImportTemplate = ` +terraform { + required_providers { + scaleway = { + source = "scaleway/scaleway" + } + } + required_version = ">= 0.13" +} + +import { + # ID of the cloud resource + # Check provider documentation for importable resources and format + id = "{{ .ResourceID }}" + + # Resource address + to = {{ .ResourceName }}.main +} +` + +func createImportFile(directory string, association *association, data interface{}) error { + importFile, err := os.CreateTemp(directory, "*.tf") + if err != nil { + return err + } + defer importFile.Close() + + resourceID, err := getResourceID(association.ImportFormat, data) + if err != nil { + return err + } + + tmpl, err := template.New("").Parse(hclImportTemplate) + if err != nil { + return err + } + // Write the terraform file + err = tmpl.Execute(importFile, hclImportTemplateData{ + ResourceID: resourceID, + ResourceName: association.ResourceName, + }) + if err != nil { + return err + } + + return nil +} + +func GetHCL(data interface{}) (string, error) { + association, ok := getAssociation(data) + if !ok { + return "", fmt.Errorf("no terraform association found for this resource type (%s)", reflect.TypeOf(data).Name()) + } + + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "scw-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) + + err = createImportFile(tmpDir, association, data) + if err != nil { + return "", err + } + + res, err := runInitCommand(tmpDir) + if err != nil { + return "", err + } + if res.ExitCode != 0 { + return "", fmt.Errorf("terraform init failed: %s", res.Stderr) + } + + res, err = runGenerateConfigCommand(tmpDir, "output.tf") + if err != nil { + return "", err + } + if res.ExitCode != 0 { + return "", fmt.Errorf("terraform generate failed: %s", res.Stderr) + } + + // Read the generated output + output, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) + if err != nil { + return "", err + } + + return string(output), nil +} diff --git a/internal/terraform/runner.go b/internal/terraform/runner.go index 3164f80776..836ba50fab 100644 --- a/internal/terraform/runner.go +++ b/internal/terraform/runner.go @@ -2,21 +2,17 @@ package terraform import ( "bytes" - "encoding/json" - "errors" "fmt" "os/exec" - "strconv" - "strings" ) -type RunResponse struct { +type runCommandResponse struct { Stdout string `js:"stdout"` Stderr string `js:"stderr"` ExitCode int `js:"exitCode"` } -func runCommandInDir(dir string, command string, args ...string) (*RunResponse, error) { +func runCommandInDir(dir string, command string, args ...string) (*runCommandResponse, error) { cmd := exec.Command(command, args...) cmd.Dir = dir @@ -27,7 +23,7 @@ func runCommandInDir(dir string, command string, args ...string) (*RunResponse, err := cmd.Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { - return &RunResponse{ + return &runCommandResponse{ Stdout: outb.String(), Stderr: errb.String(), ExitCode: exitError.ExitCode(), @@ -37,81 +33,21 @@ func runCommandInDir(dir string, command string, args ...string) (*RunResponse, return nil, err } - return &RunResponse{ + return &runCommandResponse{ Stdout: outb.String(), Stderr: errb.String(), ExitCode: 0, }, nil } -func runTerraformCommand(dir string, args ...string) (*RunResponse, error) { +func runTerraformCommand(dir string, args ...string) (*runCommandResponse, error) { return runCommandInDir(dir, "terraform", args...) } -type Version struct { - Major int - Minor int - Patch int -} - -func (v *Version) String() string { - return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) -} - -// GetClientVersion runs terraform version and returns the version string -func GetVersion() (*Version, error) { - response, err := runTerraformCommand("", "version", "-json") - if err != nil { - return nil, err - } - - var data map[string]interface{} - err = json.Unmarshal([]byte(response.Stdout), &data) - if err != nil { - return nil, err - } - - rawVersion, ok := data["terraform_version"] - if !ok { - return nil, errors.New("failed to get terraform version: terraform_version not found") - } - - version, ok := rawVersion.(string) - if !ok { - return nil, errors.New("failed to get terraform version: terraform_version is not a string") - } - - parts := strings.Split(version, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("failed to get terraform version: invalid version format '%s'", version) - } - - major, err := strconv.Atoi(parts[0]) - if err != nil { - return nil, fmt.Errorf("failed to get terraform version: invalid major version '%s'", parts[0]) - } - - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return nil, fmt.Errorf("failed to get terraform version: invalid minor version '%s'", parts[1]) - } - - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return nil, fmt.Errorf("failed to get terraform version: invalid patch version '%s'", parts[2]) - } - - return &Version{ - Major: major, - Minor: minor, - Patch: patch, - }, nil -} - -func Init(dir string) (*RunResponse, error) { +func runInitCommand(dir string) (*runCommandResponse, error) { return runTerraformCommand(dir, "init") } -func GenerateConfig(dir string, target string) (*RunResponse, error) { +func runGenerateConfigCommand(dir string, target string) (*runCommandResponse, error) { return runTerraformCommand(dir, "plan", fmt.Sprintf("-generate-config-out=%s", target)) } diff --git a/internal/terraform/version.go b/internal/terraform/version.go new file mode 100644 index 0000000000..74738a2582 --- /dev/null +++ b/internal/terraform/version.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +type Version struct { + Major int + Minor int + Patch int +} + +func (v *Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// GetClientVersion runs terraform version and returns the version string +func GetLocalClientVersion() (*Version, error) { + response, err := runTerraformCommand("", "version", "-json") + if err != nil { + return nil, err + } + + var data map[string]interface{} + err = json.Unmarshal([]byte(response.Stdout), &data) + if err != nil { + return nil, err + } + + rawVersion, ok := data["terraform_version"] + if !ok { + return nil, errors.New("failed to get terraform version: terraform_version not found") + } + + version, ok := rawVersion.(string) + if !ok { + return nil, errors.New("failed to get terraform version: terraform_version is not a string") + } + + parts := strings.Split(version, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("failed to get terraform version: invalid version format '%s'", version) + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid major version '%s'", parts[0]) + } + + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid minor version '%s'", parts[1]) + } + + patch, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to get terraform version: invalid patch version '%s'", parts[2]) + } + + return &Version{ + Major: major, + Minor: minor, + Patch: patch, + }, nil +} From 0cc8ef07ebac6c549d68c01e631c7dd0752cc3f2 Mon Sep 17 00:00:00 2001 From: Nathanael DEMACON Date: Wed, 14 Jun 2023 16:08:46 +0200 Subject: [PATCH 3/3] feat(terraform): fetch parents and children --- internal/core/bootstrap.go | 8 +- internal/core/printer.go | 39 +++++--- internal/core/shell.go | 4 +- internal/core/shell_disabled.go | 2 +- internal/core/testing.go | 4 +- internal/terraform/association.go | 97 ++++++++++++++++--- internal/terraform/hcl.go | 151 +++++++++++++++++++++++++++--- 7 files changed, 260 insertions(+), 45 deletions(-) diff --git a/internal/core/bootstrap.go b/internal/core/bootstrap.go index 3f7b55160a..a4cf8a06ca 100644 --- a/internal/core/bootstrap.go +++ b/internal/core/bootstrap.go @@ -150,7 +150,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e isClientFromBootstrapConfig = false client, err = createAnonymousClient(httpClient, config.BuildInfo) if err != nil { - printErr := printer.Print(err, nil) + printErr := printer.Print(client, err, nil) if printErr != nil { _, _ = fmt.Fprintln(config.Stderr, printErr) } @@ -201,7 +201,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e // Load CLI config cliCfg, err := cliConfig.LoadConfig(ExtractCliConfigPath(ctx)) if err != nil { - printErr := printer.Print(err, nil) + printErr := printer.Print(meta.Client, err, nil) if printErr != nil { _, _ = fmt.Fprintln(config.Stderr, printErr) } @@ -270,7 +270,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e if cliErr, ok := err.(*CliError); ok && cliErr.Code != 0 { errorCode = cliErr.Code } - printErr := printer.Print(err, nil) + printErr := printer.Print(meta.Client, err, nil) if printErr != nil { _, _ = fmt.Fprintln(os.Stderr, err) } @@ -278,7 +278,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e } if meta.command != nil { - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.Client, meta.result, meta.command.getHumanMarshalerOpt()) if printErr != nil { _, _ = fmt.Fprintln(config.Stderr, printErr) } diff --git a/internal/core/printer.go b/internal/core/printer.go index 2c333c6e6f..b90fba28e0 100644 --- a/internal/core/printer.go +++ b/internal/core/printer.go @@ -13,6 +13,7 @@ import ( "github.com/scaleway/scaleway-cli/v2/internal/gofields" "github.com/scaleway/scaleway-cli/v2/internal/human" "github.com/scaleway/scaleway-cli/v2/internal/terraform" + "github.com/scaleway/scaleway-sdk-go/scw" ) // Type defines an formatter format. @@ -44,8 +45,12 @@ const ( // Option to enable pretty output on json printer. PrinterOptJSONPretty = "pretty" - // Option to enable pretty output on json printer. - PrinterOptTerraformWithChildren = "with-children" + // Option to disable parents output on terraform printer. + PrinterOptTerraformSkipParents = "skip-parents" + // Option to disable children output on terraform printer. + PrinterOptTerraformSkipChildren = "skip-children" + // Option to disable parents and children output on terraform printer. + PrinterOptTerraformSkipParentsAndChildren = "skip-parents-and-children" ) type PrinterConfig struct { @@ -115,11 +120,16 @@ func setupJSONPrinter(printer *Printer, opts string) error { func setupTerraformPrinter(printer *Printer, opts string) error { printer.printerType = PrinterTypeTerraform switch opts { - case PrinterOptTerraformWithChildren: - printer.terraformWithChildren = true + case PrinterOptTerraformSkipParents: + printer.terraformSkipParents = true + case PrinterOptTerraformSkipChildren: + printer.terraformSkipChildren = true + case PrinterOptTerraformSkipParentsAndChildren: + printer.terraformSkipParents = true + printer.terraformSkipChildren = true case "": default: - return fmt.Errorf("invalid option %s for terraform outout. Valid options are: %s", opts, PrinterOptTerraformWithChildren) + return fmt.Errorf("invalid option %s for terraform outout. Valid options are: %s and %s", opts, PrinterOptTerraformSkipParents, PrinterOptTerraformSkipChildren) } terraformVersion, err := terraform.GetLocalClientVersion() @@ -173,8 +183,10 @@ type Printer struct { // Enable pretty print on json output jsonPretty bool - // Enable children fetching on terraform output - terraformWithChildren bool + // Disable children fetching on terraform output + terraformSkipParents bool + // Disable children fetching on terraform output + terraformSkipChildren bool // go template to use on template output template *template.Template @@ -183,7 +195,7 @@ type Printer struct { humanFields []string } -func (p *Printer) Print(data interface{}, opt *human.MarshalOpt) error { +func (p *Printer) Print(client *scw.Client, data interface{}, opt *human.MarshalOpt) error { // No matter the printer type if data is a RawResult we should print it as is. if rawResult, isRawResult := data.(RawResult); isRawResult { _, err := p.stdout.Write(rawResult) @@ -201,7 +213,7 @@ func (p *Printer) Print(data interface{}, opt *human.MarshalOpt) error { case PrinterTypeYAML: err = p.printYAML(data) case PrinterTypeTerraform: - err = p.printTerraform(data) + err = p.printTerraform(client, data) case PrinterTypeTemplate: err = p.printTemplate(data) default: @@ -322,13 +334,18 @@ func (p *Printer) printYAML(data interface{}) error { return encoder.Encode(data) } -func (p *Printer) printTerraform(data interface{}) error { +func (p *Printer) printTerraform(client *scw.Client, data interface{}) error { writer := p.stdout if _, isError := data.(error); isError { return p.printHuman(data, nil) } - hcl, err := terraform.GetHCL(data) + hcl, err := terraform.GetHCL(&terraform.GetHCLConfig{ + Client: client, + Data: data, + SkipParents: p.terraformSkipParents, + SkipChildren: p.terraformSkipChildren, + }) if err != nil { return err } diff --git a/internal/core/shell.go b/internal/core/shell.go index c7e3cd55c0..f4b2684b18 100644 --- a/internal/core/shell.go +++ b/internal/core/shell.go @@ -267,7 +267,7 @@ func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *meta) func(s return } - printErr := printer.Print(err, nil) + printErr := printer.Print(meta.Client, err, nil) if printErr != nil { _, _ = fmt.Fprintln(os.Stderr, err) } @@ -283,7 +283,7 @@ func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *meta) func(s autoCompleteCache.Update(meta.command.Namespace) - printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt()) + printErr := printer.Print(meta.Client, meta.result, meta.command.getHumanMarshalerOpt()) if printErr != nil { _, _ = fmt.Fprintln(os.Stderr, printErr) } diff --git a/internal/core/shell_disabled.go b/internal/core/shell_disabled.go index ef96ae58f7..ebd3dee45f 100644 --- a/internal/core/shell_disabled.go +++ b/internal/core/shell_disabled.go @@ -12,7 +12,7 @@ import ( ) func RunShell(ctx context.Context, printer *Printer, meta *meta, rootCmd *cobra.Command, args []string) { - err := printer.Print(fmt.Errorf("shell is currently disabled on %s/%s", runtime.GOARCH, runtime.GOOS), nil) + err := printer.Print(meta.Client, fmt.Errorf("shell is currently disabled on %s/%s", runtime.GOARCH, runtime.GOOS), nil) if err != nil { _, _ = fmt.Fprintln(os.Stderr, err) } diff --git a/internal/core/testing.go b/internal/core/testing.go index 6a8816c781..a1c0f985e8 100644 --- a/internal/core/testing.go +++ b/internal/core/testing.go @@ -726,11 +726,11 @@ func marshalGolden(t *testing.T, ctx *CheckFuncCtx) string { require.NoError(t, err) if ctx.Err != nil { - err = jsonPrinter.Print(ctx.Err, nil) + err = jsonPrinter.Print(nil, ctx.Err, nil) require.NoError(t, err) } if ctx.Result != nil { - err = jsonPrinter.Print(ctx.Result, nil) + err = jsonPrinter.Print(nil, ctx.Result, nil) require.NoError(t, err) } diff --git a/internal/terraform/association.go b/internal/terraform/association.go index 8754829fc0..d384598314 100644 --- a/internal/terraform/association.go +++ b/internal/terraform/association.go @@ -3,21 +3,32 @@ package terraform import ( "reflect" + "github.com/scaleway/scaleway-sdk-go/api/account/v2" "github.com/scaleway/scaleway-sdk-go/api/baremetal/v1" container "github.com/scaleway/scaleway-sdk-go/api/container/v1beta1" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" ) -type associationSubResource struct { - TerraformAttributeName string - Command string - AsDataSource bool +type associationParent struct { + Fetcher func(client *scw.Client, data interface{}) (interface{}, error) + AsDataSource bool +} + +type associationChild struct { + // { + // []: + // } + ParentFieldMap map[string]string + + Fetcher func(client *scw.Client, data interface{}) (interface{}, error) } type association struct { ResourceName string ImportFormat string - SubResources map[string]*associationSubResource + Parents map[string]*associationParent + Children []*associationChild } // const importFormatID = "{{ .Region }}/{{ .ID }}" @@ -36,21 +47,79 @@ var associations = map[interface{}]*association{ &container.Container{}: { ResourceName: "scaleway_container", ImportFormat: importFormatRegionID, - SubResources: map[string]*associationSubResource{ - "NamespaceID": { - TerraformAttributeName: "namespace_id", - Command: "container namespace get {{ .NamespaceID }}", + Parents: map[string]*associationParent{ + "namespace_id": { + Fetcher: func(client *scw.Client, raw interface{}) (interface{}, error) { + api := container.NewAPI(client) + data := raw.(*container.Container) + + return api.GetNamespace(&container.GetNamespaceRequest{ + NamespaceID: data.NamespaceID, + Region: data.Region, + }) + }, }, }, }, &container.Namespace{}: { ResourceName: "scaleway_container_namespace", ImportFormat: importFormatRegionID, - SubResources: map[string]*associationSubResource{ - "ProjectID": { - TerraformAttributeName: "project_id", - Command: "container project get project-id={{ .ProjectID }}", - AsDataSource: true, + Parents: map[string]*associationParent{ + "project_id": { + AsDataSource: true, + Fetcher: func(client *scw.Client, raw interface{}) (interface{}, error) { + api := account.NewAPI(client) + data := raw.(*container.Namespace) + + return api.GetProject(&account.GetProjectRequest{ + ProjectID: data.ProjectID, + }) + }, + }, + }, + Children: []*associationChild{ + { + ParentFieldMap: map[string]string{ + "namespace_id": "id", + }, + Fetcher: func(client *scw.Client, raw interface{}) (interface{}, error) { + api := container.NewAPI(client) + data := raw.(*container.Namespace) + + res, err := api.ListContainers(&container.ListContainersRequest{ + NamespaceID: data.ID, + Region: data.Region, + }) + if err != nil { + return nil, err + } + + return res.Containers, nil + }, + }, + }, + }, + &account.Project{}: { + ResourceName: "scaleway_account_project", + ImportFormat: "{{ .ID }}", + Children: []*associationChild{ + { + ParentFieldMap: map[string]string{ + "project_id": "id", + }, + Fetcher: func(client *scw.Client, raw interface{}) (interface{}, error) { + api := container.NewAPI(client) + data := raw.(*account.Project) + + res, err := api.ListNamespaces(&container.ListNamespacesRequest{ + ProjectID: &data.ID, + }) + if err != nil { + return nil, err + } + + return res.Namespaces, nil + }, }, }, }, diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go index 6f92a68855..4627284f1d 100644 --- a/internal/terraform/hcl.go +++ b/internal/terraform/hcl.go @@ -7,6 +7,10 @@ import ( "os" "path/filepath" "reflect" + "regexp" + "strings" + + "github.com/scaleway/scaleway-sdk-go/scw" ) func getResourceID(format string, data interface{}) (string, error) { @@ -32,21 +36,21 @@ type hclImportTemplateData struct { const hclImportTemplate = ` terraform { required_providers { - scaleway = { + scaleway = { source = "scaleway/scaleway" - } + } } required_version = ">= 0.13" } - + import { # ID of the cloud resource # Check provider documentation for importable resources and format id = "{{ .ResourceID }}" - + # Resource address to = {{ .ResourceName }}.main -} +} ` func createImportFile(directory string, association *association, data interface{}) error { @@ -77,10 +81,53 @@ func createImportFile(directory string, association *association, data interface return nil } -func GetHCL(data interface{}) (string, error) { - association, ok := getAssociation(data) +var ( + resourceReferenceRe = regexp.MustCompile(`(?P(data)|(resource)) "(?P[a-z_]+)" "(?P[a-z_]+)"`) + resourceReferenceResourceTypeIndex = resourceReferenceRe.SubexpIndex("type") + resourceReferenceResourceModuleIndex = resourceReferenceRe.SubexpIndex("module") + resourceReferenceResourceNameIndex = resourceReferenceRe.SubexpIndex("name") +) + +func getResourceReferenceFromOutput(output string) (resourceModule string, resourceName string) { + matches := resourceReferenceRe.FindAllStringSubmatch(output, -1) + if matches == nil { + return "", "" + } + + match := matches[len(matches)-1] + + resourceType := match[resourceReferenceResourceTypeIndex] + resourceModule = match[resourceReferenceResourceModuleIndex] + resourceName = match[resourceReferenceResourceNameIndex] + + if resourceType == "data" { + resourceModule = fmt.Sprintf("data.%s", resourceModule) + } + + return +} + +type GetHCLConfig struct { + Client *scw.Client + Data interface{} + + SkipParents bool + SkipChildren bool +} + +func GetHCL(config *GetHCLConfig) (string, error) { + association, ok := getAssociation(config.Data) if !ok { - return "", fmt.Errorf("no terraform association found for this resource type (%s)", reflect.TypeOf(data).Name()) + resourceType := "nil" + if typeOf := reflect.TypeOf(config.Data); typeOf != nil { + resourceType = typeOf.Name() + + if resourceType == "" { + resourceType = typeOf.String() + } + } + + return "", fmt.Errorf("no terraform association found for this resource type (%s)", resourceType) } // Create temporary directory @@ -90,7 +137,7 @@ func GetHCL(data interface{}) (string, error) { } defer os.RemoveAll(tmpDir) - err = createImportFile(tmpDir, association, data) + err = createImportFile(tmpDir, association, config.Data) if err != nil { return "", err } @@ -112,10 +159,92 @@ func GetHCL(data interface{}) (string, error) { } // Read the generated output - output, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) + outputRaw, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) if err != nil { return "", err } - return string(output), nil + output := string(outputRaw) + // Remove first 4 lines (terraform header) + lines := strings.Split(output, "\n") + output = strings.Join(lines[4:], "\n") + + if config.Client == nil { + return output, nil + } + + parents := make([]string, 0, len(association.Parents)) + children := make([]string, 0, len(association.Children)) + + if !config.SkipParents { + for attributeName, resource := range association.Parents { + resourceData, err := resource.Fetcher(config.Client, config.Data) + if err != nil { + return "", err + } + + resourceOutput, err := GetHCL(&GetHCLConfig{ + Client: config.Client, + Data: resourceData, + SkipChildren: true, + }) + if err != nil { + return "", err + } + + resourceModule, resourceName := getResourceReferenceFromOutput(resourceOutput) + + parents = append(parents, resourceOutput) + + re := regexp.MustCompile(fmt.Sprintf(`%s([ \t]+)= .*`, attributeName)) + matches := re.FindAllStringSubmatch(output, -1) + spaces := matches[len(matches)-1][1] + + output = re.ReplaceAllString(output, fmt.Sprintf("%s%s= %s.%s", attributeName, spaces, resourceModule, resourceName)) + } + } + + if !config.SkipChildren { + parentResourceModule, parentResourceName := getResourceReferenceFromOutput(output) + + for _, child := range association.Children { + resourceData, err := child.Fetcher(config.Client, config.Data) + if err != nil { + return "", err + } + + // resourceData SHOULD be a slice + slice := reflect.ValueOf(resourceData) + for i := 0; i < slice.Len(); i++ { + resourceOutput, err := GetHCL(&GetHCLConfig{ + Client: config.Client, + Data: slice.Index(i).Interface(), + SkipParents: true, + }) + if err != nil { + return "", err + } + + for childField, parentField := range child.ParentFieldMap { + re := regexp.MustCompile(fmt.Sprintf(`%s([ \t]+)= .*`, childField)) + matches := re.FindAllStringSubmatch(resourceOutput, -1) + spaces := matches[len(matches)-1][1] + + resourceOutput = re.ReplaceAllString(resourceOutput, fmt.Sprintf("%s%s= %s.%s.%s", childField, spaces, parentResourceModule, parentResourceName, parentField)) + } + + children = append(children, resourceOutput) + } + } + } + + for _, parent := range parents { + output = parent + "\n" + output + } + + for _, child := range children { + output = output + "\n" + child + } + + return output, nil }