blob: c077dc9f45530e878e11468f2e7f9fb206577cb5 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package config
import (
"context"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/retry/transient"
"go.chromium.org/luci/config"
"go.chromium.org/luci/config/cfgclient"
"go.chromium.org/luci/gae/service/datastore"
pb "go.chromium.org/luci/cv/api/config/v2"
)
const ConfigFileName = "commit-queue.cfg"
// ProjectsWithConfig returns all LUCI projects which have CV config.
func ProjectsWithConfig(ctx context.Context) ([]string, error) {
projects, err := cfgclient.ProjectsWithConfig(ctx, ConfigFileName)
if err != nil {
return nil, errors.Annotate(err, "failed to get projects with %q from LUCI Config",
ConfigFileName).Tag(transient.Tag).Err()
}
return projects, nil
}
// NotifyCallback is called in a transaction context from UpdateProject and
// DisableProject. Used by configcron package.
type NotifyCallback func(context.Context) error
// UpdateProject imports the latest CV Config for a given LUCI Project
// from LUCI Config if the config in CV is outdated.
func UpdateProject(ctx context.Context, project string, notify NotifyCallback) error {
meta, err := getConfigMeta(ctx, project)
if err != nil {
return err
}
existingPC := ProjectConfig{Project: project}
switch err := datastore.Get(ctx, &existingPC); {
case err != nil && err != datastore.ErrNoSuchEntity:
return errors.Annotate(err, "failed to get ProjectConfig(project=%q)", project).Tag(transient.Tag).Err()
case !existingPC.Enabled:
// Go through update process to ensure all configs are present.
case existingPC.ExternalHash == meta.ContentHash:
return nil // Already up-to-date.
}
cfg, err := fetchCfg(ctx, meta.ContentHash)
if err != nil {
return err
}
// Write out ConfigHashInfo if missing and all ConfigGroups.
localHash := computeHash(cfg)
cgNames := make([]string, len(cfg.GetConfigGroups()))
for i, cg := range cfg.GetConfigGroups() {
cgNames[i] = makeConfigGroupName(cg.GetName(), i)
}
targetEVersion := existingPC.EVersion + 1
err = datastore.RunInTransaction(ctx, func(ctx context.Context) error {
hashInfo := ConfigHashInfo{
GitRevision: meta.Revision,
Hash: localHash,
Project: datastore.MakeKey(ctx, projectConfigKind, project),
}
switch err := datastore.Get(ctx, &hashInfo); {
case err != nil && err != datastore.ErrNoSuchEntity:
return errors.Annotate(err, "failed to get ConfigHashInfo(Hash=%q)", localHash).Tag(transient.Tag).Err()
case err == nil && hashInfo.ProjectEVersion >= targetEVersion:
return nil // Do not go backwards.
default:
hashInfo.ProjectEVersion = targetEVersion
hashInfo.UpdateTime = datastore.RoundTime(clock.Now(ctx)).UTC()
hashInfo.ConfigGroupNames = cgNames
return errors.Annotate(datastore.Put(ctx, &hashInfo), "failed to put ConfigHashInfo(Hash=%q)", localHash).Tag(transient.Tag).Err()
}
}, nil)
if err != nil {
return errors.Annotate(err, "failed to run transaction to update ConfigHashInfo").Tag(transient.Tag).Err()
}
if err := putConfigGroups(ctx, cfg, project, localHash); err != nil {
return err
}
updated := false
err = datastore.RunInTransaction(ctx, func(ctx context.Context) error {
updated = false
pc := ProjectConfig{Project: project}
switch err := datastore.Get(ctx, &pc); {
case err != nil && err != datastore.ErrNoSuchEntity:
return errors.Annotate(err, "failed to get ProjectConfig(project=%q)", project).Tag(transient.Tag).Err()
case pc.EVersion != existingPC.EVersion:
return nil // Already updated by concurrent updateProject.
default:
pc = ProjectConfig{
Project: project,
Enabled: true,
UpdateTime: datastore.RoundTime(clock.Now(ctx)).UTC(),
EVersion: targetEVersion,
Hash: localHash,
ExternalHash: meta.ContentHash,
ConfigGroupNames: cgNames,
}
updated = true
if err := datastore.Put(ctx, &pc); err != nil {
return errors.Annotate(err, "failed to put ProjectConfig(project=%q)",
project).Tag(transient.Tag).Err()
}
return notify(ctx)
}
}, nil)
switch {
case err != nil:
return errors.Annotate(err, "failed to run transaction to update ProjectConfig").Tag(transient.Tag).Err()
case updated:
logging.Infof(ctx, "updated project %q to rev %s hash %s ", project, meta.Revision, localHash)
}
return nil
}
// getConfigMeta fetches the Meta for a project config.
//
// Returns an error in the case of an empty content hash or fetch failure.
func getConfigMeta(ctx context.Context, project string) (config.Meta, error) {
var meta config.Meta
switch err := cfgclient.Get(ctx, config.ProjectSet(project), ConfigFileName, nil, &meta); {
case err != nil:
return meta, errors.Annotate(err, "failed to fetch meta from LUCI Config").Tag(transient.Tag).Err()
case meta.ContentHash == "":
return meta, errors.Reason("LUCI Config returns empty content hash for project %q", project).Err()
default:
return meta, nil
}
}
// fetchCfg a config contents from luci-config using the content hash.
func fetchCfg(ctx context.Context, contentHash string) (*pb.Config, error) {
content, err := cfgclient.Client(ctx).GetConfigByHash(ctx, contentHash)
if err != nil {
return nil, errors.Annotate(err, "failed to get config by content hash").Tag(transient.Tag).Err()
}
ret := &pb.Config{}
if err := cfgclient.ProtoText(ret)(content); err != nil {
return nil, errors.Annotate(err, "failed to deserialize config content").Err()
}
// TODO(yiwzhang): validate the config here again to prevent ingesting a
// bad version of config that accidentally slips into LUCI Config.
// See: go.chromium.org/luci/cq/appengine/config
return ret, nil
}
// DisableProject disables the given LUCI Project if it is currently enabled.
func DisableProject(ctx context.Context, project string, notify NotifyCallback) error {
disabled := false
err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
disabled = false
pc := ProjectConfig{Project: project}
switch err := datastore.Get(ctx, &pc); {
case datastore.IsErrNoSuchEntity(err):
return nil // No-op when disabling non-existent Project
case err != nil:
return errors.Annotate(err, "failed to get existing ProjectConfig").Tag(transient.Tag).Err()
case !pc.Enabled:
return nil // Already disabled
}
pc.Enabled = false
pc.UpdateTime = datastore.RoundTime(clock.Now(ctx)).UTC()
pc.EVersion++
if err := datastore.Put(ctx, &pc); err != nil {
return errors.Annotate(err, "failed to put ProjectConfig").Tag(transient.Tag).Err()
}
disabled = true
return notify(ctx)
}, nil)
switch {
case err != nil:
return errors.Annotate(err, "failed to run transaction to disable project %q", project).Tag(transient.Tag).Err()
case disabled:
logging.Infof(ctx, "disabled project %q", project)
}
return nil
}