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

     1  // Copyright 2021 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  	"regexp"
    20  	"strings"
    21  
    22  	"google.golang.org/protobuf/encoding/prototext"
    23  
    24  	"go.chromium.org/luci/config"
    25  	"go.chromium.org/luci/config/server/cfgcache"
    26  	"go.chromium.org/luci/config/validation"
    27  
    28  	pb "go.chromium.org/luci/buildbucket/proto"
    29  )
    30  
    31  const settingsCfgFilename = "settings.cfg"
    32  
    33  // Cached settings config.
    34  var cachedSettingsCfg = cfgcache.Register(&cfgcache.Entry{
    35  	Path: settingsCfgFilename,
    36  	Type: (*pb.SettingsCfg)(nil),
    37  })
    38  
    39  // init registers validation rules.
    40  func init() {
    41  	validation.Rules.Add("services/${appid}", settingsCfgFilename, validateSettingsCfg)
    42  	validation.Rules.Add("regex:projects/.*", "${appid}.cfg", validateProjectCfg)
    43  }
    44  
    45  // validateSettingsCfg implements validation.Func and validates the content of
    46  // the settings file.
    47  //
    48  // Validation errors are returned via validation.Context. An error directly
    49  // returned by this function means a bug in the code.
    50  func validateSettingsCfg(ctx *validation.Context, configSet, path string, content []byte) error {
    51  	cfg := pb.SettingsCfg{}
    52  	if err := prototext.Unmarshal(content, &cfg); err != nil {
    53  		ctx.Errorf("invalid SettingsCfg proto message: %s", err)
    54  		return nil
    55  	}
    56  	if s := cfg.Swarming; s != nil {
    57  		ctx.Enter("swarming")
    58  		validateSwarmingSettings(ctx, s)
    59  		ctx.Exit()
    60  	}
    61  
    62  	for i, exp := range cfg.Experiment.GetExperiments() {
    63  		ctx.Enter("experiment.experiments #%d", i)
    64  		validateExperiment(ctx, exp)
    65  		ctx.Exit()
    66  	}
    67  
    68  	for i, backend := range cfg.GetBackends() {
    69  		ctx.Enter("Backends.BackendSetting #%d", i)
    70  		validateHostname(ctx, "BackendSetting.hostname", backend.GetHostname())
    71  		switch backend.Mode.(type) {
    72  		case *pb.BackendSetting_FullMode_:
    73  			validateBackendFullMode(ctx, backend.GetFullMode())
    74  		case *pb.BackendSetting_LiteMode_:
    75  		default:
    76  			ctx.Errorf("mode field is not set or its type is unsupported")
    77  		}
    78  		ctx.Exit()
    79  	}
    80  
    81  	validateHostname(ctx, "logdog.hostname", cfg.Logdog.GetHostname())
    82  	validateHostname(ctx, "resultdb.hostname", cfg.Resultdb.GetHostname())
    83  	return nil
    84  }
    85  
    86  func validateBackendFullMode(ctx *validation.Context, m *pb.BackendSetting_FullMode) {
    87  	if m.PubsubId == "" {
    88  		ctx.Errorf("pubsub_id for UpdateBuildTask must be specified")
    89  	}
    90  	validateBuildSyncSetting(ctx, m.GetBuildSyncSetting())
    91  }
    92  
    93  func validateSwarmingSettings(ctx *validation.Context, s *pb.SwarmingSettings) {
    94  	validateHostname(ctx, "milo_hostname", s.MiloHostname)
    95  	for i, pkg := range s.UserPackages {
    96  		ctx.Enter("user_packages #%d", i)
    97  		validatePackage(ctx, pkg)
    98  		ctx.Exit()
    99  	}
   100  
   101  	for i, pkg := range s.AlternativeAgentPackages {
   102  		ctx.Enter("alternative_agent_packages #%d", i)
   103  		validatePackage(ctx, pkg)
   104  		if len(pkg.OmitOnExperiment) == 0 && len(pkg.IncludeOnExperiment) == 0 {
   105  			ctx.Errorf("alternative_agent_package must set constraints on either omit_on_experiment or include_on_experiment")
   106  		}
   107  		ctx.Exit()
   108  	}
   109  
   110  	if bbPkg := s.BbagentPackage; bbPkg != nil {
   111  		ctx.Enter("bbagent_package")
   112  		validatePackage(ctx, bbPkg)
   113  		if !strings.HasSuffix(bbPkg.PackageName, "/${platform}") {
   114  			ctx.Errorf("package_name must end with '/${platform}'")
   115  		}
   116  		ctx.Exit()
   117  	}
   118  
   119  	if kitchen := s.KitchenPackage; kitchen != nil {
   120  		ctx.Enter("kitchen_package")
   121  		validatePackage(ctx, kitchen)
   122  		ctx.Exit()
   123  	}
   124  }
   125  
   126  func validateHostname(ctx *validation.Context, field string, host string) {
   127  	if host == "" {
   128  		ctx.Errorf("%s unspecified", field)
   129  	}
   130  	if strings.Contains(host, "://") {
   131  		ctx.Errorf("%s must not contain '://'", field)
   132  	}
   133  }
   134  
   135  func validateBuildSyncSetting(ctx *validation.Context, setting *pb.BackendSetting_BuildSyncSetting) {
   136  	if setting.GetShards() < 0 {
   137  		ctx.Errorf("shards must be greater than or equal to 0")
   138  	}
   139  
   140  	if setting.GetSyncIntervalSeconds() != 0 && setting.GetSyncIntervalSeconds() < 60 {
   141  		ctx.Errorf("sync_interval_seconds must be greater than or equal to 60")
   142  	}
   143  }
   144  
   145  func validatePackage(ctx *validation.Context, pkg *pb.SwarmingSettings_Package) {
   146  	if pkg.PackageName == "" {
   147  		ctx.Errorf("package_name is required")
   148  	}
   149  	if pkg.Version == "" {
   150  		ctx.Errorf("version is required")
   151  	}
   152  	if pkg.Builders != nil {
   153  		validateRegex(ctx, "builders.regex", pkg.Builders.Regex)
   154  		validateRegex(ctx, "builders.regex_exclude", pkg.Builders.RegexExclude)
   155  	}
   156  }
   157  
   158  func validateExperiment(ctx *validation.Context, exp *pb.ExperimentSettings_Experiment) {
   159  	if exp.Name == "" {
   160  		ctx.Errorf("name is required")
   161  	}
   162  	if exp.MinimumValue < 0 || exp.MinimumValue > 100 {
   163  		ctx.Errorf("minimum_value must be in [0,100]")
   164  	}
   165  	if exp.DefaultValue < exp.MinimumValue || exp.DefaultValue > 100 {
   166  		ctx.Errorf("default_value must be in [${minimum_value},100]")
   167  	}
   168  	if exp.Inactive && (exp.DefaultValue != 0 || exp.MinimumValue != 0) {
   169  		ctx.Errorf("default_value and minimum_value must both be 0 when inactive is true")
   170  	}
   171  	if exp.Builders != nil {
   172  		validateRegex(ctx, "builders.regex", exp.Builders.Regex)
   173  		validateRegex(ctx, "builders.regex_exclude", exp.Builders.RegexExclude)
   174  	}
   175  }
   176  
   177  func validateRegex(ctx *validation.Context, field string, patterns []string) {
   178  	for _, p := range patterns {
   179  		if _, err := regexp.Compile(p); err != nil {
   180  			ctx.Errorf("%s %q: invalid regex", field, p)
   181  		}
   182  	}
   183  }
   184  
   185  // UpdateSettingsCfg is called from a cron periodically to import settings.cfg into datastore.
   186  func UpdateSettingsCfg(ctx context.Context) error {
   187  	_, err := cachedSettingsCfg.Update(ctx, nil)
   188  	return err
   189  }
   190  
   191  // GetSettingsCfg fetches the settings.cfg from luci-config.
   192  func GetSettingsCfg(ctx context.Context) (*pb.SettingsCfg, error) {
   193  	cfg, err := cachedSettingsCfg.Get(ctx, nil)
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  	return cfg.(*pb.SettingsCfg), nil
   198  }
   199  
   200  // SetTestSettingsCfg is used in tests only.
   201  func SetTestSettingsCfg(ctx context.Context, cfg *pb.SettingsCfg) error {
   202  	return cachedSettingsCfg.Set(ctx, cfg, &config.Meta{Path: "settings.cfg"})
   203  }