Metadata-Version: 2.4
Name: netbox-opennms-plugin
Version: 0.0.4
Summary: Provision NetBox devices and virtual machines into OpenNMS via the REST provisioning API.
Author-email: Ronny Trommer <ronny@no42.org>
License-Expression: MIT
Project-URL: Homepage, https://github.com/no42-org/netbox-opennms-plugin
Project-URL: Documentation, https://github.com/no42-org/netbox-opennms-plugin#readme
Project-URL: Source, https://github.com/no42-org/netbox-opennms-plugin
Project-URL: Tracker, https://github.com/no42-org/netbox-opennms-plugin/issues
Keywords: netbox,netbox-plugin,opennms,provisioning,monitoring
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Intended Audience :: System Administrators
Classifier: Intended Audience :: Information Technology
Classifier: Topic :: System :: Networking :: Monitoring
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.34
Dynamic: license-file

# netbox-opennms-plugin

> Provision NetBox devices and virtual machines into **OpenNMS Horizon 36** as
> requisition nodes — NetBox is the source of truth, OpenNMS monitoring is a
> derived artifact kept in sync from NetBox intent.

[![CI](https://github.com/no42-org/netbox-opennms-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/no42-org/netbox-opennms-plugin/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/no42-org/netbox-opennms-plugin)](https://github.com/no42-org/netbox-opennms-plugin/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
![NetBox 4.6.1+](https://img.shields.io/badge/NetBox-4.6.1%2B-blue)
![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue)
[![Docs](https://img.shields.io/badge/docs-contributor%20guide-0d9488)](https://no42-org.github.io/netbox-opennms-plugin/)

📖 **[Documentation & contributor guide →](https://no42-org.github.io/netbox-opennms-plugin/)**

![NetBox OpenNMS — Requisition detail](docs/img/requisition.png)

## Why

[NetBox](https://netboxlabs.com/) is your source of truth for what exists;
[OpenNMS](https://www.opennms.com/) (Horizon 36) monitors it. This plugin closes
the gap: you describe monitoring **intent** in NetBox — which Devices/VMs, which
detectors and policies, which services — and the plugin renders and imports the
matching OpenNMS **requisition** over the REST provisioning API. Membership is a
live NetBox query, so the monitored set follows your inventory automatically, and
every re-sync is idempotent (render-and-replace — a node is never duplicated or
implicitly moved).

## Features

- **Filter-based Requisitions** — a Requisition is one user-named OpenNMS Foreign
  Source whose members are a live NetBox **filter** (role, tag, site, status,
  custom field, …) over Devices and/or Virtual Machines.
- **Discovery-driven detectors & policies** — configured from your live OpenNMS
  catalog (classes + parameters read via REST, the same API the OpenNMS UI uses),
  with a curated preset overlay for labels/defaults and a freeform-class escape
  hatch.
- **Per-interface SNMP roles** — the management IP is the **Primary** SNMP
  interface by default; add more IPs as Primary / Secondary / Not-eligible.
- **Asset & metadata enrichment** — map NetBox inventory to OpenNMS node **asset
  fields**, and attach `meta-data` at **node / interface / service** scope.
- **Conflict safety** — an object matched by two Requisitions is a blocking
  **conflict**; Sync of every involved Requisition freezes until you resolve it,
  so a node always lives in exactly one Foreign Source.
- **Graded node status** — a member with no management IP becomes an
  inventory-only node with a **warning**, not a silent skip.
- **Dry-run before sync** — a per-node diff of exactly what a Sync would
  add / remove / change against the live OpenNMS state.
- **Honest background sync** — Sync/Remove run as NetBox **Jobs**; an OpenNMS
  `202` is reported as *submitted for import*, never "provisioned"; a drift
  reconciler cleans up only the requisitions the plugin owns.

## Compatibility

| | |
| --- | --- |
| NetBox | 4.6.1+ |
| Python | 3.12+ |
| OpenNMS | Horizon 36 |
| License | MIT |

## Quick start (try it in the browser)

Spin up a throwaway NetBox (UI + worker) **and** a disposable OpenNMS Horizon 36,
then click **Sync to OpenNMS** and watch a node appear:

```bash
cd quickstart
docker compose --profile opennms up -d
```

The [`quickstart/`](quickstart/) stack is seeded with example Devices, VMs, and
Requisitions, so every path is clickable from the first boot. Full walkthrough —
seeding, dry-run, and Sync — in the
**[Quickstart guide](https://no42-org.github.io/netbox-opennms-plugin/#local)**.

## Installation

Install into the same Python environment as NetBox. A **PyPI** release is a
fast-follow; until then, install from the tagged Git ref:

```bash
pip install "git+https://github.com/no42-org/netbox-opennms-plugin@v0.0.2"
```

Enable the plugin in NetBox's `configuration.py`:

```python
PLUGINS = ["netbox_opennms"]
```

Apply migrations and restart NetBox **and its RQ worker**:

```bash
python manage.py migrate
```

Then set the OpenNMS connection — see [Configuration](#configuration).

### Kubernetes (Helm chart)

The [`netbox-community/netbox`](https://github.com/netbox-community/netbox-chart)
chart enables plugins that are **already installed in the image** — it has no
runtime pip step — so bake the plugin into a custom image, then point the chart at
it and enable it. (This is also netbox-docker's own current guidance.) Installing a
plugin at runtime by mounting it into the container is **not** a supported path:
netbox-docker declined to add runtime-mounted plugins
([PR #1071](https://github.com/netbox-community/netbox-docker/pull/1071)) in favour
of build-time images, because a container should be fully defined at build time and
plugins that ship static assets need `collectstatic` to run during the build. (The
plugin itself is pure-Python, so it is not the constraint — this is about the chart
and netbox-docker, not the package.)

1. Build an image with the plugin. Match the base image to a NetBox version the
   plugin supports (**4.6.1+**) and to the chart's `appVersion`:

   ```dockerfile
   # Dockerfile
   FROM netboxcommunity/netbox:v4.6      # NetBox 4.6.1+; keep in step with the chart appVersion
   RUN /opt/netbox/venv/bin/pip install \
       "git+https://github.com/no42-org/netbox-opennms-plugin@v0.0.2"
   ```

   The tagged Git ref is the install source available today. PyPI is a fast-follow
   (then this becomes `pip install netbox-opennms-plugin==<version>`); for an
   air-gapped build, `make build` a wheel and `COPY` it in instead.

   ```bash
   docker build -t registry.example.org/netbox-opennms:v4.6 .
   docker push registry.example.org/netbox-opennms:v4.6
   ```

2. In `values.yaml`, point the chart at that image, enable the plugin, and enable
   the worker:

   ```yaml
   image:
     repository: registry.example.org/netbox-opennms
     tag: v4.6

   plugins:
     - netbox_opennms          # the package must already be in the image (step 1)

   pluginsConfig:
     netbox_opennms:
       opennms_url: "https://opennms.example.org/opennms"
       opennms_username: "provision-svc"
       opennms_password: "********"     # lab only — in production source from a Secret via extraConfig
       default_location: ""
       import_mode: "false"
       reconcile_orphans: "true"

   worker:
     enabled: true
   ```

   **The worker must run the same plugin-bearing image.** The plugin's Sync and
   drift-reconciler jobs run in the RQ worker; the chart's worker uses `image` by
   default, so leave it unset. If the worker runs an image without the plugin, the
   deploy succeeds and the UI works, but background jobs fail at dequeue with an
   import error.

3. Install or upgrade. The image entrypoint applies migrations on boot (no separate
   `manage.py migrate`), so just confirm the plugin's migrations ran:

   ```bash
   helm repo add netbox https://netbox-community.github.io/netbox-chart/
   helm upgrade --install netbox netbox/netbox -f values.yaml
   kubectl logs deploy/netbox | grep netbox_opennms   # → Applying netbox_opennms... OK
   ```

Keep `opennms_password` out of plaintext values — load the plugin config from a
`Secret` via the chart's `extraConfig`. On upgrades, rebuild the image with the
new plugin version, bump `image.tag`, and `helm upgrade`.

## Configuration

Configure the OpenNMS connection and behaviour in `PLUGINS_CONFIG`. Credentials
are read at runtime and are **never stored on a NetBox model**.

```python
PLUGINS_CONFIG = {
    "netbox_opennms": {
        # Base URL of the OpenNMS instance, including the context path.
        "opennms_url": "https://opennms.example.org/opennms",
        # A provisioning/REST role account (NOT stored on any NetBox model).
        "opennms_username": "provision-svc",
        "opennms_password": "********",          # use your secrets mechanism
        # Default OpenNMS monitoring location for requisitions that don't set one.
        # Empty means OpenNMS's built-in "Default" location.
        "default_location": "",
        # rescanExisting value passed to the import step: one of
        # "true" | "false" | "dbonly".
        "import_mode": "false",
        # Periodic drift reconciler (hourly): clears OpenNMS Foreign Sources the
        # plugin has pushed but NetBox no longer monitors — when a Requisition is
        # renamed or deleted, or its last member leaves. Ownership is tracked per
        # pushed Foreign Source, so it only ever touches requisitions the plugin
        # created, never a foreign one. "true" / "false"; needs an RQ worker.
        "reconcile_orphans": "true",
    },
}
```

You can verify the configured connection from the UI at **Plugins → NetBox
OpenNMS → Connect OpenNMS** (permission-gated; it tests the configured
connection and never persists credentials).

### `import_mode` values

| Value | Effect on import |
| --- | --- |
| `false` (default) | Import without rescanning nodes already known to OpenNMS. |
| `true` | Import and rescan existing nodes (re-run detectors/policies). |
| `dbonly` | Update the OpenNMS database only; do not schedule a scan. |

### Running the sync worker

Sync, Remove, and Move run as NetBox **background Jobs** — they never block the
request, and a bare OpenNMS `202 ACCEPTED` is reported honestly as *submitted for
import*, never "provisioned". A NetBox **RQ worker must be running** for those
jobs to execute:

```bash
python manage.py rqworker
```

If no worker is running, the Requisition and Sync pages show a warning
and jobs stay pending until one starts. Each object's last-sync state (submitted /
succeeded-accepted / removed / failed, with the triggering user, time, and any
error) is shown on the **Device/VM detail page**, backed by the NetBox Job log as
the audit trail.

## How it works

You author a **Requisition** — one user-named OpenNMS Foreign Source — that owns
its OpenNMS **detectors** and **policies** (discovered live from your OpenNMS
instance, with a curated preset overlay for labels/defaults, or a freeform class),
a set of declared **services** (e.g. ICMP, SNMP), and a live
NetBox **filter** that selects its member Devices/VMs (by role, tag, site, status,
custom field, …). Every member is monitored: its management IP is its **primary
IP** unless overridden, OpenNMS auto-discovers services via the detectors, and the
declared services are the guaranteed-present floor. A per-object **Monitoring
Override** is the escape hatch (exclude an object, pin a different management IP,
add extra interfaces — each with an SNMP role of **Primary / Secondary /
Not-eligible** (`snmp-primary` P/S/N; at most one Primary per node) — add/suppress
a service, or change its location).

Requisition filters must be **disjoint**: an object matched by more than one
Requisition's filter is a **conflict** — Sync of every involved Requisition is
blocked (their OpenNMS state stays untouched) until you resolve the overlap, so a
node always lives in exactly one Foreign Source and nothing ever moves or
disappears implicitly. A **dry-run** shows, per node, exactly what a Sync would
add / remove / change against the live OpenNMS state before you commit.

Per-node status is **graded**: a **Critical** (red) — a filter conflict — **blocks
Sync**; a **Warning** (yellow) is advisory and does **not**. A member with **no
management IP** is a Warning: rather than silently skipping it, the plugin
provisions an **inventory-only node with no IP interface** (it will not be actively
monitored) and surfaces the warning in the Sync preview, the dry-run, and the
Device/VM page — exclude the object if you don't want it in OpenNMS at all.

**Sync** renders the complete OpenNMS *foreign-source definition* + *requisition*
and imports it. Membership is a live NetBox query, so adding/removing a Device or
changing an attribute the filter matches simply re-resolves the Requisition;
render-and-replace makes every re-sync idempotent and never duplicates a node.

### OpenNMS-side setup

The plugin writes requisitions; it does **not** configure OpenNMS polling. For
monitoring to actually happen:

- **Provisioning account** — `opennms_username` needs a role that can read/write
  requisitions and trigger imports (e.g. the OpenNMS provisioning/REST role).
- **Detectors → poller packages** — the requisition's detectors tell OpenNMS which
  services to auto-discover, but OpenNMS only *polls* a discovered service if a
  matching **poller package** exists for it. The plugin cannot create poller
  packages — ensure your `poller-configuration.xml` covers the services your
  detectors discover (and the requisition's declared services).
- **Detector/policy discovery** — the detector and policy editors are populated
  live from your instance (`GET /rest/foreignSourcesConfig/{detectors,policies}`,
  the same API the OpenNMS UI uses), so the available classes and their parameters
  reflect what that OpenNMS actually has (including plugin-provided detectors). The
  built-in preset registry is only a curation overlay (friendly labels, sensible
  defaults, a shortlist). If OpenNMS is unreachable while editing, the editor
  degrades to the curated presets and notes it — you can still save (freeform class
  entry always works). Discovered results are cached briefly and refreshed at Sync.
- **Asset & metadata enrichment** — a Requisition can carry NetBox inventory into
  OpenNMS through two channels. **Asset mappings** map a NetBox attribute (serial,
  model, site, …) to a **fixed** OpenNMS node **asset field** (the `OnmsAssetRecord`
  set, discovered from `/foreignSourcesConfig/assets`; validated at save). **Metadata
  entries** attach an arbitrary `context`/`key`/`value` triad at **node / interface /
  service** scope (`context` defaults to `requisition`; a custom context must be
  `X-`-prefixed) — the open channel for anything without a fixed asset field, and the
  home for custom fields (`cf_<name>`). Values resolve per member; an unresolved value
  is simply omitted.
- **Minions / monitoring locations** — a node assigned to a non-`Default`
  monitoring location is only polled if a **Minion** is registered at that
  location. The plugin best-effort warns when a chosen location is unknown to
  OpenNMS, but cannot create it. The built-in `Default` location is polled by the
  OpenNMS core (no Minion required).

### Requisitions, membership, and node identity

A **Requisition**'s name *is* its OpenNMS Foreign Source name — user-chosen (it
must be Foreign-Source- and URL-path-safe: no whitespace or `# % & + ? / \ : * ' "`),
not derived. Its membership is a NetBox **filter** (FilterSet parameters, e.g.
`{"role": ["switch"], "tag": ["critical"]}`) applied to Devices and/or
VirtualMachines per its *object types*; you can seed the filter by **importing a
NetBox Saved Filter** (a one-time copy — no live link). A filter must actually
constrain each selected object type, so a typo or empty value can't silently
become a fleet-wide catch-all.

Filters must be **disjoint**. When several Requisitions match the same object it
is a **conflict**: the object is rendered into none of them and Sync of every
involved Requisition is blocked (frozen — the OpenNMS state stays exactly as last
synced) until you resolve it. Resolve by narrowing a filter — typically with a
negated parameter, e.g. `{"role": ["switch"], "tag__n": ["critical"]}` — or by
excluding the object (an excluded object never conflicts; it is monitored
nowhere). Conflicts are shown on the Requisition page, the Sync preview, the
dry-run, and the affected Device/VM page. The REST API follows the same
save-never-blocks rule but has **no warning channel** — after automated writes,
check the Sync preview (or the requisition page) for conflicts.

Node identity is the pair *(Foreign Source, type-qualified Foreign ID)* —
`device-{pk}` / `vm-{pk}` — so a re-sync updates a node in place and renaming a
Device only relabels it (never a duplicate). Moving an object between Requisitions
(a filter change) changes its Foreign Source, which OpenNMS treats as a new node;
the per-node **dry-run** surfaces such moves — and every add / remove / change
against the live OpenNMS state — before you Sync.

## Development & contributing

Full project-structure, build, dev-environment, testing, and release docs live in
the **[contributor guide](https://no42-org.github.io/netbox-opennms-plugin/)**. The
short version:

```bash
pip install -e .     # editable install
make verify          # ruff lint + full unit suite (what CI gates on)
make integration     # live round-trip against a disposable OpenNMS Horizon 36
```

Before a PR: `make verify` green, [Conventional Commits](https://www.conventionalcommits.org/),
**signed** commits (`git commit -s` + `commit.gpgsign`), and an SPDX header on every
source file. CI runs the same Makefile targets, so local and CI stay identical.

## License

MIT — see [LICENSE](./LICENSE). Every source file carries an SPDX header.
