Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use metal-go for device reads (data source and resource) #291

Merged
merged 11 commits into from
Oct 25, 2023
1 change: 1 addition & 0 deletions docs/data-sources/equinix_metal_device.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ In addition to all arguments above, the following attributes are exported:
* `ports` - List of ports assigned to the device. See [Ports Attribute](#ports-attribute) below for
more details.
* `root_password` - Root password to the server (if still available).
* `sos_hostname` - The hostname to use for [Serial over SSH](https://deploy.equinix.com/developers/docs/metal/resilience-recovery/serial-over-ssh/) access to the device
* `ssh_key_ids` - List of IDs of SSH keys deployed in the device, can be both user or project SSH keys.
* `state` - The state of the device.
* `tags` - Tags attached to the device.
Expand Down
1 change: 1 addition & 0 deletions docs/resources/equinix_metal_device.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ See [network_types guide](../guides/network_types.md) for more info.
more details.
* `project_id` - The ID of the project the device belongs to.
* `root_password` - Root password to the server (disabled after 24 hours).
* `sos_hostname` - The hostname to use for [Serial over SSH](https://deploy.equinix.com/developers/docs/metal/resilience-recovery/serial-over-ssh/) access to the device
* `ssh_key_ids` - List of IDs of SSH keys deployed in the device, can be both user and project SSH keys.
* `state` - The status of the device.
* `tags` - Tags attached to the device.
Expand Down
69 changes: 39 additions & 30 deletions equinix/data_source_metal_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import (
"sort"
"strings"

metalv1 "github.com/equinix-labs/metal-go/metal/v1"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure"
"github.com/packethost/packngo"
)

func dataSourceMetalDevice() *schema.Resource {
Expand Down Expand Up @@ -198,12 +198,17 @@ func dataSourceMetalDevice() *schema.Resource {
},
},
},
"sos_hostname": {
ctreatma marked this conversation as resolved.
Show resolved Hide resolved
Type: schema.TypeString,
Description: "The hostname to use for [Serial over SSH](https://deploy.equinix.com/developers/docs/metal/resilience-recovery/serial-over-ssh/) access to the device",
Computed: true,
},
},
}
}

func dataSourceMetalDeviceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) error {
client := meta.(*Config).metal
client := meta.(*Config).metalgo

hostnameRaw, hostnameOK := d.GetOk("hostname")
projectIdRaw, projectIdOK := d.GetOk("project_id")
Expand All @@ -212,17 +217,16 @@ func dataSourceMetalDeviceRead(ctx context.Context, d *schema.ResourceData, meta
if !deviceIdOK && !hostnameOK {
return fmt.Errorf("You must supply device_id or hostname")
}
var device *packngo.Device
var device *metalv1.Device

if hostnameOK {
if !projectIdOK {
return fmt.Errorf("If you lookup via hostname, you must supply project_id")
}
hostname := hostnameRaw.(string)
projectId := projectIdRaw.(string)

ds, _, err := client.Devices.List(
projectId,
&packngo.ListOptions{Search: hostname, Includes: deviceCommonIncludes})
ds, _, err := client.DevicesApi.FindProjectDevices(ctx, projectId).Hostname(hostname).Include(deviceCommonIncludes).Execute()
if err != nil {
return err
}
Expand All @@ -234,26 +238,28 @@ func dataSourceMetalDeviceRead(ctx context.Context, d *schema.ResourceData, meta
} else {
deviceId := deviceIdRaw.(string)
var err error
device, _, err = client.Devices.Get(deviceId, deviceReadOptions)
device, _, err = client.DevicesApi.FindDeviceById(ctx, deviceId).Include(deviceCommonIncludes).Execute()
if err != nil {
return err
}
}

d.Set("hostname", device.Hostname)
d.Set("project_id", device.Project.ID)
d.Set("device_id", device.ID)
d.Set("hostname", device.GetHostname())
d.Set("project_id", device.Project.GetId())
d.Set("device_id", device.GetId())
d.Set("plan", device.Plan.Slug)
d.Set("facility", device.Facility.Code)
if device.Metro != nil {
d.Set("metro", strings.ToLower(device.Metro.Code))
d.Set("metro", strings.ToLower(device.Metro.GetCode()))
}
d.Set("operating_system", device.OS.Slug)
d.Set("state", device.State)
d.Set("billing_cycle", device.BillingCycle)
d.Set("ipxe_script_url", device.IPXEScriptURL)
d.Set("always_pxe", device.AlwaysPXE)
d.Set("root_password", device.RootPassword)
d.Set("operating_system", device.OperatingSystem.GetSlug())
d.Set("state", device.GetState())
d.Set("billing_cycle", device.GetBillingCycle())
d.Set("ipxe_script_url", device.GetIpxeScriptUrl())
d.Set("always_pxe", device.GetAlwaysPxe())
d.Set("root_password", device.GetRootPassword())
d.Set("sos_hostname", device.GetSos())

if device.Storage != nil {
rawStorageBytes, err := json.Marshal(device.Storage)
if err != nil {
Expand All @@ -268,27 +274,30 @@ func dataSourceMetalDeviceRead(ctx context.Context, d *schema.ResourceData, meta
}

if device.HardwareReservation != nil {
d.Set("hardware_reservation_id", device.HardwareReservation.ID)
d.Set("hardware_reservation_id", device.HardwareReservation.GetId())
}
networkType, err := getNetworkType(device)
if err != nil {
return fmt.Errorf("[ERR] Error computing network type for device (%s): %s", d.Id(), err)
}
networkType := device.GetNetworkType()

d.Set("network_type", networkType)

d.Set("tags", device.Tags)

keyIDs := []string{}
for _, k := range device.SSHKeys {
keyIDs = append(keyIDs, path.Base(k.URL))
for _, k := range device.SshKeys {
keyIDs = append(keyIDs, path.Base(k.Href))
}
d.Set("ssh_key_ids", keyIDs)
networkInfo := getNetworkInfo(device.Network)
networkInfo := getNetworkInfo(device.IpAddresses)

sort.SliceStable(networkInfo.Networks, func(i, j int) bool {
famI := networkInfo.Networks[i]["family"].(int)
famJ := networkInfo.Networks[j]["family"].(int)
famI := networkInfo.Networks[i]["family"].(int32)
famJ := networkInfo.Networks[j]["family"].(int32)
pubI := networkInfo.Networks[i]["public"].(bool)
pubJ := networkInfo.Networks[j]["public"].(bool)
return getNetworkRank(famI, pubI) < getNetworkRank(famJ, pubJ)
return getNetworkRank(int(famI), pubI) < getNetworkRank(int(famJ), pubJ)
})

d.Set("network", networkInfo.Networks)
Expand All @@ -299,14 +308,14 @@ func dataSourceMetalDeviceRead(ctx context.Context, d *schema.ResourceData, meta
ports := getPorts(device.NetworkPorts)
d.Set("ports", ports)

d.SetId(device.ID)
d.SetId(device.GetId())
return nil
}

func findDeviceByHostname(devices []packngo.Device, hostname string) (*packngo.Device, error) {
results := make([]packngo.Device, 0)
for _, d := range devices {
if d.Hostname == hostname {
func findDeviceByHostname(devices *metalv1.DeviceList, hostname string) (*metalv1.Device, error) {
results := make([]metalv1.Device, 0)
for _, d := range devices.GetDevices() {
if *d.Hostname == hostname {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: presumably this will never be null in a returned devices list, so this should be fine. GetHostname() is an option.

results = append(results, d)
}
}
Expand Down
40 changes: 25 additions & 15 deletions equinix/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,35 @@ func friendlyError(err error) error {
if 0 == len(errors) {
errors = Errors{e.SingleError}
}
er := &ErrorResponse{
StatusCode: resp.StatusCode,
Errors: errors,
}
respHead := resp.Header

// this checks if the error comes from API (and not from cache/LB)
if len(errors) > 0 {
ct := respHead.Get("Content-Type")
xrid := respHead.Get("X-Request-Id")
if strings.Contains(ct, "application/json") && len(xrid) > 0 {
er.IsAPIError = true
}
}
return er

return convertToFriendlyError(errors, resp)
}
return err
}

func friendlyErrorForMetalGo(err error, resp *http.Response) error {
errors := Errors([]string{err.Error()})
return convertToFriendlyError(errors, resp)
}

func convertToFriendlyError(errors Errors, resp *http.Response) error {
er := &ErrorResponse{
StatusCode: resp.StatusCode,
Errors: errors,
}
respHead := resp.Header

// this checks if the error comes from API (and not from cache/LB)
if len(errors) > 0 {
ct := respHead.Get("Content-Type")
xrid := respHead.Get("X-Request-Id")
if strings.Contains(ct, "application/json") && len(xrid) > 0 {
er.IsAPIError = true
}
}
return er
}

func isForbidden(err error) bool {
r, ok := err.(*packngo.ErrorResponse)
if ok && r.Response != nil {
Expand Down
103 changes: 36 additions & 67 deletions equinix/helpers_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package equinix

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
Expand Down Expand Up @@ -68,64 +69,46 @@ type NetworkInfo struct {
PrivateIPv4 string
}

func getNetworkInfoMetalGo(ips []metalv1.IPAssignment) NetworkInfo {
func getNetworkInfo(ips []metalv1.IPAssignment) NetworkInfo {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cleaned up the unnecessary MetalGo suffixes from helper methods, but it does make some of these changes harder to review. I can back out the name change if that's preferred, but I'm going to instead try to annotate the current changes to guide review.

ni := NetworkInfo{Networks: make([]map[string]interface{}, 0, 1)}
for _, ip := range ips {
network := map[string]interface{}{
"address": ip.Address,
"gateway": ip.Gateway,
"family": ip.AddressFamily,
"cidr": ip.Cidr,
"public": ip.Public,
"address": ip.GetAddress(),
Copy link
Contributor Author

@ctreatma ctreatma Sep 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is replacing direct references to the ip struct properties with calls to the Get...() functions. The struct properties are pointers and the Get...() functions dereference the pointers (returning the zero value if the pointer is nil), so the Get...() functions better reflect how the previous packngo implementation worked.

"gateway": ip.GetGateway(),
"family": ip.GetAddressFamily(),
"cidr": ip.GetCidr(),
"public": ip.GetPublic(),
}
ni.Networks = append(ni.Networks, network)

// Initial device IPs are fixed and marked as "Management"
if *ip.Management {
if *ip.AddressFamily == 4 {
if *ip.Public {
ni.Host = *ip.Address
ni.IPv4SubnetSize = int(*ip.Cidr)
ni.PublicIPv4 = *ip.Address
if ip.GetManagement() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is replacing pointer dereferences with Get...() calls for better protection against nil pointer errors.

if ip.GetAddressFamily() == 4 {
if ip.GetPublic() {
ni.Host = ip.GetAddress()
ni.IPv4SubnetSize = int(ip.GetCidr())
ni.PublicIPv4 = ip.GetAddress()
} else {
ni.PrivateIPv4 = *ip.Address
ni.PrivateIPv4 = ip.GetAddress()
}
} else {
ni.PublicIPv6 = *ip.Address
ni.PublicIPv6 = ip.GetAddress()
}
}
}
return ni
}

func getNetworkInfo(ips []*packngo.IPAddressAssignment) NetworkInfo {
ni := NetworkInfo{Networks: make([]map[string]interface{}, 0, 1)}
for _, ip := range ips {
network := map[string]interface{}{
"address": ip.Address,
"gateway": ip.Gateway,
"family": ip.AddressFamily,
"cidr": ip.CIDR,
"public": ip.Public,
}
ni.Networks = append(ni.Networks, network)

// Initial device IPs are fixed and marked as "Management"
if ip.Management {
if ip.AddressFamily == 4 {
if ip.Public {
ni.Host = ip.Address
ni.IPv4SubnetSize = ip.CIDR
ni.PublicIPv4 = ip.Address
} else {
ni.PrivateIPv4 = ip.Address
}
} else {
ni.PublicIPv6 = ip.Address
}
func getNetworkType(device *metalv1.Device) (*string, error) {
pgDevice := packngo.Device{}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NetworkType device attribute exposed by packngo is not an API-provided attribute; this serves as a shim so we can use the packngo logic to calculate NetworkType. We could instead either duplicate the complete packngo logic here or copy it into metal-go, but my understanding is that NetworkType is not something we want to keep around anyway, so this shim is designed to get us through the SDK transition and then be eliminated when packngo is no longer used in the provider.

res, err := device.MarshalJSON()
if err == nil {
if err = json.Unmarshal(res, &pgDevice); err == nil {
networkType := pgDevice.GetNetworkType()
return &networkType, nil
}
}
return ni
return nil, err
}

func getNetworkRank(family int, public bool) int {
Expand All @@ -140,30 +123,15 @@ func getNetworkRank(family int, public bool) int {
return 3
}

func getPortsMetalGo(ps []metalv1.Port) []map[string]interface{} {
ret := make([]map[string]interface{}, 0, 1)
for _, p := range ps {
port := map[string]interface{}{
"name": p.Name,
"id": p.Id,
"type": p.Type,
"mac": p.Data.Mac,
"bonded": p.Data.Bonded,
}
ret = append(ret, port)
}
return ret
}

func getPorts(ps []packngo.Port) []map[string]interface{} {
func getPorts(ps []metalv1.Port) []map[string]interface{} {
ret := make([]map[string]interface{}, 0, 1)
for _, p := range ps {
port := map[string]interface{}{
"name": p.Name,
"id": p.ID,
"type": p.Type,
"mac": p.Data.MAC,
"bonded": p.Data.Bonded,
"name": p.GetName(),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with getNetworkInfo, this change replaces the innards of getPorts with those of getPortsMetalGo while also updating the function to use Get...() helpers, since those helpers more closely match the behavior of the packngo implementation.

"id": p.GetId(),
"type": p.GetType(),
"mac": p.Data.GetMac(),
"bonded": p.Data.GetBonded(),
}
ret = append(ret, port)
}
Expand Down Expand Up @@ -271,19 +239,19 @@ func ipAddressSchema() *schema.Resource {
}

func getDeviceMap(device metalv1.Device) map[string]interface{} {
networkInfo := getNetworkInfoMetalGo(device.IpAddresses)
networkInfo := getNetworkInfo(device.IpAddresses)
sort.SliceStable(networkInfo.Networks, func(i, j int) bool {
famI := int(*networkInfo.Networks[i]["family"].(*int32))
famJ := int(*networkInfo.Networks[j]["family"].(*int32))
pubI := *networkInfo.Networks[i]["public"].(*bool)
pubJ := *networkInfo.Networks[j]["public"].(*bool)
famI := int(networkInfo.Networks[i]["family"].(int32))
famJ := int(networkInfo.Networks[j]["family"].(int32))
pubI := networkInfo.Networks[i]["public"].(bool)
pubJ := networkInfo.Networks[j]["public"].(bool)
return getNetworkRank(famI, pubI) < getNetworkRank(famJ, pubJ)
})
keyIDs := []string{}
for _, k := range device.SshKeys {
keyIDs = append(keyIDs, path.Base(k.GetHref()))
}
ports := getPortsMetalGo(device.NetworkPorts)
ports := getPorts(device.NetworkPorts)

return map[string]interface{}{
"hostname": device.GetHostname(),
Expand All @@ -306,5 +274,6 @@ func getDeviceMap(device metalv1.Device) map[string]interface{} {
"network": networkInfo.Networks,
"ssh_key_ids": keyIDs,
"ports": ports,
"sos_hostname": device.GetSos(),
}
}
Loading
Loading