This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package queryplan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
func plannerForTest(t *testing.T, limits Limits) Planner {
|
||||
t.Helper()
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewPlanner(registry, limits)
|
||||
}
|
||||
func viewerContext() context.Context {
|
||||
return auth.WithPrincipal(context.Background(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer})
|
||||
}
|
||||
func validRequest() Request {
|
||||
now := time.Date(2026, 8, 1, 12, 0, 0, 0, time.FixedZone("CEST", 2*60*60))
|
||||
return Request{Metric: "container.cpu.utilization", Scope: map[string]string{"containerId": "media_server"}, Range: Range{From: now.Add(-time.Hour), To: now, StepSeconds: 60}, Aggregation: "avg", GroupBy: []string{"image", "container"}}
|
||||
}
|
||||
|
||||
func TestPlannerRequiresAuthorizationBeforeMetricResolution(t *testing.T) {
|
||||
planner := plannerForTest(t, Limits{})
|
||||
request := validRequest()
|
||||
request.Metric = "not.approved"
|
||||
if _, err := planner.Plan(context.Background(), request); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerNormalizesEquivalentRequestsDeterministically(t *testing.T) {
|
||||
planner := plannerForTest(t, Limits{})
|
||||
first := validRequest()
|
||||
second := validRequest()
|
||||
second.GroupBy = []string{"container", "image"}
|
||||
second.Range.From = second.Range.From.UTC()
|
||||
second.Range.To = second.Range.To.UTC()
|
||||
firstPlan, err := planner.Plan(viewerContext(), first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPlan, err := planner.Plan(viewerContext(), second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstPlan.CacheKey != secondPlan.CacheKey {
|
||||
t.Fatalf("keys differ: %s/%s", firstPlan.CacheKey, secondPlan.CacheKey)
|
||||
}
|
||||
if firstPlan.Request.Scope["container"] != "media_server" || firstPlan.Cost.Points != 60 || firstPlan.Cost.EstimatedSamples != 1200 {
|
||||
t.Fatalf("unexpected normalized plan: %+v", firstPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerRejectsInvalidScopeLabelsAndGrouping(t *testing.T) {
|
||||
planner := plannerForTest(t, Limits{})
|
||||
request := validRequest()
|
||||
request.Scope = map[string]string{"diskId": "disk-1"}
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_SCOPE_INVALID" {
|
||||
t.Fatalf("scope error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
request.GroupBy = []string{"container", "not_allowed"}
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_LABEL_INVALID" {
|
||||
t.Fatalf("group error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
request.GroupBy = []string{"container", "container"}
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_LABEL_DUPLICATE" {
|
||||
t.Fatalf("duplicate error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
request.Scope = map[string]string{"containerId": "bad value"}
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_SCOPE_INVALID" {
|
||||
t.Fatalf("value error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerEnforcesRangePointSeriesAndCostBudgets(t *testing.T) {
|
||||
request := validRequest()
|
||||
request.Range.From = request.Range.To.Add(-31 * 24 * time.Hour)
|
||||
planner := plannerForTest(t, Limits{})
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_RANGE_LIMIT" {
|
||||
t.Fatalf("range error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
request.MaxPoints = 10
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_POINT_LIMIT" {
|
||||
t.Fatalf("point error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
request.MaxSeries = 201
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_SERIES_LIMIT" {
|
||||
t.Fatalf("series error=%v", err)
|
||||
}
|
||||
request = validRequest()
|
||||
planner = plannerForTest(t, Limits{MaxCostPoints: 100})
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_COST_LIMIT" {
|
||||
t.Fatalf("cost error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerHonorsCancellationAndStepBounds(t *testing.T) {
|
||||
planner := plannerForTest(t, Limits{})
|
||||
canceled, cancel := context.WithCancel(viewerContext())
|
||||
cancel()
|
||||
if _, err := planner.Plan(canceled, validRequest()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel error=%v", err)
|
||||
}
|
||||
request := validRequest()
|
||||
request.Range.StepSeconds = 0
|
||||
if _, err := planner.Plan(viewerContext(), request); err == nil || err.(Error).Code != "QUERY_STEP_INVALID" {
|
||||
t.Fatalf("step error=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user