On this page

Xodiac Agent — Plugin SDK

How collectors work, the contracts they must honor, and the checklist for adding a new one. Read pkg/plugins/plugins.go first — it is the router.

Architecture

platform config (plugins[])  ──poll──▶  agent daemon
        │                                  │
        │  enabled plugin list + params    │  conf.d/<name>.json creds
        ▼                                  ▼
   RunAll()  → VirtEntity[]          CollectMetrics() → Metric[]
        │                                  │
        └──────── report payload ───────────┘
  • Entities (RunAll/RunOne) → virt_entities table → Virt page, topology tree, virt-subject incidents.
  • Metrics (CollectMetrics) → metric_samples/metric_buckets → per-device graphs + device_metric alert rules.
  • A plugin may emit both (docker/podman/snmptrap/snmp do).

Every plugin runs sandboxed: RunAll wraps each call in recover() + a 30 s context.WithTimeout. One bad plugin never blocks the report — return an error and it is logged-and-skipped.

The two contracts

Entity collector

func runMyPlugin(ctx context.Context, creds json.RawMessage,
                 params map[string]any) ([]VirtEntity, error)
  • creds = contents of conf.d/<name>.json on the host (may be nil for creds-less plugins like docker, smartmon). Secrets live only here — never in params (params come from the platform and are tenant-editable).
  • params = the plugin's params object from platform config.
  • Return []VirtEntity; error → logged + skipped.
type VirtEntity struct {
    Kind      string         // vm|container|link|service|device|cluster|backup|security|disk|network …
    ExtID     string         // stable unique id, e.g. "proxmox/node/pve1" — dedup key on platform
    Name      string         // display name
    ParentExt string         // optional parent ExtID → topology edges
    State     string         // running|stopped|degraded|stale|up|down… (free-form)
    Meta      map[string]any // JSON blob shown in the UI; numbers stay numeric
}

Metrics collector

Registered by name inside CollectMetrics (pkg/plugins/scrape.go) — same creds/params, returns []collect.Metric:

type Metric struct {
    Name  string  // dotted, sanitized to [a-zA-Z0-9_.] — "link.eth0.bytes_in"
    Value float64
    TS    string  // optional RFC3339; empty = server receipt time
}

Rates/counters: keep delta state in a package-level var guarded by a mutex (see snmpmetrics.go) — the agent is a long-running daemon, so in-memory baselines survive across report cycles. First sample after a restart should emit nothing rather than a fake spike.

The declarative escape hatch — no Go required

Most HTTP JSON APIs need zero code:

  • custom_http — params url/path/name_field/id_field/ state_field/parent_field/meta_fields map any JSON response to entities. Auth only via conf.d/custom_http[.<cred_key>].json.
  • metrics_scrape — params/conf.d endpoints[] scrape Prometheus text or flat JSON → metrics.
  • custom_metrics — drop executables or .prom files in conf.d/metrics.d/ → metrics.
  • script — executables in conf.d/scripts/ → entities/metrics.

Only write Go when the API needs sessions, multi-step flows, binary protocols, or local system access.

The generic spec table

pkg/plugins/generic.go specs map covers ~200 services with ~4 lines each — prefer extending it over a new file:

type spec struct {
    kind, name string   // entity kind + display name
    exec       []string // argv; first element must be on PATH
    http       string   // JSON endpoint; "{{url}}" = params.url
    httpAuth   string   // "basic" | "bearer" | "header:<Name>" | "query:<param>"
    basicParse bool     // parse k:v / k=v output lines into meta
    tcpCmd     string   // tcp:// specs: line sent before reading stats
}

http may also be tcp://{{url}} for stats-over-TCP services (memcached stats, ZooKeeper mntr, beanstalkd stats).

Credentials boundary (security contract — do not weaken)

  • Secrets only from conf.d/<name>.json. Params are platform-side and tenant-editable.
  • credsURL() (plugins.go): when creds carry a url, that url is the fetch target and auth attaches only to it — params.url can never redirect credentials. A creds file with secrets but no url sends nothing to a params.url target.
  • custom_http cred_key gives each authed endpoint its own conf.d file (custom_http.<key>.json).
  • Never log creds; never put them in VirtEntity.Meta (mask as "***").
  • plugin_run remote action can only name the plugin — params always come from stored platform config.

Adding a collector — checklist

  1. Agent: implement run<Name> (or a spec row) in pkg/plugins/.
  2. Register: add a case in run() (plugins.go) — or rely on runGeneric for spec-table names.
  3. Metrics? add a branch in CollectMetrics (scrape.go).
  4. Platform catalog: add a KNOWN entry in app/Controllers/Api/Plugins.php (label, detect hints, params defaults, blurb) — without it the UI can't enable the plugin.
  5. Docs: FEATURES.md plugin list + cookbook entry if it needs conf.d setup.
  6. Test: xodiac-agent -test-plugins runs every conf.d cred file locally and dumps entities/metrics — the cred-verification path. Unit-test pure parsing/delta logic (see snmpmetrics_test.go, trap_test.go).

Conventions

  • Context: honor ctx for network/exec (exec.CommandContext, http.NewRequestWithContext); the 30 s plugin timeout is the hard cap.
  • Bounded reads: io.LimitReader on HTTP bodies (1 MB) and exec output.
  • ExtID stability: derive from immutable ids (vmid, serial, ifIndex) — never from names that can be renamed.
  • Meta values: strings/numbers/bools only (it round-trips as JSON).
  • State: running for healthy services; degraded for partial faults (non-ok sensor, stale backup); stale for old data.