From a5fffacc57a283d9fc7686aabf93451596618b9d Mon Sep 17 00:00:00 2001 From: Marques Johansson Date: Fri, 23 Jul 2021 01:48:33 -0400 Subject: [PATCH 1/6] add terraform outputter format (with basic device support) Signed-off-by: Marques Johansson --- internal/cli/root.go | 1 + internal/outputs/outputs.go | 20 +++++++++++-- internal/outputs/terraform/format.go | 45 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 internal/outputs/terraform/format.go diff --git a/internal/cli/root.go b/internal/cli/root.go index e96d95bc..1d9ae9f4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -234,6 +234,7 @@ func (c *Client) Format() outputPkg.Format { case "": break case outputPkg.FormatTable, + outputPkg.FormatTerraform, outputPkg.FormatJSON, outputPkg.FormatYAML: format = f diff --git a/internal/outputs/outputs.go b/internal/outputs/outputs.go index 53386c35..e5c18fe7 100644 --- a/internal/outputs/outputs.go +++ b/internal/outputs/outputs.go @@ -7,14 +7,17 @@ import ( "github.com/olekukonko/tablewriter" "sigs.k8s.io/yaml" + + "github.com/equinix/metal-cli/internal/outputs/terraform" ) type Format string const ( - FormatTable Format = "table" - FormatJSON Format = "json" - FormatYAML Format = "yaml" + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" + FormatTerraform Format = "tf" ) type Outputer interface { @@ -26,6 +29,15 @@ type Standard struct { Format Format } +func outputTerraform(in interface{}) error { + output, err := terraform.Marshal(in) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil +} + func outputJSON(in interface{}) error { output, err := json.MarshalIndent(in, "", " ") if err != nil { @@ -49,6 +61,8 @@ func (o *Standard) Output(in interface{}, header []string, data *[][]string) err return outputJSON(in) } else if o.Format == FormatYAML { return outputYAML(in) + } else if o.Format == FormatTerraform { + return outputTerraform(in) } else { table := tablewriter.NewWriter(os.Stdout) table.SetAutoWrapText(false) diff --git a/internal/outputs/terraform/format.go b/internal/outputs/terraform/format.go new file mode 100644 index 00000000..7651ece3 --- /dev/null +++ b/internal/outputs/terraform/format.go @@ -0,0 +1,45 @@ +package terraform + +import ( + "bytes" + "html/template" + + "github.com/packethost/packngo" +) + +const deviceFormat = ` +# terraform import metal_device.{{.Hostname}} {{.ID}} +resource "metal_device" "{{.Hostname}}" { + plan = "{{.Plan.Slug}}" + hostname = "{{.Hostname}}" + billing_cycle = "{{.BillingCycle}}" + metro = "{{.Metro.Code}}" + operating_system = "{{.OS.Slug}}" + project_id = "{{.Project.ID}}" + + tags = {{.Tags}} +} +` + +func many(s string) string { + return `{{range .}}` + s + `{{end}}` +} +func Marshal(i interface{}) ([]byte, error) { + var f = "" + switch i.(type) { + case *packngo.Device: + f = deviceFormat + case []packngo.Device: + f = many(deviceFormat) + } + tmpl, err := template.New("terraform").Parse(f) + if err != nil { + return nil, err + } + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, i) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} From 46bf72ccf3a37be9cf9f8f6a25f2ebc19690bac1 Mon Sep 17 00:00:00 2001 From: Marques Johansson Date: Fri, 23 Jul 2021 02:31:04 -0400 Subject: [PATCH 2/6] add crossplane outputter format (with basic device external-name support) Signed-off-by: Marques Johansson --- internal/cli/root.go | 1 + internal/outputs/crossplane/format.go | 68 +++++++++++++++++++++++++++ internal/outputs/outputs.go | 21 +++++++-- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 internal/outputs/crossplane/format.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 1d9ae9f4..872a5135 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -235,6 +235,7 @@ func (c *Client) Format() outputPkg.Format { break case outputPkg.FormatTable, outputPkg.FormatTerraform, + outputPkg.FormatCrossplane, outputPkg.FormatJSON, outputPkg.FormatYAML: format = f diff --git a/internal/outputs/crossplane/format.go b/internal/outputs/crossplane/format.go new file mode 100644 index 00000000..66379fbd --- /dev/null +++ b/internal/outputs/crossplane/format.go @@ -0,0 +1,68 @@ +package crossplane + +import ( + "bytes" + "html/template" + + "github.com/packethost/packngo" +) + +const ( + deviceFormat = ` +--- +apiVersion: server.metal.equinix.com/v1alpha2 +kind: Device +metadata: + name: {{.Hostname}} + annotations: + crossplane.io/external-name: {{.ID}} +spec: + ## Late-Initialization will provide the current values + ## so we don't specify them here. + + forProvider: + hostname: {{.Hostname}} + plan: {{.Plan.Slug}} + metro: {{.Metro.Code}} + operatingSystem: {{.OS.Slug}} + billingCycle: {{.BillingCycle}} + locked: {{.Locked}} + tags: {{.Tags}} + + ## The "default" provider will be used unless named here. + # providerConfigRef: + # name: equinix-metal-provider + + ## EM devices do not persist passwords in the API long. This + ## optional secret will not get the root pass for devices > 24h + # writeConnectionSecretToRef: + # name: crossplane-example + # namespace: crossplane-system + + ## Do not delete devices that have been imported. + # reclaimPolicy: Retain +` +) + +func many(s string) string { + return `{{range .}}` + s + `{{end}}` +} +func Marshal(i interface{}) ([]byte, error) { + var f = "" + switch i.(type) { + case *packngo.Device: + f = deviceFormat + case []packngo.Device: + f = many(deviceFormat) + } + tmpl, err := template.New("crossplane").Parse(f) + if err != nil { + return nil, err + } + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, i) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/outputs/outputs.go b/internal/outputs/outputs.go index e5c18fe7..48e1842d 100644 --- a/internal/outputs/outputs.go +++ b/internal/outputs/outputs.go @@ -8,16 +8,18 @@ import ( "github.com/olekukonko/tablewriter" "sigs.k8s.io/yaml" + "github.com/equinix/metal-cli/internal/outputs/crossplane" "github.com/equinix/metal-cli/internal/outputs/terraform" ) type Format string const ( - FormatTable Format = "table" - FormatJSON Format = "json" - FormatYAML Format = "yaml" - FormatTerraform Format = "tf" + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" + FormatTerraform Format = "tf" + FormatCrossplane Format = "crossplane" ) type Outputer interface { @@ -29,6 +31,15 @@ type Standard struct { Format Format } +func outputCrossplane(in interface{}) error { + output, err := crossplane.Marshal(in) + if err != nil { + return err + } + fmt.Println(string(output)) + return nil +} + func outputTerraform(in interface{}) error { output, err := terraform.Marshal(in) if err != nil { @@ -63,6 +74,8 @@ func (o *Standard) Output(in interface{}, header []string, data *[][]string) err return outputYAML(in) } else if o.Format == FormatTerraform { return outputTerraform(in) + } else if o.Format == FormatCrossplane { + return outputCrossplane(in) } else { table := tablewriter.NewWriter(os.Stdout) table.SetAutoWrapText(false) From 58abbc62c8cde0072837ac5d4f609d2c89c52217 Mon Sep 17 00:00:00 2001 From: Marques Johansson Date: Thu, 23 Feb 2023 16:15:37 -0500 Subject: [PATCH 3/6] use latest TF+crossplane provider, use go embeds for templating --- internal/outputs/crossplane/format.go | 5 +-- internal/outputs/terraform/device.tf.gotmpl | 11 +++++++ internal/outputs/terraform/format.go | 34 ++++++++++++-------- internal/outputs/terraform/project.tf.gotmpl | 7 ++++ 4 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 internal/outputs/terraform/device.tf.gotmpl create mode 100644 internal/outputs/terraform/project.tf.gotmpl diff --git a/internal/outputs/crossplane/format.go b/internal/outputs/crossplane/format.go index 66379fbd..995efd5a 100644 --- a/internal/outputs/crossplane/format.go +++ b/internal/outputs/crossplane/format.go @@ -10,7 +10,7 @@ import ( const ( deviceFormat = ` --- -apiVersion: server.metal.equinix.com/v1alpha2 +apiVersion: devices.metal.equinix.jet.crossplane.io/v1alpha1 kind: Device metadata: name: {{.Hostname}} @@ -47,8 +47,9 @@ spec: func many(s string) string { return `{{range .}}` + s + `{{end}}` } + func Marshal(i interface{}) ([]byte, error) { - var f = "" + f := "" switch i.(type) { case *packngo.Device: f = deviceFormat diff --git a/internal/outputs/terraform/device.tf.gotmpl b/internal/outputs/terraform/device.tf.gotmpl new file mode 100644 index 00000000..47ab07e6 --- /dev/null +++ b/internal/outputs/terraform/device.tf.gotmpl @@ -0,0 +1,11 @@ +# terraform import equinix_metal_device.{{.Hostname}} {{.ID}} +resource "equinix_metal_device" "{{.Hostname}}" { + plan = "{{.Plan.Slug}}" + hostname = "{{.Hostname}}" + billing_cycle = "{{.BillingCycle}}" + metro = "{{.Metro.Code}}" + operating_system = "{{.OS.Slug}}" + project_id = "{{.Project.ID}}" + + tags = {{.Tags}} +} diff --git a/internal/outputs/terraform/format.go b/internal/outputs/terraform/format.go index 7651ece3..dd2d8a18 100644 --- a/internal/outputs/terraform/format.go +++ b/internal/outputs/terraform/format.go @@ -2,37 +2,43 @@ package terraform import ( "bytes" + _ "embed" "html/template" + "path" "github.com/packethost/packngo" ) -const deviceFormat = ` -# terraform import metal_device.{{.Hostname}} {{.ID}} -resource "metal_device" "{{.Hostname}}" { - plan = "{{.Plan.Slug}}" - hostname = "{{.Hostname}}" - billing_cycle = "{{.BillingCycle}}" - metro = "{{.Metro.Code}}" - operating_system = "{{.OS.Slug}}" - project_id = "{{.Project.ID}}" +var ( + //go:embed device.tf.gotmpl + deviceFormat string - tags = {{.Tags}} -} -` + //go:embed project.tf.gotmpl + projectFormat string +) func many(s string) string { return `{{range .}}` + s + `{{end}}` } + func Marshal(i interface{}) ([]byte, error) { - var f = "" + f := "" switch i.(type) { case *packngo.Device: f = deviceFormat case []packngo.Device: f = many(deviceFormat) + case *packngo.Project: + f = projectFormat + case []packngo.Project: + f = many(projectFormat) } - tmpl, err := template.New("terraform").Parse(f) + + tmpl, err := template.New("terraform").Funcs(template.FuncMap{ + "hrefToID": func(href string) string { + return path.Base(href) + }, + }).Parse(f) if err != nil { return nil, err } diff --git a/internal/outputs/terraform/project.tf.gotmpl b/internal/outputs/terraform/project.tf.gotmpl new file mode 100644 index 00000000..bd26da16 --- /dev/null +++ b/internal/outputs/terraform/project.tf.gotmpl @@ -0,0 +1,7 @@ +# terraform import equinix_metal_project.{{.Name}} {{.ID}} +resource "equinix_metal_project" "{{.Name}}" { + name = "{{.Name}}" + organization_id = "{{.Organization.URL | hrefToID}}" + # TODO: bgp_config +} + From e0da3088fcffd1ff84c3beaca9efd2188650289b Mon Sep 17 00:00:00 2001 From: ocobleseqx Date: Fri, 10 Nov 2023 20:38:16 +0100 Subject: [PATCH 4/6] update to metal-go and add all required fields and validations for Device Signed-off-by: ocobleseqx --- internal/outputs/terraform/device.tf.gotmpl | 35 +++-- internal/outputs/terraform/format.go | 28 ++-- internal/outputs/terraform/utils.go | 151 ++++++++++++++++++++ 3 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 internal/outputs/terraform/utils.go diff --git a/internal/outputs/terraform/device.tf.gotmpl b/internal/outputs/terraform/device.tf.gotmpl index 47ab07e6..f4a5c359 100644 --- a/internal/outputs/terraform/device.tf.gotmpl +++ b/internal/outputs/terraform/device.tf.gotmpl @@ -1,11 +1,26 @@ -# terraform import equinix_metal_device.{{.Hostname}} {{.ID}} -resource "equinix_metal_device" "{{.Hostname}}" { - plan = "{{.Plan.Slug}}" - hostname = "{{.Hostname}}" - billing_cycle = "{{.BillingCycle}}" - metro = "{{.Metro.Code}}" - operating_system = "{{.OS.Slug}}" - project_id = "{{.Project.ID}}" - - tags = {{.Tags}} +# terraform import equinix_metal_device.example {{.Id}} +resource "equinix_metal_device" "example" { + always_pxe = {{.AlwaysPxe}} + billing_cycle = {{.BillingCycle}} + custom_data = {{.Customdata | nullIfNilOrEmpty}} + description = {{.Description | nullIfNilOrEmpty}} + force_detach_volumes = false +{{- if .HardwareReservation }} + hardware_reservation_id = {{.HardwareReservation.Id }} +{{ else }} + hardware_reservation_id = null +{{- end }} + hostname = {{.Hostname}} + ipxe_script_url = {{.IpxeScriptUrl}} + metro = {{.Metro.Code}} + operating_system = {{.OperatingSystem.Slug}} + plan = {{.Plan.Slug}} + project_id = {{ hrefToID .Project.Href}} + project_ssh_key_ids = null + storage = {{.Storage | nullIfNilOrEmpty}} + tags = {{.Tags}} + termination_time = {{.TerminationTime | nullIfNilOrEmpty}} + user_data = {{.Userdata | nullIfNilOrEmpty}} # sensitive + user_ssh_key_ids = null + wait_for_reservation_deprovision = false } diff --git a/internal/outputs/terraform/format.go b/internal/outputs/terraform/format.go index dd2d8a18..73e3db99 100644 --- a/internal/outputs/terraform/format.go +++ b/internal/outputs/terraform/format.go @@ -3,10 +3,11 @@ package terraform import ( "bytes" _ "embed" + "fmt" + "html" "html/template" "path" - - "github.com/packethost/packngo" + metal "github.com/equinix-labs/metal-go/metal/v1" ) var ( @@ -23,21 +24,29 @@ func many(s string) string { func Marshal(i interface{}) ([]byte, error) { f := "" - switch i.(type) { - case *packngo.Device: + + switch v := i.(type) { + case *metal.Device: + fmt.Printf("single device") f = deviceFormat - case []packngo.Device: + case []metal.Device: + fmt.Printf("devices") f = many(deviceFormat) - case *packngo.Project: + case *metal.Project: f = projectFormat - case []packngo.Project: + case []metal.Project: f = many(projectFormat) + default: + return nil, fmt.Errorf("%v is not compatible with terraform output", v) } + addQuotesToString(i) + tmpl, err := template.New("terraform").Funcs(template.FuncMap{ "hrefToID": func(href string) string { - return path.Base(href) + return fmt.Sprintf("\"%s", path.Base(href)) }, + "nullIfNilOrEmpty": nullIfNilOrEmpty, }).Parse(f) if err != nil { return nil, err @@ -47,5 +56,6 @@ func Marshal(i interface{}) ([]byte, error) { if err != nil { return nil, err } - return buf.Bytes(), nil + result := html.UnescapeString(buf.String()) + return []byte(result), nil } diff --git a/internal/outputs/terraform/utils.go b/internal/outputs/terraform/utils.go new file mode 100644 index 00000000..d629c720 --- /dev/null +++ b/internal/outputs/terraform/utils.go @@ -0,0 +1,151 @@ +package terraform + +import ( + "fmt" + "reflect" + "time" +) + +func addQuotesToString(v interface{}) { + val := reflect.ValueOf(v) + + switch val.Kind() { + case reflect.Ptr: + val = val.Elem() + if val.Kind() != reflect.Struct { + return + } + + if val.Type() == reflect.TypeOf(new(string)) { + oldValue := val.Elem().String() + newValue := fmt.Sprintf(`"%s"`, oldValue) + val.Elem().SetString(newValue) + return + } + case reflect.String: + oldValue := val.String() + newValue := fmt.Sprintf(`"%s"`, oldValue) + val.SetString(newValue) + return + case reflect.Slice: + for i := 0; i < val.Len(); i++ { + elem := val.Index(i) + if elem.Kind() == reflect.Struct || (elem.Kind() == reflect.Ptr && elem.Elem().Kind() == reflect.Struct) { + addQuotesToString(elem.Interface()) + } + } + return + case reflect.Map: + for _, key := range val.MapKeys() { + elem := val.MapIndex(key) + if elem.Kind() == reflect.Struct || (elem.Kind() == reflect.Ptr && elem.Elem().Kind() == reflect.Struct) { + addQuotesToString(elem.Interface()) + } + } + return + default: + return + } + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + + switch field.Kind() { + case reflect.String: + oldValue := field.String() + newValue := fmt.Sprintf(`"%s"`, oldValue) + field.SetString(newValue) + case reflect.Ptr: + if field.IsNil() { + continue + } + // Check if the pointer is to a string + if field.Type().Elem() == reflect.TypeOf("") { + oldValue := field.Elem().String() + newValue := fmt.Sprintf(`"%s"`, oldValue) + field.Elem().SetString(newValue) + } else { + // Exclude *time.Time from recursion + if field.Type() != reflect.TypeOf(&time.Time{}) { + addQuotesToString(field.Interface()) + } + } + case reflect.Struct: + // Exclude time.Time from recursion + if field.Type() != reflect.TypeOf(time.Time{}) { + addQuotesToString(field.Interface()) + } + } + } +} + +func nullIfNilOrEmpty(v interface{}) interface{} { + if v == nil { + return "null" + } + + // Use reflection to check if the value is an empty value (e.g., empty string, empty slice, or empty map) + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.String: + if val.String() == "\"\"" { + return "null" + } + case reflect.Array, reflect.Slice, reflect.Map: + if val.Len() == 0 { + return "null" + } + case reflect.Ptr: + if val.IsNil() || val.IsZero() { + return "null" + } + + elem := val.Elem() + + // Check if it's a pointer to a string + if elem.Kind() == reflect.String && elem.String() == "\"\"" { + return "null" + } + + switch elem.Kind() { + case reflect.Struct: + if isPointerStructEmpty(elem) { + return "null" + } + case reflect.Array, reflect.Slice: + if elem.Len() == 0 { + return "null" + } + case reflect.Map: + if elem.Len() == 0 { + return "null" + } + } + } + + return v +} + +func isPointerStructEmpty(structVal reflect.Value) bool { + // Iterate through the struct fields + for i := 0; i < structVal.NumField(); i++ { + field := structVal.Field(i) + + // You can define custom logic to determine if a field is empty + // For example, check if a string field is empty, or if a slice/map field is empty + switch field.Kind() { + case reflect.String: + if field.String() != "" { + return false + } + case reflect.Slice, reflect.Map: + if field.Len() > 0 { + return false + } + // Add more cases for other field types as needed + } + } + + // All fields are empty + return true +} From f3fcc6bf177529fdd7cfe7169e2d954e940e2336 Mon Sep 17 00:00:00 2001 From: Chris Privitere <23177737+cprivitere@users.noreply.github.com> Date: Mon, 29 Jan 2024 16:47:31 -0600 Subject: [PATCH 5/6] chore: update to use equinix-sdk-go Signed-off-by: Chris Privitere <23177737+cprivitere@users.noreply.github.com> --- internal/outputs/terraform/format.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/outputs/terraform/format.go b/internal/outputs/terraform/format.go index 73e3db99..9bcdc772 100644 --- a/internal/outputs/terraform/format.go +++ b/internal/outputs/terraform/format.go @@ -7,7 +7,8 @@ import ( "html" "html/template" "path" - metal "github.com/equinix-labs/metal-go/metal/v1" + + metal "github.com/equinix/equinix-sdk-go/services/metalv1" ) var ( From 915eaa19dcda0e3d45148e330e4a41d195183ace Mon Sep 17 00:00:00 2001 From: Chris Privitere <23177737+cprivitere@users.noreply.github.com> Date: Mon, 29 Jan 2024 17:44:29 -0600 Subject: [PATCH 6/6] chore: update to use equinix-sdk-go in the crossplane function Signed-off-by: Chris Privitere <23177737+cprivitere@users.noreply.github.com> --- internal/outputs/crossplane/format.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/outputs/crossplane/format.go b/internal/outputs/crossplane/format.go index 995efd5a..5e364c3d 100644 --- a/internal/outputs/crossplane/format.go +++ b/internal/outputs/crossplane/format.go @@ -4,7 +4,7 @@ import ( "bytes" "html/template" - "github.com/packethost/packngo" + metal "github.com/equinix/equinix-sdk-go/services/metalv1" ) const ( @@ -51,10 +51,11 @@ func many(s string) string { func Marshal(i interface{}) ([]byte, error) { f := "" switch i.(type) { - case *packngo.Device: + case metal.Device: f = deviceFormat - case []packngo.Device: + case []metal.Device: f = many(deviceFormat) + } tmpl, err := template.New("crossplane").Parse(f) if err != nil {