diff --git a/examples/resources/netbox_ip_address_assignment/device_interface_id.tf b/examples/resources/netbox_ip_address_assignment/device_interface_id.tf new file mode 100644 index 00000000..8b83c08f --- /dev/null +++ b/examples/resources/netbox_ip_address_assignment/device_interface_id.tf @@ -0,0 +1,16 @@ +// Assuming a device with the id `123` exists +resource "netbox_device_interface" "this" { + name = "eth0" + device_id = 123 + type = "1000base-t" +} + +resource "netbox_ip_address" "this" { + ip_address = "10.0.0.60/24" + status = "active" +} + +resource "netbox_ip_address_assignment" "this" { + ip_address_id = netbox_ip_address.this.id + device_interface_id = netbox_device_interface.this.id +} diff --git a/examples/resources/netbox_ip_address_assignment/object_type_device.tf b/examples/resources/netbox_ip_address_assignment/object_type_device.tf new file mode 100644 index 00000000..2abdcebc --- /dev/null +++ b/examples/resources/netbox_ip_address_assignment/object_type_device.tf @@ -0,0 +1,17 @@ +// Assuming a device with the id `123` exists +resource "netbox_device_interface" "this" { + name = "eth0" + device_id = 123 + type = "1000base-t" +} + +resource "netbox_ip_address" "this" { + ip_address = "10.0.0.60/24" + status = "active" +} + +resource "netbox_ip_address_assignment" "this" { + ip_address_id = netbox_ip_address.this.id + interface_id = netbox_device_interface.this.id + object_type = "dcim.interface" +} diff --git a/examples/resources/netbox_ip_address_assignment/object_type_virtual_machine.tf b/examples/resources/netbox_ip_address_assignment/object_type_virtual_machine.tf new file mode 100644 index 00000000..a137ec44 --- /dev/null +++ b/examples/resources/netbox_ip_address_assignment/object_type_virtual_machine.tf @@ -0,0 +1,16 @@ +// Assuming a virtual machine with the id `123` exists +resource "netbox_interface" "this" { + name = "eth0" + virtual_machine_id = 123 +} + +resource "netbox_ip_address" "this" { + ip_address = "10.0.0.60/24" + status = "active" +} + +resource "netbox_ip_address_assignment" "this" { + ip_address_id = netbox_ip_address.this.id + interface_id = netbox_interface.this.id + object_type = "virtualization.vminterface" +} diff --git a/examples/resources/netbox_ip_address_assignment/virtual_machine_interface_id.tf b/examples/resources/netbox_ip_address_assignment/virtual_machine_interface_id.tf new file mode 100644 index 00000000..81880e41 --- /dev/null +++ b/examples/resources/netbox_ip_address_assignment/virtual_machine_interface_id.tf @@ -0,0 +1,16 @@ +// Assuming a virtual machine with the id `123` exists +resource "netbox_interface" "this" { + name = "eth0" + virtual_machine_id = 123 +} + +resource "netbox_ip_address" "this" { + ip_address = "10.0.0.60/24" + status = "active" +} + + +resource "netbox_ip_address_assignment" "this" { + ip_address_id = netbox_ip_address.this.id + virtual_machine_interface_id = netbox_interface.this.id +} 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..c40158f1 --- /dev/null +++ b/netbox/resource_netbox_ip_address_assignment.go @@ -0,0 +1,211 @@ +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, + ForceNew: true, + }, + "interface_id": { + Type: schema.TypeInt, + Optional: true, + RequiredWith: []string{"object_type"}, + }, + "object_type": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(resourceNetboxIPAddressAssignmentObjectTypeOptions, false), + Description: buildValidValueDescription(resourceNetboxIPAddressAssignmentObjectTypeOptions), + 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", 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 + } + + tags := make([]*models.NestedTag, len(ipAddress.Tags)) + for i, t := range ipAddress.Tags { + tags[i] = &models.NestedTag{Name: t.Name, Slug: t.Slug, Color: t.Color} + } + data.Tags = tags + + outsideNat := make([]*models.NestedIPAddress, len(ipAddress.NatOutside)) + for i, t := range ipAddress.NatOutside { + outsideNat[i] = &models.NestedIPAddress{Address: t.Address} + } + data.NatOutside = outsideNat + + 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.NewIpamIPAddressesPartialUpdateParams().WithID(id).WithData(&data) + + _, err2 := api.Ipam.IpamIPAddressesPartialUpdate(params2, nil) + if err2 != nil { + return err2 + } + + return nil +} + +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..0044979c --- /dev/null +++ b/netbox/resource_netbox_ip_address_assignment_test.go @@ -0,0 +1,340 @@ +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, testIP2 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" "outer" { + ip_address = "%[3]s" + status = "active" + tags = [netbox_tag.test.name] +} + +resource "netbox_ip_address" "test" { + ip_address = "%[2]s" + status = "active" + tags = [netbox_tag.test.name] + dns_name = "abc.example.com" + description = "abc" + role = "anycast" + nat_inside_address_id = netbox_ip_address.outer.id +} +`, testName, testIP, testIP2) +} + +func testAccNetboxIPAddressAssignmentFullDeviceDependencies(testName string, testIP string) string { + return fmt.Sprintf(` +resource "netbox_tag" "test" { + name = "%[1]s" +} + +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.2.1.1/32" + testIP2 := "1.2.2.1/32" + testSlug := "ipaddress_assign" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP, testIP2) + fmt.Sprintf(` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = netbox_ip_address.test.id + object_type = "virtualization.vminterface" + interface_id = netbox_interface.test.id +} + +data "netbox_ip_addresses" "test" { + depends_on = [netbox_ip_address_assignment.test] + filter { + name = "ip_address" + value = "%[1]s" + } +} +`, testIP), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("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.TestCheckResourceAttr("data.netbox_ip_addresses.test", "ip_addresses.0.dns_name", "abc.example.com"), + resource.TestCheckResourceAttr("data.netbox_ip_addresses.test", "ip_addresses.0.status", "active"), + resource.TestCheckResourceAttr("data.netbox_ip_addresses.test", "ip_addresses.0.description", "abc"), + resource.TestCheckResourceAttr("data.netbox_ip_addresses.test", "ip_addresses.0.role", "anycast"), + resource.TestCheckResourceAttr("data.netbox_ip_addresses.test", "ip_addresses.0.tags.0.name", testName), + 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.2.1.2/32" + testSlug := "ipadr_dev_ot_assign" + 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.TestCheckResourceAttrPair("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.2.1.9/32" + testIP2 := "1.2.2.9/32" + testSlug := "ipadr_vm_sw_assign" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP, testIP2) + ` +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, testIP2) + ` +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.2.1.4/32" + testSlug := "ipadr_dev_fn_assign" + 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.TestCheckResourceAttrPair("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.2.1.5/32" + testIP2 := "1.2.2.5/32" + testSlug := "ipadr_vm_fn_assign" + testName := testAccGetTestName(testSlug) + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccNetboxIPAddressAssignmentFullDependencies(testName, testIP, testIP2) + ` +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.TestCheckResourceAttrPair("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) { + resource.ParallelTest(t, resource.TestCase{ + Providers: testAccProviders, + Steps: []resource.TestStep{ // api.Ipam.IpamIPAddressesPartialUpdate() + // NewPatchedWritableIPAddressRequest() + + { + Config: ` +resource "netbox_ip_address_assignment" "test" { + ip_address_id = 1 + 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 = 1 + 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 = 1 + 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 + }, + }) +}