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.
1. What the probe found
Section titled “1. What the probe found”The fork is pinned at v0.56.0-fzymgc.1, with go-unifi v1.33.43-0.20260824085237-ead99009383e.
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 defaulttrue(device_resource.go:1337-1341). Withtrue, Delete callsForgetDevice, which un-adopts the switch. The flag only matters ifunifi_devicemanages the switch. This design does not useunifi_device, so the problem goes away.port_overrideafter 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.- Fields the provider does not model. The UniFi PUT replaces
port_overridesas a whole array. go-unifi’sDevicePortOverrides(device.generated.go:451) has nolag_idx,stp_edge_state,stp_bpdu_guard_enabled,multicast_router_modeorsd_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-unifiv1.34.xadds four of these fields, but notlag_idx. It also has no passthrough for unknown fields. Ruleqa8kh6gteaforbids 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: nullwhen 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.
2. Decisions
Section titled “2. Decisions”| # | 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 |
3. Resource shape
Section titled “3. Resource shape”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.
4. Lifecycle
Section titled “4. Lifecycle”| 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.
6. Upstream
Section titled “6. Upstream”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.
- go-unifi: the exported raw-request method.
- Provider:
unifi_device_portsand 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.
7. Adoption in tf/unifi
Section titled “7. Adoption in tf/unifi”After the fork releases 0.56.0-fzymgc.2:
- Bump the exact pin and update the lock file with both the
linux_arm64anddarwin_arm64hashes. - Add
unifi_device_ports.switch_mainwith all 22 live entries. Values come from a fresh View Only read, never from memory. Networks are referenced throughunifi_network.*.id. Add an import block by MAC andprevent_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.