From 473655a470f34c5b802817324b7139cd9d7e3e34 Mon Sep 17 00:00:00 2001 From: Sebastiaan Koppe Date: Sun, 2 Jun 2024 15:42:13 +0200 Subject: [PATCH] feat: add 'netbox_ip_address_assignment' resource (#601) --- netbox/provider.go | 1 + .../resource_netbox_ip_address_assignment.go | 199 +++++++++++ ...ource_netbox_ip_address_assignment_test.go | 308 ++++++++++++++++++ 3 files changed, 508 insertions(+) create mode 100644 netbox/resource_netbox_ip_address_assignment.go create mode 100644 netbox/resource_netbox_ip_address_assignment_test.go diff --git a/netbox/provider.go b/netbox/provider.go index 25f39ec4..1bff24cc 100644 --- a/netbox/provider.go +++ b/netbox/provider.go @@ -89,6 +89,7 @@ func Provider() *schema.Provider { "netbox_tenant_group": resourceNetboxTenantGroup(), "netbox_vrf": resourceNetboxVrf(), "netbox_ip_address": resourceNetboxIPAddress(), + "netbox_ip_address_assignment": resourceNetboxIPAddressAssignment(), "netbox_interface_template": resourceNetboxInterfaceTemplate(), "netbox_interface": resourceNetboxInterface(), "netbox_service": resourceNetboxService(), diff --git a/netbox/resource_netbox_ip_address_assignment.go b/netbox/resource_netbox_ip_address_assignment.go new file mode 100644 index 00000000..248b9562 --- /dev/null +++ b/netbox/resource_netbox_ip_address_assignment.go @@ -0,0 +1,199 @@ +package netbox + +import ( + "strconv" + + "github.com/fbreckle/go-netbox/netbox/client" + "github.com/fbreckle/go-netbox/netbox/client/ipam" + "github.com/fbreckle/go-netbox/netbox/models" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +var resourceNetboxIPAddressAssignmentObjectTypeOptions = []string{"virtualization.vminterface", "dcim.interface"} + +func resourceNetboxIPAddressAssignment() *schema.Resource { + return &schema.Resource{ + Create: resourceNetboxIPAddressAssignmentCreate, + Read: resourceNetboxIPAddressAssignmentRead, + Update: resourceNetboxIPAddressAssignmentUpdate, + Delete: resourceNetboxIPAddressAssignmentDelete, + + Description: `:meta:subcategory:IP Address Management (IPAM):From the [official documentation](https://docs.netbox.dev/en/stable/features/ipam/#ip-addresses): + +> Assigns a NetBox Device, physical or virtual, to an already constructed IP address. +> +> In cases where the device assigned to the IP Address is not yet known when constructing the IP address (using either netbox_available_ip_address or netbox_ip_address), this resource allows assigning it independently. +> +> A typical scenario is when you statically allocate IP's to virtual machines and use netbox_available_ip_address to fetch that IP, but where the netbox_virtual_machine or netbox_interface can only be constructed after having started the virtual machine.`, + + Schema: map[string]*schema.Schema{ + "ip_address_id": { + Type: schema.TypeInt, + Required: true, + }, + "interface_id": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"object_type"}, + }, + "object_type": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(resourceNetboxIPAddressObjectTypeOptions, false), + Description: buildValidValueDescription(resourceNetboxIPAddressObjectTypeOptions), + RequiredWith: []string{"interface_id"}, + }, + "virtual_machine_interface_id": { + Type: schema.TypeInt, + Optional: true, + ConflictsWith: []string{"interface_id", "device_interface_id"}, + }, + "device_interface_id": { + Type: schema.TypeInt, + Optional: true, + ConflictsWith: []string{"interface_id", "virtual_machine_interface_id"}, + }, + }, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + } +} + +func resourceNetboxIPAddressAssignmentCreate(d *schema.ResourceData, m interface{}) error { + id := d.Get("ip_address_id").(int) + + d.SetId(strconv.Itoa(id)) + + return resourceNetboxIPAddressAssignmentUpdate(d, m) +} + +func resourceNetboxIPAddressAssignmentRead(d *schema.ResourceData, m interface{}) error { + api := m.(*client.NetBoxAPI) + + id, _ := strconv.ParseInt(d.Id(), 10, 64) + params := ipam.NewIpamIPAddressesReadParams().WithID(id) + + res, err := api.Ipam.IpamIPAddressesRead(params, nil) + if err != nil { + if errresp, ok := err.(*ipam.IpamIPAddressesReadDefault); ok { + errorcode := errresp.Code() + if errorcode == 404 { + // If the ID is updated to blank, this tells Terraform the resource no longer exists (maybe it was destroyed out of band). Just like the destroy callback, the Read function should gracefully handle this case. https://www.terraform.io/docs/extend/writing-custom-providers.html + d.SetId("") + return nil + } + } + return err + } + + ipAddress := res.GetPayload() + if ipAddress.AssignedObjectID != nil { + vmInterfaceID := getOptionalInt(d, "virtual_machine_interface_id") + deviceInterfaceID := getOptionalInt(d, "device_interface_id") + interfaceID := getOptionalInt(d, "interface_id") + + switch { + case vmInterfaceID != nil: + d.Set("virtual_machine_interface_id", ipAddress.AssignedObjectID) + case deviceInterfaceID != nil: + d.Set("device_interface_id", ipAddress.AssignedObjectID) + // if interfaceID is given, object_type must be set as well + case interfaceID != nil: + d.Set("object_type", ipAddress.AssignedObjectType) + d.Set("interface_id", ipAddress.AssignedObjectID) + } + } else { + d.Set("interface_id", nil) + d.Set("object_type", "") + } + + d.Set("ip_address_id", d.Id()) + + return nil +} + +func resourceNetboxIPAddressAssignmentUpdate(d *schema.ResourceData, m interface{}) error { + api := m.(*client.NetBoxAPI) + + id, _ := strconv.ParseInt(d.Id(), 10, 64) + params := ipam.NewIpamIPAddressesReadParams().WithID(id) + + res, err := api.Ipam.IpamIPAddressesRead(params, nil) + if err != nil { + if errresp, ok := err.(*ipam.IpamIPAddressesReadDefault); ok { + errorcode := errresp.Code() + if errorcode == 404 { + // If the ID is updated to blank, this tells Terraform the resource no longer exists (maybe it was destroyed out of band). Just like the destroy callback, the Read function should gracefully handle this case. https://www.terraform.io/docs/extend/writing-custom-providers.html + d.SetId("") + return nil + } + } + return err + } + + ipAddress := res.GetPayload() + data := models.WritableIPAddress{} + + data.Address = ipAddress.Address + if ipAddress.Status != nil { + data.Status = *ipAddress.Status.Value + } + + data.Description = ipAddress.Description + if ipAddress.Role != nil { + data.Role = *ipAddress.Role.Value + } + data.DNSName = ipAddress.DNSName + if ipAddress.Vrf != nil { + data.Vrf = &ipAddress.Vrf.ID + } + if ipAddress.Tenant != nil { + data.Tenant = &ipAddress.Tenant.ID + } + if ipAddress.NatInside != nil { + data.NatInside = &ipAddress.NatInside.ID + } + + data.Tags = ipAddress.Tags + + vmInterfaceID := getOptionalInt(d, "virtual_machine_interface_id") + deviceInterfaceID := getOptionalInt(d, "device_interface_id") + interfaceID := getOptionalInt(d, "interface_id") + + switch { + case vmInterfaceID != nil: + data.AssignedObjectType = strToPtr("virtualization.vminterface") + data.AssignedObjectID = vmInterfaceID + case deviceInterfaceID != nil: + data.AssignedObjectType = strToPtr("dcim.interface") + data.AssignedObjectID = deviceInterfaceID + // if interfaceID is given, object_type must be set as well + case interfaceID != nil: + data.AssignedObjectType = strToPtr(d.Get("object_type").(string)) + data.AssignedObjectID = interfaceID + // default = ip is not linked to anything + default: + data.AssignedObjectType = strToPtr("") + data.AssignedObjectID = nil + } + + params2 := ipam.NewIpamIPAddressesUpdateParams().WithID(id).WithData(&data) + + _, err2 := api.Ipam.IpamIPAddressesUpdate(params2, nil) + if err2 != nil { + return err2 + } + + return resourceNetboxIPAddressRead(d, m) +} + +func resourceNetboxIPAddressAssignmentDelete(d *schema.ResourceData, m interface{}) error { + d.Set("interface_id", nil) + d.Set("object_type", "") + d.Set("virtual_machine_interface_id", nil) + d.Set("device_interface_id", nil) + + return resourceNetboxIPAddressAssignmentUpdate(d, m) +} diff --git a/netbox/resource_netbox_ip_address_assignment_test.go b/netbox/resource_netbox_ip_address_assignment_test.go new file mode 100644 index 00000000..06e8d0cd --- /dev/null +++ b/netbox/resource_netbox_ip_address_assignment_test.go @@ -0,0 +1,308 @@ +package netbox + +import ( + "fmt" + "log" + "regexp" + "testing" + + "github.com/fbreckle/go-netbox/netbox/client" + "github.com/fbreckle/go-netbox/netbox/client/ipam" + "github.com/fbreckle/go-netbox/netbox/models" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func testAccNetboxIPAddressAssignmentFullDependencies(testName string, testIP string) string { + return fmt.Sprintf(` +resource "netbox_tag" "test" { + name = "%[1]s" +} + +resource "netbox_tenant" "test" { + name = "%[1]s" +} + +resource "netbox_vrf" "test" { + name = "%[1]s" +} + +resource "netbox_cluster_type" "test" { + name = "%[1]s" +} + +resource "netbox_cluster" "test" { + name = "%[1]s" + cluster_type_id = netbox_cluster_type.test.id +} + +resource "netbox_virtual_machine" "test" { + name = "%[1]s" + cluster_id = netbox_cluster.test.id +} + +resource "netbox_interface" "test" { + name = "%[1]s" + virtual_machine_id = netbox_virtual_machine.test.id +} + +resource "netbox_ip_address" "test" { + ip_address = "%[2]s" + status = "active" + tags = [netbox_tag.test.name] +} +`, testName, testIP) +} + +func testAccNetboxIPAddressAssignmentFullDeviceDependencies(testName string, testIP string) string { + return fmt.Sprintf(` +resource "netbox_site" "test" { + name = "%[1]s" + status = "active" +} + +resource "netbox_device_role" "test" { + name = "%[1]s" + color_hex = "123456" +} + +resource "netbox_manufacturer" "test" { + name = "%[1]s" +} + +resource "netbox_device_type" "test" { + model = "%[1]s" + manufacturer_id = netbox_manufacturer.test.id +} + +resource "netbox_device" "test" { + name = "%[1]s" + site_id = netbox_site.test.id + device_type_id = netbox_device_type.test.id + role_id = netbox_device_role.test.id +} +resource "netbox_device_interface" "test" { + name = "%[1]s" + device_id = netbox_device.test.id + type = "1000base-t" +} +resource "netbox_ip_address" "test" { + ip_address = "%[2]s" + status = "active" + tags = [netbox_tag.test.name] +} +`, testName, testIP) +} + +func TestAccNetboxIPAddressAssignment_basic(t *testing.T) { + testIP := "1.1.2.1/32" + testSlug := "ipaddress" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + object_type = "virtualization.vminterface" + interface_id = netbox_interface.test.id +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "ip_address_id", "netbox_ip_address.test", "id"), + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "object_type", "virtualization.vminterface"), + resource.TestCheckResourceAttrPair("netbox_ip_address_assignment.test", "interface_id", "netbox_interface.test", "id"), + ), + }, + { + ResourceName: "netbox_ip_address_assignment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"interface_id", "object_type"}, + }, + }, + }) +} + +func TestAccNetboxIPAddressAssignment_deviceByObjectType(t *testing.T) { + testIP := "1.1.2.2/32" + testSlug := "ipadr_dev_ot" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDeviceDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + object_type = "dcim.interface" + interface_id = netbox_device_interface.test.id +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "ip_address_id", "netbox_ip_address.test", "id"), + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "object_type", "dcim.interface"), + resource.TestCheckResourceAttrPair("netbox_ip_address_assignment.test", "interface_id", "netbox_device_interface.test", "id"), + ), + }, + { + ResourceName: "netbox_ip_address_assignment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"interface_id", "object_type"}, + }, + }, + }) +} + +func TestAccNetboxIPAddressAssignment_vmSwitchStyle(t *testing.T) { + testIP := "1.1.2.9/32" + testSlug := "ipadr_vm_sw" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + object_type = "virtualization.vminterface" + interface_id = netbox_interface.test.id +`, + }, + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + virtual_machine_interface_id = netbox_interface.test.id +`, + }, + { + ResourceName: "netbox_ip_address_assignment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"interface_id", "object_type", "virtual_machine_interface_id"}, + }, + }, + }) +} + +// TestAccNetboxIPAddressAssignment_deviceByFieldName tests if creating an ip address and linking it to a device via the `device_interface_id` field works +func TestAccNetboxIPAddressAssignment_deviceByFieldName(t *testing.T) { + testIP := "1.1.2.4/32" + testSlug := "ipadr_dev_fn" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDeviceDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + device_interface_id = netbox_device_interface.test.id +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "ip_address_id", "netbox_ip_address.test", "id"), + resource.TestCheckResourceAttrPair("netbox_ip_address_assignment.test", "device_interface_id", "netbox_device_interface.test", "id"), + ), + }, + { + ResourceName: "netbox_ip_address_assignment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"device_interface_id"}, + }, + }, + }) +} + +func TestAccNetboxIPAddressAssignment_vmByFieldName(t *testing.T) { + testIP := "1.1.2.5/32" + testSlug := "ipadr_vm_fn" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP) + ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + virtual_machine_interface_id = netbox_interface.test.id +`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("netbox_ip_address_assignment.test", "ip_address_id", "netbox_ip_address.test", "id"), + resource.TestCheckResourceAttrPair("netbox_ip_address_assignment.test", "virtual_machine_interface_id", "netbox_interface.test", "id"), + ), + }, + { + ResourceName: "netbox_ip_address_assignment.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"virtual_machine_interface_id"}, + }, + }, + }) +} + +func TestAccNetboxIPAddressAssignment_invalidConfig(t *testing.T) { + testIP := "1.1.2.7/32" + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + object_type = "dcim.interface" +`, + ExpectError: regexp.MustCompile(".*all of `interface_id,object_type` must be specified.*"), + }, + { + Config: ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + interface_id = 1 +`, + ExpectError: regexp.MustCompile(".*all of `interface_id,object_type` must be specified.*"), + }, + { + Config: ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + virtual_machine_interface_id = 1 + interface_id = 1 + object_type = "dcim.interface" +`, + ExpectError: regexp.MustCompile(".*conflicts with interface_id.*"), + }, + }, + }) +} + +func init() { + resource.AddTestSweepers("netbox_ip_address_assignment", &resource.Sweeper{ + Name: "netbox_ip_address_assignment", + Dependencies: []string{}, + F: func(region string) error { + m, err := sharedClientForRegion(region) + if err != nil { + return fmt.Errorf("Error getting client: %s", err) + } + api := m.(*client.NetBoxAPI) + params := ipam.NewIpamIPAddressesListParams() + res, err := api.Ipam.IpamIPAddressesList(params, nil) + if err != nil { + return err + } + for _, ipAddress := range res.GetPayload().Results { + if len(ipAddress.Tags) > 0 && (ipAddress.Tags[0] == &models.NestedTag{Name: strToPtr("acctest"), Slug: strToPtr("acctest")}) { + deleteParams := ipam.NewIpamIPAddressesDeleteParams().WithID(ipAddress.ID) + _, err := api.Ipam.IpamIPAddressesDelete(deleteParams, nil) + if err != nil { + return err + } + log.Print("[DEBUG] Deleted an ip address") + } + } + return nil + }, + }) +}