From 1f3de7b5d7801ecdf2fefb6ee3c6a4bb1ea36811 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 27 Oct 2026 23:42:40 +0000 Subject: [PATCH] feat(api): report the next scheduled collection in recommendations freshness GET /api/recommendations/freshness now carries next_collection_at so the first-run empty state can say when recommendations will next be collected. The runtime did not know its own schedule, so every compute module that schedules collect_recommendations (Lambda, Fargate, Cloud Run, GKE, AKS, Container Apps) now passes it as RECOMMENDATION_SCHEDULE, empty when the scheduler resource is not created. A new internal/schedexpr package parses EventBridge rate()/cron() and five-field cron and computes the next UTC fire time. rate() schedules report null: EventBridge counts them from rule creation, which the server cannot see. Unset, disabled or unparsable schedules also report null. Container Apps passes the daily recurrence its Logic App really runs (hour field only), not the raw cron. Refs #756 Co-Authored-By: Claude Opus 5.5 --- .env.example | 1 + internal/api/handler.go | 7 + internal/api/handler_recommendations.go | 8 +- internal/api/openapi.yaml | 20 +- .../api/recommendations_next_collection.go | 40 +++ .../recommendations_next_collection_test.go | 76 +++++ internal/api/types.go | 5 + internal/config/types.go | 4 + internal/schedexpr/cron.go | 263 ++++++++++++++++++ internal/schedexpr/schedexpr.go | 105 +++++++ internal/schedexpr/schedexpr_test.go | 180 ++++++++++++ internal/server/app.go | 10 + internal/server/app_test.go | 18 ++ terraform/modules/compute/aws/fargate/main.tf | 7 + terraform/modules/compute/aws/lambda/main.tf | 4 + terraform/modules/compute/azure/aks/main.tf | 8 + .../compute/azure/container-apps/main.tf | 6 + .../modules/compute/gcp/cloud-run/main.tf | 4 + terraform/modules/compute/gcp/gke/main.tf | 8 + .../recommendation_schedule_env_test.go | 82 ++++++ 20 files changed, 853 insertions(+), 3 deletions(-) create mode 100644 internal/api/recommendations_next_collection.go create mode 100644 internal/api/recommendations_next_collection_test.go create mode 100644 internal/schedexpr/cron.go create mode 100644 internal/schedexpr/schedexpr.go create mode 100644 internal/schedexpr/schedexpr_test.go create mode 100644 terraform/modules/compute/recommendation_schedule_env_test.go diff --git a/.env.example b/.env.example index 46cd66add..daa4f877d 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,7 @@ DASHBOARD_URL=http://localhost:3000 # Optional: tunables # --------------------------------------------------------------------- # CUDLY_RECOMMENDATION_CACHE_TTL=15m +# RECOMMENDATION_SCHEDULE=cron(0 2 * * ? *) # Terraform sets it; drives next_collection_at # CUDLY_MAX_ACCOUNT_PARALLELISM=8 # DEFAULT_PAYMENT_OPTION=no-upfront # DEFAULT_RAMP_SCHEDULE=quarterly diff --git a/internal/api/handler.go b/internal/api/handler.go index c0ee31a23..b762b4d80 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -17,6 +17,7 @@ import ( "github.com/LeanerCloud/cloud-commitments-platform/internal/credentials" "github.com/LeanerCloud/cloud-commitments-platform/internal/email" "github.com/LeanerCloud/cloud-commitments-platform/internal/oidc" + "github.com/LeanerCloud/cloud-commitments-platform/internal/schedexpr" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -157,6 +158,11 @@ type Handler struct { // revoke handlers use the production Azure credential and client // constructors. azureRevokeFactory *azureRevokeClientFactory + + // collectionSchedule is HandlerConfig.RecommendationSchedule parsed once + // at construction. Nil when the schedule is unset or unparsable, which + // makes next_collection_at null. + collectionSchedule schedexpr.Schedule } // getRIUtilizationCache returns the Postgres-backed TTL cache for Cost @@ -210,6 +216,7 @@ func NewHandler(cfg HandlerConfig) *Handler { commitmentOpts: cfg.CommitmentOpts, encryptionKeySource: cfg.EncryptionKeySource, isLambda: cfg.IsLambda, + collectionSchedule: parseCollectionSchedule(cfg.RecommendationSchedule), } // Pre-load API key (with a 5s timeout to avoid stalling cold-start indefinitely) diff --git a/internal/api/handler_recommendations.go b/internal/api/handler_recommendations.go index 2be7f0268..54d10ea7b 100644 --- a/internal/api/handler_recommendations.go +++ b/internal/api/handler_recommendations.go @@ -332,7 +332,10 @@ func stampComputeCapacity(rec *config.RecommendationRecord) { // getRecommendationsFreshness returns the cache-freshness state (last // successful collection timestamp + most recent collection error) so the // frontend can render a "Data from min ago" indicator and surface a -// warning banner when the last collect was partial or failed. +// warning banner when the last collect was partial or failed. It also +// reports when the deployment's schedule fires next (next_collection_at, +// null when unknown; issue #756), computed from the schedule rather than +// stored. // // Gated by view:recommendations permission inside the handler. The route // itself inherits AuthAdmin (the router default) so the permission check @@ -346,6 +349,9 @@ func (h *Handler) getRecommendationsFreshness(ctx context.Context, req *events.L if err != nil { return nil, fmt.Errorf("failed to get recommendations freshness: %w", err) } + if freshness != nil { + freshness.NextCollectionAt = h.NextCollectionAt(h.clock()) + } return freshness, nil } diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index a7782101b..96ab1a29f 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -439,7 +439,8 @@ paths: collection error, whether a collection is in flight, and whether the last collection was partial (some subscriptions, projects or scopes could not be queried, so the stored recommendations under-state the - real opportunity). + real opportunity). It also reports when the deployment's schedule + next triggers a collection, or null when that is unknown. responses: '200': description: Freshness state @@ -6849,7 +6850,7 @@ components: RecommendationsFreshness: type: object - required: [last_collected_at, last_collection_error, last_collection_started_at, last_collection_partial] + required: [last_collected_at, last_collection_error, last_collection_started_at, last_collection_partial, next_collection_at] properties: last_collected_at: type: string @@ -6867,6 +6868,21 @@ components: description: Set while a collection is in flight. last_collection_partial: $ref: '#/components/schemas/RecommendationsCollectionPartial' + next_collection_at: + type: string + format: date-time + nullable: true + description: > + When the deployment's scheduler next triggers a recommendation + collection (UTC), computed from the schedule the deployment was + given (RECOMMENDATION_SCHEDULE) rather than stored. Null when + scheduled collection is off, the schedule is not set or not + understood, or the next run cannot be known: an EventBridge + rate() schedule counts from when its rule was created, which the + server cannot see, so rate() schedules always report null; a + cron() schedule gives an exact time. Manual refreshes and + on-read background refreshes are not included. + example: "2026-10-28T02:00:00Z" RecommendationsCollectionPartial: type: object diff --git a/internal/api/recommendations_next_collection.go b/internal/api/recommendations_next_collection.go new file mode 100644 index 000000000..64cbd9057 --- /dev/null +++ b/internal/api/recommendations_next_collection.go @@ -0,0 +1,40 @@ +package api + +import ( + "errors" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/LeanerCloud/cloud-commitments-platform/internal/schedexpr" +) + +// parseCollectionSchedule parses the deployment's recommendation-collection +// schedule for next_collection_at (issue #756). An empty expression means +// scheduled collection is off; an unparsable one is logged once per handler +// and treated the same way, so the API reports "unknown" instead of failing +// or guessing. +func parseCollectionSchedule(expr string) schedexpr.Schedule { + s, err := schedexpr.Parse(expr) + if err != nil { + if !errors.Is(err, schedexpr.ErrEmpty) { + logging.Warnf("RECOMMENDATION_SCHEDULE %q is not understood (%v); next_collection_at will be null", expr, err) + } + return nil + } + return s +} + +// NextCollectionAt returns the next scheduled collection strictly after +// now, or nil when it is unknown: no schedule, a rate() schedule (whose +// phase the runtime cannot see) or no fire time within the search horizon. +// Exported so the server package can check the schedule is wired through. +func (h *Handler) NextCollectionAt(now time.Time) *time.Time { + if h.collectionSchedule == nil { + return nil + } + next, ok := h.collectionSchedule.Next(now) + if !ok { + return nil + } + return &next +} diff --git a/internal/api/recommendations_next_collection_test.go b/internal/api/recommendations_next_collection_test.go new file mode 100644 index 000000000..5cfbc7971 --- /dev/null +++ b/internal/api/recommendations_next_collection_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// freshnessJSON calls GET /api/recommendations/freshness on a handler built +// from schedule (as NewHandler would) at the fixed instant now, and returns +// the serialized next_collection_at value. +func freshnessJSON(t *testing.T, schedule string, now time.Time) string { + t.Helper() + ctx := context.Background() + mockStore := new(MockConfigStore) + collected := now.Add(-3 * time.Hour) + mockStore.On("GetRecommendationsFreshness", ctx). + Return(&config.RecommendationsFreshness{LastCollectedAt: &collected}, nil) + + handler := &Handler{ + config: mockStore, + apiKey: "test-key", + now: func() time.Time { return now }, + collectionSchedule: parseCollectionSchedule(schedule), + } + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"x-api-key": "test-key"}} + + got, err := handler.getRecommendationsFreshness(ctx, req) + require.NoError(t, err) + body, err := json.Marshal(got) + require.NoError(t, err) + return string(mustField(t, body, "next_collection_at")) +} + +// Issue #756: the first-run empty state needs the next collection time, so +// the freshness payload computes it from the deployment's schedule. +func TestFreshnessNextCollectionAt(t *testing.T) { + now := time.Date(2026, 10, 27, 15, 4, 5, 0, time.UTC) + + t.Run("EventBridge cron schedule", func(t *testing.T) { + assert.Equal(t, `"2026-10-28T02:00:00Z"`, freshnessJSON(t, "cron(0 2 * * ? *)", now)) + }) + t.Run("five-field cron schedule (GCP, AKS, Container Apps)", func(t *testing.T) { + assert.Equal(t, `"2026-10-27T18:00:00Z"`, freshnessJSON(t, "0 */6 * * *", now)) + }) + + // The key is always present so the frontend can rely on it. + for name, schedule := range map[string]string{ + "unset (scheduled collection off)": "", + "rate schedule (phase unknown)": "rate(1 day)", + "unparsable schedule": "every day at 2", + "no run within the horizon": "cron(0 0 1 1 ? 2020)", + } { + t.Run(name+" is null", func(t *testing.T) { + assert.Equal(t, "null", freshnessJSON(t, schedule, now)) + }) + } +} + +func TestNewHandlerParsesRecommendationSchedule(t *testing.T) { + now := time.Date(2026, 10, 27, 15, 4, 5, 0, time.UTC) + + h := NewHandler(HandlerConfig{RecommendationSchedule: "0 2 * * *"}) + next := h.NextCollectionAt(now) + require.NotNil(t, next) + assert.Equal(t, time.Date(2026, 10, 28, 2, 0, 0, 0, time.UTC), *next) + + assert.Nil(t, NewHandler(HandlerConfig{}).NextCollectionAt(now)) + assert.Nil(t, NewHandler(HandlerConfig{RecommendationSchedule: "cron(bad)"}).NextCollectionAt(now)) +} diff --git a/internal/api/types.go b/internal/api/types.go index fdeb91d37..0b907ecec 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -60,6 +60,11 @@ type HandlerConfig struct { // refresh, stale-execution sweep) that would freeze mid-flight on Lambda. // The handler never re-reads the environment itself (issue #110). IsLambda bool + // RecommendationSchedule is the deployment's recommendation-collection + // schedule (RECOMMENDATION_SCHEDULE, set by Terraform). Empty means + // scheduled collection is off or the deployment predates the variable. + // It feeds next_collection_at on GET /api/recommendations/freshness. + RecommendationSchedule string } // CommitmentOptsInterface lets us swap the real *commitmentopts.Service for diff --git a/internal/config/types.go b/internal/config/types.go index 7041f89be..32793d5d1 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -905,11 +905,15 @@ type PurchasePlanFilter struct { // kept rows from a sweep that did not cover everything it was asked to (for // example 9 of 10 Azure subscriptions answered). It is cleared by the next // collection whose every sweep is complete. +// NextCollectionAt is not stored: the API handler computes it from the +// deployment's schedule (issue #756) and leaves it nil when scheduled +// collection is off or its next run cannot be known. type RecommendationsFreshness struct { LastCollectedAt *time.Time `json:"last_collected_at"` LastCollectionError *string `json:"last_collection_error"` LastCollectionStartedAt *time.Time `json:"last_collection_started_at"` LastCollectionPartial *RecommendationsCollectionPartial `json:"last_collection_partial"` + NextCollectionAt *time.Time `json:"next_collection_at"` } // RecommendationsCollectionPartial records that the most recent collection diff --git a/internal/schedexpr/cron.go b/internal/schedexpr/cron.go new file mode 100644 index 000000000..f0e1ba021 --- /dev/null +++ b/internal/schedexpr/cron.go @@ -0,0 +1,263 @@ +package schedexpr + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// searchYears bounds Next. Ten years covers every expression that fires at +// all in practice, including a 29 February that skips a non-leap century +// year, while keeping the worst case to a few thousand day steps. +const searchYears = 10 + +// cronSchedule is a parsed cron expression. Each set is indexed by the +// field's value (dow by time.Weekday); year is nil when any year matches. +type cronSchedule struct { + minute, hour, dom, month []bool + dow [7]bool + year []bool + // orDays makes a day match when either day-of-month or day-of-week + // matches (classic cron when both fields are restricted); otherwise + // both must match. + orDays bool +} + +// dialect captures how the cron syntaxes differ: field count and the +// day-of-month/day-of-week rules. +type dialect struct { + name string + fields int + parseDays func(c *cronSchedule, dom, dow string) error +} + +var ( + eventBridgeDialect = dialect{name: "EventBridge cron", fields: 6, parseDays: parseEventBridgeDays} + unixDialect = dialect{name: "cron", fields: 5, parseDays: parseUnixDays} +) + +// field describes one cron field's value range and optional names. +type field struct { + lo, hi int + names map[string]int +} + +var ( + minuteField = field{lo: 0, hi: 59} + hourField = field{lo: 0, hi: 23} + domField = field{lo: 1, hi: 31} + monthField = field{lo: 1, hi: 12, names: map[string]int{ + "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6, + "JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12, + }} + yearField = field{lo: 1970, hi: 2199} + // EventBridge numbers days of the week 1-7 from Sunday. + eventBridgeDowField = field{lo: 1, hi: 7, names: map[string]int{ + "SUN": 1, "MON": 2, "TUE": 3, "WED": 4, "THU": 5, "FRI": 6, "SAT": 7, + }} + // Classic cron numbers them 0-6 from Sunday and also accepts 7 for Sunday. + unixDowField = field{lo: 0, hi: 7, names: map[string]int{ + "SUN": 0, "MON": 1, "TUE": 2, "WED": 3, "THU": 4, "FRI": 5, "SAT": 6, + }} +) + +// parseCron parses the space-separated fields of a cron expression. +func parseCron(fields []string, d dialect) (Schedule, error) { + if len(fields) != d.fields { + return nil, fmt.Errorf("%s: want %d fields, got %d", d.name, d.fields, len(fields)) + } + c := &cronSchedule{} + var err error + if c.minute, err = parseField(fields[0], minuteField); err != nil { + return nil, fmt.Errorf("%s minutes: %w", d.name, err) + } + if c.hour, err = parseField(fields[1], hourField); err != nil { + return nil, fmt.Errorf("%s hours: %w", d.name, err) + } + if c.month, err = parseField(fields[3], monthField); err != nil { + return nil, fmt.Errorf("%s month: %w", d.name, err) + } + if err = d.parseDays(c, fields[2], fields[4]); err != nil { + return nil, fmt.Errorf("%s: %w", d.name, err) + } + if d.fields == 6 && fields[5] != "*" { + if c.year, err = parseField(fields[5], yearField); err != nil { + return nil, fmt.Errorf("%s year: %w", d.name, err) + } + } + return c, nil +} + +// parseEventBridgeDays applies EventBridge's rule that exactly one of +// day-of-month and day-of-week is "?" (no constraint); the other field alone +// decides which days match. +func parseEventBridgeDays(c *cronSchedule, dom, dow string) error { + if (dom == "?") == (dow == "?") { + return errors.New("exactly one of day-of-month and day-of-week must be ?") + } + if dom == "?" { + dom = "*" + } + var err error + if c.dom, err = parseField(dom, domField); err != nil { + return fmt.Errorf("day-of-month: %w", err) + } + if dow == "?" { + dow = "*" + } + days, err := parseField(dow, eventBridgeDowField) + if err != nil { + return fmt.Errorf("day-of-week: %w", err) + } + for v := 1; v <= 7; v++ { + c.dow[v-1] = days[v] + } + return nil +} + +// parseUnixDays applies classic cron's rule: when both day fields are +// restricted a day matches either; when either is "*" (or "?") both must +// match. "*/n" is treated as restricted by some implementations and as "*" +// by others, so combining it with a restricted other field is rejected +// rather than guessed. +func parseUnixDays(c *cronSchedule, dom, dow string) error { + domStar, dowStar := isStar(dom), isStar(dow) + if (strings.HasPrefix(dom, "*/") && !dowStar) || (strings.HasPrefix(dow, "*/") && !domStar) { + return errors.New("a */n day field combined with a restricted other day field is ambiguous") + } + var err error + if c.dom, err = parseField(starOf(dom), domField); err != nil { + return fmt.Errorf("day-of-month: %w", err) + } + days, err := parseField(starOf(dow), unixDowField) + if err != nil { + return fmt.Errorf("day-of-week: %w", err) + } + for v := 0; v <= 7; v++ { + c.dow[v%7] = c.dow[v%7] || days[v] + } + c.orDays = !domStar && !dowStar + return nil +} + +func isStar(s string) bool { return s == "*" || s == "?" } + +// starOf maps "?" (accepted as "*" by Kubernetes CronJobs) to "*". +func starOf(s string) string { + if s == "?" { + return "*" + } + return s +} + +// parseField parses a comma-separated list of values, ranges and steps +// into a set indexed by value. +func parseField(spec string, f field) ([]bool, error) { + set := make([]bool, f.hi+1) + for _, part := range strings.Split(spec, ",") { + start, end, step, err := parseRange(part, f) + if err != nil { + return nil, err + } + for v := start; v <= end; v += step { + set[v] = true + } + } + return set, nil +} + +// parseRange parses one list element: "*", "v", "a-b", each optionally +// followed by "/step". "v/step" runs from v to the field maximum. +func parseRange(part string, f field) (start, end, step int, err error) { + rng, stepText, hasStep := strings.Cut(part, "/") + step = 1 + if hasStep { + if step, err = number(stepText); err != nil || step < 1 { + return 0, 0, 0, fmt.Errorf("invalid step in %q", part) + } + } + if rng == "*" { + return f.lo, f.hi, step, nil + } + loText, hiText, isRange := strings.Cut(rng, "-") + if start, err = f.value(loText); err != nil { + return 0, 0, 0, err + } + end = start + if isRange { + end, err = f.value(hiText) + } else if hasStep { + end = f.hi + } + if err != nil { + return 0, 0, 0, err + } + if end < start { + return 0, 0, 0, fmt.Errorf("range %q runs backwards", rng) + } + return start, end, step, nil +} + +// value parses a single field value: a name or an in-range number. +func (f field) value(s string) (int, error) { + if v, ok := f.names[strings.ToUpper(s)]; ok { + return v, nil + } + v, err := number(s) + if err != nil || v < f.lo || v > f.hi { + return 0, fmt.Errorf("value %q outside %d-%d", s, f.lo, f.hi) + } + return v, nil +} + +// number parses an unsigned decimal; unlike strconv.Atoi it rejects signs, +// so "+5" or "-5" cannot slip through as a value. +func number(s string) (int, error) { + if s == "" || strings.TrimLeft(s, "0123456789") != "" { + return 0, fmt.Errorf("%q is not a number", s) + } + return strconv.Atoi(s) +} + +// Next implements Schedule. It walks forward a day, an hour or a minute at a +// time, whichever is the smallest step that can still reach a match. +func (c *cronSchedule) Next(after time.Time) (time.Time, bool) { + t := after.UTC().Truncate(time.Minute).Add(time.Minute) + limit := t.AddDate(searchYears, 0, 0) + for t.Before(limit) { + switch { + case !c.dayMatches(t): + t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC) + case !c.hour[t.Hour()]: + t = t.Truncate(time.Hour).Add(time.Hour) + case !c.minute[t.Minute()]: + t = t.Add(time.Minute) + default: + return t, true + } + } + return time.Time{}, false +} + +func (c *cronSchedule) dayMatches(t time.Time) bool { + if !c.yearMatches(t.Year()) { + return false + } + if !c.month[t.Month()] { + return false + } + dom, dow := c.dom[t.Day()], c.dow[t.Weekday()] + if c.orDays { + return dom || dow + } + return dom && dow +} + +func (c *cronSchedule) yearMatches(y int) bool { + if c.year == nil { + return true + } + return y >= 0 && y < len(c.year) && c.year[y] +} diff --git a/internal/schedexpr/schedexpr.go b/internal/schedexpr/schedexpr.go new file mode 100644 index 000000000..1efe4908c --- /dev/null +++ b/internal/schedexpr/schedexpr.go @@ -0,0 +1,105 @@ +// Package schedexpr parses the schedule expressions the deployment's +// scheduler uses to trigger recommendation collection and works out when +// the next run fires (issue #756). +// +// Three syntaxes are accepted, matching what the Terraform modules hand to +// their schedulers: +// +// - EventBridge rate expressions, e.g. "rate(1 day)" (AWS Lambda/Fargate); +// - EventBridge cron expressions with six fields, e.g. +// "cron(0 2 * * ? *)" (AWS Lambda/Fargate); +// - standard five-field cron, e.g. "0 2 * * *" (GCP Cloud Scheduler, +// Kubernetes CronJobs, the Azure Logic App recurrence). +// +// Every scheduler involved evaluates its schedule in UTC, so Next works in +// UTC. The parser is deliberately strict: anything it does not fully +// understand is an error, and callers report "unknown" instead of a wrong +// time. +package schedexpr + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// ErrEmpty is returned by Parse for an empty or blank expression, which the +// deployment uses to mean "scheduled collection is disabled". +var ErrEmpty = errors.New("empty schedule expression") + +// Schedule is a parsed schedule expression. +type Schedule interface { + // Next returns the first fire time strictly after t, in UTC. ok is + // false when the fire time cannot be known from the expression alone + // (rate expressions) or no fire time exists within the search horizon. + Next(t time.Time) (next time.Time, ok bool) +} + +// Parse parses expr in any of the supported syntaxes. +func Parse(expr string) (Schedule, error) { + s := strings.TrimSpace(expr) + if s == "" { + return nil, ErrEmpty + } + if inner, ok := unwrap(s, "rate"); ok { + return parseRate(inner) + } + if inner, ok := unwrap(s, "cron"); ok { + return parseCron(strings.Fields(inner), eventBridgeDialect) + } + return parseCron(strings.Fields(s), unixDialect) +} + +// unwrap returns the text between "(" and the closing ")". +func unwrap(s, name string) (string, bool) { + if !strings.HasPrefix(s, name+"(") || !strings.HasSuffix(s, ")") { + return "", false + } + return s[len(name)+1 : len(s)-1], true +} + +// rateSchedule is an EventBridge rate expression. EventBridge starts a rate +// schedule's clock when the rule is created, a moment the runtime cannot +// see, so the period is known but the phase is not: Next always reports +// unknown rather than guessing. +type rateSchedule struct { + period time.Duration +} + +// Next implements Schedule. See rateSchedule for why it is always unknown. +func (rateSchedule) Next(time.Time) (time.Time, bool) { + return time.Time{}, false +} + +var rateUnits = map[string]time.Duration{ + "minute": time.Minute, + "hour": time.Hour, + "day": 24 * time.Hour, +} + +// parseRate parses the " " body of a rate expression with +// EventBridge's rules: a positive integer value, the singular unit for 1 and +// the plural unit for anything larger. +func parseRate(body string) (Schedule, error) { + parts := strings.Fields(body) + if len(parts) != 2 { + return nil, fmt.Errorf("rate(%s): want \" \"", body) + } + value, err := number(parts[0]) + if err != nil || value < 1 { + return nil, fmt.Errorf("rate(%s): value must be a positive integer", body) + } + unit := parts[1] + if value > 1 { + if !strings.HasSuffix(unit, "s") { + return nil, fmt.Errorf("rate(%s): unit must be plural for a value above 1", body) + } + unit = strings.TrimSuffix(unit, "s") + } + d, ok := rateUnits[unit] + if !ok { + return nil, fmt.Errorf("rate(%s): unit must be minute(s), hour(s) or day(s)", body) + } + return rateSchedule{period: time.Duration(value) * d}, nil +} diff --git a/internal/schedexpr/schedexpr_test.go b/internal/schedexpr/schedexpr_test.go new file mode 100644 index 000000000..32a7371d3 --- /dev/null +++ b/internal/schedexpr/schedexpr_test.go @@ -0,0 +1,180 @@ +package schedexpr + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func utc(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} + +// Tuesday 2026-10-27 15:04:05 UTC. +var base = utc("2026-10-27T15:04:05Z") + +func TestParseRate(t *testing.T) { + cases := map[string]time.Duration{ + "rate(1 minute)": time.Minute, + "rate(5 minutes)": 5 * time.Minute, + "rate(1 hour)": time.Hour, + "rate(12 hours)": 12 * time.Hour, + "rate(1 day)": 24 * time.Hour, + "rate(7 days)": 7 * 24 * time.Hour, + " rate(2 hours) ": 2 * time.Hour, + "rate(1 minute)": time.Minute, + } + for expr, want := range cases { + t.Run(expr, func(t *testing.T) { + s, err := Parse(expr) + require.NoError(t, err) + r, ok := s.(rateSchedule) + require.True(t, ok, "got %T", s) + assert.Equal(t, want, r.period) + // The rule's creation time anchors a rate schedule and is not + // visible to the runtime, so the next run is unknown. + next, ok := s.Next(base) + assert.False(t, ok) + assert.True(t, next.IsZero()) + }) + } +} + +func TestParseRateInvalid(t *testing.T) { + for _, expr := range []string{ + "rate(0 days)", "rate(-1 days)", "rate(+2 days)", "rate(1 days)", "rate(2 day)", + "rate(1 week)", "rate(3 weeks)", "rate(x minutes)", "rate(1)", "rate()", + "rate(1 day", "rate(1.5 hours)", "Rate(1 day)", "rate(1 day 2)", + } { + t.Run(expr, func(t *testing.T) { + _, err := Parse(expr) + assert.Error(t, err) + }) + } +} + +func TestParseEmpty(t *testing.T) { + for _, expr := range []string{"", " "} { + _, err := Parse(expr) + assert.True(t, errors.Is(err, ErrEmpty), "%q: %v", expr, err) + } +} + +func TestNextEventBridgeCron(t *testing.T) { + cases := []struct { + expr, from, want string + }{ + {"cron(0 2 * * ? *)", "2026-10-27T15:04:05Z", "2026-10-28T02:00:00Z"}, + {"cron(0 2 * * ? *)", "2026-10-27T01:59:59Z", "2026-10-27T02:00:00Z"}, + // Strictly after: a call at the fire instant gets the following run. + {"cron(0 2 * * ? *)", "2026-10-27T02:00:00Z", "2026-10-28T02:00:00Z"}, + {"cron(0/15 * * * ? *)", "2026-10-27T15:04:05Z", "2026-10-27T15:15:00Z"}, + {"cron(5,35 */6 * * ? *)", "2026-10-27T15:04:05Z", "2026-10-27T18:05:00Z"}, + {"cron(0 0 1 * ? *)", "2026-10-27T15:04:05Z", "2026-11-01T00:00:00Z"}, + {"cron(0 0 ? * * *)", "2026-10-27T15:04:05Z", "2026-10-28T00:00:00Z"}, + // Day-of-week 1-7 starts on Sunday: 2 is Monday. + {"cron(30 6 ? * 2 *)", "2026-10-27T15:04:05Z", "2026-11-02T06:30:00Z"}, + {"cron(0 9 ? * MON-FRI *)", "2026-10-30T10:00:00Z", "2026-11-02T09:00:00Z"}, + {"cron(0 9 ? * sun *)", "2026-10-27T15:04:05Z", "2026-11-01T09:00:00Z"}, + {"cron(0 0 1 JAN ? 2027)", "2026-10-27T15:04:05Z", "2027-01-01T00:00:00Z"}, + {"cron(0 0 29 2 ? *)", "2026-10-27T15:04:05Z", "2028-02-29T00:00:00Z"}, + // Non-UTC input is evaluated in UTC (EventBridge cron is UTC): + // 03:30+02:00 is 01:30Z, so 02:00Z the same day is next. + {"cron(0 2 * * ? *)", "2026-10-27T03:30:00+02:00", "2026-10-27T02:00:00Z"}, + } + for _, tc := range cases { + t.Run(tc.expr+"@"+tc.from, func(t *testing.T) { + s, err := Parse(tc.expr) + require.NoError(t, err) + next, ok := s.Next(utc(tc.from)) + require.True(t, ok) + assert.Equal(t, utc(tc.want), next) + assert.Equal(t, time.UTC, next.Location()) + }) + } +} + +func TestNextUnixCron(t *testing.T) { + cases := []struct { + expr, from, want string + }{ + {"0 2 * * *", "2026-10-27T15:04:05Z", "2026-10-28T02:00:00Z"}, + {"*/30 * * * *", "2026-10-27T15:04:05Z", "2026-10-27T15:30:00Z"}, + {"0 9 * * 1-5", "2026-10-30T10:00:00Z", "2026-11-02T09:00:00Z"}, + // Day-of-week 0 and 7 are both Sunday. + {"0 9 * * 0", "2026-10-27T15:04:05Z", "2026-11-01T09:00:00Z"}, + {"0 9 * * 7", "2026-10-27T15:04:05Z", "2026-11-01T09:00:00Z"}, + {"0 9 ? * MON", "2026-10-27T15:04:05Z", "2026-11-02T09:00:00Z"}, + // Both day fields restricted: either matches (Sunday 1 Nov is + // the 1st, before Monday 2 Nov). + {"0 0 1 * 1", "2026-10-27T15:04:05Z", "2026-11-01T00:00:00Z"}, + {"0 0 15 * 1", "2026-10-27T15:04:05Z", "2026-11-02T00:00:00Z"}, + {"0 0 1 * *", "2026-10-27T15:04:05Z", "2026-11-01T00:00:00Z"}, + } + for _, tc := range cases { + t.Run(tc.expr+"@"+tc.from, func(t *testing.T) { + s, err := Parse(tc.expr) + require.NoError(t, err) + next, ok := s.Next(utc(tc.from)) + require.True(t, ok) + assert.Equal(t, utc(tc.want), next) + }) + } +} + +func TestNextNoFireWithinHorizon(t *testing.T) { + for _, expr := range []string{"cron(0 0 1 1 ? 2020)", "cron(0 0 1 1 ? 2199)", "0 0 31 2 *"} { + s, err := Parse(expr) + require.NoError(t, err, expr) + _, ok := s.Next(base) + assert.False(t, ok, expr) + } +} + +func TestParseCronInvalid(t *testing.T) { + for _, expr := range []string{ + // EventBridge + "cron(0 2 * * * *)", // neither day field is ? + "cron(0 2 ? * ? *)", // both day fields are ? + "cron(0 2 * * ?)", // five fields + "cron(0 2 * * ? * *)", // seven fields + "cron(60 2 * * ? *)", // minute out of range + "cron(0 24 * * ? *)", // hour out of range + "cron(0 0 32 * ? *)", // day-of-month out of range + "cron(0 0 ? * 0 *)", // EventBridge day-of-week starts at 1 + "cron(0 0 ? * 8 *)", // day-of-week out of range + "cron(0 0 ? 13 * *)", // month out of range + "cron(0 0 * * ? 1969)", // year out of range + "cron(0 0 L * ? *)", // L is not supported + "cron(0 0 15W * ? *)", // W is not supported + "cron(0 12 ? * MON#1 *)", // # is not supported + "cron(0/0 * * * ? *)", // zero step + "cron(5-1 * * * ? *)", // backwards range + "cron(0 2 * * ? )", // trailing blanks are not a field + "cron(0 +2 * * ? *)", // signed value + "cron(0 1,,2 * * ? *)", // empty list element + "cron(? 2 * * ? *)", // ? outside the day fields + "cron()", + // Classic cron + "0 2 * *", + "0 2 * * * *", + "@daily", + "0 25 * * *", + "a b c d e", + "0 0 */2 * 1", // */n with a restricted day-of-week is ambiguous + "0 0 1 * */2", + "0 0 * * FRI-MON", + } { + t.Run(expr, func(t *testing.T) { + _, err := Parse(expr) + assert.Error(t, err) + }) + } +} diff --git a/internal/server/app.go b/internal/server/app.go index f4e9b732b..5d0cfb8de 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -150,6 +150,11 @@ type ApplicationConfig struct { // SQS event source mapping's function_response_types actually includes // "ReportBatchItemFailures" (issue #108 follow-up). ReportSQSBatchItemFailures bool + // RecommendationSchedule is RECOMMENDATION_SCHEDULE: the schedule + // Terraform gave the deployment's recommendation-collection trigger + // (EventBridge rate()/cron() or five-field cron), empty when scheduled + // collection is off. Only read to report the next run (issue #756). + RecommendationSchedule string // authBcryptCost is passed to auth.ServiceConfig.BcryptCost. Only tests // in this package set it; zero keeps the production bcrypt cost. authBcryptCost int @@ -332,6 +337,7 @@ func LoadApplicationConfig() (ApplicationConfig, error) { ScheduledTaskSecretName: os.Getenv("SCHEDULED_TASK_SECRET_NAME"), IsLambda: runtime.IsLambda(), Analytics: analyticsConfig, + RecommendationSchedule: os.Getenv("RECOMMENDATION_SCHEDULE"), }, nil } @@ -552,6 +558,8 @@ func NewApplicationFromDeps(ctx context.Context, cfg ApplicationConfig, deps Ext OIDCIssuerURL: resolveOIDCIssuerURL(cfg), Insurance: archeraProvider, IsLambda: cfg.IsLambda, + + RecommendationSchedule: cfg.RecommendationSchedule, }) log.Printf("CUDly Server initialization complete") @@ -941,6 +949,8 @@ func (app *Application) reinitializeAfterConnect(ctx context.Context, dbConn *da EncryptionKeySource: app.encKeySource, Insurance: app.archera, IsLambda: app.appConfig.IsLambda, + + RecommendationSchedule: app.appConfig.RecommendationSchedule, }) if app.API == nil { return fmt.Errorf("failed to create API handler") diff --git a/internal/server/app_test.go b/internal/server/app_test.go index 285eb867e..4b4c44fd0 100644 --- a/internal/server/app_test.go +++ b/internal/server/app_test.go @@ -317,6 +317,7 @@ func TestLoadApplicationConfig(t *testing.T) { "DEFAULT_PAYMENT_OPTION", "DEFAULT_COVERAGE", "DEFAULT_RAMP_SCHEDULE", "API_KEY_SECRET_ARN", "ENABLE_DASHBOARD", "DASHBOARD_BUCKET", "DASHBOARD_URL", "CORS_ALLOWED_ORIGIN", "AWS_LAMBDA_RUNTIME_API", + "RECOMMENDATION_SCHEDULE", } for _, key := range envVars { testutil.SetEnv(t, key, "") @@ -333,6 +334,7 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.AssertEqual(t, "", cfg.DefaultRampSchedule) testutil.AssertEqual(t, false, cfg.EnableDashboard) testutil.AssertEqual(t, false, cfg.IsLambda) + testutil.AssertEqual(t, "", cfg.RecommendationSchedule) }) t.Run("custom values", func(t *testing.T) { @@ -348,6 +350,7 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.SetEnv(t, "DASHBOARD_URL", "https://dash.example.com") testutil.SetEnv(t, "CORS_ALLOWED_ORIGIN", "https://example.com") testutil.SetEnv(t, "AWS_LAMBDA_RUNTIME_API", "localhost:9001") + testutil.SetEnv(t, "RECOMMENDATION_SCHEDULE", "cron(0 2 * * ? *)") cfg, err := LoadApplicationConfig() testutil.AssertNoError(t, err) @@ -364,6 +367,7 @@ func TestLoadApplicationConfig(t *testing.T) { testutil.AssertEqual(t, "https://dash.example.com", cfg.DashboardURL) testutil.AssertEqual(t, "https://example.com", cfg.CORSAllowedOrigin) testutil.AssertEqual(t, true, cfg.IsLambda) + testutil.AssertEqual(t, "cron(0 2 * * ? *)", cfg.RecommendationSchedule) }) } @@ -499,6 +503,20 @@ func TestNewApplicationFromDeps(t *testing.T) { testutil.AssertTrue(t, app.RateLimiter != nil, "Rate limiter must not be nil for Lambda (fix for issue #420: cold-start requests must be rate-limited)") }) + // Issue #756: the schedule reaches the API handler that serves + // next_collection_at on GET /api/recommendations/freshness. + t.Run("recommendation schedule reaches the API handler", func(t *testing.T) { + cfg := baseCfg + cfg.RecommendationSchedule = "cron(0 2 * * ? *)" + deps := ExternalDeps{EmailSender: &noopEmailSender{}, DBConfig: validDBConfig} + + app, err := NewApplicationFromDeps(ctx, cfg, deps) + testutil.AssertNoError(t, err) + next := app.API.NextCollectionAt(time.Date(2026, 10, 27, 15, 4, 5, 0, time.UTC)) + testutil.AssertTrue(t, next != nil, "next_collection_at must be computed from the configured schedule") + testutil.AssertEqual(t, time.Date(2026, 10, 28, 2, 0, 0, 0, time.UTC), *next) + }) + t.Run("nil dbConfig returns error", func(t *testing.T) { deps := ExternalDeps{ EmailSender: &noopEmailSender{}, diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index 30f35e74a..572de0d8d 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -647,6 +647,13 @@ resource "aws_ecs_task_definition" "main" { { name = "SCHEDULED_TASK_SECRET_NAME" value = var.scheduled_task_secret_name + }, + # The EventBridge schedule of aws_cloudwatch_event_rule.recommendations, + # so GET /api/recommendations/freshness can report the next run + # (next_collection_at, #756). Empty when the rule is not created. + { + name = "RECOMMENDATION_SCHEDULE" + value = var.enable_scheduled_tasks ? var.recommendation_schedule : "" } ], [ diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 2f57c3687..3ab5f07a4 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -82,6 +82,10 @@ resource "aws_lambda_function" "main" { # function_name + account_id ARN avoids the self-reference cycle # that aws_lambda_function.main.arn would create here. SCHEDULER_LAMBDA_ARN = "arn:${data.aws_partition.current.partition}:lambda:${var.region}:${data.aws_caller_identity.current.account_id}:function:${var.stack_name}-api" + # The EventBridge schedule of scheduled_recommendations below, so + # GET /api/recommendations/freshness can report the next run + # (next_collection_at, #756). Empty when the rule is not created. + RECOMMENDATION_SCHEDULE = var.enable_scheduled_tasks ? var.recommendation_schedule : "" }, # Stuck-purchase reaper threshold (#678). Only set when the # operator overrides the default — empty string means "use the diff --git a/terraform/modules/compute/azure/aks/main.tf b/terraform/modules/compute/azure/aks/main.tf index cb7a8e476..4631d21f0 100644 --- a/terraform/modules/compute/azure/aks/main.tf +++ b/terraform/modules/compute/azure/aks/main.tf @@ -777,6 +777,14 @@ locals { # Connector does not emit Entra OIDC tokens). Validated app-side via # internal/server/scheduledauth. { name = "SCHEDULED_TASK_AUTH_MODE", value = "bearer", secret_ref = null }, + # The collect_recommendations CronJob schedule (UTC, scheduled-tasks.tf), + # so GET /api/recommendations/freshness can report the next run + # (next_collection_at, #756). Empty when that CronJob is not created. + { + name = "RECOMMENDATION_SCHEDULE" + value = var.enable_scheduled_tasks ? var.recommendation_schedule : "" + secret_ref = null + }, ], [for k, v in local.archera_env : { name = k, value = v, secret_ref = null }], [for k, v in var.additional_env_vars : { name = k, value = v, secret_ref = null }], diff --git a/terraform/modules/compute/azure/container-apps/main.tf b/terraform/modules/compute/azure/container-apps/main.tf index 0f1d65378..3f5bcf846 100644 --- a/terraform/modules/compute/azure/container-apps/main.tf +++ b/terraform/modules/compute/azure/container-apps/main.tf @@ -140,6 +140,12 @@ resource "azurerm_container_app" "main" { # would require Entra-issued tokens, which Logic Apps does # not emit on the HTTP Connector trigger we use. SCHEDULED_TASK_AUTH_MODE = "bearer" + # The recurrence the recommendations Logic App actually runs + # (daily at local.schedule_hour:00 UTC, see scheduled-tasks.tf), + # not the raw cron, so GET /api/recommendations/freshness reports + # the real next run (next_collection_at, #756). Empty when the + # Logic App is not created. + RECOMMENDATION_SCHEDULE = var.enable_scheduled_tasks ? "0 ${local.schedule_hour} * * *" : "" }, local.archera_env, var.additional_env_vars diff --git a/terraform/modules/compute/gcp/cloud-run/main.tf b/terraform/modules/compute/gcp/cloud-run/main.tf index 5e83bd796..379382cd6 100644 --- a/terraform/modules/compute/gcp/cloud-run/main.tf +++ b/terraform/modules/compute/gcp/cloud-run/main.tf @@ -172,6 +172,10 @@ resource "google_cloud_run_v2_service" "main" { SCHEDULED_TASK_AUTH_MODE = local.scheduled_task_auth_mode SCHEDULED_TASK_OIDC_AUDIENCE = local.scheduled_task_oidc_audiences SCHEDULED_TASK_OIDC_SUBJECTS = local.scheduled_task_oidc_subjects + # The Cloud Scheduler cron (UTC) of the recommendations job, so + # GET /api/recommendations/freshness can report the next run + # (next_collection_at, #756). Empty when the job is not created. + RECOMMENDATION_SCHEDULE = var.enable_scheduled_tasks ? var.recommendation_schedule : "" }, local.archera_env, var.additional_env_vars diff --git a/terraform/modules/compute/gcp/gke/main.tf b/terraform/modules/compute/gcp/gke/main.tf index ff397960a..442d9e300 100644 --- a/terraform/modules/compute/gcp/gke/main.tf +++ b/terraform/modules/compute/gcp/gke/main.tf @@ -582,6 +582,14 @@ resource "kubernetes_deployment" "app" { value = local.scheduled_task_oidc_subject } + # The Cloud Scheduler cron (UTC) of the recommendations job, so + # GET /api/recommendations/freshness can report the next run + # (next_collection_at, #756). Empty when the job is not created. + env { + name = "RECOMMENDATION_SCHEDULE" + value = var.enable_scheduled_tasks ? var.recommendation_schedule : "" + } + dynamic "env" { for_each = local.archera_env content { diff --git a/terraform/modules/compute/recommendation_schedule_env_test.go b/terraform/modules/compute/recommendation_schedule_env_test.go new file mode 100644 index 000000000..443172d45 --- /dev/null +++ b/terraform/modules/compute/recommendation_schedule_env_test.go @@ -0,0 +1,82 @@ +// Issue #756: every compute module that schedules collect_recommendations +// hands the same schedule to the API as RECOMMENDATION_SCHEDULE, so GET +// /api/recommendations/freshness can report next_collection_at. The value +// must be empty when the scheduler resource is not created, otherwise the +// API would announce runs that never happen. +package compute_test + +import ( + "os" + "regexp" + "strings" + "testing" +) + +const scheduleWhenEnabled = `var.enable_scheduled_tasks ? var.recommendation_schedule : ""` + +// recommendationScheduleEnv lists, per module file, the expression the +// RECOMMENDATION_SCHEDULE entry must carry. +var recommendationScheduleEnv = map[string]string{ + "aws/lambda/main.tf": scheduleWhenEnabled, + "aws/fargate/main.tf": scheduleWhenEnabled, + "gcp/cloud-run/main.tf": scheduleWhenEnabled, + "gcp/gke/main.tf": scheduleWhenEnabled, + "azure/aks/main.tf": scheduleWhenEnabled, + // The Logic App runs daily at the hour field only (scheduled-tasks.tf), + // so the env var describes that recurrence rather than the raw cron. + "azure/container-apps/main.tf": `var.enable_scheduled_tasks ? "0 ${local.schedule_hour} * * *" : ""`, +} + +var ( + // Map style: `RECOMMENDATION_SCHEDULE = `. + scheduleEnvMapRe = regexp.MustCompile(`(?m)^\s*RECOMMENDATION_SCHEDULE\s*=\s*(.+?)\s*$`) + // List style: `name = "RECOMMENDATION_SCHEDULE"` followed by + // `value = ` in the same object (ECS, Kubernetes env blocks and + // the AKS env list). + scheduleEnvListRe = regexp.MustCompile(`(?s)name\s*=\s*"RECOMMENDATION_SCHEDULE"\s*,?\s*value\s*=\s*([^\n]+?)\s*(?:,|\n)`) +) + +// scheduleEnvValues returns every RECOMMENDATION_SCHEDULE expression in src. +func scheduleEnvValues(src string) []string { + var out []string + for _, re := range []*regexp.Regexp{scheduleEnvMapRe, scheduleEnvListRe} { + for _, m := range re.FindAllStringSubmatch(src, -1) { + out = append(out, strings.TrimSpace(m[1])) + } + } + return out +} + +func TestRecommendationScheduleEnvWiring(t *testing.T) { + for file, want := range recommendationScheduleEnv { + t.Run(file, func(t *testing.T) { + raw, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + got := scheduleEnvValues(stripComments(string(raw))) + if len(got) != 1 { + t.Fatalf("want exactly one RECOMMENDATION_SCHEDULE env entry, got %d: %q", len(got), got) + } + if got[0] != want { + t.Errorf("RECOMMENDATION_SCHEDULE = %s; want %s", got[0], want) + } + }) + } +} + +func TestScheduleEnvValuesShapes(t *testing.T) { + for name, src := range map[string]string{ + "map": " RECOMMENDATION_SCHEDULE = var.x\n", + "ecs": "{\n name = \"RECOMMENDATION_SCHEDULE\"\n value = var.x\n}\n", + "aks list": "{ name = \"RECOMMENDATION_SCHEDULE\", value = var.x, secret_ref = null },\n", + "aks multi": "{\n name = \"RECOMMENDATION_SCHEDULE\"\n value = var.x\n secret_ref = null\n},\n", + } { + if got := scheduleEnvValues(src); len(got) != 1 || got[0] != "var.x" { + t.Errorf("%s: got %q", name, got) + } + } + if got := scheduleEnvValues(" OTHER = var.x\n"); len(got) != 0 { + t.Errorf("unrelated entry matched: %q", got) + } +}