Skip to content

Commit 079dc09

Browse files
committed
feat: add terraform generator printer
1 parent ff8cc42 commit 079dc09

File tree

4 files changed

+306
-1
lines changed

4 files changed

+306
-1
lines changed

internal/core/bootstrap.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e
8787
flags := pflag.NewFlagSet(config.Args[0], pflag.ContinueOnError)
8888
flags.StringVarP(&profileFlag, "profile", "p", "", "The config profile to use")
8989
flags.StringVarP(&configPathFlag, "config", "c", "", "The path to the config file")
90-
flags.StringVarP(&outputFlag, "output", "o", cliConfig.DefaultOutput, "Output format: json or human")
90+
flags.StringVarP(&outputFlag, "output", "o", cliConfig.DefaultOutput, "Output format: json, yaml, terraform, human, wide or template")
9191
flags.BoolVarP(&debug, "debug", "D", os.Getenv("SCW_DEBUG") == "true", "Enable debug mode")
9292
// Ignore unknown flag
9393
flags.ParseErrorsWhitelist.UnknownFlags = true

internal/core/printer.go

+156
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package core
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
67
"io"
8+
"os"
9+
"path/filepath"
710
"reflect"
811
"strings"
912
"text/template"
@@ -12,6 +15,7 @@ import (
1215

1316
"github.com/scaleway/scaleway-cli/v2/internal/gofields"
1417
"github.com/scaleway/scaleway-cli/v2/internal/human"
18+
"github.com/scaleway/scaleway-cli/v2/internal/terraform"
1519
)
1620

1721
// Type defines an formatter format.
@@ -28,6 +32,9 @@ const (
2832
// PrinterTypeYAML defines a YAML formatter.
2933
PrinterTypeYAML = PrinterType("yaml")
3034

35+
// PrinterTypeYAML defines a Terraform formatter.
36+
PrinterTypeTerraform = PrinterType("terraform")
37+
3138
// PrinterTypeHuman defines a human readable formatted formatter.
3239
PrinterTypeHuman = PrinterType("human")
3340

@@ -39,6 +46,9 @@ const (
3946

4047
// Option to enable pretty output on json printer.
4148
PrinterOptJSONPretty = "pretty"
49+
50+
// Option to enable pretty output on json printer.
51+
PrinterOptTerraformWithChildren = "with-children"
4252
)
4353

4454
type PrinterConfig struct {
@@ -75,6 +85,11 @@ func NewPrinter(config *PrinterConfig) (*Printer, error) {
7585
}
7686
case PrinterTypeYAML.String():
7787
printer.printerType = PrinterTypeYAML
88+
case PrinterTypeTerraform.String():
89+
err := setupTerraformPrinter(printer, printerOpt)
90+
if err != nil {
91+
return nil, err
92+
}
7893
case PrinterTypeTemplate.String():
7994
err := setupTemplatePrinter(printer, printerOpt)
8095
if err != nil {
@@ -100,6 +115,28 @@ func setupJSONPrinter(printer *Printer, opts string) error {
100115
return nil
101116
}
102117

118+
func setupTerraformPrinter(printer *Printer, opts string) error {
119+
printer.printerType = PrinterTypeTerraform
120+
switch opts {
121+
case PrinterOptTerraformWithChildren:
122+
printer.terraformWithChildren = true
123+
case "":
124+
default:
125+
return fmt.Errorf("invalid option %s for terraform outout. Valid options are: %s", opts, PrinterOptTerraformWithChildren)
126+
}
127+
128+
terraformVersion, err := terraform.GetVersion()
129+
if err != nil {
130+
return err
131+
}
132+
133+
if terraformVersion.Major < 1 || (terraformVersion.Major == 1 && terraformVersion.Minor < 5) {
134+
return fmt.Errorf("terraform version %s is not supported. Please upgrade to terraform >= 1.5.0", terraformVersion.String())
135+
}
136+
137+
return nil
138+
}
139+
103140
func setupTemplatePrinter(printer *Printer, opts string) error {
104141
printer.printerType = PrinterTypeTemplate
105142
if opts == "" {
@@ -139,6 +176,9 @@ type Printer struct {
139176
// Enable pretty print on json output
140177
jsonPretty bool
141178

179+
// Enable children fetching on terraform output
180+
terraformWithChildren bool
181+
142182
// go template to use on template output
143183
template *template.Template
144184

@@ -163,6 +203,8 @@ func (p *Printer) Print(data interface{}, opt *human.MarshalOpt) error {
163203
err = p.printJSON(data)
164204
case PrinterTypeYAML:
165205
err = p.printYAML(data)
206+
case PrinterTypeTerraform:
207+
err = p.printTerraform(data)
166208
case PrinterTypeTemplate:
167209
err = p.printTemplate(data)
168210
default:
@@ -283,6 +325,120 @@ func (p *Printer) printYAML(data interface{}) error {
283325
return encoder.Encode(data)
284326
}
285327

328+
type TerraformImportTemplateData struct {
329+
ResourceID string
330+
ResourceName string
331+
}
332+
333+
const terraformImportTemplate = `
334+
terraform {
335+
required_providers {
336+
scaleway = {
337+
source = "scaleway/scaleway"
338+
}
339+
}
340+
required_version = ">= 0.13"
341+
}
342+
343+
import {
344+
# ID of the cloud resource
345+
# Check provider documentation for importable resources and format
346+
id = "{{ .ResourceID }}"
347+
348+
# Resource address
349+
to = {{ .ResourceName }}.main
350+
}
351+
`
352+
353+
func (p *Printer) printTerraform(data interface{}) error {
354+
writer := p.stdout
355+
if _, isError := data.(error); isError {
356+
return p.printHuman(data, nil)
357+
}
358+
359+
dataValue := reflect.ValueOf(data)
360+
dataType := dataValue.Type().Elem()
361+
362+
for i, association := range terraform.Associations {
363+
iValue := reflect.ValueOf(i)
364+
iType := iValue.Type().Elem()
365+
if dataType != iType {
366+
continue
367+
}
368+
369+
tmpl, err := template.New("terraform").Parse(association.ImportFormat)
370+
if err != nil {
371+
return err
372+
}
373+
374+
var resourceID bytes.Buffer
375+
err = tmpl.Execute(&resourceID, data)
376+
if err != nil {
377+
return err
378+
}
379+
380+
// Create temporary directory
381+
tmpDir, err := os.MkdirTemp("", "scw-*")
382+
if err != nil {
383+
return err
384+
}
385+
386+
tmplFile, err := os.CreateTemp(tmpDir, "*.tf")
387+
if err != nil {
388+
return err
389+
}
390+
defer os.Remove(tmplFile.Name())
391+
392+
tmpl, err = template.New("terraform").Parse(terraformImportTemplate)
393+
if err != nil {
394+
return err
395+
}
396+
// Write the terraform file
397+
err = tmpl.Execute(tmplFile, TerraformImportTemplateData{
398+
ResourceID: resourceID.String(),
399+
ResourceName: association.ResourceName,
400+
})
401+
if err != nil {
402+
return err
403+
}
404+
405+
// Close the file
406+
err = tmplFile.Close()
407+
if err != nil {
408+
return err
409+
}
410+
411+
res, err := terraform.Init(tmpDir)
412+
if err != nil {
413+
return err
414+
}
415+
if res.ExitCode != 0 {
416+
return fmt.Errorf("terraform init failed: %s", res.Stderr)
417+
}
418+
419+
res, err = terraform.GenerateConfig(tmpDir, "output.tf")
420+
if err != nil {
421+
return err
422+
}
423+
if res.ExitCode != 0 {
424+
return fmt.Errorf("terraform generate failed: %s", res.Stderr)
425+
}
426+
427+
// Print the generated config
428+
data, err := os.ReadFile(filepath.Join(tmpDir, "output.tf"))
429+
if err != nil {
430+
return err
431+
}
432+
433+
_, err = writer.Write(data)
434+
return err
435+
}
436+
437+
return p.printHuman(&CliError{
438+
Err: fmt.Errorf("no terraform association found for this resource type (%s)", dataType),
439+
}, nil)
440+
}
441+
286442
func (p *Printer) printTemplate(data interface{}) error {
287443
writer := p.stdout
288444
if _, isError := data.(error); isError {

internal/terraform/association.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package terraform
2+
3+
import (
4+
"github.com/scaleway/scaleway-sdk-go/api/baremetal/v1"
5+
container "github.com/scaleway/scaleway-sdk-go/api/container/v1beta1"
6+
"github.com/scaleway/scaleway-sdk-go/api/instance/v1"
7+
)
8+
9+
type Association struct {
10+
ResourceName string
11+
ImportFormat string
12+
SubResources []string
13+
}
14+
15+
const ImportFormatID = "{{ .Region }}/{{ .ID }}"
16+
const ImportFormatZoneID = "{{ .Zone }}/{{ .ID }}"
17+
const ImportFormatRegionID = "{{ .Region }}/{{ .ID }}"
18+
19+
var Associations = map[interface{}]Association{
20+
&baremetal.Server{}: {
21+
ResourceName: "scaleway_baremetal_server",
22+
ImportFormat: ImportFormatZoneID,
23+
},
24+
&instance.Server{}: {
25+
ResourceName: "scaleway_instance_server",
26+
ImportFormat: ImportFormatZoneID,
27+
},
28+
&container.Container{}: {
29+
ResourceName: "scaleway_container",
30+
ImportFormat: ImportFormatRegionID,
31+
},
32+
}

internal/terraform/runner.go

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package terraform
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
)
12+
13+
type RunResponse struct {
14+
Stdout string `js:"stdout"`
15+
Stderr string `js:"stderr"`
16+
ExitCode int `js:"exitCode"`
17+
}
18+
19+
func runCommandInDir(dir string, command string, args ...string) (*RunResponse, error) {
20+
cmd := exec.Command(command, args...)
21+
cmd.Dir = dir
22+
23+
var outb, errb bytes.Buffer
24+
cmd.Stdout = &outb
25+
cmd.Stderr = &errb
26+
27+
err := cmd.Run()
28+
if err != nil {
29+
if exitError, ok := err.(*exec.ExitError); ok {
30+
return &RunResponse{
31+
Stdout: outb.String(),
32+
Stderr: errb.String(),
33+
ExitCode: exitError.ExitCode(),
34+
}, nil
35+
}
36+
37+
return nil, err
38+
}
39+
40+
return &RunResponse{
41+
Stdout: outb.String(),
42+
Stderr: errb.String(),
43+
ExitCode: 0,
44+
}, nil
45+
}
46+
47+
func runTerraformCommand(dir string, args ...string) (*RunResponse, error) {
48+
return runCommandInDir(dir, "terraform", args...)
49+
}
50+
51+
type Version struct {
52+
Major int
53+
Minor int
54+
Patch int
55+
}
56+
57+
func (v *Version) String() string {
58+
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
59+
}
60+
61+
// GetClientVersion runs terraform version and returns the version string
62+
func GetVersion() (*Version, error) {
63+
response, err := runTerraformCommand("", "version", "-json")
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
var data map[string]interface{}
69+
err = json.Unmarshal([]byte(response.Stdout), &data)
70+
if err != nil {
71+
return nil, err
72+
}
73+
74+
rawVersion, ok := data["terraform_version"]
75+
if !ok {
76+
return nil, errors.New("failed to get terraform version: terraform_version not found")
77+
}
78+
79+
version, ok := rawVersion.(string)
80+
if !ok {
81+
return nil, errors.New("failed to get terraform version: terraform_version is not a string")
82+
}
83+
84+
parts := strings.Split(version, ".")
85+
if len(parts) != 3 {
86+
return nil, fmt.Errorf("failed to get terraform version: invalid version format '%s'", version)
87+
}
88+
89+
major, err := strconv.Atoi(parts[0])
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to get terraform version: invalid major version '%s'", parts[0])
92+
}
93+
94+
minor, err := strconv.Atoi(parts[1])
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to get terraform version: invalid minor version '%s'", parts[1])
97+
}
98+
99+
patch, err := strconv.Atoi(parts[2])
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to get terraform version: invalid patch version '%s'", parts[2])
102+
}
103+
104+
return &Version{
105+
Major: major,
106+
Minor: minor,
107+
Patch: patch,
108+
}, nil
109+
}
110+
111+
func Init(dir string) (*RunResponse, error) {
112+
return runTerraformCommand(dir, "init")
113+
}
114+
115+
func GenerateConfig(dir string, target string) (*RunResponse, error) {
116+
return runTerraformCommand(dir, "plan", fmt.Sprintf("-generate-config-out=%s", target))
117+
}

0 commit comments

Comments
 (0)