go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luci_notify/config/validate.go (about) 1 // Copyright 2017 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package config 16 17 import ( 18 "fmt" 19 html "html/template" 20 "net/mail" 21 "regexp" 22 "strings" 23 text "text/template" 24 25 "github.com/golang/protobuf/proto" 26 27 "go.chromium.org/luci/common/api/gitiles" 28 "go.chromium.org/luci/common/data/stringset" 29 "go.chromium.org/luci/config/validation" 30 31 notifypb "go.chromium.org/luci/luci_notify/api/config" 32 "go.chromium.org/luci/luci_notify/mailtmpl" 33 ) 34 35 // init registers validators for the project config and email template files. 36 func init() { 37 validation.Rules.Add( 38 "regex:projects/.*", 39 "${appid}.cfg", 40 func(ctx *validation.Context, configSet, path string, content []byte) error { 41 cfg := ¬ifypb.ProjectConfig{} 42 if err := proto.UnmarshalText(string(content), cfg); err != nil { 43 ctx.Errorf("invalid ProjectConfig proto message: %s", err) 44 } else { 45 validateProjectConfig(ctx, cfg) 46 } 47 return nil 48 }) 49 } 50 51 const ( 52 requiredFieldError = "field %q is required" 53 invalidFieldError = "field %q has invalid format" 54 uniqueFieldError = "field %q must be unique in %s" 55 badEmailError = "recipient %q is not a valid RFC 5322 email address" 56 badRepoURLError = "repo url %q is invalid" 57 duplicateBuilderError = "builder %q is specified more than once in file" 58 duplicateHostError = "builder has multiple tree closers with host %q" 59 badRegexError = "field %q contains an invalid regex: %s" 60 ) 61 62 // validateNotification is a helper function for validateConfig which validates 63 // an individual notification configuration. 64 func validateNotification(c *validation.Context, cfgNotification *notifypb.Notification) { 65 if cfgNotification.Email != nil { 66 for _, addr := range cfgNotification.Email.Recipients { 67 if _, err := mail.ParseAddress(addr); err != nil { 68 c.Errorf(badEmailError, addr) 69 } 70 } 71 } 72 73 validateRegexField(c, "failed_step_regexp", cfgNotification.FailedStepRegexp) 74 validateRegexField(c, "failed_step_regexp_exclude", cfgNotification.FailedStepRegexpExclude) 75 } 76 77 // validateTreeCloser is a helper function for validateConfig which validates an 78 // individual tree closer configuration. 79 func validateTreeCloser(c *validation.Context, cfgTreeCloser *notifypb.TreeCloser, treeStatusHosts stringset.Set) { 80 host := cfgTreeCloser.TreeStatusHost 81 if host == "" { 82 c.Errorf(requiredFieldError, "tree_status_host") 83 } 84 if !treeStatusHosts.Add(host) { 85 c.Errorf(duplicateHostError, host) 86 } 87 88 validateRegexField(c, "failed_step_regexp", cfgTreeCloser.FailedStepRegexp) 89 validateRegexField(c, "failed_step_regexp_exclude", cfgTreeCloser.FailedStepRegexpExclude) 90 } 91 92 // validateRegexField validates that a field contains a valid regex. 93 func validateRegexField(c *validation.Context, fieldName, regex string) { 94 _, err := regexp.Compile(regex) 95 if err != nil { 96 c.Errorf(badRegexError, fieldName, err.Error()) 97 } 98 } 99 100 // validateBuilder is a helper function for validateConfig which validates 101 // an individual Builder. 102 func validateBuilder(c *validation.Context, cfgBuilder *notifypb.Builder, builderNames stringset.Set) { 103 if cfgBuilder.Bucket == "" { 104 c.Errorf(requiredFieldError, "bucket") 105 } 106 if strings.HasPrefix(cfgBuilder.Bucket, "luci.") { 107 // TODO(tandrii): change to warning once our validation library supports it. 108 c.Errorf(`field "bucket" should not include legacy "luci.<project_name>." prefix, given %q`, cfgBuilder.Bucket) 109 } 110 if cfgBuilder.Name == "" { 111 c.Errorf(requiredFieldError, "name") 112 } 113 if cfgBuilder.Repository != "" { 114 if err := gitiles.ValidateRepoURL(cfgBuilder.Repository); err != nil { 115 c.Errorf(badRepoURLError, cfgBuilder.Repository) 116 } 117 } 118 fullName := fmt.Sprintf("%s/%s", cfgBuilder.Bucket, cfgBuilder.Name) 119 if !builderNames.Add(fullName) { 120 c.Errorf(duplicateBuilderError, fullName) 121 } 122 } 123 124 // validateNotifier validates a Notifier. 125 func validateNotifier(c *validation.Context, cfgNotifier *notifypb.Notifier, builderNames stringset.Set) { 126 for i, cfgNotification := range cfgNotifier.Notifications { 127 c.Enter("notification #%d", i+1) 128 validateNotification(c, cfgNotification) 129 c.Exit() 130 } 131 hosts := stringset.New(len(cfgNotifier.TreeClosers)) 132 for i, cfgTreeCloser := range cfgNotifier.TreeClosers { 133 c.Enter("tree_closer #%d", i+1) 134 validateTreeCloser(c, cfgTreeCloser, hosts) 135 c.Exit() 136 } 137 for i, cfgBuilder := range cfgNotifier.Builders { 138 c.Enter("builder #%d", i+1) 139 validateBuilder(c, cfgBuilder, builderNames) 140 c.Exit() 141 } 142 } 143 144 // validateProjectConfig returns an error if the configuration violates any of the 145 // requirements in the proto definition. 146 func validateProjectConfig(ctx *validation.Context, projectCfg *notifypb.ProjectConfig) { 147 builderNames := stringset.New(len(projectCfg.Notifiers)) // At least one builder per notifier 148 for i, cfgNotifier := range projectCfg.Notifiers { 149 ctx.Enter("notifier #%d", i+1) 150 validateNotifier(ctx, cfgNotifier, builderNames) 151 ctx.Exit() 152 } 153 } 154 155 // validateSettings returns an error if the service configuration violates any 156 // of the requirements in the proto definition. 157 func validateSettings(ctx *validation.Context, settings *notifypb.Settings) { 158 if settings.LuciTreeStatusHost != "" { 159 if validation.ValidateHostname(settings.LuciTreeStatusHost) != nil { 160 ctx.Errorf(invalidFieldError, "luci_tree_status_host") 161 } 162 } 163 switch { 164 case settings.MiloHost == "": 165 ctx.Errorf(requiredFieldError, "milo_host") 166 case validation.ValidateHostname(settings.MiloHost) != nil: 167 ctx.Errorf(invalidFieldError, "milo_host") 168 } 169 } 170 171 // validateEmailTemplateFile validates an email template file, including 172 // its filename and contents. 173 func validateEmailTemplateFile(ctx *validation.Context, configSet, path string, content []byte) error { 174 // Validate file name. 175 rgx, err := emailTemplateFilenameRegexp(ctx.Context) 176 if err != nil { 177 return err 178 } 179 180 if !rgx.MatchString(path) { 181 ctx.Errorf("filename does not match %q", rgx.String()) 182 } 183 184 // Validate file contents. 185 subject, body, err := mailtmpl.SplitTemplateFile(string(content)) 186 if err != nil { 187 ctx.Error(err) 188 } else { 189 // Note: Parse does not return an error if the template attempts to 190 // call an undefined template, e.g. {{template "does-not-exist"}} 191 if _, err = text.New("subject").Funcs(mailtmpl.Funcs).Parse(subject); err != nil { 192 ctx.Error(err) // error includes template name 193 } 194 // Due to luci-config limitation, we cannot detect an invalid reference to 195 // a sub-template defined in a different file. 196 if _, err = html.New("body").Funcs(mailtmpl.Funcs).Parse(body); err != nil { 197 ctx.Error(err) // error includes template name 198 } 199 } 200 return nil 201 }