From 1993fa3b2e0b94af26433c3c24e2134ae697282c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:28:40 +0000 Subject: [PATCH 1/4] Initial plan From ce9d74a2685e936eda40888368904058ca220d42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:34:22 +0000 Subject: [PATCH 2/4] feat: add worktime scoring strategy for Prometheus discovery Implements a strategy pattern for time-based weighting of Prometheus range query data points. The WorktimeStrategy allows configuring time-of-day windows with weight multipliers, so images discovered during business hours score higher than those discovered at night. API additions: - ScoringStrategy type with worktime variant - WorktimeStrategy with configurable windows and timezone - WorktimeWindow with startHour, endHour, weight Implementation: - ScoreWeighter interface in internal/discovery/scoring.go - worktimeWeighter implementation with timezone support - Integration into aggregateRangeValues via optional weighter param - Controller wiring in buildSource Closes #53 --- api/v1alpha1/discoverypolicy_types.go | 55 ++++++++ api/v1alpha1/zz_generated.deepcopy.go | 60 ++++++++ .../drop.corewire.io_discoverypolicies.yaml | 67 +++++++++ .../controller/discoverypolicy_controller.go | 6 +- internal/discovery/prometheus.go | 33 ++++- internal/discovery/prometheus_test.go | 6 +- internal/discovery/scoring.go | 85 +++++++++++ internal/discovery/scoring_test.go | 133 ++++++++++++++++++ 8 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 internal/discovery/scoring.go create mode 100644 internal/discovery/scoring_test.go diff --git a/api/v1alpha1/discoverypolicy_types.go b/api/v1alpha1/discoverypolicy_types.go index 14b87fd..3a6646f 100644 --- a/api/v1alpha1/discoverypolicy_types.go +++ b/api/v1alpha1/discoverypolicy_types.go @@ -125,6 +125,61 @@ type PrometheusSource struct { // Default: 5m. Example: "1m", "15m" // +optional Step *metav1.Duration `json:"step,omitempty"` + // ScoringStrategy applies a weighting function to data points before aggregation. + // Use this to give higher importance to data points that occur during specific time windows + // (e.g., working hours). Only used when queryType is "range". + // +optional + ScoringStrategy *ScoringStrategy `json:"scoringStrategy,omitempty"` +} + +// ScoringStrategy configures how data point values are weighted before aggregation. +type ScoringStrategy struct { + // Type identifies the scoring strategy. Must be "worktime". + // +kubebuilder:validation:Enum=worktime + Type ScoringStrategyType `json:"type"` + // Worktime contains the configuration when type=worktime. + // +optional + Worktime *WorktimeStrategy `json:"worktime,omitempty"` +} + +// ScoringStrategyType identifies the scoring strategy. +// +kubebuilder:validation:Enum=worktime +type ScoringStrategyType string + +const ( + // ScoringStrategyWorktime weights data points based on the time of day they occurred. + ScoringStrategyWorktime ScoringStrategyType = "worktime" +) + +// WorktimeStrategy weights data points based on configurable time-of-day windows. +// Each window defines an hour range and a weight multiplier. Data points outside +// all defined windows receive zero weight by default. +type WorktimeStrategy struct { + // Windows defines the time-of-day windows and their weight multipliers. + // Hours are in 24h format (0-23) interpreted in the configured timezone. + // Windows must not overlap. Data points outside all windows have weight 0. + // +kubebuilder:validation:MinItems=1 + Windows []WorktimeWindow `json:"windows"` + // Timezone is the IANA timezone name used to interpret window hours. + // Default: "UTC". Example: "Europe/Berlin", "America/New_York" + // +kubebuilder:default="UTC" + // +optional + Timezone string `json:"timezone,omitempty"` +} + +// WorktimeWindow defines a single time-of-day window with a weight multiplier. +type WorktimeWindow struct { + // StartHour is the beginning of the window (inclusive, 0-23). + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=23 + StartHour int32 `json:"startHour"` + // EndHour is the end of the window (exclusive, 1-24). Must be greater than startHour. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=24 + EndHour int32 `json:"endHour"` + // Weight is the multiplier applied to data point values within this window. + // Example: "1.0" (full weight), "0.3" (reduced), "0.0" (ignored) + Weight string `json:"weight"` } // RegistrySource defines OCI registry tag listing configuration for image discovery. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index eafb2e1..5d020d9 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -522,6 +522,11 @@ func (in *PrometheusSource) DeepCopyInto(out *PrometheusSource) { *out = new(metav1.Duration) **out = **in } + if in.ScoringStrategy != nil { + in, out := &in.ScoringStrategy, &out.ScoringStrategy + *out = new(ScoringStrategy) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrometheusSource. @@ -651,3 +656,58 @@ func (in *RegistrySource) DeepCopy() *RegistrySource { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScoringStrategy) DeepCopyInto(out *ScoringStrategy) { + *out = *in + if in.Worktime != nil { + in, out := &in.Worktime, &out.Worktime + *out = new(WorktimeStrategy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScoringStrategy. +func (in *ScoringStrategy) DeepCopy() *ScoringStrategy { + if in == nil { + return nil + } + out := new(ScoringStrategy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorktimeStrategy) DeepCopyInto(out *WorktimeStrategy) { + *out = *in + if in.Windows != nil { + in, out := &in.Windows, &out.Windows + *out = make([]WorktimeWindow, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorktimeStrategy. +func (in *WorktimeStrategy) DeepCopy() *WorktimeStrategy { + if in == nil { + return nil + } + out := new(WorktimeStrategy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorktimeWindow) DeepCopyInto(out *WorktimeWindow) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorktimeWindow. +func (in *WorktimeWindow) DeepCopy() *WorktimeWindow { + if in == nil { + return nil + } + out := new(WorktimeWindow) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/drop.corewire.io_discoverypolicies.yaml b/config/crd/bases/drop.corewire.io_discoverypolicies.yaml index a1183f2..b77463b 100644 --- a/config/crd/bases/drop.corewire.io_discoverypolicies.yaml +++ b/config/crd/bases/drop.corewire.io_discoverypolicies.yaml @@ -132,6 +132,73 @@ spec: - range - instant type: string + scoringStrategy: + description: |- + ScoringStrategy applies a weighting function to data points before aggregation. + Use this to give higher importance to data points that occur during specific time windows + (e.g., working hours). Only used when queryType is "range". + properties: + type: + allOf: + - enum: + - worktime + - enum: + - worktime + description: Type identifies the scoring strategy. Must + be "worktime". + type: string + worktime: + description: Worktime contains the configuration when + type=worktime. + properties: + timezone: + default: UTC + description: |- + Timezone is the IANA timezone name used to interpret window hours. + Default: "UTC". Example: "Europe/Berlin", "America/New_York" + type: string + windows: + description: |- + Windows defines the time-of-day windows and their weight multipliers. + Hours are in 24h format (0-23) interpreted in the configured timezone. + Windows must not overlap. Data points outside all windows have weight 0. + items: + description: WorktimeWindow defines a single time-of-day + window with a weight multiplier. + properties: + endHour: + description: EndHour is the end of the window + (exclusive, 1-24). Must be greater than + startHour. + format: int32 + maximum: 24 + minimum: 1 + type: integer + startHour: + description: StartHour is the beginning of + the window (inclusive, 0-23). + format: int32 + maximum: 23 + minimum: 0 + type: integer + weight: + description: |- + Weight is the multiplier applied to data point values within this window. + Example: "1.0" (full weight), "0.3" (reduced), "0.0" (ignored) + type: string + required: + - endHour + - startHour + - weight + type: object + minItems: 1 + type: array + required: + - windows + type: object + required: + - type + type: object step: description: |- Step is the resolution step for range queries (only used when lookback is set). diff --git a/internal/controller/discoverypolicy_controller.go b/internal/controller/discoverypolicy_controller.go index c801165..774e9b1 100644 --- a/internal/controller/discoverypolicy_controller.go +++ b/internal/controller/discoverypolicy_controller.go @@ -250,7 +250,11 @@ func (r *DiscoveryPolicyReconciler) buildSource(ctx context.Context, src dropv1a if src.Prometheus.Step != nil { step = src.Prometheus.Step.Duration } - return discovery.NewPrometheusSource(src.Prometheus.Endpoint, src.Prometheus.Query, src.Prometheus.QueryType, lookback, src.Prometheus.AggregationMethod, step, httpClient), nil + weighter, err := discovery.NewScoreWeighter(src.Prometheus.ScoringStrategy) + if err != nil { + return nil, fmt.Errorf("building scoring strategy: %w", err) + } + return discovery.NewPrometheusSource(src.Prometheus.Endpoint, src.Prometheus.Query, src.Prometheus.QueryType, lookback, src.Prometheus.AggregationMethod, step, weighter, httpClient), nil case "registry": if src.Registry == nil { return nil, fmt.Errorf("registry config is required when type=registry") diff --git a/internal/discovery/prometheus.go b/internal/discovery/prometheus.go index 94423f8..3d4d1db 100644 --- a/internal/discovery/prometheus.go +++ b/internal/discovery/prometheus.go @@ -23,11 +23,12 @@ type PrometheusSource struct { Lookback time.Duration // time window for range queries AggregationMethod *dropv1alpha1.AggregationMethod // nil = use last value; sum, count, avg, max Step time.Duration // resolution step for range queries (default 5m) + Weighter ScoreWeighter // optional time-based weighting strategy HTTPClient *http.Client } // NewPrometheusSource creates a new Prometheus discovery source. -func NewPrometheusSource(endpoint, query string, queryType dropv1alpha1.QueryType, lookback time.Duration, aggregationMethod *dropv1alpha1.AggregationMethod, step time.Duration, httpClient *http.Client) *PrometheusSource { +func NewPrometheusSource(endpoint, query string, queryType dropv1alpha1.QueryType, lookback time.Duration, aggregationMethod *dropv1alpha1.AggregationMethod, step time.Duration, weighter ScoreWeighter, httpClient *http.Client) *PrometheusSource { if httpClient == nil { httpClient = &http.Client{Timeout: 30 * time.Second} } @@ -44,6 +45,7 @@ func NewPrometheusSource(endpoint, query string, queryType dropv1alpha1.QueryTyp Lookback: lookback, AggregationMethod: aggregationMethod, Step: step, + Weighter: weighter, HTTPClient: httpClient, } } @@ -121,7 +123,7 @@ func (p *PrometheusSource) Fetch(ctx context.Context) ([]ImageResult, error) { var score int64 if p.QueryType == dropv1alpha1.QueryTypeRange { // Range query: aggregate values according to configured method (nil = last value) - score = aggregateRangeValues(r.Values, p.AggregationMethod) + score = aggregateRangeValues(r.Values, p.AggregationMethod, p.Weighter) } else { // Instant query: use single value score = extractScore(r.Value) @@ -159,7 +161,8 @@ func extractScore(value []interface{}) int64 { // aggregateRangeValues aggregates all values from a query_range result using the specified method. // When method is nil, the last data-point value is used directly (no aggregation). -func aggregateRangeValues(values [][]interface{}, method *dropv1alpha1.AggregationMethod) int64 { +// When weighter is non-nil, each data point value is multiplied by the weight for its timestamp. +func aggregateRangeValues(values [][]interface{}, method *dropv1alpha1.AggregationMethod, weighter ScoreWeighter) int64 { // nil = no aggregation, use last data-point value directly if method == nil { if len(values) == 0 { @@ -177,6 +180,10 @@ func aggregateRangeValues(values [][]interface{}, method *dropv1alpha1.Aggregati if _, err := fmt.Sscanf(strVal, "%f", &v); err != nil { return 0 } + if weighter != nil { + ts := extractTimestamp(lastPair[0]) + v *= weighter.Weight(ts) + } return int64(v) } @@ -197,6 +204,10 @@ func aggregateRangeValues(values [][]interface{}, method *dropv1alpha1.Aggregati if _, err := fmt.Sscanf(strVal, "%f", &v); err != nil { continue } + if weighter != nil { + ts := extractTimestamp(pair[0]) + v *= weighter.Weight(ts) + } total += v count++ if !maxSet || v > max { @@ -219,3 +230,19 @@ func aggregateRangeValues(values [][]interface{}, method *dropv1alpha1.Aggregati return int64(total) } } + +// extractTimestamp parses a Unix timestamp from a Prometheus data point. +func extractTimestamp(raw interface{}) time.Time { + switch v := raw.(type) { + case float64: + return time.Unix(int64(v), 0).UTC() + case json.Number: + f, err := v.Float64() + if err != nil { + return time.Time{} + } + return time.Unix(int64(f), 0).UTC() + default: + return time.Time{} + } +} diff --git a/internal/discovery/prometheus_test.go b/internal/discovery/prometheus_test.go index e5157c5..dad2a39 100644 --- a/internal/discovery/prometheus_test.go +++ b/internal/discovery/prometheus_test.go @@ -106,7 +106,7 @@ func TestPrometheusSource_Fetch_Instant(t *testing.T) { })) defer server.Close() - source := NewPrometheusSource(server.URL, "test_query", dropv1alpha1.QueryTypeInstant, 0, nil, 0, server.Client()) + source := NewPrometheusSource(server.URL, "test_query", dropv1alpha1.QueryTypeInstant, 0, nil, 0, nil, server.Client()) results, err := source.Fetch(context.Background()) if tt.wantErr { @@ -287,7 +287,7 @@ func TestPrometheusSource_Fetch_Range(t *testing.T) { })) defer server.Close() - source := NewPrometheusSource(server.URL, "test_query", dropv1alpha1.QueryTypeRange, time.Hour, tt.aggregationMethod, 5*time.Minute, server.Client()) + source := NewPrometheusSource(server.URL, "test_query", dropv1alpha1.QueryTypeRange, time.Hour, tt.aggregationMethod, 5*time.Minute, nil, server.Client()) results, err := source.Fetch(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -323,7 +323,7 @@ func TestPrometheusSource_DefaultQueryType(t *testing.T) { defer server.Close() // Empty queryType should default to range - source := NewPrometheusSource(server.URL, "test_query", "", time.Hour, nil, 0, server.Client()) + source := NewPrometheusSource(server.URL, "test_query", "", time.Hour, nil, 0, nil, server.Client()) if source.QueryType != dropv1alpha1.QueryTypeRange { t.Errorf("default QueryType = %q, want %q", source.QueryType, dropv1alpha1.QueryTypeRange) } diff --git a/internal/discovery/scoring.go b/internal/discovery/scoring.go new file mode 100644 index 0000000..544bd87 --- /dev/null +++ b/internal/discovery/scoring.go @@ -0,0 +1,85 @@ +package discovery + +import ( + "fmt" + "strconv" + "time" + + dropv1alpha1 "github.com/corewire/drop/api/v1alpha1" +) + +// ScoreWeighter applies a time-based weight to data point values during aggregation. +type ScoreWeighter interface { + // Weight returns the multiplier for a data point at the given timestamp. + Weight(t time.Time) float64 +} + +// NewScoreWeighter builds a ScoreWeighter from the API scoring strategy config. +// Returns nil if strategy is nil (no weighting applied). +func NewScoreWeighter(strategy *dropv1alpha1.ScoringStrategy) (ScoreWeighter, error) { + if strategy == nil { + return nil, nil + } + switch strategy.Type { + case dropv1alpha1.ScoringStrategyWorktime: + if strategy.Worktime == nil { + return nil, fmt.Errorf("worktime config is required when type=worktime") + } + return newWorktimeWeighter(strategy.Worktime) + default: + return nil, fmt.Errorf("unsupported scoring strategy type: %s", strategy.Type) + } +} + +// worktimeWeighter weights data points based on time-of-day windows. +type worktimeWeighter struct { + windows []worktimeWindow + loc *time.Location +} + +type worktimeWindow struct { + startHour int + endHour int + weight float64 +} + +func newWorktimeWeighter(cfg *dropv1alpha1.WorktimeStrategy) (*worktimeWeighter, error) { + tz := cfg.Timezone + if tz == "" { + tz = "UTC" + } + loc, err := time.LoadLocation(tz) + if err != nil { + return nil, fmt.Errorf("loading timezone %q: %w", tz, err) + } + + windows := make([]worktimeWindow, 0, len(cfg.Windows)) + for _, w := range cfg.Windows { + weight, err := strconv.ParseFloat(w.Weight, 64) + if err != nil { + return nil, fmt.Errorf("parsing weight %q: %w", w.Weight, err) + } + windows = append(windows, worktimeWindow{ + startHour: int(w.StartHour), + endHour: int(w.EndHour), + weight: weight, + }) + } + + return &worktimeWeighter{ + windows: windows, + loc: loc, + }, nil +} + +// Weight returns the multiplier for the given timestamp based on configured windows. +// Returns 0 if the timestamp doesn't fall within any window. +func (w *worktimeWeighter) Weight(t time.Time) float64 { + hour := t.In(w.loc).Hour() + for _, win := range w.windows { + if hour >= win.startHour && hour < win.endHour { + return win.weight + } + } + return 0 +} diff --git a/internal/discovery/scoring_test.go b/internal/discovery/scoring_test.go new file mode 100644 index 0000000..bc2f654 --- /dev/null +++ b/internal/discovery/scoring_test.go @@ -0,0 +1,133 @@ +package discovery + +import ( + "testing" + "time" + + dropv1alpha1 "github.com/corewire/drop/api/v1alpha1" +) + +func TestWorktimeWeighter_Weight(t *testing.T) { + strategy := &dropv1alpha1.ScoringStrategy{ + Type: dropv1alpha1.ScoringStrategyWorktime, + Worktime: &dropv1alpha1.WorktimeStrategy{ + Timezone: "UTC", + Windows: []dropv1alpha1.WorktimeWindow{ + {StartHour: 9, EndHour: 17, Weight: "1.0"}, + {StartHour: 6, EndHour: 9, Weight: "0.3"}, + {StartHour: 17, EndHour: 19, Weight: "0.3"}, + }, + }, + } + + weighter, err := NewScoreWeighter(strategy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + tests := []struct { + name string + hour int + want float64 + }{ + {"peak hours 9am", 9, 1.0}, + {"peak hours 12pm", 12, 1.0}, + {"peak hours 16pm", 16, 1.0}, + {"early morning 6am", 6, 0.3}, + {"early morning 8am", 8, 0.3}, + {"evening 17pm", 17, 0.3}, + {"evening 18pm", 18, 0.3}, + {"night 0am", 0, 0.0}, + {"night 3am", 3, 0.0}, + {"night 5am", 5, 0.0}, + {"late night 20pm", 20, 0.0}, + {"late night 23pm", 23, 0.0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := time.Date(2025, 1, 15, tt.hour, 30, 0, 0, time.UTC) + got := weighter.Weight(ts) + if got != tt.want { + t.Errorf("Weight at hour %d = %f, want %f", tt.hour, got, tt.want) + } + }) + } +} + +func TestWorktimeWeighter_Timezone(t *testing.T) { + strategy := &dropv1alpha1.ScoringStrategy{ + Type: dropv1alpha1.ScoringStrategyWorktime, + Worktime: &dropv1alpha1.WorktimeStrategy{ + Timezone: "Europe/Berlin", + Windows: []dropv1alpha1.WorktimeWindow{ + {StartHour: 9, EndHour: 17, Weight: "1.0"}, + }, + }, + } + + weighter, err := NewScoreWeighter(strategy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // 10:00 UTC = 11:00 Berlin (CET, winter) — should be in window + ts := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC) + if got := weighter.Weight(ts); got != 1.0 { + t.Errorf("Weight at 10:00 UTC (11:00 Berlin) = %f, want 1.0", got) + } + + // 07:00 UTC = 08:00 Berlin (CET, winter) — should be outside window + ts = time.Date(2025, 1, 15, 7, 0, 0, 0, time.UTC) + if got := weighter.Weight(ts); got != 0.0 { + t.Errorf("Weight at 07:00 UTC (08:00 Berlin) = %f, want 0.0", got) + } +} + +func TestNewScoreWeighter_Nil(t *testing.T) { + weighter, err := NewScoreWeighter(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if weighter != nil { + t.Error("expected nil weighter for nil strategy") + } +} + +func TestAggregateRangeValues_WithWorktime(t *testing.T) { + strategy := &dropv1alpha1.ScoringStrategy{ + Type: dropv1alpha1.ScoringStrategyWorktime, + Worktime: &dropv1alpha1.WorktimeStrategy{ + Timezone: "UTC", + Windows: []dropv1alpha1.WorktimeWindow{ + {StartHour: 9, EndHour: 17, Weight: "1.0"}, + {StartHour: 0, EndHour: 9, Weight: "0.0"}, + }, + }, + } + + weighter, err := NewScoreWeighter(strategy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Timestamps: 10:00 (weight 1.0), 03:00 (weight 0.0), 12:00 (weight 1.0) + values := [][]interface{}{ + {float64(time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC).Unix()), "100"}, + {float64(time.Date(2025, 1, 15, 3, 0, 0, 0, time.UTC).Unix()), "200"}, + {float64(time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC).Unix()), "50"}, + } + + method := dropv1alpha1.AggregationSum + // Expected: 100*1.0 + 200*0.0 + 50*1.0 = 150 + got := aggregateRangeValues(values, &method, weighter) + if got != 150 { + t.Errorf("aggregateRangeValues with worktime weighting = %d, want 150", got) + } + + // Without weighter: 100 + 200 + 50 = 350 + got = aggregateRangeValues(values, &method, nil) + if got != 350 { + t.Errorf("aggregateRangeValues without weighting = %d, want 350", got) + } +} From 19836f66164ee1d1071dc516d6016d94e3b789bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:12:07 +0000 Subject: [PATCH 3/4] test(e2e): add worktime scoring strategy chainsaw tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds Prometheus with images in the aggregation-test namespace and creates two DiscoveryPolicies: - e2e-worktime-full: weight=1.0 for all hours → positive scores - e2e-worktime-zero: weight=0.0 for all hours → scores suppressed to 0 The zero-weight policy demonstrates that images pulled outside desired time windows (e.g., nighttime) are suppressed and do not appear with meaningful scores in the discovery object. --- .../01-discoverypolicies.yaml | 62 ++++++++++++++++ .../02-assert-full-weight.yaml | 11 +++ .../03-assert-zero-weight.yaml | 15 ++++ .../e2e/discovery-worktime/chainsaw-test.yaml | 73 +++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 test/e2e/discovery-worktime/01-discoverypolicies.yaml create mode 100644 test/e2e/discovery-worktime/02-assert-full-weight.yaml create mode 100644 test/e2e/discovery-worktime/03-assert-zero-weight.yaml create mode 100644 test/e2e/discovery-worktime/chainsaw-test.yaml diff --git a/test/e2e/discovery-worktime/01-discoverypolicies.yaml b/test/e2e/discovery-worktime/01-discoverypolicies.yaml new file mode 100644 index 0000000..39626d7 --- /dev/null +++ b/test/e2e/discovery-worktime/01-discoverypolicies.yaml @@ -0,0 +1,62 @@ +# Two DiscoveryPolicies testing worktime scoring strategy. +# Both query the same seed metrics (aggregation-test namespace, sum aggregation). +# +# Policy 1 (e2e-worktime-full): window 0–24 with weight 1.0 +# → All data points get full weight, scores are normal positive values. +# +# Policy 2 (e2e-worktime-zero): window 0–24 with weight 0.0 +# → All data points get zero weight, simulating "nighttime" suppression. +# → Images still appear in results but with score=0 (suppressed). +# +# This also seeds busybox (the lower-scoring image) to demonstrate that +# images we do NOT want to prioritize get suppressed by the strategy. +--- +apiVersion: drop.corewire.io/v1alpha1 +kind: DiscoveryPolicy +metadata: + name: e2e-worktime-full +spec: + sources: + - type: prometheus + prometheus: + endpoint: "http://prometheus.e2e-infra.svc.cluster.local:9090" + query: 'sum(container_cpu_usage_seconds_total{namespace="aggregation-test"}) by (image)' + queryType: range + lookback: 1h + step: 5m + aggregationMethod: sum + scoringStrategy: + type: worktime + worktime: + timezone: "UTC" + windows: + - startHour: 0 + endHour: 24 + weight: "1.0" + syncInterval: 30s + maxImages: 10 +--- +apiVersion: drop.corewire.io/v1alpha1 +kind: DiscoveryPolicy +metadata: + name: e2e-worktime-zero +spec: + sources: + - type: prometheus + prometheus: + endpoint: "http://prometheus.e2e-infra.svc.cluster.local:9090" + query: 'sum(container_cpu_usage_seconds_total{namespace="aggregation-test"}) by (image)' + queryType: range + lookback: 1h + step: 5m + aggregationMethod: sum + scoringStrategy: + type: worktime + worktime: + timezone: "UTC" + windows: + - startHour: 0 + endHour: 24 + weight: "0.0" + syncInterval: 30s + maxImages: 10 diff --git a/test/e2e/discovery-worktime/02-assert-full-weight.yaml b/test/e2e/discovery-worktime/02-assert-full-weight.yaml new file mode 100644 index 0000000..3cc7288 --- /dev/null +++ b/test/e2e/discovery-worktime/02-assert-full-weight.yaml @@ -0,0 +1,11 @@ +# Assert full-weight worktime policy: Ready, both images discovered with positive scores. +# window 0-24 weight=1.0 means all data points keep their original value. +apiVersion: drop.corewire.io/v1alpha1 +kind: DiscoveryPolicy +metadata: + name: e2e-worktime-full +status: + (conditions[?type == 'Ready']): + - status: "True" + reason: Synced + imageCount: 2 diff --git a/test/e2e/discovery-worktime/03-assert-zero-weight.yaml b/test/e2e/discovery-worktime/03-assert-zero-weight.yaml new file mode 100644 index 0000000..dd2bb52 --- /dev/null +++ b/test/e2e/discovery-worktime/03-assert-zero-weight.yaml @@ -0,0 +1,15 @@ +# Assert zero-weight worktime policy: Ready, images discovered but all scores are 0. +# window 0-24 weight=0.0 simulates "nighttime" — all data points are suppressed. +# This demonstrates that images pulled outside business hours get zeroed out. +apiVersion: drop.corewire.io/v1alpha1 +kind: DiscoveryPolicy +metadata: + name: e2e-worktime-zero +status: + (conditions[?type == 'Ready']): + - status: "True" + reason: Synced + imageCount: 2 + discoveredImages: + - score: 0 + - score: 0 diff --git a/test/e2e/discovery-worktime/chainsaw-test.yaml b/test/e2e/discovery-worktime/chainsaw-test.yaml new file mode 100644 index 0000000..5c3f1bb --- /dev/null +++ b/test/e2e/discovery-worktime/chainsaw-test.yaml @@ -0,0 +1,73 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: discovery-worktime-scoring +spec: + description: | + Verify that DiscoveryPolicy scoringStrategy (worktime) correctly weights data points + by time of day before aggregation. + + Two policies query the same Prometheus metrics (aggregation-test namespace): + - e2e-worktime-full: window 0-24 with weight 1.0 → normal scores (positive) + - e2e-worktime-zero: window 0-24 with weight 0.0 → all scores become 0 + + This proves the worktime weighting multiplier is applied to data points during aggregation. + The zero-weight policy demonstrates that images outside desired time windows are suppressed. + steps: + - name: Create DiscoveryPolicies with worktime scoring strategies + try: + - apply: + file: 01-discoverypolicies.yaml + - name: Assert full-weight policy discovers images with positive scores + try: + - assert: + timeout: 90s + file: 02-assert-full-weight.yaml + - name: Assert zero-weight policy discovers images with score 0 + try: + - assert: + timeout: 90s + file: 03-assert-zero-weight.yaml + - name: Verify worktime scoring produces expected score relationships + try: + - script: + timeout: 30s + content: | + # Full-weight policy should produce positive scores + FULL_SCORE=$(kubectl get discoverypolicy e2e-worktime-full -o jsonpath='{.status.discoveredImages[0].score}') + # Zero-weight policy should produce zero scores + ZERO_SCORE=$(kubectl get discoverypolicy e2e-worktime-zero -o jsonpath='{.status.discoveredImages[0].score}') + + echo "Scores — full-weight:$FULL_SCORE zero-weight:$ZERO_SCORE" + + if [ -z "$FULL_SCORE" ]; then + echo "FAIL: full-weight policy has no discovered images" + exit 1 + fi + + if [ "$FULL_SCORE" -le 0 ]; then + echo "FAIL: full-weight policy score should be positive, got $FULL_SCORE" + exit 1 + fi + + if [ "$ZERO_SCORE" != "0" ]; then + echo "FAIL: zero-weight policy score should be 0, got $ZERO_SCORE" + exit 1 + fi + + echo "OK: worktime scoring produces correct score relationships" + echo " full-weight score=$FULL_SCORE (positive as expected)" + echo " zero-weight score=$ZERO_SCORE (zero as expected — nighttime images suppressed)" + - name: Cleanup + try: + - delete: + ref: + apiVersion: drop.corewire.io/v1alpha1 + kind: DiscoveryPolicy + name: e2e-worktime-full + - delete: + ref: + apiVersion: drop.corewire.io/v1alpha1 + kind: DiscoveryPolicy + name: e2e-worktime-zero From fb8b8f60726b15dd6fd671d077692b159b6ade5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:32:25 +0000 Subject: [PATCH 4/4] fix: gofmt formatting in scoring_test.go --- internal/discovery/scoring_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/discovery/scoring_test.go b/internal/discovery/scoring_test.go index bc2f654..28917d5 100644 --- a/internal/discovery/scoring_test.go +++ b/internal/discovery/scoring_test.go @@ -26,9 +26,9 @@ func TestWorktimeWeighter_Weight(t *testing.T) { } tests := []struct { - name string - hour int - want float64 + name string + hour int + want float64 }{ {"peak hours 9am", 9, 1.0}, {"peak hours 12pm", 12, 1.0},