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:
Joshua Spence
2023-02-24 10:42:06 +11:00
committed by GitHub
parent 7eadb9ba08
commit 066163a22c
4 changed files with 361 additions and 69 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}
}