go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/config/project_config.go (about)

     1  // Copyright 2022 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 validates and gives access to ResultDB LUCI config.
    16  package config
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"time"
    22  
    23  	"google.golang.org/protobuf/proto"
    24  
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/common/logging"
    27  	"go.chromium.org/luci/common/tsmon/field"
    28  	"go.chromium.org/luci/common/tsmon/metric"
    29  	"go.chromium.org/luci/config"
    30  	"go.chromium.org/luci/config/cfgclient"
    31  	"go.chromium.org/luci/config/validation"
    32  	"go.chromium.org/luci/gae/service/datastore"
    33  	"go.chromium.org/luci/server/caching"
    34  
    35  	configpb "go.chromium.org/luci/resultdb/proto/config"
    36  )
    37  
    38  var projectCacheSlot = caching.RegisterCacheSlot()
    39  
    40  const projectConfigKind = "resultdb.ProjectConfig"
    41  
    42  var (
    43  	importAttemptCounter = metric.NewCounter(
    44  		"resultdb/project_config/import_attempt",
    45  		"The number of import attempts of project config",
    46  		nil,
    47  		// status can be "success" or "failure".
    48  		field.String("project"), field.String("status"))
    49  )
    50  
    51  var (
    52  	// Returned if configuration for a given project does not exist.
    53  	ErrNotFoundProjectConfig = fmt.Errorf("no project config found")
    54  )
    55  
    56  type cachedProjectConfig struct {
    57  	_extra datastore.PropertyMap `gae:"-,extra"`
    58  	_kind  string                `gae:"$kind,resultdb.ProjectConfig"`
    59  
    60  	ID     string      `gae:"$id"` // The name of the project for which the config is.
    61  	Config []byte      `gae:",noindex"`
    62  	Meta   config.Meta `gae:",noindex"`
    63  }
    64  
    65  func init() {
    66  	// Registers validation of the given configuration paths with cfgmodule.
    67  	validation.Rules.Add("regex:projects/.*", "${appid}.cfg", func(ctx *validation.Context, configSet, path string, content []byte) error {
    68  		// Discard the returned deserialized message.
    69  		validateProjectConfigRaw(ctx, string(content))
    70  		return nil
    71  	})
    72  }
    73  
    74  // UpdateProjects fetches fresh project-level configuration from LUCI Config
    75  // service and stores it in datastore.
    76  func UpdateProjects(ctx context.Context) error {
    77  	// Fetch freshest configs from the LUCI Config.
    78  	fetchedConfigs, err := fetchLatestProjectConfigs(ctx)
    79  	if err != nil {
    80  		return err
    81  	}
    82  
    83  	var errs []error
    84  	parsedConfigs := make(map[string]*fetchedProjectConfig)
    85  	for project, fetch := range fetchedConfigs {
    86  		valCtx := validation.Context{Context: ctx}
    87  		valCtx.SetFile(fetch.Path)
    88  		msg := validateProjectConfigRaw(&valCtx, fetch.Content)
    89  		if err := valCtx.Finalize(); err != nil {
    90  			blocking := err.(*validation.Error).WithSeverity(validation.Blocking)
    91  			if blocking != nil {
    92  				// Continue through validation errors to ensure a validation
    93  				// error in one project does not affect other projects.
    94  				errs = append(errs, errors.Annotate(blocking, "validation errors for %q", project).Err())
    95  				msg = nil
    96  			}
    97  		}
    98  		// We create an entry even for invalid config (where msg == nil),
    99  		// because we want to signal that config for this project still exists
   100  		// and existing config should be retained instead of being deleted.
   101  		parsedConfigs[project] = &fetchedProjectConfig{
   102  			Config: msg,
   103  			Meta:   fetch.Meta,
   104  		}
   105  	}
   106  	forceUpdate := false
   107  	success := true
   108  	if err := updateStoredConfig(ctx, parsedConfigs, forceUpdate); err != nil {
   109  		errs = append(errs, err)
   110  		success = false
   111  	}
   112  	// Report success for all projects that passed validation, assuming the
   113  	// update succeeded.
   114  	for project, config := range parsedConfigs {
   115  		status := "success"
   116  		if !success || config.Config == nil {
   117  			status = "failure"
   118  		}
   119  		importAttemptCounter.Add(ctx, 1, project, status)
   120  	}
   121  
   122  	if len(errs) > 0 {
   123  		return errors.NewMultiError(errs...)
   124  	}
   125  	return nil
   126  }
   127  
   128  type fetchedProjectConfig struct {
   129  	// config is the project-level configuration, if it has passed validation,
   130  	// and nil otherwise.
   131  	Config *configpb.ProjectConfig
   132  	// meta is populated with config metadata.
   133  	Meta config.Meta
   134  }
   135  
   136  // updateStoredConfig updates the config stored in datastore. fetchedConfigs
   137  // contains the new configs to store, forceUpdate forces overwrite of existing
   138  // configuration (ignoring whether the config revision is newer).
   139  func updateStoredConfig(ctx context.Context, fetchedConfigs map[string]*fetchedProjectConfig, forceUpdate bool) error {
   140  	// Drop out of any existing datastore transactions.
   141  	ctx = cleanContext(ctx)
   142  
   143  	currentConfigs, err := fetchProjectConfigEntities(ctx)
   144  	if err != nil {
   145  		return err
   146  	}
   147  
   148  	var errs []error
   149  	var toPut []*cachedProjectConfig
   150  	for project, fetch := range fetchedConfigs {
   151  		if fetch.Config == nil {
   152  			// Config did not pass validation.
   153  			continue
   154  		}
   155  		blob, err := proto.Marshal(fetch.Config)
   156  		if err != nil {
   157  			// Continue through errors to ensure bad config for one project
   158  			// does not affect others.
   159  			errs = append(errs, errors.Annotate(err, "").Err())
   160  			continue
   161  		}
   162  		cur, ok := currentConfigs[project]
   163  		if !ok {
   164  			cur = &cachedProjectConfig{
   165  				ID: project,
   166  			}
   167  		}
   168  		if !forceUpdate && cur.Meta.Revision == fetch.Meta.Revision {
   169  			logging.Infof(ctx, "Cached config %s is up-to-date at rev %q", cur.ID, cur.Meta.Revision)
   170  			continue
   171  		}
   172  		logging.Infof(ctx, "Updating cached config %s: %q -> %q", cur.ID, cur.Meta.Revision, fetch.Meta.Revision)
   173  		toPut = append(toPut, &cachedProjectConfig{
   174  			ID:     cur.ID,
   175  			Config: blob,
   176  			Meta:   fetch.Meta,
   177  		})
   178  	}
   179  	if err := datastore.Put(ctx, toPut); err != nil {
   180  		errs = append(errs, errors.Annotate(err, "updating project configs").Err())
   181  	}
   182  
   183  	var toDelete []*datastore.Key
   184  	for project, cur := range currentConfigs {
   185  		if _, ok := fetchedConfigs[project]; ok {
   186  			continue
   187  		}
   188  		toDelete = append(toDelete, datastore.KeyForObj(ctx, cur))
   189  	}
   190  
   191  	if err := datastore.Delete(ctx, toDelete); err != nil {
   192  		errs = append(errs, errors.Annotate(err, "deleting stale project configs").Err())
   193  	}
   194  
   195  	if len(errs) > 0 {
   196  		return errors.NewMultiError(errs...)
   197  	}
   198  	return nil
   199  }
   200  
   201  func fetchLatestProjectConfigs(ctx context.Context) (map[string]config.Config, error) {
   202  	configs, err := cfgclient.Client(ctx).GetProjectConfigs(ctx, "${appid}.cfg", false)
   203  	if err != nil {
   204  		return nil, err
   205  	}
   206  	result := make(map[string]config.Config)
   207  	for _, cfg := range configs {
   208  		project := cfg.ConfigSet.Project()
   209  		if project != "" {
   210  			result[project] = cfg
   211  		}
   212  	}
   213  	return result, nil
   214  }
   215  
   216  // fetchProjectConfigEntities retrieves project configuration entities
   217  // from datastore, including metadata.
   218  func fetchProjectConfigEntities(ctx context.Context) (map[string]*cachedProjectConfig, error) {
   219  	var configs []*cachedProjectConfig
   220  	err := datastore.GetAll(ctx, datastore.NewQuery(projectConfigKind), &configs)
   221  	if err != nil {
   222  		return nil, errors.Annotate(err, "fetching project configs from datastore").Err()
   223  	}
   224  	result := make(map[string]*cachedProjectConfig)
   225  	for _, cfg := range configs {
   226  		result[cfg.ID] = cfg
   227  	}
   228  	return result, nil
   229  }
   230  
   231  // Projects returns all project configurations, in a map by project name.
   232  // Uses in-memory cache to avoid hitting datastore all the time.
   233  // Note that the config may be stale by up to 1 minute.
   234  func Projects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) {
   235  	val, err := projectCacheSlot.Fetch(ctx, func(any) (val any, exp time.Duration, err error) {
   236  		var pc map[string]*configpb.ProjectConfig
   237  		if pc, err = fetchProjects(ctx); err != nil {
   238  			return nil, 0, err
   239  		}
   240  		return pc, time.Minute, nil
   241  	})
   242  	switch {
   243  	case err == caching.ErrNoProcessCache:
   244  		// A fallback useful in unit tests that may not have the process cache
   245  		// available. Production environments usually have the cache installed
   246  		// by the framework code that initializes the root context.
   247  		return fetchProjects(ctx)
   248  	case err != nil:
   249  		return nil, err
   250  	default:
   251  		pc := val.(map[string]*configpb.ProjectConfig)
   252  		return pc, nil
   253  	}
   254  }
   255  
   256  // fetchProjects retrieves all project configurations from datastore.
   257  func fetchProjects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) {
   258  	ctx = cleanContext(ctx)
   259  
   260  	cachedCfgs, err := fetchProjectConfigEntities(ctx)
   261  	if err != nil {
   262  		return nil, errors.Annotate(err, "fetching cached config").Err()
   263  	}
   264  	result := make(map[string]*configpb.ProjectConfig)
   265  	for project, cached := range cachedCfgs {
   266  		cfg := &configpb.ProjectConfig{}
   267  		if err := proto.Unmarshal(cached.Config, cfg); err != nil {
   268  			return nil, errors.Annotate(err, "unmarshalling cached config").Err()
   269  		}
   270  		result[project] = cfg
   271  	}
   272  	return result, nil
   273  }
   274  
   275  // cleanContext returns a context with datastore and not using transactions.
   276  func cleanContext(ctx context.Context) context.Context {
   277  	return datastore.WithoutTransaction(ctx)
   278  }
   279  
   280  // SetTestProjectConfig sets test project configuration in datastore.
   281  // It should be used from unit/integration tests only.
   282  func SetTestProjectConfig(ctx context.Context, cfg map[string]*configpb.ProjectConfig) error {
   283  	fetchedConfigs := make(map[string]*fetchedProjectConfig)
   284  	for project, pcfg := range cfg {
   285  		fetchedConfigs[project] = &fetchedProjectConfig{
   286  			Config: pcfg,
   287  			Meta:   config.Meta{},
   288  		}
   289  	}
   290  	forceUpdate := true
   291  	if err := updateStoredConfig(ctx, fetchedConfigs, forceUpdate); err != nil {
   292  		return err
   293  	}
   294  	testable := datastore.GetTestable(ctx)
   295  	if testable == nil {
   296  		return errors.New("SetTestProjectConfig should only be used with testable datastore implementations")
   297  	}
   298  	// An up-to-date index is required for fetch to retrieve the project
   299  	// entities we just saved.
   300  	testable.CatchupIndexes()
   301  	return nil
   302  }
   303  
   304  // Project returns the configurations of the requested project.
   305  // Returns an ErrNotFoundProjectConfig error if config for the given project
   306  // does not exist.
   307  func Project(ctx context.Context, project string) (*configpb.ProjectConfig, error) {
   308  	configs, err := Projects(ctx)
   309  	if err != nil {
   310  		return nil, err
   311  	}
   312  	if c, ok := configs[project]; ok {
   313  		return c, nil
   314  	}
   315  	return nil, ErrNotFoundProjectConfig
   316  }