diff options
Diffstat (limited to 'src/cmd/go/internal/vcweb/vcweb.go')
-rw-r--r-- | src/cmd/go/internal/vcweb/vcweb.go | 425 |
1 files changed, 425 insertions, 0 deletions
diff --git a/src/cmd/go/internal/vcweb/vcweb.go b/src/cmd/go/internal/vcweb/vcweb.go new file mode 100644 index 0000000..e1f1282 --- /dev/null +++ b/src/cmd/go/internal/vcweb/vcweb.go @@ -0,0 +1,425 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package vcweb serves version control repos for testing the go command. +// +// It is loosely derived from golang.org/x/build/vcs-test/vcweb, +// which ran as a service hosted at vcs-test.golang.org. +// +// When a repository URL is first requested, the vcweb [Server] dynamically +// regenerates the repository using a script interpreted by a [script.Engine]. +// The script produces the server's contents for a corresponding root URL and +// all subdirectories of that URL, which are then cached: subsequent requests +// for any URL generated by the script will serve the script's previous output +// until the script is modified. +// +// The script engine includes all of the engine's default commands and +// conditions, as well as commands for each supported VCS binary (bzr, fossil, +// git, hg, and svn), a "handle" command that informs the script which protocol +// or handler to use to serve the request, and utilities "at" (which sets +// environment variables for Git timestamps) and "unquote" (which unquotes its +// argument as if it were a Go string literal). +// +// The server's "/" endpoint provides a summary of the available scripts, +// and "/help" provides documentation for the script environment. +// +// To run a standalone server based on the vcweb engine, use: +// +// go test cmd/go/internal/vcweb/vcstest -v --port=0 +package vcweb + +import ( + "bufio" + "cmd/go/internal/script" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "runtime/debug" + "strings" + "sync" + "text/tabwriter" + "time" +) + +// A Server serves cached, dynamically-generated version control repositories. +type Server struct { + env []string + logger *log.Logger + + scriptDir string + workDir string + homeDir string // $workdir/home + engine *script.Engine + + scriptCache sync.Map // script path → *scriptResult + + vcsHandlers map[string]vcsHandler +} + +// A vcsHandler serves repositories over HTTP for a known version-control tool. +type vcsHandler interface { + Available() bool + Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) +} + +// A scriptResult describes the cached result of executing a vcweb script. +type scriptResult struct { + mu sync.RWMutex + + hash [sha256.Size]byte // hash of the script file, for cache invalidation + hashTime time.Time // timestamp at which the script was run, for diagnostics + + handler http.Handler // HTTP handler configured by the script + err error // error from executing the script, if any +} + +// NewServer returns a Server that generates and serves repositories in workDir +// using the scripts found in scriptDir and its subdirectories. +// +// A request for the path /foo/bar/baz will be handled by the first script along +// that path that exists: $scriptDir/foo.txt, $scriptDir/foo/bar.txt, or +// $scriptDir/foo/bar/baz.txt. +func NewServer(scriptDir, workDir string, logger *log.Logger) (*Server, error) { + if scriptDir == "" { + panic("vcweb.NewServer: scriptDir is required") + } + var err error + scriptDir, err = filepath.Abs(scriptDir) + if err != nil { + return nil, err + } + + if workDir == "" { + workDir, err = os.MkdirTemp("", "vcweb-*") + if err != nil { + return nil, err + } + logger.Printf("vcweb work directory: %s", workDir) + } else { + workDir, err = filepath.Abs(workDir) + if err != nil { + return nil, err + } + } + + homeDir := filepath.Join(workDir, "home") + if err := os.MkdirAll(homeDir, 0755); err != nil { + return nil, err + } + + env := scriptEnviron(homeDir) + + s := &Server{ + env: env, + logger: logger, + scriptDir: scriptDir, + workDir: workDir, + homeDir: homeDir, + engine: newScriptEngine(), + vcsHandlers: map[string]vcsHandler{ + "auth": new(authHandler), + "dir": new(dirHandler), + "bzr": new(bzrHandler), + "fossil": new(fossilHandler), + "git": new(gitHandler), + "hg": new(hgHandler), + "insecure": new(insecureHandler), + "svn": &svnHandler{svnRoot: workDir, logger: logger}, + }, + } + + if err := os.WriteFile(filepath.Join(s.homeDir, ".gitconfig"), []byte(gitConfig), 0644); err != nil { + return nil, err + } + gitConfigDir := filepath.Join(s.homeDir, ".config", "git") + if err := os.MkdirAll(gitConfigDir, 0755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(gitConfigDir, "ignore"), []byte(""), 0644); err != nil { + return nil, err + } + + if err := os.WriteFile(filepath.Join(s.homeDir, ".hgrc"), []byte(hgrc), 0644); err != nil { + return nil, err + } + + return s, nil +} + +func (s *Server) Close() error { + var firstErr error + for _, h := range s.vcsHandlers { + if c, ok := h.(io.Closer); ok { + if closeErr := c.Close(); firstErr == nil { + firstErr = closeErr + } + } + } + return firstErr +} + +// gitConfig contains a ~/.gitconfg file that attempts to provide +// deterministic, platform-agnostic behavior for the 'git' command. +var gitConfig = ` +[user] + name = Go Gopher + email = gopher@golang.org +[init] + defaultBranch = main +[core] + eol = lf +[gui] + encoding = utf-8 +`[1:] + +// hgrc contains a ~/.hgrc file that attempts to provide +// deterministic, platform-agnostic behavior for the 'hg' command. +var hgrc = ` +[ui] +username=Go Gopher <gopher@golang.org> +[phases] +new-commit=public +[extensions] +convert= +`[1:] + +// ServeHTTP implements [http.Handler] for version-control repositories. +func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + s.logger.Printf("serving %s", req.URL) + + defer func() { + if v := recover(); v != nil { + debug.PrintStack() + s.logger.Fatal(v) + } + }() + + urlPath := req.URL.Path + if !strings.HasPrefix(urlPath, "/") { + urlPath = "/" + urlPath + } + clean := path.Clean(urlPath)[1:] + if clean == "" { + s.overview(w, req) + return + } + if clean == "help" { + s.help(w, req) + return + } + + // Locate the script that generates the requested path. + // We follow directories all the way to the end, then look for a ".txt" file + // matching the first component that doesn't exist. That guarantees + // uniqueness: if a path exists as a directory, then it cannot exist as a + // ".txt" script (because the search would ignore that file). + scriptPath := "." + for _, part := range strings.Split(clean, "/") { + scriptPath = filepath.Join(scriptPath, part) + dir := filepath.Join(s.scriptDir, scriptPath) + if _, err := os.Stat(dir); err != nil { + if !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // scriptPath does not exist as a directory, so it either is the script + // location or the script doesn't exist. + break + } + } + scriptPath += ".txt" + + err := s.HandleScript(scriptPath, s.logger, func(handler http.Handler) { + handler.ServeHTTP(w, req) + }) + if err != nil { + s.logger.Print(err) + if notFound := (ScriptNotFoundError{}); errors.As(err, ¬Found) { + http.NotFound(w, req) + } else if notInstalled := (ServerNotInstalledError{}); errors.As(err, ¬Installed) || errors.Is(err, exec.ErrNotFound) { + http.Error(w, err.Error(), http.StatusNotImplemented) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// A ScriptNotFoundError indicates that the requested script file does not exist. +// (It typically wraps a "stat" error for the script file.) +type ScriptNotFoundError struct{ err error } + +func (e ScriptNotFoundError) Error() string { return e.err.Error() } +func (e ScriptNotFoundError) Unwrap() error { return e.err } + +// A ServerNotInstalledError indicates that the server binary required for the +// indicated VCS does not exist. +type ServerNotInstalledError struct{ name string } + +func (v ServerNotInstalledError) Error() string { + return fmt.Sprintf("server for %#q VCS is not installed", v.name) +} + +// HandleScript ensures that the script at scriptRelPath has been evaluated +// with its current contents. +// +// If the script completed successfully, HandleScript invokes f on the handler +// with the script's result still read-locked, and waits for it to return. (That +// ensures that cache invalidation does not race with an in-flight handler.) +// +// Otherwise, HandleScript returns the (cached) error from executing the script. +func (s *Server) HandleScript(scriptRelPath string, logger *log.Logger, f func(http.Handler)) error { + ri, ok := s.scriptCache.Load(scriptRelPath) + if !ok { + ri, _ = s.scriptCache.LoadOrStore(scriptRelPath, new(scriptResult)) + } + r := ri.(*scriptResult) + + relDir := strings.TrimSuffix(scriptRelPath, filepath.Ext(scriptRelPath)) + workDir := filepath.Join(s.workDir, relDir) + prefix := path.Join("/", filepath.ToSlash(relDir)) + + r.mu.RLock() + defer r.mu.RUnlock() + for { + // For efficiency, we cache the script's output (in the work directory) + // across invocations. However, to allow for rapid iteration, we hash the + // script's contents and regenerate its output if the contents change. + // + // That way, one can use 'go run main.go' in this directory to stand up a + // server and see the output of the test script in order to fine-tune it. + content, err := os.ReadFile(filepath.Join(s.scriptDir, scriptRelPath)) + if err != nil { + if !os.IsNotExist(err) { + return err + } + return ScriptNotFoundError{err} + } + + hash := sha256.Sum256(content) + if prevHash := r.hash; prevHash != hash { + // The script's hash has changed, so regenerate its output. + func() { + r.mu.RUnlock() + r.mu.Lock() + defer func() { + r.mu.Unlock() + r.mu.RLock() + }() + if r.hash != prevHash { + // The cached result changed while we were waiting on the lock. + // It may have been updated to our hash or something even newer, + // so don't overwrite it. + return + } + + r.hash = hash + r.hashTime = time.Now() + r.handler, r.err = nil, nil + + if err := os.RemoveAll(workDir); err != nil { + r.err = err + return + } + + // Note: we use context.Background here instead of req.Context() so that we + // don't cache a spurious error (and lose work) if the request is canceled + // while the script is still running. + scriptHandler, err := s.loadScript(context.Background(), logger, scriptRelPath, content, workDir) + if err != nil { + r.err = err + return + } + r.handler = http.StripPrefix(prefix, scriptHandler) + }() + } + + if r.hash != hash { + continue // Raced with an update from another handler; try again. + } + + if r.err != nil { + return r.err + } + f(r.handler) + return nil + } +} + +// overview serves an HTML summary of the status of the scripts in the server's +// script directory. +func (s *Server) overview(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "<html>\n") + fmt.Fprintf(w, "<title>vcweb</title>\n<pre>\n") + fmt.Fprintf(w, "<b>vcweb</b>\n\n") + fmt.Fprintf(w, "This server serves various version control repos for testing the go command.\n\n") + fmt.Fprintf(w, "For an overview of the script lanugage, see <a href=\"/help\">/help</a>.\n\n") + + fmt.Fprintf(w, "<b>cache</b>\n") + + tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0) + err := filepath.WalkDir(s.scriptDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if filepath.Ext(path) != ".txt" { + return nil + } + + rel, err := filepath.Rel(s.scriptDir, path) + if err != nil { + return err + } + hashTime := "(not loaded)" + status := "" + if ri, ok := s.scriptCache.Load(rel); ok { + r := ri.(*scriptResult) + r.mu.RLock() + defer r.mu.RUnlock() + + if !r.hashTime.IsZero() { + hashTime = r.hashTime.Format(time.RFC3339) + } + if r.err == nil { + status = "ok" + } else { + status = r.err.Error() + } + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", rel, hashTime, status) + return nil + }) + tw.Flush() + + if err != nil { + fmt.Fprintln(w, err) + } +} + +// help serves a plain-text summary of the server's supported script language. +func (s *Server) help(w http.ResponseWriter, req *http.Request) { + st, err := s.newState(req.Context(), s.workDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + scriptLog := new(strings.Builder) + err = s.engine.Execute(st, "help", bufio.NewReader(strings.NewReader("help")), scriptLog) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + io.WriteString(w, scriptLog.String()) +} |