blob: f748b343585661b3776b246802075401c0011ef8 [file] [log] [blame]
// 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, &notFound) {
http.NotFound(w, req)
} else if notInstalled := (ServerNotInstalledError{}); errors.As(err, &notInstalled) || 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 language, 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())
}