Files
ITWorx-Pulse-Public/internal/unraid/containers.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

112 lines
3.6 KiB
Go

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")
}