Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+198
View File
@@ -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"
}
+58
View File
@@ -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")
}
}
+121
View File
@@ -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
}
+89
View File
@@ -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")
}
}
+111
View File
@@ -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")
}
+105
View File
@@ -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)
}
}
+69
View File
@@ -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
}
+50
View File
@@ -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")
}
}
+73
View File
@@ -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
}
+32
View File
@@ -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)
}
}