package queryplan import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "math" "regexp" "sort" "strings" "time" "github.com/itworx/pulse/internal/auth" "github.com/itworx/pulse/internal/metriccatalog" ) var scopeValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`) var scopeAliases = map[string]string{ "entityId": "entity_id", "serverId": "instance", "containerId": "container", "diskId": "device", "poolId": "pool", "serviceId": "service_id", "probeId": "probe_id", } var allowedAggregations = map[string]struct{}{"none": {}, "avg": {}, "sum": {}, "min": {}, "max": {}, "rate": {}, "increase": {}, "p50": {}, "p95": {}, "p99": {}} const ( defaultMaxSeries = 20 defaultMaxPoints = 4000 maxGroupBy = 30 maxScope = 20 maxCostPoints = int64(10_000_000) ) type Range struct { From time.Time `json:"from"` To time.Time `json:"to"` StepSeconds int `json:"stepSeconds"` } type Request struct { Metric string `json:"metric"` Scope map[string]string `json:"scope,omitempty"` Range Range `json:"range"` Aggregation string `json:"aggregation,omitempty"` GroupBy []string `json:"groupBy,omitempty"` MaxSeries int `json:"maxSeries,omitempty"` MaxPoints int `json:"maxPoints,omitempty"` } type Limits struct { MaxSeries int MaxPoints int MaxCostPoints int64 } type Cost struct { Series int `json:"series"` Points int `json:"points"` EstimatedSamples int64 `json:"estimatedSamples"` } type Plan struct { Metric metriccatalog.Definition `json:"metric"` Request Request `json:"request"` ResolvedScope map[string]string `json:"resolvedScope,omitempty"` Cost Cost `json:"cost"` CacheKey string `json:"cacheKey"` } type Error struct { Code string Field string Detail string } func (e Error) Error() string { if e.Field == "" { return e.Code + ": " + e.Detail } return e.Code + " (" + e.Field + "): " + e.Detail } var ErrUnauthorized = Error{Code: "QUERY_UNAUTHORIZED", Detail: "a view-authorized principal is required"} func (p Planner) CatalogVersion() string { return p.registry.Version() } func NewPlanner(registry metriccatalog.Registry, limits Limits) Planner { if limits.MaxSeries == 0 { limits.MaxSeries = defaultMaxSeries } if limits.MaxPoints == 0 { limits.MaxPoints = defaultMaxPoints } if limits.MaxCostPoints == 0 { limits.MaxCostPoints = maxCostPoints } return Planner{registry: registry, limits: limits} } type Planner struct { registry metriccatalog.Registry limits Limits } func (p Planner) Plan(ctx context.Context, request Request) (Plan, error) { if err := ctx.Err(); err != nil { return Plan{}, err } principal, ok := auth.PrincipalFromContext(ctx) if !ok || !auth.Allows(principal.Role, auth.PermissionView) { return Plan{}, ErrUnauthorized } if err := ctx.Err(); err != nil { return Plan{}, err } metricName := strings.TrimSpace(request.Metric) if metricName == "" { return Plan{}, Error{Code: "QUERY_METRIC_REQUIRED", Field: "metric", Detail: "metric is required"} } metric, ok := p.registry.Find(metricName) if !ok { return Plan{}, Error{Code: "QUERY_METRIC_UNKNOWN", Field: "metric", Detail: "metric is not in the approved catalog"} } resolvedScope, err := normalizeScope(request.Scope, metric.AllowedLabels) if err != nil { return Plan{}, err } groupBy, err := normalizeGroupBy(request.GroupBy, metric.AllowedLabels) if err != nil { return Plan{}, err } aggregation := request.Aggregation if aggregation == "" { aggregation = requestMetricDefault(metric) } if _, ok := allowedAggregations[aggregation]; !ok { return Plan{}, Error{Code: "QUERY_AGGREGATION_INVALID", Field: "aggregation", Detail: "aggregation is not allowed"} } if err := validateRange(request.Range, metric.Limits.MaxRangeSeconds); err != nil { return Plan{}, err } maxSeries := request.MaxSeries if maxSeries == 0 { maxSeries = min(defaultMaxSeries, metric.Limits.MaxSeries) } maxPoints := request.MaxPoints if maxPoints == 0 { maxPoints = min(defaultMaxPoints, metric.Limits.MaxPoints) } if maxSeries < 1 || maxSeries > metric.Limits.MaxSeries || maxSeries > p.limits.MaxSeries || maxSeries > metric.CardinalityBudget { return Plan{}, Error{Code: "QUERY_SERIES_LIMIT", Field: "maxSeries", Detail: "requested series exceed the metric or planner budget"} } if maxPoints < 10 || maxPoints > metric.Limits.MaxPoints || maxPoints > p.limits.MaxPoints { return Plan{}, Error{Code: "QUERY_POINT_LIMIT", Field: "maxPoints", Detail: "requested points exceed the metric or planner budget"} } estimatedPoints := int64(math.Ceil(request.Range.To.Sub(request.Range.From).Seconds() / float64(request.Range.StepSeconds))) if estimatedPoints < 1 || estimatedPoints > int64(maxPoints) { return Plan{}, Error{Code: "QUERY_POINT_LIMIT", Field: "range", Detail: "range and step exceed the point budget"} } estimatedSamples := estimatedPoints * int64(maxSeries) if estimatedSamples > p.limits.MaxCostPoints { return Plan{}, Error{Code: "QUERY_COST_LIMIT", Field: "range", Detail: "estimated query cost exceeds the planner budget"} } normalized := Request{Metric: metric.SemanticName, Scope: resolvedScope, Range: Range{From: request.Range.From.UTC(), To: request.Range.To.UTC(), StepSeconds: request.Range.StepSeconds}, Aggregation: aggregation, GroupBy: groupBy, MaxSeries: maxSeries, MaxPoints: maxPoints} keyBytes, err := json.Marshal(normalized) if err != nil { return Plan{}, fmt.Errorf("normalize query: %w", err) } digest := sha256.Sum256(keyBytes) return Plan{Metric: metric, Request: normalized, ResolvedScope: resolvedScope, Cost: Cost{Series: maxSeries, Points: int(estimatedPoints), EstimatedSamples: estimatedSamples}, CacheKey: hex.EncodeToString(digest[:])}, nil } func normalizeScope(scope map[string]string, allowedLabels []string) (map[string]string, error) { if len(scope) > maxScope { return nil, Error{Code: "QUERY_SCOPE_LIMIT", Field: "scope", Detail: "scope has too many selectors"} } resolved := make(map[string]string, len(scope)) allowed := make(map[string]struct{}, len(allowedLabels)) for _, label := range allowedLabels { allowed[label] = struct{}{} } for alias, value := range scope { label, ok := scopeAliases[alias] if !ok { return nil, Error{Code: "QUERY_SCOPE_INVALID", Field: "scope." + alias, Detail: "scope alias is not allowed"} } if _, allowedLabel := allowed[label]; !allowedLabel { return nil, Error{Code: "QUERY_SCOPE_INVALID", Field: "scope." + alias, Detail: "scope alias is not supported by the metric"} } if !scopeValuePattern.MatchString(value) { return nil, Error{Code: "QUERY_SCOPE_INVALID", Field: "scope." + alias, Detail: "scope value contains unsupported characters"} } if previous, exists := resolved[label]; exists && previous != value { return nil, Error{Code: "QUERY_SCOPE_CONFLICT", Field: "scope." + alias, Detail: "scope aliases resolve to conflicting values"} } resolved[label] = value } return resolved, nil } func normalizeGroupBy(groupBy, allowed []string) ([]string, error) { if len(groupBy) > maxGroupBy { return nil, Error{Code: "QUERY_GROUPING_LIMIT", Field: "groupBy", Detail: "too many grouping labels"} } allowedSet := make(map[string]struct{}, len(allowed)) for _, label := range allowed { allowedSet[label] = struct{}{} } result := append([]string(nil), groupBy...) seen := make(map[string]struct{}, len(result)) for _, label := range result { if _, ok := allowedSet[label]; !ok { return nil, Error{Code: "QUERY_LABEL_INVALID", Field: "groupBy", Detail: "label is not allowed for the metric"} } if _, ok := seen[label]; ok { return nil, Error{Code: "QUERY_LABEL_DUPLICATE", Field: "groupBy", Detail: "grouping labels must be unique"} } seen[label] = struct{}{} } sort.Strings(result) return result, nil } func validateRange(queryRange Range, maxRangeSeconds int) error { if queryRange.From.IsZero() || queryRange.To.IsZero() || !queryRange.From.Before(queryRange.To) { return Error{Code: "QUERY_RANGE_INVALID", Field: "range", Detail: "from must be before to"} } if queryRange.From.Location() == nil || queryRange.To.Location() == nil { return Error{Code: "QUERY_RANGE_INVALID", Field: "range", Detail: "timestamps must include a timezone"} } if queryRange.To.Sub(queryRange.From) > time.Duration(maxRangeSeconds)*time.Second { return Error{Code: "QUERY_RANGE_LIMIT", Field: "range", Detail: "range exceeds the metric limit"} } if queryRange.StepSeconds < 1 || queryRange.StepSeconds > 86400 { return Error{Code: "QUERY_STEP_INVALID", Field: "range.stepSeconds", Detail: "step must be between 1 second and 24 hours"} } return nil } func requestMetricDefault(metric metriccatalog.Definition) string { if metric.DefaultAggregation == "" { return "none" } return metric.DefaultAggregation } func min(a, b int) int { if a < b { return a } return b }