Public source validation / validate (push) Failing after 3m8s
199 lines
6.2 KiB
Go
199 lines
6.2 KiB
Go
package unraid
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/itworx/pulse/internal/array"
|
|
"github.com/itworx/pulse/internal/disk"
|
|
)
|
|
|
|
// ArraySource adapts the documented read-only array query. It intentionally maps only
|
|
// documented fields; unavailable SMART/pool/share detail is represented by the domain
|
|
// as unavailable rather than guessed from names or host paths.
|
|
type ArraySource struct {
|
|
Client *Client
|
|
Now func() time.Time
|
|
}
|
|
|
|
type arrayDiskDocument struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Size json.RawMessage `json:"size"`
|
|
Status string `json:"status"`
|
|
FSSize json.RawMessage `json:"fsSize"`
|
|
FSUsed json.RawMessage `json:"fsUsed"`
|
|
FSFree json.RawMessage `json:"fsFree"`
|
|
FSType string `json:"fsType"`
|
|
Temp json.RawMessage `json:"temp"`
|
|
Spinning *bool `json:"isSpinning"`
|
|
}
|
|
|
|
type arrayDocument struct {
|
|
Array struct {
|
|
State string `json:"state"`
|
|
Disks []arrayDiskDocument `json:"disks"`
|
|
Parities []arrayDiskDocument `json:"parities"`
|
|
Caches []arrayDiskDocument `json:"caches"`
|
|
} `json:"array"`
|
|
}
|
|
|
|
func (s ArraySource) document(ctx context.Context) (arrayDocument, time.Time, error) {
|
|
if s.Client == nil {
|
|
return arrayDocument{}, time.Time{}, errors.New("Unraid client is required")
|
|
}
|
|
payload, err := s.Client.Query(ctx, "array")
|
|
if err != nil {
|
|
return arrayDocument{}, time.Time{}, fmt.Errorf("query Unraid array: %w", err)
|
|
}
|
|
var response arrayDocument
|
|
if err := json.Unmarshal(payload, &response); err != nil {
|
|
return arrayDocument{}, time.Time{}, errors.New("decode Unraid array response")
|
|
}
|
|
if len(response.Array.Disks)+len(response.Array.Parities)+len(response.Array.Caches) > 64 {
|
|
return arrayDocument{}, time.Time{}, errors.New("Unraid disk response exceeds bounds")
|
|
}
|
|
now := time.Now().UTC()
|
|
if s.Now != nil {
|
|
now = s.Now().UTC()
|
|
}
|
|
return response, now, nil
|
|
}
|
|
|
|
func (s ArraySource) Snapshot(ctx context.Context) (array.RawSnapshot, error) {
|
|
doc, now, err := s.document(ctx)
|
|
if err != nil {
|
|
return array.RawSnapshot{}, err
|
|
}
|
|
members := make([]array.RawMember, 0, len(doc.Array.Disks)+len(doc.Array.Parities))
|
|
for _, item := range append(doc.Array.Parities, doc.Array.Disks...) {
|
|
size, err := rawUint(item.Size)
|
|
if err != nil || strings.TrimSpace(item.Name) == "" {
|
|
return array.RawSnapshot{}, errors.New("Unraid disk identity or size is invalid")
|
|
}
|
|
members = append(members, array.RawMember{ID: identity(item.ID, item.Name), Name: item.Name, Role: role(item.Type), State: diskState(item.Status), CapacityBytes: kilobytes(size)})
|
|
}
|
|
return array.RawSnapshot{Source: array.Source{ID: "unraid", Type: "unraid"}, State: arrayState(doc.Array.State), Members: members, ObservedAt: now, ReceivedAt: now}, nil
|
|
}
|
|
|
|
// DiskSource shares the one bounded array document and exposes its documented disk
|
|
// fields without inventing SMART values.
|
|
type DiskSource struct {
|
|
Client *Client
|
|
Now func() time.Time
|
|
}
|
|
|
|
func (s DiskSource) Snapshot(ctx context.Context) (disk.RawSnapshot, error) {
|
|
doc, now, err := (ArraySource{Client: s.Client, Now: s.Now}).document(ctx)
|
|
if err != nil {
|
|
return disk.RawSnapshot{}, err
|
|
}
|
|
items := make([]disk.RawDisk, 0, len(doc.Array.Disks)+len(doc.Array.Parities)+len(doc.Array.Caches))
|
|
for _, item := range append(append(doc.Array.Parities, doc.Array.Disks...), doc.Array.Caches...) {
|
|
size, err := rawUint(item.FSSize)
|
|
if err != nil || size == 0 {
|
|
size, err = rawUint(item.Size)
|
|
}
|
|
if err != nil || strings.TrimSpace(item.Name) == "" {
|
|
return disk.RawSnapshot{}, errors.New("Unraid disk identity or size is invalid")
|
|
}
|
|
used, err := rawUint(item.FSUsed)
|
|
if err != nil && len(item.FSUsed) != 0 && string(item.FSUsed) != "null" {
|
|
return disk.RawSnapshot{}, errors.New("Unraid disk usage is invalid")
|
|
}
|
|
raw := disk.RawDisk{ID: identity(item.ID, item.Name), Name: item.Name, Role: role(item.Type), SizeBytes: kilobytes(size), UsedBytes: kilobytes(used), State: diskState(item.Status), Filesystem: item.FSType}
|
|
if item.Temp != nil && string(item.Temp) != "null" {
|
|
temp, err := rawUint(item.Temp)
|
|
if err != nil {
|
|
return disk.RawSnapshot{}, errors.New("Unraid disk temperature is invalid")
|
|
}
|
|
raw.Temperature = &disk.RawTemperature{Available: true, Celsius: float64(temp), ObservedAt: now}
|
|
}
|
|
if item.Spinning != nil {
|
|
raw.Spin = &disk.RawSpin{Supported: true, State: spinState(*item.Spinning)}
|
|
}
|
|
items = append(items, raw)
|
|
}
|
|
return disk.RawSnapshot{Source: disk.Source{ID: "unraid", Type: "unraid"}, Disks: items, ObservedAt: now, ReceivedAt: now}, nil
|
|
}
|
|
|
|
func rawUint(raw json.RawMessage) (uint64, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return 0, errors.New("missing unsigned integer")
|
|
}
|
|
var number json.Number
|
|
if err := json.Unmarshal(raw, &number); err == nil {
|
|
return strconv.ParseUint(number.String(), 10, 64)
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseUint(text, 10, 64)
|
|
}
|
|
|
|
func kilobytes(value uint64) uint64 {
|
|
const unit = uint64(1024)
|
|
if value > ^uint64(0)/unit {
|
|
return ^uint64(0)
|
|
}
|
|
return value * unit
|
|
}
|
|
|
|
func identity(id, name string) string {
|
|
if strings.TrimSpace(id) != "" {
|
|
return strings.ToLower(strings.TrimSpace(id))
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(name))
|
|
}
|
|
|
|
func role(value string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
|
case "PARITY":
|
|
return "parity"
|
|
case "CACHE":
|
|
return "cache"
|
|
default:
|
|
return "data"
|
|
}
|
|
}
|
|
|
|
func arrayState(value string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
|
case "STARTED":
|
|
return array.StateOperational
|
|
case "RECON_DISK", "DISABLE_DISK", "SWAP_DSBL":
|
|
return array.StateDegraded
|
|
case "TOO_MANY_MISSING_DISKS", "NO_DATA_DISKS":
|
|
return array.StateMissing
|
|
default:
|
|
return array.StateUnknown
|
|
}
|
|
}
|
|
|
|
func diskState(value string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
|
case "DISK_OK":
|
|
return disk.StateOnline
|
|
case "DISK_NP_MISSING", "DISK_NP":
|
|
return disk.StateMissing
|
|
case "DISK_DSBL", "DISK_NP_DSBL", "DISK_DSBL_NEW":
|
|
return disk.StateDisabled
|
|
default:
|
|
return disk.StateUnknown
|
|
}
|
|
}
|
|
|
|
func spinState(spinning bool) string {
|
|
if spinning {
|
|
return "spinning"
|
|
}
|
|
return "stopped"
|
|
}
|