diff --git a/internal/routing/validator.go b/internal/routing/validator.go new file mode 100644 index 0000000..279371b --- /dev/null +++ b/internal/routing/validator.go @@ -0,0 +1,346 @@ +package routing + +import ( + "fmt" + "time" +) + +// ValidationError represents a single validation error +type ValidationError struct { + Path string // JSONPath where error occurred + Message string // Error message +} + +// ValidationResult contains all validation errors +type ValidationResult struct { + Valid bool + Errors []ValidationError +} + +// Validator validates WorkflowSpec and CronWorkflowSpec +type Validator struct { + kb *KnowledgeBase +} + +// NewValidator creates a new validator with knowledge base +func NewValidator(kb *KnowledgeBase) *Validator { + return &Validator{kb: kb} +} + +// ValidateWorkflowSpec validates a one-time workflow spec +func (v *Validator) ValidateWorkflowSpec(spec *WorkflowSpec) *ValidationResult { + result := &ValidationResult{ + Valid: true, + Errors: []ValidationError{}, + } + + if spec == nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec", + Message: "workflow spec cannot be nil", + }) + return result + } + + // Validate name + var errs []ValidationError + if spec.Name == "" { + result.Valid = false + errs = append(errs, ValidationError{ + Path: "spec.name", + Message: "workflow name is required", + }) + } + + // Validate states + if len(spec.States) == 0 { + result.Valid = false + errs = append(errs, ValidationError{ + Path: "spec.states", + Message: "at least one state is required", + }) + } + + // Validate each state + stateNames := make(map[string]bool) + + for i, state := range spec.States { + path := fmt.Sprintf("spec.states[%d]", i) + + if state.Name == "" { + result.Valid = false + errs = append(errs, ValidationError{ + Path: path + ".name", + Message: "state name is required", + }) + continue + } + + if stateNames[state.Name] { + result.Valid = false + errs = append(errs, ValidationError{ + Path: path + ".name", + Message: fmt.Sprintf("duplicate state name: %s", state.Name), + }) + } + stateNames[state.Name] = true + + // Validate state type + stateErrs := v.validateState(state, path) + if len(stateErrs) > 0 { + result.Valid = false + errs = append(errs, stateErrs...) + } + } + + // Validate state transitions + for i, state := range spec.States { + path := fmt.Sprintf("spec.states[%d]", i) + + // Check that Next state exists (if specified) + if state.Next != "" && !stateNames[state.Next] { + result.Valid = false + errs = append(errs, ValidationError{ + Path: path + ".next", + Message: fmt.Sprintf("state '%s' does not exist", state.Next), + }) + } + + // Check that Catch targets exist + for j, catchClause := range state.Catch { + if catchClause.Next != "" && !stateNames[catchClause.Next] { + result.Valid = false + errs = append(errs, ValidationError{ + Path: path + fmt.Sprintf(".catch[%d].next", j), + Message: fmt.Sprintf("state '%s' does not exist", catchClause.Next), + }) + } + } + } + + result.Errors = errs + return result +} + +// ValidateCronWorkflowSpec validates a scheduled workflow spec +func (v *Validator) ValidateCronWorkflowSpec(spec *CronWorkflowSpec) *ValidationResult { + result := &ValidationResult{ + Valid: true, + Errors: []ValidationError{}, + } + + if spec == nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec", + Message: "cron workflow spec cannot be nil", + }) + return result + } + + // Validate name + if spec.Name == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.name", + Message: "workflow name is required", + }) + } + + // Validate type + if spec.Type != "CronWorkflow" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.type", + Message: "type must be 'CronWorkflow'", + }) + } + + // Validate cron expression + if spec.Schedule == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.schedule", + Message: "cron schedule is required", + }) + } else { + if err := validateCronExpression(spec.Schedule); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.schedule", + Message: err.Error(), + }) + } + } + + // Validate timezone + if spec.Timezone == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.timezone", + Message: "timezone is required", + }) + } else { + if _, err := time.LoadLocation(spec.Timezone); err != nil { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.timezone", + Message: fmt.Sprintf("invalid timezone: %s", spec.Timezone), + }) + } + } + + // Validate maxConcurrent + if spec.MaxConcurrent < 1 { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.maxConcurrent", + Message: "maxConcurrent must be >= 1", + }) + } + + // Validate states (same as one-time workflow) + if len(spec.States) == 0 { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: "spec.states", + Message: "at least one state is required", + }) + } + + stateNames := make(map[string]bool) + for i, state := range spec.States { + path := fmt.Sprintf("spec.states[%d]", i) + + if state.Name == "" { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: path + ".name", + Message: "state name is required", + }) + continue + } + + if stateNames[state.Name] { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: path + ".name", + Message: fmt.Sprintf("duplicate state name: %s", state.Name), + }) + } + stateNames[state.Name] = true + + stateErrs := v.validateState(state, path) + if len(stateErrs) > 0 { + result.Valid = false + result.Errors = append(result.Errors, stateErrs...) + } + } + + // Validate state transitions + for i, state := range spec.States { + path := fmt.Sprintf("spec.states[%d]", i) + + if state.Next != "" && !stateNames[state.Next] { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: path + ".next", + Message: fmt.Sprintf("state '%s' does not exist", state.Next), + }) + } + + for j, catchClause := range state.Catch { + if catchClause.Next != "" && !stateNames[catchClause.Next] { + result.Valid = false + result.Errors = append(result.Errors, ValidationError{ + Path: path + fmt.Sprintf(".catch[%d].next", j), + Message: fmt.Sprintf("state '%s' does not exist", catchClause.Next), + }) + } + } + } + + return result +} + +// validateState validates a single state +func (v *Validator) validateState(state State, path string) []ValidationError { + var errs []ValidationError + + switch state.Type { + case StateTypeTask: + if state.Resource == "" { + errs = append(errs, ValidationError{ + Path: path + ".resource", + Message: "resource is required for Task state", + }) + } else { + // Check if activity exists in knowledge base + if v.kb != nil && !v.kb.HasActivity(state.Resource) { + errs = append(errs, ValidationError{ + Path: path + ".resource", + Message: fmt.Sprintf("activity '%s' not found in knowledge base", state.Resource), + }) + } + } + + // Validate timeout format + if state.Timeout != "" { + if err := validateDuration(state.Timeout); err != nil { + errs = append(errs, ValidationError{ + Path: path + ".timeout", + Message: err.Error(), + }) + } + } + + case StateTypePass: + // Pass state is valid with just a result + if state.Result == nil { + errs = append(errs, ValidationError{ + Path: path + ".result", + Message: "result is required for Pass state", + }) + } + + case StateTypeFail: + // Fail state requires error + if state.Error == "" { + errs = append(errs, ValidationError{ + Path: path + ".error", + Message: "error is required for Fail state", + }) + } + + default: + errs = append(errs, ValidationError{ + Path: path + ".type", + Message: fmt.Sprintf("invalid state type: %s", state.Type), + }) + } + + return errs +} + +// validateDuration validates Go duration string (e.g., "5m", "30s") +func validateDuration(dur string) error { + _, err := time.ParseDuration(dur) + if err != nil { + return fmt.Errorf("invalid duration: %s", dur) + } + return nil +} + +// String returns a human-readable validation result +func (vr *ValidationResult) String() string { + if vr.Valid { + return "Valid ✓" + } + + msg := fmt.Sprintf("Invalid ✗ (%d errors):\n", len(vr.Errors)) + for _, err := range vr.Errors { + msg += fmt.Sprintf(" %s: %s\n", err.Path, err.Message) + } + return msg +} diff --git a/internal/routing/validator_cron.go b/internal/routing/validator_cron.go new file mode 100644 index 0000000..ee13bed --- /dev/null +++ b/internal/routing/validator_cron.go @@ -0,0 +1,56 @@ +package routing + +import ( + "fmt" + "regexp" + "strings" +) + +// validateCronExpression validates a cron expression (simplified) +// Supports standard 5-field cron: minute hour day month weekday +// Does NOT validate all possible edge cases - just basic format +func validateCronExpression(expr string) error { + fields := strings.Fields(expr) + if len(fields) != 5 { + return fmt.Errorf("cron expression must have 5 fields (minute hour day month weekday), got %d", len(fields)) + } + + // Validate field ranges + ranges := []struct { + name string + min int + max int + }{ + {"minute", 0, 59}, + {"hour", 0, 23}, + {"day", 1, 31}, + {"month", 1, 12}, + {"weekday", 0, 6}, + } + + // Basic pattern: * or */n or n or n-m or n,m or n-m/p + // This is simplified and doesn't validate all edge cases + fieldRegex := regexp.MustCompile(`^(\*|(\d+)(,(\d+))*(\/\d+)?|(\d+)-(\d+)(\/\d+)?|\*\/\d+)$`) + + for i, field := range fields { + if field == "*" { + continue + } + + // Check basic format + if !fieldRegex.MatchString(field) { + return fmt.Errorf("invalid %s field: %s", ranges[i].name, field) + } + + // Validate simple number values + if !strings.ContainsAny(field, "*,-/") { + var val int + _, _ = fmt.Sscanf(field, "%d", &val) + if val < ranges[i].min || val > ranges[i].max { + return fmt.Errorf("invalid %s value %d (range %d-%d)", ranges[i].name, val, ranges[i].min, ranges[i].max) + } + } + } + + return nil +} diff --git a/internal/routing/validator_test.go b/internal/routing/validator_test.go new file mode 100644 index 0000000..a06d0ad --- /dev/null +++ b/internal/routing/validator_test.go @@ -0,0 +1,540 @@ +package routing + +import ( + "testing" +) + +func TestValidateValidWorkflowSpec(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test-workflow", + Input: map[string]interface{}{ + "repo": "https://github.com/test/repo", + }, + States: []State{ + { + Name: "Clone", + Type: StateTypeTask, + Resource: "CloneRepoActivity", + Parameters: map[string]interface{}{ + "repo": "${input.repo}", + }, + Timeout: "5m", + Next: "Analyze", + }, + { + Name: "Analyze", + Type: StateTypeTask, + Resource: "AnalyzeCodeActivity", + Parameters: map[string]interface{}{ + "path": "${Clone.output.path}", + }, + Timeout: "10m", + End: true, + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if !result.Valid { + t.Errorf("Spec should be valid. Errors: %v", result.Errors) + } +} + +func TestValidateWorkflowSpecNilName(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "", + States: []State{ + { + Name: "Test", + Type: StateTypeTask, + Resource: "CloneRepoActivity", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with empty name should be invalid") + } + + if len(result.Errors) == 0 { + t.Error("Should have validation errors") + } +} + +func TestValidateWorkflowSpecNoStates(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{}, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with no states should be invalid") + } +} + +func TestValidateInvalidStateTransition(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "A", + Type: StateTypeTask, + Resource: "CloneRepoActivity", + Next: "NonExistent", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with invalid state transition should be invalid") + } +} + +func TestValidateDuplicateStateName(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "A", + Type: StateTypeTask, + Resource: "CloneRepoActivity", + }, + { + Name: "A", // Duplicate! + Type: StateTypeTask, + Resource: "CloneRepoActivity", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with duplicate state names should be invalid") + } +} + +func TestValidateUnknownActivity(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "A", + Type: StateTypeTask, + Resource: "UnknownActivity", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with unknown activity should be invalid") + } +} + +func TestValidatePassState(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "Success", + Type: StateTypePass, + Result: map[string]interface{}{"status": "ok"}, + End: true, + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if !result.Valid { + t.Errorf("Pass state spec should be valid: %v", result.Errors) + } +} + +func TestValidatePassStateNoResult(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "Success", + Type: StateTypePass, + End: true, + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Pass state without result should be invalid") + } +} + +func TestValidateFailState(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "Fail", + Type: StateTypeFail, + Error: "TestError", + Cause: "For testing", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if !result.Valid { + t.Errorf("Fail state spec should be valid: %v", result.Errors) + } +} + +func TestValidateInvalidDuration(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &WorkflowSpec{ + Name: "test", + States: []State{ + { + Name: "A", + Type: StateTypeTask, + Resource: "CloneRepoActivity", + Timeout: "invalid", + }, + }, + } + + result := validator.ValidateWorkflowSpec(spec) + if result.Valid { + t.Error("Spec with invalid timeout should be invalid") + } +} + +func TestValidateCronWorkflowSpec(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &CronWorkflowSpec{ + Name: "daily-scan", + Type: "CronWorkflow", + Schedule: "0 2 * * *", + Timezone: "UTC", + MaxConcurrent: 1, + States: []State{ + { + Name: "Scan", + Type: StateTypeTask, + Resource: "SecurityScanActivity", + End: true, + }, + }, + } + + result := validator.ValidateCronWorkflowSpec(spec) + if !result.Valid { + t.Errorf("Valid cron spec should pass: %v", result.Errors) + } +} + +func TestValidateCronWorkflowSpecInvalidTimezone(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &CronWorkflowSpec{ + Name: "daily-scan", + Type: "CronWorkflow", + Schedule: "0 2 * * *", + Timezone: "InvalidTimezone", + MaxConcurrent: 1, + States: []State{ + { + Name: "Scan", + Type: StateTypeTask, + Resource: "SecurityScanActivity", + End: true, + }, + }, + } + + result := validator.ValidateCronWorkflowSpec(spec) + if result.Valid { + t.Error("Cron spec with invalid timezone should be invalid") + } +} + +func TestValidateCronWorkflowSpecInvalidSchedule(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &CronWorkflowSpec{ + Name: "daily-scan", + Type: "CronWorkflow", + Schedule: "invalid cron", + Timezone: "UTC", + MaxConcurrent: 1, + States: []State{ + { + Name: "Scan", + Type: StateTypeTask, + Resource: "SecurityScanActivity", + End: true, + }, + }, + } + + result := validator.ValidateCronWorkflowSpec(spec) + if result.Valid { + t.Error("Cron spec with invalid schedule should be invalid") + } +} + +func TestValidateCronWorkflowSpecInvalidType(t *testing.T) { + kbPath := getKBPath() + if kbPath == "" { + t.Skip("Knowledge base not found") + } + + kb, err := LoadKnowledgeBase(kbPath) + if err != nil { + t.Fatalf("Failed to load KB: %v", err) + } + + validator := NewValidator(kb) + + spec := &CronWorkflowSpec{ + Name: "daily-scan", + Type: "WrongType", + Schedule: "0 2 * * *", + Timezone: "UTC", + MaxConcurrent: 1, + States: []State{ + { + Name: "Scan", + Type: StateTypeTask, + Resource: "SecurityScanActivity", + End: true, + }, + }, + } + + result := validator.ValidateCronWorkflowSpec(spec) + if result.Valid { + t.Error("Cron spec with wrong type should be invalid") + } +} + +func TestValidateDuration(t *testing.T) { + validDurations := []string{"1s", "5m", "1h", "100ms"} + for _, dur := range validDurations { + if err := validateDuration(dur); err != nil { + t.Errorf("Duration %s should be valid: %v", dur, err) + } + } + + invalidDurations := []string{"invalid", "5x", ""} + for _, dur := range invalidDurations { + if err := validateDuration(dur); err == nil { + t.Errorf("Duration %s should be invalid", dur) + } + } +} + +func TestValidateCronExpression(t *testing.T) { + validCrons := []string{ + "0 2 * * *", // 2 AM daily + "*/5 * * * *", // Every 5 minutes + "0 0 1 * *", // First of month + "0 12 * * 1", // Noon on Mondays + "30 15 * * *", // 3:30 PM daily + } + + for _, cron := range validCrons { + if err := validateCronExpression(cron); err != nil { + t.Errorf("Cron %s should be valid: %v", cron, err) + } + } + + invalidCrons := []string{ + "invalid", // Too few fields + "0 2 * * * *", // Too many fields + "60 * * * *", // Invalid minute + "* 25 * * *", // Invalid hour + } + + for _, cron := range invalidCrons { + if err := validateCronExpression(cron); err == nil { + t.Errorf("Cron %s should be invalid", cron) + } + } +} + +func TestValidationResultString(t *testing.T) { + result := &ValidationResult{ + Valid: true, + Errors: []ValidationError{}, + } + + str := result.String() + if str != "Valid ✓" { + t.Errorf("Valid result string should be 'Valid ✓', got: %s", str) + } + + result.Valid = false + result.Errors = []ValidationError{ + { + Path: "spec.name", + Message: "name is required", + }, + } + + str = result.String() + if !contains(str, "Invalid") { + t.Error("Invalid result string should contain 'Invalid'") + } +} diff --git a/k8s/git-commit.yaml b/k8s/git-commit.yaml index c98edbc..33294a4 100644 --- a/k8s/git-commit.yaml +++ b/k8s/git-commit.yaml @@ -9,6 +9,6 @@ metadata: app.kubernetes.io/name: poimen app.kubernetes.io/component: orchestrator data: - GIT_COMMIT: "f03184ad" # Updated automatically by CI/CD + GIT_COMMIT: "b17def78" # Updated automatically by CI/CD GIT_BRANCH: "main" DEPLOYMENT_DATE: "2026-08-31" diff --git a/k8s/worker-deployment.yaml b/k8s/worker-deployment.yaml index 963866b..72b2975 100644 --- a/k8s/worker-deployment.yaml +++ b/k8s/worker-deployment.yaml @@ -13,7 +13,7 @@ spec: labels: app: poimen-worker annotations: - git-commit: "f03184ad" # ✅ Updated on each push, triggers rolling restart + git-commit: "b17def78" # ✅ Updated on each push, triggers rolling restart deployment-date: "2026-08-31" spec: containers: