From 9bc7f2c5bf884da3f74fec1102b0b52ed48b0dc4 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Thu, 27 Aug 2026 16:24:09 -0500 Subject: [PATCH] Complete InfluxDB v3 rollout: tests, docs, and docker example. Add v3 integration and version tests, migration notes, InfluxDB 3 docker-compose stack, and README updates to finish the remaining plan phases. Co-authored-by: Cursor --- init/docker/README.md | 9 ++ .../docker-compose-influxdb3.env.example | 16 +++ init/docker/docker-compose-influxdb3.yml | 52 +++++++++ pkg/influxunifi/MIGRATION.md | 42 +++++++ pkg/influxunifi/README.md | 2 + pkg/influxunifi/integration_v3_test.go | 106 ++++++++++++++++++ pkg/influxunifi/version_test.go | 84 ++++++++++++++ 7 files changed, 311 insertions(+) create mode 100644 init/docker/docker-compose-influxdb3.env.example create mode 100644 init/docker/docker-compose-influxdb3.yml create mode 100644 pkg/influxunifi/MIGRATION.md create mode 100644 pkg/influxunifi/integration_v3_test.go create mode 100644 pkg/influxunifi/version_test.go diff --git a/init/docker/README.md b/init/docker/README.md index c2c6ec86..9f534212 100644 --- a/init/docker/README.md +++ b/init/docker/README.md @@ -24,3 +24,12 @@ docker exec /usr/bin/unpoller --health The health check is automatically used by Docker and container orchestration platforms (Kubernetes, Docker Swarm, etc.) to determine container health status. + +## InfluxDB 3 Core + +Use `docker-compose-influxdb3.yml` with `docker-compose-influxdb3.env.example` to run UniFi Poller against InfluxDB 3 Core on port `8181`. Copy the env example, set `INFLUXDB_ADMIN_TOKEN` to a token created in InfluxDB 3, then start the stack: + +```bash +cp docker-compose-influxdb3.env.example docker-compose-influxdb3.env +docker compose -f docker-compose-influxdb3.yml --env-file docker-compose-influxdb3.env up -d +``` diff --git a/init/docker/docker-compose-influxdb3.env.example b/init/docker/docker-compose-influxdb3.env.example new file mode 100644 index 00000000..bfe75b0f --- /dev/null +++ b/init/docker/docker-compose-influxdb3.env.example @@ -0,0 +1,16 @@ +# InfluxDB 3 Core +# Create an admin token in InfluxDB 3 and set it here. +INFLUXDB_ADMIN_TOKEN=CHANGEME +INFLUXDB_DATABASE=unpoller + +# Grafana +GRAFANA_USERNAME=admin +GRAFANA_PASSWORD=grafanaadmin + +# UniFi Poller +POLLER_TAG=latest +POLLER_DEBUG=false +POLLER_SAVE_DPI=false +UNIFI_USER=unpoller +UNIFI_PASS=set_this_on_your_controller +UNIFI_URL=https://127.0.0.1:8443 diff --git a/init/docker/docker-compose-influxdb3.yml b/init/docker/docker-compose-influxdb3.yml new file mode 100644 index 00000000..aca12de9 --- /dev/null +++ b/init/docker/docker-compose-influxdb3.yml @@ -0,0 +1,52 @@ +# UniFi Poller with InfluxDB 3 Core. +# See README.md in this directory and pkg/influxunifi/README.md. +version: '3' +services: + influxdb3: + restart: always + image: influxdb:3-core + ports: + - '8181:8181' + command: + - influxdb3 + - serve + - --node-id=node0 + - --object-store=file + - --data-dir=/var/lib/influxdb3/data + - --plugin-dir=/var/lib/influxdb3/plugins + volumes: + - influxdb3-data:/var/lib/influxdb3/data + - influxdb3-plugins:/var/lib/influxdb3/plugins + grafana: + image: grafana/grafana:latest + restart: always + ports: + - '3000:3000' + volumes: + - grafana-storage:/var/lib/grafana + depends_on: + - influxdb3 + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_USERNAME} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD} + - GF_INSTALL_PLUGINS=grafana-clock-panel,natel-discrete-panel,grafana-piechart-panel + unifi-poller: + restart: always + image: ghcr.io/unpoller/unpoller:${POLLER_TAG} + depends_on: + - grafana + - influxdb3 + environment: + - UP_INFLUXDB_VERSION=3 + - UP_INFLUXDB_URL=http://influxdb3:8181 + - UP_INFLUXDB_AUTH_TOKEN=${INFLUXDB_ADMIN_TOKEN} + - UP_INFLUXDB_DATABASE=${INFLUXDB_DATABASE} + - UP_UNIFI_DEFAULT_USER=${UNIFI_USER} + - UP_UNIFI_DEFAULT_PASS=${UNIFI_PASS} + - UP_UNIFI_DEFAULT_URL=${UNIFI_URL} + - UP_POLLER_DEBUG=${POLLER_DEBUG} + - UP_UNIFI_DEFAULT_SAVE_DPI=${POLLER_SAVE_DPI} +volumes: + influxdb3-data: + influxdb3-plugins: + grafana-storage: diff --git a/pkg/influxunifi/MIGRATION.md b/pkg/influxunifi/MIGRATION.md new file mode 100644 index 00000000..c4230b33 --- /dev/null +++ b/pkg/influxunifi/MIGRATION.md @@ -0,0 +1,42 @@ +# InfluxDB Output Migration Notes + +## InfluxDB 3 support + +UnPoller now supports InfluxDB 1.x, 2.x, and 3.x from the same `influxdb` output plugin. + +Enable v3 explicitly: + +```yaml +influxdb: + version: 3 + url: http://influxdb3:8181 + auth_token: your-token + database: unifi +``` + +For InfluxDB Cloud Serverless or Clustered, set `use_v2_api: true` so writes use the v2-compatible endpoint. + +See [README.md](README.md) and `init/docker/docker-compose-influxdb3.yml` for examples. + +## Schema changes (v1/v2/v3) + +InfluxDB 3 rejects line protocol where the same key is used as both a tag and a field on one point. The following keys were adjusted for compatibility: + +| Measurement | Change | +|-------------|--------| +| `subsystems` | Removed duplicate field `wan_ip` (remains a tag) | +| `clients` | Renamed tag `channel` to `channel_name` (numeric field `channel` unchanged) | +| `uap_radios` | Renamed field `channel` to `channel_num`; removed duplicate field `radio` (remains a tag) | +| `usg`, `ubb`, `uci`, `udm`, `uxg` | Removed duplicate fields `source` and/or `version` (remain tags) | + +### Grafana dashboards + +Import updated dashboards from the [unpoller/dashboards](https://github.com/unpoller/dashboards) repository (`v2.0.0` InfluxDB JSON files) if panels reference the old tag/field names. + +### Existing InfluxDB 3 databases + +If writes previously failed with errors such as `invalid column type for column 'wan_ip'`, drop and recreate the affected database or bucket, then allow UnPoller to recreate the schema with the corrected layout. + +## Issue reference + +Fixes [unpoller#1061](https://github.com/unpoller/unpoller/issues/1061). diff --git a/pkg/influxunifi/README.md b/pkg/influxunifi/README.md index 512906a0..4e50bf2a 100644 --- a/pkg/influxunifi/README.md +++ b/pkg/influxunifi/README.md @@ -23,6 +23,8 @@ influxdb: verify_ssl: false ``` +See [MIGRATION.md](MIGRATION.md) for schema changes affecting Grafana dashboards. + ### InfluxDB 1.8+, 2.x Note the use of `auth_token` to enable v2 mode when `version` is omitted. diff --git a/pkg/influxunifi/integration_v3_test.go b/pkg/influxunifi/integration_v3_test.go new file mode 100644 index 00000000..a99e8c93 --- /dev/null +++ b/pkg/influxunifi/integration_v3_test.go @@ -0,0 +1,106 @@ +package influxunifi_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + influxdb3 "github.com/InfluxCommunity/influxdb3-go/v2/influxdb3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unpoller/pkg/influxunifi" + "github.com/unpoller/unpoller/pkg/unittest" + "golift.io/cnfg" +) + +type v3WriteCapture struct { + mu sync.Mutex + batch []string +} + +func (c *v3WriteCapture) handler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + + return + } + + if !strings.Contains(r.URL.Path, "write") { + http.NotFound(w, r) + + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + c.mu.Lock() + c.batch = append(c.batch, string(body)) + c.mu.Unlock() + + w.WriteHeader(http.StatusNoContent) +} + +func (c *v3WriteCapture) lines() []string { + c.mu.Lock() + defer c.mu.Unlock() + + raw := strings.Join(c.batch, "\n") + + return strings.Split(strings.TrimSpace(raw), "\n") +} + +func TestInfluxV3Integration(t *testing.T) { + capture := &v3WriteCapture{} + server := httptest.NewServer(http.HandlerFunc(capture.handler)) + t.Cleanup(server.Close) + + client, err := influxdb3.New(influxdb3.ClientConfig{ + Host: server.URL, + Token: "test-token", + Database: "unpoller", + WriteOptions: &influxdb3.WriteOptions{ + UseV2Api: false, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + testRig := unittest.NewTestSetup(t) + t.Cleanup(testRig.Close) + + u := influxunifi.InfluxUnifi{ + Collector: testRig.Collector, + Version: influxunifi.InfluxV3, + InfluxV3Client: client, + InfluxDB: &influxunifi.InfluxDB{ + Config: &influxunifi.Config{ + Version: 3, + Database: "unpoller", + AuthToken: "test-token", + URL: server.URL, + Interval: cnfg.Duration{Duration: time.Hour}, + }, + }, + } + + testRig.Initialize() + u.Poll(time.Minute) + + lines := capture.lines() + require.NotEmpty(t, lines) + + body := strings.Join(lines, "\n") + assert.Contains(t, body, "subsystems,") + assert.Contains(t, body, "clients,") + assert.Contains(t, body, "channel_name=") + assert.NotRegexp(t, `(?m)^clients,[^ ]* channel=`, body) +} diff --git a/pkg/influxunifi/version_test.go b/pkg/influxunifi/version_test.go new file mode 100644 index 00000000..3e8d5045 --- /dev/null +++ b/pkg/influxunifi/version_test.go @@ -0,0 +1,84 @@ +package influxunifi_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpoller/unpoller/pkg/influxunifi" + "golift.io/cnfg" +) + +func TestInfluxVersionDefaults(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config influxunifi.Config + version influxunifi.InfluxVersion + }{ + { + name: "v1 default without token", + config: influxunifi.Config{DB: "unifi"}, + version: influxunifi.InfluxV1, + }, + { + name: "v2 default with token", + config: influxunifi.Config{AuthToken: "secret"}, + version: influxunifi.InfluxV2, + }, + { + name: "explicit v3", + config: influxunifi.Config{Version: 3, AuthToken: "secret", Database: "unifi"}, + version: influxunifi.InfluxV3, + }, + { + name: "explicit v1 with token present", + config: influxunifi.Config{Version: 1, AuthToken: "ignored", DB: "unifi"}, + version: influxunifi.InfluxV1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(server.Close) + + u := &influxunifi.InfluxUnifi{ + InfluxDB: &influxunifi.InfluxDB{Config: &tc.config}, + } + u.Config.URL = server.URL + + ok, err := u.DebugOutput() + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, tc.version, u.Version) + }) + } +} + +func TestInfluxV3RequiresAuthToken(t *testing.T) { + t.Parallel() + + u := &influxunifi.InfluxUnifi{ + InfluxDB: &influxunifi.InfluxDB{ + Config: &influxunifi.Config{ + Version: 3, + URL: "http://127.0.0.1:8181", + Database: "unifi", + Interval: cnfg.Duration{}, + }, + }, + } + + ok, err := u.DebugOutput() + require.Error(t, err) + assert.False(t, ok) + assert.Contains(t, err.Error(), "auth_token") +}