package v1 import ( "context" "errors" "fmt" "github.com/filipowm/terraform-provider-unifi/internal/provider" "strconv" "strings" "time" "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: "`unifi_device` manages a device of the network.\n\n" + "Devices are adopted by the controller, so it is not possible for this resource to be created through " + "Terraform, the create operation instead will simply start managing the device specified by MAC address. " + "It's safer to start this process with an explicit import of the device.", CreateContext: resourceDeviceCreate, ReadContext: resourceDeviceRead, UpdateContext: resourceDeviceUpdate, DeleteContext: resourceDeviceDelete, Importer: &schema.ResourceImporter{ StateContext: resourceDeviceImport, }, Schema: map[string]*schema.Schema{ "id": { Description: "The ID of the device.", Type: schema.TypeString, Computed: true, }, "site": { Description: "The name of the site to associate the device with.", Type: schema.TypeString, Computed: true, Optional: true, ForceNew: true, }, "mac": { Description: "The MAC address of the device. This can be specified so that the provider can take control of a device (since devices are created through adoption).", Type: schema.TypeString, Optional: true, Computed: true, ForceNew: true, DiffSuppressFunc: macDiffSuppressFunc, ValidateFunc: validation.StringMatch(macAddressRegexp, "Mac address is invalid"), }, "name": { Description: "The name of the device.", Type: schema.TypeString, Optional: true, Computed: true, }, "disabled": { Description: "Specifies whether this device should be disabled.", Type: schema.TypeBool, Computed: true, }, "port_override": { Description: "Settings overrides for specific switch ports.", // TODO: this should really be a map or something when possible in the SDK // see https://github.com/hashicorp/terraform-plugin-sdk/issues/62 Type: schema.TypeSet, Optional: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "number": { Description: "Switch port number.", Type: schema.TypeInt, Required: true, }, "name": { Description: "Human-readable name of the port.", Type: schema.TypeString, Optional: true, }, "port_profile_id": { Description: "ID of the Port Profile used on this port.", Type: schema.TypeString, Optional: true, }, "op_mode": { Description: "Operating mode of the port, valid values are `switch`, `mirror`, and `aggregate`.", 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: "PoE mode of the port; valid values are `auto`, `pasv24`, `passthrough`, and `off`.", Type: schema.TypeString, Optional: true, ValidateFunc: validation.StringInSlice([]string{"auto", "pasv24", "passthrough", "off"}, false), }, "aggregate_num_ports": { Description: "Number of ports in the aggregate.", 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: "Specifies whether this resource should tell the controller to adopt the device on create.", Type: schema.TypeBool, Optional: true, Default: true, }, "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, }, }, } } func resourceDeviceImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { c := meta.(*provider.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 macAddressRegexp.MatchString(id) { // look up id by mac mac := 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.(*provider.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 = 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.(*provider.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.(*provider.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.ForgetDevice(ctx, site, mac) 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.(*provider.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.(*provider.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 }