github.com/GoogleCloudPlatform/testgrid@v0.0.174/pkg/merger/merger.go (about)

     1  /*
     2  Copyright 2021 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 merger
    18  
    19  import (
    20  	"context"
    21  	"errors"
    22  	"fmt"
    23  	"sync"
    24  	"time"
    25  
    26  	"cloud.google.com/go/storage"
    27  	"github.com/golang/protobuf/proto"
    28  	"github.com/sirupsen/logrus"
    29  	"gopkg.in/yaml.v2"
    30  
    31  	"github.com/GoogleCloudPlatform/testgrid/config"
    32  	configpb "github.com/GoogleCloudPlatform/testgrid/pb/config"
    33  	"github.com/GoogleCloudPlatform/testgrid/util/gcs"
    34  	"github.com/GoogleCloudPlatform/testgrid/util/metrics"
    35  )
    36  
    37  const componentName = "config-merger"
    38  
    39  // MergeList is a list of config sources to merge together
    40  // ParseAndCheck will construct this from a YAML document
    41  type MergeList struct {
    42  	Target  string    `json:"Target"`
    43  	Path    *gcs.Path `json:"-"`
    44  	Sources []Source  `json:"Sources"`
    45  }
    46  
    47  // Source represents a configuration source in cloud storage
    48  type Source struct {
    49  	Name     string    `json:"Name"`
    50  	Location string    `json:"Location"`
    51  	Path     *gcs.Path `json:"-"`
    52  	Contact  string    `json:"Contact,omitempty"`
    53  }
    54  
    55  // ParseAndCheck parses and checks the configuration file for common errors
    56  func ParseAndCheck(data []byte) (list MergeList, err error) {
    57  	err = yaml.UnmarshalStrict(data, &list)
    58  	if err != nil {
    59  		return
    60  	}
    61  
    62  	list.Path, err = gcs.NewPath(list.Target)
    63  	if err != nil {
    64  		return
    65  	}
    66  
    67  	if len(list.Sources) == 0 {
    68  		return list, errors.New("no shards to converge")
    69  	}
    70  
    71  	names := map[string]bool{}
    72  	for i, source := range list.Sources {
    73  		if _, exists := names[source.Name]; exists {
    74  			return list, fmt.Errorf("duplicated name %s", source.Name)
    75  		}
    76  		path, err := gcs.NewPath(source.Location)
    77  		if err != nil {
    78  			return list, err
    79  		}
    80  		list.Sources[i].Path = path
    81  		source.Path = path
    82  		names[source.Name] = true
    83  	}
    84  
    85  	return
    86  }
    87  
    88  // Metrics holds metrics relevant to the config merger.
    89  type Metrics struct {
    90  	Update       metrics.Cyclic
    91  	Fields       metrics.Int64
    92  	LastModified metrics.Int64
    93  }
    94  
    95  // CreateMetrics creates metrics for the Config Merger
    96  func CreateMetrics(factory metrics.Factory) *Metrics {
    97  	return &Metrics{
    98  		Update:       factory.NewCyclic(componentName),
    99  		Fields:       factory.NewInt64("config_fields", "Config field usage by name", "component", "field"),
   100  		LastModified: factory.NewInt64("last_modified", "Seconds since shard last modified ", "shard"),
   101  	}
   102  }
   103  
   104  type mergeClient interface {
   105  	gcs.Opener
   106  	gcs.Uploader
   107  }
   108  
   109  // MergeAndUpdate gathers configurations from each path and merges them.
   110  // Puts the result at targetPath if confirm is true
   111  // Will skip an input config if it is invalid and skipValidate is false
   112  // Other problems are considered fatal and will return an error
   113  func MergeAndUpdate(ctx context.Context, client mergeClient, mets *Metrics, list MergeList, skipValidate, confirm bool) (*configpb.Configuration, error) {
   114  	ctx, cancel := context.WithCancel(ctx)
   115  	defer cancel()
   116  
   117  	var finish *metrics.CycleReporter
   118  	if mets != nil {
   119  		finish = mets.Update.Start()
   120  	}
   121  
   122  	// TODO: Cache the version for each source. Only read if they've changed.
   123  	shards := map[string]*configpb.Configuration{}
   124  	var shardsLock sync.Mutex
   125  	var fatal error
   126  
   127  	var wg sync.WaitGroup
   128  
   129  	for _, source := range list.Sources {
   130  		if source.Path == nil {
   131  			finish.Skip()
   132  			return nil, fmt.Errorf("path at %q is nil", source.Name)
   133  		}
   134  
   135  		wg.Add(1)
   136  		source := source
   137  		go func() {
   138  			defer wg.Done()
   139  			cfg, attrs, err := config.ReadGCS(ctx, client, *source.Path)
   140  			recordLastModified(attrs, mets, source.Name)
   141  			if err != nil {
   142  				// Log each fatal error, but it's okay to return any fatal error
   143  				logrus.WithError(err).WithFields(logrus.Fields{
   144  					"component":   "config-merger",
   145  					"config-path": source.Location,
   146  					"contact":     source.Contact,
   147  				}).Errorf("can't read config %q", source.Name)
   148  				fatal = fmt.Errorf("can't read config %q at %s: %w", source.Name, source.Path, err)
   149  				return
   150  			}
   151  			if !skipValidate {
   152  				if err := config.Validate(cfg); err != nil {
   153  					logrus.WithError(err).WithFields(logrus.Fields{
   154  						"component":   "config-merger",
   155  						"config-path": source.Location,
   156  						"contact":     source.Contact,
   157  					}).Errorf("config %q is invalid; skipping config", source.Name)
   158  					return
   159  				}
   160  			}
   161  
   162  			shardsLock.Lock()
   163  			defer shardsLock.Unlock()
   164  			shards[source.Name] = cfg
   165  		}()
   166  	}
   167  
   168  	wg.Wait()
   169  
   170  	if fatal != nil {
   171  		finish.Fail()
   172  		return nil, fatal
   173  	}
   174  	if len(shards) == 0 {
   175  		finish.Skip()
   176  		return nil, errors.New("no configs to merge")
   177  	}
   178  
   179  	// Merge and output the result
   180  	result, err := config.Converge(shards)
   181  	if err != nil {
   182  		finish.Fail()
   183  		return result, fmt.Errorf("can't merge configurations: %w", err)
   184  	}
   185  
   186  	if !confirm {
   187  		fmt.Println(result)
   188  		finish.Success()
   189  		return result, nil
   190  	}
   191  
   192  	// Log each field as a metric
   193  	if mets != nil {
   194  		f := config.Fields(result)
   195  		for name, qty := range f {
   196  			mets.Fields.Set(qty, componentName, name)
   197  		}
   198  	}
   199  
   200  	buf, err := proto.Marshal(result)
   201  	if err != nil {
   202  		finish.Fail()
   203  		return result, fmt.Errorf("can't marshal merged proto: %w", err)
   204  	}
   205  
   206  	if _, err := client.Upload(ctx, *list.Path, buf, gcs.DefaultACL, gcs.NoCache); err != nil {
   207  		finish.Fail()
   208  		return result, fmt.Errorf("can't upload merged proto to %s: %w", list.Path, err)
   209  	}
   210  
   211  	finish.Success()
   212  	return result, nil
   213  }
   214  
   215  func recordLastModified(attrs *storage.ReaderObjectAttrs, mets *Metrics, source string) {
   216  	if attrs != nil {
   217  		lastModified := attrs.LastModified
   218  		diff := time.Since(lastModified)
   219  		if mets != nil {
   220  			mets.LastModified.Set(int64(diff.Seconds()), source)
   221  		}
   222  		logrus.WithFields(logrus.Fields{
   223  			"diff":  diff,
   224  			"shard": source,
   225  		}).Info("Time since last updated.")
   226  	}
   227  }