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 }