Skip to content

unifi_device_ports: one way to define switch ports

What: a new resource, unifi_device_ports, in our provider fork. It declares a UniFi device’s complete port configuration and becomes the only way port settings are managed. With it, tf/unifi adopts switch-main’s ports (#2118) as an import with 0 changes.

Why: the quick-task probe 260919-jrm stopped the #2118 adoption. The fork’s unifi_device cannot import switch-main at 0 changes, and its port write path deletes fields it does not model. The evidence is in .planning/workstreams/milestone/quick/260919-jrm-2118-manage-unifi-port-profiles-and-per-/260919-jrm-SUMMARY.md.

Status: design agreed with Sean on 2026-09-19. Implementation is planned in docs/engineering/plans/2026-09-19-unifi-device-ports.md.

The fork is pinned at v0.56.0-fzymgc.1, with go-unifi v1.33.43-0.20260824085237-ead99009383e.

  1. forget_on_destroy. The flag lives only in the provider. After an import, Read has no config and nothing on the controller to read, so it writes the schema default true (device_resource.go:1337-1341). With true, Delete calls ForgetDevice, which un-adopts the switch. The flag only matters if unifi_device manages the switch. This design does not use unifi_device, so the problem goes away.
  2. port_override after import. Read leaves the block null when prior state is null (:1348-1349), so any declared block plans a change. The plugin framework does not allow blocks to be Computed, so no patch to the inline block can make an import converge.
  3. Fields the provider does not model. The UniFi PUT replaces port_overrides as a whole array. go-unifi’s DevicePortOverrides (device.generated.go:451) has no lag_idx, stp_edge_state, stp_bpdu_guard_enabled, multicast_router_mode or sd_wan_underlay_port. The first port edit would strip them from all 22 overridden ports on switch-main, including the resolver trunks (12, 13) and the LAG lead ports (6, 23, 25). go-unifi v1.34.x adds four of these fields, but not lag_idx. It also has no passthrough for unknown fields. Rule qa8kh6gtea forbids letting a resource Update under these conditions.

How the inline block evolved upstream:

  • The original provider (paultyng) had a small block keyed to port profiles.
  • ubiquiti-community grew it to about 40 fields.
  • #266 fixed a silent wipe of undeclared ports by merging by index.
  • #438 fixed port_overrides: null when no blocks are declared.

Today the block is partial: it manages only the ports you declare, and removing a block does not reset that port.

# Decision Why
D1 One way to define ports. unifi_device_ports is the only mechanism. The inline unifi_device.port_override block is deprecated in the fork, and we propose the same deprecation upstream. It is not removed Sean: “I don’t want 700 ways of doing ports.” Deprecating instead of removing keeps the fork compatible with upstream configs and keeps rebases cheap
D2 Authoritative, one resource per device. A ports map keyed by port index holds every port that differs from the default. A port that is not listed runs the default config The API’s unit of write is the device’s whole port_overrides array: one PUT, full replace. The resource maps 1:1 onto that write, needs no lock, and has no second writer. Only an authoritative resource makes a new UI change to an undeclared port show up in the plan, which is the risk #2118 names
D3 A port definition, not an override. Create defines the device’s ports. Delete resets every port to the default config by dropping all entries. To stop managing a device, use removed { lifecycle { destroy = false } }. There is no forget_on_destroy-style flag A physical port always exists. “Override” is how the controller stores the data, not what the user manages
D4 Field names follow the existing block and the API: index, native_networkconf_id, excluded_networkconf_ids, port_profile_id, and so on Makes the new resource easy to adopt and easy to review upstream
D5 One VLAN model: native_networkconf_id, tagged_vlan_mgmt (auto, block_all, custom) and excluded_networkconf_ids. The legacy forward is not in the schema. The provider keeps it consistent, and the acceptance tests decide how (§5) One way inside a port too. Live data shows the two fields can disagree: port 10 has forward: customize and tagged_vlan_mgmt: auto
D6 Every per-port key is Optional and Computed. A key left out of config is read from the controller and left alone on write. A key in config is managed An import plans 0 changes without declaring every key. Caveat: removing a key from config does not revert it on the controller
D7 Writes patch raw JSON. Read the device’s raw JSON, patch only the declared keys by index, and PUT {"port_overrides": [...]} alone. Never marshal typed structs, and never send the whole device Keys the provider does not model survive (rule qa8kh6gtea), the schema can grow gradually without losing data, and fields outside the override list are never touched
D8 Port profiles are an attribute, not a second path. port_profile_id is one optional key of a port definition switch-main uses no profile. Whether profiles belong in our config is decided when the other switches are adopted
D9 Identity is the device MAC plus the site. The import ID is the MAC The device _id changes if the switch is forgotten and re-adopted. The MAC does not. unifi_device also imports by MAC
D10 go-unifi gets a minimal fork. A fzymgc-house/go-unifi branch adds one exported raw-request method. The provider fork consumes it through a replace directive, and we send the same change upstream as a PR go-unifi exposes no raw access: do() is private and UpdateDevice diffs typed structs. Adding typed fields only fixes the fields known today. This follows the fork policy (memory jbwzhkw8wx): fix in our fork first and never wait on upstream
resource "unifi_device_ports" "switch_main" {
mac = local.switch_main_mac
ports = {
"6" = {
name = "switch-office"
op_mode = "aggregate"
aggregate_members = [6, 7]
}
"12" = {
name = "sandiego"
native_networkconf_id = unifi_network.core.id
tagged_vlan_mgmt = "auto" # every VLAN tagged
}
# ...every port that differs from the default config
}
lifecycle {
prevent_destroy = true
}
}
import {
to = unifi_device_ports.switch_main
id = local.switch_main_mac
}

Top-level attributes:

Attribute Kind Notes
mac Required Device MAC. Replaced only when the parsed address changes; a different spelling of the same address (case, dashes) is an in-place update, because a replacement would reset every port
site Optional, Computed, RequiresReplace Defaults to the provider site
id Computed Controller _id, for reference only
ports Required map of nested attributes Keyed by the port index as a string. A map gives per-port plan diffs. A list would renumber on insert, and a set would show every change as a whole-element replace

Per-port attributes: all are Optional and Computed unless marked. Each one carries UseStateForUnknown, which keeps the plan quiet for ports whose declared values do not change. On a port whose declared values do change, and on a new port, a plan modifier marks every undeclared attribute as “known after apply” instead: the controller rewrites some keys as a side effect of a write (lag_idx on a new aggregation lead, voice_networkconf_id when a port stops tagging VLANs), and a pinned prior value would fail the apply. Version 1 exposes the keys present on switch-main today. Any other key is carried through untouched by D7.

Group Attributes
Identity name
VLANs (D5) native_networkconf_id, tagged_vlan_mgmt, excluded_networkconf_ids (set), voice_networkconf_id
Profile (D8) port_profile_id
Mode and LAG op_mode (switch, mirror, aggregate), aggregate_members (list), mirror_port_idx, lag_idx (Computed only, assigned by the controller)
Link autoneg, speed, full_duplex, poe_mode, port_keepalive_enabled
STP and multicast stp_port_mode, stp_edge_state, stp_bpdu_guard_enabled, multicast_router_mode
Security and 802.1X isolation, port_security_enabled, port_security_mac_address, dot1x_ctrl, dot1x_idle_timeout (integer seconds, the controller’s own type, with no schema default)
Traffic egress_rate_limit_kbps_enabled, lldpmed_enabled, stormctrl_{bcast,mcast,ucast}_{enabled,rate}, setting_preference, sd_wan_underlay_port

A LAG is declared on its lead port. Member ports (7, 24, 26 on switch-main) have no entry of their own.

Operation Behaviour
Read Raw GET of the device by MAC. ports gets one element per port_overrides entry, with every modelled key filled from the controller. An entry for a port not in config is a planned removal, which is the drift #2118 asks to see
Create Raw GET. If the device already has any entry, fail with “device has N port definitions; import it”. Adoption is always explicit. Otherwise PUT the declared entries
Update Raw GET. For each declared port, start from the live raw entry (or {} for a new one), set the declared keys and keep every other key. Drop entries whose index is not declared. PUT {"port_overrides": [...]}
Delete PUT {"port_overrides": []}, which resets every port to the default. tf/unifi guards the resource with prevent_destroy
Import By MAC. Read fills every entry, so a config that states the live values plans 1 to import, 0 to change

One resource per device is a documented constraint. Two unifi_device_ports resources on the same MAC, or unifi_device_ports together with inline port_override blocks, would overwrite each other. Nothing can check this across resources.

unifi_device without port_override blocks can safely manage the same device’s other attributes. Its typed diff leaves port_overrides out of the PUT when that field has not changed. tf/unifi does not need unifi_device at all.

5. What the controller does with forward (settled)

Section titled “5. What the controller does with forward (settled)”

Measured against the demo controller (Network 10.0.162): forward is output-only. The controller derives it from tagged_vlan_mgmt (auto gives all, block_all gives native, custom gives customize) and ignores writes to it, so the provider never touches it. Two related findings: a custom port that excludes every VLAN network is stored as block_all, and an unknown key in a port_overrides entry is dropped by the controller, with retention of the STP and multicast keys depending on the device model. Unit tests with a synthetic fixture prove unmodelled keys survive the provider; the controller decides what it keeps.

These changes land in our forks first and are proposed upstream in parallel. Each post needs Sean’s go-ahead at the time of posting, as with #2117. Both are tracked in #2148 and are blocked until upstream main compiles again: checked 2026-09-19 at 6c535130, it fails on DeviceRadioTable.AssistedRoaming* and on a missing settings.SettingIpsSuppression, so a PR on that tree cannot pass CI.

  1. go-unifi: the exported raw-request method.
  2. Provider: unifi_device_ports and the deprecation of the inline block. The PR must say plainly that the new resource is authoritative on purpose and the plan shows it, unlike the accidental whole-list replace that #266 fixed.

Not planned: fixing the inline block’s own update path so it stops dropping unmodelled keys (#2143, closed). The defect is reachable only through a block this repository does not use and which our fork deprecates. The sketch stays in §1.3 in case that changes.

After the fork releases 0.56.0-fzymgc.2:

  • Bump the exact pin and update the lock file with both the linux_arm64 and darwin_arm64 hashes.
  • Add unifi_device_ports.switch_main with all 22 live entries. Values come from a fresh View Only read, never from memory. Networks are referenced through unifi_network.*.id. Add an import block by MAC and prevent_destroy.
  • Acceptance: a speculative plan prints exactly Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
  • Rule qa8kh6gtea: the design passes structurally (D7). The provider’s unit tests prove it with a fixture holding unknown keys.
  • Update docs/operations/unifi.md: remove ports from “Not yet managed” and add a “Changing a port” procedure.
  • The other switches, and the question of profiles, are out of scope.