go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/impl/filesystem/fs.go (about)

     1  // Copyright 2015 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 filesystem implements a file system backend for the config client.
    16  //
    17  // May be useful during local development.
    18  //
    19  // # Layout
    20  //
    21  // A "Config Folder" has the following format:
    22  //   - ./services/<servicename>/...
    23  //   - ./projects/<projectname>.json
    24  //   - ./projects/<projectname>/...
    25  //
    26  // Where `...` indicates any arbitrary path-to-a-file, and <brackets> indicate
    27  // a single non-slash-containing filesystem path token. "services", "projects",
    28  // ".json", and slashes are all literal text.
    29  //
    30  // # This package allows two modes of operation
    31  //
    32  // # Symlink Mode
    33  //
    34  // This mode allows you to simulate the evolution of multiple configuration
    35  // versions during the duration of your test. Lay out your entire directory
    36  // structure like:
    37  //
    38  //   - ./current -> ./v1
    39  //   - ./v1/config_folder/...
    40  //   - ./v2/config_folder/...
    41  //
    42  // During the execution of your app, you can change ./current from v1 to v2 (or
    43  // any other version), and that will be reflected in the config client's
    44  // Revision field. That way you may "simulate" atomic changes in the
    45  // configuration. You would pass the path to `current` as the basePath in the
    46  // constructor of New.
    47  //
    48  // # Sloppy Version Mode
    49  //
    50  // The folder will be scanned each time a config file is accessed, and the
    51  // Revision will be derived based on the current content of all config files.
    52  // Some inconsistencies are possible if configs change during the directory
    53  // rescan (thus "sloppiness" of this mode). This is good if you just want to
    54  // be able to easily modify configs manually during the development without
    55  // restarting the server or messing with symlinks.
    56  //
    57  // # Quirks
    58  //
    59  // This implementation is quite dumb, and will scan the entire directory each
    60  // time configs are accessed, caching the whole thing in memory (content, hashes
    61  // and metadata) and never cleaning it up. This means that if you keep editing
    62  // the files, more and more stuff will accumulate in memory.
    63  package filesystem
    64  
    65  import (
    66  	"context"
    67  	"crypto/sha256"
    68  	"encoding/hex"
    69  	"encoding/json"
    70  	"fmt"
    71  	"net/url"
    72  	"os"
    73  	"path/filepath"
    74  	"sort"
    75  	"strings"
    76  	"sync"
    77  
    78  	"go.chromium.org/luci/common/data/stringset"
    79  	"go.chromium.org/luci/common/errors"
    80  	"go.chromium.org/luci/config"
    81  )
    82  
    83  // ProjectConfiguration is the struct that will be used to read the
    84  // `projectname.json` config file, if any is specified for a given project.
    85  type ProjectConfiguration struct {
    86  	Name string
    87  	URL  string
    88  }
    89  
    90  type lookupKey struct {
    91  	revision  string
    92  	configSet configSet
    93  	path      luciPath
    94  }
    95  
    96  type filesystemImpl struct {
    97  	sync.RWMutex
    98  	scannedConfigs
    99  
   100  	basePath nativePath
   101  	islink   bool
   102  
   103  	contentRevisionsScanned stringset.Set
   104  }
   105  
   106  type scannedConfigs struct {
   107  	contentHashMap    map[string]string
   108  	contentRevPathMap map[lookupKey]*config.Config
   109  	contentRevProject map[lookupKey]*config.Project
   110  }
   111  
   112  func newScannedConfigs() scannedConfigs {
   113  	return scannedConfigs{
   114  		contentHashMap:    map[string]string{},
   115  		contentRevPathMap: map[lookupKey]*config.Config{},
   116  		contentRevProject: map[lookupKey]*config.Project{},
   117  	}
   118  }
   119  
   120  // setRevision updates 'revision' fields of all objects owned by scannedConfigs.
   121  func (c *scannedConfigs) setRevision(revision string) {
   122  	newRevPathMap := make(map[lookupKey]*config.Config, len(c.contentRevPathMap))
   123  	for k, v := range c.contentRevPathMap {
   124  		k.revision = revision
   125  		v.Revision = revision
   126  		newRevPathMap[k] = v
   127  	}
   128  	c.contentRevPathMap = newRevPathMap
   129  
   130  	newRevProject := make(map[lookupKey]*config.Project, len(c.contentRevProject))
   131  	for k, v := range c.contentRevProject {
   132  		k.revision = revision
   133  		newRevProject[k] = v
   134  	}
   135  	c.contentRevProject = newRevProject
   136  }
   137  
   138  // deriveRevision generates a revision string from data in contentHashMap.
   139  func deriveRevision(c *scannedConfigs) string {
   140  	keys := make([]string, 0, len(c.contentHashMap))
   141  	for k := range c.contentHashMap {
   142  		keys = append(keys, k)
   143  	}
   144  	sort.Strings(keys)
   145  	hsh := sha256.New()
   146  	for _, k := range keys {
   147  		fmt.Fprintf(hsh, "%s\n%s\n", k, c.contentHashMap[k])
   148  	}
   149  	digest := hsh.Sum(nil)
   150  	return hex.EncodeToString(digest[:])[:40]
   151  }
   152  
   153  // New returns an implementation of the config service which reads configuration
   154  // from the local filesystem. `basePath` may be one of two things:
   155  //   - A folder containing the following:
   156  //     ./services/servicename/...               # service confinguations
   157  //     ./projects/projectname.json              # project information configuration
   158  //     ./projects/projectname/...               # project configurations
   159  //   - A symlink to a folder as organized above:
   160  //     -> /path/to/revision/folder
   161  //
   162  // If a symlink is used, all Revision fields will be the 'revision' portion of
   163  // that path. If a non-symlink path is isued, the Revision fields will be
   164  // derived based on the contents of the files in the directory.
   165  //
   166  // Any unrecognized paths will be ignored. If basePath is not a link-to-folder,
   167  // and not a folder, this will panic.
   168  //
   169  // Every read access will scan each revision exactly once. If you want to make
   170  // changes, rename the folder and re-link it.
   171  func New(basePath string) (config.Interface, error) {
   172  	basePath, err := filepath.Abs(basePath)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	inf, err := os.Lstat(basePath)
   178  	if err != nil {
   179  		return nil, err
   180  	}
   181  
   182  	ret := &filesystemImpl{
   183  		basePath:                nativePath(basePath),
   184  		islink:                  (inf.Mode() & os.ModeSymlink) != 0,
   185  		scannedConfigs:          newScannedConfigs(),
   186  		contentRevisionsScanned: stringset.New(1),
   187  	}
   188  
   189  	if ret.islink {
   190  		if inf, err = os.Stat(basePath); err != nil {
   191  			return nil, err
   192  		}
   193  		if !inf.IsDir() {
   194  			return nil, errors.Reason("filesystem.New(%q): does not link to a directory", basePath).Err()
   195  		}
   196  		if len(ret.basePath.explode()) < 1 {
   197  			return nil, errors.Reason("filesystem.New(%q): not enough tokens in path", basePath).Err()
   198  		}
   199  	} else if !inf.IsDir() {
   200  		return nil, errors.Reason("filesystem.New(%q): not a directory", basePath).Err()
   201  	}
   202  	return ret, nil
   203  }
   204  
   205  func (fs *filesystemImpl) resolveBasePath() (realPath nativePath, revision string, err error) {
   206  	if fs.islink {
   207  		realPath, err = fs.basePath.readlink()
   208  		if err != nil && err.(*os.PathError).Err != os.ErrInvalid {
   209  			return
   210  		}
   211  		toks := realPath.explode()
   212  		revision = toks[len(toks)-1]
   213  		return
   214  	}
   215  	return fs.basePath, "", nil
   216  }
   217  
   218  func parsePath(rel nativePath) (cs configSet, path luciPath, ok bool) {
   219  	toks := rel.explode()
   220  
   221  	const jsonExt = ".json"
   222  
   223  	if toks[0] == "services" {
   224  		cs = newConfigSet(toks[:2]...)
   225  		path = newLUCIPath(toks[2:]...)
   226  		ok = true
   227  	} else if toks[0] == "projects" {
   228  		ok = true
   229  		if len(toks) == 2 && strings.HasSuffix(toks[1], jsonExt) {
   230  			cs = newConfigSet(toks[0], toks[1][:len(toks[1])-len(jsonExt)])
   231  		} else {
   232  			cs = newConfigSet(toks[:2]...)
   233  			path = newLUCIPath(toks[2:]...)
   234  		}
   235  	}
   236  	return
   237  }
   238  
   239  func scanDirectory(realPath nativePath) (*scannedConfigs, error) {
   240  	ret := newScannedConfigs()
   241  
   242  	err := filepath.Walk(realPath.s(), func(rawPath string, info os.FileInfo, err error) error {
   243  		path := nativePath(rawPath)
   244  
   245  		if err != nil {
   246  			return err
   247  		}
   248  
   249  		if !info.IsDir() {
   250  			rel, err := realPath.rel(path)
   251  			if err != nil {
   252  				return err
   253  			}
   254  
   255  			cs, cfgPath, ok := parsePath(rel)
   256  			if !ok {
   257  				return nil
   258  			}
   259  			lk := lookupKey{"", cs, cfgPath}
   260  
   261  			data, err := path.read()
   262  			if err != nil {
   263  				return err
   264  			}
   265  
   266  			if cfgPath == "" { // this is the project configuration file
   267  				proj := &ProjectConfiguration{}
   268  				if err := json.Unmarshal(data, proj); err != nil {
   269  					return err
   270  				}
   271  				toks := cs.explode()
   272  				parsedURL, err := url.ParseRequestURI(proj.URL)
   273  				if err != nil {
   274  					return err
   275  				}
   276  				ret.contentRevProject[lk] = &config.Project{
   277  					ID:       toks[1],
   278  					Name:     proj.Name,
   279  					RepoType: "FILESYSTEM",
   280  					RepoURL:  parsedURL,
   281  				}
   282  				return nil
   283  			}
   284  
   285  			content := string(data)
   286  
   287  			hsh := sha256.Sum256(data)
   288  			hexHsh := "v1:" + hex.EncodeToString(hsh[:])[:40]
   289  
   290  			ret.contentHashMap[hexHsh] = content
   291  
   292  			ret.contentRevPathMap[lk] = &config.Config{
   293  				Meta: config.Meta{
   294  					ConfigSet:   config.Set(cs.s()),
   295  					Path:        cfgPath.s(),
   296  					ContentHash: hexHsh,
   297  					ViewURL:     "file://./" + filepath.ToSlash(cfgPath.s()),
   298  				},
   299  				Content: content,
   300  			}
   301  		}
   302  
   303  		return nil
   304  	})
   305  	if err != nil {
   306  		return nil, err
   307  	}
   308  
   309  	for lk := range ret.contentRevPathMap {
   310  		cs := lk.configSet
   311  		if cs.isProject() {
   312  			pk := lookupKey{"", cs, ""}
   313  			if ret.contentRevProject[pk] == nil {
   314  				id := cs.id()
   315  				ret.contentRevProject[pk] = &config.Project{
   316  					ID:       id,
   317  					Name:     id,
   318  					RepoType: "FILESYSTEM",
   319  				}
   320  			}
   321  		}
   322  	}
   323  
   324  	return &ret, nil
   325  }
   326  
   327  func (fs *filesystemImpl) scanHeadRevision() (string, error) {
   328  	realPath, revision, err := fs.resolveBasePath()
   329  	if err != nil {
   330  		return "", err
   331  	}
   332  
   333  	// Using symlinks? The revision is derived from the symlink target name,
   334  	// do not rescan it all the time.
   335  	if revision != "" {
   336  		if err := fs.scanSymlinkedRevision(realPath, revision); err != nil {
   337  			return "", err
   338  		}
   339  		return revision, nil
   340  	}
   341  
   342  	// If using regular directory, rescan it to find if anything changed.
   343  	return fs.scanCurrentRevision(realPath)
   344  }
   345  
   346  func (fs *filesystemImpl) scanSymlinkedRevision(realPath nativePath, revision string) error {
   347  	fs.RLock()
   348  	done := fs.contentRevisionsScanned.Has(revision)
   349  	fs.RUnlock()
   350  	if done {
   351  		return nil
   352  	}
   353  
   354  	fs.Lock()
   355  	defer fs.Unlock()
   356  
   357  	scanned, err := scanDirectory(realPath)
   358  	if err != nil {
   359  		return err
   360  	}
   361  	fs.slurpScannedConfigs(revision, scanned)
   362  	return nil
   363  }
   364  
   365  func (fs *filesystemImpl) scanCurrentRevision(realPath nativePath) (string, error) {
   366  	// Forbid parallel scans to avoid hitting the disk too hard.
   367  	//
   368  	// TODO(vadimsh): Can use some sort of rate limiting instead if this code is
   369  	// ever used in production.
   370  	fs.Lock()
   371  	defer fs.Unlock()
   372  
   373  	scanned, err := scanDirectory(realPath)
   374  	if err != nil {
   375  		return "", err
   376  	}
   377  
   378  	revision := deriveRevision(scanned)
   379  	if fs.contentRevisionsScanned.Has(revision) {
   380  		return revision, nil // no changes to configs
   381  	}
   382  	fs.slurpScannedConfigs(revision, scanned)
   383  	return revision, nil
   384  }
   385  
   386  func (fs *filesystemImpl) slurpScannedConfigs(revision string, scanned *scannedConfigs) {
   387  	scanned.setRevision(revision)
   388  	for k, v := range scanned.contentHashMap {
   389  		fs.contentHashMap[k] = v
   390  	}
   391  	for k, v := range scanned.contentRevPathMap {
   392  		fs.contentRevPathMap[k] = v
   393  	}
   394  	for k, v := range scanned.contentRevProject {
   395  		fs.contentRevProject[k] = v
   396  	}
   397  	fs.contentRevisionsScanned.Add(revision)
   398  }
   399  
   400  func (fs *filesystemImpl) GetConfig(ctx context.Context, cfgSet config.Set, cfgPath string, metaOnly bool) (*config.Config, error) {
   401  	cs := configSet{luciPath(cfgSet)}
   402  	path := luciPath(cfgPath)
   403  
   404  	if err := cs.validate(); err != nil {
   405  		return nil, err
   406  	}
   407  
   408  	revision, err := fs.scanHeadRevision()
   409  	if err != nil {
   410  		return nil, err
   411  	}
   412  
   413  	lk := lookupKey{revision, cs, path}
   414  
   415  	fs.RLock()
   416  	ret, ok := fs.contentRevPathMap[lk]
   417  	fs.RUnlock()
   418  	if ok {
   419  		c := *ret
   420  		if metaOnly {
   421  			c.Content = ""
   422  		}
   423  		return &c, nil
   424  	}
   425  	return nil, config.ErrNoConfig
   426  }
   427  
   428  func (fs *filesystemImpl) GetConfigs(ctx context.Context, cfgSet config.Set, filter func(path string) bool, metaOnly bool) (map[string]config.Config, error) {
   429  	cs := configSet{luciPath(cfgSet)}
   430  	if err := cs.validate(); err != nil {
   431  		return nil, err
   432  	}
   433  
   434  	out := map[string]config.Config{}
   435  	err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) {
   436  		if lk.configSet == cs && (filter == nil || filter(cfg.Path)) {
   437  			c := *cfg
   438  			if metaOnly {
   439  				c.Content = ""
   440  			}
   441  			out[cfg.Path] = c
   442  		}
   443  	})
   444  
   445  	if err != nil {
   446  		return nil, err
   447  	}
   448  	return out, nil
   449  }
   450  
   451  func (fs *filesystemImpl) ListFiles(ctx context.Context, cfgSet config.Set) ([]string, error) {
   452  	cs := configSet{luciPath(cfgSet)}
   453  	if err := cs.validate(); err != nil {
   454  		return nil, err
   455  	}
   456  
   457  	var files []string
   458  	err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) {
   459  		if lk.configSet == cs {
   460  			files = append(files, cfg.Path)
   461  		}
   462  	})
   463  	sort.Strings(files)
   464  	return files, err
   465  }
   466  
   467  func (fs *filesystemImpl) iterContentRevPath(fn func(lk lookupKey, cfg *config.Config)) error {
   468  	revision, err := fs.scanHeadRevision()
   469  	if err != nil {
   470  		return err
   471  	}
   472  
   473  	fs.RLock()
   474  	defer fs.RUnlock()
   475  	for lk, cfg := range fs.contentRevPathMap {
   476  		if lk.revision == revision {
   477  			fn(lk, cfg)
   478  		}
   479  	}
   480  	return nil
   481  }
   482  
   483  func (fs *filesystemImpl) GetProjectConfigs(ctx context.Context, cfgPath string, metaOnly bool) ([]config.Config, error) {
   484  	path := luciPath(cfgPath)
   485  
   486  	ret := make(configList, 0, 10)
   487  	err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) {
   488  		if lk.path != path {
   489  			return
   490  		}
   491  		if lk.configSet.isProject() {
   492  			c := *cfg
   493  			if metaOnly {
   494  				c.Content = ""
   495  			}
   496  			ret = append(ret, c)
   497  		}
   498  	})
   499  	sort.Sort(ret)
   500  	return ret, err
   501  }
   502  
   503  func (fs *filesystemImpl) GetProjects(ctx context.Context) ([]config.Project, error) {
   504  	revision, err := fs.scanHeadRevision()
   505  	if err != nil {
   506  		return nil, err
   507  	}
   508  
   509  	fs.RLock()
   510  	ret := make(projList, 0, len(fs.contentRevProject))
   511  	for lk, proj := range fs.contentRevProject {
   512  		if lk.revision == revision {
   513  			ret = append(ret, *proj)
   514  		}
   515  	}
   516  	fs.RUnlock()
   517  	sort.Sort(ret)
   518  	return ret, nil
   519  }
   520  
   521  func (fs *filesystemImpl) Close() error {
   522  	return nil
   523  }