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 }