diff --git a/CLAUDE.md b/CLAUDE.md index 4df06f1..aab9928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,163 +4,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a network documentation and discovery project that integrates with NetBox. +A small fleet of CLIs for documenting, auditing, and operating the vntx WISP — a multi-tower MikroTik backbone with cnMaestro and UISP managing the radios/CPE behind it. Source of truth for network documentation is NetBox at `https://netbox.vntx.net/` (API token in env var `NETBOX_KEY`); source of truth for the routers is whatever the live `.rsc` exports say. -## NetBox Integration +## Repo Layout -- **NetBox URL**: https://netbox.vntx.net/ -- **API Key**: e50298f7fd20f7fd6f1931f635511b34f6e8cfde -- **Purpose**: Network documentation and discovery +The repo is a collection of independent CLIs that each operate on the same physical fleet but talk to different control planes. Each subproject has its own README/CLAUDE.md with details; only repo-wide context lives here. -## Development Guidelines +| Subdir | Lang | Talks to | Purpose | +|--|--|--|--| +| `mikrotik-tool/` | Go | MikroTik API-SSL + SSH | Fan-out to every router in `routers.yaml`: `list`, `export` (saves `.rsc` per router), `inventory` (rewrites `inventory.yaml` from live data + SNMP), `api`/`schema` (ad-hoc API introspection on one router), `script` (push a `.rsc` file via SSH and stream output). Authoritative `inventory.yaml`, `subnets.yaml`, `radios.yaml`, `ipv6.md`, and `mpls.md` live here. | +| `mikrotik-tool-rs/` | Rust | MikroTik API-SSL + system `ssh` | Newer rewrite of a subset of `mikrotik-tool` (`list`, `export`, `run `) with `-r name1,name2` filter to scope a fan-out command. Has its own `routers.yaml`. See `mikrotik-tool-rs/CLAUDE.md`. | +| `uisp/` | Go (`main.go`) + Python (`uisp.py`) | UISP API at `uisp.vntx.net` | List/approve/upgrade UISP-managed devices. Auth via `UISP_KEY` env var. UNKNOWN-model devices are filtered by default (use `--include-unknown`). | +| `cnmaestro/` | Python (uv) | cnMaestro Cloud OAuth2 | Cleanup offline devices and bulk-upgrade firmware. `uv sync` + `.env` with client id/secret. See `cnmaestro/README.md`. | -### NetBox API Usage -- Use the provided API key for authentication with NetBox -- The NetBox instance is located at https://netbox.vntx.net/ -- Follow NetBox API documentation for proper endpoint usage -- The netbox api token is in env var NETBOX_KEY +`routers.yaml` at the repo root is the config consumed by `mikrotik-tool/` (Go); `mikrotik-tool-rs/` has its own copy. Keep them aligned when adding/removing routers. -### Security Notes -- Never commit API keys directly in code files -- Use environment variables or configuration files for sensitive data -- The API key provided should be stored securely +`poll_snmp_site.sh` is a one-off helper for SNMP-polling a list of IPs at a site (`./poll_snmp_site.sh ...`). The `*.rsc` files at the repo root are stale exports — fresh ones come out of `mikrotik-tool` or `mikrotik-tool-rs`. -## Common Tasks +## Common Commands -### NetBox API Connection -When connecting to NetBox, use: -- Base URL: `https://netbox.vntx.net/api/` -- Authentication header: `Authorization: Token e50298f7fd20f7fd6f1931f635511b34f6e8cfde` - -### MikroTik Router Connection -Connect to MikroTik routers using API-SSL: -- **Protocol**: API-SSL (port 8729) -- **Username**: `grahamro` (read-only) -- **Password**: `cFKhz8q5gPLoucMbcT1Iy58r3IXgc3` -- **Example - Verona Router**: `10.254.254.101` - -Use the `mikrotik_connect.py` script to connect and retrieve router information: ```bash -python3 mikrotik_connect.py +# mikrotik-tool (Go) +cd mikrotik-tool && go build . +./mikrotik-tool list +./mikrotik-tool export # writes .rsc per router into cwd +./mikrotik-tool api verona /ip/route/print +./mikrotik-tool schema verona /routing/bgp/connection +./mikrotik-tool script 494 494-v6-apply.rsc +./mikrotik-tool inventory # re-renders inventory.yaml in place +go test ./... # inventory_test.go + +# mikrotik-tool-rs (Rust) +cd mikrotik-tool-rs && cargo build --release +cargo run -- list +cargo run -- -r verona,982 export # filter to a subset before the verb +cargo run -- run /system identity print +cargo test # full suite +cargo test encode_length # single test by name substring +cargo clippy --all-targets && cargo fmt + +# uisp (Go binary, also a Python script) +cd uisp && go build . +./uisp list +./uisp approve +./uisp upgrade +# python equivalent: +python3 uisp.py list + +# cnmaestro (Python, uv) +cd cnmaestro && uv sync +uv run cnmaestro cleanup --dry-run +uv run cnmaestro upgrade --product ePMP,PMP --dry-run +uv run pytest +uv run pytest tests/test_devices.py::test_specific_thing # single test ``` -The script handles SSL connection, authentication, and can retrieve: -- IP addresses and subnets -- Interface configurations -- Routing tables -- PPPoE connections +`UISP_KEY` and `NETBOX_KEY` need to be exported in the shell. `cnmaestro` reads `cnmaestro/.env`. -## Router Access Credentials +## NetBox -### MikroTik Routers -- Read-only access via API-SSL: username `grahamro`, password `cFKhz8q5gPLoucMbcT1Iy58r3IXgc3` +- URL: `https://netbox.vntx.net/`, API base `https://netbox.vntx.net/api/` +- Auth: `Authorization: Token $NETBOX_KEY` +- Convention when creating sites/devices: site slugs are lowercase-hyphenated, status `active`; routers are Manufacturer=MikroTik / Role=Router / primary IP = loopback `/32` (`10.254.254.x/32`). +- Prefix roles in use: Infrastructure, Customer, Management, Loopback. -### Verona Routers -- Verona router is 10.254.254.101 +## MikroTik Router Access -### Additional Router IP Addresses -- Climax router: 10.254.254.102 -- Culleoka router: 10.254.254.104 +Read-only fan-out (used by all the above tools): API-SSL on port 8729, user `grahamro` / password in `CLAUDE.md` history. The committed `routers.yaml` actually uses `graham` (not `grahamro`) because `/export` requires the `ftp` policy in addition to `read,api`, which the read-only group lacks. New routers: ensure the user's group has `read,api,ftp` (or extend the read group: `/user group set read add-policy=ftp`). -## NetBox Site and Device Creation Process - -### Creating a new site and router in NetBox: -1. **Create Site**: Use `create_verona_site_and_router.py` as template - - Site name and slug (lowercase, hyphenated) - - Status: 'active' - - Comments describing the site - -2. **Create Device**: - - Manufacturer: MikroTik - - Device Type: RouterBOARD - - Device Role: Router - - Primary IP: Router's loopback IP (e.g., 10.254.254.101/32) - -3. **Add Network Data**: Use `update_netbox_verona.py` as template - - Creates prefixes with proper roles (Infrastructure, Customer, Management, Loopback) - - Creates interfaces on the device - - Associates IP addresses with interfaces - -### API Authentication -- Always use environment variable `NETBOX_KEY` for API token -- Fallback to hardcoded token only if env var not set - -## Generic Scripts for Network Management - -### 1. Create Site Only in NetBox -```bash -# Basic usage -python3 create_site_only.py - -# With options -python3 create_site_only.py 380 --comments "Central site with multiple routers" --address "380 Main St" -``` - -### 2. Create Site and Router in NetBox -```bash -# Basic usage -python3 create_site_and_router.py - -# With options -python3 create_site_and_router.py Climax 10.254.254.102 --router-name climax-core --physical-address "123 Tower Rd" - -# For sites with multiple routers, create site first, then add each router -python3 create_site_only.py 380 --comments "Multi-router site" -python3 create_site_and_router.py 380 10.254.254.105 --router-name 380-core-router -python3 create_site_and_router.py 380 10.254.254.106 --router-name 380-edge-router -``` - -### 3. Get MikroTik Router Configuration -```bash -# Basic usage (uses default read-only credentials) -python3 get_mikrotik_router_data.py - -# Save to specific file -python3 get_mikrotik_router_data.py 10.254.254.102 -o climax_config.json - -# Output JSON to stdout -python3 get_mikrotik_router_data.py 10.254.254.102 --json - -# Custom credentials -python3 get_mikrotik_router_data.py 10.254.254.102 -u admin -p secretpass -``` - -The script retrieves: -- Router identity -- IP addresses and subnets -- Active interfaces -- VLANs -- PPPoE servers -- Static routes - -### 4. Get MikroTik Router Data (Basic/Older RouterOS) -For older RouterOS versions or routers with many IPs (like CGNAT): -```bash -python3 get_mikrotik_basic_data.py -o router_data.json -``` - -This simplified script: -- Groups CGNAT addresses into a single subnet entry -- Focuses on key interfaces only -- Works better with older RouterOS versions -- Handles large configurations without timing out - -### 5. Get All Network Devices -```bash -# Get all devices categorized by type -python3 get_all_network_devices.py - -# Show all devices including "Other" category -python3 get_all_network_devices.py --show-all - -# Save to JSON file -python3 get_all_network_devices.py -o devices.json -``` - -This script retrieves DHCP leases and ARP table to identify: -- Ubiquiti access points and devices -- MikroTik devices -- Other network devices +Every router's loopback is `10.254.254.x/32`; see the **Fleet Topology** table below for the full mapping. ## Network Topology Patterns diff --git a/mikrotik-tool/494.rsc b/mikrotik-tool/494.rsc index b3d8dd0..7367418 100644 --- a/mikrotik-tool/494.rsc +++ b/mikrotik-tool/494.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-09 21:18:17 by RouterOS 7.21.4 # software id = K4QG-8NQV # # model = RB5009UG+S+ @@ -378,19 +378,15 @@ add bridge=management interface=vlan10_ether7 internal-path-cost=10 \ add bridge=management interface=vlan10_ether8 internal-path-cost=10 \ path-cost=10 /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip neighbor discovery-settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set discover-interface-list=!dynamic /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add default-profile=494 disabled=no interface=494 max-mru=1492 max-mtu=1492 \ @@ -543,8 +539,6 @@ add address=10.250.1.70%ether2-climax area=ospf-area-1 disabled=no \ poll-interval=10s /snmp set contact=graham@vntx.net enabled=yes location="33.208204, -96.462530" -/system clock -set time-zone-name=America/Chicago /system identity set name=494 /system logging @@ -560,7 +554,6 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /system scheduler add name=reboot on-event="/system reboot" policy=\ diff --git a/mikrotik-tool/982.rsc b/mikrotik-tool/982.rsc index 18865d5..3302d3b 100644 --- a/mikrotik-tool/982.rsc +++ b/mikrotik-tool/982.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-10 14:48:48 by RouterOS 7.21.4 # software id = FUVS-HCM5 # # model = CCR1009-7G-1C-1S+ diff --git a/mikrotik-tool/climax.rsc b/mikrotik-tool/climax.rsc index 70fce29..a04a6da 100644 --- a/mikrotik-tool/climax.rsc +++ b/mikrotik-tool/climax.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:04 by RouterOS 7.21.4 +# 2026-05-09 16:18:38 by RouterOS 7.21.4 # software id = UETF-WF31 # # model = CCR2004-16G-2S+ @@ -299,19 +299,15 @@ add bridge=climax-bridge interface=sfp-sfpplus1 add bridge=mgmt interface=vlan10_ether7 add bridge=mgmt interface=vlan10_sfp-sfpplus1 /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set enabled=yes icmp-timeout=30s tcp-close-wait-timeout=1m \ tcp-established-timeout=4h tcp-fin-wait-timeout=2m tcp-last-ack-timeout=\ 30s tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip neighbor discovery-settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set discover-interface-list=!dynamic /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set max-neighbor-entries=8192 tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add authentication=chap,mschap2 default-profile=pppoe disabled=no interface=\ @@ -528,5 +524,4 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes enter-setup-on=delete-key diff --git a/mikrotik-tool/core.rsc b/mikrotik-tool/core.rsc index 707c0dd..6bd8e1a 100644 --- a/mikrotik-tool/core.rsc +++ b/mikrotik-tool/core.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-09 16:20:09 by RouterOS 7.21.4 # software id = XS5B-41QR # # model = CCR1009-7G-1C-1S+ @@ -372,17 +372,13 @@ add name=logs remote=204.110.191.229 src-address=10.254.254.253 target=remote /ip smb set enabled=no /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-established-timeout=4h tcp-fin-wait-timeout=2m tcp-time-wait-timeout=\ 2m /ip neighbor discovery-settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set discover-interface-list=!dynamic /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set max-neighbor-entries=8192 tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no max-neighbor-entries=8192 \ soft-max-neighbor-entries=8191 /interface pppoe-server server diff --git a/mikrotik-tool/culleoka.rsc b/mikrotik-tool/culleoka.rsc index cf52208..05dbba7 100644 --- a/mikrotik-tool/culleoka.rsc +++ b/mikrotik-tool/culleoka.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-09 16:21:31 by RouterOS 7.21.4 # software id = HVP9-3G0K # # model = CCR1009-7G-1C-1S+ @@ -375,16 +375,13 @@ add bridge=culleoka disabled=yes ingress-filtering=no interface=*10 \ add bridge=mgmt ingress-filtering=no interface=vlan10_ether2 \ internal-path-cost=10 path-cost=10 /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add accept-empty-service=no authentication=chap,mschap2 default-profile=pppoe \ @@ -725,7 +722,6 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /system scheduler add interval=5m name=remove_hotspot on-event=\ diff --git a/mikrotik-tool/edge.rsc b/mikrotik-tool/edge.rsc index fbf6e9e..5c76cc6 100644 --- a/mikrotik-tool/edge.rsc +++ b/mikrotik-tool/edge.rsc @@ -1,4 +1,4 @@ -# may/09/2026 15:08:04 by RouterOS 6.49.18 +# may/10/2026 05:53:37 by RouterOS 6.49.18 # software id = 8XZE-R7EJ # # model = CCR2004-1G-12S+2XS diff --git a/mikrotik-tool/home.rsc b/mikrotik-tool/home.rsc index e3dc4ed..51c8688 100644 --- a/mikrotik-tool/home.rsc +++ b/mikrotik-tool/home.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.22.3 +# 2026-05-10 14:48:48 by RouterOS 7.22.3 # software id = ZGNY-ZJW7 # # model = RB5009UG+S+ @@ -100,7 +100,6 @@ add bridge=bridge comment=defconf ingress-filtering=no interface=sfp-sfpplus1 \ internal-path-cost=10 path-cost=10 add bridge=dockers interface=veth1 /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=yes /interface detect-internet set detect-interface-list=all internet-interface-list=all lan-interface-list=\ @@ -430,7 +429,7 @@ add comment=wigle.net disabled=no distance=1 dst-address=54.70.85.50/32 \ add dst-address=100.64.0.0/10 gateway=172.17.0.2 add comment="Default via TMO (primary)" disabled=no distance=1 dst-address=\ 0.0.0.0/0 gateway=192.168.12.1 target-scope=11 -add comment="Default via VNTX (secondary)" disabled=no distance=2 \ +add comment="Default via VNTX (secondary)" disabled=yes distance=2 \ dst-address=0.0.0.0/0 gateway=204.110.191.30 target-scope=11 add check-gateway=ping comment="Default via Starlink (last\ \n resort)" distance=3 dst-address=0.0.0.0/0 gateway=4.2.2.3 \ @@ -512,7 +511,6 @@ set enabled=yes /system ntp client servers add address=ntp.vntx.net /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes /tool e-mail set certificate-verification=no from=mikrotik@vntx.net port=2525 server=\ diff --git a/mikrotik-tool/newhope.rsc b/mikrotik-tool/newhope.rsc index 2c235ea..66c72ba 100644 --- a/mikrotik-tool/newhope.rsc +++ b/mikrotik-tool/newhope.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-10 14:48:49 by RouterOS 7.21.4 # software id = 5HTF-YFWV # # model = CCR1009-7G-1C-1S+ diff --git a/mikrotik-tool/verona.rsc b/mikrotik-tool/verona.rsc index 827e04c..822684c 100644 --- a/mikrotik-tool/verona.rsc +++ b/mikrotik-tool/verona.rsc @@ -1,4 +1,4 @@ -# 2026-05-09 15:08:05 by RouterOS 7.21.4 +# 2026-05-09 16:19:30 by RouterOS 7.21.4 # software id = Y1CT-1WB1 # # model = CCR2004-16G-2S+ @@ -456,19 +456,15 @@ add bridge=*1F interface=*23 internal-path-cost=10 path-cost=10 add bridge=cpe_vlan_10 interface=vlan10_ether15 add bridge=cpe_vlan_10 interface=ether10-powerswitch /ip firewall connection tracking -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set icmp-timeout=30s tcp-close-wait-timeout=1m tcp-established-timeout=4h \ tcp-fin-wait-timeout=2m tcp-last-ack-timeout=30s \ tcp-syn-received-timeout=1m tcp-syn-sent-timeout=2m \ tcp-time-wait-timeout=2m udp-stream-timeout=2m /ip neighbor discovery-settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set discover-interface-list=!dynamic /ip settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set tcp-syncookies=yes /ipv6 settings -# ipv6 *accept router advertisements* configuration has changed, please restart device to apply settings set accept-router-advertisements=no /interface pppoe-server server add default-profile=pppoe-verona disabled=no interface=verona max-mru=1500 \ @@ -1025,7 +1021,6 @@ add address=0.us.pool.ntp.org /system package update set channel=long-term /system routerboard settings -# Firmware upgraded successfully, please reboot for changes to take effect! set auto-upgrade=yes enter-setup-on=delete-key /system scheduler add name=reboot on-event="/system reboot" policy=\ diff --git a/uisp/uisp.py b/uisp/uisp.py index acefd91..91db7c7 100755 --- a/uisp/uisp.py +++ b/uisp/uisp.py @@ -134,6 +134,21 @@ def latest_firmware_for(fws: list[dict], d: dict) -> str: return best +def upgrade_in_flight(d: dict, target_v: str = "") -> bool: + """True if UISP already has a pending/running upgrade for this device. + Re-POSTing /tasks for a device with one queued returns a misleading + 422 "Firmware not found" error; skip these instead. + """ + up = d.get("upgrade") or {} + status = (up.get("status") or "").lower() + if status not in {"queued", "running", "in-progress", "started", "preparing", "downloading", "installing"}: + return False + if not target_v: + return True + # Only skip if the queued upgrade is for the same target we'd request. + return up.get("firmwareVersion") == target_v + + def needs_upgrade(d: dict, fws: list[dict]) -> bool: ident = d.get("identification") or {} if ident.get("type") == "blackBox": @@ -147,7 +162,11 @@ def needs_upgrade(d: dict, fws: list[dict]) -> bool: latest = latest_firmware_for(fws, d) if not latest: return False - return version_lt(cur, latest) + if not version_lt(cur, latest): + return False + if upgrade_in_flight(d, latest): + return False + return True def is_unauthorized(d: dict) -> bool: @@ -595,7 +614,7 @@ def cmd_upgrade(args: argparse.Namespace) -> None: print(f"\ndry-run: pass --yes to actually trigger upgrades.") return - batch_size = max(1, args.batch_size) + batch_size = args.batch_size if args.batch_size > 0 else len(targets) n_batches = (len(targets) + batch_size - 1) // batch_size failed_total = 0 @@ -603,45 +622,35 @@ def cmd_upgrade(args: argparse.Namespace) -> None: batch = targets[batch_idx * batch_size:(batch_idx + 1) * batch_size] print(f"\n=== batch {batch_idx + 1}/{n_batches}: {len(batch)} device(s) ===") - # Build the upgrades payload and POST one task per device. UISP's - # actual upgrade endpoint is POST /tasks with - # {upgrades: [{deviceId, firmwareVersion}], upgradeInMaintenanceWindow}. - # /devices/{id}/update only refreshes device data — it does NOT - # trigger a firmware upgrade. + # POST /tasks one device at a time. Bulk submission (all device + # upgrades in a single /tasks payload) is atomic: any one device + # whose target firmwareVersion doesn't exactly match a catalog + # entry causes the whole call to 422 with "Firmware not found". + # Per-device /tasks isolates failures. triggered: list[tuple[str, str, str]] = [] # (id, name, target_version) - upgrades = [] for d in batch: did = d["identification"]["id"] target_v = latest_firmware_for(fws, d) if not target_v: - print(f" skip {did} {disp_name(d)}: no catalog firmware match", file=sys.stderr) + print(f" skip {did} {disp_name(d):<30}: no catalog firmware match", file=sys.stderr) failed_total += 1 continue - upgrades.append({ - "deviceId": did, - "firmwareVersion": target_v, - }) - triggered.append((did, disp_name(d), target_v)) - - if upgrades: + if upgrade_in_flight(d, target_v): + up = d.get("upgrade") or {} + print(f" skip {did} {disp_name(d):<30}: already {up.get('status')} → {up.get('firmwareVersion')}") + continue try: api("POST", "/tasks", { - "upgrades": upgrades, + "upgrades": [{"deviceId": did, "firmwareVersion": target_v}], "upgradeInMaintenanceWindow": False, }) - for did, name, tv in triggered: - print(f" trigger OK {did} {name:<30} → {tv}") + print(f" trigger OK {did} {disp_name(d):<30} → {target_v}") + triggered.append((did, disp_name(d), target_v)) except SystemExit: - print(f" batch trigger FAILED for {len(upgrades)} device(s)", file=sys.stderr) - failed_total += len(upgrades) - triggered = [] + print(f" trigger FAIL {did} {disp_name(d):<30} → {target_v}", file=sys.stderr) + failed_total += 1 if not args.wait: - # No completion wait — just space batches out so we don't blast - # the whole fleet simultaneously. - if batch_idx + 1 < n_batches: - print(f" sleeping {args.batch_delay}s before next batch...") - time.sleep(args.batch_delay) continue # Wait for every device in this batch to land on its target version. @@ -724,14 +733,12 @@ def main() -> None: pu.add_argument("--all", action="store_true", help="upgrade every device with a newer firmware available") pu.add_argument("--yes", action="store_true", help="actually trigger upgrades (default: dry-run)") pu.add_argument("--force", action="store_true", help="upgrade even if already on latest") - pu.add_argument("--batch-size", type=int, default=5, - help="how many devices to upgrade in parallel per batch (default: 5)") + pu.add_argument("--batch-size", type=int, default=0, + help="devices per batch; 0 means all in one batch (default). Only useful with --wait.") pu.add_argument("--wait", action="store_true", help="wait for each batch to land on the new firmware before starting the next") pu.add_argument("--wait-timeout", type=int, default=900, help="seconds to wait per batch when --wait is set (default: 900)") - pu.add_argument("--batch-delay", type=int, default=30, - help="seconds to sleep between batches when --wait is NOT set (default: 30)") pu.set_defaults(func=cmd_upgrade) args = p.parse_args()