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