github.com/go-asm/go@v1.21.1-0.20240213172139-40c5ead50c48/cmd/go/vcweb/vcweb.go (about) 1 // Copyright 2022 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package vcweb serves version control repos for testing the go command. 6 // 7 // It is loosely derived from golang.org/x/build/vcs-test/vcweb, 8 // which ran as a service hosted at vcs-test.golang.org. 9 // 10 // When a repository URL is first requested, the vcweb [Server] dynamically 11 // regenerates the repository using a script interpreted by a [script.Engine]. 12 // The script produces the server's contents for a corresponding root URL and 13 // all subdirectories of that URL, which are then cached: subsequent requests 14 // for any URL generated by the script will serve the script's previous output 15 // until the script is modified. 16 // 17 // The script engine includes all of the engine's default commands and 18 // conditions, as well as commands for each supported VCS binary (bzr, fossil, 19 // git, hg, and svn), a "handle" command that informs the script which protocol 20 // or handler to use to serve the request, and utilities "at" (which sets 21 // environment variables for Git timestamps) and "unquote" (which unquotes its 22 // argument as if it were a Go string literal). 23 // 24 // The server's "/" endpoint provides a summary of the available scripts, 25 // and "/help" provides documentation for the script environment. 26 // 27 // To run a standalone server based on the vcweb engine, use: 28 // 29 // go test github.com/go-asm/go/cmd/go/vcweb/vcstest -v --port=0 30 package vcweb 31 32 import ( 33 "bufio" 34 "context" 35 "crypto/sha256" 36 "errors" 37 "fmt" 38 "io" 39 "io/fs" 40 "log" 41 "net/http" 42 "os" 43 "os/exec" 44 "path" 45 "path/filepath" 46 "runtime/debug" 47 "strings" 48 "sync" 49 "text/tabwriter" 50 "time" 51 52 "github.com/go-asm/go/cmd/go/script" 53 ) 54 55 // A Server serves cached, dynamically-generated version control repositories. 56 type Server struct { 57 env []string 58 logger *log.Logger 59 60 scriptDir string 61 workDir string 62 homeDir string // $workdir/home 63 engine *script.Engine 64 65 scriptCache sync.Map // script path → *scriptResult 66 67 vcsHandlers map[string]vcsHandler 68 } 69 70 // A vcsHandler serves repositories over HTTP for a known version-control tool. 71 type vcsHandler interface { 72 Available() bool 73 Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) 74 } 75 76 // A scriptResult describes the cached result of executing a vcweb script. 77 type scriptResult struct { 78 mu sync.RWMutex 79 80 hash [sha256.Size]byte // hash of the script file, for cache invalidation 81 hashTime time.Time // timestamp at which the script was run, for diagnostics 82 83 handler http.Handler // HTTP handler configured by the script 84 err error // error from executing the script, if any 85 } 86 87 // NewServer returns a Server that generates and serves repositories in workDir 88 // using the scripts found in scriptDir and its subdirectories. 89 // 90 // A request for the path /foo/bar/baz will be handled by the first script along 91 // that path that exists: $scriptDir/foo.txt, $scriptDir/foo/bar.txt, or 92 // $scriptDir/foo/bar/baz.txt. 93 func NewServer(scriptDir, workDir string, logger *log.Logger) (*Server, error) { 94 if scriptDir == "" { 95 panic("vcweb.NewServer: scriptDir is required") 96 } 97 var err error 98 scriptDir, err = filepath.Abs(scriptDir) 99 if err != nil { 100 return nil, err 101 } 102 103 if workDir == "" { 104 workDir, err = os.MkdirTemp("", "vcweb-*") 105 if err != nil { 106 return nil, err 107 } 108 logger.Printf("vcweb work directory: %s", workDir) 109 } else { 110 workDir, err = filepath.Abs(workDir) 111 if err != nil { 112 return nil, err 113 } 114 } 115 116 homeDir := filepath.Join(workDir, "home") 117 if err := os.MkdirAll(homeDir, 0755); err != nil { 118 return nil, err 119 } 120 121 env := scriptEnviron(homeDir) 122 123 s := &Server{ 124 env: env, 125 logger: logger, 126 scriptDir: scriptDir, 127 workDir: workDir, 128 homeDir: homeDir, 129 engine: newScriptEngine(), 130 vcsHandlers: map[string]vcsHandler{ 131 "auth": new(authHandler), 132 "dir": new(dirHandler), 133 "bzr": new(bzrHandler), 134 "fossil": new(fossilHandler), 135 "git": new(gitHandler), 136 "hg": new(hgHandler), 137 "insecure": new(insecureHandler), 138 "svn": &svnHandler{svnRoot: workDir, logger: logger}, 139 }, 140 } 141 142 if err := os.WriteFile(filepath.Join(s.homeDir, ".gitconfig"), []byte(gitConfig), 0644); err != nil { 143 return nil, err 144 } 145 gitConfigDir := filepath.Join(s.homeDir, ".config", "git") 146 if err := os.MkdirAll(gitConfigDir, 0755); err != nil { 147 return nil, err 148 } 149 if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte(""), 0644); err != nil { 150 return nil, err 151 } 152 153 if err := os.WriteFile(filepath.Join(s.homeDir, ".hgrc"), []byte(hgrc), 0644); err != nil { 154 return nil, err 155 } 156 157 return s, nil 158 } 159 160 func (s *Server) Close() error { 161 var firstErr error 162 for _, h := range s.vcsHandlers { 163 if c, ok := h.(io.Closer); ok { 164 if closeErr := c.Close(); firstErr == nil { 165 firstErr = closeErr 166 } 167 } 168 } 169 return firstErr 170 } 171 172 // gitConfig contains a ~/.gitconfg file that attempts to provide 173 // deterministic, platform-agnostic behavior for the 'git' command. 174 var gitConfig = ` 175 [user] 176 name = Go Gopher 177 email = gopher@golang.org 178 [init] 179 defaultBranch = main 180 [core] 181 eol = lf 182 [gui] 183 encoding = utf-8 184 `[1:] 185 186 // hgrc contains a ~/.hgrc file that attempts to provide 187 // deterministic, platform-agnostic behavior for the 'hg' command. 188 var hgrc = ` 189 [ui] 190 username=Go Gopher <gopher@golang.org> 191 [phases] 192 new-commit=public 193 [extensions] 194 convert= 195 `[1:] 196 197 // ServeHTTP implements [http.Handler] for version-control repositories. 198 func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { 199 s.logger.Printf("serving %s", req.URL) 200 201 defer func() { 202 if v := recover(); v != nil { 203 debug.PrintStack() 204 s.logger.Fatal(v) 205 } 206 }() 207 208 urlPath := req.URL.Path 209 if !strings.HasPrefix(urlPath, "/") { 210 urlPath = "/" + urlPath 211 } 212 clean := path.Clean(urlPath)[1:] 213 if clean == "" { 214 s.overview(w, req) 215 return 216 } 217 if clean == "help" { 218 s.help(w, req) 219 return 220 } 221 222 // Locate the script that generates the requested path. 223 // We follow directories all the way to the end, then look for a ".txt" file 224 // matching the first component that doesn't exist. That guarantees 225 // uniqueness: if a path exists as a directory, then it cannot exist as a 226 // ".txt" script (because the search would ignore that file). 227 scriptPath := "." 228 for _, part := range strings.Split(clean, "/") { 229 scriptPath = filepath.Join(scriptPath, part) 230 dir := filepath.Join(s.scriptDir, scriptPath) 231 if _, err := os.Stat(dir); err != nil { 232 if !os.IsNotExist(err) { 233 http.Error(w, err.Error(), http.StatusInternalServerError) 234 return 235 } 236 // scriptPath does not exist as a directory, so it either is the script 237 // location or the script doesn't exist. 238 break 239 } 240 } 241 scriptPath += ".txt" 242 243 err := s.HandleScript(scriptPath, s.logger, func(handler http.Handler) { 244 handler.ServeHTTP(w, req) 245 }) 246 if err != nil { 247 s.logger.Print(err) 248 if notFound := (ScriptNotFoundError{}); errors.As(err, ¬Found) { 249 http.NotFound(w, req) 250 } else if notInstalled := (ServerNotInstalledError{}); errors.As(err, ¬Installed) || errors.Is(err, exec.ErrNotFound) { 251 http.Error(w, err.Error(), http.StatusNotImplemented) 252 } else { 253 http.Error(w, err.Error(), http.StatusInternalServerError) 254 } 255 } 256 } 257 258 // A ScriptNotFoundError indicates that the requested script file does not exist. 259 // (It typically wraps a "stat" error for the script file.) 260 type ScriptNotFoundError struct{ err error } 261 262 func (e ScriptNotFoundError) Error() string { return e.err.Error() } 263 func (e ScriptNotFoundError) Unwrap() error { return e.err } 264 265 // A ServerNotInstalledError indicates that the server binary required for the 266 // indicated VCS does not exist. 267 type ServerNotInstalledError struct{ name string } 268 269 func (v ServerNotInstalledError) Error() string { 270 return fmt.Sprintf("server for %#q VCS is not installed", v.name) 271 } 272 273 // HandleScript ensures that the script at scriptRelPath has been evaluated 274 // with its current contents. 275 // 276 // If the script completed successfully, HandleScript invokes f on the handler 277 // with the script's result still read-locked, and waits for it to return. (That 278 // ensures that cache invalidation does not race with an in-flight handler.) 279 // 280 // Otherwise, HandleScript returns the (cached) error from executing the script. 281 func (s *Server) HandleScript(scriptRelPath string, logger *log.Logger, f func(http.Handler)) error { 282 ri, ok := s.scriptCache.Load(scriptRelPath) 283 if !ok { 284 ri, _ = s.scriptCache.LoadOrStore(scriptRelPath, new(scriptResult)) 285 } 286 r := ri.(*scriptResult) 287 288 relDir := strings.TrimSuffix(scriptRelPath, filepath.Ext(scriptRelPath)) 289 workDir := filepath.Join(s.workDir, relDir) 290 prefix := path.Join("/", filepath.ToSlash(relDir)) 291 292 r.mu.RLock() 293 defer r.mu.RUnlock() 294 for { 295 // For efficiency, we cache the script's output (in the work directory) 296 // across invocations. However, to allow for rapid iteration, we hash the 297 // script's contents and regenerate its output if the contents change. 298 // 299 // That way, one can use 'go run main.go' in this directory to stand up a 300 // server and see the output of the test script in order to fine-tune it. 301 content, err := os.ReadFile(filepath.Join(s.scriptDir, scriptRelPath)) 302 if err != nil { 303 if !os.IsNotExist(err) { 304 return err 305 } 306 return ScriptNotFoundError{err} 307 } 308 309 hash := sha256.Sum256(content) 310 if prevHash := r.hash; prevHash != hash { 311 // The script's hash has changed, so regenerate its output. 312 func() { 313 r.mu.RUnlock() 314 r.mu.Lock() 315 defer func() { 316 r.mu.Unlock() 317 r.mu.RLock() 318 }() 319 if r.hash != prevHash { 320 // The cached result changed while we were waiting on the lock. 321 // It may have been updated to our hash or something even newer, 322 // so don't overwrite it. 323 return 324 } 325 326 r.hash = hash 327 r.hashTime = time.Now() 328 r.handler, r.err = nil, nil 329 330 if err := os.RemoveAll(workDir); err != nil { 331 r.err = err 332 return 333 } 334 335 // Note: we use context.Background here instead of req.Context() so that we 336 // don't cache a spurious error (and lose work) if the request is canceled 337 // while the script is still running. 338 scriptHandler, err := s.loadScript(context.Background(), logger, scriptRelPath, content, workDir) 339 if err != nil { 340 r.err = err 341 return 342 } 343 r.handler = http.StripPrefix(prefix, scriptHandler) 344 }() 345 } 346 347 if r.hash != hash { 348 continue // Raced with an update from another handler; try again. 349 } 350 351 if r.err != nil { 352 return r.err 353 } 354 f(r.handler) 355 return nil 356 } 357 } 358 359 // overview serves an HTML summary of the status of the scripts in the server's 360 // script directory. 361 func (s *Server) overview(w http.ResponseWriter, r *http.Request) { 362 fmt.Fprintf(w, "<html>\n") 363 fmt.Fprintf(w, "<title>vcweb</title>\n<pre>\n") 364 fmt.Fprintf(w, "<b>vcweb</b>\n\n") 365 fmt.Fprintf(w, "This server serves various version control repos for testing the go command.\n\n") 366 fmt.Fprintf(w, "For an overview of the script language, see <a href=\"/help\">/help</a>.\n\n") 367 368 fmt.Fprintf(w, "<b>cache</b>\n") 369 370 tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0) 371 err := filepath.WalkDir(s.scriptDir, func(path string, d fs.DirEntry, err error) error { 372 if err != nil { 373 return err 374 } 375 if filepath.Ext(path) != ".txt" { 376 return nil 377 } 378 379 rel, err := filepath.Rel(s.scriptDir, path) 380 if err != nil { 381 return err 382 } 383 hashTime := "(not loaded)" 384 status := "" 385 if ri, ok := s.scriptCache.Load(rel); ok { 386 r := ri.(*scriptResult) 387 r.mu.RLock() 388 defer r.mu.RUnlock() 389 390 if !r.hashTime.IsZero() { 391 hashTime = r.hashTime.Format(time.RFC3339) 392 } 393 if r.err == nil { 394 status = "ok" 395 } else { 396 status = r.err.Error() 397 } 398 } 399 fmt.Fprintf(tw, "%s\t%s\t%s\n", rel, hashTime, status) 400 return nil 401 }) 402 tw.Flush() 403 404 if err != nil { 405 fmt.Fprintln(w, err) 406 } 407 } 408 409 // help serves a plain-text summary of the server's supported script language. 410 func (s *Server) help(w http.ResponseWriter, req *http.Request) { 411 st, err := s.newState(req.Context(), s.workDir) 412 if err != nil { 413 http.Error(w, err.Error(), http.StatusInternalServerError) 414 return 415 } 416 417 scriptLog := new(strings.Builder) 418 err = s.engine.Execute(st, "help", bufio.NewReader(strings.NewReader("help")), scriptLog) 419 if err != nil { 420 http.Error(w, err.Error(), http.StatusInternalServerError) 421 return 422 } 423 424 w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 425 io.WriteString(w, scriptLog.String()) 426 }