diff --git a/cmd/branchingconfigmanagers/frequency-reducer/main.go b/cmd/branchingconfigmanagers/frequency-reducer/main.go index 8cbca358818..e8ae9593533 100644 --- a/cmd/branchingconfigmanagers/frequency-reducer/main.go +++ b/cmd/branchingconfigmanagers/frequency-reducer/main.go @@ -3,13 +3,18 @@ package main import ( "flag" "fmt" + "io/ioutil" "math/rand" + "os" "regexp" + "runtime" "strings" + "sync" "time" "github.com/sirupsen/logrus" "gopkg.in/robfig/cron.v2" + "gopkg.in/yaml.v2" utilerrors "k8s.io/apimachinery/pkg/util/errors" @@ -18,9 +23,16 @@ import ( "github.com/openshift/ci-tools/pkg/config" ) +// ClusterProfilesConfig defines the YAML structure for cluster profiles filtering +type ClusterProfilesConfig struct { + ClusterProfiles []string `yaml:"cluster_profiles"` +} + type options struct { config.ConfirmableOptions - currentOCPVersion string + currentOCPVersion string + maxThreads int + clusterProfilesConfig string } func (o options) validate() error { @@ -28,6 +40,9 @@ func (o options) validate() error { if err := o.ConfirmableOptions.Validate(); err != nil { errs = append(errs, err) } + if o.maxThreads <= 0 { + errs = append(errs, fmt.Errorf("max-threads must be positive, got %d", o.maxThreads)) + } return utilerrors.NewAggregate(errs) } @@ -35,6 +50,8 @@ func (o options) validate() error { func gatherOptions() options { o := options{} flag.StringVar(&o.currentOCPVersion, "current-release", "", "Current OCP version") + flag.IntVar(&o.maxThreads, "max-threads", runtime.NumCPU(), "Maximum number of threads to use for parallel processing") + flag.StringVar(&o.clusterProfilesConfig, "cluster-profiles-config", "", "Path to YAML file containing cluster profiles to filter by (optional)") o.Bind(flag.CommandLine) flag.Parse() @@ -57,43 +74,228 @@ func main() { logrus.Fatalf("Couldn't complete the config options: %v", err) } - if err := o.OperateOnCIOperatorConfigDir(o.ConfigDir, func(configuration *api.ReleaseBuildConfiguration, info *config.Info) error { - output := config.DataWithInfo{Configuration: *configuration, Info: *info} - updateIntervalFieldsForMatchedSteps(&output, *ocpVersion) - - if err := output.CommitTo(o.ConfigDir); err != nil { - logrus.WithError(err).Fatal("commitTo failed") + // Load cluster profiles filter if provided + var allowedClusterProfiles map[string]bool + if o.clusterProfilesConfig != "" { + var err error + allowedClusterProfiles, err = loadClusterProfilesConfig(o.clusterProfilesConfig) + if err != nil { + logrus.WithError(err).Fatal("Could not load cluster profiles configuration.") } + logrus.Infof("Loaded cluster profiles filter: %d profiles specified", len(allowedClusterProfiles)) + } else { + logrus.Info("No cluster profiles filter specified, processing all configurations") + } + + if err := processConfigurationsInParallel(&o, *ocpVersion, allowedClusterProfiles); err != nil { + logrus.WithError(err).Fatal("Could not process configurations.") + } + +} + +// loadClusterProfilesConfig loads and parses the cluster profiles configuration file +func loadClusterProfilesConfig(filePath string) (map[string]bool, error) { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("cluster profiles config file does not exist: %s", filePath) + } + + data, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read cluster profiles config file: %w", err) + } + + var config ClusterProfilesConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse cluster profiles config YAML: %w", err) + } + + if len(config.ClusterProfiles) == 0 { + return nil, fmt.Errorf("no cluster profiles specified in config file") + } + + // Convert to map for O(1) lookup + allowedProfiles := make(map[string]bool) + for _, profile := range config.ClusterProfiles { + allowedProfiles[profile] = true + logrus.Debugf("Allowing cluster profile: %s", profile) + } + + return allowedProfiles, nil +} + +type configJob struct { + configuration *api.ReleaseBuildConfiguration + info *config.Info + configDir string +} + +func processConfigurationsInParallel(o *options, ocpVersion ocplifecycle.MajorMinor, allowedClusterProfiles map[string]bool) error { + var jobs []configJob + var jobsMutex sync.Mutex + + err := o.OperateOnCIOperatorConfigDir(o.ConfigDir, func(configuration *api.ReleaseBuildConfiguration, info *config.Info) error { + jobsMutex.Lock() + jobs = append(jobs, configJob{ + configuration: configuration, + info: info, + configDir: o.ConfigDir, + }) + jobsMutex.Unlock() return nil - }); err != nil { - logrus.WithError(err).Fatal("Could not branch configurations.") + }) + if err != nil { + return fmt.Errorf("failed to collect configurations: %w", err) } + jobsChan := make(chan configJob, len(jobs)) + errorsChan := make(chan error, o.maxThreads) + + var errors []error + var errorMutex sync.Mutex + var errorWg sync.WaitGroup + errorWg.Add(1) + + go func() { + defer errorWg.Done() + for err := range errorsChan { + errorMutex.Lock() + errors = append(errors, err) + errorMutex.Unlock() + } + }() + + var wg sync.WaitGroup + var processedCount int64 + var processedMutex sync.Mutex + + for i := 0; i < o.maxThreads; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + workerProcessedCount := 0 + logrus.Infof("Worker %d started", workerID) + + for job := range jobsChan { + if err := processConfiguration(job, ocpVersion, workerID, allowedClusterProfiles); err != nil { + select { + case errorsChan <- err: + default: + logrus.WithError(err).Errorf("Worker %d failed to process configuration, error channel full", workerID) + } + } + + workerProcessedCount++ + processedMutex.Lock() + processedCount++ + currentProcessed := processedCount + processedMutex.Unlock() + + if currentProcessed%100 == 0 || currentProcessed == int64(len(jobs)) { + logrus.Infof("Progress: %d/%d configurations processed (%.1f%%)", + currentProcessed, len(jobs), float64(currentProcessed)/float64(len(jobs))*100) + } + } + + logrus.Infof("Worker %d finished processing %d configurations", workerID, workerProcessedCount) + }(i) + } + + logrus.Infof("Processing %d configurations with %d threads", len(jobs), o.maxThreads) + for _, job := range jobs { + jobsChan <- job + } + close(jobsChan) + + wg.Wait() + + close(errorsChan) + errorWg.Wait() + + errorMutex.Lock() + finalErrors := make([]error, len(errors)) + copy(finalErrors, errors) + errorMutex.Unlock() + + successCount := len(jobs) - len(finalErrors) + logrus.Infof("Processing completed: %d successful, %d errors out of %d total configurations", + successCount, len(finalErrors), len(jobs)) + + if len(finalErrors) > 0 { + logrus.Errorf("Failed to process %d configurations", len(finalErrors)) + return utilerrors.NewAggregate(finalErrors) + } + + logrus.Info("All configurations processed successfully") + return nil +} + +func processConfiguration(job configJob, ocpVersion ocplifecycle.MajorMinor, workerID int, allowedClusterProfiles map[string]bool) error { + output := config.DataWithInfo{Configuration: *job.configuration, Info: *job.info} + + configPath := job.info.RelativePath() + logger := logrus.WithFields(logrus.Fields{ + "worker": workerID, + "org": job.info.Metadata.Org, + "repo": job.info.Metadata.Repo, + "branch": job.info.Metadata.Branch, + "config": configPath, + "variant": job.info.Metadata.Variant, + }) + + logger.Info("Worker processing configuration") + + originalTestCount := len(output.Configuration.Tests) + updateIntervalFieldsForMatchedSteps(&output, ocpVersion, allowedClusterProfiles) + + modifiedTests := 0 + for _, test := range output.Configuration.Tests { + if test.Cron != nil || test.Interval != nil { + modifiedTests++ + } + } + + if err := output.CommitTo(job.configDir); err != nil { + logger.WithError(err).Error("Failed to commit configuration") + return fmt.Errorf("failed to commit configuration for %s/%s@%s: %w", + job.info.Metadata.Org, job.info.Metadata.Repo, job.info.Metadata.Branch, err) + } + + logger.WithFields(logrus.Fields{ + "total_tests": originalTestCount, + "modified_tests": modifiedTests, + }).Info("Worker completed processing configuration") + return nil } func updateIntervalFieldsForMatchedSteps( configuration *config.DataWithInfo, version ocplifecycle.MajorMinor, + allowedClusterProfiles map[string]bool, ) { testVersion, err := ocplifecycle.ParseMajorMinor(extractVersion(configuration.Info.Metadata.Branch)) if err != nil { return } if configuration.Info.Metadata.Org == "openshift" || configuration.Info.Metadata.Org == "openshift-priv" { - for _, test := range configuration.Configuration.Tests { + for i := range configuration.Configuration.Tests { + test := &configuration.Configuration.Tests[i] if !strings.Contains(test.As, "mirror-nightly-image") && !strings.Contains(test.As, "promote-") { + // Skip tests that don't match the cluster profiles filter + if allowedClusterProfiles != nil && !shouldProcessTest(test, allowedClusterProfiles) { + continue + } if test.Cron != nil { - // check if less then past past version - if testVersion.Less(ocplifecycle.MajorMinor{Major: version.Major, Minor: version.Minor - 2}) { - correctCron, err := isExecutedAtMostXTimesAMonth(*test.Cron, 1) + n3Version := ocplifecycle.MajorMinor{Major: version.Major, Minor: version.Minor - 3} + if testVersion.Less(n3Version) || *testVersion == n3Version { + correctCron, err := isExecutedAtMostOncePerYear(*test.Cron) if err != nil { logrus.Warningf("Can't parse cron string %s", *test.Cron) continue } if !correctCron { - *test.Cron = generateMonthlyCron() + *test.Cron = generateYearlyCron() } - } else if testVersion.GetVersion() == version.GetPastPastVersion() { + } else if testVersion.GetVersion() == fmt.Sprintf("%d.%d", version.Major, version.Minor-2) { correctCron, err := isExecutedAtMostXTimesAMonth(*test.Cron, 2) if err != nil { logrus.Warningf("Can't parse cron string %s", *test.Cron) @@ -114,21 +316,22 @@ func updateIntervalFieldsForMatchedSteps( } } if test.Interval != nil { - if testVersion.Less(ocplifecycle.MajorMinor{Major: version.Major, Minor: version.Minor - 2}) { + n3Version := ocplifecycle.MajorMinor{Major: version.Major, Minor: version.Minor - 3} + if testVersion.Less(n3Version) || *testVersion == n3Version { duration, err := time.ParseDuration(*test.Interval) if err != nil { - logrus.Warningf("Can't parse interval string %s", *test.Cron) + logrus.Warningf("Can't parse interval string %s", *test.Interval) continue } - if duration < time.Hour*24*28 { - cronExpr := generateWeeklyWeekendCron() + if duration < time.Hour*24*365 { + cronExpr := generateYearlyCron() test.Cron = &cronExpr test.Interval = nil } - } else if testVersion.GetVersion() == version.GetPastPastVersion() { + } else if testVersion.GetVersion() == fmt.Sprintf("%d.%d", version.Major, version.Minor-2) { duration, err := time.ParseDuration(*test.Interval) if err != nil { - logrus.Warningf("Can't parse interval string %s", *test.Cron) + logrus.Warningf("Can't parse interval string %s", *test.Interval) continue } if duration < time.Hour*24*14 { @@ -139,7 +342,7 @@ func updateIntervalFieldsForMatchedSteps( } else if testVersion.GetVersion() == version.GetPastVersion() { duration, err := time.ParseDuration(*test.Interval) if err != nil { - logrus.Warningf("Can't parse interval string %s", *test.Cron) + logrus.Warningf("Can't parse interval string %s", *test.Interval) continue } if duration < time.Hour*24*7 { @@ -154,6 +357,60 @@ func updateIntervalFieldsForMatchedSteps( } } +// shouldProcessTest checks if a test should be processed based on cluster profiles filter +func shouldProcessTest(test *api.TestStepConfiguration, allowedClusterProfiles map[string]bool) bool { + clusterProfile := test.GetClusterProfileName() + + // If the test doesn't have a cluster profile, include it + if clusterProfile == "" { + return true + } + + // Check if the cluster profile is in the allowed list + return allowedClusterProfiles[clusterProfile] +} + +func isExecutedAtMostOncePerYear(cronExpr string) (bool, error) { + switch strings.ToLower(cronExpr) { + case "@daily": + cronExpr = "0 0 * * *" + case "@weekly": + cronExpr = "0 0 * * 0" + case "@monthly": + cronExpr = "0 0 1 * *" + case "@yearly", "@annually": + cronExpr = "0 0 1 1 *" + } + + schedule, err := cron.Parse(cronExpr) + if err != nil { + return false, err + } + start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + end := start.AddDate(1, 0, 0) + + executionCount := 0 + maxIterations := 400 + iterations := 0 + + for { + iterations++ + if iterations > maxIterations { + logrus.Warningf("Cron expression '%s' might be invalid, stopping after %d iterations", cronExpr, maxIterations) + return false, fmt.Errorf("cron expression '%s' appears to be invalid or causes infinite loop", cronExpr) + } + + next := schedule.Next(start) + if next.After(end) || next.Equal(end) { + break + } + executionCount++ + start = next + } + + return executionCount <= 1, nil +} + func isExecutedAtMostXTimesAMonth(cronExpr string, x int) (bool, error) { switch strings.ToLower(cronExpr) { case "@daily": @@ -174,7 +431,16 @@ func isExecutedAtMostXTimesAMonth(cronExpr string, x int) (bool, error) { end := start.AddDate(0, 1, 0) executionCount := 0 + maxIterations := 100 // Allow counting up to ~100 executions per month (daily = ~31) + iterations := 0 + for { + iterations++ + if iterations > maxIterations { + logrus.Warningf("Cron expression '%s' might be invalid, stopping after %d iterations", cronExpr, maxIterations) + return false, fmt.Errorf("cron expression '%s' appears to be invalid or causes infinite loop", cronExpr) + } + next := schedule.Next(start) if next.After(end) { break @@ -200,6 +466,18 @@ func generateMonthlyCron() string { return fmt.Sprintf("%d %d %d * *", rand.Intn(60), rand.Intn(24), rand.Intn(28)+1) } +func generateYearlyCron() string { + // Generate a cron that runs once per year on a random day + // Format: minute hour day month * + // Pick a random month (1-12) and day (1-28 to avoid month boundary issues) + month := rand.Intn(12) + 1 + day := rand.Intn(28) + 1 + hour := rand.Intn(24) + minute := rand.Intn(60) + + return fmt.Sprintf("%d %d %d %d *", minute, hour, day, month) +} + func extractVersion(s string) string { pattern := `^(release|openshift)-(\d+\.\d+)$` re := regexp.MustCompile(pattern) diff --git a/cmd/branchingconfigmanagers/frequency-reducer/main_test.go b/cmd/branchingconfigmanagers/frequency-reducer/main_test.go new file mode 100644 index 00000000000..c2f21fe26c3 --- /dev/null +++ b/cmd/branchingconfigmanagers/frequency-reducer/main_test.go @@ -0,0 +1,845 @@ +package main + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/openshift/ci-tools/pkg/api" + "github.com/openshift/ci-tools/pkg/api/ocplifecycle" + "github.com/openshift/ci-tools/pkg/config" +) + +func TestExtractVersion(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "release branch", + input: "release-4.15", + expected: "4.15", + }, + { + name: "openshift branch", + input: "openshift-4.14", + expected: "4.14", + }, + { + name: "invalid format", + input: "main", + expected: "", + }, + { + name: "wrong prefix", + input: "feature-4.15", + expected: "", + }, + { + name: "no hyphen", + input: "release4.15", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := extractVersion(tc.input) + if result != tc.expected { + t.Errorf("extractVersion(%q) = %q, want %q", tc.input, result, tc.expected) + } + }) + } +} + +func TestIsExecutedAtMostOncePerYear(t *testing.T) { + testCases := []struct { + name string + cronExpr string + expected bool + expectError bool + }{ + { + name: "yearly", + cronExpr: "0 0 1 1 *", + expected: true, + expectError: false, + }, + { + name: "monthly", + cronExpr: "0 0 1 * *", + expected: false, + expectError: false, + }, + { + name: "daily", + cronExpr: "0 0 * * *", + expected: false, + expectError: false, + }, + { + name: "weekly", + cronExpr: "0 0 * * 0", + expected: false, + expectError: false, + }, + { + name: "@yearly", + cronExpr: "@yearly", + expected: true, + expectError: false, + }, + { + name: "@annually", + cronExpr: "@annually", + expected: true, + expectError: false, + }, + { + name: "@monthly", + cronExpr: "@monthly", + expected: false, + expectError: false, + }, + { + name: "@weekly", + cronExpr: "@weekly", + expected: false, + expectError: false, + }, + { + name: "@daily", + cronExpr: "@daily", + expected: false, + expectError: false, + }, + { + name: "custom yearly march", + cronExpr: "30 14 15 3 *", + expected: true, + expectError: false, + }, + { + name: "custom yearly december", + cronExpr: "0 0 25 12 *", + expected: true, + expectError: false, + }, + { + name: "invalid", + cronExpr: "invalid", + expected: false, + expectError: true, + }, + { + name: "too many fields", + cronExpr: "0 0 1 1 * 2024", + expected: false, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := isExecutedAtMostOncePerYear(tc.cronExpr) + + if tc.expectError { + if err == nil { + t.Errorf("isExecutedAtMostOncePerYear(%q) expected error, got nil", tc.cronExpr) + } + return + } + + if err != nil { + t.Errorf("isExecutedAtMostOncePerYear(%q) unexpected error: %v", tc.cronExpr, err) + return + } + + if result != tc.expected { + t.Errorf("isExecutedAtMostOncePerYear(%q) = %v, want %v", tc.cronExpr, result, tc.expected) + } + }) + } +} + +func TestIsExecutedAtMostXTimesAMonth(t *testing.T) { + testCases := []struct { + name string + cronExpr string + maxTimes int + expected bool + expectError bool + }{ + { + name: "daily limit 4", + cronExpr: "0 0 * * *", + maxTimes: 4, + expected: false, + expectError: false, + }, + { + name: "weekly limit 4", + cronExpr: "0 0 * * 0", + maxTimes: 4, + expected: true, + expectError: false, + }, + { + name: "monthly limit 1", + cronExpr: "0 0 1 * *", + maxTimes: 1, + expected: true, + expectError: false, + }, + { + name: "monthly limit 0", + cronExpr: "0 0 1 * *", + maxTimes: 0, + expected: false, + expectError: false, + }, + { + name: "bi-weekly limit 2", + cronExpr: "0 0 1,15 * *", + maxTimes: 2, + expected: true, + expectError: false, + }, + { + name: "bi-weekly limit 1", + cronExpr: "0 0 1,15 * *", + maxTimes: 1, + expected: false, + expectError: false, + }, + { + name: "@weekly limit 4", + cronExpr: "@weekly", + maxTimes: 4, + expected: true, + expectError: false, + }, + { + name: "@daily limit 31", + cronExpr: "@daily", + maxTimes: 31, + expected: true, + expectError: false, + }, + { + name: "@monthly limit 1", + cronExpr: "@monthly", + maxTimes: 1, + expected: true, + expectError: false, + }, + { + name: "invalid", + cronExpr: "invalid", + maxTimes: 1, + expected: false, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := isExecutedAtMostXTimesAMonth(tc.cronExpr, tc.maxTimes) + + if tc.expectError { + if err == nil { + t.Errorf("isExecutedAtMostXTimesAMonth(%q, %d) expected error, got nil", tc.cronExpr, tc.maxTimes) + } + return + } + + if err != nil { + t.Errorf("isExecutedAtMostXTimesAMonth(%q, %d) unexpected error: %v", tc.cronExpr, tc.maxTimes, err) + return + } + + if result != tc.expected { + t.Errorf("isExecutedAtMostXTimesAMonth(%q, %d) = %v, want %v", tc.cronExpr, tc.maxTimes, result, tc.expected) + } + }) + } +} + +func TestGenerateCronFunctions(t *testing.T) { + t.Run("generateYearlyCron", func(t *testing.T) { + for i := 0; i < 10; i++ { + cron := generateYearlyCron() + isYearly, err := isExecutedAtMostOncePerYear(cron) + if err != nil { + t.Errorf("generateYearlyCron() produced invalid cron: %q, error: %v", cron, err) + } + if !isYearly { + t.Errorf("generateYearlyCron() produced non-yearly cron: %q", cron) + } + } + }) + + t.Run("generateMonthlyCron", func(t *testing.T) { + for i := 0; i < 10; i++ { + cron := generateMonthlyCron() + isMonthly, err := isExecutedAtMostXTimesAMonth(cron, 1) + if err != nil { + t.Errorf("generateMonthlyCron() produced invalid cron: %q, error: %v", cron, err) + } + if !isMonthly { + t.Errorf("generateMonthlyCron() produced non-monthly cron: %q", cron) + } + } + }) + + t.Run("generateBiWeeklyCron", func(t *testing.T) { + for i := 0; i < 10; i++ { + cron := generateBiWeeklyCron() + isBiWeekly, err := isExecutedAtMostXTimesAMonth(cron, 2) + if err != nil { + t.Errorf("generateBiWeeklyCron() produced invalid cron: %q, error: %v", cron, err) + } + if !isBiWeekly { + t.Errorf("generateBiWeeklyCron() produced non-bi-weekly cron: %q", cron) + } + } + }) + + t.Run("generateWeeklyWeekendCron", func(t *testing.T) { + for i := 0; i < 10; i++ { + cron := generateWeeklyWeekendCron() + isWeekly, err := isExecutedAtMostXTimesAMonth(cron, 5) + if err != nil { + t.Errorf("generateWeeklyWeekendCron() produced invalid cron: %q, error: %v", cron, err) + } + if !isWeekly { + t.Errorf("generateWeeklyWeekendCron() produced invalid weekly cron: %q", cron) + } + } + }) +} + +func TestUpdateIntervalFieldsForMatchedSteps(t *testing.T) { + currentVersion := ocplifecycle.MajorMinor{Major: 4, Minor: 17} + + testCases := []struct { + name string + testVersion string + org string + testName string + initialCron *string + initialInterval *string + expectCronChange bool + expectIntervalChange bool + expectYearlyCron bool + }{ + { + name: "n-3 daily to yearly", + testVersion: "4.14", + org: "openshift", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: true, + expectIntervalChange: false, + expectYearlyCron: true, + }, + { + name: "n-3 yearly unchanged", + testVersion: "4.14", + org: "openshift", + testName: "e2e-test", + initialCron: stringPtr("0 0 1 6 *"), + initialInterval: nil, + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "n-2 daily to bi-weekly", + testVersion: "4.15", + org: "openshift", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: true, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "n-1 daily to weekly", + testVersion: "4.16", + org: "openshift", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: true, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "current unchanged", + testVersion: "4.17", + org: "openshift", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "non-openshift unchanged", + testVersion: "4.14", + org: "other-org", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "openshift-priv org", + testVersion: "4.14", + org: "openshift-priv", + testName: "e2e-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: true, + expectIntervalChange: false, + expectYearlyCron: true, + }, + { + name: "mirror test unchanged", + testVersion: "4.14", + org: "openshift", + testName: "mirror-nightly-image-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "promote test unchanged", + testVersion: "4.14", + org: "openshift", + testName: "promote-test", + initialCron: stringPtr("0 0 * * *"), + initialInterval: nil, + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + { + name: "n-3 interval to yearly", + testVersion: "4.14", + org: "openshift", + testName: "e2e-test", + initialCron: nil, + initialInterval: stringPtr("24h"), + expectCronChange: true, + expectIntervalChange: true, + expectYearlyCron: true, + }, + { + name: "n-3 long interval unchanged", + testVersion: "4.14", + org: "openshift", + testName: "e2e-test", + initialCron: nil, + initialInterval: stringPtr("8760h"), + expectCronChange: false, + expectIntervalChange: false, + expectYearlyCron: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + config := &config.DataWithInfo{ + Configuration: api.ReleaseBuildConfiguration{ + Tests: []api.TestStepConfiguration{ + { + As: tc.testName, + Cron: tc.initialCron, + Interval: tc.initialInterval, + }, + }, + }, + Info: config.Info{ + Metadata: api.Metadata{ + Org: tc.org, + Branch: "release-" + tc.testVersion, + }, + }, + } + + var originalCronValue string + if config.Configuration.Tests[0].Cron != nil { + originalCronValue = *config.Configuration.Tests[0].Cron + } + var originalIntervalValue string + if config.Configuration.Tests[0].Interval != nil { + originalIntervalValue = *config.Configuration.Tests[0].Interval + } + + updateIntervalFieldsForMatchedSteps(config, currentVersion, nil) // nil = no cluster profile filtering + + if tc.expectCronChange { + var currentCronValue string + if config.Configuration.Tests[0].Cron != nil { + currentCronValue = *config.Configuration.Tests[0].Cron + } + if currentCronValue == originalCronValue { + t.Errorf("Expected cron to change, but it remained: %v", originalCronValue) + } + if tc.expectYearlyCron && config.Configuration.Tests[0].Cron != nil { + isYearly, err := isExecutedAtMostOncePerYear(*config.Configuration.Tests[0].Cron) + if err != nil { + t.Errorf("Generated cron is invalid: %v", err) + } + if !isYearly { + t.Errorf("Expected yearly cron, got: %s", *config.Configuration.Tests[0].Cron) + } + } + } else { + var currentCronValue string + if config.Configuration.Tests[0].Cron != nil { + currentCronValue = *config.Configuration.Tests[0].Cron + } + if currentCronValue != originalCronValue { + t.Errorf("Expected cron to remain unchanged, but it changed from %v to %v", originalCronValue, currentCronValue) + } + } + + if tc.expectIntervalChange { + var currentIntervalValue string + if config.Configuration.Tests[0].Interval != nil { + currentIntervalValue = *config.Configuration.Tests[0].Interval + } + if currentIntervalValue == originalIntervalValue { + t.Errorf("Expected interval to change, but it remained: %v", originalIntervalValue) + } + if config.Configuration.Tests[0].Interval != nil { + t.Errorf("Expected interval to be nil after conversion, but got: %v", config.Configuration.Tests[0].Interval) + } + if config.Configuration.Tests[0].Cron == nil { + t.Errorf("Expected cron to be set after interval conversion, but it's nil") + } + } else if originalIntervalValue != "" { + var currentIntervalValue string + if config.Configuration.Tests[0].Interval != nil { + currentIntervalValue = *config.Configuration.Tests[0].Interval + } + if currentIntervalValue != originalIntervalValue { + t.Errorf("Expected interval to remain unchanged, but it changed from %v to %v", originalIntervalValue, currentIntervalValue) + } + } + }) + } +} + +func TestOptionsValidation(t *testing.T) { + testCases := []struct { + name string + options options + expectError bool + }{ + { + name: "valid options", + options: options{ + ConfirmableOptions: config.ConfirmableOptions{ + Options: config.Options{ + ConfigDir: "/tmp/test", + LogLevel: "info", + }, + }, + maxThreads: 4, + }, + expectError: false, + }, + { + name: "zero threads", + options: options{ + ConfirmableOptions: config.ConfirmableOptions{ + Options: config.Options{ + ConfigDir: "/tmp/test", + LogLevel: "info", + }, + }, + maxThreads: 0, + }, + expectError: true, + }, + { + name: "negative threads", + options: options{ + ConfirmableOptions: config.ConfirmableOptions{ + Options: config.Options{ + ConfigDir: "/tmp/test", + LogLevel: "info", + }, + }, + maxThreads: -1, + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.options.validate() + + if tc.expectError && err == nil { + t.Errorf("Expected validation error, but got none") + } + + if !tc.expectError && err != nil { + t.Errorf("Unexpected validation error: %v", err) + } + }) + } +} + +func TestGatherOptions(t *testing.T) { + originalArgs := os.Args + defer func() { os.Args = originalArgs }() + + os.Args = []string{"frequency-reducer", "-max-threads", "8", "-current-release", "4.17"} + + opts := gatherOptions() + + if opts.maxThreads != 8 { + t.Errorf("Expected maxThreads to be 8, got %d", opts.maxThreads) + } + + if opts.currentOCPVersion != "4.17" { + t.Errorf("Expected currentOCPVersion to be '4.17', got %q", opts.currentOCPVersion) + } +} + +func TestShouldProcessTest(t *testing.T) { + tests := []struct { + name string + testConfig *api.TestStepConfiguration + allowedClusterProfiles map[string]bool + expected bool + }{ + { + name: "test with allowed cluster profile", + testConfig: &api.TestStepConfiguration{ + As: "test-with-aws", + MultiStageTestConfiguration: &api.MultiStageTestConfiguration{ + ClusterProfile: api.ClusterProfileAWS, + }, + }, + allowedClusterProfiles: map[string]bool{ + "aws": true, + "gcp": true, + }, + expected: true, + }, + { + name: "test with disallowed cluster profile", + testConfig: &api.TestStepConfiguration{ + As: "test-with-azure", + MultiStageTestConfiguration: &api.MultiStageTestConfiguration{ + ClusterProfile: api.ClusterProfileAzure4, + }, + }, + allowedClusterProfiles: map[string]bool{ + "aws": true, + "gcp": true, + }, + expected: false, + }, + { + name: "test without cluster profile should be processed", + testConfig: &api.TestStepConfiguration{ + As: "test-without-cluster-profile", + }, + allowedClusterProfiles: map[string]bool{ + "aws": true, + }, + expected: true, + }, + { + name: "test without cluster profile with nil filter", + testConfig: &api.TestStepConfiguration{ + As: "test-without-cluster-profile", + }, + allowedClusterProfiles: nil, + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := shouldProcessTest(tc.testConfig, tc.allowedClusterProfiles) + if result != tc.expected { + t.Errorf("Expected shouldProcessTest to return %v, got %v", tc.expected, result) + } + }) + } +} + +func TestLoadClusterProfilesConfig(t *testing.T) { + tests := []struct { + name string + fileContent string + expectError bool + expected map[string]bool + }{ + { + name: "valid config file", + fileContent: `cluster_profiles: + - aws + - gcp + - azure4`, + expectError: false, + expected: map[string]bool{ + "aws": true, + "gcp": true, + "azure4": true, + }, + }, + { + name: "empty cluster profiles", + fileContent: `cluster_profiles: []`, + expectError: true, + expected: nil, + }, + { + name: "invalid YAML", + fileContent: `cluster_profiles: + - aws + - gcp +invalid: yaml: content`, + expectError: true, + expected: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create a temporary file + tmpFile, err := ioutil.TempFile("", "cluster-profiles-*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write content to file + if _, err := tmpFile.WriteString(tc.fileContent); err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tmpFile.Close() + + // Test the function + result, err := loadClusterProfilesConfig(tmpFile.Name()) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(result) != len(tc.expected) { + t.Errorf("Expected %d cluster profiles, got %d", len(tc.expected), len(result)) + return + } + + for profile := range tc.expected { + if !result[profile] { + t.Errorf("Expected cluster profile %s to be allowed", profile) + } + } + }) + } +} + +func TestUpdateIntervalFieldsWithClusterProfileFiltering(t *testing.T) { + currentVersion := ocplifecycle.MajorMinor{Major: 4, Minor: 17} + + tests := []struct { + name string + testConfig *api.TestStepConfiguration + allowedClusterProfiles map[string]bool + expectChange bool + }{ + { + name: "n-3 test with allowed cluster profile should be modified", + testConfig: &api.TestStepConfiguration{ + As: "test-allowed", + Cron: stringPtr("0 0 * * *"), // daily cron for n-3 should change to yearly + MultiStageTestConfiguration: &api.MultiStageTestConfiguration{ + ClusterProfile: api.ClusterProfileAWS, + }, + }, + allowedClusterProfiles: map[string]bool{ + "aws": true, + }, + expectChange: true, + }, + { + name: "n-3 test with disallowed cluster profile should not be modified", + testConfig: &api.TestStepConfiguration{ + As: "test-disallowed", + Cron: stringPtr("0 0 * * *"), // daily cron for n-3 should NOT change + MultiStageTestConfiguration: &api.MultiStageTestConfiguration{ + ClusterProfile: api.ClusterProfileGCP, + }, + }, + allowedClusterProfiles: map[string]bool{ + "aws": true, + }, + expectChange: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + config := &config.DataWithInfo{ + Configuration: api.ReleaseBuildConfiguration{ + Tests: []api.TestStepConfiguration{*tc.testConfig}, + }, + Info: config.Info{ + Metadata: api.Metadata{ + Org: "openshift", + Repo: "test-repo", + Branch: "release-4.14", // n-3 for 4.17 + }, + }, + } + + originalCron := *config.Configuration.Tests[0].Cron + updateIntervalFieldsForMatchedSteps(config, currentVersion, tc.allowedClusterProfiles) + + cronChanged := *config.Configuration.Tests[0].Cron != originalCron + + if tc.expectChange && !cronChanged { + t.Errorf("Expected cron to change for %s, but it remained: %s", tc.name, *config.Configuration.Tests[0].Cron) + } else if !tc.expectChange && cronChanged { + t.Errorf("Expected cron NOT to change for %s, but it changed from %s to %s", tc.name, originalCron, *config.Configuration.Tests[0].Cron) + } + }) + } +} + +func stringPtr(s string) *string { + return &s +}