Skip to content

Commit b6f74d6

Browse files
committed
feat: add cron expression validation to manifest validator
Enhances validateScheduledTriggers to parse and validate inline cron expressions from scheduled:<name>:<cron-expr> triggers. Enforces a 15-minute minimum interval, 20-schedule-per-manifest cap, and warns on schedules that fire less frequently than once per 365 days.
1 parent 31519f4 commit b6f74d6

2 files changed

Lines changed: 214 additions & 2 deletions

File tree

services/control-plane/internal/validator/domain_validator.go

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,28 @@ import (
77
"regexp"
88
"sort"
99
"strings"
10+
"time"
1011

1112
controlplanev1 "github.com/meridianhub/meridian/api/proto/meridian/control_plane/v1"
1213
mappingv1 "github.com/meridianhub/meridian/api/proto/meridian/mapping/v1"
14+
"github.com/robfig/cron/v3"
1315
"github.com/shopspring/decimal"
1416
)
1517

18+
const (
19+
// maxScheduledTriggersPerTenant is the maximum number of scheduled sagas per manifest.
20+
maxScheduledTriggersPerTenant = 20
21+
22+
// minCronInterval is the minimum allowed interval between cron occurrences.
23+
minCronInterval = 15 * time.Minute
24+
25+
// warnCronInterval is the threshold above which a schedule is considered very infrequent.
26+
warnCronInterval = 365 * 24 * time.Hour
27+
)
28+
29+
// cronParser matches the scheduler's parser: standard 5-field cron (no seconds).
30+
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
31+
1632
// accountIDPattern matches valid Stripe Connect account IDs (acct_ followed by 16+ alphanumeric chars).
1733
var accountIDPattern = regexp.MustCompile(`^acct_[A-Za-z0-9]{16,}$`)
1834

@@ -362,21 +378,26 @@ func (v *ManifestValidator) validateWebhookTriggers(
362378
}
363379
}
364380

365-
// validateScheduledTriggers enforces that scheduled trigger names are unique across all sagas.
381+
// validateScheduledTriggers enforces uniqueness, cron syntax, minimum interval,
382+
// maximum schedule count, and warns on very infrequent schedules.
366383
func (v *ManifestValidator) validateScheduledTriggers(
367384
manifest *controlplanev1.Manifest,
368385
result *ValidationResult,
369386
) {
370387
// Track seen schedule names → first saga index.
371388
seen := make(map[string]int)
389+
scheduledCount := 0
372390

373391
for i, saga := range manifest.GetSagas() {
374392
trigger := saga.GetTrigger()
375393
if !strings.HasPrefix(trigger, "scheduled:") {
376394
continue
377395
}
396+
scheduledCount++
397+
398+
remainder := strings.TrimPrefix(trigger, "scheduled:")
399+
name, cronExpr := parsScheduledTrigger(remainder)
378400

379-
name := strings.TrimPrefix(trigger, "scheduled:")
380401
if firstIdx, exists := seen[name]; exists {
381402
addError(result, ValidationError{
382403
Severity: SeverityError,
@@ -387,6 +408,74 @@ func (v *ManifestValidator) validateScheduledTriggers(
387408
} else {
388409
seen[name] = i
389410
}
411+
412+
if cronExpr != "" {
413+
v.validateCronExpression(cronExpr, fmt.Sprintf("sagas[%d].trigger", i), saga.GetName(), result)
414+
}
415+
}
416+
417+
if scheduledCount > maxScheduledTriggersPerTenant {
418+
addError(result, ValidationError{
419+
Severity: SeverityError,
420+
Path: "sagas",
421+
Code: "TOO_MANY_SCHEDULED_TRIGGERS",
422+
Message: fmt.Sprintf("manifest has %d scheduled triggers, maximum is %d", scheduledCount, maxScheduledTriggersPerTenant),
423+
})
424+
}
425+
}
426+
427+
// parsScheduledTrigger splits "name" or "name:cron expr" into (name, cronExpr).
428+
// The cron expression may contain spaces, so only the first colon is used as separator.
429+
func parsScheduledTrigger(remainder string) (name, cronExpr string) {
430+
idx := strings.Index(remainder, ":")
431+
if idx < 0 {
432+
return remainder, ""
433+
}
434+
return remainder[:idx], remainder[idx+1:]
435+
}
436+
437+
// validateCronExpression parses a cron expression and checks interval constraints.
438+
func (v *ManifestValidator) validateCronExpression(expr, path, sagaName string, result *ValidationResult) {
439+
schedule, err := cronParser.Parse(expr)
440+
if err != nil {
441+
addError(result, ValidationError{
442+
Severity: SeverityError,
443+
Path: path,
444+
Code: "INVALID_CRON_EXPRESSION",
445+
Message: fmt.Sprintf("invalid cron expression %q: %s", expr, err.Error()),
446+
ResourceType: "saga",
447+
ResourceID: sagaName,
448+
})
449+
return
450+
}
451+
452+
// Compute next two occurrences to determine the interval.
453+
now := time.Now()
454+
t1 := schedule.Next(now)
455+
t2 := schedule.Next(t1)
456+
interval := t2.Sub(t1)
457+
458+
if interval < minCronInterval {
459+
addError(result, ValidationError{
460+
Severity: SeverityError,
461+
Path: path,
462+
Code: "CRON_INTERVAL_TOO_SHORT",
463+
Message: fmt.Sprintf("cron expression %q fires every %s, minimum interval is %s", expr, interval.Round(time.Second), minCronInterval),
464+
ResourceType: "saga",
465+
ResourceID: sagaName,
466+
})
467+
return
468+
}
469+
470+
if interval >= warnCronInterval {
471+
addError(result, ValidationError{
472+
Severity: SeverityWarning,
473+
Path: path,
474+
Code: "CRON_VERY_INFREQUENT",
475+
Message: fmt.Sprintf("cron expression %q fires every %s, which is more than 365 days", expr, interval.Round(time.Hour)),
476+
ResourceType: "saga",
477+
ResourceID: sagaName,
478+
})
390479
}
391480
}
392481

services/control-plane/internal/validator/domain_validator_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package validator
22

33
import (
4+
"fmt"
45
"testing"
56

67
controlplanev1 "github.com/meridianhub/meridian/api/proto/meridian/control_plane/v1"
@@ -399,3 +400,125 @@ func TestValidateMappingIdempotency_ValidContentHash(t *testing.T) {
399400
v.validateMappingIdempotency(mp, "mappings[0]", result)
400401
assert.Empty(t, result.Errors)
401402
}
403+
404+
// ─── Scheduled Trigger Cron Validation ───────────────────────────────────────
405+
406+
func TestValidateScheduledTriggers_ValidCronExpression(t *testing.T) {
407+
v, err := New()
408+
require.NoError(t, err)
409+
410+
manifest := &controlplanev1.Manifest{
411+
Sagas: []*controlplanev1.SagaDefinition{
412+
{Name: "hourly_billing", Trigger: "scheduled:hourly_billing:0 * * * *"},
413+
},
414+
}
415+
416+
result := &ValidationResult{Valid: true}
417+
v.validateScheduledTriggers(manifest, result)
418+
assert.Empty(t, result.Errors)
419+
assert.Empty(t, result.Warnings)
420+
}
421+
422+
func TestValidateScheduledTriggers_NoCronExpression_Valid(t *testing.T) {
423+
v, err := New()
424+
require.NoError(t, err)
425+
426+
// scheduled:<name> with no cron is valid (cron comes from DB)
427+
manifest := &controlplanev1.Manifest{
428+
Sagas: []*controlplanev1.SagaDefinition{
429+
{Name: "billing", Trigger: "scheduled:billing"},
430+
},
431+
}
432+
433+
result := &ValidationResult{Valid: true}
434+
v.validateScheduledTriggers(manifest, result)
435+
assert.Empty(t, result.Errors)
436+
assert.Empty(t, result.Warnings)
437+
}
438+
439+
func TestValidateScheduledTriggers_InvalidCronSyntax(t *testing.T) {
440+
v, err := New()
441+
require.NoError(t, err)
442+
443+
manifest := &controlplanev1.Manifest{
444+
Sagas: []*controlplanev1.SagaDefinition{
445+
{Name: "billing", Trigger: "scheduled:billing:not-a-cron"},
446+
},
447+
}
448+
449+
result := &ValidationResult{Valid: true}
450+
v.validateScheduledTriggers(manifest, result)
451+
require.Len(t, result.Errors, 1)
452+
assert.Equal(t, "INVALID_CRON_EXPRESSION", result.Errors[0].Code)
453+
assert.Equal(t, "sagas[0].trigger", result.Errors[0].Path)
454+
}
455+
456+
func TestValidateScheduledTriggers_IntervalTooShort(t *testing.T) {
457+
v, err := New()
458+
require.NoError(t, err)
459+
460+
// Every minute - interval is 1 min, below 15 min minimum
461+
manifest := &controlplanev1.Manifest{
462+
Sagas: []*controlplanev1.SagaDefinition{
463+
{Name: "too_frequent", Trigger: "scheduled:too_frequent:* * * * *"},
464+
},
465+
}
466+
467+
result := &ValidationResult{Valid: true}
468+
v.validateScheduledTriggers(manifest, result)
469+
require.Len(t, result.Errors, 1)
470+
assert.Equal(t, "CRON_INTERVAL_TOO_SHORT", result.Errors[0].Code)
471+
}
472+
473+
func TestValidateScheduledTriggers_IntervalExactly15Minutes_Valid(t *testing.T) {
474+
v, err := New()
475+
require.NoError(t, err)
476+
477+
// Every 15 minutes - exactly at minimum
478+
manifest := &controlplanev1.Manifest{
479+
Sagas: []*controlplanev1.SagaDefinition{
480+
{Name: "quarter_hourly", Trigger: "scheduled:quarter_hourly:0,15,30,45 * * * *"},
481+
},
482+
}
483+
484+
result := &ValidationResult{Valid: true}
485+
v.validateScheduledTriggers(manifest, result)
486+
assert.Empty(t, result.Errors)
487+
}
488+
489+
func TestValidateScheduledTriggers_TooManySchedules(t *testing.T) {
490+
v, err := New()
491+
require.NoError(t, err)
492+
493+
sagas := make([]*controlplanev1.SagaDefinition, maxScheduledTriggersPerTenant+1)
494+
for i := range sagas {
495+
sagas[i] = &controlplanev1.SagaDefinition{
496+
Name: fmt.Sprintf("saga_%d", i),
497+
Trigger: fmt.Sprintf("scheduled:schedule_%d", i),
498+
}
499+
}
500+
501+
manifest := &controlplanev1.Manifest{Sagas: sagas}
502+
result := &ValidationResult{Valid: true}
503+
v.validateScheduledTriggers(manifest, result)
504+
require.Len(t, result.Errors, 1)
505+
assert.Equal(t, "TOO_MANY_SCHEDULED_TRIGGERS", result.Errors[0].Code)
506+
}
507+
508+
func TestValidateScheduledTriggers_InfrequentScheduleWarns(t *testing.T) {
509+
v, err := New()
510+
require.NoError(t, err)
511+
512+
// Every year on Jan 1 at midnight
513+
manifest := &controlplanev1.Manifest{
514+
Sagas: []*controlplanev1.SagaDefinition{
515+
{Name: "annual", Trigger: "scheduled:annual:0 0 1 1 *"},
516+
},
517+
}
518+
519+
result := &ValidationResult{Valid: true}
520+
v.validateScheduledTriggers(manifest, result)
521+
assert.Empty(t, result.Errors)
522+
require.Len(t, result.Warnings, 1)
523+
assert.Equal(t, "CRON_VERY_INFREQUENT", result.Warnings[0].Code)
524+
}

0 commit comments

Comments
 (0)