go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/experiments.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 lucicfg
    16  
    17  import (
    18  	"fmt"
    19  	"sort"
    20  	"strings"
    21  
    22  	"go.chromium.org/luci/common/data/stringset"
    23  	"go.chromium.org/luci/common/logging"
    24  
    25  	"go.starlark.net/starlark"
    26  	"go.starlark.net/syntax"
    27  )
    28  
    29  // experiments holds a set of registered experiment IDs and enabled ones.
    30  type experiments struct {
    31  	all        map[string]version // name => lucicfg version to auto-enable on
    32  	enabled    stringset.Set
    33  	lucicfgVer version // max of versions passed to lucicfg.check_version(...)
    34  }
    35  
    36  // Register adds an experiment ID to the set of known experiments.
    37  func (exp *experiments) Register(id string, minVer starlark.Tuple) {
    38  	if exp.all == nil {
    39  		exp.all = make(map[string]version, 1)
    40  	}
    41  
    42  	ver := version{minVer}
    43  	exp.all[id] = ver
    44  
    45  	// Auto-enable based on version passed to lucicfg.check_version(...).
    46  	if exp.lucicfgVer.isSet() && ver.isSet() && exp.lucicfgVer.greaterOrEq(ver) {
    47  		exp.Enable(id)
    48  	}
    49  }
    50  
    51  // Registered returns a sorted list of all registered experiments.
    52  func (exp *experiments) Registered() []string {
    53  	names := make([]string, 0, len(exp.all))
    54  	for name := range exp.all {
    55  		names = append(names, name)
    56  	}
    57  	sort.Strings(names)
    58  	return names
    59  }
    60  
    61  // Enable adds an experiment ID to the set of enabled experiments.
    62  //
    63  // Always succeeds, but returns false if the experiment ID hasn't been
    64  // registered before.
    65  func (exp *experiments) Enable(id string) bool {
    66  	if exp.enabled == nil {
    67  		exp.enabled = stringset.New(1)
    68  	}
    69  	exp.enabled.Add(id)
    70  	_, known := exp.all[id]
    71  	return known
    72  }
    73  
    74  // IsEnabled returns true if an experiment has been enabled already.
    75  func (exp *experiments) IsEnabled(id string) bool {
    76  	return exp.enabled.Has(id)
    77  }
    78  
    79  // setMinVersion is called from lucicfg.check_version(...).
    80  //
    81  // Auto-enables eligible experiments.
    82  func (exp *experiments) setMinVersion(minVer starlark.Tuple) {
    83  	ver := version{minVer}
    84  	if !ver.isSet() {
    85  		panic(fmt.Sprintf("empty version passed to setMinVersion: %v", minVer))
    86  	}
    87  	if exp.lucicfgVer.isSet() && exp.lucicfgVer.greaterOrEq(ver) {
    88  		return // already checked with more recent version
    89  	}
    90  	exp.lucicfgVer = ver
    91  
    92  	// Auto-enable experiments based on their enable_on_min_version.
    93  	for id, min := range exp.all {
    94  		if min.isSet() && exp.lucicfgVer.greaterOrEq(min) {
    95  			exp.Enable(id)
    96  		}
    97  	}
    98  }
    99  
   100  type version struct {
   101  	tup starlark.Tuple // either () or (major, minor, revision)
   102  }
   103  
   104  func (v version) isSet() bool {
   105  	return len(v.tup) != 0
   106  }
   107  
   108  func (v version) greaterOrEq(another version) bool {
   109  	yes, err := starlark.Compare(syntax.GE, v.tup, another.tup)
   110  	if err != nil {
   111  		panic(fmt.Sprintf("comparing tuples should succeed, got: %s", err))
   112  	}
   113  	return yes
   114  }
   115  
   116  func init() {
   117  	// set_min_version_for_experiments is called from lucicfg.check_version.
   118  	declNative("set_min_version_for_experiments", func(call nativeCall) (starlark.Value, error) {
   119  		var ver starlark.Tuple
   120  		if err := call.unpack(1, &ver); err != nil {
   121  			return nil, err
   122  		}
   123  		call.State.experiments.setMinVersion(ver)
   124  		return starlark.None, nil
   125  	})
   126  
   127  	// enable_experiment is used by lucicfg.enable_experiment in lucicfg.star.
   128  	declNative("enable_experiment", func(call nativeCall) (starlark.Value, error) {
   129  		var id starlark.String
   130  		if err := call.unpack(1, &id); err != nil {
   131  			return nil, err
   132  		}
   133  		if expID := id.GoString(); !call.State.experiments.Enable(expID) {
   134  			help := "there are no experiments available"
   135  			if all := call.State.experiments.Registered(); len(all) != 0 {
   136  				quoted := make([]string, len(all))
   137  				for i, s := range all {
   138  					quoted[i] = fmt.Sprintf("%q", s)
   139  				}
   140  				help = "available experiments: " + strings.Join(quoted, ", ")
   141  			}
   142  			logging.Warningf(call.Ctx, "enable_experiment: unknown experiment %q (%s). "+
   143  				"It is possible the experiment was retired already, consider removing this call to stop the warning.", expID, help)
   144  		}
   145  		return starlark.None, nil
   146  	})
   147  
   148  	// register_experiment is used in experiments.star.
   149  	declNative("register_experiment", func(call nativeCall) (starlark.Value, error) {
   150  		var id starlark.String
   151  		var minVer starlark.Tuple
   152  		if err := call.unpack(1, &id, &minVer); err != nil {
   153  			return nil, err
   154  		}
   155  		call.State.experiments.Register(id.GoString(), minVer)
   156  		return starlark.None, nil
   157  	})
   158  
   159  	// is_experiment_enabled is used in experiments.star.
   160  	declNative("is_experiment_enabled", func(call nativeCall) (starlark.Value, error) {
   161  		var id starlark.String
   162  		if err := call.unpack(1, &id); err != nil {
   163  			return nil, err
   164  		}
   165  		return starlark.Bool(call.State.experiments.IsEnabled(id.GoString())), nil
   166  	})
   167  
   168  	// list_enabled_experiments lists experiments enabled via enable_experiment.
   169  	//
   170  	// Lists all experiments passed to enable_experiment(...), even ones that
   171  	// aren't registered anymore. Also includes all experiments auto-enabled via
   172  	// `enable_on_min_version` mechanism.
   173  	//
   174  	// This list ends up in `lucicfg {...}` section of project.cfg. Listing *all*
   175  	// experiments there is useful to figure out what LUCI projects enable retired
   176  	// experiments.
   177  	declNative("list_enabled_experiments", func(call nativeCall) (starlark.Value, error) {
   178  		if err := call.unpack(0); err != nil {
   179  			return nil, err
   180  		}
   181  		exps := make([]starlark.Value, 0, call.State.experiments.enabled.Len())
   182  		for _, exp := range call.State.experiments.enabled.ToSortedSlice() {
   183  			exps = append(exps, starlark.String(exp))
   184  		}
   185  		return starlark.NewList(exps), nil
   186  	})
   187  }