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  }