This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestArrayAndDiskSourcesMapDocumentedStorageFields(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"array":{"state":"STARTED","parities":[{"id":"parity-1","name":"parity","type":"PARITY","size":"100","status":"DISK_OK"}],"disks":[{"id":"disk-1","name":"disk1","type":"DATA","size":"100","status":"DISK_OK","fsSize":"90","fsUsed":"45","fsFree":"45","fsType":"xfs","temp":31,"isSpinning":true}],"caches":[{"id":"cache-1","name":"cache","type":"CACHE","size":"50","status":"DISK_OK","fsSize":"50","fsUsed":"5","fsFree":"45","fsType":"btrfs","temp":29,"isSpinning":false}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 10, 4, 0, 0, 0, time.UTC)
|
||||
arraySnapshot, err := (ArraySource{Client: client, Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if arraySnapshot.State != "operational" || len(arraySnapshot.Members) != 2 || arraySnapshot.Members[0].CapacityBytes != 100*1024 || arraySnapshot.Members[0].State != "online" {
|
||||
t.Fatalf("array snapshot = %+v", arraySnapshot)
|
||||
}
|
||||
diskSnapshot, err := (DiskSource{Client: client, Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(diskSnapshot.Disks) != 3 {
|
||||
t.Fatalf("disks = %+v", diskSnapshot.Disks)
|
||||
}
|
||||
var found bool
|
||||
for _, item := range diskSnapshot.Disks {
|
||||
if item.ID == "disk-1" {
|
||||
found = item.SizeBytes == 90*1024 && item.UsedBytes == 45*1024 && item.Temperature != nil && item.Spin != nil
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("data disk lost usage, temperature, or spin state: %+v", diskSnapshot.Disks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArraySourceRejectsOversizedOrInvalidPayload(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"array":{"state":"STARTED","disks":[{"id":"disk-1","name":"disk1","type":"DATA","size":"not-a-number","status":"DISK_OK"}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (ArraySource{Client: client}).Snapshot(context.Background()); err == nil {
|
||||
t.Fatal("invalid capacity must not produce a partial snapshot")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ProtocolVersion = "v1"
|
||||
|
||||
var allowedQueries = map[string]string{
|
||||
"server": "query server { server { name status } }",
|
||||
// The fields below are the documented monitoring-only selection sets. Keeping the
|
||||
// complete documents here, rather than accepting caller supplied GraphQL, means a
|
||||
// compromised caller cannot turn this client into a mutation or schema-enumeration
|
||||
// proxy. See docs.unraid.net/API/how-to-use-the-api/.
|
||||
"array": "query array { array { state capacity { disks { free used total } } parities { id name type size status } disks { id name type size status fsSize fsUsed fsFree fsType temp isSpinning } caches { id name type size status fsSize fsUsed fsFree fsType temp isSpinning } } }",
|
||||
"containers": "query containers { docker { containers { id names state status autoStart } } }",
|
||||
"shares": "query shares { shares { id name used size include exclude cache allocator } }",
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
endpoint *url.URL
|
||||
token string
|
||||
http *http.Client
|
||||
timeout time.Duration
|
||||
maxResponse int64
|
||||
}
|
||||
|
||||
func New(endpoint, token string, httpClient *http.Client) (*Client, error) {
|
||||
return newClient(endpoint, token, httpClient)
|
||||
}
|
||||
|
||||
// NewWithCAPEM builds the production client with one explicitly trusted private
|
||||
// CA/leaf certificate. It never disables hostname or chain verification.
|
||||
func NewWithCAPEM(endpoint, token string, caPEM []byte) (*Client, error) {
|
||||
if len(caPEM) == 0 || len(caPEM) > 1<<20 {
|
||||
return nil, errors.New("unraid CA certificate must be between 1 byte and 1 MiB")
|
||||
}
|
||||
roots, err := x509.SystemCertPool()
|
||||
if err != nil || roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("unraid CA certificate is invalid")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}
|
||||
return newClient(endpoint, token, &http.Client{Transport: transport})
|
||||
}
|
||||
|
||||
func newClient(endpoint, token string, httpClient *http.Client) (*Client, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("unraid endpoint must be an HTTPS URL without credentials or query")
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, errors.New("unraid token is required")
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{}
|
||||
}
|
||||
return &Client{endpoint: u, token: token, http: httpClient, timeout: 10 * time.Second, maxResponse: 2 << 20}, nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
func (c *Client) Query(ctx context.Context, operation string) (json.RawMessage, error) {
|
||||
query, ok := allowedQueries[operation]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported read-only Unraid operation %q", operation)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"query": query, "operationName": operation})
|
||||
requestContext, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Unraid API keys are sent through x-api-key. Do not use a browser/session bearer
|
||||
// token here: the agent/API holds a narrowly scoped API key and never forwards it.
|
||||
req.Header.Set("x-api-key", c.token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unraid request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.ContentLength > c.maxResponse {
|
||||
return nil, errors.New("unraid response exceeds bounds")
|
||||
}
|
||||
limited, err := io.ReadAll(io.LimitReader(response.Body, c.maxResponse+1))
|
||||
if err != nil || int64(len(limited)) > c.maxResponse {
|
||||
return nil, errors.New("unraid response exceeds bounds")
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unraid HTTP status %d", response.StatusCode)
|
||||
}
|
||||
var result Response
|
||||
if err := json.Unmarshal(limited, &result); err != nil {
|
||||
return nil, errors.New("invalid Unraid response")
|
||||
}
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, errors.New("unraid GraphQL query failed")
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientAllowsOnlyReadOperationsAndUsesUnraidAPIKey(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.Header.Get("x-api-key") != "test-token" || r.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("unexpected request")
|
||||
}
|
||||
var request struct {
|
||||
Query string `json:"query"`
|
||||
OperationName string `json:"operationName"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.OperationName != "server" || !strings.HasPrefix(request.Query, "query server ") {
|
||||
t.Fatalf("query and operation name must agree: %#v", request)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"server":{"name":"pulse","status":"ONLINE"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
c, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Query(context.Background(), "server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Query(context.Background(), "deleteContainer"); err == nil || !strings.Contains(err.Error(), "read-only") {
|
||||
t.Fatalf("unexpected mutation result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUsesOnlyDocumentedReadOnlySelections(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(request.Query, "docker { containers {") || strings.Contains(strings.ToLower(request.Query), "mutation") {
|
||||
t.Fatalf("unexpected query %q", request.Query)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":{"docker":{"containers":[]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Query(context.Background(), "containers"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsNonHTTPSEndpoint(t *testing.T) {
|
||||
if _, err := New("http://unraid.local/graphql", "token", nil); err == nil {
|
||||
t.Fatal("expected HTTPS requirement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTrustsExplicitCAPEMWithoutDisablingVerification(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"server":{"name":"Tower","status":"ONLINE"}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
certificate := server.Certificate()
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})
|
||||
client, err := NewWithCAPEM(server.URL, "viewer-key", caPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.Query(context.Background(), "server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewWithCAPEM(server.URL, "viewer-key", []byte("not a certificate")); err == nil {
|
||||
t.Fatal("expected invalid private CA rejection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/container"
|
||||
)
|
||||
|
||||
// ContainerSource adapts Unraid's documented docker.containers query into the
|
||||
// bounded container raw contract. It is deliberately read-only and contains no
|
||||
// Docker-socket path; all I/O stays behind Client's fixed GraphQL allowlist.
|
||||
type ContainerSource struct {
|
||||
Client *Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s ContainerSource) Snapshot(ctx context.Context) (container.RawSnapshot, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return container.RawSnapshot{}, err
|
||||
}
|
||||
if s.Client == nil {
|
||||
return container.RawSnapshot{}, errors.New("Unraid client is required")
|
||||
}
|
||||
payload, err := s.Client.Query(ctx, "containers")
|
||||
if err != nil {
|
||||
return container.RawSnapshot{}, fmt.Errorf("query Unraid containers: %w", err)
|
||||
}
|
||||
var response struct {
|
||||
Docker struct {
|
||||
Containers []struct {
|
||||
ID string `json:"id"`
|
||||
Names json.RawMessage `json:"names"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
} `json:"containers"`
|
||||
} `json:"docker"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return container.RawSnapshot{}, errors.New("decode Unraid container response")
|
||||
}
|
||||
if len(response.Docker.Containers) > container.DefaultMaxContainers {
|
||||
return container.RawSnapshot{}, errors.New("Unraid container response exceeds bounds")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
items := make([]container.RawContainer, 0, len(response.Docker.Containers))
|
||||
for _, item := range response.Docker.Containers {
|
||||
name, err := containerName(item.Names)
|
||||
if err != nil || strings.TrimSpace(item.ID) == "" {
|
||||
return container.RawSnapshot{}, errors.New("Unraid container identity is invalid")
|
||||
}
|
||||
runtimeState := containerRuntimeState(item.State, item.Status)
|
||||
items = append(items, container.RawContainer{
|
||||
ID: item.ID, Name: name, State: runtimeState, Health: containerHealth(item.Status),
|
||||
IntentionalStop: !item.AutoStart && runtimeState == "exited",
|
||||
})
|
||||
}
|
||||
return container.RawSnapshot{Source: container.Source{ID: "unraid", Type: "unraid"}, Containers: items, ObservedAt: now, ReceivedAt: now}, nil
|
||||
}
|
||||
|
||||
func containerRuntimeState(state, status string) string {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
switch {
|
||||
case strings.HasPrefix(status, "restarting"):
|
||||
return "restarting"
|
||||
case status == "created":
|
||||
return "created"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(state))
|
||||
}
|
||||
}
|
||||
|
||||
func containerHealth(status string) string {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
switch {
|
||||
case status == "healthy", strings.Contains(status, "(healthy)"):
|
||||
return "healthy"
|
||||
case status == "unhealthy", strings.Contains(status, "(unhealthy)"):
|
||||
return "unhealthy"
|
||||
case status == "starting", strings.Contains(status, "(health: starting)"), strings.Contains(status, "(starting)"):
|
||||
return "starting"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func containerName(raw json.RawMessage) (string, error) {
|
||||
var name string
|
||||
if err := json.Unmarshal(raw, &name); err == nil && strings.TrimSpace(name) != "" {
|
||||
return strings.TrimPrefix(strings.TrimSpace(name), "/"), nil
|
||||
}
|
||||
var names []string
|
||||
if err := json.Unmarshal(raw, &names); err != nil || len(names) == 0 {
|
||||
return "", errors.New("Unraid container name is invalid")
|
||||
}
|
||||
for _, candidate := range names {
|
||||
candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "/")
|
||||
if candidate != "" {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Unraid container name is invalid")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/container"
|
||||
)
|
||||
|
||||
func TestContainerSourceAcceptsBoundedOperationalHeadroomAndRejectsOverflow(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
count int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bounded headroom", count: container.DefaultMaxContainers},
|
||||
{name: "overflow", count: container.DefaultMaxContainers + 1, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
items := make([]map[string]any, 0, test.count)
|
||||
for index := 0; index < test.count; index++ {
|
||||
items = append(items, map[string]any{"id": fmt.Sprintf("id-%03d", index), "names": []string{fmt.Sprintf("/container-%03d", index)}, "state": "RUNNING", "status": "Up 1 hour", "autoStart": true})
|
||||
}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"docker": map[string]any{"containers": items}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-key", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := (ContainerSource{Client: client}).Snapshot(context.Background())
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized response rejection")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(snapshot.Containers) != test.count {
|
||||
t.Fatalf("containers=%d err=%v", len(snapshot.Containers), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerSourceNormalizesDocumentedReadOnlyContainerFields(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"docker":{"containers":[{"id":"two","names":["/zeta"],"state":"EXITED","status":"Exited (0) 2 days ago","autoStart":false},{"id":"one","names":"/alpha","state":"RUNNING","status":"Up 34 hours (healthy)","autoStart":true},{"id":"three","names":"/beta","state":"RUNNING","status":"Up 34 hours","autoStart":true},{"id":"four","names":"/gamma","state":"EXITED","status":"Restarting (137) 11 seconds ago","autoStart":false}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-key", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 10, 2, 0, 0, 0, time.UTC)
|
||||
snapshot, err := (ContainerSource{Client: client, Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Containers) != 4 || snapshot.Containers[0].Name != "zeta" || !snapshot.Containers[0].IntentionalStop {
|
||||
t.Fatalf("unexpected normalized snapshot: %+v", snapshot)
|
||||
}
|
||||
if snapshot.Containers[0].State != "exited" || snapshot.Containers[0].Health != "unknown" || snapshot.Containers[1].Health != "healthy" || snapshot.Containers[2].Health != "unknown" || snapshot.Containers[3].State != "restarting" || snapshot.Containers[3].IntentionalStop {
|
||||
t.Fatalf("runtime status was mistaken for health: %+v", snapshot.Containers)
|
||||
}
|
||||
if !snapshot.ObservedAt.Equal(now) || snapshot.Source.Type != "unraid" {
|
||||
t.Fatalf("unexpected source timestamps: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerSourceRejectsOversizedOrInvalidPayload(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"docker":{"containers":[{"id":"","names":[],"state":"running","status":"healthy","autoStart":true}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-key", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (ContainerSource{Client: client}).Snapshot(context.Background()); err == nil {
|
||||
t.Fatal("expected invalid identity rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStatusClassification(t *testing.T) {
|
||||
healthCases := map[string]string{
|
||||
"healthy": "healthy", "Up 34 hours (healthy)": "healthy",
|
||||
"unhealthy": "unhealthy", "Up 2 minutes (unhealthy)": "unhealthy",
|
||||
"starting": "starting", "Up 3 seconds (health: starting)": "starting",
|
||||
"Up 34 hours": "unknown", "Exited (0) 2 days ago": "unknown",
|
||||
}
|
||||
for input, want := range healthCases {
|
||||
if got := containerHealth(input); got != want {
|
||||
t.Fatalf("health %q = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
if got := containerRuntimeState("EXITED", "Restarting (137) 11 seconds ago"); got != "restarting" {
|
||||
t.Fatalf("runtime = %q, want restarting", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
)
|
||||
|
||||
// PoolSource maps only cache records that carry filesystem totals. Unraid 7.2
|
||||
// exposes one such aggregate record per named pool; the remaining member
|
||||
// devices have null filesystem fields. This avoids inferring membership from
|
||||
// device names while still preserving the API's truthful pool boundary.
|
||||
type PoolSource struct {
|
||||
Client *Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s PoolSource) Snapshot(ctx context.Context) (pool.RawSnapshot, error) {
|
||||
doc, now, err := (ArraySource{Client: s.Client, Now: s.Now}).document(ctx)
|
||||
if err != nil {
|
||||
return pool.RawSnapshot{}, err
|
||||
}
|
||||
items := make([]pool.RawPool, 0, len(doc.Array.Caches))
|
||||
for _, item := range doc.Array.Caches {
|
||||
if len(item.FSSize) == 0 || string(item.FSSize) == "null" {
|
||||
continue
|
||||
}
|
||||
total, err := rawUint(item.FSSize)
|
||||
if err != nil || total == 0 || strings.TrimSpace(item.Name) == "" {
|
||||
return pool.RawSnapshot{}, errors.New("Unraid pool identity or capacity is invalid")
|
||||
}
|
||||
used, err := rawUint(item.FSUsed)
|
||||
if err != nil || used > total {
|
||||
return pool.RawSnapshot{}, errors.New("Unraid pool usage is invalid")
|
||||
}
|
||||
items = append(items, pool.RawPool{
|
||||
ID: identity(item.ID, item.Name),
|
||||
Name: item.Name,
|
||||
Filesystem: item.FSType,
|
||||
State: poolState(item.Status),
|
||||
UsableBytes: kilobytes(total),
|
||||
UsedBytes: kilobytes(used),
|
||||
Capabilities: pool.Capabilities{
|
||||
Members: pool.CapabilityUnsupported,
|
||||
Capacity: pool.CapabilityAvailable,
|
||||
Redundancy: pool.CapabilityUnsupported,
|
||||
Scrub: pool.CapabilityUnsupported,
|
||||
FilesystemErrors: pool.CapabilityUnsupported,
|
||||
Performance: pool.CapabilityUnsupported,
|
||||
SSDWear: pool.CapabilityUnsupported,
|
||||
MoverSignals: pool.CapabilityUnsupported,
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(items) > 64 {
|
||||
return pool.RawSnapshot{}, errors.New("Unraid pool response exceeds bounds")
|
||||
}
|
||||
return pool.RawSnapshot{Source: pool.Source{ID: "unraid", Type: "unraid"}, Pools: items, ObservedAt: now, ReceivedAt: now}, nil
|
||||
}
|
||||
|
||||
func poolState(value string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "DISK_OK") {
|
||||
return pool.StateHealthy
|
||||
}
|
||||
return pool.StateUnknown
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/pool"
|
||||
)
|
||||
|
||||
func TestPoolSourceUsesOnlyExplicitFilesystemAggregates(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"array":{"state":"STARTED","parities":[],"disks":[],"caches":[{"id":"cache-id","name":"cache","type":"CACHE","size":"1000","status":"DISK_OK","fsSize":"900","fsUsed":"300","fsFree":"600","fsType":"zfs"},{"id":"cache-member","name":"cache2","type":"CACHE","size":"1000","status":"DISK_OK","fsSize":null,"fsUsed":null,"fsFree":null,"fsType":null},{"id":"ssd-id","name":"ssd","type":"CACHE","size":"2000","status":"DISK_OK","fsSize":"1800","fsUsed":"1200","fsFree":"600","fsType":"zfs"}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-key", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
|
||||
snapshot, err := (PoolSource{Client: client, Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Pools) != 2 || snapshot.Pools[0].Name != "cache" || snapshot.Pools[1].Name != "ssd" {
|
||||
t.Fatalf("pools = %+v", snapshot.Pools)
|
||||
}
|
||||
if snapshot.Pools[0].UsableBytes != 900*1024 || snapshot.Pools[0].UsedBytes != 300*1024 || snapshot.Pools[0].State != pool.StateHealthy {
|
||||
t.Fatalf("pool aggregate = %+v", snapshot.Pools[0])
|
||||
}
|
||||
if snapshot.Pools[0].Capabilities.Capacity != pool.CapabilityAvailable || snapshot.Pools[0].Capabilities.Members != pool.CapabilityUnsupported {
|
||||
t.Fatalf("capabilities = %+v", snapshot.Pools[0].Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSourceRejectsInvalidExplicitAggregate(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"array":{"caches":[{"id":"cache","name":"cache","status":"DISK_OK","fsSize":"100","fsUsed":"101","fsType":"zfs"}]}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-key", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (PoolSource{Client: client}).Snapshot(context.Background()); err == nil {
|
||||
t.Fatal("expected invalid pool usage rejection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/share"
|
||||
)
|
||||
|
||||
// ShareSource maps Unraid's bounded, read-only shares query. Share usage is reported
|
||||
// by Unraid in KiB; Pulse's domain contract consistently uses bytes. The API does not
|
||||
// expose a complete placement scan in this selection, so no placement is inferred.
|
||||
type ShareSource struct {
|
||||
Client *Client
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s ShareSource) Snapshot(ctx context.Context) (share.RawSnapshot, error) {
|
||||
if s.Client == nil {
|
||||
return share.RawSnapshot{}, errors.New("Unraid client is required")
|
||||
}
|
||||
payload, err := s.Client.Query(ctx, "shares")
|
||||
if err != nil {
|
||||
return share.RawSnapshot{}, err
|
||||
}
|
||||
var response struct {
|
||||
Shares []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Used json.RawMessage `json:"used"`
|
||||
Size json.RawMessage `json:"size"`
|
||||
Include []string `json:"include"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Cache bool `json:"cache"`
|
||||
Allocator string `json:"allocator"`
|
||||
} `json:"shares"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
return share.RawSnapshot{}, errors.New("decode Unraid shares response")
|
||||
}
|
||||
if len(response.Shares) > 1000 {
|
||||
return share.RawSnapshot{}, errors.New("Unraid share response exceeds bounds")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
items := make([]share.RawShare, 0, len(response.Shares))
|
||||
for _, item := range response.Shares {
|
||||
used, err := rawUint(item.Used)
|
||||
if err != nil || strings.TrimSpace(item.ID) == "" || strings.TrimSpace(item.Name) == "" {
|
||||
return share.RawSnapshot{}, errors.New("Unraid share identity or usage is invalid")
|
||||
}
|
||||
// `cache` is a boolean in this API version. Preserve exactly that fact instead
|
||||
// of inventing a legacy mover policy or a primary/secondary placement.
|
||||
cachePolicy := "disabled"
|
||||
if item.Cache {
|
||||
cachePolicy = "enabled"
|
||||
}
|
||||
items = append(items, share.RawShare{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
StoragePolicy: share.StoragePolicy{Allocation: strings.TrimSpace(item.Allocator), CachePolicy: cachePolicy},
|
||||
UsedBytes: kilobytes(used),
|
||||
SizeObservedAt: now,
|
||||
SizeState: share.SizeAvailable,
|
||||
})
|
||||
}
|
||||
return share.RawSnapshot{Source: share.Source{ID: "unraid", Type: "unraid"}, Shares: items, ObservedAt: now, ReceivedAt: now}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package unraid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShareSourcePreservesReadOnlyUsageAndPolicy(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"shares":[{"id":"share-1","name":"appdata","used":"64","size":"128","include":["disk1"],"exclude":[],"cache":true,"allocator":"high-water"}]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := New(server.URL, "test-token", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 10, 4, 0, 0, 0, time.UTC)
|
||||
snapshot, err := (ShareSource{Client: client, Now: func() time.Time { return now }}).Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Shares) != 1 {
|
||||
t.Fatalf("shares = %+v", snapshot.Shares)
|
||||
}
|
||||
item := snapshot.Shares[0]
|
||||
if item.UsedBytes != 64*1024 || item.SizeState != "available" || item.StoragePolicy.CachePolicy != "enabled" || item.StoragePolicy.Allocation != "high-water" {
|
||||
t.Fatalf("share mapping = %+v", item)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user