k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/config/tests/testgrids/config_test.go (about)

     1  /*
     2  Copyright 2016 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 testgrids
    18  
    19  import (
    20  	"context"
    21  	"flag"
    22  	"fmt"
    23  	"net/mail"
    24  	"os"
    25  	"path"
    26  	"path/filepath"
    27  	"regexp"
    28  	"strings"
    29  	"testing"
    30  
    31  	"k8s.io/apimachinery/pkg/util/sets"
    32  
    33  	"github.com/GoogleCloudPlatform/testgrid/config"
    34  	config_pb "github.com/GoogleCloudPlatform/testgrid/pb/config"
    35  	"k8s.io/test-infra/testgrid/pkg/configurator/configurator"
    36  	"k8s.io/test-infra/testgrid/pkg/configurator/options"
    37  	prow_config "sigs.k8s.io/prow/pkg/config"
    38  	"sigs.k8s.io/prow/pkg/flagutil"
    39  
    40  	configflagutil "sigs.k8s.io/prow/pkg/flagutil/config"
    41  )
    42  
    43  type SQConfig struct {
    44  	Data map[string]string `yaml:"data,omitempty"`
    45  }
    46  
    47  var (
    48  	companies = []string{
    49  		"amazon",
    50  		"canonical",
    51  		"cos",
    52  		"containerd",
    53  		"cri-o",
    54  		"istio",
    55  		"googleoss",
    56  		"google",
    57  		"kopeio",
    58  		"redhat",
    59  		"ibm",
    60  		"vmware",
    61  		"gardener",
    62  		"jetstack",
    63  		"kubevirt",
    64  	}
    65  	orgs = []string{
    66  		"conformance",
    67  		"kops",
    68  		"presubmits",
    69  		"sig",
    70  		"wg",
    71  		"provider",
    72  		"kubernetes-clients",
    73  		"kcp",
    74  	}
    75  	dashboardPrefixes = [][]string{orgs, companies}
    76  
    77  	// gcs prefixes populated by the kubernetes prow instance
    78  	prowGcsPrefixes = []string{
    79  		"kubernetes-jenkins/logs/",
    80  		"kubernetes-jenkins/pr-logs/directory/",
    81  	}
    82  )
    83  
    84  var defaultInputs options.MultiString = []string{"../../testgrids"}
    85  var prowPath = flag.String("prow-config", "../../../config/prow/config.yaml", "Path to prow config")
    86  var jobPath = flag.String("job-config", "../../jobs", "Path to prow job config")
    87  var defaultYAML = flag.String("default", "../../testgrids/default.yaml", "Default yaml for testgrid")
    88  var inputs options.MultiString
    89  var protoPath = flag.String("config", "", "Path to TestGrid config proto")
    90  
    91  // Shared testgrid config, loaded at TestMain.
    92  var cfg *config_pb.Configuration
    93  
    94  // Shared prow config, loaded at Test Main
    95  var prowConfig *prow_config.Config
    96  
    97  func TestMain(m *testing.M) {
    98  	flag.Var(&inputs, "yaml", "comma-separated list of input YAML files or directories")
    99  	flag.Parse()
   100  	if *protoPath == "" {
   101  		if len(inputs) == 0 {
   102  			inputs = defaultInputs
   103  		}
   104  		// Generate proto from testgrid config
   105  		tmpDir, err := os.MkdirTemp("", "testgrid-config-test")
   106  		if err != nil {
   107  			fmt.Println(err)
   108  			os.Exit(1)
   109  		}
   110  		defer os.RemoveAll(tmpDir)
   111  		tmpFile := path.Join(tmpDir, "test-proto")
   112  
   113  		opt := options.Options{
   114  			Inputs: inputs,
   115  			ProwConfig: configflagutil.ConfigOptions{
   116  				ConfigPath:    *prowPath,
   117  				JobConfigPath: *jobPath,
   118  			},
   119  			DefaultYAML:     *defaultYAML,
   120  			Output:          flagutil.NewStringsBeenSet(tmpFile),
   121  			Oneshot:         true,
   122  			StrictUnmarshal: true,
   123  		}
   124  
   125  		if err := configurator.RealMain(&opt); err != nil {
   126  			fmt.Println(err)
   127  			os.Exit(1)
   128  		}
   129  
   130  		protoPath = &tmpFile
   131  	}
   132  
   133  	var err error
   134  	cfg, err = config.Read(context.Background(), *protoPath, nil)
   135  	if err != nil {
   136  		fmt.Printf("Could not load config: %v\n", err)
   137  		os.Exit(1)
   138  	}
   139  
   140  	prowConfig, err = prow_config.Load(*prowPath, *jobPath, nil, "")
   141  	if err != nil {
   142  		fmt.Printf("Could not load prow configs: %v\n", err)
   143  		os.Exit(1)
   144  	}
   145  
   146  	os.Exit(m.Run())
   147  }
   148  
   149  func TestConfig(t *testing.T) {
   150  	// testgroup - occurrence map, validate testgroups
   151  	testgroupMap := make(map[string]int32)
   152  
   153  	for testgroupidx, testgroup := range cfg.TestGroups {
   154  		// All testgroup must have a name and a query
   155  		if testgroup.Name == "" || testgroup.GcsPrefix == "" {
   156  			t.Errorf("Testgroup #%v (Name: '%v', GcsPrefix: '%v'): - Must have a name and gcs_prefix",
   157  				testgroupidx, testgroup.Name, testgroup.GcsPrefix)
   158  		}
   159  
   160  		// All testgroup must not have duplicated names
   161  		if testgroupMap[testgroup.Name] > 0 {
   162  			t.Errorf("Duplicated Testgroup: %v", testgroup.Name)
   163  		} else {
   164  			testgroupMap[testgroup.Name] = 1
   165  		}
   166  
   167  		t.Run("Testgroup "+testgroup.Name, func(t *testing.T) {
   168  			if !testgroup.IsExternal {
   169  				t.Error("IsExternal must be true")
   170  			}
   171  
   172  			for hIdx, header := range testgroup.ColumnHeader {
   173  				if header.ConfigurationValue == "" {
   174  					t.Errorf("Column Header %d is empty", hIdx)
   175  				}
   176  			}
   177  
   178  			for _, prowGcsPrefix := range prowGcsPrefixes {
   179  				if strings.Contains(testgroup.GcsPrefix, prowGcsPrefix) {
   180  					// The expectation is that testgroup.Name is the name of a Prow job and the GCSPrefix
   181  					// follows the convention kubernetes-jenkins/logs/.../jobName
   182  					// The final part of the prefix should be the job name.
   183  					expected := filepath.Join(filepath.Dir(testgroup.GcsPrefix), testgroup.Name)
   184  					if expected != testgroup.GcsPrefix {
   185  						t.Errorf("GcsPrefix: Got %s; Want %s", testgroup.GcsPrefix, expected)
   186  					}
   187  					break // out of prowGcsPrefix for loop
   188  				}
   189  			}
   190  
   191  			if testgroup.TestNameConfig != nil {
   192  				if testgroup.TestNameConfig.NameFormat == "" {
   193  					t.Error("Empty NameFormat")
   194  				}
   195  
   196  				if got, want := len(testgroup.TestNameConfig.NameElements), strings.Count(testgroup.TestNameConfig.NameFormat, "%"); got != want {
   197  					t.Errorf("TestNameConfig has %d elements, format %s wants %d", got, testgroup.TestNameConfig.NameFormat, want)
   198  				}
   199  			}
   200  
   201  			// All PR testgroup has num_columns_recent equals 20
   202  			if strings.HasPrefix(testgroup.GcsPrefix, "kubernetes-jenkins/pr-logs/directory/") {
   203  				if testgroup.NumColumnsRecent < 20 {
   204  					t.Errorf("presubmit num_columns_recent want >=20, got %d", testgroup.NumColumnsRecent)
   205  				}
   206  			}
   207  		})
   208  	}
   209  
   210  	// dashboard name set
   211  	dashboardSet := sets.NewString()
   212  
   213  	for dashboardidx, dashboard := range cfg.Dashboards {
   214  		// All dashboard must have a name
   215  		if dashboard.Name == "" {
   216  			t.Errorf("Dashboard %v: - Must have a name", dashboardidx)
   217  		}
   218  
   219  		found := false
   220  		for _, kind := range dashboardPrefixes {
   221  			for _, prefix := range kind {
   222  				if strings.HasPrefix(dashboard.Name, prefix) || dashboard.Name == prefix {
   223  					found = true
   224  					break
   225  				}
   226  			}
   227  			if found {
   228  				break
   229  			}
   230  		}
   231  		if !found {
   232  			t.Errorf("Dashboard %v: must prefix with one of: %v", dashboard.Name, dashboardPrefixes)
   233  		}
   234  
   235  		// All dashboard must not have duplicated names
   236  		if dashboardSet.Has(dashboard.Name) {
   237  			t.Errorf("Duplicated dashboard: %v", dashboard.Name)
   238  		} else {
   239  			dashboardSet.Insert(dashboard.Name)
   240  		}
   241  
   242  		// All dashboard must have at least one tab
   243  		if len(dashboard.DashboardTab) == 0 {
   244  			t.Errorf("Dashboard %v: - Must have more than one dashboardtab", dashboard.Name)
   245  		}
   246  
   247  		// dashboardtabSet is a set that checks duplicate tab name within each dashboard
   248  		dashboardtabSet := sets.NewString()
   249  
   250  		// dashboardtestgroupSet is a set that checks duplicate testgroups within each dashboard
   251  		dashboardtestgroupSet := sets.NewString()
   252  
   253  		// All notifications in dashboard must have a summary
   254  		if len(dashboard.Notifications) != 0 {
   255  			for notificationindex, notification := range dashboard.Notifications {
   256  				if notification.Summary == "" {
   257  					t.Errorf("Notification %v in dashboard %v: - Must have a summary", notificationindex, dashboard.Name)
   258  				}
   259  			}
   260  		}
   261  
   262  		for tabindex, dashboardtab := range dashboard.DashboardTab {
   263  
   264  			// All dashboardtab must have a name and a testgroup
   265  			if dashboardtab.Name == "" || dashboardtab.TestGroupName == "" {
   266  				t.Errorf("Dashboard %v, tab %v: - Must have a name and a testgroup name", dashboard.Name, tabindex)
   267  			}
   268  
   269  			// All dashboardtab within a dashboard must not have duplicated names
   270  			if dashboardtabSet.Has(dashboardtab.Name) {
   271  				t.Errorf("Duplicated name in dashboard %s: %v", dashboard.Name, dashboardtab.Name)
   272  			} else {
   273  				dashboardtabSet.Insert(dashboardtab.Name)
   274  			}
   275  
   276  			// All dashboardtab within a dashboard must not have duplicated testgroupnames
   277  			if dashboardtestgroupSet.Has(dashboardtab.TestGroupName) {
   278  				t.Errorf("Duplicated testgroupnames in dashboard %s: %v", dashboard.Name, dashboardtab.TestGroupName)
   279  			} else {
   280  				dashboardtestgroupSet.Insert(dashboardtab.TestGroupName)
   281  			}
   282  
   283  			// All testgroup in dashboard must be defined in testgroups
   284  			if testgroupMap[dashboardtab.TestGroupName] == 0 {
   285  				t.Errorf("Dashboard %v, tab %v: - Testgroup %v must be defined first",
   286  					dashboard.Name, dashboardtab.Name, dashboardtab.TestGroupName)
   287  			} else {
   288  				testgroupMap[dashboardtab.TestGroupName]++
   289  			}
   290  		}
   291  	}
   292  
   293  	// No dup of dashboard groups, and no dup dashboard in a dashboard group
   294  	groupSet := sets.NewString()
   295  	dashboardToGroupMap := make(map[string]string)
   296  
   297  	for idx, dashboardGroup := range cfg.DashboardGroups {
   298  		// All dashboard must have a name
   299  		if dashboardGroup.Name == "" {
   300  			t.Errorf("DashboardGroup %v: - DashboardGroup must have a name", idx)
   301  		}
   302  
   303  		found := false
   304  		for _, kind := range dashboardPrefixes {
   305  			for _, prefix := range kind {
   306  				if strings.HasPrefix(dashboardGroup.Name, prefix) || prefix == dashboardGroup.Name {
   307  					found = true
   308  					break
   309  				}
   310  			}
   311  			if found {
   312  				break
   313  			}
   314  		}
   315  		if !found {
   316  			t.Errorf("Dashboard group %v: must prefix with one of: %v", dashboardGroup.Name, dashboardPrefixes)
   317  		}
   318  
   319  		// All dashboardgroup must not have duplicated names
   320  		if groupSet.Has(dashboardGroup.Name) {
   321  			t.Errorf("Duplicated dashboard: %v", dashboardGroup.Name)
   322  		} else {
   323  			groupSet.Insert(dashboardGroup.Name)
   324  		}
   325  
   326  		if dashboardSet.Has(dashboardGroup.Name) {
   327  			t.Errorf("%v is both a dashboard and dashboard group name.", dashboardGroup.Name)
   328  		}
   329  
   330  		for _, dashboard := range dashboardGroup.DashboardNames {
   331  			// All dashboard must not have duplicated names
   332  			if assignedGroup, ok := dashboardToGroupMap[dashboard]; ok {
   333  				t.Errorf("Duplicated dashboard %v in dashboard group %v and %v", dashboard, assignedGroup, dashboardGroup.Name)
   334  			} else {
   335  				dashboardToGroupMap[dashboard] = dashboardGroup.Name
   336  			}
   337  
   338  			if !dashboardSet.Has(dashboard) {
   339  				t.Errorf("Dashboard %v needs to be defined before adding to a dashboard group!", dashboard)
   340  			}
   341  
   342  			if !strings.HasPrefix(dashboard, dashboardGroup.Name) {
   343  				t.Errorf("Dashboard %v in group %v must have the group name as a prefix", dashboard, dashboardGroup.Name)
   344  			}
   345  		}
   346  	}
   347  
   348  	// Dashboards that match this dashboard group's prefix should be a part of it, unless this group is the prefix of the assigned group
   349  	// (e.g. knative and knative-sandbox).
   350  	for thisGroup := range groupSet {
   351  		for dashboard := range dashboardSet {
   352  			if strings.HasPrefix(dashboard, thisGroup+"-") {
   353  				assignedGroup, ok := dashboardToGroupMap[dashboard]
   354  				if !ok {
   355  					t.Errorf("Dashboard %v should be in dashboard_group %v", dashboard, thisGroup)
   356  				} else if assignedGroup != thisGroup && !strings.HasPrefix(assignedGroup, thisGroup) {
   357  					t.Errorf("Dashboard %v should be in dashboard_group %v instead of dashboard_group %v", dashboard, thisGroup, assignedGroup)
   358  				}
   359  			}
   360  		}
   361  	}
   362  
   363  	// All Testgroup should be mapped to one or more tabs
   364  	missedTestgroups := false
   365  	for testgroupname, occurrence := range testgroupMap {
   366  		if occurrence == 1 {
   367  			t.Errorf("Testgroup %v - defined but not used in any dashboards.", testgroupname)
   368  			missedTestgroups = true
   369  		}
   370  	}
   371  	if missedTestgroups {
   372  		t.Logf("Note: Testgroups are automatically defined for postsubmits and periodics.")
   373  		t.Logf("Testgroups can be added to a dashboard either by using the `testgrid-dashboards` annotation on the prowjob, or by adding them to testgrid/config.yaml")
   374  	}
   375  }
   376  
   377  // TODO: These are all repos that don't have their presubmits in testgrid.
   378  // Convince sig leads or subproject owners this is a bad idea and whittle this down
   379  // to just kubernetes-security/
   380  // Tracking issue: https://github.com/kubernetes/test-infra/issues/18159
   381  var noPresubmitsInTestgridPrefixes = []string{
   382  	"containerd/cri",
   383  	"kubernetes-sigs/cluster-capacity",
   384  	"kubernetes-sigs/gcp-filestore-csi-driver",
   385  	"kubernetes-sigs/kind",
   386  	"kubernetes-sigs/kubetest2",
   387  	"kubernetes-sigs/oci-proxy",
   388  	"kubernetes-sigs/kubebuilder-declarative-pattern",
   389  	"kubernetes-sigs/scheduler-plugins",
   390  	"kubernetes-sigs/service-catalog",
   391  	"kubernetes-sigs/sig-storage-local-static-provisioner",
   392  	"kubernetes-sigs/slack-infra",
   393  	"kubernetes-sigs/testing_frameworks",
   394  	"kubernetes/client-go",
   395  	"kubernetes/cloud-provider-openstack",
   396  	"kubernetes/dns",
   397  	"kubernetes/enhancements",
   398  	"kubernetes/ingress-gce",
   399  	"kubernetes/kubeadm",
   400  	"kubernetes/minikube",
   401  	// This is the one entry that should be here
   402  	"kubernetes-security/",
   403  }
   404  
   405  func hasAnyPrefix(s string, prefixes []string) bool {
   406  	for _, prefix := range prefixes {
   407  		if strings.HasPrefix(s, prefix) {
   408  			return true
   409  		}
   410  	}
   411  	return false
   412  }
   413  
   414  // A job is merge-blocking if it:
   415  // - is not optional
   416  // - reports (aka does not skip reporting)
   417  // - always runs OR runs if some path changed
   418  func isMergeBlocking(job prow_config.Presubmit) bool {
   419  	return !job.Optional && !job.SkipReport && (job.AlwaysRun || job.RunIfChanged != "" || job.SkipIfOnlyChanged != "")
   420  }
   421  
   422  // All jobs in presubmits-kubernetes-blocking must be merge-blocking for kubernetes/kubernetes
   423  // All jobs that are merge-blocking for kubernetes/kubernetes must be in presubmits-kubernetes-blocking
   424  func TestPresubmitsKubernetesDashboards(t *testing.T) {
   425  	var dashboard *config_pb.Dashboard
   426  	repo := "kubernetes/kubernetes"
   427  	dash := "presubmits-kubernetes-blocking"
   428  	for _, d := range cfg.Dashboards {
   429  		if d.Name == dash {
   430  			dashboard = d
   431  		}
   432  	}
   433  	if dashboard == nil {
   434  		t.Fatalf("Missing dashboard: %s", dash)
   435  	}
   436  	testgroups := make(map[string]bool)
   437  	for _, tab := range dashboard.DashboardTab {
   438  		testgroups[tab.TestGroupName] = false
   439  	}
   440  	jobs := make(map[string]bool)
   441  	for _, job := range prowConfig.AllStaticPresubmits([]string{repo}) {
   442  		if isMergeBlocking(job) {
   443  			jobs[job.Name] = false
   444  		}
   445  	}
   446  	for job, seen := range jobs {
   447  		if _, ok := testgroups[job]; !seen && !ok {
   448  			t.Errorf("%s: job is merge-blocking for %s but missing from %s", job, repo, dash)
   449  		}
   450  		jobs[job] = true
   451  	}
   452  	for tg, seen := range testgroups {
   453  		if _, ok := jobs[tg]; !seen && !ok {
   454  			t.Errorf("%s: should not be in %s because not actually merge-blocking for %s", tg, dash, repo)
   455  		}
   456  		testgroups[tg] = true
   457  	}
   458  }
   459  
   460  func TestKubernetesProwInstanceJobsMustHaveMatchingTestgridEntries(t *testing.T) {
   461  	jobs := make(map[string]bool)
   462  
   463  	for repo, presubmits := range prowConfig.PresubmitsStatic {
   464  		// Assume that all jobs in the exceptionList are valid
   465  		if hasAnyPrefix(repo, noPresubmitsInTestgridPrefixes) {
   466  			for _, job := range presubmits {
   467  				jobs[job.Name] = true
   468  			}
   469  			continue
   470  		}
   471  		for _, job := range presubmits {
   472  			jobs[job.Name] = false
   473  		}
   474  	}
   475  
   476  	for _, job := range prowConfig.AllStaticPostsubmits([]string{}) {
   477  		jobs[job.Name] = false
   478  	}
   479  
   480  	for _, job := range prowConfig.AllPeriodics() {
   481  		jobs[job.Name] = false
   482  	}
   483  
   484  	// Ignore any test groups that get their results from a gcs prefix
   485  	// that is not populated by the kubernetes prow instance
   486  	testgroups := make(map[string]bool)
   487  	for _, testgroup := range cfg.TestGroups {
   488  		for _, prowGcsPrefix := range prowGcsPrefixes {
   489  			if strings.Contains(testgroup.GcsPrefix, prowGcsPrefix) {
   490  				// The convention is that the job name is the final part of the GcsPrefix
   491  				job := filepath.Base(testgroup.GcsPrefix)
   492  				testgroups[job] = false
   493  				break // to next testgroup
   494  			}
   495  		}
   496  	}
   497  
   498  	// Each job running in the kubernetes prow instance must have an
   499  	// identically named test_groups entry in the kubernetes testgrid config
   500  	for job := range jobs {
   501  		if _, ok := testgroups[job]; ok {
   502  			testgroups[job] = true
   503  			jobs[job] = true
   504  		}
   505  	}
   506  
   507  	// Conclusion
   508  	for job, valid := range jobs {
   509  		if !valid {
   510  			t.Errorf("Job %v does not have a matching testgrid testgroup", job)
   511  		}
   512  	}
   513  
   514  	for testgroup, valid := range testgroups {
   515  		if !valid {
   516  			t.Errorf("Testgrid group %v is supposed to be moved to have their presubmits in testgrid. See this issue: https://github.com/kubernetes/test-infra/issues/18159", testgroup)
   517  		}
   518  	}
   519  }
   520  
   521  func TestReleaseBlockingJobsMustHaveTestgridDescriptions(t *testing.T) {
   522  	// TODO(spiffxp): start with master, enforce for all release branches
   523  	re := regexp.MustCompile("^sig-release-master-(blocking|informing)$")
   524  	for _, dashboard := range cfg.Dashboards {
   525  		if !re.MatchString(dashboard.Name) {
   526  			continue
   527  		}
   528  		suffix := re.FindStringSubmatch(dashboard.Name)[1]
   529  		for _, dashboardtab := range dashboard.DashboardTab {
   530  			intro := fmt.Sprintf("dashboard_tab %v/%v is release-%v", dashboard.Name, dashboardtab.Name, suffix)
   531  			if dashboardtab.Name == "" {
   532  				t.Errorf("%v: - Must have a name", intro)
   533  			}
   534  			if dashboardtab.TestGroupName == "" {
   535  				t.Errorf("%v: - Must have a test_group_name", intro)
   536  			}
   537  			if dashboardtab.Description == "" {
   538  				t.Errorf("%v: - Must have a description", intro)
   539  			}
   540  			// TODO(spiffxp): enforce for informing as well
   541  			if suffix == "informing" {
   542  				if !strings.HasPrefix(dashboardtab.Description, "OWNER: ") {
   543  					t.Logf("NOTICE: %v: - Must have a description that starts with OWNER: ", intro)
   544  				}
   545  				if dashboardtab.AlertOptions == nil {
   546  					t.Logf("NOTICE: %v: - Must have alert_options (ensure informing dashboard is listed first in testgrid-dashboards)", intro)
   547  				} else if dashboardtab.AlertOptions.AlertMailToAddresses == "" {
   548  					t.Logf("NOTICE: %v: - Must have alert_options.alert_mail_to_addresses", intro)
   549  				}
   550  			} else {
   551  				if dashboardtab.AlertOptions == nil {
   552  					t.Errorf("%v: - Must have alert_options (ensure blocking dashboard is listed first in testgrid-dashboards)", intro)
   553  				} else if dashboardtab.AlertOptions.AlertMailToAddresses == "" {
   554  					t.Errorf("%v: - Must have alert_options.alert_mail_to_addresses", intro)
   555  				}
   556  			}
   557  		}
   558  	}
   559  }
   560  
   561  func TestNoEmpyMailToAddresses(t *testing.T) {
   562  	for _, dashboard := range cfg.Dashboards {
   563  		for _, dashboardtab := range dashboard.DashboardTab {
   564  			intro := fmt.Sprintf("dashboard_tab %v/%v", dashboard.Name, dashboardtab.Name)
   565  			if dashboardtab.AlertOptions != nil {
   566  				if dashboardtab.AlertOptions.AlertMailToAddresses == "" {
   567  					continue
   568  				}
   569  				mails := strings.Split(dashboardtab.AlertOptions.AlertMailToAddresses, ",")
   570  				for _, m := range mails {
   571  					_, err := mail.ParseAddress(m)
   572  					if err != nil {
   573  						t.Errorf("%v: - invalid alert_mail_to_address '%v': %v", intro, m, err)
   574  					}
   575  				}
   576  			}
   577  		}
   578  	}
   579  }