Skip to content

Commit

Permalink
feat: use metal-go for device reads (data source and resource) (#291)
Browse files Browse the repository at this point in the history
This updates the device data source to use `metal-go` instead of
`packngo` and also updates the device resource to use `metal-go`
for read operations.  As part of this migration, the device data source
and resource gain the new `sos_hostname` attribute which enables
customers to reduce their reliance on the deprecated `facility`
attribute,
which was previously necessary in order to compute the SOS hostname
for a device.
  • Loading branch information
displague authored Oct 25, 2023
2 parents b08510a + e378823 commit 7f62d83
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 141 deletions.
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": {
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 {
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 {
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(),
"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() {
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{}
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(),
"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

0 comments on commit 7f62d83

Please sign in to comment.