go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/gerrit/cfgmatcher/matcher.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 cfgmatcher efficiently matches a CL to 0+ ConfigGroupID for a single
    16  // LUCI project.
    17  package cfgmatcher
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"regexp"
    23  	"strings"
    24  
    25  	"google.golang.org/protobuf/proto"
    26  
    27  	"go.chromium.org/luci/common/errors"
    28  
    29  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    30  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    31  )
    32  
    33  // Matcher effieciently find matching ConfigGroupID for Gerrit CLs.
    34  type Matcher struct {
    35  	state                *MatcherState
    36  	cachedConfigGroupIDs []prjcfg.ConfigGroupID
    37  }
    38  
    39  // LoadMatcher instantiates Matcher from config stored in Datastore.
    40  func LoadMatcher(ctx context.Context, luciProject, configHash string) (*Matcher, error) {
    41  	meta, err := prjcfg.GetHashMeta(ctx, luciProject, configHash)
    42  	if err != nil {
    43  		return nil, err
    44  	}
    45  	return LoadMatcherFrom(ctx, meta)
    46  }
    47  
    48  // LoadMatcherFrom instantiates Matcher from the given config.Meta.
    49  func LoadMatcherFrom(ctx context.Context, meta prjcfg.Meta) (*Matcher, error) {
    50  	configGroups, err := meta.GetConfigGroups(ctx)
    51  	if err != nil {
    52  		return nil, err
    53  	}
    54  	return LoadMatcherFromConfigGroups(ctx, configGroups, &meta), nil
    55  }
    56  
    57  // LoadMatcherFromConfigGroups instantiates Matcher.
    58  //
    59  // There must be at least 1 config group, which is true for all valid CV
    60  // configs.
    61  //
    62  // meta, if not nil, must have been used to load the given ConfigGroups. It's an
    63  // optimization to re-use memory since most callers typically have it.
    64  func LoadMatcherFromConfigGroups(ctx context.Context, configGroups []*prjcfg.ConfigGroup, meta *prjcfg.Meta) *Matcher {
    65  	m := &Matcher{
    66  		state: &MatcherState{
    67  			// 1-2 Gerrit hosts is typical as of 2020.
    68  			Hosts:            make(map[string]*MatcherState_Projects, 2),
    69  			ConfigGroupNames: make([]string, len(configGroups)),
    70  		},
    71  	}
    72  	if meta != nil {
    73  		m.state.ConfigHash = meta.Hash()
    74  		m.cachedConfigGroupIDs = meta.ConfigGroupIDs
    75  	} else {
    76  		m.state.ConfigHash = configGroups[0].ID.Hash()
    77  		m.cachedConfigGroupIDs = make([]prjcfg.ConfigGroupID, len(configGroups))
    78  		for i, cg := range configGroups {
    79  			m.cachedConfigGroupIDs[i] = cg.ID
    80  		}
    81  	}
    82  
    83  	for i, cg := range configGroups {
    84  		m.state.ConfigGroupNames[i] = cg.ID.Name()
    85  		for _, gerrit := range cg.Content.GetGerrit() {
    86  			host := prjcfg.GerritHost(gerrit)
    87  			var projectsMap map[string]*Groups
    88  			if ps, ok := m.state.GetHosts()[host]; ok {
    89  				projectsMap = ps.GetProjects()
    90  			} else {
    91  				// Either 1 Gerrit project or lots of them is typical as 2020.
    92  				projectsMap = make(map[string]*Groups, 1)
    93  				m.state.GetHosts()[host] = &MatcherState_Projects{Projects: projectsMap}
    94  			}
    95  
    96  			for _, p := range gerrit.GetProjects() {
    97  				g := MakeGroup(cg, p)
    98  				// Don't store exact ID, it can be computed from the rest of matcher
    99  				// state if index is known. This reduces RAM usage after
   100  				// serialize/deserialize cycle.
   101  				g.Id = ""
   102  				g.Index = int32(i)
   103  				if groups, ok := projectsMap[p.GetName()]; ok {
   104  					groups.Groups = append(groups.GetGroups(), g)
   105  				} else {
   106  					projectsMap[p.GetName()] = &Groups{Groups: []*Group{g}}
   107  				}
   108  			}
   109  		}
   110  	}
   111  	return m
   112  }
   113  
   114  func (m *Matcher) Serialize() ([]byte, error) {
   115  	return proto.Marshal(m.state)
   116  }
   117  
   118  func Deserialize(buf []byte) (*Matcher, error) {
   119  	m := &Matcher{state: &MatcherState{}}
   120  	if err := proto.Unmarshal(buf, m.state); err != nil {
   121  		return nil, errors.Annotate(err, "failed to Deserialize Matcher").Err()
   122  	}
   123  	m.cachedConfigGroupIDs = make([]prjcfg.ConfigGroupID, len(m.state.ConfigGroupNames))
   124  	hash := m.state.GetConfigHash()
   125  	for i, name := range m.state.ConfigGroupNames {
   126  		m.cachedConfigGroupIDs[i] = prjcfg.MakeConfigGroupID(hash, name)
   127  	}
   128  	return m, nil
   129  }
   130  
   131  // Match returns ConfigGroupIDs matched for a given triple.
   132  func (m *Matcher) Match(host, project, ref string) []prjcfg.ConfigGroupID {
   133  	ps, ok := m.state.GetHosts()[host]
   134  	if !ok {
   135  		return nil
   136  	}
   137  	gs, ok := ps.GetProjects()[project]
   138  	if !ok {
   139  		return nil
   140  	}
   141  	matched := gs.Match(ref)
   142  	if len(matched) == 0 {
   143  		return nil
   144  	}
   145  	ret := make([]prjcfg.ConfigGroupID, len(matched))
   146  	for i, g := range matched {
   147  		ret[i] = m.cachedConfigGroupIDs[g.GetIndex()]
   148  	}
   149  	return ret
   150  }
   151  
   152  // ConfigHash returns ConfigHash for which Matcher does matching.
   153  func (m *Matcher) ConfigHash() string {
   154  	return m.state.GetConfigHash()
   155  }
   156  
   157  // TODO(tandrii): add "main" branch too to ease migration once either:
   158  //   - CQDaemon is no longer involved,
   159  //   - CQDaemon does the same at the same time.
   160  var defaultRefRegexpInclude = []string{"refs/heads/master"}
   161  var defaultRefRegexpExclude = []string{"^$" /* matches nothing */}
   162  
   163  // MakeGroup returns a new Group based on the Gerrit Project section of a
   164  // ConfigGroup.
   165  func MakeGroup(g *prjcfg.ConfigGroup, p *cfgpb.ConfigGroup_Gerrit_Project) *Group {
   166  	var inc, exc []string
   167  	if inc = p.GetRefRegexp(); len(inc) == 0 {
   168  		inc = defaultRefRegexpInclude
   169  	}
   170  	if exc = p.GetRefRegexpExclude(); len(exc) == 0 {
   171  		exc = defaultRefRegexpExclude
   172  	}
   173  	return &Group{
   174  		Id:       string(g.ID),
   175  		Include:  disjunctiveOfRegexps(inc),
   176  		Exclude:  disjunctiveOfRegexps(exc),
   177  		Fallback: g.Content.Fallback == cfgpb.Toggle_YES,
   178  	}
   179  }
   180  
   181  // Match returns matching groups, obeying fallback config.
   182  //
   183  // If there are two groups that match, one fallback and one non-fallback, the
   184  // non-fallback group is the one to use. The fallback group will be used if it's
   185  // the only group that matches.
   186  func (gs *Groups) Match(ref string) []*Group {
   187  	var ret []*Group
   188  	var fallback *Group
   189  	for _, g := range gs.GetGroups() {
   190  		switch {
   191  		case !g.Match(ref):
   192  			continue
   193  		case g.GetFallback() && fallback != nil:
   194  			// Valid config require at most 1 fallback group in a LUCI project.
   195  			panic(fmt.Errorf("invalid Groups: %s and %s are both fallback", fallback, g))
   196  		case g.GetFallback():
   197  			fallback = g
   198  		default:
   199  			ret = append(ret, g)
   200  		}
   201  	}
   202  	if len(ret) == 0 && fallback != nil {
   203  		ret = []*Group{fallback}
   204  	}
   205  	return ret
   206  }
   207  
   208  // Match returns true iff ref matches given Group.
   209  func (g *Group) Match(ref string) bool {
   210  	if !regexp.MustCompile(g.GetInclude()).MatchString(ref) {
   211  		return false
   212  	}
   213  	return !regexp.MustCompile(g.GetExclude()).MatchString(ref)
   214  }
   215  
   216  func disjunctiveOfRegexps(rs []string) string {
   217  	sb := strings.Builder{}
   218  	sb.WriteString("^(")
   219  	for i, r := range rs {
   220  		if i > 0 {
   221  			sb.WriteRune('|')
   222  		}
   223  		sb.WriteRune('(')
   224  		sb.WriteString(r)
   225  		sb.WriteRune(')')
   226  	}
   227  	sb.WriteString(")$")
   228  	return sb.String()
   229  }