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

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