| // Copyright 2015 The LUCI Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Package filesystem implements a file system backend for the config client. |
| // |
| // May be useful during local development. |
| // |
| // # Layout |
| // |
| // A "Config Folder" has the following format: |
| // - ./services/<servicename>/... |
| // - ./projects/<projectname>.json |
| // - ./projects/<projectname>/... |
| // |
| // Where `...` indicates any arbitrary path-to-a-file, and <brackets> indicate |
| // a single non-slash-containing filesystem path token. "services", "projects", |
| // ".json", and slashes are all literal text. |
| // |
| // # This package allows two modes of operation |
| // |
| // # Symlink Mode |
| // |
| // This mode allows you to simulate the evolution of multiple configuration |
| // versions during the duration of your test. Lay out your entire directory |
| // structure like: |
| // |
| // - ./current -> ./v1 |
| // - ./v1/config_folder/... |
| // - ./v2/config_folder/... |
| // |
| // During the execution of your app, you can change ./current from v1 to v2 (or |
| // any other version), and that will be reflected in the config client's |
| // Revision field. That way you may "simulate" atomic changes in the |
| // configuration. You would pass the path to `current` as the basePath in the |
| // constructor of New. |
| // |
| // # Sloppy Version Mode |
| // |
| // The folder will be scanned each time a config file is accessed, and the |
| // Revision will be derived based on the current content of all config files. |
| // Some inconsistencies are possible if configs change during the directory |
| // rescan (thus "sloppiness" of this mode). This is good if you just want to |
| // be able to easily modify configs manually during the development without |
| // restarting the server or messing with symlinks. |
| // |
| // # Quirks |
| // |
| // This implementation is quite dumb, and will scan the entire directory each |
| // time configs are accessed, caching the whole thing in memory (content, hashes |
| // and metadata) and never cleaning it up. This means that if you keep editing |
| // the files, more and more stuff will accumulate in memory. |
| package filesystem |
| |
| import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| "net/url" |
| "os" |
| "path/filepath" |
| "sort" |
| "strings" |
| "sync" |
| |
| "go.chromium.org/luci/common/data/stringset" |
| "go.chromium.org/luci/common/errors" |
| "go.chromium.org/luci/config" |
| ) |
| |
| // ProjectConfiguration is the struct that will be used to read the |
| // `projectname.json` config file, if any is specified for a given project. |
| type ProjectConfiguration struct { |
| Name string |
| URL string |
| } |
| |
| type lookupKey struct { |
| revision string |
| configSet configSet |
| path luciPath |
| } |
| |
| type filesystemImpl struct { |
| sync.RWMutex |
| scannedConfigs |
| |
| basePath nativePath |
| islink bool |
| |
| contentRevisionsScanned stringset.Set |
| } |
| |
| type scannedConfigs struct { |
| contentHashMap map[string]string |
| contentRevPathMap map[lookupKey]*config.Config |
| contentRevProject map[lookupKey]*config.Project |
| } |
| |
| func newScannedConfigs() scannedConfigs { |
| return scannedConfigs{ |
| contentHashMap: map[string]string{}, |
| contentRevPathMap: map[lookupKey]*config.Config{}, |
| contentRevProject: map[lookupKey]*config.Project{}, |
| } |
| } |
| |
| // setRevision updates 'revision' fields of all objects owned by scannedConfigs. |
| func (c *scannedConfigs) setRevision(revision string) { |
| newRevPathMap := make(map[lookupKey]*config.Config, len(c.contentRevPathMap)) |
| for k, v := range c.contentRevPathMap { |
| k.revision = revision |
| v.Revision = revision |
| newRevPathMap[k] = v |
| } |
| c.contentRevPathMap = newRevPathMap |
| |
| newRevProject := make(map[lookupKey]*config.Project, len(c.contentRevProject)) |
| for k, v := range c.contentRevProject { |
| k.revision = revision |
| newRevProject[k] = v |
| } |
| c.contentRevProject = newRevProject |
| } |
| |
| // deriveRevision generates a revision string from data in contentHashMap. |
| func deriveRevision(c *scannedConfigs) string { |
| keys := make([]string, 0, len(c.contentHashMap)) |
| for k := range c.contentHashMap { |
| keys = append(keys, k) |
| } |
| sort.Strings(keys) |
| hsh := sha256.New() |
| for _, k := range keys { |
| fmt.Fprintf(hsh, "%s\n%s\n", k, c.contentHashMap[k]) |
| } |
| digest := hsh.Sum(nil) |
| return hex.EncodeToString(digest[:])[:40] |
| } |
| |
| // New returns an implementation of the config service which reads configuration |
| // from the local filesystem. `basePath` may be one of two things: |
| // - A folder containing the following: |
| // ./services/servicename/... # service confinguations |
| // ./projects/projectname.json # project information configuration |
| // ./projects/projectname/... # project configurations |
| // - A symlink to a folder as organized above: |
| // -> /path/to/revision/folder |
| // |
| // If a symlink is used, all Revision fields will be the 'revision' portion of |
| // that path. If a non-symlink path is isued, the Revision fields will be |
| // derived based on the contents of the files in the directory. |
| // |
| // Any unrecognized paths will be ignored. If basePath is not a link-to-folder, |
| // and not a folder, this will panic. |
| // |
| // Every read access will scan each revision exactly once. If you want to make |
| // changes, rename the folder and re-link it. |
| func New(basePath string) (config.Interface, error) { |
| basePath, err := filepath.Abs(basePath) |
| if err != nil { |
| return nil, err |
| } |
| |
| inf, err := os.Lstat(basePath) |
| if err != nil { |
| return nil, err |
| } |
| |
| ret := &filesystemImpl{ |
| basePath: nativePath(basePath), |
| islink: (inf.Mode() & os.ModeSymlink) != 0, |
| scannedConfigs: newScannedConfigs(), |
| contentRevisionsScanned: stringset.New(1), |
| } |
| |
| if ret.islink { |
| if inf, err = os.Stat(basePath); err != nil { |
| return nil, err |
| } |
| if !inf.IsDir() { |
| return nil, errors.Reason("filesystem.New(%q): does not link to a directory", basePath).Err() |
| } |
| if len(ret.basePath.explode()) < 1 { |
| return nil, errors.Reason("filesystem.New(%q): not enough tokens in path", basePath).Err() |
| } |
| } else if !inf.IsDir() { |
| return nil, errors.Reason("filesystem.New(%q): not a directory", basePath).Err() |
| } |
| return ret, nil |
| } |
| |
| func (fs *filesystemImpl) resolveBasePath() (realPath nativePath, revision string, err error) { |
| if fs.islink { |
| realPath, err = fs.basePath.readlink() |
| if err != nil && err.(*os.PathError).Err != os.ErrInvalid { |
| return |
| } |
| toks := realPath.explode() |
| revision = toks[len(toks)-1] |
| return |
| } |
| return fs.basePath, "", nil |
| } |
| |
| func parsePath(rel nativePath) (cs configSet, path luciPath, ok bool) { |
| toks := rel.explode() |
| |
| const jsonExt = ".json" |
| |
| if toks[0] == "services" { |
| cs = newConfigSet(toks[:2]...) |
| path = newLUCIPath(toks[2:]...) |
| ok = true |
| } else if toks[0] == "projects" { |
| ok = true |
| if len(toks) == 2 && strings.HasSuffix(toks[1], jsonExt) { |
| cs = newConfigSet(toks[0], toks[1][:len(toks[1])-len(jsonExt)]) |
| } else { |
| cs = newConfigSet(toks[:2]...) |
| path = newLUCIPath(toks[2:]...) |
| } |
| } |
| return |
| } |
| |
| func scanDirectory(realPath nativePath) (*scannedConfigs, error) { |
| ret := newScannedConfigs() |
| |
| err := filepath.Walk(realPath.s(), func(rawPath string, info os.FileInfo, err error) error { |
| path := nativePath(rawPath) |
| |
| if err != nil { |
| return err |
| } |
| |
| if !info.IsDir() { |
| rel, err := realPath.rel(path) |
| if err != nil { |
| return err |
| } |
| |
| cs, cfgPath, ok := parsePath(rel) |
| if !ok { |
| return nil |
| } |
| lk := lookupKey{"", cs, cfgPath} |
| |
| data, err := path.read() |
| if err != nil { |
| return err |
| } |
| |
| if cfgPath == "" { // this is the project configuration file |
| proj := &ProjectConfiguration{} |
| if err := json.Unmarshal(data, proj); err != nil { |
| return err |
| } |
| toks := cs.explode() |
| parsedURL, err := url.ParseRequestURI(proj.URL) |
| if err != nil { |
| return err |
| } |
| ret.contentRevProject[lk] = &config.Project{ |
| ID: toks[1], |
| Name: proj.Name, |
| RepoType: "FILESYSTEM", |
| RepoURL: parsedURL, |
| } |
| return nil |
| } |
| |
| content := string(data) |
| |
| hsh := sha256.Sum256(data) |
| hexHsh := "v1:" + hex.EncodeToString(hsh[:])[:40] |
| |
| ret.contentHashMap[hexHsh] = content |
| |
| ret.contentRevPathMap[lk] = &config.Config{ |
| Meta: config.Meta{ |
| ConfigSet: config.Set(cs.s()), |
| Path: cfgPath.s(), |
| ContentHash: hexHsh, |
| ViewURL: "file://./" + filepath.ToSlash(cfgPath.s()), |
| }, |
| Content: content, |
| } |
| } |
| |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| for lk := range ret.contentRevPathMap { |
| cs := lk.configSet |
| if cs.isProject() { |
| pk := lookupKey{"", cs, ""} |
| if ret.contentRevProject[pk] == nil { |
| id := cs.id() |
| ret.contentRevProject[pk] = &config.Project{ |
| ID: id, |
| Name: id, |
| RepoType: "FILESYSTEM", |
| } |
| } |
| } |
| } |
| |
| return &ret, nil |
| } |
| |
| func (fs *filesystemImpl) scanHeadRevision() (string, error) { |
| realPath, revision, err := fs.resolveBasePath() |
| if err != nil { |
| return "", err |
| } |
| |
| // Using symlinks? The revision is derived from the symlink target name, |
| // do not rescan it all the time. |
| if revision != "" { |
| if err := fs.scanSymlinkedRevision(realPath, revision); err != nil { |
| return "", err |
| } |
| return revision, nil |
| } |
| |
| // If using regular directory, rescan it to find if anything changed. |
| return fs.scanCurrentRevision(realPath) |
| } |
| |
| func (fs *filesystemImpl) scanSymlinkedRevision(realPath nativePath, revision string) error { |
| fs.RLock() |
| done := fs.contentRevisionsScanned.Has(revision) |
| fs.RUnlock() |
| if done { |
| return nil |
| } |
| |
| fs.Lock() |
| defer fs.Unlock() |
| |
| scanned, err := scanDirectory(realPath) |
| if err != nil { |
| return err |
| } |
| fs.slurpScannedConfigs(revision, scanned) |
| return nil |
| } |
| |
| func (fs *filesystemImpl) scanCurrentRevision(realPath nativePath) (string, error) { |
| // Forbid parallel scans to avoid hitting the disk too hard. |
| // |
| // TODO(vadimsh): Can use some sort of rate limiting instead if this code is |
| // ever used in production. |
| fs.Lock() |
| defer fs.Unlock() |
| |
| scanned, err := scanDirectory(realPath) |
| if err != nil { |
| return "", err |
| } |
| |
| revision := deriveRevision(scanned) |
| if fs.contentRevisionsScanned.Has(revision) { |
| return revision, nil // no changes to configs |
| } |
| fs.slurpScannedConfigs(revision, scanned) |
| return revision, nil |
| } |
| |
| func (fs *filesystemImpl) slurpScannedConfigs(revision string, scanned *scannedConfigs) { |
| scanned.setRevision(revision) |
| for k, v := range scanned.contentHashMap { |
| fs.contentHashMap[k] = v |
| } |
| for k, v := range scanned.contentRevPathMap { |
| fs.contentRevPathMap[k] = v |
| } |
| for k, v := range scanned.contentRevProject { |
| fs.contentRevProject[k] = v |
| } |
| fs.contentRevisionsScanned.Add(revision) |
| } |
| |
| func (fs *filesystemImpl) GetConfig(ctx context.Context, cfgSet config.Set, cfgPath string, metaOnly bool) (*config.Config, error) { |
| cs := configSet{luciPath(cfgSet)} |
| path := luciPath(cfgPath) |
| |
| if err := cs.validate(); err != nil { |
| return nil, err |
| } |
| |
| revision, err := fs.scanHeadRevision() |
| if err != nil { |
| return nil, err |
| } |
| |
| lk := lookupKey{revision, cs, path} |
| |
| fs.RLock() |
| ret, ok := fs.contentRevPathMap[lk] |
| fs.RUnlock() |
| if ok { |
| c := *ret |
| if metaOnly { |
| c.Content = "" |
| } |
| return &c, nil |
| } |
| return nil, config.ErrNoConfig |
| } |
| |
| func (fs *filesystemImpl) GetConfigs(ctx context.Context, cfgSet config.Set, filter func(path string) bool, metaOnly bool) (map[string]config.Config, error) { |
| cs := configSet{luciPath(cfgSet)} |
| if err := cs.validate(); err != nil { |
| return nil, err |
| } |
| |
| out := map[string]config.Config{} |
| err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) { |
| if lk.configSet == cs && (filter == nil || filter(cfg.Path)) { |
| c := *cfg |
| if metaOnly { |
| c.Content = "" |
| } |
| out[cfg.Path] = c |
| } |
| }) |
| |
| if err != nil { |
| return nil, err |
| } |
| return out, nil |
| } |
| |
| func (fs *filesystemImpl) ListFiles(ctx context.Context, cfgSet config.Set) ([]string, error) { |
| cs := configSet{luciPath(cfgSet)} |
| if err := cs.validate(); err != nil { |
| return nil, err |
| } |
| |
| var files []string |
| err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) { |
| if lk.configSet == cs { |
| files = append(files, cfg.Path) |
| } |
| }) |
| sort.Strings(files) |
| return files, err |
| } |
| |
| func (fs *filesystemImpl) iterContentRevPath(fn func(lk lookupKey, cfg *config.Config)) error { |
| revision, err := fs.scanHeadRevision() |
| if err != nil { |
| return err |
| } |
| |
| fs.RLock() |
| defer fs.RUnlock() |
| for lk, cfg := range fs.contentRevPathMap { |
| if lk.revision == revision { |
| fn(lk, cfg) |
| } |
| } |
| return nil |
| } |
| |
| func (fs *filesystemImpl) GetProjectConfigs(ctx context.Context, cfgPath string, metaOnly bool) ([]config.Config, error) { |
| path := luciPath(cfgPath) |
| |
| ret := make(configList, 0, 10) |
| err := fs.iterContentRevPath(func(lk lookupKey, cfg *config.Config) { |
| if lk.path != path { |
| return |
| } |
| if lk.configSet.isProject() { |
| c := *cfg |
| if metaOnly { |
| c.Content = "" |
| } |
| ret = append(ret, c) |
| } |
| }) |
| sort.Sort(ret) |
| return ret, err |
| } |
| |
| func (fs *filesystemImpl) GetProjects(ctx context.Context) ([]config.Project, error) { |
| revision, err := fs.scanHeadRevision() |
| if err != nil { |
| return nil, err |
| } |
| |
| fs.RLock() |
| ret := make(projList, 0, len(fs.contentRevProject)) |
| for lk, proj := range fs.contentRevProject { |
| if lk.revision == revision { |
| ret = append(ret, *proj) |
| } |
| } |
| fs.RUnlock() |
| sort.Sort(ret) |
| return ret, nil |
| } |
| |
| func (fs *filesystemImpl) Close() error { |
| return nil |
| } |