diff --git a/internal/cli/root.go b/internal/cli/root.go index e96d95bc..872a5135 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -234,6 +234,8 @@ func (c *Client) Format() outputPkg.Format { case "": 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..5e364c3d --- /dev/null +++ b/internal/outputs/crossplane/format.go @@ -0,0 +1,70 @@ +package crossplane + +import ( + "bytes" + "html/template" + + metal "github.com/equinix/equinix-sdk-go/services/metalv1" +) + +const ( + deviceFormat = ` +--- +apiVersion: devices.metal.equinix.jet.crossplane.io/v1alpha1 +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) { + f := "" + switch i.(type) { + case metal.Device: + f = deviceFormat + case []metal.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 53386c35..48e1842d 100644 --- a/internal/outputs/outputs.go +++ b/internal/outputs/outputs.go @@ -7,14 +7,19 @@ 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" + FormatTable Format = "table" + FormatJSON Format = "json" + FormatYAML Format = "yaml" + FormatTerraform Format = "tf" + FormatCrossplane Format = "crossplane" ) type Outputer interface { @@ -26,6 +31,24 @@ 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 { + return err + } + fmt.Println(string(output)) + return nil +} + func outputJSON(in interface{}) error { output, err := json.MarshalIndent(in, "", " ") if err != nil { @@ -49,6 +72,10 @@ 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 if o.Format == FormatCrossplane { + return outputCrossplane(in) } else { table := tablewriter.NewWriter(os.Stdout) table.SetAutoWrapText(false) diff --git a/internal/outputs/terraform/device.tf.gotmpl b/internal/outputs/terraform/device.tf.gotmpl new file mode 100644 index 00000000..f4a5c359 --- /dev/null +++ b/internal/outputs/terraform/device.tf.gotmpl @@ -0,0 +1,26 @@ +# 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 new file mode 100644 index 00000000..9bcdc772 --- /dev/null +++ b/internal/outputs/terraform/format.go @@ -0,0 +1,62 @@ +package terraform + +import ( + "bytes" + _ "embed" + "fmt" + "html" + "html/template" + "path" + + metal "github.com/equinix/equinix-sdk-go/services/metalv1" +) + +var ( + //go:embed device.tf.gotmpl + deviceFormat string + + //go:embed project.tf.gotmpl + projectFormat string +) + +func many(s string) string { + return `{{range .}}` + s + `{{end}}` +} + +func Marshal(i interface{}) ([]byte, error) { + f := "" + + switch v := i.(type) { + case *metal.Device: + fmt.Printf("single device") + f = deviceFormat + case []metal.Device: + fmt.Printf("devices") + f = many(deviceFormat) + case *metal.Project: + f = projectFormat + 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 fmt.Sprintf("\"%s", path.Base(href)) + }, + "nullIfNilOrEmpty": nullIfNilOrEmpty, + }).Parse(f) + if err != nil { + return nil, err + } + buf := new(bytes.Buffer) + err = tmpl.Execute(buf, i) + if err != nil { + return nil, err + } + result := html.UnescapeString(buf.String()) + return []byte(result), nil +} 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 +} + 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 +}