23 KiB
Invoke-RackpadPopulation
Idempotently populates one or more Rackpad labs from live infrastructure.
The live systems are authoritative. The settings document only supplies what no API can know, which is the physical layout consisting of rooms, racks, and rack unit placement, together with the address ranges that serve them. It cannot create devices, VLANs, subnets, or SSIDs.
Every run is safe to repeat. Objects are matched on stable natural keys, only differing properties are transmitted, and a run against an unchanged environment produces no write traffic at all.
Ownership model
| Source | Owns |
|---|---|
| Omada | Switches, access points, switch ports, cables and topology, VLANs, wireless controller, SSIDs, radios, client associations |
| UniFi | The same shapes as Omada, and can run alongside it |
| Proxmox | Hypervisor hosts, bridges, guests, guest network interfaces. One entry per cluster and per standalone host |
| Hyper-V | The same shapes as Proxmox, and can run alongside it |
| OPNsense | VLANs, subnets, virtual IP addresses, the routed edge, firewall interfaces |
| Public IP lookup | The WAN address as a /32 subnet carrying ISP, ASN, and location detail |
| Settings document | Rooms, racks, placement rules, IP ranges |
DHCP is deliberately out of scope. No DHCP scope is ever created, updated, or deleted, and IP assignments are always written using the static allocation mode. DHCP stays a manual concern inside Rackpad.
How the layer two and layer three split is modelled
The lab has physical layer two equipment and a virtualized layer three edge. The graph is built so that both read correctly in the Rackpad user interface at the same time.
graph TD
subgraph Physical["Room and rack, from the settings document"]
RACK["Rack"]
SW["Switch, rack placed"]
AP["Access point, room placed"]
HOST["Hypervisor host, rack placed"]
RACK --> SW
RACK --> HOST
end
subgraph L2["Layer two, from Omada"]
SWPORT["Switch ports, rj45 and sfp_plus"]
UPLINK["Access point uplink port"]
CABLE["Cable links"]
SSID["SSIDs and radios"]
SW --> SWPORT
AP --> UPLINK
SWPORT -->|uplink and downlink topology| CABLE
UPLINK --> CABLE
AP --> SSID
end
subgraph L3["Layer three, virtualized"]
VSW["Virtual switches, bridges on the host"]
FW["Firewall, virtual, parented to its host"]
VM["VMs and containers, virtual"]
VNIC["Virtual ports, attached to a virtual switch"]
HOST --> VSW
HOST --> FW
HOST --> VM
FW --> VNIC
VM --> VNIC
VNIC --> VSW
end
subgraph IPAM["Addressing"]
VLAN["VLANs"]
SUBNET["Subnets"]
ZONE["IP zones, ranges per room and rack"]
ASSIGN["IP assignments"]
WAN["WAN slash 32, ISP and ASN"]
VLAN --> SUBNET
SUBNET --> ZONE
SUBNET --> ASSIGN
FW --> VLAN
FW --> WAN
end
The result is a rack elevation that reflects reality, alongside a virtual topology that explains where layer three actually lives: the firewall appears as a virtual device parented to its hypervisor, its interfaces appear as ports attached to the correct bridge, and the VLANs and subnets it serves appear in the Networks workspace.
Execution flow
flowchart TD
START([Start]) --> READ["Read the settings document, expand variables, load with XPath"]
READ --> LOOP{"For each enabled instance"}
LOOP --> AUTH["Sign in to Rackpad and capture the bearer token"]
AUTH --> LAB["Resolve the lab by name, creating it when permitted"]
LAB --> INV["Pre-load every collection, about twenty reads"]
INV --> STATE["Load the managed state document"]
STATE --> COLLECT{"Collect enabled sources"}
COLLECT --> OMADA["Omada, controller interface preferred"]
COLLECT --> UNI["UniFi, network interface preferred"]
COLLECT --> PVE["Proxmox, one call per endpoint"]
COLLECT --> HV["Hyper-V, local or over a CIM session"]
COLLECT --> OPN["OPNsense"]
COLLECT --> PUB["Public address lookup"]
OMADA --> MERGE
UNI --> MERGE
PVE --> MERGE
HV --> MERGE
OPN --> MERGE
PUB --> MERGE
MERGE["Build the desired state graph on natural keys"] --> PUBLISH["Publish in 13 dependency phases"]
PUBLISH --> DIFF{"Per object"}
DIFF -->|no match| CREATE["Create, then record ownership"]
DIFF -->|match, differs| UPDATE["Patch only the differing properties"]
DIFF -->|match, identical| SKIP["No request"]
CREATE --> PRUNE
UPDATE --> PRUNE
SKIP --> PRUNE
PRUNE["Prune recorded objects no longer wanted"] --> SAVE["Save the managed state document"]
SAVE --> LOOP
LOOP -->|done| EXIT([Exit code])
Publication phases
Objects are written in dependency order so that everything referenced already exists.
| # | Phase | Depends on |
|---|---|---|
| 1 | Lab | — |
| 2 | VLANs | Lab |
| 3 | Rooms | Lab |
| 4 | Racks | Rooms |
| 5 | Physical devices | Racks, Rooms |
| 6 | Virtual and attached devices | Physical devices |
| 7 | Virtual switches | Host devices |
| 8 | Ports | Devices, VLANs, virtual switches |
| 9 | Subnets | VLANs |
| 10 | IP zones | Subnets |
| 11 | IP assignments | Subnets, devices |
| 12 | Cable links | Ports |
| 13 | Wireless | Devices, VLANs, SSIDs |
Idempotency and pruning
Every object is identified by a natural key rather than by a Rackpad identifier.
| Entity | Natural key |
|---|---|
| Room, Rack | lab + name |
| Device | lab + hostname |
| Port | hostname + port name |
| Cable link | both port keys, sorted so direction does not matter |
| Client association | client hostname |
| VLAN | lab + VLAN id |
| Subnet | lab + CIDR |
| IP zone | subnet CIDR + start + end |
| IP assignment | subnet CIDR + address |
| Virtual switch | host hostname + name |
| SSID | lab + name |
| Radio | AP hostname + slot name |
Pruning only ever deletes objects that this script created. Each created object is recorded in Data/<InstanceName>.state.json. On the next run, any recorded entry whose natural key is no longer present in the desired state is deleted, in reverse dependency order. Objects created by hand in the Rackpad user interface are never recorded, and therefore never pruned. A 404 during deletion counts as success, which keeps the operation repeatable.
Delete the state document and the script loses the ability to prune, but it will never delete anything unexpected.
Getting started
-
Populate the environment variables the settings document references. User and machine scoped variables are promoted into the process automatically, so a variable created after the shell started still resolves.
RACKPAD_BASE_URL, RACKPAD_USERNAME, RACKPAD_PASSWORD OMADA_API_BASE_URL, OMADA_API_CLIENT_ID, OMADA_API_CLIENT_SECRET OMADA_API_USERNAME, OMADA_API_PASSWORD (controller login: topology, VLANs, SSIDs) UNIFI_API_BASE_URL, UNIFI_API_USERNAME, UNIFI_API_PASSWORD, UNIFI_API_KEY PROXMOX_API_BASE_URL, PROXMOX_API_TOKEN_ID, PROXMOX_API_TOKEN_SECRET OPNSENSE_API_BASE_URL, OPNSENSE_API_KEY, OPNSENSE_API_SECRET HYPERV_USERNAME, HYPERV_PASSWORD, HYPERV_GUEST_USERNAME, HYPERV_GUEST_PASSWORD -
Edit
Settings/Invoke-RackpadPopulation.xml. SetInstance/@Enabledtotrue, name the lab, enable the sources you have credentials for, and describe your rooms, racks, and placements. -
Preview.
.\Invoke-RackpadPopulation.ps1 -WhatIf -Verbose -
Apply.
.\Invoke-RackpadPopulation.ps1 -Verbose
-WhatIfreports the first few phases accurately, then under-reports. Because nothing is actually created, later phases cannot resolve the identifiers of objects that a real run would have created, so ports, cables, and wireless show as zero on a first preview against an empty lab. Preview against an already populated lab and the numbers are exact.
-Verbose reports every individual create, update, and skip, including which properties differed. Without it only the per-entity summary is written.
Parameters
| Parameter | Purpose |
|---|---|
-ConfigurationPath |
Overrides the settings document. Defaults to Settings\<ScriptBaseName>.xml. |
-InstanceName |
Processes only the named instances. |
-SkipPrune |
Suppresses pruning for this run. |
-LogDirectory |
Overrides the transcript directory. |
-ContinueOnError |
Continues to the next instance after a failure. |
-WhatIf |
Previews without writing. |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Every enabled instance reconciled successfully |
| 1000 | Completed, but one or more individual objects failed to write |
| 2000 | An instance failed outright |
| 6000 | The toolkit failed to load |
Settings document
The document is named after the script, so Invoke-RackpadPopulation.ps1 reads Settings\Invoke-RackpadPopulation.xml.
Multiple instances live in one file, each gated by Enabled. Values are expanded in three passes before parsing:
- Environment variables in the
%NAME%form, viaSystem.Environment. - PowerShell expressions, via
$ExecutionContext.InvokeCommand.ExpandString, so$env:NAMEand$($env:NAME)resolve. - The result is loaded into a
System.Xml.XmlDocumentand navigated with XPath.
Because pass two evaluates PowerShell, treat this document with the same care as a script, and escape any literal
$that is not meant as an expression with a backtick.
Full PowerShell is available, which is useful for tolerating variable naming drift:
<Omada BaseURL="$(if ($env:OMADA_API_BASE_URL) { $env:OMADA_API_BASE_URL } else { $env:OAMDA_API_BASE_URL })">
Secrets belong in CDATA sections so reserved XML characters survive. Prefer keeping the value in an environment variable and referencing it.
<Rackpad BaseURL="$env:RACKPAD_BASE_URL" SkipCertificateCheck="false">
<Username><![CDATA[$env:RACKPAD_USERNAME]]></Username>
<Password><![CDATA[$env:RACKPAD_PASSWORD]]></Password>
</Rackpad>
Rooms, racks, and placement
Leave Rooms and Racks empty and every discovered device stays loose in the lab. Declare them and they are created, and Placement rules file matching equipment into them.
Placement is rule based rather than per device. InclusionExpression and ExclusionExpression are regular expressions matched case insensitively against whichever value MatchBy selects (Hostname, MAC, or Serial). A rule matches when the inclusion expression matches and the exclusion expression, if present, does not.
Rules are evaluated in order and the first match wins, so specific rules go above general ones, exactly as in an access list. A handful of rules covers an entire estate:
<Placements>
<!-- Wireless first, so the catch-all never claims an access point. -->
<Placement InclusionExpression=".*-AP-.*" MatchBy="Hostname" Room="Server Room" Enabled="true" />
<!-- The one device worth pinning to a specific rack unit. -->
<Placement InclusionExpression=".*-SW-0*1$" MatchBy="Hostname" Rack="RACK-01" StartU="42" Enabled="true" />
<!-- Everything else racks, except storage. -->
<Placement InclusionExpression=".*" ExclusionExpression=".*NAS.*|.*STORAGE.*" MatchBy="Hostname" Rack="RACK-01" Enabled="true" />
</Placements>
StartU is optional. Rackpad requires a rack unit for anything rack placed, so a device matched by a broad rule is assigned the highest free block that fits. Assignment is sticky: a device that already holds a position keeps it, so the elevation never reshuffles between runs. If a rack has no free block left, the device is placed loose in the room and a warning is written rather than the write failing.
The literal Match attribute from earlier versions is still accepted and is treated as an anchored exact expression.
IP ranges
Ranges become IP zones inside a subnet that a source already discovered. Kind accepts static, reserved, or infrastructure; a dhcp kind is rejected and downgraded to static. Room and Rack are recorded in the zone description so the range stays attributable.
<IPRange Subnet="10.0.0.0/24" Kind="infrastructure" StartIP="10.0.0.1" EndIP="10.0.0.31" Room="Server Room" Rack="RACK-01" Enabled="true" />
Layout
Invoke-RackpadPopulation.ps1 Orchestration only
Settings/
Invoke-RackpadPopulation.xml Named after the script
Data/
<Instance>.state.json What this script created, used for pruning
Toolkit/
Toolkit.ps1 Dot-sources Functions, imports Modules, manages the transcript
Functions/
Invoke-RestAPIRequest.ps1 Shared transport: retry, rate limits, status translation
Get-RackpadPopulationConfiguration.ps1
Connect-RackpadInstance.ps1
Invoke-RackpadRequest.ps1
Get-RackpadInventory.ps1
Get-RackpadManagedState.ps1
Save-RackpadManagedState.ps1
Sync-RackpadObject.ps1 The create / update / no-op primitive
Remove-RackpadOrphanedObject.ps1
Connect-OmadaController.ps1 Chooses the controller interface or the Open API
Invoke-OmadaRequest.ps1 Per-request degradation between the two
Get-OmadaInventory.ps1
Connect-UnifiController.ps1 Chooses the network interface or the integration API
Invoke-UnifiRequest.ps1 Per-request degradation between the two
Get-UnifiInventory.ps1
Get-ColorPalette.ps1 Shared palette for VLAN and SSID colors
Get-ProxmoxInventory.ps1
Get-HyperVInventory.ps1
Get-OPNsenseInventory.ps1
Get-PublicIPInformation.ps1
ConvertTo-RackpadDesiredState.ps1
Publish-RackpadDesiredState.ps1
Modules/
Indented.Net.IP/ Bundled; supplies all subnet arithmetic
Verification status
| Area | Status |
|---|---|
| Rackpad create / update / no-op | Verified live. 9 devices, 117 ports, rack, room, subnet, wireless all created, then a repeat run reported every entity Unchanged with zero write traffic. |
| Pruning | Verified live. 133 objects removed, target lab confirmed empty, neighbouring lab untouched. |
| Omada controller interface | Verified live. 9 devices, 117 ports, 16 VLANs, real SSID configuration, and 5 inter-switch cables drawn with both ends known. |
| Combined layer two and layer three | Verified live. 10 devices, 136 ports, 16 VLANs, 18 subnets with 13 linked to their VLAN, 14 IP assignments, all colored, repeat run entirely unchanged. |
| VLAN colors | Verified live. 16 VLANs, all distinct, stable across runs. |
| Omada Open API fallback | Verified live. Sites, devices, switch port lists, and client sessions. |
| UniFi collection | Unverified. Written against the documented network and integration API shapes; no controller was available. The Omada path was regression tested after the shared refactor. |
| Placement rules and rack allocation | Verified live. Regex inclusion and exclusion, first match wins, automatic rack units, and stickiness across runs. |
| OPNsense collection | Verified live. 21 interfaces, 13 VLANs, 18 derived subnets, 3 virtual IPs, 4 gateways. |
| Virtual IP handling | Verified live. Two addresses inside the WAN block became assignments; a third outside it contributed its own subnet. |
| Hyper-V collection | Verified live against localhost. Host detail, virtual switches with correct kinds, guest with adapter, MAC, and bridge. |
| Proxmox collection | Unverified. Written against the documented API shape; no credentials were available. |
| Multiple Proxmox endpoints | Unverified. Overlapping endpoints collapse duplicates on the natural key. |
Service rules worth knowing
These are constraints the Rackpad API enforces that the collectors have to respect. Each was found by a failed write rather than from documentation.
| Rule | How it is handled |
|---|---|
| A rack placed device must carry a start unit | Devices matched by a broad placement rule are allocated the highest free block that fits, and keep it thereafter |
| Rack positions may not overlap | Declared positions are reserved in a prior pass, before anything is allocated |
| A subnet may not overlap another subnet in the same lab | The public address only contributes a /32 when no reported subnet already covers it |
| A subnet gateway may not be an ordinary endpoint assignment | An assignment whose address equals its subnet gateway is reclassified as infrastructure |
| A management address must be a valid IPv4 address | The routed edge resolves to an internal interface address rather than the hostname it is reached by |
| A client association requires the client to exist as a device | Associations are only emitted when ManageClientEndpoints creates those devices |
| Optional fields are defaulted rather than nulled | Rack geometry is omitted entirely for anything not rack placed, so it never reports a phantom change |
Implementation notes
Transport. Everything flows through one System.Net.Http based function. Invoke-RestMethod is not used, because Omada transmits its token as Authorization: AccessToken=<value>, which standard clients reject during header validation. The transport adds headers without validation, translates status codes into readable text, honours Retry-After, and retries 408, 429, and 5xx with linear backoff.
Subnet arithmetic is delegated to the bundled Indented.Net.IP module, using Get-NetworkSummary and Test-SubnetMember.
Omada and UniFi are handled identically. Both collectors emit the same normalized model, and the desired state graph is built from a combined site list rather than by branching on vendor, so an estate running both is a single picture. Each controller still contributes its own controller record, and every access point is attributed to the controller that reported it.
Omada has two transports and prefers the richer one.
The controller interface, under /{omadacId}/api/v2, is what the Omada web interface consumes. It reports switch uplinks and downlinks including the peer port, per port LLDP neighbours, VLAN membership and media type per port, VLAN definitions with gateway subnets, and the real SSID configuration. It needs a controller login and is not a published contract.
The Open API, under /openapi/v1, is published and versioned but narrower: no uplinks and no SSID configuration, which forces SSIDs to be inferred from whichever clients happen to be associated.
Supply both and the controller interface is used with the Open API held in reserve. Degradation is per request, so an expired session or an unimplemented route costs only that data. Set PreferControllerInterface to false to pin collection to the Open API.
Two controller quirks are handled rather than hard-coded: some routes reject paging parameters while others require them, so a rejected first page is retried without paging; and collections arrive either as a bare array or as a data array with a total, which must be distinguished carefully because asking a bare array for .data returns an empty array rather than null.
UniFi mirrors the Omada arrangement. The network interface, at /api/s/{site}/..., carries per port media, LLDP neighbour tables, uplinks with the remote port, network definitions with subnets, and wireless configuration; it needs a controller login. The integration API, at /integration/v1/..., is published and versioned but narrower, covering sites, devices, and clients only; it needs an API key.
Both deployment styles are detected rather than configured. A UniFi OS host signs in at /api/auth/login and proxies the network application beneath /proxy/network; an older self hosted controller signs in at /api/login and serves it from the root. The collector tries both and keeps whichever returns a session cookie.
Cables. Access-to-switch and host-to-switch runs are derived from wired client sessions, where the controller supplies the switch and port and the far end is matched by MAC. Access points get a synthesized Uplink port so their run has something to anchor to.
Switch-to-switch cabling comes from the controller interface, which reports each device's uplink with its local port and the peer port, and reports the same relationship again from the upstream device as a downlink. Both directions are emitted and publication collapses the pair, so every inter-switch cable is drawn with both ends known. On the Open API alone this is not derivable at all, because it never says which port faces upstream and /sites/{id}/lldp returns only the enable state rather than a neighbour table; those runs are named in the verbose log for manual completion.
Colors. VLANs and SSIDs are assigned a color from a shared palette so that the workspace is readable rather than uniformly grey. Allocation walks a queue drawn from Get-ColorPalette and recycles it once exhausted, over a list sorted by VLAN identifier rather than discovery order, which is what makes the assignment identical on every run. An SSID bound to a VLAN inherits that VLAN's color so the two read as one network; an unbound SSID takes the next color in the queue.
Subnets are linked to their VLAN wherever the source reports a tag. OPNsense supplies the tag per interface, and Omada VLANs that carry a gateway address contribute both the VLAN and its subnet, linked by construction. Subnets with no VLAN, such as VPN tunnels, point to point links, and the WAN block, are left unlinked rather than guessed at.
SSIDs come from the real wireless configuration when the controller interface is in use, including SSIDs with no client currently associated and the security mode and VLAN binding that no client session could reveal. On the Open API they fall back to inference from active sessions.
Wireless client associations require the client to already exist as a device — Rackpad rejects the write with "Selected client device does not exist" otherwise. They are therefore only emitted when ManageClientEndpoints is enabled, which creates an endpoint device per wireless client parented to its access point. With it disabled, associations are skipped and logged rather than failing.
Hyper-V uses the Hyper-V module. A local collection talks to the provider directly, because requiring a CIM session for the local machine would mean requiring WinRM, which is often not enabled on a workstation that nonetheless has the Hyper-V role. A remote collection creates a session and falls back from WSMan to DCOM automatically.
The guest operating system is read from the integration services key value pair exchange, which works remotely without a guest credential and is empty for a powered-off guest. UseGuestIntrospection additionally attempts PowerShell Direct for a more precise answer, which only works when the script runs on the Hyper-V host itself and a guest credential is supplied. OperatingSystemType always stays the virtual machine generation; the guest OS is a separate field.
OPNsense virtual IPs. A WAN interface often carries addresses beyond its primary one. Each virtual IP is tested against every interface network; those that fall inside one become an infrastructure assignment, and those that fall outside contribute their own subnet first so the assignment has a parent. Mixed groups on a single interface are handled per address rather than per interface.
Public IP lookup is an outbound request to a third party geolocation service. Only the request leaves the environment; no inventory data is transmitted. It is disabled by default.