github.com/GoogleCloudPlatform/testgrid@v0.0.174/config/config.go (about) 1 /* 2 Copyright 2019 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package config 18 19 import ( 20 "errors" 21 "fmt" 22 "io" 23 "io/ioutil" 24 "regexp" 25 "strings" 26 "unicode/utf8" 27 28 "github.com/golang/protobuf/proto" 29 30 configpb "github.com/GoogleCloudPlatform/testgrid/pb/config" 31 "github.com/GoogleCloudPlatform/testgrid/pkg/updater/resultstore/query" 32 multierror "github.com/hashicorp/go-multierror" 33 ) 34 35 // MissingFieldError is an error that includes the missing root field. 36 // Entities that contain no children should use a ValidationError, so they can point to the empty Entity 37 type MissingFieldError struct { 38 Field string 39 } 40 41 func (e MissingFieldError) Error() string { 42 return fmt.Sprintf("field missing or unset: %s", e.Field) 43 } 44 45 // DuplicateNameError is an error that includes the duplicate name. 46 type DuplicateNameError struct { 47 Name string 48 Entity string 49 } 50 51 func (e DuplicateNameError) Error() string { 52 return fmt.Sprintf("found duplicate name after normalizing: (%s) %s", e.Entity, e.Name) 53 } 54 55 // MissingEntityError is an error that includes the missing entity. 56 type MissingEntityError struct { 57 Name string 58 Entity string 59 } 60 61 func (e MissingEntityError) Error() string { 62 return fmt.Sprintf("could not find the referenced (%s) %s", e.Entity, e.Name) 63 } 64 65 // ValidationError is an error for invalid configuration that includes what entity errored. 66 type ValidationError struct { 67 Name string 68 Entity string 69 Message string 70 } 71 72 func (e ValidationError) Error() string { 73 return fmt.Sprintf("configuration error for (%s) %s: %s", e.Entity, e.Name, e.Message) 74 } 75 76 // Normalize lowercases, and removes all non-alphanumeric characters from a string. 77 // WARNING: Unless you are validating config or sanitizing API input, avoid using normalization. Bare names are acceptable keys. 78 func Normalize(s string) string { 79 regex := regexp.MustCompile("[^a-zA-Z0-9]+") 80 s = regex.ReplaceAllString(s, "") 81 s = strings.ToLower(s) 82 return s 83 } 84 85 const minNameLength = 3 86 const maxNameLength = 2048 87 88 // validateUnique checks that a list has no duplicate normalized entries. 89 func validateUnique(items []string, entity string) error { 90 var mErr error 91 set := map[string]bool{} 92 for _, item := range items { 93 s := Normalize(item) 94 _, ok := set[s] 95 if ok { 96 mErr = multierror.Append(mErr, DuplicateNameError{s, entity}) 97 } else { 98 set[s] = true 99 } 100 } 101 return mErr 102 } 103 104 func validateAllUnique(c *configpb.Configuration) error { 105 var mErr error 106 if c == nil { 107 return multierror.Append(mErr, errors.New("got an empty config.Configuration")) 108 } 109 var tgNames []string 110 for _, tg := range c.GetTestGroups() { 111 if err := validateName(tg.GetName()); err != nil { 112 mErr = multierror.Append(mErr, &ValidationError{tg.GetName(), "TestGroup", err.Error()}) 113 } 114 tgNames = append(tgNames, tg.GetName()) 115 } 116 // Test Group names must be unique. 117 if err := validateUnique(tgNames, "TestGroup"); err != nil { 118 mErr = multierror.Append(mErr, err) 119 } 120 121 var dashNames []string 122 for _, dash := range c.GetDashboards() { 123 if err := validateName(dash.Name); err != nil { 124 mErr = multierror.Append(mErr, &ValidationError{dash.GetName(), "Dashboard", err.Error()}) 125 } 126 dashNames = append(dashNames, dash.Name) 127 var tabNames []string 128 for _, tab := range dash.GetDashboardTab() { 129 if err := validateName(tab.Name); err != nil { 130 mErr = multierror.Append(mErr, &ValidationError{tab.Name, "DashboardTab", err.Error()}) 131 } 132 tabNames = append(tabNames, tab.Name) 133 } 134 // Dashboard Tab names must be unique within a Dashboard. 135 if err := validateUnique(tabNames, "DashboardTab"); err != nil { 136 mErr = multierror.Append(mErr, err) 137 } 138 } 139 // Dashboard names must be unique within Dashboards. 140 if err := validateUnique(dashNames, "Dashboard"); err != nil { 141 mErr = multierror.Append(mErr, err) 142 } 143 144 var dgNames []string 145 for _, dg := range c.GetDashboardGroups() { 146 if err := validateName(dg.Name); err != nil { 147 mErr = multierror.Append(mErr, &ValidationError{dg.Name, "DashboardGroup", err.Error()}) 148 } 149 dgNames = append(dgNames, dg.Name) 150 } 151 // Dashboard Group names must be unique within Dashboard Groups. 152 if err := validateUnique(dgNames, "DashboardGroup"); err != nil { 153 mErr = multierror.Append(mErr, err) 154 } 155 156 // Names must also be unique within DashboardGroups AND Dashbaords. 157 if err := validateUnique(append(dashNames, dgNames...), "Dashboard/DashboardGroup"); err != nil { 158 mErr = multierror.Append(mErr, err) 159 } 160 161 return mErr 162 } 163 164 func validateReferencesExist(c *configpb.Configuration) error { 165 var mErr error 166 if c == nil { 167 return multierror.Append(mErr, errors.New("got an empty config.Configuration")) 168 } 169 170 tgNames := map[string]bool{} 171 for _, tg := range c.GetTestGroups() { 172 tgNames[tg.GetName()] = true 173 } 174 tgInTabs := map[string]bool{} 175 for _, dash := range c.GetDashboards() { 176 for _, tab := range dash.DashboardTab { 177 tabTg := tab.TestGroupName 178 tgInTabs[tabTg] = true 179 // Verify that each Test Group referenced by a Dashboard Tab exists. 180 if _, ok := tgNames[tabTg]; !ok { 181 mErr = multierror.Append(mErr, MissingEntityError{tabTg, "TestGroup"}) 182 } 183 } 184 } 185 // Likewise, each Test Group must be referenced by a Dashboard Tab, so each Test Group gets displayed. 186 for tgName := range tgNames { 187 if _, ok := tgInTabs[tgName]; !ok { 188 mErr = multierror.Append(mErr, ValidationError{tgName, "TestGroup", "Each Test Group must be referenced by at least 1 Dashboard Tab."}) 189 } 190 } 191 192 dashNames := map[string]bool{} 193 for _, dash := range c.GetDashboards() { 194 dashNames[dash.Name] = true 195 } 196 dashToDg := map[string]bool{} 197 for _, dg := range c.GetDashboardGroups() { 198 for _, name := range dg.DashboardNames { 199 dgDash := name 200 if _, ok := dashNames[dgDash]; !ok { 201 // The Dashboards each Dashboard Group references must exist. 202 mErr = multierror.Append(mErr, MissingEntityError{dgDash, "Dashboard"}) 203 } else if _, ok = dashToDg[dgDash]; ok { 204 mErr = multierror.Append(mErr, ValidationError{dgDash, "Dashboard", "A Dashboard cannot be in more than 1 Dashboard Group."}) 205 } else { 206 dashToDg[dgDash] = true 207 } 208 } 209 } 210 return mErr 211 } 212 213 // TODO(michelle192837): Remove '/' and '–' from this regex. 214 var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_.~<>()|\[\]",@/ –-]+$`) 215 216 // validateName validates an entity name is well-formed. 217 func validateName(s string) error { 218 if !nameRegex.MatchString(s) { 219 return fmt.Errorf("names must conform to the regex %q", nameRegex.String()) 220 } 221 222 name := Normalize(s) 223 224 if len(name) < minNameLength { 225 return fmt.Errorf("names must contain at least %d alphanumeric characters", minNameLength) 226 } 227 228 if len(name) > maxNameLength { 229 return fmt.Errorf("names should not contain more than %d alphanumeric characters", maxNameLength) 230 } 231 232 invalidPrefixes := []string{"dashboard", "alerter", "summary", "bugs"} 233 for _, prefix := range invalidPrefixes { 234 if strings.HasPrefix(name, prefix) { 235 return fmt.Errorf("normalized name can't be prefixed with any of %v", invalidPrefixes) 236 } 237 } 238 239 return nil 240 } 241 242 // validateEmails is a very basic check that each address in a comma-separated list is valid. 243 func validateEmails(addresses string) error { 244 // Each address should have exactly one @ symbol, with characters before and after. 245 regex := regexp.MustCompile("^[^@]+@[^@]+$") 246 invalid := []string{} 247 for _, address := range strings.Split(addresses, ",") { 248 match := regex.Match([]byte(address)) 249 if !match { 250 invalid = append(invalid, address) 251 } 252 } 253 254 if len(invalid) > 0 { 255 return fmt.Errorf("bad emails %v specified in '%s'; an email address should have exactly one at (@) symbol)", invalid, addresses) 256 } 257 return nil 258 } 259 260 func validateResultStoreSource(tg *configpb.TestGroup) error { 261 if rs := tg.GetResultSource().GetResultstoreConfig(); rs != nil { 262 // Can't define other sources if ResultStore source is used. 263 if tg.GetGcsPrefix() != "" { 264 return errors.New("cannot define both resultstore_config and gcs_prefix") 265 } 266 if tg.GetUseKubernetesClient() { 267 return errors.New("cannot define both resultstore_config and use_kubernetes_client") 268 } 269 // Can't leave project ID blank. 270 if rs.GetProject() == "" { 271 return errors.New("project ID in resultstore_config cannot be empty") 272 } 273 if _, err := query.TranslateQuery(rs.GetQuery()); err != nil { 274 return fmt.Errorf("invalid ResultStore query %q: %v", rs.GetQuery(), err) 275 } 276 } 277 return nil 278 } 279 280 func validateGCSSource(tg *configpb.TestGroup) error { 281 if rs := tg.GetResultSource().GetGcsConfig(); rs != nil { 282 // Can't define other sources if GCS source is used. 283 if tg.GetGcsPrefix() != "" { 284 return errors.New("cannot define both resultstore_config and gcs_prefix") 285 } 286 if tg.GetUseKubernetesClient() { 287 return errors.New("cannot define both resultstore_config and use_kubernetes_client") 288 } 289 // Can't leave the source's GCS prefix blank. 290 if rs.GetGcsPrefix() == "" { 291 return errors.New("gcs_prefix in gcs_config cannot be empty") 292 } 293 // Pubsub project and subscription must both be empty or filled. 294 proj := rs.GetPubsubProject() 295 sub := rs.GetPubsubSubscription() 296 if (proj == "" && sub != "") || (proj != "" && sub == "") { 297 return fmt.Errorf("pubsub project and subscription must both be empty or filled; got project %q and subscription %q", proj, sub) 298 } 299 } 300 return nil 301 } 302 303 func validateTestGroup(tg *configpb.TestGroup) error { 304 var mErr error 305 if tg == nil { 306 return multierror.Append(mErr, errors.New("got an empty TestGroup")) 307 } 308 // Check that required fields are a non-zero-value. 309 if tg.GetGcsPrefix() == "" && tg.GetResultSource() == nil { 310 mErr = multierror.Append(mErr, errors.New("require one of gcs_prefix or result_source")) 311 } 312 if tg.GetDaysOfResults() <= 0 { 313 mErr = multierror.Append(mErr, errors.New("days_of_results should be positive")) 314 } 315 if tg.GetNumColumnsRecent() <= 0 { 316 mErr = multierror.Append(mErr, errors.New("num_columns_recent should be positive")) 317 } 318 319 // Result source should be valid. 320 if err := validateResultStoreSource(tg); err != nil { 321 mErr = multierror.Append(mErr, fmt.Errorf("error in ResultStore result source: %v", err)) 322 } 323 if err := validateGCSSource(tg); err != nil { 324 mErr = multierror.Append(mErr, fmt.Errorf("error in GCS result source: %v", err)) 325 } 326 327 // Regexes should be valid. 328 if _, err := regexp.Compile(tg.GetTestMethodMatchRegex()); err != nil { 329 mErr = multierror.Append(mErr, fmt.Errorf("test_method_match_regex doesn't compile: %v", err)) 330 } 331 332 // Email address for alerts should be valid. 333 if tg.GetAlertMailToAddresses() != "" { 334 if err := validateEmails(tg.GetAlertMailToAddresses()); err != nil { 335 mErr = multierror.Append(mErr, err) 336 } 337 } 338 339 // Test metadata options should be reasonable, valid values. 340 metadataOpts := tg.GetTestMetadataOptions() 341 for _, opt := range metadataOpts { 342 if opt.GetMessageRegex() == "" && opt.GetTestNameRegex() == "" { 343 mErr = multierror.Append(mErr, errors.New("at least one of message_regex or test_name_regex must be specified")) 344 } 345 if _, err := regexp.Compile(opt.GetMessageRegex()); err != nil { 346 mErr = multierror.Append(mErr, fmt.Errorf("message_regex doesn't compile: %v", err)) 347 } 348 if _, err := regexp.Compile(opt.GetTestNameRegex()); err != nil { 349 mErr = multierror.Append(mErr, fmt.Errorf("test_name_regex doesn't compile: %v", err)) 350 } 351 } 352 353 for _, notification := range tg.GetNotifications() { 354 if notification.GetSummary() == "" { 355 mErr = multierror.Append(mErr, errors.New("summary is required")) 356 } 357 } 358 359 annotations := tg.GetTestAnnotations() 360 for _, annotation := range annotations { 361 if annotation.GetPropertyName() == "" { 362 mErr = multierror.Append(mErr, errors.New("property_name is required")) 363 } 364 if annotation.GetShortText() == "" || utf8.RuneCountInString(annotation.GetShortText()) > 5 { 365 mErr = multierror.Append(mErr, errors.New("short_text must be 1-5 characters long")) 366 } 367 } 368 369 fallbackConfigSettingSet := tg.GetFallbackGrouping() == configpb.TestGroup_FALLBACK_GROUPING_CONFIGURATION_VALUE 370 fallbackConfigValueSet := tg.GetFallbackGroupingConfigurationValue() != "" 371 if fallbackConfigSettingSet != fallbackConfigValueSet { 372 mErr = multierror.Append( 373 mErr, 374 errors.New("fallback_grouping_configuration_value and fallback_grouping = FALLBACK_GROUPING_CONFIGURATION_VALUE require each other"), 375 ) 376 } 377 378 // For each defined column_header, verify it has exactly one value set. 379 for idx, header := range tg.GetColumnHeader() { 380 if cv, p, l := header.ConfigurationValue, header.Property, header.Label; cv == "" && p == "" && l == "" { 381 mErr = multierror.Append(mErr, &ValidationError{tg.GetName(), "TestGroup", fmt.Sprintf("Column Header %d is empty", idx)}) 382 } else if cv != "" && (p != "" || l != "") || p != "" && (cv != "" || l != "") { 383 mErr = multierror.Append( 384 mErr, 385 fmt.Errorf("Column Header %d must only set one value, got configuration_value: %q, property: %q, label: %q", idx, cv, p, l), 386 ) 387 } 388 389 } 390 391 // test_name_config should have a matching number of format strings and name elements. 392 if tg.GetTestNameConfig() != nil { 393 nameFormat := tg.GetTestNameConfig().GetNameFormat() 394 nameElements := tg.GetTestNameConfig().GetNameElements() 395 396 if len(nameElements) == 0 { 397 mErr = multierror.Append(mErr, errors.New("TestNameConfig.NameElements must be specified")) 398 } 399 400 if nameFormat == "" { 401 mErr = multierror.Append(mErr, errors.New("TestNameConfig.NameFormat must be specified")) 402 } else { 403 if got, want := len(nameElements), strings.Count(nameFormat, "%"); got != want { 404 mErr = multierror.Append( 405 mErr, 406 fmt.Errorf("TestNameConfig has %d elements, format %s wants %d", got, nameFormat, want), 407 ) 408 } 409 elements := make([]interface{}, 0) 410 for range nameElements { 411 elements = append(elements, "") 412 } 413 s := fmt.Sprintf(nameFormat, elements...) 414 if strings.Contains(s, "%!") { 415 return fmt.Errorf("number of format strings and name_elements must match; got %s (%d)", s, len(elements)) 416 } 417 } 418 } 419 420 return mErr 421 } 422 423 func validateDashboardTab(dt *configpb.DashboardTab) error { 424 var mErr error 425 if dt == nil { 426 return multierror.Append(mErr, errors.New("got an empty DashboardTab")) 427 } 428 429 // Check that required fields are a non-zero-value. 430 if dt.GetTestGroupName() == "" { 431 mErr = multierror.Append(mErr, errors.New("test_group_name can't be empty")) 432 } 433 434 // A Dashboard Tab can't be named the same as the default 'Summary' tab. 435 if dt.GetName() == "Summary" { 436 mErr = multierror.Append(mErr, errors.New("tab can't be named 'Summary'")) 437 } 438 439 // TabularNamesRegex should be valid and have capture groups defined. 440 if dt.GetTabularNamesRegex() != "" { 441 regex, err := regexp.Compile(dt.GetTabularNamesRegex()) 442 if err != nil { 443 mErr = multierror.Append( 444 mErr, 445 fmt.Errorf("invalid regex %s: %v", dt.GetTabularNamesRegex(), err)) 446 } else { 447 var names []string 448 for _, subexpName := range regex.SubexpNames() { 449 if subexpName != "" { 450 names = append(names, subexpName) 451 } 452 } 453 if regex.NumSubexp() != len(names) { 454 mErr = multierror.Append(mErr, errors.New("all tabular_name_regex capture groups must be named")) 455 } 456 if len(names) < 1 { 457 mErr = multierror.Append(mErr, errors.New("tabular_name_regex requires at least one capture group")) 458 } 459 } 460 } 461 462 // Email address for alerts should be valid. 463 if dt.GetAlertOptions().GetAlertMailToAddresses() != "" { 464 if err := validateEmails(dt.GetAlertOptions().GetAlertMailToAddresses()); err != nil { 465 mErr = multierror.Append(mErr, err) 466 } 467 } 468 469 // Max acceptable flakiness parameter should be valid (between 0.0 and 100.0 - both inclusive). 470 if maxAcceptableFlakiness := dt.GetStatusCustomizationOptions().GetMaxAcceptableFlakiness(); maxAcceptableFlakiness < 0 || maxAcceptableFlakiness > 100 { 471 mErr = multierror.Append(mErr, errors.New("invalid value provided for max_acceptable_flakiness (should be between 0.0 and 100.0)")) 472 } 473 474 return mErr 475 } 476 477 func validateEntityConfigs(c *configpb.Configuration) error { 478 var mErr error 479 if c == nil { 480 return multierror.Append(mErr, errors.New("got an empty config.Configuration")) 481 } 482 483 // At the moment, don't need to further validate Dashboards or DashboardGroups. 484 for _, tg := range c.GetTestGroups() { 485 if err := validateTestGroup(tg); err != nil { 486 mErr = multierror.Append(mErr, &ValidationError{tg.GetName(), "TestGroup", err.Error()}) 487 } 488 } 489 490 for _, d := range c.GetDashboards() { 491 for _, dt := range d.DashboardTab { 492 if err := validateDashboardTab(dt); err != nil { 493 mErr = multierror.Append(mErr, &ValidationError{dt.GetName(), "DashboardTab", err.Error()}) 494 } 495 } 496 } 497 498 return mErr 499 } 500 501 // Validate checks that a configuration is well-formed. 502 func Validate(c *configpb.Configuration) error { 503 var mErr error 504 if c == nil { 505 return multierror.Append(mErr, errors.New("got an empty config.Configuration")) 506 } 507 508 // TestGrid requires at least 1 TestGroup and 1 Dashboard in order to do anything. 509 if len(c.GetTestGroups()) == 0 { 510 return multierror.Append(mErr, MissingFieldError{"TestGroups"}) 511 } 512 if len(c.GetDashboards()) == 0 { 513 return multierror.Append(mErr, MissingFieldError{"Dashboards"}) 514 } 515 516 // Each Dashboard must contain at least 1 Tab to do anything 517 for _, dashboard := range c.GetDashboards() { 518 if len(dashboard.DashboardTab) == 0 { 519 mErr = multierror.Append(mErr, ValidationError{dashboard.Name, "Dashboard", "contains no tabs"}) 520 } 521 } 522 523 // Names have to be unique (after normalizing) within types of entities, to prevent storing 524 // duplicate state on updates and confusion between similar names. 525 // Entity names can't be empty or start with the same prefix as a TestGrid file type. 526 if err := validateAllUnique(c); err != nil { 527 mErr = multierror.Append(mErr, err) 528 } 529 530 // The entity that an entity references must exist. 531 if err := validateReferencesExist(c); err != nil { 532 mErr = multierror.Append(mErr, err) 533 } 534 535 // Validate individual entities have reasonable, well-formed options set. 536 if err := validateEntityConfigs(c); err != nil { 537 mErr = multierror.Append(mErr, err) 538 } 539 540 return mErr 541 } 542 543 // Unmarshal reads a protocol buffer into memory 544 func Unmarshal(r io.Reader) (*configpb.Configuration, error) { 545 buf, err := ioutil.ReadAll(r) 546 if err != nil { 547 return nil, fmt.Errorf("failed to read config: %v", err) 548 } 549 var cfg configpb.Configuration 550 if err = proto.Unmarshal(buf, &cfg); err != nil { 551 return nil, fmt.Errorf("failed to parse: %v", err) 552 } 553 return &cfg, nil 554 } 555 556 // MarshalText writes a text version of the parsed configuration to the supplied io.Writer. 557 // Returns an error if config is invalid or writing failed. 558 func MarshalText(c *configpb.Configuration, w io.Writer) error { 559 if c == nil { 560 return errors.New("got an empty config.Configuration") 561 } 562 if err := Validate(c); err != nil { 563 return err 564 } 565 return proto.MarshalText(w, c) 566 } 567 568 // MarshalBytes returns the wire-encoded protobuf data for the parsed configuration. 569 // Returns an error if config is invalid or encoding failed. 570 func MarshalBytes(c *configpb.Configuration) ([]byte, error) { 571 if c == nil { 572 return nil, errors.New("got an empty config.Configuration") 573 } 574 if err := Validate(c); err != nil { 575 return nil, err 576 } 577 return proto.Marshal(c) 578 } 579 580 // FindTestGroup returns the configpb.TestGroup proto for a given TestGroup name. 581 func FindTestGroup(name string, cfg *configpb.Configuration) *configpb.TestGroup { 582 if cfg == nil { 583 return nil 584 } 585 for _, tg := range cfg.GetTestGroups() { 586 if tg.GetName() == name { 587 return tg 588 } 589 } 590 return nil 591 } 592 593 // FindDashboard returns the configpb.Dashboard proto for a given Dashboard name. 594 func FindDashboard(name string, cfg *configpb.Configuration) *configpb.Dashboard { 595 if cfg == nil { 596 return nil 597 } 598 for _, d := range cfg.GetDashboards() { 599 if d.Name == name { 600 return d 601 } 602 } 603 return nil 604 }