Skip to content

Commit

Permalink
feat: metal_devices datasource (#366)
Browse files Browse the repository at this point in the history
This PR adds `metal_devices` datasource, which fetches lists of device,
similarly to
[metal_plans](https://github.com/equinix/terraform-provider-equinix/blob/main/equinix/data_source_metal_plans.go)
([doc](https://registry.terraform.io/providers/equinix/equinix/latest/docs/data-sources/equinix_metal_plans))

fixes #288 

The datasource is using metal-go.
  • Loading branch information
displague authored Sep 29, 2023
2 parents 0d5e50a + 53a91ca commit 77ecefd
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 1 deletion.
4 changes: 3 additions & 1 deletion docs/data-sources/equinix_metal_device.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ subcategory: "Metal"

# equinix_metal_device (Data Source)

Provides an Equinix Metal device datasource.
The datasource can be used to fetch a single device.

If you need to fetch a list of devices which meet filter criteria, you can use the [equinix_metal_devices](equinix_metal_devices.md) datasource.

~> **Note:** All arguments including the `root_password` and `user_data` will be stored in
the raw state as plain-text.
Expand Down
69 changes: 69 additions & 0 deletions docs/data-sources/equinix_metal_devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
subcategory: "Metal"
---

# equinix_metal_devices

The datasource can be used to find a list of devices which meet filter criteria.

If you need to fetch a single device by ID or by project ID and hostname, use the [equinix_metal_device](equinix_metal_device.md) datasource.

## Example Usage

```hcl
# Following example will select c3.small.x86 devices which are deplyed in metro 'da' (Dallas)
# OR 'sv' (Sillicon Valley).
data "equinix_metal_devices" "example" {
project_id = local.project_id
filter {
attribute = "plan"
values = ["c3.small.x86"]
}
filter {
attribute = "metro"
values = ["da", "sv"]
}
}
output "devices" {
organization_id = local.org_id
value = data.equinix_metal_devices.example.devices
}
```

```hcl
# Following example takes advantage of the `search` field in the API request, and will select devices with
# string "database" in one of the searched attributes. See `search` in argument reference.
data "equinix_metal_devices" "example" {
search = "database"
}
output "devices" {
value = data.equinix_metal_devices.example.devices
}
```

## search vs filter

The difference between `search` and `filter` is that `search` is an API parameter, interpreted by the Equinix Metal service. The "filter" arguments will reduce the API list (or search) results by applying client-side filtering, within this provider.

## Argument Reference

The following arguments are supported:

* `project_id` - (Optional) ID of project containing the devices. Exactly one of `project_id` and `organization_id` must be set.
* `organization_id` - (Optional) ID of organization containing the devices.
* `search` - (Optional) - Search string to filter devices by hostname, description, short_id, reservation short_id, tags, plan name, plan slug, facility code, facility name, operating system name, operating system slug, IP addresses.
* `filter` - (Optional) One or more attribute/values pairs to filter. List of atributes to filter can be found in the [attribute reference](equinix_metal_device.md#attributes-reference) of the `equinix_metal_device` datasource.
- `attribute` - (Required) The attribute used to filter. Filter attributes are case-sensitive
- `values` - (Required) The filter values. Filter values are case-sensitive. If you specify multiple values for a filter, the values are joined with an OR by default, and the request returns all results that match any of the specified values
- `match_by` - (Optional) The type of comparison to apply. One of: `in` , `re`, `substring`, `less_than`, `less_than_or_equal`, `greater_than`, `greater_than_or_equal`. Default is `in`.
- `all` - (Optional) If is set to true, the values are joined with an AND, and the requests returns only the results that match all specified values. Default is `false`.

All fields in the `devices` block defined below can be used as attribute for both `sort` and `filter` blocks.

## Attributes Reference

In addition to all arguments above, the following attributes are exported:

* `devices` - list of resources with attributes like in the [equninix_metal_device datasources](equinix_metal_device.md).
97 changes: 97 additions & 0 deletions equinix/data_source_metal_devices.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package equinix

import (
"context"
"fmt"

metalv1 "github.com/equinix-labs/metal-go/metal/v1"
"github.com/equinix/terraform-provider-equinix/equinix/internal/datalist"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func dataSourceMetalDevices() *schema.Resource {
dsmd := dataSourceMetalDevice()
sch := dsmd.Schema
for _, v := range sch {
if v.Optional {
v.Optional = false
}
if v.ConflictsWith != nil {
v.ConflictsWith = nil
}
}
dataListConfig := &datalist.ResourceConfig{
RecordSchema: sch,
ResultAttributeName: "devices",
ResultAttributeDescription: "List of devices that match specified filters",
FlattenRecord: flattenDevice,
GetRecords: getDevices,
ExtraQuerySchema: map[string]*schema.Schema{
"project_id": {
Type: schema.TypeString,
Description: "The id of the project to query for devices",
Optional: true,
ConflictsWith: []string{"organization_id"},
},
"organization_id": {
Type: schema.TypeString,
Description: "The id of the organization to query for devices",
Optional: true,
ConflictsWith: []string{"project_id"},
},
"search": {
Type: schema.TypeString,
Description: "Search string to filter devices by hostname, description, short_id, reservation short_id, tags, plan name, plan slug, facility code, facility name, operating system name, operating system slug, IP addresses.",
Optional: true,
},
},
}
return datalist.NewResource(dataListConfig)
}

func getDevices(meta interface{}, extra map[string]interface{}) ([]interface{}, error) {
client := meta.(*Config).metalgo
projectID := extra["project_id"].(string)
orgID := extra["organization_id"].(string)

if (len(projectID) == 0) && (len(orgID) == 0) {
return nil, fmt.Errorf("one of project_id or organization_id must be specified")
}

search := extra["search"].(string)

var devices *metalv1.DeviceList
devicesIf := []interface{}{}
var err error

if len(projectID) > 0 {
query := client.DevicesApi.FindProjectDevices(
context.Background(), projectID).Include(deviceCommonIncludes)
if len(search) > 0 {
query = query.Search(search)
}
devices, _, err = query.Execute()
}

if len(orgID) > 0 {
query := client.DevicesApi.FindOrganizationDevices(
context.Background(), orgID).Include(deviceCommonIncludes)
if len(search) > 0 {
query = query.Search(search)
}
devices, _, err = query.Execute()
}

for _, d := range devices.Devices {
devicesIf = append(devicesIf, d)
}
return devicesIf, err
}

func flattenDevice(rawDevice interface{}, meta interface{}, extra map[string]interface{}) (map[string]interface{}, error) {
device, ok := rawDevice.(metalv1.Device)
if !ok {
return nil, fmt.Errorf("expected device to be of type *metalv1.Device, got %T", rawDevice)
}
return getDeviceMap(device), nil
}
82 changes: 82 additions & 0 deletions equinix/data_source_metal_devices_acc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package equinix

import (
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)

func TestAccDataSourceMetalDevices(t *testing.T) {
projectName := fmt.Sprintf("ds-device-%s", acctest.RandString(10))

resource.ParallelTest(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ExternalProviders: testExternalProviders,
Providers: testAccProviders,
CheckDestroy: testAccMetalDeviceCheckDestroyed,
Steps: []resource.TestStep{
{
Config: testDataSourceMetalDevicesConfig_basic(projectName),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(
"data.equinix_metal_devices.test_filter_tags", "devices.#", "1"),
resource.TestCheckResourceAttr(
"data.equinix_metal_devices.test_search", "devices.#", "1"),
resource.TestCheckResourceAttrPair(
"equinix_metal_device.dev_tags", "id",
"data.equinix_metal_devices.test_filter_tags", "devices.0.device_id"),
resource.TestCheckResourceAttrPair(
"equinix_metal_device.dev_search", "id",
"data.equinix_metal_devices.test_search", "devices.0.device_id"),
),
},
},
})
}

func testDataSourceMetalDevicesConfig_basic(projSuffix string) string {
return fmt.Sprintf(`
%[1]s
resource "equinix_metal_project" "test" {
name = "tfacc-project-%[2]s"
}
resource "equinix_metal_device" "dev_tags" {
hostname = "tfacc-test-device1"
plan = local.plan
metro = local.metro
operating_system = local.os
billing_cycle = "hourly"
project_id = "${equinix_metal_project.test.id}"
termination_time = "%[3]s"
tags = ["tag1", "tag2"]
}
resource "equinix_metal_device" "dev_search" {
hostname = "tfacc-test-device2-unlikelystring"
plan = local.plan
metro = local.metro
operating_system = local.os
billing_cycle = "hourly"
project_id = "${equinix_metal_project.test.id}"
termination_time = "%[3]s"
}
data "equinix_metal_devices" "test_filter_tags" {
project_id = equinix_metal_project.test.id
filter {
attribute = "tags"
values = ["tag1"]
}
depends_on = [equinix_metal_device.dev_tags]
}
data "equinix_metal_devices" "test_search" {
project_id = equinix_metal_project.test.id
search = "unlikelystring"
depends_on = [equinix_metal_device.dev_search]
}`, confAccMetalDevice_base(preferable_plans, preferable_metros, preferable_os), projSuffix, testDeviceTerminationTime())
}
87 changes: 87 additions & 0 deletions equinix/helpers_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import (
"errors"
"fmt"
"log"
"path"
"sort"
"strings"
"sync"
"time"

metalv1 "github.com/equinix-labs/metal-go/metal/v1"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
Expand Down Expand Up @@ -65,6 +68,36 @@ type NetworkInfo struct {
PrivateIPv4 string
}

func getNetworkInfoMetalGo(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,
}
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
} else {
ni.PrivateIPv4 = *ip.Address
}
} else {
ni.PublicIPv6 = *ip.Address
}
}
}
return ni
}

func getNetworkInfo(ips []*packngo.IPAddressAssignment) NetworkInfo {
ni := NetworkInfo{Networks: make([]map[string]interface{}, 0, 1)}
for _, ip := range ips {
Expand Down Expand Up @@ -107,6 +140,21 @@ 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{} {
ret := make([]map[string]interface{}, 0, 1)
for _, p := range ps {
Expand Down Expand Up @@ -221,3 +269,42 @@ func ipAddressSchema() *schema.Resource {
},
}
}

func getDeviceMap(device metalv1.Device) map[string]interface{} {
networkInfo := getNetworkInfoMetalGo(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)
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)

return map[string]interface{}{
"hostname": device.GetHostname(),
"project_id": device.Project.GetId(),
"description": device.GetDescription(),
"device_id": device.GetId(),
"facility": device.Facility.GetCode(),
"metro": device.Metro.GetCode(),
"plan": device.Plan.GetSlug(),
"operating_system": device.OperatingSystem.GetSlug(),
"state": device.GetState(),
"billing_cycle": device.GetBillingCycle(),
"ipxe_script_url": device.GetIpxeScriptUrl(),
"always_pxe": device.GetAlwaysPxe(),
"root_password": device.GetRootPassword(),
"tags": stringArrToIfArr(device.GetTags()),
"access_public_ipv6": networkInfo.PublicIPv6,
"access_public_ipv4": networkInfo.PublicIPv4,
"access_private_ipv4": networkInfo.PrivateIPv4,
"network": networkInfo.Networks,
"ssh_key_ids": keyIDs,
"ports": ports,
}
}
1 change: 1 addition & 0 deletions equinix/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ func Provider() *schema.Provider {
"equinix_metal_organization": dataSourceMetalOrganization(),
"equinix_metal_spot_market_price": dataSourceSpotMarketPrice(),
"equinix_metal_device": dataSourceMetalDevice(),
"equinix_metal_devices": dataSourceMetalDevices(),
"equinix_metal_device_bgp_neighbors": dataSourceMetalDeviceBGPNeighbors(),
"equinix_metal_plans": dataSourceMetalPlans(),
"equinix_metal_port": dataSourceMetalPort(),
Expand Down

0 comments on commit 77ecefd

Please sign in to comment.