diff --git a/docs/data-sources/device.md b/docs/data-sources/device.md new file mode 100644 index 00000000..91254dde --- /dev/null +++ b/docs/data-sources/device.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "maas_device Data Source - terraform-provider-maas" +subcategory: "" +description: |- + Provides details about an existing MAAS device. +--- + +# maas_device (Data Source) + +Provides details about an existing MAAS device. + +## Example Usage + +```terraform +resource "maas_dns_domain" "test_domain" { + name = "domain" + ttl = 3600 + authoritative = true +} + +resource "maas_device" "test_device" { + description = "Test description" + domain = "maas" + hostname = "test-device" + zone = "default" + network_interfaces { + mac_address = "12:23:45:67:89:de" + } +} + +data "maas_device" "test_device" { + hostname = maas_device.test_device.hostname +} +``` + + +## Schema + +### Required + +- `hostname` (String) The device hostname. + +### Read-Only + +- `description` (String) The description of the device. +- `domain` (String) The domain of the device. +- `fqdn` (String) The device FQDN. +- `id` (String) The ID of this resource. +- `ip_addresses` (Set of String) A set of IP addressed assigned to the device. +- `network_interfaces` (Set of Object) A set of network interfaces attached to the device. (see [below for nested schema](#nestedatt--network_interfaces)) +- `owner` (String) The owner of the device. +- `zone` (String) The zone of the device. + + +### Nested Schema for `network_interfaces` + +Read-Only: + +- `id` (Number) +- `mac_address` (String) +- `name` (String) diff --git a/docs/resources/device.md b/docs/resources/device.md new file mode 100644 index 00000000..f5efaac9 --- /dev/null +++ b/docs/resources/device.md @@ -0,0 +1,73 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "maas_device Resource - terraform-provider-maas" +subcategory: "" +description: |- + Provides a resource to manage MAAS devices. +--- + +# maas_device (Resource) + +Provides a resource to manage MAAS devices. + +## Example Usage + +```terraform +resource "maas_dns_domain" "test_domain" { + name = "domain" + ttl = 3600 + authoritative = true +} + +resource "maas_device" "test_device" { + description = "Test description" + domain = maas_dns_domain.test_domain.name + hostname = "test-device" + zone = "default" + network_interfaces { + mac_address = "12:23:45:67:89:de" + } +} +``` + + +## Schema + +### Required + +- `network_interfaces` (Block Set, Min: 1) A set of network interfaces attached to the device. (see [below for nested schema](#nestedblock--network_interfaces)) + +### Optional + +- `description` (String) The description of the device. +- `domain` (String) The domain of the device. +- `hostname` (String) The device hostname. +- `zone` (String) The zone of the device. + +### Read-Only + +- `fqdn` (String) The device FQDN. +- `id` (String) The ID of this resource. +- `ip_addresses` (Set of String) A set of IP addressed assigned to the device. +- `owner` (String) The owner of the device. + + +### Nested Schema for `network_interfaces` + +Required: + +- `mac_address` (String) MAC address of the network interface. + +Read-Only: + +- `id` (Number) The id of the network interface. +- `name` (String) The name of the network interface. + +## Import + +Import is supported using the following syntax: + +```shell +# Devices can be imported using their ID or hostname. e.g. +$ terraform import maas_device.test_device test-device +``` diff --git a/examples/data-sources/maas_device/data-source.tf b/examples/data-sources/maas_device/data-source.tf new file mode 100644 index 00000000..53eb5b53 --- /dev/null +++ b/examples/data-sources/maas_device/data-source.tf @@ -0,0 +1,19 @@ +resource "maas_dns_domain" "test_domain" { + name = "domain" + ttl = 3600 + authoritative = true +} + +resource "maas_device" "test_device" { + description = "Test description" + domain = "maas" + hostname = "test-device" + zone = "default" + network_interfaces { + mac_address = "12:23:45:67:89:de" + } +} + +data "maas_device" "test_device" { + hostname = maas_device.test_device.hostname +} diff --git a/examples/resources/maas_device/import.sh b/examples/resources/maas_device/import.sh new file mode 100644 index 00000000..3f42eeff --- /dev/null +++ b/examples/resources/maas_device/import.sh @@ -0,0 +1,2 @@ +# Devices can be imported using their ID or hostname. e.g. +$ terraform import maas_device.test_device test-device diff --git a/examples/resources/maas_device/resource.tf b/examples/resources/maas_device/resource.tf new file mode 100644 index 00000000..12cb4a77 --- /dev/null +++ b/examples/resources/maas_device/resource.tf @@ -0,0 +1,15 @@ +resource "maas_dns_domain" "test_domain" { + name = "domain" + ttl = 3600 + authoritative = true +} + +resource "maas_device" "test_device" { + description = "Test description" + domain = maas_dns_domain.test_domain.name + hostname = "test-device" + zone = "default" + network_interfaces { + mac_address = "12:23:45:67:89:de" + } +} diff --git a/maas/data_source_maas_device.go b/maas/data_source_maas_device.go new file mode 100644 index 00000000..2e239865 --- /dev/null +++ b/maas/data_source_maas_device.go @@ -0,0 +1,147 @@ +package maas + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/maas/gomaasclient/client" + "github.com/maas/gomaasclient/entity" +) + +func dataSourceMaasDevice() *schema.Resource { + return &schema.Resource{ + Description: "Provides details about an existing MAAS device.", + ReadContext: dataSourceDeviceRead, + + Schema: map[string]*schema.Schema{ + "description": { + Type: schema.TypeString, + Computed: true, + Description: "The description of the device.", + }, + "domain": { + Type: schema.TypeString, + Computed: true, + Description: "The domain of the device.", + }, + "fqdn": { + Type: schema.TypeString, + Computed: true, + Description: "The device FQDN.", + }, + "hostname": { + Type: schema.TypeString, + Required: true, + Description: "The device hostname.", + }, + "ip_addresses": { + Type: schema.TypeSet, + Computed: true, + Description: "A set of IP addressed assigned to the device.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "network_interfaces": { + Type: schema.TypeSet, + Computed: true, + Description: "A set of network interfaces attached to the device.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The id of the network interface.", + }, + "mac_address": { + Type: schema.TypeString, + Computed: true, + Description: "MAC address of the network interface.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the network interface.", + }, + }, + }, + }, + "owner": { + Type: schema.TypeString, + Computed: true, + Description: "The owner of the device.", + }, + "zone": { + Type: schema.TypeString, + Computed: true, + Description: "The zone of the device.", + }, + }, + } +} + +func dataSourceDeviceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*client.Client) + + device, err := getDevice(client, d.Get("hostname").(string)) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(device.SystemID) + + d.Set("description", device.Description) + d.Set("domain", device.Domain.Name) + d.Set("fqdn", device.FQDN) + d.Set("hostname", device.Hostname) + d.Set("owner", device.Owner) + d.Set("zone", device.Zone.Name) + + ipAddresses := make([]string, len(device.IPAddresses)) + for i, ip := range device.IPAddresses { + ipAddresses[i] = ip.String() + } + if err := d.Set("ip_addresses", ipAddresses); err != nil { + return diag.FromErr(err) + } + + networkInterfaces := make([]map[string]interface{}, len(device.InterfaceSet)) + for i, networkInterface := range device.InterfaceSet { + networkInterfaces[i] = map[string]interface{}{ + "id": networkInterface.ID, + "mac_address": networkInterface.MACAddress, + "name": networkInterface.Name, + } + } + if err := d.Set("network_interfaces", networkInterfaces); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func getDevice(client *client.Client, identifier string) (*entity.Device, error) { + device, err := findDevice(client, identifier) + if err != nil { + return nil, err + } + if device == nil { + return nil, fmt.Errorf("device (%s) was not found", identifier) + } + return device, nil +} + +func findDevice(client *client.Client, identifier string) (*entity.Device, error) { + devices, err := client.Devices.Get() + if err != nil { + return nil, err + } + for _, d := range devices { + if d.SystemID == identifier || d.Hostname == identifier { + return &d, nil + } + } + return nil, nil +} diff --git a/maas/data_source_maas_device_test.go b/maas/data_source_maas_device_test.go new file mode 100644 index 00000000..2b7fbb43 --- /dev/null +++ b/maas/data_source_maas_device_test.go @@ -0,0 +1,58 @@ +package maas_test + +import ( + "fmt" + "terraform-provider-maas/maas/testutils" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/maas/gomaasclient/entity" +) + +func TestAccDataSourceMaasDevice_basic(t *testing.T) { + + var device entity.Device + description := "Test description" + domain := "test-data-domain" + hostname := "test-data-device" + zone := "default" + mac_address := "12:23:45:67:89:fa" + + checks := []resource.TestCheckFunc{ + testAccMaasDeviceCheckExists("maas_device.test", &device), + resource.TestCheckResourceAttr("maas_device.test", "description", description), + resource.TestCheckResourceAttr("maas_device.test", "domain", domain), + resource.TestCheckResourceAttr("maas_device.test", "fqdn", fmt.Sprintf("%s.%s", hostname, domain)), + resource.TestCheckResourceAttr("maas_device.test", "hostname", hostname), + resource.TestCheckResourceAttr("maas_device.test", "zone", zone), + resource.TestCheckResourceAttr("maas_device.test", "ip_addresses.#", "0"), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.#", "1"), + resource.TestCheckResourceAttrSet("maas_device.test", "network_interfaces.0.id"), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.0.mac_address", mac_address), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.0.name", "eth0"), + resource.TestCheckResourceAttrSet("maas_device.test", "owner"), + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testutils.PreCheck(t) }, + Providers: testutils.TestAccProviders, + CheckDestroy: testAccCheckMaasDeviceDestroy, + ErrorCheck: func(err error) error { return err }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceMaasDevice(description, domain, hostname, zone, mac_address), + Check: resource.ComposeTestCheckFunc(checks...), + }, + }, + }) +} + +func testAccDataSourceMaasDevice(description string, domain string, hostname string, zone string, mac_address string) string { + return fmt.Sprintf(` +%s + +data "maas_device" "test" { + hostname = maas_device.test.hostname +} +`, testAccMaasDevice(description, domain, hostname, zone, mac_address)) +} diff --git a/maas/provider.go b/maas/provider.go index 3e94eaf7..04fe2e4a 100644 --- a/maas/provider.go +++ b/maas/provider.go @@ -44,6 +44,7 @@ func Provider() *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ + "maas_device": resourceMaasDevice(), "maas_instance": resourceMaasInstance(), "maas_vm_host": resourceMaasVMHost(), "maas_vm_host_machine": resourceMaasVMHostMachine(), @@ -67,6 +68,7 @@ func Provider() *schema.Provider { "maas_subnet": dataSourceMaasSubnet(), "maas_machine": dataSourceMaasMachine(), "maas_network_interface_physical": dataSourceMaasNetworkInterfacePhysical(), + "maas_device": dataSourceMaasDevice(), }, ConfigureContextFunc: providerConfigure, } diff --git a/maas/provider_test.go b/maas/provider_test.go index b8d7ea4d..7150de4f 100644 --- a/maas/provider_test.go +++ b/maas/provider_test.go @@ -1,17 +1,18 @@ -package maas +package maas_test import ( + "terraform-provider-maas/maas" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func TestProvider(t *testing.T) { - if err := Provider().InternalValidate(); err != nil { + if err := maas.Provider().InternalValidate(); err != nil { t.Fatalf("err: %s", err) } } func TestProvider_impl(t *testing.T) { - var _ *schema.Provider = Provider() + var _ *schema.Provider = maas.Provider() } diff --git a/maas/resource_maas_device.go b/maas/resource_maas_device.go new file mode 100644 index 00000000..2a9b3def --- /dev/null +++ b/maas/resource_maas_device.go @@ -0,0 +1,191 @@ +package maas + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/maas/gomaasclient/client" + "github.com/maas/gomaasclient/entity" +) + +func resourceMaasDevice() *schema.Resource { + return &schema.Resource{ + Description: "Provides a resource to manage MAAS devices.", + CreateContext: resourceDeviceCreate, + ReadContext: resourceDeviceRead, + UpdateContext: resourceDeviceUpdate, + DeleteContext: resourceDeviceDelete, + Importer: &schema.ResourceImporter{ + StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + client := meta.(*client.Client) + device, err := getDevice(client, d.Id()) + if err != nil { + return nil, err + } + d.SetId(device.SystemID) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "description": { + Type: schema.TypeString, + Optional: true, + Description: "The description of the device.", + }, + "domain": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The domain of the device.", + }, + "fqdn": { + Type: schema.TypeString, + Computed: true, + Description: "The device FQDN.", + }, + "hostname": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The device hostname.", + }, + "ip_addresses": { + Type: schema.TypeSet, + Computed: true, + Description: "A set of IP addressed assigned to the device.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "network_interfaces": { + Type: schema.TypeSet, + Required: true, + Description: "A set of network interfaces attached to the device.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The id of the network interface.", + }, + "mac_address": { + Type: schema.TypeString, + Required: true, + Description: "MAC address of the network interface.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the network interface.", + }, + }, + }, + }, + "owner": { + Type: schema.TypeString, + Computed: true, + Description: "The owner of the device.", + }, + "zone": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The zone of the device.", + }, + }, + } +} + +func expandNetworkInterfacesItems(items []interface{}) []string { + networkInterfacesItems := make([]string, 0) + for _, item := range items { + itemMap := item.(map[string]interface{}) + networkInterfacesItems = append(networkInterfacesItems, itemMap["mac_address"].(string)) + } + return networkInterfacesItems +} + +func resourceDeviceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*client.Client) + + deviceParams := entity.DeviceCreateParams{ + Description: d.Get("description").(string), + Domain: d.Get("domain").(string), + Hostname: d.Get("hostname").(string), + MacAddresses: expandNetworkInterfacesItems(d.Get("network_interfaces").(*schema.Set).List()), + } + + device, err := client.Devices.Create(&deviceParams) + if err != nil { + return diag.FromErr(err) + } + d.SetId(device.SystemID) + + return resourceDeviceRead(ctx, d, meta) +} + +func resourceDeviceUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*client.Client) + + deviceParams := entity.DeviceUpdateParams{ + Description: d.Get("description").(string), + Domain: d.Get("domain").(string), + Hostname: d.Get("hostname").(string), + Zone: d.Get("zone").(string), + } + + device, err := client.Device.Update(d.Id(), &deviceParams) + if err != nil { + return diag.FromErr(err) + } + d.SetId(device.SystemID) + + return resourceDeviceRead(ctx, d, meta) +} + +func resourceDeviceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*client.Client) + return diag.FromErr(client.Device.Delete(d.Id())) +} + +func resourceDeviceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*client.Client) + + device, err := getDevice(client, d.Id()) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(device.SystemID) + + d.Set("description", device.Description) + d.Set("domain", device.Domain.Name) + d.Set("fqdn", device.FQDN) + d.Set("hostname", device.Hostname) + d.Set("owner", device.Owner) + d.Set("zone", device.Zone.Name) + + ipAddresses := make([]string, len(device.IPAddresses)) + for i, ip := range device.IPAddresses { + ipAddresses[i] = ip.String() + } + if err := d.Set("ip_addresses", ipAddresses); err != nil { + return diag.FromErr(err) + } + + networkInterfaces := make([]map[string]interface{}, len(device.InterfaceSet)) + for i, networkInterface := range device.InterfaceSet { + networkInterfaces[i] = map[string]interface{}{ + "id": networkInterface.ID, + "mac_address": networkInterface.MACAddress, + "name": networkInterface.Name, + } + } + if err := d.Set("network_interfaces", networkInterfaces); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/maas/resource_maas_device_test.go b/maas/resource_maas_device_test.go new file mode 100644 index 00000000..ab576539 --- /dev/null +++ b/maas/resource_maas_device_test.go @@ -0,0 +1,148 @@ +package maas_test + +import ( + "fmt" + "strings" + "terraform-provider-maas/maas/testutils" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/maas/gomaasclient/client" + "github.com/maas/gomaasclient/entity" +) + +func TestAccResourceMaasDevice_basic(t *testing.T) { + + var device entity.Device + description := "Test description" + domain := "test-domain" + hostname := "test-device" + zone := "default" + mac_address := "12:23:45:67:89:de" + + checks := []resource.TestCheckFunc{ + testAccMaasDeviceCheckExists("maas_device.test", &device), + resource.TestCheckResourceAttr("maas_device.test", "description", description), + resource.TestCheckResourceAttr("maas_device.test", "domain", domain), + resource.TestCheckResourceAttr("maas_device.test", "fqdn", fmt.Sprintf("%s.%s", hostname, domain)), + resource.TestCheckResourceAttr("maas_device.test", "hostname", hostname), + resource.TestCheckResourceAttr("maas_device.test", "zone", zone), + resource.TestCheckResourceAttr("maas_device.test", "ip_addresses.#", "0"), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.#", "1"), + resource.TestCheckResourceAttrSet("maas_device.test", "network_interfaces.0.id"), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.0.mac_address", mac_address), + resource.TestCheckResourceAttr("maas_device.test", "network_interfaces.0.name", "eth0"), + resource.TestCheckResourceAttrSet("maas_device.test", "owner"), + } + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testutils.PreCheck(t) }, + Providers: testutils.TestAccProviders, + CheckDestroy: testAccCheckMaasDeviceDestroy, + ErrorCheck: func(err error) error { return err }, + Steps: []resource.TestStep{ + { + Config: testAccMaasDevice(description, domain, hostname, zone, mac_address), + Check: resource.ComposeTestCheckFunc(checks...), + }, + // Test import using ID + { + ResourceName: "maas_device.test", + ImportState: true, + ImportStateVerify: true, + }, + // Test import using hostname + { + ResourceName: "maas_device.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["maas_device.test"] + if !ok { + return "", fmt.Errorf("resource not found: %s", "maas_device.test") + } + + if rs.Primary.ID == "" { + return "", fmt.Errorf("resource id not set") + } + return rs.Primary.Attributes["hostname"], nil + }, + }, + }, + }) +} + +func testAccMaasDeviceCheckExists(rn string, device *entity.Device) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("resource not found: %s\n %#v", rn, s.RootModule().Resources) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("resource id not set") + } + + conn := testutils.TestAccProvider.Meta().(*client.Client) + gotDevice, err := conn.Device.Get(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error getting device: %s", err) + } + + *device = *gotDevice + + return nil + } +} + +func testAccMaasDevice(description string, domain string, hostname string, zone string, mac_address string) string { + return fmt.Sprintf(` +resource "maas_dns_domain" "test" { + name = "%s" + ttl = 3600 + authoritative = true +} + +resource "maas_device" "test" { + description = "%s" + domain = maas_dns_domain.test.name + hostname = "%s" + zone = "%s" + network_interfaces { + mac_address = "%s" + } +} +`, domain, description, hostname, zone, mac_address) +} + +func testAccCheckMaasDeviceDestroy(s *terraform.State) error { + // retrieve the connection established in Provider configuration + conn := testutils.TestAccProvider.Meta().(*client.Client) + + // loop through the resources in state, verifying each maas_device + // is destroyed + for _, rs := range s.RootModule().Resources { + if rs.Type != "maas_device" { + continue + } + + // Retrieve our maas_device by referencing it's state ID for API lookup + response, err := conn.Device.Get(rs.Primary.ID) + if err == nil { + if response != nil && response.SystemID == rs.Primary.ID { + return fmt.Errorf("MAAS Device (%s) still exists.", rs.Primary.ID) + } + + return nil + } + + // If the error is equivalent to 404 not found, the maas_device is destroyed. + // Otherwise return the error + if !strings.Contains(err.Error(), "404 Not Found") { + return err + } + } + + return nil +} diff --git a/maas/testutils/provider.go b/maas/testutils/provider.go new file mode 100644 index 00000000..8ca39f6b --- /dev/null +++ b/maas/testutils/provider.go @@ -0,0 +1,30 @@ +package testutils + +import ( + "os" + "terraform-provider-maas/maas" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +var ( + TestAccProviders map[string]*schema.Provider + TestAccProvider *schema.Provider +) + +func init() { + TestAccProvider = maas.Provider() + TestAccProviders = map[string]*schema.Provider{ + "maas": TestAccProvider, + } +} + +func PreCheck(t *testing.T) { + if v := os.Getenv("MAAS_API_URL"); v == "" { + t.Fatal("MAAS_API_URL must be set for acceptance tests") + } + if v := os.Getenv("MAAS_API_KEY"); v == "" { + t.Fatal("MAAS_API_KEY must be set for acceptance tests") + } +}