Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 65 additions & 17 deletions cmd/prow-job-dispatcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,11 @@ type options struct {

prometheusDaysBefore int

createPR bool
githubLogin string
targetDir string
assign string
createPR bool
validateOnly bool
githubLogin string
targetDir string
assign string

enableClusters flagutil.Strings
disableClusters flagutil.Strings
Expand Down Expand Up @@ -87,6 +88,7 @@ func gatherOptions() options {
fs.IntVar(&o.prometheusDaysBefore, "prometheus-days-before", 14, "Number [1,15] of days before. Time 00-00-00 of that day will be used as time to query Prometheus. E.g., 1 means 00-00-00 of yesterday.")

fs.BoolVar(&o.createPR, "create-pr", false, "Create a pull request to the change made with this tool.")
fs.BoolVar(&o.validateOnly, "validate-only", false, "Only validate the cluster configuration and exit. Used for presubmit validation.")
fs.StringVar(&o.githubLogin, "github-login", githubLogin, "The GitHub username to use.")
fs.StringVar(&o.targetDir, "target-dir", "", "The directory containing the target repo.")
fs.StringVar(&o.assign, "assign", "ghost", "The github username or group name to assign the created pull request to.")
Expand All @@ -109,12 +111,6 @@ func gatherOptions() options {
}

func (o *options) validate() error {
if o.prowJobConfigDir == "" {
return fmt.Errorf("mandatory argument --prow-jobs-dir wasn't set")
}
if o.configPath == "" {
return fmt.Errorf("mandatory argument --config-path wasn't set")
}

if o.prometheusDaysBefore < 1 || o.prometheusDaysBefore > 15 {
return fmt.Errorf("--prometheus-days-before must be between 1 and 15")
Expand All @@ -124,12 +120,20 @@ func (o *options) validate() error {
logrus.Fatal("mandatory argument --cluster-config-path wasn't set")
}

if o.jobsStoragePath == "" {
logrus.Fatal("mandatory argument --jobs-storage-path wasn't set")
}

if o.slackTokenPath == "" {
logrus.Fatal("mandatory argument --slack-token-path wasn't set")
// These arguments are not required when running in validate-only mode
if !o.validateOnly {
if o.prowJobConfigDir == "" {
return fmt.Errorf("mandatory argument --prow-jobs-dir wasn't set")
}
if o.jobsStoragePath == "" {
return fmt.Errorf("mandatory argument --jobs-storage-path wasn't set")
}
if o.configPath == "" {
return fmt.Errorf("mandatory argument --config-path wasn't set")
}
if o.slackTokenPath == "" {
logrus.Fatal("mandatory argument --slack-token-path wasn't set")
}
}

enabled := o.enableClusters.StringSet()
Expand Down Expand Up @@ -159,7 +163,45 @@ func (o *options) validate() error {
return err
}
}
return o.PrometheusOptions.Validate()

// Prometheus validation is not required in validate-only mode
if !o.validateOnly {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have this condition in the function three times. I think if put correctly, it can be there only once

if err := o.PrometheusOptions.Validate(); err != nil {
return err
}
}

// If validate-only mode is enabled, run cluster configuration validation
if o.validateOnly {
logrus.Info("Validating cluster configuration...")

// Load and validate cluster configuration
clusterMap, blockedClusters, err := dispatcher.LoadClusterConfig(o.clusterConfigPath)
if err != nil {
return fmt.Errorf("failed to load config from %q: %w", o.clusterConfigPath, err)
}
outFilePath := filepath.Join("/tmp", filepath.Base(o.clusterConfigPath))
if err := dispatcher.SaveClusterConfigPreservingFormat(clusterMap, blockedClusters, o.clusterConfigPath, outFilePath); err != nil {
return fmt.Errorf("failed to save config to %q: %w", outFilePath, err)
}
// now diff the roundtripped file with the original
output, err := exec.Command("diff", "-u", outFilePath, o.clusterConfigPath).CombinedOutput() // Execute the diff command and get combined output
if err != nil {
switch e := err.(type) {
case *exec.ExitError:
fmt.Printf("Diff found, exit code: %d\n", e.ExitCode())
default:
logrus.Fatalf("Error running diff command: %v\n", err)
}
}

fmt.Println(string(output)) // Print the diff output
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please remove some of those comments? In some places, I would agree with them. But they are mostly disturbing. The code should be sufficient most of the time


logrus.Info("All validations passed successfully")
return err
}

return nil
}

// getCloudProvidersForE2ETests returns a set of cloud providers where a cluster is hosted for an e2e test defined in the given Prow job config.
Expand Down Expand Up @@ -658,6 +700,12 @@ func main() {
logrus.WithError(err).Fatal("Failed to complete options.")
}

// If validate-only mode is enabled, validation was already done in validate() method, just exit
if o.validateOnly {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add this to the o.validate method

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call, done

logrus.Info("Validation completed successfully")
os.Exit(0)
}

if o.createPR {
if err := o.PRCreationOptions.Finalize(); err != nil {
logrus.WithError(err).Fatal("Failed to finalize PR creation options")
Expand Down
151 changes: 151 additions & 0 deletions cmd/prow-job-dispatcher/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -523,3 +524,153 @@ func TestSendSlackMessage(t *testing.T) {
})
}
}

func TestValidateOnly(t *testing.T) {
testCases := []struct {
name string
clusterConfig string
wantErr bool
expectedErrMsg string
allowDiffError bool // Allow diff errors as they are informational
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain this to me? I would assume if I diff the file, every diff error is relevant. Why not?

}{
{
name: "valid cluster configuration",
clusterConfig: filepath.Join("testdata", t.Name(), "valid_clusters.yaml"),
wantErr: false,
allowDiffError: true, // Diff errors are expected and informational
},
{
name: "invalid cluster configuration",
clusterConfig: filepath.Join("testdata", t.Name(), "invalid_clusters.yaml"),
wantErr: true,
expectedErrMsg: "failed to load config",
allowDiffError: false,
},
{
name: "nonexistent cluster configuration",
clusterConfig: filepath.Join("testdata", t.Name(), "nonexistent_clusters.yaml"),
wantErr: true,
expectedErrMsg: "failed to load config",
allowDiffError: false,
},
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you maybe add more tests, with all sorts of different errors? They don't need to be files; the struct can be created in the test itself.
I would like to see tests:

  • if there is a capacity outside of the range 0-100
  • indentation errors
  • blocked with capacity/ blocked with disabled? (I don't know what is allowed)
    ...
    try to cover most of the common mistakes that can occur


for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
opts := options{
clusterConfigPath: tc.clusterConfig,
validateOnly: true,
prometheusDaysBefore: 7, // Valid value required for options validation
}

err := opts.validate()

if tc.wantErr && !tc.allowDiffError {
if err == nil {
t.Errorf("%s: expected error but got none", tc.name)
return
}
if tc.expectedErrMsg != "" && !strings.Contains(err.Error(), tc.expectedErrMsg) {
t.Errorf("%s: expected error containing %q, got %q", tc.name, tc.expectedErrMsg, err.Error())
}
} else if tc.allowDiffError {
// For cases where diff errors are allowed (informational only)
// We should either get no error or a diff-related error
if err != nil {
// Check if it's a diff-related error (exit status 1 from diff command)
if !strings.Contains(err.Error(), "exit status 1") {
t.Errorf("%s: expected diff error or no error, got: %v", tc.name, err)
}
}
// Diff errors are acceptable for valid configs
} else if !tc.wantErr {
if err != nil {
t.Errorf("%s: expected no error but got: %v", tc.name, err)
}
}
})
}
}

func TestOptionsValidateOnly(t *testing.T) {
testCases := []struct {
name string
opts options
wantErr bool
errMsg string
allowDiffError bool // Allow diff errors as they are informational
}{
{
name: "validate-only mode with minimal required args",
opts: options{
validateOnly: true,
clusterConfigPath: filepath.Join("testdata", "TestValidateOnly", "valid_clusters.yaml"),
prometheusDaysBefore: 7, // Valid value
},
wantErr: false,
allowDiffError: true, // Diff errors are acceptable for valid configs
},
{
name: "validate-only mode with invalid prometheus days",
opts: options{
validateOnly: true,
clusterConfigPath: "test-path",
prometheusDaysBefore: 0, // Invalid value
},
wantErr: true,
errMsg: "--prometheus-days-before must be between 1 and 15",
},
{
name: "validate-only mode skips prometheus validation",
opts: options{
validateOnly: true,
clusterConfigPath: filepath.Join("testdata", "TestValidateOnly", "valid_clusters.yaml"),
prometheusDaysBefore: 7, // Valid value
// PrometheusOptions not set - should not cause error in validate-only mode
},
wantErr: false,
allowDiffError: true, // Diff errors are acceptable for valid configs
},
{
name: "validate-only mode skips prow jobs dir requirement",
opts: options{
validateOnly: true,
clusterConfigPath: filepath.Join("testdata", "TestValidateOnly", "valid_clusters.yaml"),
prometheusDaysBefore: 7, // Valid value
// prowJobConfigDir not set - should not cause error in validate-only mode
},
wantErr: false,
allowDiffError: true, // Diff errors are acceptable for valid configs
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.opts.validate()

if tc.wantErr && !tc.allowDiffError {
if err == nil {
t.Errorf("%s: expected error but got none", tc.name)
return
}
if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) {
t.Errorf("%s: expected error containing %q, got %q", tc.name, tc.errMsg, err.Error())
}
} else if tc.allowDiffError {
// For cases where diff errors are allowed (informational only)
// We should either get no error or a diff-related error
if err != nil {
// Check if it's a diff-related error (exit status 1 from diff command)
if !strings.Contains(err.Error(), "exit status 1") {
t.Errorf("%s: expected diff error or no error, got: %v", tc.name, err)
}
}
// Diff errors are acceptable for valid configs
} else if !tc.wantErr {
if err != nil {
t.Errorf("%s: expected no error but got: %v", tc.name, err)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
aws:
- name: build01
capacity: 100
capabilities:
- intranet
- name: build03
capacity: invalid_capacity
gcp:
- name: build02
capacity: 80
capabilities:
- public
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
aws:
- name: build01
capacity: 100
capabilities:
- intranet
- name: build03
capacity: 50
disabled: true
gcp:
- name: build02
capacity: 80
capabilities:
- public
- name: build04
blocked: true
Loading