| // Copyright 2018 The LUCI Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package config |
| |
| import ( |
| "testing" |
| |
| "github.com/golang/protobuf/proto" |
| |
| "go.chromium.org/luci/appengine/gaetesting" |
| "go.chromium.org/luci/config/validation" |
| v2 "go.chromium.org/luci/cq/api/config/v2" |
| |
| . "github.com/smartystreets/goconvey/convey" |
| . "go.chromium.org/luci/common/testing/assertions" |
| ) |
| |
| func TestValidationRules(t *testing.T) { |
| t.Parallel() |
| |
| Convey("Validation Rules", t, func() { |
| c := gaetesting.TestingContextWithAppID("commit-queue") |
| patterns, err := validation.Rules.ConfigPatterns(c) |
| So(err, ShouldBeNil) |
| So(len(patterns), ShouldEqual, 2) |
| Convey("project-scope cq.cfg", func() { |
| So(patterns[0].ConfigSet.Match("projects/xyz"), ShouldBeTrue) |
| So(patterns[0].ConfigSet.Match("projects/xyz/refs/heads/master"), ShouldBeFalse) |
| So(patterns[0].Path.Match("commit-queue.cfg"), ShouldBeTrue) |
| }) |
| Convey("legacy ref-scope cq.cfg", func() { |
| So(patterns[1].ConfigSet.Match("projects/xyz"), ShouldBeFalse) |
| So(patterns[1].ConfigSet.Match("projects/xyz/refs/heads/master"), ShouldBeTrue) |
| So(patterns[1].Path.Match("cq.cfg"), ShouldBeTrue) |
| }) |
| }) |
| } |
| |
| func TestValidationLegacy(t *testing.T) { |
| t.Parallel() |
| |
| Convey("Validate Legacy Config", t, func() { |
| c := gaetesting.TestingContextWithAppID("commit-queue") |
| vctx := &validation.Context{Context: c} |
| configSet := "projects/foo/refs/heads/master" |
| path := "cq.cfg" |
| Convey("Loading any config", func() { |
| So(validateRef(vctx, configSet, path, []byte(`any!`)), ShouldBeNil) |
| err := vctx.Finalize() |
| So(err, ShouldErrLike, "delete cq.cfg") |
| }) |
| }) |
| } |
| |
| const validConfigTextPB = ` |
| draining_start_time: "2017-12-23T15:47:58Z" |
| cq_status_host: "example.com" |
| submit_options { |
| max_burst: 2 |
| burst_delay { seconds: 120 } |
| } |
| config_groups { |
| gerrit { |
| url: "https://chromium-review.googlesource.com" |
| projects { |
| name: "chromium/src" |
| } |
| } |
| verifiers { |
| tree_status { url: "https://chromium-status.appspot.com" } |
| gerrit_cq_ability { committer_list: "project-chromium-committers" } |
| tryjob { |
| retry_config { |
| single_quota: 1 |
| global_quota: 2 |
| failure_weight: 1 |
| transient_failure_weight: 1 |
| timeout_weight: 1 |
| } |
| builders { name: "chromium/try/linux" } |
| } |
| } |
| } |
| ` |
| |
| func TestValidation(t *testing.T) { |
| t.Parallel() |
| |
| Convey("Validate Config", t, func() { |
| c := gaetesting.TestingContext() |
| vctx := &validation.Context{Context: c} |
| configSet := "projects/foo" |
| path := "cq.cfg" |
| |
| Convey("Loading bad proto", func() { |
| content := []byte(` bad: "config" `) |
| So(validateProject(vctx, configSet, path, content), ShouldBeNil) |
| So(vctx.Finalize().Error(), ShouldContainSubstring, "unknown field") |
| }) |
| |
| // It's easier to manipulate Go struct than text. |
| cfg := v2.Config{} |
| So(proto.UnmarshalText(validConfigTextPB, &cfg), ShouldBeNil) |
| |
| Convey("OK", func() { |
| Convey("good proto, good config", func() { |
| So(validateProject(vctx, configSet, path, []byte(validConfigTextPB)), ShouldBeNil) |
| So(vctx.Finalize(), ShouldBeNil) |
| }) |
| Convey("good config", func() { |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldBeNil) |
| }) |
| }) |
| |
| Convey("Top-level config", func() { |
| Convey("Top level opts can be omitted", func() { |
| cfg.DrainingStartTime = "" |
| cfg.CqStatusHost = "" |
| cfg.SubmitOptions = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldBeNil) |
| }) |
| Convey("Bad draining time", func() { |
| cfg.DrainingStartTime = "meh" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, `failed to parse draining_start_time "meh" as RFC3339 format`) |
| }) |
| Convey("Bad cq_status_host", func() { |
| cfg.CqStatusHost = "h://@test:123//not//://@adsfhost." |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldNotBeNil) |
| }) |
| Convey("cq_status_host not just host", func() { |
| cfg.CqStatusHost = "example.com/path#fragment" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, `should be just a host "example.com"`) |
| }) |
| Convey("Bad max_burst", func() { |
| cfg.SubmitOptions.MaxBurst = -1 |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldNotBeNil) |
| }) |
| Convey("Bad burst_delay ", func() { |
| cfg.SubmitOptions.BurstDelay.Seconds = -1 |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldNotBeNil) |
| }) |
| Convey("at least 1 Config Group", func() { |
| cfg.ConfigGroups = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "at least 1 config_group is required") |
| }) |
| Convey("no obviously overlaping config_groups", func() { |
| cfg.ConfigGroups = append(cfg.ConfigGroups, cfg.ConfigGroups[0]) |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "aliases config_group #1") |
| }) |
| Convey("2nd heuristic against overlaping config_groups", func() { |
| // Store original valid first and only ConfigGroup. |
| orig := cfg.ConfigGroups[0] |
| cfg.ConfigGroups = nil |
| add := func(refRegexps ...string) { |
| // Add new regexps sequence with constant valid gerrit url and project and |
| // the same valid verifers. |
| cfg.ConfigGroups = append(cfg.ConfigGroups, &v2.ConfigGroup{ |
| Gerrit: []*v2.ConfigGroup_Gerrit{ |
| { |
| Url: orig.Gerrit[0].Url, |
| Projects: []*v2.ConfigGroup_Gerrit_Project{ |
| { |
| Name: orig.Gerrit[0].Projects[0].Name, |
| RefRegexp: refRegexps, |
| }, |
| }, |
| }, |
| }, |
| Verifiers: orig.Verifiers, |
| }) |
| } |
| Convey("infra/config", func() { |
| add("refs/heads/infra/config") |
| add("refs/.+") |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, `ref "refs/heads/infra/config" matches config_groups [0 1]`) |
| }) |
| Convey("master", func() { |
| add() // default, meaning refs/heads/master. |
| add("refs/branch-heads/.+") |
| add("refs/heads/.+") |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, `ref "refs/heads/master" matches config_groups [0 2]`) |
| }) |
| }) |
| }) |
| |
| Convey("ConfiGroups", func() { |
| Convey("with Gerrit", func() { |
| cfg.ConfigGroups[0].Gerrit = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "at least 1 gerrit is required") |
| }) |
| Convey("with Verifiers", func() { |
| cfg.ConfigGroups[0].Verifiers = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "verifiers are required") |
| }) |
| Convey("no dup Gerrit blocks", func() { |
| cfg.ConfigGroups[0].Gerrit = append(cfg.ConfigGroups[0].Gerrit, cfg.ConfigGroups[0].Gerrit[0]) |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "duplicate gerrit url in the same config_group") |
| }) |
| }) |
| |
| Convey("Gerrit", func() { |
| g := cfg.ConfigGroups[0].Gerrit[0] |
| Convey("needs valid URL", func() { |
| g.Url = "" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "url is required") |
| |
| g.Url = ":badscheme, bad URL" |
| vctx = &validation.Context{Context: c} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "failed to parse url") |
| }) |
| |
| Convey("without fancy URL components", func() { |
| g.Url = "bad://ok/path-not-good?query=too#neither-is-fragment" |
| validateProjectConfig(vctx, &cfg) |
| err := vctx.Finalize() |
| So(err, ShouldErrLike, "path component not yet allowed in url") |
| So(err, ShouldErrLike, "and 4 other errors") |
| }) |
| |
| Convey("current limitations", func() { |
| g.Url = "https://not.yet.allowed.com" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "only *.googlesource.com hosts supported for now") |
| |
| vctx = &validation.Context{Context: c} |
| g.Url = "new-scheme://chromium-review.googlesource.com" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "only 'https' scheme supported for now") |
| }) |
| Convey("at least 1 project required", func() { |
| g.Projects = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "at least 1 project is required") |
| }) |
| Convey("no dup project blocks", func() { |
| g.Projects = append(g.Projects, g.Projects[0]) |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "duplicate project in the same gerrit") |
| }) |
| }) |
| |
| Convey("Gerrit Project", func() { |
| p := cfg.ConfigGroups[0].Gerrit[0].Projects[0] |
| Convey("project name required", func() { |
| p.Name = "" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "name is required") |
| }) |
| Convey("incorrect project names", func() { |
| p.Name = "a/prefix-not-allowed/so-is-.git-suffix/.git" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldNotBeNil) |
| |
| vctx = &validation.Context{Context: c} |
| p.Name = "/prefix-not-allowed/so-is-/-suffix/" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldNotBeNil) |
| }) |
| Convey("bad regexp", func() { |
| p.RefRegexp = []string{"refs/heads/master", "*is-bad-regexp"} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): error parsing regexp:") |
| }) |
| Convey("duplicate regexp", func() { |
| p.RefRegexp = []string{"refs/heads/master", "refs/heads/master"} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "ref_regexp #2): duplicate regexp:") |
| }) |
| }) |
| |
| Convey("Verifiers", func() { |
| v := cfg.ConfigGroups[0].Verifiers |
| |
| Convey("fake not allowed", func() { |
| v.Fake = &v2.Verifiers_Fake{} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "fake verifier is not allowed") |
| }) |
| Convey("deprecator not allowed", func() { |
| v.Deprecator = &v2.Verifiers_Deprecator{} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "deprecator verifier is not allowed") |
| }) |
| Convey("tree_status", func() { |
| v.TreeStatus = &v2.Verifiers_TreeStatus{} |
| Convey("needs URL", func() { |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "url is required") |
| }) |
| Convey("needs https URL", func() { |
| v.TreeStatus.Url = "http://example.com/test" |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "url scheme must be 'https'") |
| }) |
| }) |
| Convey("gerrit_cq_ability", func() { |
| Convey("is required", func() { |
| v.GerritCqAbility = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "gerrit_cq_ability verifier is required") |
| }) |
| Convey("needs committer_list", func() { |
| v.GerritCqAbility.CommitterList = nil |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "committer_list is required") |
| }) |
| Convey("no empty committer_list", func() { |
| v.GerritCqAbility.CommitterList = []string{""} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "must not be empty") |
| }) |
| Convey("no empty dry_run_access_list", func() { |
| v.GerritCqAbility.DryRunAccessList = []string{""} |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, "must not be empty") |
| }) |
| }) |
| }) |
| |
| Convey("Tryjob", func() { |
| v := cfg.ConfigGroups[0].Verifiers.Tryjob |
| |
| Convey("really bad retry config", func() { |
| v.RetryConfig.SingleQuota = -1 |
| v.RetryConfig.GlobalQuota = -1 |
| v.RetryConfig.FailureWeight = -1 |
| v.RetryConfig.TransientFailureWeight = -1 |
| v.RetryConfig.TimeoutWeight = -1 |
| validateProjectConfig(vctx, &cfg) |
| So(vctx.Finalize(), ShouldErrLike, |
| "negative single_quota not allowed (-1 given) (and 4 other errors)") |
| }) |
| }) |
| }) |
| } |
| |
| func TestTryjobValidation(t *testing.T) { |
| t.Parallel() |
| |
| Convey("Validate Tryjob Verifier Config", t, func() { |
| c := gaetesting.TestingContext() |
| validate := func(textPB string) error { |
| vctx := &validation.Context{Context: c} |
| cfg := v2.Verifiers_Tryjob{} |
| if err := proto.UnmarshalText(textPB, &cfg); err != nil { |
| panic(err) |
| } |
| validateTryjobVerifier(vctx, &cfg) |
| return vctx.Finalize() |
| } |
| |
| So(validate(``), ShouldErrLike, "at least 1 builder required") |
| |
| Convey("builder name", func() { |
| So(validate(`builders {}`), ShouldErrLike, "name is required") |
| So(validate(`builders {name: ""}`), ShouldErrLike, "name is required") |
| So(validate(`builders {name: "a"}`), ShouldErrLike, |
| `name "a" doesn't match required format`) |
| So(validate(`builders {name: "a/b/c" equivalent_to {name: "z"}}`), ShouldErrLike, |
| `name "z" doesn't match required format`) |
| So(validate(`builders {name: "b/luci.b.try/c"}`), ShouldErrLike, |
| `name "b/luci.b.try/c" is highly likely malformed;`) |
| |
| So(validate(` |
| builders {name: "a/b/c"} |
| builders {name: "a/b/c"} |
| `), ShouldErrLike, "duplicate") |
| |
| So(validate(` |
| builders {name: "*/buildbot/b"} |
| builders {name: "a/b/c" equivalent_to {name: "x/y/z"}} |
| `), ShouldBeNil) |
| }) |
| |
| Convey("experiment", func() { |
| So(validate(`builders {name: "a/b/c" experiment_percentage: 1}`), ShouldBeNil) |
| So(validate(`builders {name: "a/b/c" experiment_percentage: -1}`), ShouldNotBeNil) |
| So(validate(`builders {name: "a/b/c" experiment_percentage: 101}`), ShouldNotBeNil) |
| }) |
| |
| Convey("location_regexps", func() { |
| So(validate(`builders {name: "a/b/c" location_regexp: ""}`), |
| ShouldErrLike, "must not be empty") |
| So(validate(`builders {name: "a/b/c" location_regexp_exclude: "*"}`), |
| ShouldErrLike, "error parsing regexp: missing argument") |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| location_regexp: ".+" |
| location_regexp: ".+" |
| }`), ShouldErrLike, "duplicate") |
| |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| location_regexp: "a/.+" |
| location_regexp: "b" |
| location_regexp_exclude: "redundant/but/not/caught" |
| }`), ShouldBeNil) |
| }) |
| |
| Convey("equivalent_to", func() { |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| equivalent_to {name: "x/y/z" percentage: 10 owner_whitelist_group: "group"} |
| }`), |
| ShouldBeNil) |
| |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| equivalent_to {name: "x/y/z" percentage: -1 owner_whitelist_group: "group"} |
| }`), |
| ShouldErrLike, "percentage must be between 0 and 100") |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| equivalent_to {name: "a/b/c"} |
| }`), |
| ShouldErrLike, |
| `equivalent_to.name must not refer to already defined "a/b/c" builder`) |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| equivalent_to {name: "c/d/e"} |
| } |
| builders { |
| name: "x/y/z" |
| equivalent_to {name: "c/d/e"} |
| }`), |
| ShouldErrLike, |
| `duplicate name "c/d/e"`) |
| }) |
| |
| Convey("no combinations", func() { |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| experiment_percentage: 1 |
| equivalent_to {name: "c/d/e"}}`), |
| ShouldErrLike, |
| "combining [equivalent_to experiment_percentage] features not allowed") |
| So(validate(` |
| builders { |
| name: "a/b/c" |
| location_regexp: ".+" |
| triggered_by: "c/d/e" |
| } |
| builders { name: "c/d/e" } `), |
| ShouldErrLike, |
| "combining [triggered_by location_regexp[_exclude]] features not allowed") |
| }) |
| |
| Convey("triggered_by", func() { |
| So(validate(` |
| builders {name: "a/b/0" } |
| builders {name: "a/b/1" triggered_by: "a/b/0"} |
| builders {name: "a/b/21" triggered_by: "a/b/1"} |
| builders {name: "a/b/22" triggered_by: "a/b/1"} |
| `), ShouldBeNil) |
| |
| So(validate(`builders {name: "a/b/1" triggered_by: "a/b/0"}`), |
| ShouldErrLike, `triggered_by must refer to an existing builder, but "a/b/0" given`) |
| |
| So(validate(` |
| builders {name: "a/b/0" experiment_percentage: 10} |
| builders {name: "a/b/1" triggered_by: "a/b/0"} |
| `), ShouldErrLike, |
| `builder a/b/1): triggered_by must refer to an existing builder without`) |
| |
| Convey("doesn't form loops", func() { |
| So(validate(` |
| builders {name: "l/oo/p" triggered_by: "l/oo/p"} |
| `), ShouldErrLike, `triggered_by must refer to an existing builder without`) |
| |
| So(validate(` |
| builders {name: "tri/gger/able"} |
| builders {name: "l/oo/p1" triggered_by: "l/oo/p2"} |
| builders {name: "l/oo/p2" triggered_by: "l/oo/p1"} |
| `), ShouldErrLike, `triggered_by must refer to an existing builder without`) |
| }) |
| }) |
| }) |
| } |