go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/service/find.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 service 16 17 import ( 18 "context" 19 "fmt" 20 "regexp" 21 "sort" 22 "strings" 23 "sync" 24 "time" 25 26 "go.chromium.org/luci/common/clock" 27 "go.chromium.org/luci/common/logging" 28 cfgcommonpb "go.chromium.org/luci/common/proto/config" 29 "go.chromium.org/luci/config" 30 "go.chromium.org/luci/gae/service/datastore" 31 32 "go.chromium.org/luci/config_service/internal/model" 33 ) 34 35 // Finder provides fast look up for services interested in the provided config. 36 // 37 // It loads `Service` entity for all registered services and pre-compute the 38 // ConfigPattern to in-memory data structures that promotes faster matching 39 // performance. 40 // 41 // See ConfigPattern syntax at https://pkg.go.dev/go.chromium.org/luci/common/proto/config#ConfigPattern 42 // 43 // It can be used as an one-off look up. However, to unlock its full ability, 44 // the caller should use a singleton and call `RefreshPeriodically` in the 45 // background to keep the singleton's pre-computed patterns up-to-date with. 46 type Finder struct { 47 mu sync.RWMutex // protects `services` 48 services []serviceWrapper 49 } 50 51 // serviceWrapper is a wrapper around `Service` entity that contains 52 // pre-computed patterns to match config file. 53 type serviceWrapper struct { 54 service *model.Service 55 patterns []pattern 56 } 57 58 // pattern is used to match a config file. 59 type pattern struct { 60 configSetMatcher matcher 61 pathMatcher matcher 62 } 63 64 // matcher is what will be pre-computed from a ConfigPattern. 65 type matcher struct { 66 exact string 67 re *regexp.Regexp 68 } 69 70 // match return true if the pattern matches the provided string. 71 func (m matcher) match(s string) bool { 72 if m.exact != "" && m.exact == s { 73 return true 74 } 75 if m.re != nil && m.re.MatchString(s) { 76 return true 77 } 78 return false 79 } 80 81 // matchConfig returns true if any of the pre-computed pattern matches the 82 // provided config file. 83 // 84 // TODO: crbug/1466976 - If the provided config set is a service, returns 85 // false when the service name is different from the service wrapped by 86 // serviceWrapper. Otherwise, a compromised service A may obtain the config 87 // of service B by declaring itself interested in a config file that service B 88 // has. For now, only log a warning. 89 func (sw *serviceWrapper) matchConfig(ctx context.Context, cs config.Set, filePath string) bool { 90 for _, p := range sw.patterns { 91 if p.configSetMatcher.match(string(cs)) && p.pathMatcher.match(filePath) { 92 if domain, target := cs.Split(); domain == config.ServiceDomain && sw.service.Name != target { 93 logging.Warningf(ctx, "crbug/1466976 - service %q declares it is interested in the config %q of another service %q", sw.service.Name, filePath, target) 94 } 95 return true 96 } 97 } 98 return false 99 } 100 101 // NewFinder instantiate a new Finder that are ready for look up. 102 func NewFinder(ctx context.Context) (*Finder, error) { 103 m := &Finder{} 104 if err := m.refresh(ctx); err != nil { 105 return nil, err 106 } 107 return m, nil 108 } 109 110 // FindInterestedServices look up for services interested in the given config. 111 // 112 // Look-up is performed in memory. 113 func (m *Finder) FindInterestedServices(ctx context.Context, cs config.Set, filePath string) []*model.Service { 114 m.mu.RLock() 115 defer m.mu.RUnlock() 116 var ret []*model.Service 117 for _, service := range m.services { 118 if service.matchConfig(ctx, cs, filePath) { 119 ret = append(ret, service.service) 120 } 121 } 122 if len(ret) > 0 { 123 sort.Slice(ret, func(i, j int) bool { 124 return strings.Compare(ret[i].Name, ret[j].Name) < 0 125 }) 126 } 127 return ret 128 } 129 130 // RefreshPeriodically refreshes the finder with the latest registered services 131 // information every 1 minute until context is cancelled. 132 // 133 // Log the error if refresh has failed. 134 func (m *Finder) RefreshPeriodically(ctx context.Context) { 135 for { 136 if r := <-clock.After(ctx, 1*time.Minute); r.Err != nil { 137 return // the context is canceled 138 } 139 if err := m.refresh(ctx); err != nil { 140 // TODO(yiwzhang): alert if there are consecutive refresh errors. 141 logging.Errorf(ctx, "Failed to update config finder using service metadata: %s", err) 142 } 143 } 144 } 145 146 func (m *Finder) refresh(ctx context.Context) error { 147 var services []*model.Service 148 if err := datastore.GetAll(ctx, datastore.NewQuery(model.ServiceKind), &services); err != nil { 149 return fmt.Errorf("failed to query all Services: %w", err) 150 } 151 m.mu.Lock() 152 defer m.mu.Unlock() 153 if len(services) == 0 { 154 m.services = nil 155 logging.Warningf(ctx, "no service found when updating finder") 156 return nil 157 } 158 m.services = make([]serviceWrapper, 0, len(services)) 159 for _, service := range services { 160 var patterns []pattern 161 switch { 162 case service.Metadata != nil: 163 patterns = makePatterns(service.Metadata.GetConfigPatterns()) 164 case service.LegacyMetadata != nil: 165 patterns = makePatterns(service.LegacyMetadata.GetValidation().GetPatterns()) 166 } 167 if len(patterns) > 0 { 168 m.services = append(m.services, serviceWrapper{ 169 service: service, 170 patterns: patterns, 171 }) 172 } 173 } 174 return nil 175 } 176 177 // The patterns are ensured to be valid by `validateMetadata` before updating 178 // services. 179 func makePatterns(patterns []*cfgcommonpb.ConfigPattern) []pattern { 180 if len(patterns) == 0 { 181 return nil 182 } 183 ret := make([]pattern, len(patterns)) 184 for i, p := range patterns { 185 ret[i] = pattern{ 186 configSetMatcher: makeMatcher(p.GetConfigSet()), 187 pathMatcher: makeMatcher(p.GetPath()), 188 } 189 } 190 return ret 191 } 192 193 func makeMatcher(pattern string) matcher { 194 switch p := strings.TrimSpace(pattern); { 195 case strings.HasPrefix(p, "exact:"): 196 return matcher{exact: p[len("exact:"):]} 197 case strings.HasPrefix(p, "text:"): 198 return matcher{exact: p[len("text:"):]} 199 case strings.HasPrefix(p, "regex:"): 200 expr := p[len("regex:"):] 201 if !strings.HasPrefix(expr, "^") { 202 expr = "^" + expr 203 } 204 if !strings.HasSuffix(expr, "$") { 205 expr = expr + "$" 206 } 207 return matcher{re: regexp.MustCompile(expr)} 208 default: 209 return matcher{exact: p} 210 } 211 }