diff --git a/internal/core/bootstrap.go b/internal/core/bootstrap.go index 3436be8684..a4cf8a06ca 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 @@ -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 33ba38adb2..b90fba28e0 100644 --- a/internal/core/printer.go +++ b/internal/core/printer.go @@ -12,6 +12,8 @@ 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. @@ -28,6 +30,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 +44,13 @@ const ( // Option to enable pretty output on json printer. PrinterOptJSONPretty = "pretty" + + // 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 { @@ -75,6 +87,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 +117,33 @@ func setupJSONPrinter(printer *Printer, opts string) error { return nil } +func setupTerraformPrinter(printer *Printer, opts string) error { + printer.printerType = PrinterTypeTerraform + switch opts { + 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 and %s", opts, PrinterOptTerraformSkipParents, PrinterOptTerraformSkipChildren) + } + + terraformVersion, err := terraform.GetLocalClientVersion() + 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 +183,11 @@ type Printer struct { // Enable pretty print on json output jsonPretty 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 @@ -146,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) @@ -163,6 +212,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(client, data) case PrinterTypeTemplate: err = p.printTemplate(data) default: @@ -283,6 +334,26 @@ func (p *Printer) printYAML(data interface{}) error { return encoder.Encode(data) } +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(&terraform.GetHCLConfig{ + Client: client, + Data: data, + SkipParents: p.terraformSkipParents, + SkipChildren: p.terraformSkipChildren, + }) + if err != nil { + return err + } + + _, err = writer.Write([]byte(hcl)) + return err +} + func (p *Printer) printTemplate(data interface{}) error { writer := p.stdout if _, isError := data.(error); isError { 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 new file mode 100644 index 0000000000..d384598314 --- /dev/null +++ b/internal/terraform/association.go @@ -0,0 +1,138 @@ +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 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 + Parents map[string]*associationParent + Children []*associationChild +} + +// 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, + 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, + 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 + }, + }, + }, + }, +} + +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..4627284f1d --- /dev/null +++ b/internal/terraform/hcl.go @@ -0,0 +1,250 @@ +package terraform + +import ( + "bytes" + "fmt" + "html/template" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + + "github.com/scaleway/scaleway-sdk-go/scw" +) + +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 +} + +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 { + 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 + tmpDir, err := os.MkdirTemp("", "scw-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) + + err = createImportFile(tmpDir, association, config.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 + outputRaw, err := os.ReadFile(filepath.Join(tmpDir, "output.tf")) + if err != nil { + return "", err + } + + 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 +} diff --git a/internal/terraform/runner.go b/internal/terraform/runner.go new file mode 100644 index 0000000000..836ba50fab --- /dev/null +++ b/internal/terraform/runner.go @@ -0,0 +1,53 @@ +package terraform + +import ( + "bytes" + "fmt" + "os/exec" +) + +type runCommandResponse struct { + Stdout string `js:"stdout"` + Stderr string `js:"stderr"` + ExitCode int `js:"exitCode"` +} + +func runCommandInDir(dir string, command string, args ...string) (*runCommandResponse, 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 &runCommandResponse{ + Stdout: outb.String(), + Stderr: errb.String(), + ExitCode: exitError.ExitCode(), + }, nil + } + + return nil, err + } + + return &runCommandResponse{ + Stdout: outb.String(), + Stderr: errb.String(), + ExitCode: 0, + }, nil +} + +func runTerraformCommand(dir string, args ...string) (*runCommandResponse, error) { + return runCommandInDir(dir, "terraform", args...) +} + +func runInitCommand(dir string) (*runCommandResponse, error) { + return runTerraformCommand(dir, "init") +} + +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 +}