Pulse/internal/api/recovery_handlers.go
2026-03-31 15:42:16 +01:00

1044 lines
33 KiB
Go

package api
import (
"context"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
kubernetesmapper "github.com/rcourtman/pulse-go-rewrite/internal/recovery/mapper/kubernetes"
recoverystore "github.com/rcourtman/pulse-go-rewrite/internal/recovery/store"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes"
"github.com/rs/zerolog/log"
)
type RecoveryHandlers struct {
manager *recoverymanager.Manager
}
func NewRecoveryHandlers(manager *recoverymanager.Manager) *RecoveryHandlers {
return &RecoveryHandlers{
manager: manager,
}
}
func (h *RecoveryHandlers) storeForOrg(orgID string) (*recoverystore.Store, error) {
if h == nil || h.manager == nil {
return nil, fmt.Errorf("recovery manager is not configured")
}
return h.manager.StoreForOrg(orgID)
}
type recoveryPointsResponse struct {
Data []recoveryPointPayload `json:"data"`
Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
} `json:"meta"`
}
type recoveryPointPayload struct {
ID string `json:"id"`
Platform recovery.Provider `json:"platform"`
Provider recovery.Provider `json:"provider,omitempty"`
Kind recovery.Kind `json:"kind"`
Mode recovery.Mode `json:"mode"`
Outcome recovery.Outcome `json:"outcome"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
SizeBytes *int64 `json:"sizeBytes,omitempty"`
Verified *bool `json:"verified,omitempty"`
Encrypted *bool `json:"encrypted,omitempty"`
Immutable *bool `json:"immutable,omitempty"`
ItemResourceID string `json:"itemResourceId,omitempty"`
SubjectResourceID string `json:"subjectResourceId,omitempty"`
RepositoryResourceID string `json:"repositoryResourceId,omitempty"`
ItemRef *recovery.ExternalRef `json:"itemRef,omitempty"`
SubjectRef *recovery.ExternalRef `json:"subjectRef,omitempty"`
RepositoryRef *recovery.ExternalRef `json:"repositoryRef,omitempty"`
Details map[string]any `json:"details,omitempty"`
Display *recovery.RecoveryPointDisplay `json:"display,omitempty"`
}
type recoveryRollupPayload struct {
RollupID string `json:"rollupId"`
ItemResourceID string `json:"itemResourceId,omitempty"`
SubjectResourceID string `json:"subjectResourceId,omitempty"`
ItemRef *recovery.ExternalRef `json:"itemRef,omitempty"`
SubjectRef *recovery.ExternalRef `json:"subjectRef,omitempty"`
Display *recovery.RecoveryPointDisplay `json:"display,omitempty"`
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
LastOutcome recovery.Outcome `json:"lastOutcome"`
Platforms []recovery.Provider `json:"platforms,omitempty"`
Providers []recovery.Provider `json:"providers,omitempty"`
}
func buildRecoveryPointPayload(point recovery.RecoveryPoint) recoveryPointPayload {
return recoveryPointPayload{
ID: point.ID,
Platform: point.Provider,
Provider: point.Provider,
Kind: point.Kind,
Mode: point.Mode,
Outcome: point.Outcome,
StartedAt: point.StartedAt,
CompletedAt: point.CompletedAt,
SizeBytes: point.SizeBytes,
Verified: point.Verified,
Encrypted: point.Encrypted,
Immutable: point.Immutable,
ItemResourceID: point.SubjectResourceID,
SubjectResourceID: point.SubjectResourceID,
RepositoryResourceID: point.RepositoryResourceID,
ItemRef: point.SubjectRef,
SubjectRef: point.SubjectRef,
RepositoryRef: point.RepositoryRef,
Details: point.Details,
Display: point.Display,
}
}
func buildRecoveryRollupPayload(rollup recovery.ProtectionRollup) recoveryRollupPayload {
return recoveryRollupPayload{
RollupID: rollup.RollupID,
ItemResourceID: rollup.SubjectResourceID,
SubjectResourceID: rollup.SubjectResourceID,
ItemRef: rollup.SubjectRef,
SubjectRef: rollup.SubjectRef,
Display: rollup.Display,
LastAttemptAt: rollup.LastAttemptAt,
LastSuccessAt: rollup.LastSuccessAt,
LastOutcome: rollup.LastOutcome,
Platforms: rollup.Providers,
Providers: rollup.Providers,
}
}
func parseRFC3339QueryTime(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return nil, err
}
t := parsed.UTC()
return &t, nil
}
func parseIntQuery(qs map[string][]string, key string, fallback int) int {
v := strings.TrimSpace(firstQueryValue(qs, key))
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func firstQueryValue(qs map[string][]string, key string) string {
values := qs[key]
if len(values) == 0 {
return ""
}
return values[0]
}
func parseRecoveryPlatformQuery(qs url.Values) recovery.Provider {
return recovery.Provider(strings.TrimSpace(firstNonEmpty(
qs.Get("platform"),
qs.Get("provider"),
)))
}
func parseRecoveryItemResourceIDQuery(qs url.Values) string {
return strings.TrimSpace(firstNonEmpty(
qs.Get("itemResourceId"),
qs.Get("subjectResourceId"),
))
}
func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1)
limit := parseIntQuery(qs, "limit", 100)
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_from", "Invalid from time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
from = t
}
if t, err := parseRFC3339QueryTime(qs.Get("to")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_to", "Invalid to time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
to = t
}
opts := recovery.ListPointsOptions{
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))),
SubjectResourceID: parseRecoveryItemResourceIDQuery(qs),
RollupID: strings.TrimSpace(qs.Get("rollupId")),
From: from,
To: to,
Query: strings.TrimSpace(firstNonEmpty(qs.Get("q"), qs.Get("query"))),
ClusterLabel: strings.TrimSpace(firstNonEmpty(qs.Get("cluster"), qs.Get("clusterLabel"))),
NodeHostLabel: strings.TrimSpace(firstNonEmpty(qs.Get("node"), qs.Get("nodeHost"), qs.Get("nodeHostLabel"))),
NamespaceLabel: strings.TrimSpace(firstNonEmpty(qs.Get("namespace"), qs.Get("namespaceLabel"))),
WorkloadOnly: strings.TrimSpace(qs.Get("scope")) == "workload" || strings.TrimSpace(qs.Get("workloadOnly")) == "true",
Verification: strings.TrimSpace(firstNonEmpty(qs.Get("verification"), qs.Get("verified"))),
Page: page,
Limit: limit,
}
var (
points []recovery.RecoveryPoint
total int
)
if mock.IsMockEnabled() {
all := mock.CurrentFixtureGraph().RecoveryPoints()
filtered := filterRecoveryPoints(all, opts)
total = len(filtered)
points = paginateRecoveryPoints(filtered, opts.Page, opts.Limit)
} else {
orgID := GetOrgID(r.Context())
store, err := h.storeForOrg(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
p, t, err := store.ListPoints(r.Context(), opts)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
points = p
total = t
}
// Ensure normalized display data is present for UIs (works for both real and mock paths).
for i := range points {
if points[i].Display == nil {
idx := recovery.DeriveIndex(points[i])
points[i].Display = idx.ToDisplay()
}
}
var resp recoveryPointsResponse
resp.Data = make([]recoveryPointPayload, 0, len(points))
for _, point := range points {
resp.Data = append(resp.Data, buildRecoveryPointPayload(point))
}
resp.Meta.Page = page
resp.Meta.Limit = limit
resp.Meta.Total = total
if limit <= 0 {
resp.Meta.TotalPages = 1
} else {
resp.Meta.TotalPages = (total + limit - 1) / limit
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery points response")
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func parseBoolQuery(qs map[string][]string, key string) bool {
v := strings.TrimSpace(firstQueryValue(qs, key))
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (h *RecoveryHandlers) HandleListSeries(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
qs := r.URL.Query()
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_from", "Invalid from time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
from = t
}
if t, err := parseRFC3339QueryTime(qs.Get("to")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_to", "Invalid to time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
to = t
}
tzOffsetMin := parseIntQuery(qs, "tzOffsetMinutes", 0)
opts := recovery.ListPointsOptions{
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))),
SubjectResourceID: parseRecoveryItemResourceIDQuery(qs),
RollupID: strings.TrimSpace(qs.Get("rollupId")),
From: from,
To: to,
Query: strings.TrimSpace(firstNonEmpty(qs.Get("q"), qs.Get("query"))),
ClusterLabel: strings.TrimSpace(firstNonEmpty(qs.Get("cluster"), qs.Get("clusterLabel"))),
NodeHostLabel: strings.TrimSpace(firstNonEmpty(qs.Get("node"), qs.Get("nodeHost"), qs.Get("nodeHostLabel"))),
NamespaceLabel: strings.TrimSpace(firstNonEmpty(qs.Get("namespace"), qs.Get("namespaceLabel"))),
WorkloadOnly: strings.TrimSpace(qs.Get("scope")) == "workload" || parseBoolQuery(qs, "workloadOnly"),
Verification: strings.TrimSpace(firstNonEmpty(qs.Get("verification"), qs.Get("verified"))),
}
var series []recovery.PointsSeriesBucket
if mock.IsMockEnabled() {
all := mock.CurrentFixtureGraph().RecoveryPoints()
filtered := filterRecoveryPoints(all, opts)
// Count only completed points.
series = buildSeriesFromPoints(filtered, opts, tzOffsetMin)
} else {
orgID := GetOrgID(r.Context())
store, err := h.storeForOrg(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
s, err := store.ListPointsSeries(r.Context(), opts, tzOffsetMin)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
series = s
}
if err := utils.WriteJSONResponse(w, map[string]any{"data": series}); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery series response")
}
}
func (h *RecoveryHandlers) HandleListFacets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
qs := r.URL.Query()
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_from", "Invalid from time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
from = t
}
if t, err := parseRFC3339QueryTime(qs.Get("to")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_to", "Invalid to time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
to = t
}
opts := recovery.ListPointsOptions{
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))),
SubjectResourceID: parseRecoveryItemResourceIDQuery(qs),
RollupID: strings.TrimSpace(qs.Get("rollupId")),
From: from,
To: to,
Query: strings.TrimSpace(firstNonEmpty(qs.Get("q"), qs.Get("query"))),
ClusterLabel: strings.TrimSpace(firstNonEmpty(qs.Get("cluster"), qs.Get("clusterLabel"))),
NodeHostLabel: strings.TrimSpace(firstNonEmpty(qs.Get("node"), qs.Get("nodeHost"), qs.Get("nodeHostLabel"))),
NamespaceLabel: strings.TrimSpace(firstNonEmpty(qs.Get("namespace"), qs.Get("namespaceLabel"))),
WorkloadOnly: strings.TrimSpace(qs.Get("scope")) == "workload" || parseBoolQuery(qs, "workloadOnly"),
Verification: strings.TrimSpace(firstNonEmpty(qs.Get("verification"), qs.Get("verified"))),
}
var facets recovery.PointsFacets
if mock.IsMockEnabled() {
all := mock.CurrentFixtureGraph().RecoveryPoints()
filtered := filterRecoveryPoints(all, opts)
facets = buildFacetsFromPoints(filtered)
} else {
orgID := GetOrgID(r.Context())
store, err := h.storeForOrg(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
f, err := store.ListPointsFacets(r.Context(), opts)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
facets = f
}
if err := utils.WriteJSONResponse(w, map[string]any{"data": facets}); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery facets response")
}
}
func filterRecoveryPoints(all []recovery.RecoveryPoint, opts recovery.ListPointsOptions) []recovery.RecoveryPoint {
if len(all) == 0 {
return nil
}
provider := strings.TrimSpace(string(opts.Provider))
kind := strings.TrimSpace(string(opts.Kind))
mode := strings.TrimSpace(string(opts.Mode))
outcome := strings.TrimSpace(string(opts.Outcome))
subjectRID := strings.TrimSpace(opts.SubjectResourceID)
rollupID := strings.TrimSpace(opts.RollupID)
cluster := strings.TrimSpace(opts.ClusterLabel)
node := strings.TrimSpace(opts.NodeHostLabel)
namespace := strings.TrimSpace(opts.NamespaceLabel)
itemType := recovery.NormalizeRecoveryItemType(opts.ItemType)
q := strings.ToLower(strings.TrimSpace(opts.Query))
workloadOnly := opts.WorkloadOnly
verification := strings.ToLower(strings.TrimSpace(opts.Verification))
if rollupID != "" && !strings.HasPrefix(rollupID, "res:") && !strings.HasPrefix(rollupID, "ext:") {
rollupID = "res:" + rollupID
}
out := make([]recovery.RecoveryPoint, 0, len(all))
for _, p := range all {
// Ensure display/index data exists for filtering parity with sqlite store.
if p.Display == nil {
idx := recovery.DeriveIndex(p)
p.Display = idx.ToDisplay()
}
disp := p.Display
if provider != "" && strings.TrimSpace(string(p.Provider)) != provider {
continue
}
if kind != "" && strings.TrimSpace(string(p.Kind)) != kind {
continue
}
if mode != "" && strings.TrimSpace(string(p.Mode)) != mode {
continue
}
if outcome != "" && strings.TrimSpace(string(p.Outcome)) != outcome {
continue
}
if rollupID != "" {
if strings.TrimSpace(recovery.SubjectKeyForPoint(p)) != rollupID {
continue
}
} else if subjectRID != "" && strings.TrimSpace(p.SubjectResourceID) != subjectRID {
continue
}
if opts.From != nil && !opts.From.IsZero() {
// Match store semantics: completed_at_ms IS NULL OR completed_at_ms >= from
if p.CompletedAt != nil && !p.CompletedAt.IsZero() && p.CompletedAt.Before(opts.From.UTC()) {
continue
}
}
if opts.To != nil && !opts.To.IsZero() {
// Match store semantics: completed_at_ms IS NULL OR completed_at_ms <= to
if p.CompletedAt != nil && !p.CompletedAt.IsZero() && p.CompletedAt.After(opts.To.UTC()) {
continue
}
}
if cluster != "" && strings.TrimSpace(getDisplayClusterLabel(p.Display)) != cluster {
continue
}
if node != "" && strings.TrimSpace(getDisplayNodeHostLabel(p.Display)) != node {
continue
}
if namespace != "" && strings.TrimSpace(getDisplayNamespaceLabel(p.Display)) != namespace {
continue
}
if itemType != "" && strings.TrimSpace(getDisplayItemType(p.Display)) != itemType {
continue
}
if workloadOnly && !(disp != nil && disp.IsWorkload) {
continue
}
if verification != "" {
v := p.Verified
switch verification {
case "verified":
if v == nil || !*v {
continue
}
case "unverified":
if v == nil || *v {
continue
}
case "unknown":
if v != nil {
continue
}
}
}
if q != "" {
// Best-effort match across normalized display fields.
var subjectLabel, subjectType, itemTypeLabel, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string
if disp != nil {
subjectLabel = disp.SubjectLabel
subjectType = disp.SubjectType
itemTypeLabel = disp.ItemType
clusterLabel = disp.ClusterLabel
nodeLabel = disp.NodeHostLabel
nsLabel = disp.NamespaceLabel
entityID = disp.EntityIDLabel
repoLabel = disp.RepositoryLabel
detailSummary = disp.DetailsSummary
}
hay := strings.ToLower(strings.Join([]string{
strings.TrimSpace(p.ID),
strings.TrimSpace(string(p.Provider)),
strings.TrimSpace(string(p.Kind)),
strings.TrimSpace(string(p.Mode)),
strings.TrimSpace(string(p.Outcome)),
strings.TrimSpace(subjectLabel),
strings.TrimSpace(subjectType),
strings.TrimSpace(itemTypeLabel),
strings.TrimSpace(clusterLabel),
strings.TrimSpace(nodeLabel),
strings.TrimSpace(nsLabel),
strings.TrimSpace(entityID),
strings.TrimSpace(repoLabel),
strings.TrimSpace(detailSummary),
}, " "))
if !strings.Contains(hay, q) {
continue
}
}
out = append(out, p)
}
return out
}
func getDisplayClusterLabel(d *recovery.RecoveryPointDisplay) string {
if d == nil {
return ""
}
return d.ClusterLabel
}
func getDisplayNodeHostLabel(d *recovery.RecoveryPointDisplay) string {
if d == nil {
return ""
}
return d.NodeHostLabel
}
func getDisplayNamespaceLabel(d *recovery.RecoveryPointDisplay) string {
if d == nil {
return ""
}
return d.NamespaceLabel
}
func getDisplayItemType(d *recovery.RecoveryPointDisplay) string {
if d == nil {
return ""
}
if v := recovery.NormalizeRecoveryItemType(d.ItemType); v != "" {
return v
}
return recovery.NormalizeRecoveryItemType(d.SubjectType)
}
func paginateRecoveryPoints(filtered []recovery.RecoveryPoint, page int, limit int) []recovery.RecoveryPoint {
if len(filtered) == 0 {
return []recovery.RecoveryPoint{}
}
normalizedLimit := limit
if normalizedLimit <= 0 {
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) {
return []recovery.RecoveryPoint{}
}
end := offset + normalizedLimit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end]
}
func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
qs := r.URL.Query()
page := parseIntQuery(qs, "page", 1)
limit := parseIntQuery(qs, "limit", 100)
var from, to *time.Time
if t, err := parseRFC3339QueryTime(qs.Get("from")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_from", "Invalid from time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
from = t
}
if t, err := parseRFC3339QueryTime(qs.Get("to")); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_to", "Invalid to time; must be RFC3339", map[string]string{"error": err.Error()})
return
} else {
to = t
}
opts := recovery.ListPointsOptions{
Provider: parseRecoveryPlatformQuery(qs),
Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))),
Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))),
Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))),
ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))),
SubjectResourceID: parseRecoveryItemResourceIDQuery(qs),
RollupID: strings.TrimSpace(qs.Get("rollupId")),
From: from,
To: to,
Query: strings.TrimSpace(firstNonEmpty(qs.Get("q"), qs.Get("query"))),
ClusterLabel: strings.TrimSpace(firstNonEmpty(qs.Get("cluster"), qs.Get("clusterLabel"))),
NodeHostLabel: strings.TrimSpace(firstNonEmpty(qs.Get("node"), qs.Get("nodeHost"), qs.Get("nodeHostLabel"))),
NamespaceLabel: strings.TrimSpace(firstNonEmpty(qs.Get("namespace"), qs.Get("namespaceLabel"))),
WorkloadOnly: strings.TrimSpace(qs.Get("scope")) == "workload" || parseBoolQuery(qs, "workloadOnly"),
Verification: strings.TrimSpace(firstNonEmpty(qs.Get("verification"), qs.Get("verified"))),
Page: page,
Limit: limit,
}
var (
rollups []recovery.ProtectionRollup
total int
)
if mock.IsMockEnabled() {
all := mock.CurrentFixtureGraph().RecoveryPoints()
filtered := filterRecoveryPointsForRollups(all, opts)
rollups = recovery.BuildRollupsFromPoints(filtered)
total = len(rollups)
rollups = paginateRecoveryRollups(rollups, opts.Page, opts.Limit)
} else {
orgID := GetOrgID(r.Context())
store, err := h.storeForOrg(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
r, t, err := store.ListRollups(r.Context(), opts)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
rollups = r
total = t
}
meta := map[string]any{
"page": page,
"limit": limit,
"total": total,
"totalPages": 0,
}
if limit <= 0 {
meta["totalPages"] = 1
} else {
meta["totalPages"] = (total + limit - 1) / limit
}
payloadRollups := make([]recoveryRollupPayload, 0, len(rollups))
for _, rollup := range rollups {
payloadRollups = append(payloadRollups, buildRecoveryRollupPayload(rollup))
}
if err := utils.WriteJSONResponse(w, map[string]any{
"data": payloadRollups,
"meta": meta,
}); err != nil {
log.Error().Err(err).Msg("Failed to serialize recovery rollups response")
}
}
func filterRecoveryPointsForRollups(all []recovery.RecoveryPoint, opts recovery.ListPointsOptions) []recovery.RecoveryPoint {
if len(all) == 0 {
return nil
}
provider := strings.TrimSpace(string(opts.Provider))
kind := strings.TrimSpace(string(opts.Kind))
mode := strings.TrimSpace(string(opts.Mode))
outcome := strings.TrimSpace(string(opts.Outcome))
subjectRID := strings.TrimSpace(opts.SubjectResourceID)
rollupID := strings.TrimSpace(opts.RollupID)
cluster := strings.TrimSpace(opts.ClusterLabel)
node := strings.TrimSpace(opts.NodeHostLabel)
namespace := strings.TrimSpace(opts.NamespaceLabel)
itemType := recovery.NormalizeRecoveryItemType(opts.ItemType)
q := strings.ToLower(strings.TrimSpace(opts.Query))
workloadOnly := opts.WorkloadOnly
verification := strings.ToLower(strings.TrimSpace(opts.Verification))
if rollupID != "" && !strings.HasPrefix(rollupID, "res:") && !strings.HasPrefix(rollupID, "ext:") {
rollupID = "res:" + rollupID
}
out := make([]recovery.RecoveryPoint, 0, len(all))
for _, p := range all {
if p.Display == nil {
idx := recovery.DeriveIndex(p)
p.Display = idx.ToDisplay()
}
disp := p.Display
if provider != "" && strings.TrimSpace(string(p.Provider)) != provider {
continue
}
if kind != "" && strings.TrimSpace(string(p.Kind)) != kind {
continue
}
if mode != "" && strings.TrimSpace(string(p.Mode)) != mode {
continue
}
if outcome != "" && strings.TrimSpace(string(p.Outcome)) != outcome {
continue
}
if rollupID != "" {
if strings.TrimSpace(recovery.SubjectKeyForPoint(p)) != rollupID {
continue
}
} else if subjectRID != "" && strings.TrimSpace(p.SubjectResourceID) != subjectRID {
continue
}
ts := rollupTimestamp(p)
if opts.From != nil && !opts.From.IsZero() && ts != nil && !ts.IsZero() && ts.Before(opts.From.UTC()) {
continue
}
if opts.To != nil && !opts.To.IsZero() && ts != nil && !ts.IsZero() && ts.After(opts.To.UTC()) {
continue
}
if cluster != "" && strings.TrimSpace(getDisplayClusterLabel(disp)) != cluster {
continue
}
if node != "" && strings.TrimSpace(getDisplayNodeHostLabel(disp)) != node {
continue
}
if namespace != "" && strings.TrimSpace(getDisplayNamespaceLabel(disp)) != namespace {
continue
}
if itemType != "" && strings.TrimSpace(getDisplayItemType(disp)) != itemType {
continue
}
if workloadOnly && !(disp != nil && disp.IsWorkload) {
continue
}
if verification != "" {
v := p.Verified
switch verification {
case "verified":
if v == nil || !*v {
continue
}
case "unverified":
if v == nil || *v {
continue
}
case "unknown":
if v != nil {
continue
}
}
}
if q != "" {
var subjectLabel, subjectType, itemTypeLabel, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string
if disp != nil {
subjectLabel = disp.SubjectLabel
subjectType = disp.SubjectType
itemTypeLabel = disp.ItemType
clusterLabel = disp.ClusterLabel
nodeLabel = disp.NodeHostLabel
nsLabel = disp.NamespaceLabel
entityID = disp.EntityIDLabel
repoLabel = disp.RepositoryLabel
detailSummary = disp.DetailsSummary
}
hay := strings.ToLower(strings.Join([]string{
strings.TrimSpace(recovery.SubjectKeyForPoint(p)),
strings.TrimSpace(p.SubjectResourceID),
strings.TrimSpace(string(p.Provider)),
strings.TrimSpace(string(p.Outcome)),
strings.TrimSpace(subjectLabel),
strings.TrimSpace(subjectType),
strings.TrimSpace(itemTypeLabel),
strings.TrimSpace(clusterLabel),
strings.TrimSpace(nodeLabel),
strings.TrimSpace(nsLabel),
strings.TrimSpace(entityID),
strings.TrimSpace(repoLabel),
strings.TrimSpace(detailSummary),
}, " "))
if !strings.Contains(hay, q) {
continue
}
}
out = append(out, p)
}
return out
}
func rollupTimestamp(p recovery.RecoveryPoint) *time.Time {
if p.CompletedAt != nil && !p.CompletedAt.IsZero() {
t := p.CompletedAt.UTC()
return &t
}
if p.StartedAt != nil && !p.StartedAt.IsZero() {
t := p.StartedAt.UTC()
return &t
}
return nil
}
func paginateRecoveryRollups(filtered []recovery.ProtectionRollup, page int, limit int) []recovery.ProtectionRollup {
if len(filtered) == 0 {
return []recovery.ProtectionRollup{}
}
normalizedLimit := limit
if normalizedLimit <= 0 {
normalizedLimit = 100
}
if normalizedLimit > 500 {
normalizedLimit = 500
}
normalizedPage := page
if normalizedPage <= 0 {
normalizedPage = 1
}
offset := (normalizedPage - 1) * normalizedLimit
if offset >= len(filtered) {
return []recovery.ProtectionRollup{}
}
end := offset + normalizedLimit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end]
}
func buildSeriesFromPoints(points []recovery.RecoveryPoint, opts recovery.ListPointsOptions, tzOffsetMinutes int) []recovery.PointsSeriesBucket {
// Mirror store.ListPointsSeries semantics: completed only; group by day in the requested timezone offset.
if len(points) == 0 {
return []recovery.PointsSeriesBucket{}
}
offset := time.Duration(tzOffsetMinutes) * time.Minute
type bucket struct {
day string
total int
snapshot int
local int
remote int
}
buckets := map[string]*bucket{}
// Determine the day window to return.
start := time.Now().UTC().Add(-29 * 24 * time.Hour)
end := time.Now().UTC()
if opts.From != nil && !opts.From.IsZero() {
start = opts.From.UTC()
}
if opts.To != nil && !opts.To.IsZero() {
end = opts.To.UTC()
}
if end.Before(start) {
start, end = end, start
}
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
end = time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
for _, p := range points {
if p.CompletedAt == nil || p.CompletedAt.IsZero() {
continue
}
dayKey := p.CompletedAt.UTC().Add(offset).Format("2006-01-02")
b := buckets[dayKey]
if b == nil {
b = &bucket{day: dayKey}
buckets[dayKey] = b
}
b.total++
switch strings.ToLower(strings.TrimSpace(string(p.Mode))) {
case "snapshot":
b.snapshot++
case "remote":
b.remote++
default:
b.local++
}
}
out := make([]recovery.PointsSeriesBucket, 0, int(end.Sub(start).Hours()/24)+1)
for d := start; !d.After(end); d = d.Add(24 * time.Hour) {
key := d.Format("2006-01-02")
if b := buckets[key]; b != nil {
out = append(out, recovery.PointsSeriesBucket{
Day: b.day,
Total: b.total,
Snapshot: b.snapshot,
Local: b.local,
Remote: b.remote,
})
} else {
out = append(out, recovery.PointsSeriesBucket{Day: key})
}
}
return out
}
func buildFacetsFromPoints(points []recovery.RecoveryPoint) recovery.PointsFacets {
clusters := map[string]struct{}{}
nodes := map[string]struct{}{}
namespaces := map[string]struct{}{}
itemTypes := map[string]struct{}{}
var hasSize bool
var hasVerification bool
var hasEntityID bool
for _, p := range points {
if p.Display == nil {
idx := recovery.DeriveIndex(p)
p.Display = idx.ToDisplay()
}
if p.Display != nil {
if v := strings.TrimSpace(p.Display.ClusterLabel); v != "" {
clusters[v] = struct{}{}
}
if v := strings.TrimSpace(p.Display.NodeHostLabel); v != "" {
nodes[v] = struct{}{}
}
if v := strings.TrimSpace(p.Display.NamespaceLabel); v != "" {
namespaces[v] = struct{}{}
}
if v := getDisplayItemType(p.Display); v != "" {
itemTypes[v] = struct{}{}
}
if v := strings.TrimSpace(p.Display.EntityIDLabel); v != "" {
hasEntityID = true
}
}
if p.SizeBytes != nil && *p.SizeBytes > 0 {
hasSize = true
}
if p.Verified != nil {
hasVerification = true
}
}
toSorted := func(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
return recovery.PointsFacets{
ItemTypes: toSorted(itemTypes),
Clusters: toSorted(clusters),
NodesHosts: toSorted(nodes),
Namespaces: toSorted(namespaces),
HasSize: hasSize,
HasVerification: hasVerification,
HasEntityID: hasEntityID,
}
}
// IngestKubernetesReport converts Kubernetes recovery artifacts into canonical recovery points and persists them.
// This is called from the Kubernetes agent ingest path; it is intentionally best-effort and must not block
// baseline cluster monitoring ingestion.
func (h *RecoveryHandlers) IngestKubernetesReport(ctx context.Context, orgID string, report agentsk8s.Report) error {
if report.Recovery == nil {
return nil
}
store, err := h.storeForOrg(orgID)
if err != nil {
return err
}
points := kubernetesmapper.FromKubernetesRecoveryReport(report.Cluster, report.Recovery)
if len(points) == 0 {
return nil
}
// Best-effort cap: prevent pathological payloads from creating massive DB writes.
const maxPointsPerIngest = 2000
if len(points) > maxPointsPerIngest {
points = points[:maxPointsPerIngest]
}
return store.UpsertPoints(ctx, points)
}