Skip to content

Commit f6fb26a

Browse files
author
Karthik Satheesh Kumar
committed
Added Add Device to Inventory Capability
1 parent 8227ca0 commit f6fb26a

30 files changed

Lines changed: 1168 additions & 408 deletions

‎msp-onboarding/README.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# MSP onboarding workflow
22

3-
Automate the two most common MSP onboarding workflows using MSP API Credentials:
3+
Automate the three most common MSP onboarding workflows using MSP API Credentials:
44

55
- **Create tenants**: Create new MSP tenants and provision a Central service in each.
66
- **Assign devices to tenants**: Assign MSP-owned devices in an existing tenant's Central application and attach the subscription each device needs.
7+
- **Add devices to inventory**: Add new devices (serial number and MAC address) to the MSP workspace inventory.
78

89
Devices and tenants details can be entered individually or uploaded in bulk via CSVs. It ships in two forms: a **guided web workflow** and a **Python CLI** (`onboarding.py`) for scripted, manifest-driven runs.
910

@@ -15,8 +16,9 @@ Devices and tenants details can be entered individually or uploaded in bulk via
1516
## Features
1617

1718
- **Create tenants**: create one or more new MSP tenants and provision a Central service in each, in the region you choose.
19+
- **Add devices to inventory**: add devices to the MSP workspace by serial number and MAC address, in batches of five, with per-device results; devices already in inventory are reported rather than re-added.
1820
- **Assign devices to tenants**: for each MSP-owned device, pick the tenant, the Central application within it, and the subscription to attach, the workflow submits the device assignment and the seat assignment together.
19-
- **CSV upload**: bulk-load tenants to create, or devices with their tenant and subscription mapping, from a CSV file. Rows are validated and errors are reported per row before anything is submitted.
21+
- **CSV upload**: bulk-load tenants to create, devices to add, or devices with their tenant and subscription mapping, from a CSV file. Rows are validated and errors are reported per row before anything is submitted.
2022
- **Bulk seat assignment**: apply one subscription key to every eligible device of a type, with the key's expiry shown first.
2123
- **Demo mode**: a deterministic catalog for exploring both journeys without credentials.
2224

@@ -82,12 +84,20 @@ All calls go to the GreenLake Platform (GLP) API. Read calls run during discover
8284
| 1 | GLP | `GET` | `workspaces/v1/msp-tenants` | Lists managed tenants |
8385
| 2 | GLP | `POST` | `{base_url}/{workspace_id}/token` | Token exchange (MSP token → tenant-scoped token) |
8486
| 3 | GLP | `GET` | `service-catalog/v1/service-manager-provisions` | Central applications provisioned in the tenant |
85-
| 4 | GLP | `GET` | `devices/v1/devices` | MSP-owned device inventory (by serial, MAC, or ID) |
87+
| 4 | GLP | `GET` | `devices/v1/devices` | MSP-owned device inventory (by serial or ID) |
8688
| 5 | GLP | `GET` | `subscriptions/v1/subscriptions` | Subscription keys, capacity, and expiry |
8789
| 6 | GLP | `PATCH` | `devices/v1/devices` | Assigns devices to the tenant's Central application (batches of five) |
8890
| 7 | GLP | `PATCH` | `devices/v1/devices` | Assigns the subscription to those devices (batches of five) |
8991
| 8 | GLP | `GET` | `devices/v1/async-operations/{transaction_id}` | Polls each write to completion (up to 2 minutes) |
9092

93+
### Add devices to inventory
94+
95+
| Step | Service | Method | Endpoint | Description |
96+
|------|---------|--------|----------|-------------|
97+
| 1 | GLP | `GET` | `devices/v1/devices` | Checks which serials are already in the MSP inventory |
98+
| 2 | GLP | `POST` | `devices/v1/devices` | Adds devices to the MSP inventory (batches of five) |
99+
| 3 | GLP | `GET` | `devices/v1/async-operations/{transaction_id}` | Polls each add to completion |
100+
91101
## Prerequisites
92102

93103
- [uv](https://docs.astral.sh/uv/getting-started/installation/) — the workflow requires Python 3.10+, which uv installs on demand
@@ -106,7 +116,6 @@ All calls go to the GreenLake Platform (GLP) API. Read calls run during discover
106116

107117
```bash
108118
uv venv --python 3.12
109-
source .venv/bin/activate
110119
uv pip install -r requirements.txt
111120
```
112121

@@ -147,7 +156,7 @@ uv run python server.py
147156
A run walks through:
148157

149158
1. **Sign in** — live credentials, or demo mode
150-
2. **Choose a journey** — onboard new tenants, or add devices to existing tenants
159+
2. **Choose a journey** — onboard new tenants, add devices to inventory, or add devices to existing tenants
151160
3. **Setup, Devices, Review** — pick tenants and services, map every device to a tenant and subscription key, then review the read-only preflight
152161
4. **Confirm once** — the job starts, and the Review screen switches to live per-device and per-tenant results
153162
5. **Stop safely if needed** — the in-flight batch finishes, the rest is skipped, nothing is rolled back
@@ -183,6 +192,7 @@ The web workflow surfaces the plan and the run in one place:
183192
- **Setup stage**: tenant picker with per-tenant service and region discovery; tenants without an eligible service are set aside with the reason shown.
184193
- **Devices stage**: dense inventory table with CSV import, per-device or bulk subscription mapping, and seat capacity and expiry checks.
185194
- **Preflight review**: every tenant, device, and seat assignment with its validation result, plus an impact ledger of what will and will not run.
195+
- **Inventory add**: editable serial and MAC grid with CSV import, row-level validation, preflight, then per-device results.
186196
- **Live run**: the same review switches to per-device status chips (Writing, Complete, Already satisfied, Failed) and per-tenant progress as the job runs.
187197

188198
<!-- screenshot: sign-in screen (docs/sign-in.png) -->

‎msp-onboarding/docs/onboarding-flow.mmd‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,32 @@ flowchart LR
99
b3["&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Assign subscription&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
1010
end
1111

12+
subgraph ai["Add devices to inventory"]
13+
direction LR
14+
c1["&nbsp;&nbsp;&nbsp;&nbsp;Enter serials & MACs&nbsp;&nbsp;&nbsp;&nbsp;"]
15+
c2["Add devices to inventory"]
16+
end
17+
1218
subgraph ct["Create tenants"]
1319
direction LR
1420
a1["&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Create tenant&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
1521
a2["&nbsp;&nbsp;Provision Central service&nbsp;&nbsp;"]
1622
end
1723

1824
creds -->|Authenticate| b1
25+
creds -->|Authenticate| c1
1926
creds -->|Authenticate| a1
20-
a1 -->|" Token exchange "| a2
27+
c1 --> c2
28+
a1 -->|"⇄ Token exchange"| a2
2129
b1 --> b2 --> b3
2230

2331
classDef msp fill:#0f2a1c,stroke:#22c55e,color:#86efac
2432
classDef tenant fill:#0c2540,stroke:#3b82f6,color:#93c5fd
2533
classDef root fill:#1e293b,stroke:#60a5fa,color:#bfdbfe
26-
class a1,b1,b2,b3 msp
34+
class a1,b1,b2,b3,c1,c2 msp
2735
class a2 tenant
2836
class creds root
2937
style ct fill:#111827,stroke:#475569,color:#e2e8f0
3038
style ad fill:#111827,stroke:#475569,color:#e2e8f0
39+
style ai fill:#111827,stroke:#475569,color:#e2e8f0
40+

‎msp-onboarding/docs/onboarding-flow.svg‎

Lines changed: 1 addition & 1 deletion
Loading

‎msp-onboarding/msp_onboarding/adapter.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
WRITE_LIMITS = {
2121
"v1": {"batch": 5, "requests_per_minute": 40},
2222
}
23+
INVENTORY_ADD_BATCH_SIZE = 5
2324

2425
WORKSPACE_NAME_CONFLICT_MESSAGE = (
2526
"This workspace name is already in use globally across HPE GreenLake. "
@@ -39,6 +40,10 @@ def write_endpoint_path() -> str:
3940
return "devices/v1/devices"
4041

4142

43+
def inventory_add_batch_size() -> int:
44+
return INVENTORY_ADD_BATCH_SIZE
45+
46+
4247
class AdapterError(Exception):
4348
def __init__(
4449
self,
@@ -96,9 +101,9 @@ def resolve_devices(
96101
glp_ids: Optional[list[str]] = None,
97102
) -> list[DeviceInfo]: ...
98103
def resolve_device_by_serial(self, serial: str) -> DeviceInfo: ...
99-
def resolve_device_by_mac(self, mac: str) -> DeviceInfo: ...
100104
def resolve_device(self, glp_id: str) -> DeviceInfo: ...
101105
def list_available_devices(self) -> list[DeviceInfo]: ...
106+
def add_devices(self, devices: list[tuple[str, str]]) -> dict[str, str]: ...
102107
def resolve_subscription(self, key: str) -> SubscriptionInfo: ...
103108
def list_subscriptions(self) -> list[SubscriptionInfo]: ...
104109
def assign_devices(
@@ -116,3 +121,12 @@ def transaction_origin(self, transaction_id: str) -> Optional[str]: ...
116121
def poll_transaction(
117122
self, transaction_id: str, origin: Optional[str] = None
118123
) -> TransactionResult: ...
124+
125+
126+
# Live 2026-08-28: GLP reports inventory-add failures only as serials in
127+
# `failedDevicesSerial` with no reason text, so this mapped message is the
128+
# per-device note for every rejection (invalid serial, wrong MAC, other owner).
129+
INVENTORY_ADD_REJECTED_ERROR = (
130+
"GreenLake rejected this serial: not a valid serial/MAC pair, "
131+
"or the device is owned by another workspace"
132+
)

‎msp-onboarding/msp_onboarding/demo_adapter.py‎

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424

2525
from .adapter import (
2626
AdapterError,
27+
INVENTORY_ADD_REJECTED_ERROR,
2728
WORKSPACE_NAME_CONFLICT_MESSAGE,
29+
inventory_add_batch_size,
2830
write_batch_size,
2931
)
3032
from .models import (
@@ -61,6 +63,13 @@
6163
DEVICE_D8_ID = "cccccccc-0008-0008-0008-000000000008"
6264
DEVICE_D9_ID = "cccccccc-0009-0009-0009-000000000009"
6365

66+
ADD_ALREADY_PRESENT_SERIAL = "CNADD00001"
67+
# Live 2026-08-28: GLP returns MACs uppercase; the parser lowercases manifest MACs.
68+
ADD_ALREADY_PRESENT_MAC = "AA:BB:CC:DD:EE:01"
69+
ADD_REJECTED_SERIAL = "CNADD00003"
70+
ADD_REJECTED_MAC = "aa:bb:cc:dd:ee:03"
71+
PARTIAL_ADD_PLACEHOLDER_ERROR = INVENTORY_ADD_REJECTED_ERROR
72+
6473
SUB_KEY_A = "KEY_A"
6574
SUB_KEY_B = "KEY_B"
6675
SUB_KEY_C = "KEY_C"
@@ -103,6 +112,7 @@ def utcnow(self) -> datetime:
103112
"bulk-partial",
104113
"tenant-name-conflict",
105114
"tenant-creation-systemic",
115+
"partial-add",
106116
)
107117

108118
# ---------------------------------------------------------------------------
@@ -225,18 +235,6 @@ def utcnow(self) -> datetime:
225235
management="MSP", assigned_state="UNASSIGNED", device_type="SWITCH",
226236
),
227237
},
228-
# MAC → serial (look up by serial to get full DeviceInfo)
229-
"devices_by_mac": {
230-
"aa:bb:cc:00:00:01": "CNXA001",
231-
"aa:bb:cc:00:00:02": "CNXA002",
232-
"aa:bb:cc:00:00:03": "CNXA003",
233-
"aa:bb:cc:00:00:04": "CNXA004",
234-
"aa:bb:cc:00:00:05": "CNXA005",
235-
"aa:bb:cc:00:00:06": "CNXA006",
236-
"aa:bb:cc:00:00:07": "CNXA007",
237-
"aa:bb:cc:00:00:08": "CNXA008",
238-
"aa:bb:cc:00:00:09": "CNXA009",
239-
},
240238
"subscriptions": {
241239
SUB_KEY_A: SubscriptionInfo(
242240
subscription_id=SUB_A_ID, key=SUB_KEY_A,
@@ -308,6 +306,14 @@ def __init__(self, scenario: str = "success") -> None:
308306
}
309307
for info in CATALOG["devices_by_serial"].values()
310308
}
309+
present = DeviceInfo(
310+
glp_id="demo-inventory-add-present",
311+
serial_number=ADD_ALREADY_PRESENT_SERIAL,
312+
mac_address=ADD_ALREADY_PRESENT_MAC,
313+
management="MSP",
314+
assigned_state="UNASSIGNED",
315+
)
316+
self._inventory_add_devices = {present.serial_number: present}
311317
self._subscription_assignments: dict[str, str] = {}
312318
self._created_tenants: dict[str, TenantInfo] = {}
313319
self._provisioned_services: dict[str, list[ServiceInfo]] = {}
@@ -323,6 +329,7 @@ def __init__(self, scenario: str = "success") -> None:
323329
self._tenant_creation_failure_name: Optional[str] = None
324330
self.submitted_device_batches: list[list[str]] = []
325331
self.submitted_subscription_batches: list[list[tuple[str, str]]] = []
332+
self.submitted_add_batches: list[list[tuple[str, str]]] = []
326333

327334
def now(self) -> datetime:
328335
return FIXED_CLOCK
@@ -518,6 +525,9 @@ def resolve_devices(
518525
return resolved
519526

520527
def resolve_device_by_serial(self, serial: str) -> DeviceInfo:
528+
inventory_device = self._inventory_add_devices.get(serial)
529+
if inventory_device is not None:
530+
return replace(inventory_device)
521531
device = CATALOG["devices_by_serial"].get(serial)
522532
if device is None:
523533
raise AdapterError(
@@ -527,17 +537,17 @@ def resolve_device_by_serial(self, serial: str) -> DeviceInfo:
527537
)
528538
return self.resolve_device(device.glp_id)
529539

530-
def resolve_device_by_mac(self, mac: str) -> DeviceInfo:
531-
serial = CATALOG["devices_by_mac"].get(mac)
532-
if serial is None:
533-
raise AdapterError(
534-
path="devices",
535-
code="device_not_found",
536-
message=f"Device not found for MAC: {mac!r}",
537-
)
538-
return self.resolve_device(CATALOG["devices_by_serial"][serial].glp_id)
539-
540540
def resolve_device(self, glp_id: str) -> DeviceInfo:
541+
inventory_device = next(
542+
(
543+
device
544+
for device in self._inventory_add_devices.values()
545+
if device.glp_id == glp_id
546+
),
547+
None,
548+
)
549+
if inventory_device is not None:
550+
return replace(inventory_device)
541551
for device in CATALOG["devices_by_serial"].values():
542552
if device.glp_id == glp_id:
543553
state = self._device_states[glp_id]
@@ -559,6 +569,32 @@ def list_available_devices(self) -> list[DeviceInfo]:
559569
and not self._device_states[device.glp_id]["subscription"]
560570
]
561571

572+
def add_devices(self, devices: list[tuple[str, str]]) -> dict[str, str]:
573+
batch_size = inventory_add_batch_size()
574+
if len(devices) > batch_size:
575+
raise AdapterError(
576+
"execution.devices",
577+
"batch_too_large",
578+
f"At most {batch_size} devices are allowed",
579+
)
580+
self.submitted_add_batches.append(list(devices))
581+
failures: dict[str, str] = {}
582+
for serial, mac in devices:
583+
if self._scenario == "partial-add" and serial == ADD_REJECTED_SERIAL:
584+
failures[serial] = PARTIAL_ADD_PLACEHOLDER_ERROR
585+
continue
586+
present = self._inventory_add_devices.get(serial)
587+
if present is not None and present.mac_address.lower() == mac.lower():
588+
continue
589+
self._inventory_add_devices[serial] = DeviceInfo(
590+
glp_id=f"demo-inventory-{serial.lower()}",
591+
serial_number=serial,
592+
mac_address=mac,
593+
management="MSP",
594+
assigned_state="UNASSIGNED",
595+
)
596+
return failures
597+
562598
def resolve_subscription(self, key: str) -> SubscriptionInfo:
563599
sub = CATALOG["subscriptions"].get(key)
564600
if sub is None:

0 commit comments

Comments
 (0)