---
module: agent.hw-inventory
title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe
tab: Architect
order: 30
audience: dev
---

> **Internal · architecture view.** Confirmed against `hiveops-module-hw-inventory` source 2026-07-01. See [technical.agent] for the module system as a whole.

## Shape

A tiny 4-type module: one `AgentModule` entry point, a collector SPI interface, and two platform collectors.

| Class | Role |
|---|---|
| `HardwareInventoryModule` | `AgentModule` entry point — lifecycle, scheduling, CDN probe, POST orchestration |
| `HardwareCollector` (interface) | `collect(atmName, country, institutionKey) → HardwareInventoryRequest` |
| `LinuxHardwareCollector` | Reads `/proc`, `/sys`, `/etc/os-release`, `lsblk`, Java `File.listRoots()` |
| `WindowsHardwareCollector` | Shells out to **PowerShell** (`Get-CimInstance`, `Get-HotFix`, registry reads) |

The transport (`IncidentEventClient.syncHardware`) and the payload DTO (`HardwareInventoryRequest`) live in the shared `hiveops-agent-journal` module (`com.hiveops.events`), reused here as a `provided` dependency.

## Lifecycle & flow

Standard SPI lifecycle (`initialize → isEnabled → start → stop`), driven by `ModuleRegistry`:

1. **`initialize(ModuleContext)`**
   - Reads `hw.inventory.enabled`; if `false`, returns early (module never activates).
   - Resolves the base URL: prefers `agent.endpoint`, falls back to `incident.endpoint`. If neither is set → inactive.
   - Loads HTTP settings via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` = `"agent"` or `"incident"` to match the chosen endpoint.
   - Pulls identity from context: `getAtmName()`, `getCountry()`, and `incident.institution.key` from props.
   - Picks the collector from `context.getPlatformService().isWindows()` → `WindowsHardwareCollector` or `LinuxHardwareCollector`.
   - Sets `ready = true` and **calls `syncInventory()` once immediately** (a first sync happens during init, before `start()`).
2. **`isEnabled(context)`** → returns the `ready` flag.
3. **`start()`** — creates a single daemon `ScheduledExecutorService` (thread name `hw-inventory`) and `scheduleAtFixedRate(this::syncInventory, 0, intervalHours, HOURS)`. Because initial delay is `0`, this fires an **additional** sync right at startup (so a fresh agent syncs twice close together — once from `initialize`, once from the delay-0 schedule).
4. **`stop()`** — `scheduler.shutdownNow()`.

Each `syncInventory()`:

```
collector.collect(atmName, country, institutionKey)   // build HardwareInventoryRequest
  → req.setCdnDownloadSpeedBps(probeCdnDownload())     // measure CDN bandwidth
  → client.syncHardware(req)                           // POST /api/atms/hardware/sync
```

The whole method is wrapped in try/catch; a failure logs `sync failed` and is swallowed so the schedule keeps running. Inside each collector, every sub-step (CPU, system, disks, drives, NICs, BIOS/boot, patches, …) is independently try/caught — a partial snapshot is still sent.

## How data reaches the platform

`IncidentEventClient.syncHardware(req)` does a plain `HttpURLConnection` **POST** of the JSON `HardwareInventoryRequest` to:

```
{endpoint}/api/atms/hardware/sync
```

with `Content-Type: application/json` and the shared HTTP settings (auth header, host header) applied by `HttpClientHelper`. `{endpoint}` is the agent-proxy base (`agent.endpoint`) on current ATMs, so the request lands on **agent-proxy** and is forwarded to the incident/devices hardware-sync handler — the same agent→agent-proxy path every other agent call uses. Accepts `200/201/202`; anything else throws `IOException`.

The ATM is identified by `atmName` + `country` in the payload (plus `institutionKey`); there is no client-side `atmId` resolution here — the platform maps it server-side.

## CDN bandwidth probe (`probeCdnDownload()`)

Runs inside the same sync, purely to populate `cdnDownloadSpeedBps`:

1. GET the manifest at `agent.update.check.url` (default `https://cdn.bcos.cloud/downloads/agent/latest.json`), sending `X-Institution-Key` if an institution key is set. Parsed leniently (`FAIL_ON_UNKNOWN_PROPERTIES=false`).
2. Resolve `patches.windows` or `patches.linux` from the manifest (chosen by `isWindows`); relative filenames are resolved against the manifest base URL.
3. HTTP **Range** request `bytes=0-524287` (first 512 KB) of that patch file; accepts `206 Partial` or `200`.
4. Read the bytes, time the transfer, compute `rawBps = bytes*1000/elapsedMs`, then `speedBps = rawBps * task.hotfix.network.cap` (cap clamped `0.01`–`1.0`, default `0.9`).
5. Any failure (bad HTTP, no patch URL, exception) returns `0`.

## Platform abstraction

The module uses `PlatformService` **only** for OS detection (`isWindows()`) to select the collector. The collectors themselves do **not** go through the platform abstraction — they read OS sources directly:

- **Linux:** `/proc/cpuinfo`, `/sys/devices/.../cpufreq`, `/sys/class/dmi/id/*`, `lsblk -b -o … -n -d`, `/sys/block/*/queue/rotational` (SSD/HDD), `/sys/class/net/*` (physical-NIC filter via a `device` symlink), `/proc/net/dev` (throughput), `/proc/stat` `btime` (boot time), `/etc/os-release`, `File.listRoots()` (drives).
- **Windows:** PowerShell (`-NoProfile -NonInteractive -ExecutionPolicy Bypass`) with `Get-CimInstance` (`Win32_Processor`, `Win32_ComputerSystem`, `Win32_DiskDrive`, `Win32_LogicalDisk`, `Win32_NetworkAdapter`, `Win32_BIOS`, `Win32_OperatingSystem`), `Get-NetAdapterStatistics` (throughput), `Get-HotFix` (patches), registry `Uninstall` keys (programs), and `USBSTOR`/`Control Panel\Desktop` registry reads (security posture). stderr is drained on a background thread so it can't block stdout.

> Note: the pom `<description>` says Windows uses `wmic` — that's stale; the code uses PowerShell/CIM.
