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  }