Allow device adoption (#188)
* Allow device adoption * Handling disappearing device * Allocate test devices dynamically * Increase `NotFoundChecks` * Demo devices don't seem to have sequential MACs * Change default for `forget_on_destroy` * Minor
This commit is contained in:
@@ -183,6 +183,12 @@ func (c *lazyClient) GetDevice(ctx context.Context, site, id string) (*unifi.Dev
|
||||
}
|
||||
return c.inner.GetDevice(ctx, site, id)
|
||||
}
|
||||
func (c *lazyClient) GetDeviceByMAC(ctx context.Context, site, mac string) (*unifi.Device, error) {
|
||||
if err := c.init(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.inner.GetDeviceByMAC(ctx, site, mac)
|
||||
}
|
||||
func (c *lazyClient) CreateDevice(ctx context.Context, site string, d *unifi.Device) (*unifi.Device, error) {
|
||||
if err := c.init(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -207,6 +213,18 @@ func (c *lazyClient) ListDevice(ctx context.Context, site string) ([]unifi.Devic
|
||||
}
|
||||
return c.inner.ListDevice(ctx, site)
|
||||
}
|
||||
func (c *lazyClient) AdoptDevice(ctx context.Context, site, mac string) error {
|
||||
if err := c.init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.inner.AdoptDevice(ctx, site, mac)
|
||||
}
|
||||
func (c *lazyClient) ForgetDevice(ctx context.Context, site, mac string) error {
|
||||
if err := c.init(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.inner.ForgetDevice(ctx, site, mac)
|
||||
}
|
||||
func (c *lazyClient) GetUser(ctx context.Context, site, id string) (*unifi.User, error) {
|
||||
if err := c.init(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -164,10 +164,13 @@ type unifiClient interface {
|
||||
UpdateWLAN(ctx context.Context, site string, d *unifi.WLAN) (*unifi.WLAN, error)
|
||||
|
||||
GetDevice(ctx context.Context, site, id string) (*unifi.Device, error)
|
||||
GetDeviceByMAC(ctx context.Context, site, mac string) (*unifi.Device, error)
|
||||
CreateDevice(ctx context.Context, site string, d *unifi.Device) (*unifi.Device, error)
|
||||
UpdateDevice(ctx context.Context, site string, d *unifi.Device) (*unifi.Device, error)
|
||||
DeleteDevice(ctx context.Context, site, id string) error
|
||||
ListDevice(ctx context.Context, site string) ([]unifi.Device, error)
|
||||
AdoptDevice(ctx context.Context, site, mac string) error
|
||||
ForgetDevice(ctx context.Context, site, mac string) error
|
||||
|
||||
GetUser(ctx context.Context, site, id string) (*unifi.User, error)
|
||||
GetUserByMAC(ctx context.Context, site, mac string) (*unifi.User, error)
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
|
||||
"github.com/paultyng/go-unifi/unifi"
|
||||
@@ -85,6 +87,19 @@ func resourceDevice() *schema.Resource {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"allow_adoption": {
|
||||
Description: "Specifies whether this resource should tell the controller to adopt the device on create.",
|
||||
Type: schema.TypeBool,
|
||||
Optional: true,
|
||||
Default: false,
|
||||
},
|
||||
"forget_on_destroy": {
|
||||
Description: "Specifies whether this resource should tell the controller to forget the device on destroy.",
|
||||
Type: schema.TypeBool,
|
||||
Optional: true,
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -105,18 +120,14 @@ func resourceDeviceImport(ctx context.Context, d *schema.ResourceData, meta inte
|
||||
|
||||
if macAddressRegexp.MatchString(id) {
|
||||
// look up id by mac
|
||||
find := cleanMAC(id)
|
||||
mac := cleanMAC(id)
|
||||
device, err := c.c.GetDeviceByMAC(ctx, site, mac)
|
||||
|
||||
devices, err := c.c.ListDevice(ctx, site)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range devices {
|
||||
if cleanMAC(d.MAC) == find {
|
||||
id = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
id = device.ID
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
@@ -143,25 +154,33 @@ func resourceDeviceCreate(ctx context.Context, d *schema.ResourceData, meta inte
|
||||
}
|
||||
|
||||
mac = cleanMAC(mac)
|
||||
devices, err := c.c.ListDevice(ctx, site)
|
||||
if err != nil {
|
||||
return diag.Errorf("unable to list devices: %s", err)
|
||||
}
|
||||
device, err := c.c.GetDeviceByMAC(ctx, site, mac)
|
||||
|
||||
var found *unifi.Device
|
||||
for _, dev := range devices {
|
||||
if cleanMAC(dev.MAC) == mac {
|
||||
found = &dev
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
if device == nil {
|
||||
return diag.Errorf("device not found using mac %q", mac)
|
||||
}
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
d.SetId(found.ID)
|
||||
if !device.Adopted {
|
||||
if !d.Get("allow_adoption").(bool) {
|
||||
return diag.Errorf("Device must be adopted before it can be managed")
|
||||
}
|
||||
|
||||
return resourceDeviceSetResourceData(found, d, site)
|
||||
err := c.c.AdoptDevice(ctx, site, mac)
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
device, err = waitForDeviceState(ctx, d, meta, []unifi.DeviceState{unifi.DeviceStateAdopting, unifi.DeviceStatePending, unifi.DeviceStateProvisioning}, []unifi.DeviceState{unifi.DeviceStateConnected})
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
}
|
||||
|
||||
d.SetId(device.ID)
|
||||
return resourceDeviceUpdate(ctx, d, meta)
|
||||
}
|
||||
|
||||
func resourceDeviceUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
||||
@@ -188,13 +207,31 @@ func resourceDeviceUpdate(ctx context.Context, d *schema.ResourceData, meta inte
|
||||
return resourceDeviceSetResourceData(resp, d, site)
|
||||
}
|
||||
|
||||
func resourceDeviceDelete(_ context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
||||
return diag.Diagnostics{
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Warning,
|
||||
Summary: "Deleting a device via Terraform is not supported, the device will just be removed from state.",
|
||||
},
|
||||
func resourceDeviceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
||||
c := meta.(*client)
|
||||
|
||||
if !d.Get("forget_on_destroy").(bool) {
|
||||
return nil
|
||||
}
|
||||
|
||||
site := d.Get("site").(string)
|
||||
mac := d.Get("mac").(string)
|
||||
|
||||
if site == "" {
|
||||
site = c.site
|
||||
}
|
||||
|
||||
err := c.c.ForgetDevice(ctx, site, mac)
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
_, err = waitForDeviceState(ctx, d, meta, []unifi.DeviceState{unifi.DeviceStateConnected, unifi.DeviceStateDeleting}, []unifi.DeviceState{unifi.DeviceStatePending})
|
||||
if _, ok := err.(*unifi.NotFoundError); !ok {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceDeviceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
||||
@@ -302,3 +339,67 @@ func fromPortOverride(po unifi.DevicePortOverrides) (map[string]interface{}, err
|
||||
"port_profile_id": po.PortProfileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func waitForDeviceState(ctx context.Context, d *schema.ResourceData, meta interface{}, pendingStates, targetStates []unifi.DeviceState) (*unifi.Device, error) {
|
||||
c := meta.(*client)
|
||||
|
||||
site := d.Get("site").(string)
|
||||
mac := d.Get("mac").(string)
|
||||
|
||||
if site == "" {
|
||||
site = c.site
|
||||
}
|
||||
|
||||
var pending []string
|
||||
for _, state := range pendingStates {
|
||||
pending = append(pending, state.String())
|
||||
}
|
||||
|
||||
var target []string
|
||||
for _, state := range targetStates {
|
||||
target = append(target, state.String())
|
||||
}
|
||||
|
||||
wait := resource.StateChangeConf{
|
||||
Pending: pending,
|
||||
Target: target,
|
||||
Refresh: func() (interface{}, string, error) {
|
||||
device, err := c.c.GetDeviceByMAC(ctx, site, mac)
|
||||
|
||||
if _, ok := err.(*unifi.NotFoundError); ok {
|
||||
err = nil
|
||||
}
|
||||
|
||||
// When a device is forgotten, it will disappear from the UI for a few seconds before reappearing.
|
||||
// During this time, `device.GetDeviceByMAC` will return a 400.
|
||||
//
|
||||
// TODO: Improve handling of this situation in `go-unifi`.
|
||||
if err != nil && strings.Contains(err.Error(), "api.err.UnknownDevice") {
|
||||
err = nil
|
||||
}
|
||||
|
||||
var state string
|
||||
if device != nil {
|
||||
state = device.State.String()
|
||||
}
|
||||
|
||||
// TODO: Why is this needed???
|
||||
if device == nil {
|
||||
return nil, state, err
|
||||
}
|
||||
|
||||
return device, state, err
|
||||
},
|
||||
PollInterval: 1 * time.Second,
|
||||
Timeout: 1 * time.Minute,
|
||||
NotFoundChecks: 30,
|
||||
}
|
||||
|
||||
outputRaw, err := wait.WaitForStateContext(ctx)
|
||||
|
||||
if output, ok := outputRaw.(*unifi.Device); ok {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,82 +1,170 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
|
||||
"github.com/paultyng/go-unifi/unifi"
|
||||
)
|
||||
|
||||
func preCheckSwitch(t *testing.T) {
|
||||
switchID := os.Getenv("UNIFI_TEST_SWITCH_ID")
|
||||
if switchID == "" {
|
||||
t.Skipf("UNIFI_TEST_SWITCH_ID not set")
|
||||
var (
|
||||
deviceLock sync.Mutex
|
||||
devicesAvailable []string
|
||||
devicesInitialized bool = false
|
||||
)
|
||||
|
||||
func allocateDevice(t *testing.T) (device string) {
|
||||
deviceLock.Lock()
|
||||
defer deviceLock.Unlock()
|
||||
|
||||
if !devicesInitialized {
|
||||
devicesAvailable = []string{}
|
||||
devicesInitialized = true
|
||||
|
||||
devices, err := testClient.ListDevice(context.Background(), "default")
|
||||
if err != nil {
|
||||
t.Fatalf("Error listing devices: %s", err)
|
||||
}
|
||||
|
||||
for _, device := range devices {
|
||||
// TODO: Check device type instead of MAC address.
|
||||
if strings.HasPrefix(device.MAC, "00:27:22:") {
|
||||
devicesAvailable = append(devicesAvailable, device.MAC)
|
||||
}
|
||||
}
|
||||
}
|
||||
switchMAC := os.Getenv("UNIFI_TEST_SWITCH_MAC")
|
||||
if switchMAC == "" {
|
||||
t.Skipf("UNIFI_TEST_SWITCH_MAC not set")
|
||||
|
||||
if len(devicesAvailable) == 0 {
|
||||
t.Fatal("Unable to allocate test device")
|
||||
}
|
||||
poePortList := os.Getenv("UNIFI_TEST_SWITCH_PORT_NUMBERS")
|
||||
if poePortList == "" {
|
||||
t.Skipf("UNIFI_TEST_SWITCH_PORT_NUMBERS is not set")
|
||||
}
|
||||
poePorts := strings.Split(poePortList, ",")
|
||||
if len(poePorts) < 2 {
|
||||
t.Skipf("At least 2 ports are required for testing.")
|
||||
|
||||
device, devicesAvailable = devicesAvailable[0], devicesAvailable[1:]
|
||||
return
|
||||
}
|
||||
|
||||
func unallocateDevice(t *testing.T, device string) {
|
||||
deviceLock.Lock()
|
||||
defer deviceLock.Unlock()
|
||||
|
||||
devicesAvailable = append(devicesAvailable, device)
|
||||
}
|
||||
|
||||
func preCheckDeviceExists(t *testing.T, site, mac string) {
|
||||
_, err := testClient.GetDeviceByMAC(context.Background(), site, mac)
|
||||
|
||||
if _, ok := err.(*unifi.NotFoundError); ok {
|
||||
t.Fatal("Test device not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccDevice_empty(t *testing.T) {
|
||||
resource.ParallelTest(t, resource.TestCase{
|
||||
PreCheck: func() { preCheck(t) },
|
||||
ProviderFactories: providerFactories,
|
||||
CheckDestroy: testAccCheckDeviceDestroy,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccDeviceConfigEmpty(),
|
||||
ExpectError: regexp.MustCompile(`no MAC address specified, please import the device using terraform import`),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccDevice_switch_basic(t *testing.T) {
|
||||
//switchID := os.Getenv("UNIFI_TEST_SWITCH_ID")
|
||||
switchMAC := os.Getenv("UNIFI_TEST_SWITCH_MAC")
|
||||
resourceName := "unifi_device.test"
|
||||
site := "default"
|
||||
|
||||
switchMAC := allocateDevice(t)
|
||||
defer unallocateDevice(t, switchMAC)
|
||||
|
||||
importStateVerifyIgnore := []string{"allow_adoption", "forget_on_destroy"}
|
||||
|
||||
resource.ParallelTest(t, resource.TestCase{
|
||||
PreCheck: func() {
|
||||
preCheck(t)
|
||||
preCheckSwitch(t)
|
||||
preCheckDeviceExists(t, site, switchMAC)
|
||||
},
|
||||
ProviderFactories: providerFactories,
|
||||
// TODO: CheckDestroy: ,
|
||||
CheckDestroy: testAccCheckDeviceDestroy,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccDeviceConfigEmpty(),
|
||||
ExpectError: regexp.MustCompile("no MAC address specified, please import the device using terraform import"),
|
||||
},
|
||||
|
||||
{
|
||||
Config: testAccDeviceConfig(switchMAC),
|
||||
Check: resource.ComposeTestCheckFunc(
|
||||
// TODO:
|
||||
Check: resource.ComposeTestCheckFunc(
|
||||
testAccCheckDeviceExists(resourceName),
|
||||
resource.TestCheckResourceAttr(resourceName, "site", site),
|
||||
resource.TestCheckResourceAttr(resourceName, "mac", switchMAC),
|
||||
resource.TestCheckResourceAttr(resourceName, "name", ""),
|
||||
),
|
||||
|
||||
// this plan will be non-empty since ports will already be configured most likely
|
||||
ExpectNonEmptyPlan: true,
|
||||
},
|
||||
|
||||
// import with ID
|
||||
importStep("unifi_device.test"),
|
||||
|
||||
// import with mac
|
||||
// Import with ID
|
||||
{
|
||||
ImportState: true,
|
||||
ImportStateVerify: true,
|
||||
ImportStateId: switchMAC,
|
||||
ResourceName: "unifi_device.test",
|
||||
ResourceName: resourceName,
|
||||
ImportState: true,
|
||||
ImportStateVerify: true,
|
||||
ImportStateVerifyIgnore: importStateVerifyIgnore,
|
||||
},
|
||||
|
||||
// TODO: update switch
|
||||
// TODO: test port overrides
|
||||
// Import with MAC
|
||||
{
|
||||
ResourceName: resourceName,
|
||||
ImportState: true,
|
||||
ImportStateId: switchMAC,
|
||||
ImportStateVerify: true,
|
||||
ImportStateVerifyIgnore: importStateVerifyIgnore,
|
||||
},
|
||||
|
||||
{
|
||||
Config: testAccDeviceConfig_withName(switchMAC, "Test Switch"),
|
||||
Check: resource.ComposeTestCheckFunc(
|
||||
testAccCheckDeviceExists(resourceName),
|
||||
resource.TestCheckResourceAttr(resourceName, "name", "Test Switch"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccDevice_switch_portOverrides(t *testing.T) {
|
||||
resourceName := "unifi_device.test"
|
||||
site := "default"
|
||||
|
||||
switchMAC := allocateDevice(t)
|
||||
defer unallocateDevice(t, switchMAC)
|
||||
|
||||
resource.ParallelTest(t, resource.TestCase{
|
||||
PreCheck: func() {
|
||||
preCheck(t)
|
||||
preCheckDeviceExists(t, site, switchMAC)
|
||||
},
|
||||
ProviderFactories: providerFactories,
|
||||
CheckDestroy: testAccCheckDeviceDestroy,
|
||||
Steps: []resource.TestStep{
|
||||
{
|
||||
Config: testAccDeviceConfig_withPortOverrides(switchMAC),
|
||||
Check: resource.ComposeTestCheckFunc(
|
||||
testAccCheckDeviceExists(resourceName),
|
||||
resource.TestCheckResourceAttr(resourceName, "port_override.#", "2"),
|
||||
resource.TestCheckResourceAttr(resourceName, "port_override.0.number", "1"),
|
||||
resource.TestCheckResourceAttr(resourceName, "port_override.0.name", "Port 1"),
|
||||
resource.TestCheckResourceAttr(resourceName, "port_override.1.number", "2"),
|
||||
resource.TestCheckResourceAttr(resourceName, "port_override.1.name", "Port 2"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func testAccDeviceConfigEmpty() string {
|
||||
return `
|
||||
resource "unifi_device" "test" {
|
||||
}
|
||||
resource "unifi_device" "test" {}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -84,6 +172,88 @@ func testAccDeviceConfig(mac string) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "unifi_device" "test" {
|
||||
mac = %q
|
||||
|
||||
allow_adoption = true
|
||||
forget_on_destroy = true
|
||||
}
|
||||
`, mac)
|
||||
}
|
||||
|
||||
func testAccDeviceConfig_withName(mac, name string) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "unifi_device" "test" {
|
||||
mac = %q
|
||||
name = %q
|
||||
|
||||
allow_adoption = true
|
||||
forget_on_destroy = true
|
||||
}
|
||||
`, mac, name)
|
||||
}
|
||||
|
||||
func testAccDeviceConfig_withPortOverrides(mac string) string {
|
||||
return fmt.Sprintf(`
|
||||
resource "unifi_device" "test" {
|
||||
mac = %q
|
||||
|
||||
port_override {
|
||||
number = 1
|
||||
name = "Port 1"
|
||||
}
|
||||
|
||||
port_override {
|
||||
number = 2
|
||||
name = "Port 2"
|
||||
}
|
||||
|
||||
allow_adoption = true
|
||||
forget_on_destroy = true
|
||||
}
|
||||
`, mac)
|
||||
}
|
||||
|
||||
func testAccCheckDeviceDestroy(s *terraform.State) error {
|
||||
ctx := context.Background()
|
||||
|
||||
for _, rs := range s.RootModule().Resources {
|
||||
if rs.Type != "unifi_device" {
|
||||
continue
|
||||
}
|
||||
|
||||
device, err := testClient.GetDevice(ctx, rs.Primary.Attributes["site"], rs.Primary.ID)
|
||||
if device != nil {
|
||||
return fmt.Errorf("Device still exists with ID %v", rs.Primary.ID)
|
||||
}
|
||||
if _, ok := err.(*unifi.NotFoundError); !ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testAccCheckDeviceExists(n string) resource.TestCheckFunc {
|
||||
return func(s *terraform.State) error {
|
||||
rs, ok := s.RootModule().Resources[n]
|
||||
if !ok {
|
||||
return fmt.Errorf("Not found: %s", n)
|
||||
}
|
||||
|
||||
if rs.Primary.ID == "" {
|
||||
return fmt.Errorf("No ID is set")
|
||||
}
|
||||
|
||||
id := rs.Primary.ID
|
||||
site := rs.Primary.Attributes["site"]
|
||||
|
||||
device, err := testClient.GetDevice(context.Background(), site, id)
|
||||
if device == nil {
|
||||
return fmt.Errorf("Device not found with ID %v", id)
|
||||
}
|
||||
if _, ok := err.(*unifi.NotFoundError); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user