Files
unpoller_unpoller/pkg/inputunas/input_test.go
T
Cody LeeandClaude Opus 5 d5dfc771d5 feat: add opt-in UNAS Pro support (closes #785)
Adds a new `unas` input plugin that polls UNAS Pro storage consoles and
exports console health, storage pools, disks and shares to Prometheus,
InfluxDB and DataDog.

UNAS is a separate plugin rather than a device type inside inputunifi
because a storage-only console has no Network application: it cannot
answer /status, has no sites, and shares none of the UniFi device schema.

The plugin is opt-in and inert until an operator names a console. Opt-in
is expressed as "no devices configured" rather than a `disable` flag,
because a bool named `disable` zero-values to false and so cannot make a
plugin default-off. Initialize returns silently on an empty device list
and, unlike inputunifi, nothing synthesizes a default URL.

Two behaviours are worth calling out for reviewers:

  - Metrics returns (metrics, nil) whenever any console was collected.
    poller.collectMetrics uses `if err != nil {} else if metric != nil`,
    so returning both would discard every healthy console because one
    failed. Only a total failure returns an error.

  - Re-auth fires on total failure, not on a 401. A mid-session 401 from
    GetData surfaces as ErrInvalidStatusCode, not ErrAuthenticationFailed,
    so there is no sentinel to match on. Session expiry fails all four
    endpoints at once, which is exactly the total-failure case.

Prometheus metrics use the `unifi_unas_` prefix, which diverges from the
`unas_` prefix used by the reference implementation; dashboards built
against that will need query edits.

Requires unifi/v5 v5.31.0 for the UNAS client and structs.

Credit to alexgreenbank/unaspoller for mapping the endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:52:37 -05:00

220 lines
6.6 KiB
Go

package inputunas_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unpoller/unifi/v5"
"github.com/unpoller/unpoller/pkg/inputunas"
"github.com/unpoller/unpoller/pkg/poller"
)
const (
deviceInfoJSON = `{"name":"UNAS-Pro","model":"UNASPro","version":"4.0.6","firmwareVersion":"4.0.6",
"status":"HEALTHY","cpu":{"currentLoad":12,"temperature":45},
"memory":{"free":1000,"total":8000,"available":6000}}`
storageJSON = `{"pools":[{"number":1,"id":"pool-1","type":"RAID","status":"HEALTHY",
"capacity":1000,"usage":400,"activeRaidGroupId":"rg-1",
"raidGroups":[{"number":1,"id":"rg-1","currentLevel":"RAID5","configLevel":"RAID5",
"currentProtection":1,"expectedProtection":1,"progress":100}]}],
"disks":[{"slotId":"1","poolId":"pool-1","type":"HDD","state":"HEALTHY",
"model":"HAT5300","serial":"ABC123","temperature":38,"healthScore":100,"size":500}]}`
drivesJSON = `{"drives":[{"id":"d-1","name":"media","type":"SHARE","status":"HEALTHY","quota":100,"usage":50,"memberCount":3}]}`
networkIOJSON = `{"receiveKBPS":120,"transmitKBPS":340,"timestamp":"2026-01-01T00:00:00Z"}`
)
// unasServer is a fake UNAS Pro console. failData, when set, makes every data endpoint
// return 401 -- which is how an expired session presents itself.
type unasServer struct {
logins atomic.Int64
failData atomic.Bool
}
func (s *unasServer) start(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, _ *http.Request) {
s.logins.Add(1)
w.Header().Set("x-csrf-token", "csrf-token")
w.WriteHeader(http.StatusOK)
})
for path, body := range map[string]string{
unifi.APIUNASDeviceInfoPath: deviceInfoJSON,
unifi.APIUNASStoragePath: storageJSON,
unifi.APIUNASDrivesPath: drivesJSON,
unifi.APIUNASNetworkIOPath: networkIOJSON,
} {
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
if s.failData.Load() {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
})
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newInput(t *testing.T, urls ...string) *inputunas.InputUNAS {
t.Helper()
devices := make([]*inputunas.Device, len(urls))
for i, u := range urls {
devices[i] = &inputunas.Device{URL: u, User: "unpoller", Pass: "secret"}
}
return &inputunas.InputUNAS{Config: &inputunas.Config{Devices: devices}}
}
func TestMetricsCollectsConsole(t *testing.T) {
t.Parallel()
srv := (&unasServer{}).start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.NotNil(t, m)
require.Len(t, m.UNASDevices, 1)
d, ok := m.UNASDevices[0].(*unifi.UNASDevice)
require.True(t, ok, "outputs type-assert to *unifi.UNASDevice, so nothing else may be appended")
a := assert.New(t)
a.Equal("UNAS-Pro", d.Name())
a.Equal("UNASPro", d.Model())
a.Equal(srv.URL, d.SourceName)
require.NotNil(t, d.Storage)
a.Len(d.Storage.Pools, 1)
a.Len(d.Storage.Disks, 1)
a.Len(d.Drives, 1)
require.NotNil(t, d.NetworkIO)
a.InDelta(120, d.NetworkIO.ReceiveKBPS.Val, 0.01)
}
// A UNAS session cookie expires after roughly two hours. When it does, every endpoint fails
// at once, and the plugin must log back in and re-attach fresh cookies rather than reuse the
// dead jar -- otherwise a long-running poller reports nothing until it is restarted.
func TestMetricsReauthenticatesAfterSessionExpiry(t *testing.T) {
t.Parallel()
fake := &unasServer{}
srv := fake.start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
require.Equal(t, int64(1), fake.logins.Load())
fake.failData.Store(true)
m, err := u.Metrics(nil)
require.Error(t, err, "a console that fails every endpoint even after re-auth is a real error")
require.Nil(t, m)
require.Equal(t, int64(2), fake.logins.Load(), "a total failure must trigger exactly one re-login")
// Once the console answers again, the refreshed session works without another login.
fake.failData.Store(false)
m, err = u.Metrics(nil)
require.NoError(t, err)
require.Len(t, m.UNASDevices, 1)
require.Equal(t, int64(2), fake.logins.Load(), "a healthy poll must not re-authenticate")
}
// One dead console must not discard the metrics of a healthy one. poller.collectMetrics
// drops the result entirely when an input returns both metrics and an error, so returning
// them together here would throw away everything the working console reported.
func TestMetricsPartialFailureStillReportsHealthyConsole(t *testing.T) {
t.Parallel()
good := (&unasServer{}).start(t)
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer dead.Close()
u := newInput(t, dead.URL, good.URL)
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err, "one bad console out of two is not an error")
require.Len(t, m.UNASDevices, 1)
}
func TestMetricsDisabled(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{Config: &inputunas.Config{Disable: true}}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Nil(t, m)
}
// With no [unas] block the plugin must be silent and inert: that is what makes UNAS support
// opt-in. It must not synthesize a default device the way inputunifi does with localhost.
func TestUnconfiguredIsInert(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Nil(t, m, "an unconfigured plugin disables itself")
ok, err := u.DebugInput()
require.NoError(t, err)
require.True(t, ok)
}
func TestDeviceWithNoURLIsIgnored(t *testing.T) {
t.Parallel()
u := &inputunas.InputUNAS{Config: &inputunas.Config{Devices: []*inputunas.Device{{}, nil}}}
require.NoError(t, u.Initialize(nil))
m, err := u.Metrics(nil)
require.NoError(t, err)
require.Empty(t, m.UNASDevices)
}
func TestEventsAreEmpty(t *testing.T) {
t.Parallel()
e, err := (&inputunas.InputUNAS{}).Events(&poller.Filter{})
require.NoError(t, err)
require.NotNil(t, e)
require.Empty(t, e.Logs)
}
func TestRawMetrics(t *testing.T) {
t.Parallel()
srv := (&unasServer{}).start(t)
u := newInput(t, srv.URL)
require.NoError(t, u.Initialize(nil))
body, err := u.RawMetrics(&poller.Filter{Kind: "storage"})
require.NoError(t, err)
require.Contains(t, string(body), "pool-1")
_, err = u.RawMetrics(&poller.Filter{Unit: 9})
require.ErrorIs(t, err, inputunas.ErrNoDevices)
}