go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/configs/prjcfg/refresher/refresh.go (about)

     1  // Copyright 2020 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 refresher
    16  
    17  import (
    18  	"context"
    19  
    20  	"go.chromium.org/luci/common/clock"
    21  	"go.chromium.org/luci/common/errors"
    22  	"go.chromium.org/luci/common/logging"
    23  	"go.chromium.org/luci/common/retry/transient"
    24  	"go.chromium.org/luci/config"
    25  	"go.chromium.org/luci/config/cfgclient"
    26  	lucivalidation "go.chromium.org/luci/config/validation"
    27  	"go.chromium.org/luci/gae/service/datastore"
    28  
    29  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    30  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    31  	"go.chromium.org/luci/cv/internal/configs/validation"
    32  )
    33  
    34  // ConfigFileName is the filename of CV project configs.
    35  const ConfigFileName = "commit-queue.cfg"
    36  
    37  // projectsWithConfig returns all LUCI projects which have CV config.
    38  func projectsWithConfig(ctx context.Context) ([]string, error) {
    39  	projects, err := cfgclient.ProjectsWithConfig(ctx, ConfigFileName)
    40  	if err != nil {
    41  		return nil, errors.Annotate(err, "failed to get projects with %q from LUCI Config",
    42  			ConfigFileName).Tag(transient.Tag).Err()
    43  	}
    44  	return projects, nil
    45  }
    46  
    47  // NotifyCallback is called in a transaction context from UpdateProject and
    48  // DisableProject. Used by configcron package.
    49  type NotifyCallback func(context.Context) error
    50  
    51  // UpdateProject imports the latest CV Config for a given LUCI Project
    52  // from LUCI Config if the config in CV is outdated.
    53  func UpdateProject(ctx context.Context, project string, notify NotifyCallback) error {
    54  	need, existingPC, err := needsUpdate(ctx, project)
    55  	switch {
    56  	case err != nil:
    57  		return err
    58  	case !need:
    59  		return nil
    60  	}
    61  
    62  	cfg, meta, err := fetchCfg(ctx, project)
    63  	if err != nil {
    64  		return err
    65  	}
    66  	vctx := &lucivalidation.Context{Context: ctx}
    67  	if err := validation.ValidateProject(vctx, cfg, project); err != nil {
    68  		return errors.Annotate(err, "ValidateProject").Err()
    69  	}
    70  	if verr := vctx.Finalize(); verr != nil {
    71  		logging.Errorf(ctx, "UpdateProject %q on invalid config: %s", project, verr)
    72  	}
    73  
    74  	// Write out ConfigHashInfo if missing and all ConfigGroups.
    75  	localHash := prjcfg.ComputeHash(cfg)
    76  	cgNames := make([]string, len(cfg.GetConfigGroups()))
    77  	for i, cg := range cfg.GetConfigGroups() {
    78  		cgNames[i] = cg.GetName()
    79  	}
    80  	targetEVersion := existingPC.EVersion + 1
    81  
    82  	err = datastore.RunInTransaction(ctx, func(ctx context.Context) error {
    83  		hashInfo := prjcfg.ConfigHashInfo{
    84  			Hash:    localHash,
    85  			Project: prjcfg.ProjectConfigKey(ctx, project),
    86  		}
    87  		switch err := datastore.Get(ctx, &hashInfo); {
    88  		case err != nil && err != datastore.ErrNoSuchEntity:
    89  			return errors.Annotate(err, "failed to get ConfigHashInfo(Hash=%q)", localHash).Tag(transient.Tag).Err()
    90  		case err == nil && hashInfo.ProjectEVersion >= targetEVersion:
    91  			return nil // Do not go backwards.
    92  		default:
    93  			hashInfo.ProjectEVersion = targetEVersion
    94  			hashInfo.UpdateTime = datastore.RoundTime(clock.Now(ctx)).UTC()
    95  			hashInfo.ConfigGroupNames = cgNames
    96  			hashInfo.GitRevision = meta.Revision
    97  			hashInfo.SchemaVersion = prjcfg.SchemaVersion
    98  			return errors.Annotate(datastore.Put(ctx, &hashInfo), "failed to put ConfigHashInfo(Hash=%q)", localHash).Tag(transient.Tag).Err()
    99  		}
   100  	}, nil)
   101  	if err != nil {
   102  		return errors.Annotate(err, "failed to run transaction to update ConfigHashInfo").Tag(transient.Tag).Err()
   103  	}
   104  
   105  	if err := putConfigGroups(ctx, cfg, project, localHash); err != nil {
   106  		return err
   107  	}
   108  
   109  	updated := false
   110  	err = datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   111  		updated = false
   112  		pc := prjcfg.ProjectConfig{Project: project}
   113  		switch err := datastore.Get(ctx, &pc); {
   114  		case err != nil && err != datastore.ErrNoSuchEntity:
   115  			return errors.Annotate(err, "failed to get ProjectConfig(project=%q)", project).Tag(transient.Tag).Err()
   116  		case pc.EVersion != existingPC.EVersion:
   117  			return nil // Already updated by concurrent updateProject.
   118  		default:
   119  			pc = prjcfg.ProjectConfig{
   120  				Project:          project,
   121  				Enabled:          true,
   122  				UpdateTime:       datastore.RoundTime(clock.Now(ctx)).UTC(),
   123  				EVersion:         targetEVersion,
   124  				Hash:             localHash,
   125  				ExternalHash:     meta.ContentHash,
   126  				ConfigGroupNames: cgNames,
   127  				SchemaVersion:    prjcfg.SchemaVersion,
   128  			}
   129  			updated = true
   130  			if err := datastore.Put(ctx, &pc); err != nil {
   131  				return errors.Annotate(err, "failed to put ProjectConfig(project=%q)", project).Tag(transient.Tag).Err()
   132  			}
   133  			return notify(ctx)
   134  		}
   135  	}, nil)
   136  
   137  	switch {
   138  	case err != nil:
   139  		return errors.Annotate(err, "failed to run transaction to update ProjectConfig").Tag(transient.Tag).Err()
   140  	case updated:
   141  		logging.Infof(ctx, "updated project %q to rev %s hash %s ", project, meta.Revision, localHash)
   142  	}
   143  	return nil
   144  }
   145  
   146  // needsUpdate checks if there is a new config version.
   147  //
   148  // Loads and returns the ProjectConfig stored in Datastore.
   149  func needsUpdate(ctx context.Context, project string) (bool, prjcfg.ProjectConfig, error) {
   150  	pc := prjcfg.ProjectConfig{Project: project}
   151  	var meta config.Meta
   152  	// NOTE: config metadata fetched here can't be used later to fetch actual
   153  	// contents (see https://crrev.com/c/3050832), so it is only used
   154  	// to check if fetching config contents is even necessary.
   155  	ps, err := config.ProjectSet(project)
   156  	if err != nil {
   157  		return false, pc, err
   158  	}
   159  	switch err := cfgclient.Get(ctx, ps, ConfigFileName, nil, &meta); {
   160  	case err != nil:
   161  		return false, pc, errors.Annotate(err, "failed to fetch meta from LUCI Config").Tag(transient.Tag).Err()
   162  	case meta.ContentHash == "":
   163  		return false, pc, errors.Reason("LUCI Config returns empty content hash for project %q", project).Err()
   164  	}
   165  
   166  	switch err := datastore.Get(ctx, &pc); {
   167  	case err == datastore.ErrNoSuchEntity:
   168  		// ProjectConfig's zero value is a good sentinel for non yet saved case.
   169  		return true, pc, nil
   170  	case err != nil:
   171  		return false, pc, errors.Annotate(err, "failed to get ProjectConfig(project=%q)", project).Tag(transient.Tag).Err()
   172  	case !pc.Enabled:
   173  		// Go through update process to ensure all configs are present.
   174  		return true, pc, nil
   175  	case pc.ExternalHash != meta.ContentHash:
   176  		return true, pc, nil
   177  	case pc.SchemaVersion != prjcfg.SchemaVersion:
   178  		// Intentionally using != here s.t. rollbacks result in downgrading of the
   179  		// schema. Given that project configs are checked and potentially updated
   180  		// every ~1 minute, this if OK.
   181  		return true, pc, nil
   182  	default:
   183  		// Already up-to-date.
   184  		return false, pc, nil
   185  	}
   186  }
   187  
   188  // fetchCfg a project config contents from luci-config.
   189  func fetchCfg(ctx context.Context, project string) (*cfgpb.Config, *config.Meta, error) {
   190  	meta := &config.Meta{}
   191  	ret := &cfgpb.Config{}
   192  	ps, err := config.ProjectSet(project)
   193  	if err != nil {
   194  		return nil, nil, err
   195  	}
   196  	err = cfgclient.Get(
   197  		ctx,
   198  		ps,
   199  		ConfigFileName,
   200  		cfgclient.ProtoText(ret),
   201  		meta,
   202  	)
   203  	if err != nil {
   204  		return nil, nil, errors.Annotate(err, "failed to get the project config").Err()
   205  	}
   206  	// TODO(yiwzhang): validate the config here again to prevent ingesting a
   207  	// bad version of config that accidentally slips into LUCI Config.
   208  	// See: go.chromium.org/luci/cq/appengine/config
   209  	return ret, meta, nil
   210  }
   211  
   212  // DisableProject disables the given LUCI Project if it is currently enabled.
   213  func DisableProject(ctx context.Context, project string, notify NotifyCallback) error {
   214  	disabled := false
   215  
   216  	err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
   217  		disabled = false
   218  		pc := prjcfg.ProjectConfig{Project: project}
   219  		switch err := datastore.Get(ctx, &pc); {
   220  		case datastore.IsErrNoSuchEntity(err):
   221  			return nil // No-op when disabling non-existent Project
   222  		case err != nil:
   223  			return errors.Annotate(err, "failed to get existing ProjectConfig").Tag(transient.Tag).Err()
   224  		case !pc.Enabled:
   225  			return nil // Already disabled
   226  		}
   227  		pc.Enabled = false
   228  		pc.UpdateTime = datastore.RoundTime(clock.Now(ctx)).UTC()
   229  		pc.EVersion++
   230  		if err := datastore.Put(ctx, &pc); err != nil {
   231  			return errors.Annotate(err, "failed to put ProjectConfig").Tag(transient.Tag).Err()
   232  		}
   233  		disabled = true
   234  		return notify(ctx)
   235  	}, nil)
   236  
   237  	switch {
   238  	case err != nil:
   239  		return errors.Annotate(err, "failed to run transaction to disable project %q", project).Tag(transient.Tag).Err()
   240  	case disabled:
   241  		logging.Infof(ctx, "disabled project %q", project)
   242  	}
   243  	return nil
   244  }
   245  
   246  // putConfigGroups puts the ConfigGroups in the given CV config to datastore.
   247  //
   248  // It checks for existence of each ConfigGroup first to avoid unnecessary puts.
   249  // It is also idempotent so it is safe to retry and can be called out of a
   250  // transactional context.
   251  func putConfigGroups(ctx context.Context, cfg *cfgpb.Config, project, hash string) error {
   252  	cgLen := len(cfg.GetConfigGroups())
   253  	if cgLen == 0 {
   254  		return nil
   255  	}
   256  
   257  	// Check if there are any existing entities with the current schema version
   258  	// such that we can skip updating them.
   259  	projKey := prjcfg.ProjectConfigKey(ctx, project)
   260  	entities := make([]*prjcfg.ConfigGroup, cgLen)
   261  	for i, cg := range cfg.GetConfigGroups() {
   262  		entities[i] = &prjcfg.ConfigGroup{
   263  			ID:      prjcfg.MakeConfigGroupID(hash, cg.GetName()),
   264  			Project: projKey,
   265  		}
   266  	}
   267  	err := datastore.Get(ctx, entities)
   268  	errs, ok := err.(errors.MultiError)
   269  	switch {
   270  	case err != nil && !ok:
   271  		return errors.Annotate(err, "failed to check the existence of ConfigGroups").Tag(transient.Tag).Err()
   272  	case err == nil:
   273  		errs = make(errors.MultiError, cgLen)
   274  	}
   275  	toPut := entities[:0] // re-use the slice
   276  	for i, err := range errs {
   277  		ent := entities[i]
   278  		switch {
   279  		case err == datastore.ErrNoSuchEntity:
   280  			// proceed to put below.
   281  		case err != nil:
   282  			return errors.Annotate(err, "failed to check the existence of one of ConfigGroups").Tag(transient.Tag).Err()
   283  		case ent.SchemaVersion != prjcfg.SchemaVersion:
   284  			// Intentionally using != here s.t. rollbacks result in downgrading
   285  			// of the schema. Given that project configs are checked and
   286  			// potentially updated every ~1 minute, this if OK.
   287  		default:
   288  			continue // up to date
   289  		}
   290  		ent.SchemaVersion = prjcfg.SchemaVersion
   291  		ent.DrainingStartTime = cfg.GetDrainingStartTime()
   292  		ent.SubmitOptions = cfg.GetSubmitOptions()
   293  		ent.Content = cfg.GetConfigGroups()[i]
   294  		ent.CQStatusHost = cfg.GetCqStatusHost()
   295  		toPut = append(toPut, ent)
   296  	}
   297  
   298  	if err := datastore.Put(ctx, toPut); err != nil {
   299  		return errors.Annotate(err, "failed to put ConfigGroups").Tag(transient.Tag).Err()
   300  	}
   301  	return nil
   302  }