| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package powervault |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/netdata/netdata/go/plugins/pkg/metrix" |
| 14 | "github.com/netdata/netdata/go/plugins/pkg/web" |
| 15 | "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine" |
| 16 | "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl" |
| 17 | "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest" |
| 18 | "github.com/stretchr/testify/assert" |
| 19 | "github.com/stretchr/testify/require" |
| 20 | ) |
| 21 | |
| 22 | func TestCollector_Init(t *testing.T) { |
| 23 | tests := map[string]struct { |
| 24 | config Config |
| 25 | wantFail bool |
| 26 | }{ |
| 27 | "success with valid config": { |
| 28 | config: Config{ |
| 29 | HTTPConfig: web.HTTPConfig{ |
| 30 | RequestConfig: web.RequestConfig{ |
| 31 | URL: "https://127.0.0.1", |
| 32 | Username: "admin", |
| 33 | Password: "password", |
| 34 | }, |
| 35 | }, |
| 36 | }, |
| 37 | }, |
| 38 | "success with md5 digest": { |
| 39 | config: Config{ |
| 40 | HTTPConfig: web.HTTPConfig{ |
| 41 | RequestConfig: web.RequestConfig{ |
| 42 | URL: "https://127.0.0.1", |
| 43 | Username: "admin", |
| 44 | Password: "password", |
| 45 | }, |
| 46 | }, |
| 47 | AuthDigest: "md5", |
| 48 | }, |
| 49 | }, |
| 50 | "fail without username": { |
| 51 | wantFail: true, |
| 52 | config: Config{ |
| 53 | HTTPConfig: web.HTTPConfig{ |
| 54 | RequestConfig: web.RequestConfig{ |
| 55 | URL: "https://127.0.0.1", |
| 56 | Password: "password", |
| 57 | }, |
| 58 | }, |
| 59 | }, |
| 60 | }, |
| 61 | "fail without password": { |
| 62 | wantFail: true, |
| 63 | config: Config{ |
| 64 | HTTPConfig: web.HTTPConfig{ |
| 65 | RequestConfig: web.RequestConfig{ |
| 66 | URL: "https://127.0.0.1", |
| 67 | Username: "admin", |
| 68 | }, |
| 69 | }, |
| 70 | }, |
| 71 | }, |
| 72 | "fail with invalid digest": { |
| 73 | wantFail: true, |
| 74 | config: Config{ |
| 75 | HTTPConfig: web.HTTPConfig{ |
| 76 | RequestConfig: web.RequestConfig{ |
| 77 | URL: "https://127.0.0.1", |
| 78 | Username: "admin", |
| 79 | Password: "password", |
| 80 | }, |
| 81 | }, |
| 82 | AuthDigest: "sha1", |
| 83 | }, |
| 84 | }, |
| 85 | } |
| 86 | |
| 87 | for name, test := range tests { |
| 88 | t.Run(name, func(t *testing.T) { |
| 89 | collr := New() |
| 90 | collr.Config = test.config |
| 91 | |
| 92 | if test.wantFail { |
| 93 | assert.Error(t, collr.Init(context.Background())) |
| 94 | } else { |
| 95 | assert.NoError(t, collr.Init(context.Background())) |
| 96 | } |
| 97 | }) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestCollector_Check(t *testing.T) { |
| 102 | srv := newMockPowerVaultServer() |
| 103 | defer srv.Close() |
| 104 | |
| 105 | collr := New() |
| 106 | collr.Config = Config{ |
| 107 | HTTPConfig: web.HTTPConfig{ |
| 108 | RequestConfig: web.RequestConfig{ |
| 109 | URL: srv.URL, |
| 110 | Username: "admin", |
| 111 | Password: "password", |
| 112 | }, |
| 113 | }, |
| 114 | } |
| 115 | |
| 116 | require.NoError(t, collr.Init(context.Background())) |
| 117 | assert.NoError(t, collr.Check(context.Background())) |
| 118 | } |
| 119 | |
| 120 | func TestCollector_Collect(t *testing.T) { |
| 121 | srv := newMockPowerVaultServer() |
| 122 | defer srv.Close() |
| 123 | |
| 124 | collr := New() |
| 125 | collr.Config = Config{ |
| 126 | HTTPConfig: web.HTTPConfig{ |
| 127 | RequestConfig: web.RequestConfig{ |
| 128 | URL: srv.URL, |
| 129 | Username: "admin", |
| 130 | Password: "password", |
| 131 | }, |
| 132 | }, |
| 133 | } |
| 134 | |
| 135 | require.NoError(t, collr.Init(context.Background())) |
| 136 | require.NoError(t, collr.Check(context.Background())) |
| 137 | |
| 138 | cc := mustCycleController(t, collr.MetricStore()) |
| 139 | cc.BeginCycle() |
| 140 | require.NoError(t, collr.Collect(context.Background())) |
| 141 | cc.CommitCycleSuccess() |
| 142 | |
| 143 | r := collr.MetricStore().Read(metrix.ReadRaw()) |
| 144 | |
| 145 | // System health |
| 146 | assertValue(t, r, "system_health", nil, 0) |
| 147 | |
| 148 | // Hardware health: 2 controllers OK, 3 drives (2 OK + 1 Global SP→OK), 2 fans OK, 2 PSUs OK |
| 149 | assertValue(t, r, "hw_controller_ok", nil, 2) |
| 150 | assertValue(t, r, "hw_controller_degraded", nil, 0) |
| 151 | assertValue(t, r, "hw_controller_fault", nil, 0) |
| 152 | assertValue(t, r, "hw_controller_unknown", nil, 0) |
| 153 | |
| 154 | assertValue(t, r, "hw_drive_ok", nil, 3) // 2 healthy + 1 global spare treated as OK |
| 155 | assertValue(t, r, "hw_drive_degraded", nil, 0) |
| 156 | assertValue(t, r, "hw_drive_fault", nil, 0) |
| 157 | assertValue(t, r, "hw_drive_unknown", nil, 0) |
| 158 | |
| 159 | assertValue(t, r, "hw_fan_ok", nil, 2) |
| 160 | assertValue(t, r, "hw_psu_ok", nil, 2) |
| 161 | |
| 162 | // FRUs: 2 OK + 1 N/A (status=5 → OK) |
| 163 | assertValue(t, r, "hw_fru_ok", nil, 3) |
| 164 | assertValue(t, r, "hw_fru_degraded", nil, 0) |
| 165 | |
| 166 | // Ports: 2 OK, 1 with health=4 (N/A) excluded |
| 167 | assertValue(t, r, "hw_port_ok", nil, 2) |
| 168 | assertValue(t, r, "hw_port_unknown", nil, 0) |
| 169 | |
| 170 | // Controller stats |
| 171 | ctrlA := metrix.Labels{"controller": "controller_a"} |
| 172 | ctrlB := metrix.Labels{"controller": "controller_b"} |
| 173 | assertValue(t, r, "controller_iops", ctrlA, 1250) |
| 174 | assertValue(t, r, "controller_throughput", ctrlA, 52428800) |
| 175 | assertValue(t, r, "controller_cpu_load", ctrlA, 23.5) |
| 176 | assertValue(t, r, "controller_write_cache_used", ctrlA, 45) |
| 177 | assertValue(t, r, "controller_forwarded_cmds", ctrlA, 12500) |
| 178 | assertValue(t, r, "controller_data_read", ctrlA, 107374182400) |
| 179 | assertValue(t, r, "controller_data_written", ctrlA, 53687091200) |
| 180 | assertValue(t, r, "controller_read_ops", ctrlA, 5000000) |
| 181 | assertValue(t, r, "controller_write_ops", ctrlA, 2500000) |
| 182 | assertValue(t, r, "controller_write_cache_hits", ctrlA, 1800000) |
| 183 | assertValue(t, r, "controller_write_cache_misses", ctrlA, 200000) |
| 184 | assertValue(t, r, "controller_read_cache_hits", ctrlA, 3500000) |
| 185 | assertValue(t, r, "controller_read_cache_misses", ctrlA, 500000) |
| 186 | assertHasValue(t, r, "controller_iops", ctrlB) |
| 187 | |
| 188 | // Volume stats |
| 189 | v1Labels := metrix.Labels{"volume": "prod-db-01"} |
| 190 | v2Labels := metrix.Labels{"volume": "prod-app-01"} |
| 191 | v3Labels := metrix.Labels{"volume": "test-backup"} |
| 192 | assertValue(t, r, "volume_iops", v1Labels, 850) |
| 193 | assertValue(t, r, "volume_throughput", v1Labels, 35651584) |
| 194 | assertValue(t, r, "volume_write_cache_percent", v1Labels, 52) |
| 195 | assertValue(t, r, "volume_tier_ssd", v1Labels, 60) |
| 196 | assertValue(t, r, "volume_tier_sas", v1Labels, 30) |
| 197 | assertValue(t, r, "volume_tier_sata", v1Labels, 10) |
| 198 | assertHasValue(t, r, "volume_iops", v2Labels) |
| 199 | assertHasValue(t, r, "volume_iops", v3Labels) |
| 200 | |
| 201 | // Port stats |
| 202 | pA0 := metrix.Labels{"port": "hostport_A0"} |
| 203 | pA1 := metrix.Labels{"port": "hostport_A1"} |
| 204 | assertValue(t, r, "port_read_ops", pA0, 8500000) |
| 205 | assertValue(t, r, "port_write_ops", pA0, 4200000) |
| 206 | assertValue(t, r, "port_data_read", pA0, 180388626432) |
| 207 | assertValue(t, r, "port_data_written", pA0, 90194313216) |
| 208 | assertHasValue(t, r, "port_read_ops", pA1) |
| 209 | |
| 210 | // PHY errors (aggregated per port: hostport_A0 has phy0+phy1, hostport_A1 has phy0) |
| 211 | phyA0 := metrix.Labels{"port": "hostport_A0"} |
| 212 | phyA1 := metrix.Labels{"port": "hostport_A1"} |
| 213 | assertValue(t, r, "phy_disparity_errors", phyA0, 8) // 5+3 |
| 214 | assertValue(t, r, "phy_lost_dwords", phyA0, 2) // 2+0 |
| 215 | assertValue(t, r, "phy_invalid_dwords", phyA0, 1) // 1+0 |
| 216 | assertValue(t, r, "phy_disparity_errors", phyA1, 0) |
| 217 | assertValue(t, r, "phy_lost_dwords", phyA1, 1) |
| 218 | |
| 219 | // Pool capacity (values × 512 to convert from blocks to bytes) |
| 220 | poolA := metrix.Labels{"pool": "Pool-A"} |
| 221 | poolB := metrix.Labels{"pool": "Pool-B"} |
| 222 | assertValue(t, r, "pool_total_bytes", poolA, 19531250000*512) |
| 223 | assertValue(t, r, "pool_available_bytes", poolA, 8789062500*512) |
| 224 | assertHasValue(t, r, "pool_total_bytes", poolB) |
| 225 | |
| 226 | // Drive metrics |
| 227 | d00 := metrix.Labels{"drive": "0.0"} |
| 228 | d01 := metrix.Labels{"drive": "0.1"} |
| 229 | assertValue(t, r, "drive_temperature", d00, 32) |
| 230 | assertValue(t, r, "drive_power_on_hours", d00, 18750) |
| 231 | // HDD: ssd-life-left=255 → not reported |
| 232 | _, hasSSDLife := r.Value("drive_ssd_life_left", d00) |
| 233 | assert.False(t, hasSSDLife, "HDD should not report SSD life left") |
| 234 | // SSD: ssd-life-left=97 → reported |
| 235 | assertValue(t, r, "drive_ssd_life_left", d01, 97) |
| 236 | |
| 237 | // Sensor metrics |
| 238 | tempSensor := metrix.Labels{"sensor": "sensor_temp_ctrl_A.1"} |
| 239 | assertValue(t, r, "sensor_temperature", tempSensor, 42) |
| 240 | |
| 241 | voltSensor := metrix.Labels{"sensor": "sensor_volt_ctrl_A.1"} |
| 242 | assertValue(t, r, "sensor_voltage", voltSensor, 3300) // 3.30V → 3300mV |
| 243 | |
| 244 | currSensor := metrix.Labels{"sensor": "sensor_curr_ctrl_A.1"} |
| 245 | assertValue(t, r, "sensor_current", currSensor, 1250) // 1.25A → 1250mA |
| 246 | |
| 247 | chrgSensor := metrix.Labels{"sensor": "sensor_chrg_ctrl_A.1"} |
| 248 | assertValue(t, r, "sensor_charge_capacity", chrgSensor, 95) |
| 249 | |
| 250 | // Chart coverage |
| 251 | collecttest.AssertChartCoverage(t, collr, collecttest.ChartCoverageExpectation{}) |
| 252 | } |
| 253 | |
| 254 | func TestCollector_CollectWithVolumeSelector(t *testing.T) { |
| 255 | srv := newMockPowerVaultServer() |
| 256 | defer srv.Close() |
| 257 | |
| 258 | collr := New() |
| 259 | collr.Config = Config{ |
| 260 | HTTPConfig: web.HTTPConfig{ |
| 261 | RequestConfig: web.RequestConfig{ |
| 262 | URL: srv.URL, |
| 263 | Username: "admin", |
| 264 | Password: "password", |
| 265 | }, |
| 266 | }, |
| 267 | VolumeSelector: "prod-*", |
| 268 | } |
| 269 | |
| 270 | require.NoError(t, collr.Init(context.Background())) |
| 271 | require.NoError(t, collr.Check(context.Background())) |
| 272 | |
| 273 | cc := mustCycleController(t, collr.MetricStore()) |
| 274 | cc.BeginCycle() |
| 275 | require.NoError(t, collr.Collect(context.Background())) |
| 276 | cc.CommitCycleSuccess() |
| 277 | |
| 278 | r := collr.MetricStore().Read(metrix.ReadRaw()) |
| 279 | |
| 280 | // prod-* volumes should be included |
| 281 | assertHasValue(t, r, "volume_iops", metrix.Labels{"volume": "prod-db-01"}) |
| 282 | assertHasValue(t, r, "volume_iops", metrix.Labels{"volume": "prod-app-01"}) |
| 283 | // test-backup should be excluded |
| 284 | _, ok := r.Value("volume_iops", metrix.Labels{"volume": "test-backup"}) |
| 285 | assert.False(t, ok, "test-backup should be excluded by volume selector") |
| 286 | } |
| 287 | |
| 288 | func TestCollector_Cleanup(t *testing.T) { |
| 289 | collr := New() |
| 290 | assert.NotPanics(t, func() { collr.Cleanup(context.Background()) }) |
| 291 | |
| 292 | srv := newMockPowerVaultServer() |
| 293 | defer srv.Close() |
| 294 | |
| 295 | collr.Config = Config{ |
| 296 | HTTPConfig: web.HTTPConfig{ |
| 297 | RequestConfig: web.RequestConfig{ |
| 298 | URL: srv.URL, |
| 299 | Username: "admin", |
| 300 | Password: "password", |
| 301 | }, |
| 302 | }, |
| 303 | } |
| 304 | |
| 305 | require.NoError(t, collr.Init(context.Background())) |
| 306 | require.NoError(t, collr.Check(context.Background())) |
| 307 | assert.NotPanics(t, func() { collr.Cleanup(context.Background()) }) |
| 308 | } |
| 309 | |
| 310 | func TestCollector_ChartTemplateYAML(t *testing.T) { |
| 311 | templateYAML := New().ChartTemplateYAML() |
| 312 | collecttest.AssertChartTemplateSchema(t, templateYAML) |
| 313 | |
| 314 | spec, err := charttpl.DecodeYAML([]byte(templateYAML)) |
| 315 | require.NoError(t, err) |
| 316 | require.NoError(t, spec.Validate()) |
| 317 | |
| 318 | _, err = chartengine.Compile(spec, 1) |
| 319 | require.NoError(t, err) |
| 320 | } |
| 321 | |
| 322 | func mustCycleController(t *testing.T, store metrix.CollectorStore) metrix.CycleController { |
| 323 | t.Helper() |
| 324 | managed, ok := metrix.AsCycleManagedStore(store) |
| 325 | require.True(t, ok, "store does not expose cycle control") |
| 326 | return managed.CycleController() |
| 327 | } |
| 328 | |
| 329 | func assertValue(t *testing.T, r metrix.Reader, name string, labels metrix.Labels, want float64) { |
| 330 | t.Helper() |
| 331 | got, ok := r.Value(name, labels) |
| 332 | require.Truef(t, ok, "expected metric %s labels=%v", name, labels) |
| 333 | assert.InDeltaf(t, want, got, 1e-9, "unexpected value for %s labels=%v: got %v want %v", name, labels, got, want) |
| 334 | } |
| 335 | |
| 336 | func assertHasValue(t *testing.T, r metrix.Reader, name string, labels metrix.Labels) { |
| 337 | t.Helper() |
| 338 | _, ok := r.Value(name, labels) |
| 339 | assert.Truef(t, ok, "expected metric %s labels=%v to exist", name, labels) |
| 340 | } |
| 341 | |
| 342 | func newMockPowerVaultServer() *httptest.Server { |
| 343 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 344 | w.Header().Set("Content-Type", "application/json") |
| 345 | |
| 346 | p := r.URL.Path |
| 347 | |
| 348 | switch { |
| 349 | case strings.Contains(p, "/api/login/"): |
| 350 | writeTestData(w, "testdata/login.json") |
| 351 | case strings.HasSuffix(p, "/api/set/cli-parameters/locale/English"): |
| 352 | w.WriteHeader(http.StatusOK) |
| 353 | _, _ = w.Write([]byte(`{"status":[{"response-type":"Success","response":"Command completed successfully.","return-code":0,"response-type-numeric":0}]}`)) |
| 354 | case strings.HasSuffix(p, "/api/show/system"): |
| 355 | writeTestData(w, "testdata/system.json") |
| 356 | case strings.HasSuffix(p, "/api/show/controllers"): |
| 357 | writeTestData(w, "testdata/controllers.json") |
| 358 | case strings.HasSuffix(p, "/api/show/disks"): |
| 359 | writeTestData(w, "testdata/drives.json") |
| 360 | case strings.HasSuffix(p, "/api/show/fans"): |
| 361 | writeTestData(w, "testdata/fans.json") |
| 362 | case strings.HasSuffix(p, "/api/show/power-supplies"): |
| 363 | writeTestData(w, "testdata/power_supplies.json") |
| 364 | case strings.HasSuffix(p, "/api/show/sensor-status"): |
| 365 | writeTestData(w, "testdata/sensors.json") |
| 366 | case strings.HasSuffix(p, "/api/show/frus"): |
| 367 | writeTestData(w, "testdata/frus.json") |
| 368 | case strings.HasSuffix(p, "/api/show/volumes"): |
| 369 | writeTestData(w, "testdata/volumes.json") |
| 370 | case strings.HasSuffix(p, "/api/show/pools"): |
| 371 | writeTestData(w, "testdata/pools.json") |
| 372 | case strings.HasSuffix(p, "/api/show/ports"): |
| 373 | writeTestData(w, "testdata/ports.json") |
| 374 | case strings.HasSuffix(p, "/api/show/controller-statistics"): |
| 375 | writeTestData(w, "testdata/controller_statistics.json") |
| 376 | case strings.HasSuffix(p, "/api/show/volume-statistics"): |
| 377 | writeTestData(w, "testdata/volume_statistics.json") |
| 378 | case strings.HasSuffix(p, "/api/show/host-port-statistics"): |
| 379 | writeTestData(w, "testdata/port_statistics.json") |
| 380 | case strings.HasSuffix(p, "/api/show/host-phy-statistics"): |
| 381 | writeTestData(w, "testdata/phy_statistics.json") |
| 382 | default: |
| 383 | w.WriteHeader(http.StatusNotFound) |
| 384 | } |
| 385 | })) |
| 386 | } |
| 387 | |
| 388 | func writeTestData(w http.ResponseWriter, path string) { |
| 389 | data, err := os.ReadFile(path) |
| 390 | if err != nil { |
| 391 | w.WriteHeader(http.StatusInternalServerError) |
| 392 | return |
| 393 | } |
| 394 | _, _ = w.Write(data) |
| 395 | } |