A kubectl-style command-line interface for OpenNMS Horizon. One
declarative entrypoint — onmsctl apply -f — peeks each YAML document's kind
and routes it to the right handler, so users, event sources, SNMP config,
requisitions, maintenance windows, and data-collection sources all reconcile
through a single command. XML→YAML migrators bring legacy eventconf and
provision.pl-shape files into the loop; reads, explicit deletes, and convert
stay imperative.
New to onmsctl? Start with the Quick Start — install, configure a context, and run your first
applyin a few minutes.
Pre-stability notice.
v0.x.yreleases may break CLI flags, the config schema, and theEventSourceYAML schema between minor versions. Surfaces stabilize atv1.0.0.
- Install · Container image · Configure a context
- Declarative apply — the apply model
- Capabilities: Event configuration · Provisioning · Users and roles · SNMP configuration · Maintenance windows · SNMP data collection · Business services
- Conventions & tooling · Server compatibility
- Migration · EventSource reference
Pre-compiled binaries are published as GitHub Releases for every v*.*.* tag,
with per-binary SHA256 checksums, an aggregate SHA256SUMS, and Sigstore
(cosign) keyless signatures + certificates for every asset.
| Target | Asset suffix |
|---|---|
| Linux x86_64 | x86_64-unknown-linux-gnu |
| Linux aarch64 | aarch64-unknown-linux-gnu |
| macOS x86_64 (Intel) | x86_64-apple-darwin |
| macOS aarch64 (Apple Silicon) | aarch64-apple-darwin |
Windows is not yet in the release matrix; Windows users build from source.
VERSION=v0.4.1
TARGET=x86_64-apple-darwin # or one of the rows above
curl -fL -O https://github.com/no42-org/onmsctl/releases/download/${VERSION}/onmsctl-${VERSION}-${TARGET}
curl -fL -O https://github.com/no42-org/onmsctl/releases/download/${VERSION}/onmsctl-${VERSION}-${TARGET}.sha256
shasum -a 256 -c onmsctl-${VERSION}-${TARGET}.sha256
chmod +x onmsctl-${VERSION}-${TARGET}
sudo mv onmsctl-${VERSION}-${TARGET} /usr/local/bin/onmsctl
onmsctl versionVerify the cosign signature (recommended) — ties the binary to a specific GitHub Actions run on this repo, with no long-lived key:
cosign verify-blob \
--certificate-identity-regexp "^https://github.com/no42-org/onmsctl/.github/workflows/release.yml@" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate onmsctl-${VERSION}-${TARGET}.pem \
--signature onmsctl-${VERSION}-${TARGET}.sig \
onmsctl-${VERSION}-${TARGET}Binaries are Sigstore-signed but not Apple-notarized; on macOS, clear the
quarantine flag once with xattr -d com.apple.quarantine /usr/local/bin/onmsctl
(or approve via System Settings → Privacy & Security).
Requires the toolchain pinned in rust-toolchain.toml (currently Rust 1.95):
git clone https://github.com/no42-org/onmsctl && cd onmsctl
make build # debug → target/debug/onmsctl
cargo install --path crates/onmsctl # → ~/.cargo/binonmsctl is one binary statically linking every capability crate. onmsctl version prints the binary version alongside each linked capability:
onmsctl 0.4.1
capabilities:
- eventconf 0.4.1
- provisioning 0.4.1
- iam 0.4.1
- snmp 0.4.1
- maintenance 0.4.1
- datacollection 0.4.1
- business-service 0.4.1
A multi-arch (linux/amd64, linux/arm64) distroless image is published to
GHCR for every v*.*.* tag at ghcr.io/no42-org/onmsctl. It's a single static
binary on gcr.io/distroless/static — no shell, no package manager, running as
the non-root user 65532 — so it's small and has a minimal attack surface for
CI/CD pipelines. Each release publishes the exact version (0.4.1), the
rolling MAJOR.MINOR tag (0.4), and latest (the newest non-prerelease).
Image tags carry no leading v (the v0.4.1 git tag publishes as 0.4.1):
docker run --rm ghcr.io/no42-org/onmsctl:0.4.1 versionMount your config and manifests to run a declarative apply as a pipeline step
(the entrypoint is onmsctl, so pass subcommands directly). apply resolves a
config file that defines the context (server URL + user); point ONMSCTL_CONFIG
at the mounted file and supply the password at runtime via ONMS_PASSWORD:
docker run --rm \
-e ONMSCTL_CONFIG=/work/onmsctl.yaml \
-e ONMS_PASSWORD \
-v "$PWD:/work:ro" -w /work \
ghcr.io/no42-org/onmsctl:0.4.1 apply -f requisition.yamlONMS_URL, ONMS_USER, and ONMS_TOKEN are also honored as overrides on top
of the resolved context. See Configure a context for the
config-file format.
Because the image is distroless it has no shell — use it as a docker run
step rather than as a GitLab image: / GitHub container: job that expects to
run before_script shell commands.
Images carry build provenance + an SBOM and are Sigstore-signed (keyless). Verify
the signature ties the image to a build of this repo's docker.yml workflow:
cosign verify \
--certificate-identity-regexp "^https://github.com/no42-org/onmsctl/.github/workflows/docker.yml@" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/no42-org/onmsctl:0.4.1Build it locally for your host architecture with make docker (produces
onmsctl:dev).
kubectl pattern: one config file, one or more named contexts, one active context.
| OS | Default path |
|---|---|
| Linux | $XDG_CONFIG_HOME/onmsctl/config.yaml (typically ~/.config/onmsctl/config.yaml) |
| macOS | ~/Library/Application Support/org.no42-org.onmsctl/config.yaml |
| Windows | %APPDATA%\no42-org\onmsctl\config\config.yaml |
Override with --config <path> or $ONMSCTL_CONFIG.
current-context: dev
contexts:
- name: dev
server:
url: https://horizon.dev.lab/opennms
auth:
basic:
username: admin
password: admin # don't commit inline secrets — see Credentialsonmsctl config view # current config (inline secrets redacted)
onmsctl config use-context staging # atomic switch of the active contextCredentials. auth.basic / auth.bearer take exactly one source: inline
(password/token), a file (password-file/token-file, mode 0600), or the
OS keyring (macOS Keychain / Windows Credential Manager built-in; Linux needs a
rebuild with --features keyring/sync-secret-service). Resolution at request
time: env ($ONMS_PASSWORD / $ONMS_TOKEN) > keyring > file > inline.
Override precedence: flags (--url, --user, --context) > env (ONMS_URL, ONMS_USER, ONMSCTL_CONTEXT) > active context > built-in default.
Full credential + context walkthrough: Quick Start.
onmsctl apply -f <file|dir|glob> is the single declarative mutation entrypoint.
It peeks each YAML document's kind and routes it to the registered handler —
there is no per-capability apply verb. Recognized kinds:
kind |
apiVersion |
Reconciles |
|---|---|---|
User |
onmsctl.no42.org/v1alpha1 |
Horizon users + roles |
EventSource |
eventconf.opennms.org/v1 |
event configuration sources |
SnmpConfig |
snmp.opennms.org/v1 |
SNMP agent + trap-daemon config (singleton) |
Requisition |
provisioning.opennms.org/v1 |
provisioning requisitions |
Maintenance |
maintenance.opennms.org/v1 |
scheduled-outage maintenance windows |
DataCollectionSource |
datacollection.opennms.org/v1 |
SNMP data-collection sources |
BusinessService |
bsm.opennms.org/v1 |
Business Service Monitoring (BSM) services + edges |
A single file may hold many ----separated documents, and a directory can mix
all kinds.
Plan → gate → execute. Every document is planned first. If any fails to
plan — unknown kind, duplicate metadata.name, parse error — the whole apply
aborts before any mutation. Once the gate passes, documents execute in a
static precedence order so dependencies settle first:
User (100) → EventSource (200) → SnmpConfig (250) → Requisition (300) → Maintenance (350) → DataCollectionSource (375) → BusinessService (400)
Each document yields one ApplyOutcome row, rendered through -o table|yaml|json:
kind name action status message
Requisition acme-prod create Skipped dry-run: would create
Requisition site-b none Unchanged in sync
onmsctl apply -f users.yaml # single file
onmsctl apply -f ./desired-state/ # directory (mixed kinds)
onmsctl apply -f ./desired-state/ -R # recurse into subdirs
onmsctl apply -f 'sources/cisco-*.yaml' # glob (quote it)| Flag | Behavior |
|---|---|
--dry-run |
Plan only; zero mutating HTTP. Classifies as a Read, so --read-only contexts may run it. |
--diff |
Render each kind-bucket's diff to stderr (stdout stays clean for -o json/yaml). |
--continue-on-error (alias --keep-going) |
Keep applying after a failing document. Default is stop-on-error. |
-R / --recursive |
Recurse into subdirectories (off by default). |
Exit codes: 0 all applied/unchanged; 1 any document failed (incl. a
plan-gate failure); 2 usage error. Full table under
Conventions & tooling. The imperative mutators that predated this
model are gone — see Migration.
Keep event configuration in git as YAML and push it to Horizon declaratively.
The loop: event-source convert brings existing XML in, onmsctl apply -f ships
edits out, event-source export snapshots the server back to YAML.
onmsctl event-source convert /opt/opennms/etc/events/cisco.foo.events.xml # XML → YAML
onmsctl apply -f cisco.foo.yaml --diff # push (diff to stderr)
onmsctl event-source export cisco.foo --out ./sources/ # server → YAMLmetadata.name becomes the server's stored source name verbatim; Horizon derives
vendor as the prefix before the first . (cisco.foo → vendor cisco). apply
is idempotent (Horizon's upsert replaces events under an existing basename).
Bulk export is continue-on-error and runs each source through the convert
migrator, so the same EC### findings apply.
Read/raw verbs stay imperative — event-source list|get|names|names-and-ids,
event list (incl. cross-source --uei/--vendor), and the raw-XML
event-source upload|download round-trip (use these for full fidelity when a
source carries fields the YAML model doesn't represent yet).
The examples/ directory ships event-source-{minimal,full,severities,disabled}.yaml.
See the Quick Start for the
walkthrough and the EventSource reference for the
field-by-field schema and the event-source convert finding catalog.
Manage Horizon requisitions (the provision.pl-shape data) declaratively from
git: requisition convert brings existing XML in, apply ships edits out,
requisition import / status cover the lifecycle.
onmsctl requisition convert --from etc/imports/ --foreign-sources-dir etc/foreign-sources/ --out yaml/
onmsctl apply -f acme-prod.yaml --dry-run --diff # preview
onmsctl apply -f acme-prod.yaml # apply (computes the diff, triggers import)
onmsctl requisition import acme-prod --wait --timeout 5m # block on the async importThe apply path computes a three-level diff (canonical-bytes / per-node / per-leaf),
auto-decides rescanExisting from the scan-relevance of what changed, writes
the foreign-source + requisition, and triggers the import. Import completion is
asynchronous — block with import --wait or poll requisition status <fs>.
- Pinned vs portable
foreignSource. Includespec.foreignSource(detectors + policies) and the YAML owns both the requisition and its foreign-source. Omit it and the requisition inherits Horizon's default FS — and if a custom FS existed for that name,applydeletes it (the--diffenumerates the displaced detectors/policies). Theexamples/requisition-acme-prod.yamlfixture shows the pinned style with every modeled field. - Unmodeled XML is preserved under
metadata.x-onmsctl-unmodeled, so aconvert → apply → export → re-applyround-trip keeps server-side fields onmsctl doesn't model yet. The annotation is stripped before the diff and the wire body, so it never changes apply outcome.
Lifecycle/read verbs: requisition list|status|import|export|delete and the
node|interface|service|category|asset list/get reads. requisition delete <fs>
requires --yes (it purges both pending and deployed snapshots).
Quick Start §7 ·
provision.pl migration.
Manage Horizon users and roles declaratively (kind: User) and imperatively via
the read / rotate / delete verbs. Targets the users REST surface on Horizon 35+.
onmsctl iam whoami # the calling user
onmsctl apply -f ./users/ --dry-run --diff # preview
onmsctl apply -f ./users/ # apply
onmsctl iam user list|get|export # reads
onmsctl iam user delete alice --yes
printf %s "$NEW_PW" | onmsctl iam user set-password alice --password-stdin- Roles reconcile as a set —
roles: [B, C]against a server holding[A, B]grantsC, revokesA, keepsB. Omitted scalar fields never clear the server value (merge, not replace). passwordRef— passwords are create-only and never inline. A literalpassword:is rejected at parse (PR-IAM-001); reference an external secret (fromFile/fromEnv/fromKeyring). Honored on Create only —applynever rotates (it can't read the current password to diff); useiam user set-password.- Lockout protection. Apply refuses a plan that would empty a protected role
(
IAM-001, exit 13; override per-context withallow-admin-lockout: true) or strip/delete the calling user's own protected role (IAM-002, exit 14, no override). Ifwhoamiis unavailable for a self-affecting change, it refuses (exit 15) rather than skip the check. dutyScheduleis create-only (PR-IAM-004warns on change); a purely numericmetadata.nameis refused (PR-IAM-003).
A context may tune defaults under an iam: block (protected-roles,
known-roles, allow-admin-lockout).
Quick Start §8 ·
users.xml migration.
Manage Horizon's SNMP configuration (snmp-config.xml: defaults + profiles +
definitions) declaratively, and read it back with snmp export / snmp lookup.
Targets /api/v2/snmp-config.
SnmpConfig is a singleton (metadata.name fixed to default): apply
reconciles by whole-config replace — pull the deployed config, compare ignoring
secret values, re-upload only when it differs.
onmsctl apply -f examples/snmp-config.yaml --dry-run --diff
onmsctl snmp export -O snmp-config.yaml # deployed config → YAML (secrets as refs)
onmsctl snmp lookup 192.168.8.8 --show-secrets # effective params OpenNMS would use- Secrets are write-only. Communities and v3 passphrases are rejected inline;
reference an external secret (same shape as IAM's
passwordRef). They're excluded from the idempotency comparison, so a secret-only rotation isn't auto-detected — re-apply deliberately.snmp exportemits placeholders, never cleartext. - Trap daemon via optional
spec.trapd(reconciled against/api/v2/trapd/configin the same apply; additive — omit it and the trap daemon is untouched). Requires a Horizon build with the Trapd REST API (NMS-19128, the37.x/developline); against older servers thetrapdhalf fails with a clear version message while the snmp-config half still applies. - Order:
SnmpConfigapplies beforeRequisition, so a co-located directory configures SNMP before importing nodes. An SNMP change does not auto-rescan already-imported nodes — follow withrequisition import <fs> --rescan-existing.
Plan a maintenance window — for a period, stop OpenNMS polling, notifications, and
threshold (and optionally collection) evaluation on chosen devices. Maps to an
OpenNMS scheduled outage (/rest/sched-outages); named and multi-instance
(one document per window), reconciled like requisitions. No version gate — the API
is present in every supported Horizon/Meridian.
apiVersion: maintenance.opennms.org/v1
kind: Maintenance
metadata: { name: weekend-patching }
spec:
schedule:
type: specific # specific | daily | weekly | monthly
times:
- { begins: "20-Jun-2026 22:00:00", ends: "21-Jun-2026 04:00:00" }
devices: # selectors are a UNION, deduped to nodeIds
interfaces: [192.168.8.8] # an IP, or the single literal `match-any`
nodes: [ { foreignSource: hq, foreignId: web01 } ] # resolved to a nodeId at apply
categories: [Routers] # every node in ANY listed category
locations: [Berlin] # every node at ANY listed Minion location
asset: { field: city, value: Berlin }
suppress:
polling: { packages: [production] } # explicit packages required (no default)
notifications: true # global (no package)onmsctl apply -f examples/maintenance.yaml --dry-run --diff
onmsctl maintenance list
onmsctl maintenance status 192.168.8.8 12 # IP or nodeId → in a window now?
onmsctl maintenance delete weekend-patching # full teardownApply writes the outage definition (a true Created/Updated/Unchanged),
then attaches it per daemon (polling→pollerd, thresholds→threshd,
collection→collectd per package; notifications→notifd global). Two things to
know:
- Attachments are ensure-present. The API can't read which daemons an outage is
attached to, so onmsctl re-issues the idempotent attach every apply and cannot
detach — removing a
suppressentry does not detach it. To reduce suppression,maintenance delete <name>and re-apply. - Explicit packages; server timezone; foreignId nodes.
polling/thresholds/collectionrequire an explicitpackageslist. Times are the server's timezone. Nodes are named by{foreignSource, foreignId}and resolved at apply (an un-imported node fails that window — which is whyMaintenanceapplies afterRequisition; preferinterfaces/match-anywhen nodes may be un-imported). - Dynamic selectors (
categories/locations/asset) are a union, not an intersection, re-resolved on every apply (a snapshot). A selector matching nothing warns; a window covering nothing fails.
Manage Horizon's SNMP data-collection config — which MIB objects, resource types,
and system definitions are collected — over the DB-backed
/api/v2/datacollectionconf surface. One kind: DataCollectionSource document per
datacollection-group (a "source"); onmsctl owns only the sources you write —
the stock vendor library is left untouched (additive prune, not a singleton).
onmsctl apply -f examples/datacollection-source.yaml
onmsctl datacollection list [--profiles] # deployed sources / profiles
onmsctl datacollection export acme-router # the group as xml|json
onmsctl datacollection delete acme-router- Whole-source replace — a changed group tree is re-uploaded and the server upserts and prunes removed children; an unchanged source (normalized, order- insensitive) is a no-op.
profilesis the full truth (true reconcile) — a name added is attached, a name dropped is detached. A new source must list ≥1 profile. An optional inlineprofileSpeccreates/tunes that profile from zero.- Preflight version gate — the endpoint is absent from released Horizon ≤ 37.0.0; apply probes it once and fails the whole apply early (before any write) on a server that lacks it.
list/export are Read; apply/delete are Write.
Describe a Business Service Monitoring (BSM) hierarchy declaratively — a service,
its per-service reduce function, optional attributes, and four kinds of edge
(to child services, monitored IP services, applications, and raw alarm reduction
keys), each with a weight and optional per-edge map function. Maps to the v2
/api/v2/business-services surface; named and multi-instance (one document per
service). No version gate — the API is present in every supported Horizon/Meridian.
apiVersion: bsm.opennms.org/v1
kind: BusinessService
metadata: { name: web-frontend }
spec:
attributes: { owner: platform-team }
reduceFunction:
type: Threshold # HighestSeverity | Threshold | HighestSeverityAbove | ExponentialPropagation
properties: { threshold: "0.75" }
childServices: # → another BusinessService, by name
- { name: database-tier, weight: 2, mapFunction: { type: Increase } }
ipServices: # → a monitored service; node by label+location
- node: { label: webhost01, location: Default }
ipAddress: 10.0.0.10
service: HTTP
applications: # → an OpenNMS Application, by name
- { name: Webservers }
reductionKeys: # → a raw alarm reduction key (escape hatch)
- reductionKey: "uei.opennms.org/threshold/highThresholdExceeded::{{nodeId}}:10.0.0.10:ifHCInOctets:90.0:3:75.0:Gi1/0/1"
node: { label: webhost01 } # resolves {{nodeId}} at apply
mapFunction: { type: SetTo, properties: { status: Major } }onmsctl apply -f examples/business-service.yaml --dry-run --diff
onmsctl business-service list # or: onmsctl bs list
onmsctl business-service get web-frontend
onmsctl business-service delete web-frontend # explicit removalReferences are by name (BSM's REST API is ID-centric; onmsctl resolves names → ids at apply, so the same YAML is portable across instances). Things to know:
- Whole-object reconcile. A service is created then fully PUT (a destructive full-replace). Edges omitted from a document are pruned; an unchanged service (order-insensitive) is a no-op. Child references are resolved by a two-pass apply (create, then PUT with resolved ids), so two new services can reference each other in one file regardless of order; a child cycle fails at plan time.
- Across-apply non-deletion. A service present on the server but absent from
your apply is not deleted — use
business-service delete <name>. - Node references. A
nodeis{label, location}(the default location isDefault) or{foreignSource, foreignId}. Labels are not unique in OpenNMS; an ambiguous label fails the apply and tells you to use the foreignSource/foreignId form. - Prefer
ipServicesover hand-written reduction keys. AnipServicesedge auto-covers the service's standard alarms (nodeLostService/interfaceDown/nodeDown), so you never type a node id. Reach forreductionKeysonly for custom alarms (thresholds, custom UEIs, situations); the literal key must match a resolved alarm reduction key (copy it from a real alarm — not the event-def formula), and{{nodeId}}(plus{{foreignSource}}/{{foreignId}}/{{nodeLabel}}) is expanded from the edge'snode. - Daemon reload. Changes are inert until
bsmdreloads; a mutating apply (anddelete) issues exactly onedaemon/reloadautomatically.
list/get are Read; apply/delete are Write.
JSON Schemas (draft 2020-12) live under schemas/, one per kind. Add a
modeline to the top of your YAML for in-editor validation via
yaml-language-server:
# yaml-language-server: $schema=https://raw.githubusercontent.com/no42-org/onmsctl/main/schemas/event-source.schema.jsonSwap the filename for requisition / iam-user / snmp-config / maintenance /
datacollection. Pin a release tag for stability, or reference a local clone
(./schemas/<name>.schema.json). Regenerate with make schema (CI fails if a
committed artifact lags). The requisition schema annotates list fields with
x-onmsctl-list-kind: ordered|set so diff tooling distinguishes ordered sequences
(detectors, policies) from sets (categories, services).
Every list/get accepts -o table (default), -o yaml, -o json.
Short aliases: event-source→evtsrc, event→evt, requisition→req,
config→cfg, maintenance→maint, datacollection→dc (both forms appear in
--help).
Mark a context read-only: true (or pass --read-only / set $ONMSCTL_READ_ONLY)
to refuse every Write verb locally — exit code 12 — before any HTTP call.
Precedence is flag > env > context > default false; the flag is one-way.
--dry-run apply classifies as a Read, so it runs in read-only contexts.
Stable and safe for scripting:
| Code | Meaning | Code | Meaning | |
|---|---|---|---|---|
| 0 | success | 9 | unsupported auth scheme | |
| 1 | HTTP non-success / partial-failure batch | 10 | --wait timed out |
|
| 2 | misuse / config error | 11 | --wait saw the async op fail |
|
| 4 | DNS failure | 12 | write refused by read-only context | |
| 5 | connection refused | 13 | apply refused: admin lockout (IAM-001) | |
| 6 | timeout | 14 | apply refused: self lockout (IAM-002) | |
| 7 | TLS handshake failed | 15 | apply refused: whoami unavailable |
|
| 8 | redirect loop |
onmsctl completion bash > /etc/bash_completion.d/onmsctl
onmsctl completion fish > ~/.config/fish/completions/onmsctl.fish
onmsctl completion zsh > "$(brew --prefix)/share/zsh/site-functions/_onmsctl" # adjust target to your setupFor Oh My Zsh, write to ~/.oh-my-zsh/custom/completions/_onmsctl and add
fpath=("$ZSH_CUSTOM/completions" $fpath) to ~/.zshrc above the
source $ZSH/oh-my-zsh.sh line. The script targets the literal name onmsctl —
sed -e 's/onmsctl/<name>/g' if you've repackaged it.
server.insecure-skip-tls-verify: true (or --insecure-tls) disables certificate
verification and emits a per-request stderr warning. Keep it off in production.
| Server | Status |
|---|---|
| OpenNMS Horizon 35+ | Primary target (EventConf REST reproducible on 35.0.5 / 36.0.0). |
Some capabilities need newer builds: the SNMP Trapd block (NMS-19128, 37.x/
develop) and data collection (absent from released Horizon ≤ 37.0.0) — both
gate cleanly with a clear version message on older servers.
Known eventconf quirks (Horizon 35.0.5 / 36.0.0, tracked upstream; onmsctl works around the load-bearing ones client-side):
event-source listcan print empty despite existing sources (NMS-19810) — useevent-source names-and-ids;apply --diffmay show a whole document as "added" rather than a true delta, though the upload still succeeds.POST /eventconf/uploadrequires the multipart field name to be literallyupload(NMS-19813) and derives the source name by stripping only the final extension — onmsctl handles both, and uploads{metadata.name}.xmlso the stored name equalsmetadata.name.
Apache-2.0. Third-party crate licenses are inventoried in THIRD-PARTY-LICENSES.md
(regenerated by make licenses).
See CONTRIBUTING.md. Implementations work from the OpenAPI document and black-box
observation of a Horizon instance — never from Horizon's server source — so the
result stays an Apache-2.0 clean-room.