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 }