* docs: improve provider documentation * fix accidentally changed type of stormctrl_ucast_rate * docs: add badges and plans to readme
511 lines
16 KiB
Go
511 lines
16 KiB
Go
package device
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
|
|
"github.com/filipowm/terraform-provider-unifi/internal/utils"
|
|
|
|
"github.com/filipowm/go-unifi/unifi"
|
|
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
|
|
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
|
|
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
|
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
|
|
)
|
|
|
|
func ResourceDevice() *schema.Resource {
|
|
return &schema.Resource{
|
|
Description: "The `unifi_device` resource manages UniFi network devices such as access points, switches, gateways, etc.\n\n" +
|
|
"Devices must first be adopted by the UniFi controller before they can be managed through Terraform. " +
|
|
"This resource cannot create new devices, but instead allows you to manage existing devices that have already been adopted. " +
|
|
"The recommended approach is to adopt devices through the UniFi controller UI first, then import them into Terraform using the device's MAC address.\n\n" +
|
|
"This resource supports managing device names, port configurations, and other device-specific settings.",
|
|
|
|
CreateContext: resourceDeviceCreate,
|
|
ReadContext: resourceDeviceRead,
|
|
UpdateContext: resourceDeviceUpdate,
|
|
DeleteContext: resourceDeviceDelete,
|
|
Importer: &schema.ResourceImporter{
|
|
StateContext: resourceDeviceImport,
|
|
},
|
|
|
|
Schema: map[string]*schema.Schema{
|
|
"id": {
|
|
Description: "The unique identifier of the device in the UniFi controller.",
|
|
Type: schema.TypeString,
|
|
Computed: true,
|
|
},
|
|
"site": {
|
|
Description: "The name of the UniFi site where the device is located. If not specified, the default site will be used.",
|
|
Type: schema.TypeString,
|
|
Computed: true,
|
|
Optional: true,
|
|
ForceNew: true,
|
|
},
|
|
"mac": {
|
|
Description: "The MAC address of the device in standard format (e.g., 'aa:bb:cc:dd:ee:ff'). This is used to identify and manage specific devices that have already been adopted by the controller.",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
Computed: true,
|
|
ForceNew: true,
|
|
DiffSuppressFunc: utils.MacDiffSuppressFunc,
|
|
ValidateFunc: validation.StringMatch(utils.MacAddressRegexp, "Mac address is invalid"),
|
|
},
|
|
"name": {
|
|
Description: "A friendly name for the device that will be displayed in the UniFi controller UI. Examples:\n" +
|
|
"* 'Office-AP-1' for an access point\n" +
|
|
"* 'Core-Switch-01' for a switch\n" +
|
|
"* 'Main-Gateway' for a gateway\n" +
|
|
"Choose descriptive names that indicate location and purpose.",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
Computed: true,
|
|
},
|
|
"disabled": {
|
|
Description: "Whether the device is administratively disabled. When true, the device will not forward traffic or provide services.",
|
|
Type: schema.TypeBool,
|
|
Computed: true,
|
|
},
|
|
"port_override": {
|
|
// TODO: this should really be a map or something when possible in the SDK
|
|
// see https://github.com/hashicorp/terraform-plugin-sdk/issues/62
|
|
Description: "A list of port-specific configuration overrides for UniFi switches. This allows you to customize individual port settings such as:\n" +
|
|
" * Port names and labels for easy identification\n" +
|
|
" * Port profiles for VLAN and security settings\n" +
|
|
" * Operating modes for special functions\n\n" +
|
|
"Common use cases include:\n" +
|
|
" * Setting up trunk ports for inter-switch connections\n" +
|
|
" * Configuring PoE settings for powered devices\n" +
|
|
" * Creating mirrored ports for network monitoring\n" +
|
|
" * Setting up link aggregation between switches or servers",
|
|
Type: schema.TypeSet,
|
|
Optional: true,
|
|
Elem: &schema.Resource{
|
|
Schema: map[string]*schema.Schema{
|
|
"number": {
|
|
Description: "The physical port number on the switch to configure.",
|
|
Type: schema.TypeInt,
|
|
Required: true,
|
|
},
|
|
"name": {
|
|
Description: "A friendly name for the port that will be displayed in the UniFi controller UI. Examples:\n" +
|
|
" * 'Uplink to Core Switch'\n" +
|
|
" * 'Conference Room AP'\n" +
|
|
" * 'Server LACP Group 1'\n" +
|
|
" * 'VoIP Phone Port'",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
},
|
|
"port_profile_id": {
|
|
Description: "The ID of a pre-configured port profile to apply to this port. Port profiles define settings like VLANs, PoE, and other port-specific configurations.",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
},
|
|
"op_mode": {
|
|
Description: "The operating mode of the port. Valid values are:\n" +
|
|
" * `switch` - Normal switching mode (default)\n" +
|
|
" - Standard port operation for connecting devices\n" +
|
|
" - Supports VLANs and all standard switching features\n" +
|
|
" * `mirror` - Port mirroring for traffic analysis\n" +
|
|
" - Copies traffic from other ports for monitoring\n" +
|
|
" - Useful for network troubleshooting and security\n" +
|
|
" * `aggregate` - Link aggregation/bonding mode\n" +
|
|
" - Combines multiple ports for increased bandwidth\n" +
|
|
" - Used for switch uplinks or high-bandwidth servers",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
Default: "switch",
|
|
ValidateFunc: validation.StringInSlice([]string{"switch", "mirror", "aggregate"}, false),
|
|
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
|
|
if old == "" && new == "switch" {
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
},
|
|
"poe_mode": {
|
|
Description: "The Power over Ethernet (PoE) mode for the port. Valid values are:\n" +
|
|
"* `auto` - Automatically detect and power PoE devices (recommended)\n" +
|
|
" - Provides power based on device negotiation\n" +
|
|
" - Safest option for most PoE devices\n" +
|
|
"* `pasv24` - Passive 24V PoE\n" +
|
|
" - For older UniFi devices requiring passive 24V\n" +
|
|
" - Use with caution to avoid damage\n" +
|
|
"* `passthrough` - PoE passthrough mode\n" +
|
|
" - For daisy-chaining PoE devices\n" +
|
|
" - Available on select UniFi switches\n" +
|
|
"* `off` - Disable PoE on the port\n" +
|
|
" - For non-PoE devices\n" +
|
|
" - To prevent unwanted power delivery",
|
|
Type: schema.TypeString,
|
|
Optional: true,
|
|
ValidateFunc: validation.StringInSlice([]string{"auto", "pasv24", "passthrough", "off"}, false),
|
|
},
|
|
"aggregate_num_ports": {
|
|
Description: "The number of ports to include in a link aggregation group (LAG). Valid range: 2-8 ports. Used when:\n" +
|
|
"* Creating switch-to-switch uplinks for increased bandwidth\n" +
|
|
"* Setting up high-availability connections\n" +
|
|
"* Connecting to servers requiring more bandwidth\n" +
|
|
"Note: All ports in the LAG must be sequential and have matching configurations.",
|
|
Type: schema.TypeInt,
|
|
Optional: true,
|
|
ValidateFunc: validation.IntBetween(2, 8),
|
|
DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
|
|
if old == strconv.Itoa(0) && new == "" {
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
|
|
"allow_adoption": {
|
|
Description: "Whether to automatically adopt the device when creating this resource. When true:\n" +
|
|
"* The controller will attempt to adopt the device\n" +
|
|
"* Device must be in a pending adoption state\n" +
|
|
"* Device must be accessible on the network\n" +
|
|
"Set to false if you want to manage adoption manually.",
|
|
Type: schema.TypeBool,
|
|
Optional: true,
|
|
Default: true,
|
|
},
|
|
"forget_on_destroy": {
|
|
Description: "Whether to forget (un-adopt) the device when this resource is destroyed. When true:\n" +
|
|
"* The device will be removed from the controller\n" +
|
|
"* The device will need to be readopted to be managed again\n" +
|
|
"* Device configuration will be reset\n" +
|
|
"Set to false to keep the device adopted when removing from Terraform management.",
|
|
Type: schema.TypeBool,
|
|
Optional: true,
|
|
Default: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func resourceDeviceImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) {
|
|
c := meta.(*base.Client)
|
|
id := d.Id()
|
|
site := d.Get("site").(string)
|
|
if site == "" {
|
|
site = c.Site
|
|
}
|
|
|
|
if colons := strings.Count(id, ":"); colons == 1 || colons == 6 {
|
|
importParts := strings.SplitN(id, ":", 2)
|
|
site = importParts[0]
|
|
id = importParts[1]
|
|
}
|
|
|
|
if utils.MacAddressRegexp.MatchString(id) {
|
|
// look up id by mac
|
|
mac := utils.CleanMAC(id)
|
|
device, err := c.GetDeviceByMAC(ctx, site, mac)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id = device.ID
|
|
}
|
|
|
|
if id != "" {
|
|
d.SetId(id)
|
|
}
|
|
if site != "" {
|
|
d.Set("site", site)
|
|
}
|
|
|
|
return []*schema.ResourceData{d}, nil
|
|
}
|
|
|
|
func resourceDeviceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
|
c := meta.(*base.Client)
|
|
|
|
site := d.Get("site").(string)
|
|
if site == "" {
|
|
site = c.Site
|
|
}
|
|
|
|
mac := d.Get("mac").(string)
|
|
if mac == "" {
|
|
return diag.Errorf("no MAC address specified, please import the device using terraform import")
|
|
}
|
|
|
|
mac = utils.CleanMAC(mac)
|
|
device, err := c.GetDeviceByMAC(ctx, site, mac)
|
|
|
|
if device == nil {
|
|
return diag.Errorf("device not found using mac %q", mac)
|
|
}
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
if !device.Adopted {
|
|
if !d.Get("allow_adoption").(bool) {
|
|
return diag.Errorf("Device must be adopted before it can be managed")
|
|
}
|
|
|
|
err := c.AdoptDevice(ctx, site, mac)
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
device, err = waitForDeviceState(ctx, d, meta, unifi.DeviceStateConnected, []unifi.DeviceState{unifi.DeviceStateAdopting, unifi.DeviceStatePending, unifi.DeviceStateProvisioning, unifi.DeviceStateUpgrading}, 2*time.Minute)
|
|
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 {
|
|
c := meta.(*base.Client)
|
|
|
|
site := d.Get("site").(string)
|
|
if site == "" {
|
|
site = c.Site
|
|
}
|
|
|
|
req, err := resourceDeviceGetResourceData(d)
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
req.ID = d.Id()
|
|
req.SiteID = site
|
|
|
|
resp, err := c.UpdateDevice(ctx, site, req)
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
_, err = waitForDeviceState(ctx, d, meta, unifi.DeviceStateConnected, []unifi.DeviceState{unifi.DeviceStateAdopting, unifi.DeviceStateProvisioning}, 1*time.Minute)
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
return resourceDeviceSetResourceData(resp, d, site)
|
|
}
|
|
|
|
func resourceDeviceDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
|
c := meta.(*base.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 := retry.RetryContext(ctx, 1*time.Minute, func() *retry.RetryError {
|
|
internalErr := c.ForgetDevice(ctx, site, mac)
|
|
if internalErr == nil {
|
|
return nil
|
|
}
|
|
if base.IsServerErrorContains(internalErr, "api.err.DeviceBusy") {
|
|
return retry.RetryableError(internalErr)
|
|
}
|
|
return retry.NonRetryableError(internalErr)
|
|
})
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
_, err = waitForDeviceState(ctx, d, meta, unifi.DeviceStatePending, []unifi.DeviceState{unifi.DeviceStateConnected, unifi.DeviceStateDeleting}, 1*time.Minute)
|
|
if !errors.Is(err, unifi.ErrNotFound) {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func resourceDeviceRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
|
|
c := meta.(*base.Client)
|
|
|
|
id := d.Id()
|
|
|
|
site := d.Get("site").(string)
|
|
if site == "" {
|
|
site = c.Site
|
|
}
|
|
|
|
resp, err := c.GetDevice(ctx, site, id)
|
|
if errors.Is(err, unifi.ErrNotFound) {
|
|
d.SetId("")
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
return resourceDeviceSetResourceData(resp, d, site)
|
|
}
|
|
|
|
func resourceDeviceSetResourceData(resp *unifi.Device, d *schema.ResourceData, site string) diag.Diagnostics {
|
|
portOverrides, err := setFromPortOverrides(resp.PortOverrides)
|
|
if err != nil {
|
|
return diag.FromErr(err)
|
|
}
|
|
|
|
d.Set("site", site)
|
|
d.Set("mac", resp.MAC)
|
|
d.Set("name", resp.Name)
|
|
d.Set("disabled", resp.Disabled)
|
|
d.Set("port_override", portOverrides)
|
|
|
|
return nil
|
|
}
|
|
|
|
func resourceDeviceGetResourceData(d *schema.ResourceData) (*unifi.Device, error) {
|
|
pos, err := setToPortOverrides(d.Get("port_override").(*schema.Set))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to process port_override block: %w", err)
|
|
}
|
|
|
|
//TODO: pass Disabled once we figure out how to enable the device afterwards
|
|
|
|
return &unifi.Device{
|
|
MAC: d.Get("mac").(string),
|
|
Name: d.Get("name").(string),
|
|
PortOverrides: pos,
|
|
}, nil
|
|
}
|
|
|
|
func setToPortOverrides(set *schema.Set) ([]unifi.DevicePortOverrides, error) {
|
|
// use a map here to remove any duplication
|
|
overrideMap := map[int]unifi.DevicePortOverrides{}
|
|
for _, item := range set.List() {
|
|
data, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("unexpected data in block")
|
|
}
|
|
po, err := toPortOverride(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create port override: %w", err)
|
|
}
|
|
overrideMap[po.PortIDX] = po
|
|
}
|
|
|
|
pos := make([]unifi.DevicePortOverrides, 0, len(overrideMap))
|
|
for _, item := range overrideMap {
|
|
pos = append(pos, item)
|
|
}
|
|
return pos, nil
|
|
}
|
|
|
|
func setFromPortOverrides(pos []unifi.DevicePortOverrides) ([]map[string]interface{}, error) {
|
|
list := make([]map[string]interface{}, 0, len(pos))
|
|
for _, po := range pos {
|
|
v, err := fromPortOverride(po)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to parse port override: %w", err)
|
|
}
|
|
list = append(list, v)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func toPortOverride(data map[string]interface{}) (unifi.DevicePortOverrides, error) {
|
|
idx := data["number"].(int)
|
|
name := data["name"].(string)
|
|
profileID := data["port_profile_id"].(string)
|
|
opMode := data["op_mode"].(string)
|
|
poeMode := data["poe_mode"].(string)
|
|
aggregateNumPorts := data["aggregate_num_ports"].(int)
|
|
|
|
return unifi.DevicePortOverrides{
|
|
PortIDX: idx,
|
|
Name: name,
|
|
PortProfileID: profileID,
|
|
OpMode: opMode,
|
|
PoeMode: poeMode,
|
|
AggregateNumPorts: aggregateNumPorts,
|
|
}, nil
|
|
}
|
|
|
|
func fromPortOverride(po unifi.DevicePortOverrides) (map[string]interface{}, error) {
|
|
return map[string]interface{}{
|
|
"number": po.PortIDX,
|
|
"name": po.Name,
|
|
"port_profile_id": po.PortProfileID,
|
|
"op_mode": po.OpMode,
|
|
"poe_mode": po.PoeMode,
|
|
"aggregate_num_ports": po.AggregateNumPorts,
|
|
}, nil
|
|
}
|
|
|
|
func waitForDeviceState(ctx context.Context, d *schema.ResourceData, meta interface{}, targetState unifi.DeviceState, pendingStates []unifi.DeviceState, timeout time.Duration) (*unifi.Device, error) {
|
|
c := meta.(*base.Client)
|
|
|
|
site := d.Get("site").(string)
|
|
mac := d.Get("mac").(string)
|
|
|
|
if site == "" {
|
|
site = c.Site
|
|
}
|
|
|
|
// Always consider unknown to be a pending state.
|
|
pendingStates = append(pendingStates, unifi.DeviceStateUnknown)
|
|
|
|
var pending []string
|
|
for _, state := range pendingStates {
|
|
pending = append(pending, state.String())
|
|
}
|
|
|
|
wait := retry.StateChangeConf{
|
|
Pending: pending,
|
|
Target: []string{targetState.String()},
|
|
Refresh: func() (interface{}, string, error) {
|
|
device, err := c.GetDeviceByMAC(ctx, site, mac)
|
|
|
|
if errors.Is(err, unifi.ErrNotFound) {
|
|
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
|
|
},
|
|
Timeout: timeout,
|
|
NotFoundChecks: 30,
|
|
}
|
|
|
|
outputRaw, err := wait.WaitForStateContext(ctx)
|
|
|
|
if output, ok := outputRaw.(*unifi.Device); ok {
|
|
return output, err
|
|
}
|
|
|
|
return nil, err
|
|
}
|