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 }