github.com/seilagamo/poc-lava-release@v0.3.3-rc3/internal/gitserver/gitserver.go (about)

     1  // Copyright 2023 Adevinta
     2  
     3  // Package gitserver provides a read-only smart HTTP Git server.
     4  package gitserver
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"io"
    12  	"io/fs"
    13  	"math/rand"
    14  	"net"
    15  	"net/http"
    16  	"os"
    17  	"os/exec"
    18  	"path"
    19  	"path/filepath"
    20  	"regexp"
    21  	"sync"
    22  )
    23  
    24  // ErrGit is returned by [New] when the git command cannot be run.
    25  var ErrGit = errors.New("git cannot be run")
    26  
    27  // Server represents a Git server.
    28  type Server struct {
    29  	basePath string
    30  	httpsrv  *http.Server
    31  
    32  	mu    sync.Mutex
    33  	repos map[string]string
    34  	paths map[string]string
    35  }
    36  
    37  // New creates a git server, but doesn't start it.
    38  func New() (*Server, error) {
    39  	if err := checkGit(); err != nil {
    40  		return nil, fmt.Errorf("%w: %w", ErrGit, err)
    41  	}
    42  
    43  	tmpPath, err := os.MkdirTemp("", "")
    44  	if err != nil {
    45  		return nil, fmt.Errorf("make temp dir: %w", err)
    46  	}
    47  
    48  	srv := &Server{
    49  		basePath: tmpPath,
    50  		repos:    make(map[string]string),
    51  		paths:    make(map[string]string),
    52  		httpsrv:  &http.Server{Handler: newSmartServer(tmpPath)},
    53  	}
    54  	return srv, nil
    55  }
    56  
    57  // AddRepository adds a repository to the Git server. It returns the
    58  // name of the new served repository.
    59  func (srv *Server) AddRepository(path string) (string, error) {
    60  	srv.mu.Lock()
    61  	defer srv.mu.Unlock()
    62  
    63  	if repoName, ok := srv.repos[path]; ok {
    64  		return repoName, nil
    65  	}
    66  
    67  	dstPath, err := os.MkdirTemp(srv.basePath, "*.git")
    68  	if err != nil {
    69  		return "", fmt.Errorf("make temp dir: %w", err)
    70  	}
    71  
    72  	// --mirror implies --bare. Compared to --bare, --mirror not
    73  	// only maps local branches of the source to local branches of
    74  	// the target, it maps all refs (including remote-tracking
    75  	// branches, notes etc.) and sets up a refspec configuration
    76  	// such that all these refs are overwritten by a git remote
    77  	// update in the target repository.
    78  	cmd := exec.Command("git", "clone", "--mirror", path, dstPath)
    79  	if err = cmd.Run(); err != nil {
    80  		return "", fmt.Errorf("git clone: %w", err)
    81  	}
    82  
    83  	// Create a branch at HEAD. So, if HEAD is detached, the Git
    84  	// client is able to guess the reference where HEAD is
    85  	// pointing to.
    86  	//
    87  	// Reference: https://github.com/go-git/go-git/blob/f92cb0d49088af996433ebb106b9fc7c2adb8875/plumbing/protocol/packp/advrefs.go#L94-L104
    88  	branch := fmt.Sprintf("lava-%v", rand.Int63())
    89  	cmd = exec.Command("git", "branch", branch)
    90  	cmd.Dir = dstPath
    91  	if err = cmd.Run(); err != nil {
    92  		return "", fmt.Errorf("git branch: %w", err)
    93  	}
    94  
    95  	repoName := filepath.Base(dstPath)
    96  	srv.repos[path] = repoName
    97  	return repoName, nil
    98  }
    99  
   100  // AddPath adds a file path to the Git server. The path is served as a
   101  // Git repository with a single commit. It returns the name of the new
   102  // served repository.
   103  func (srv *Server) AddPath(path string) (string, error) {
   104  	srv.mu.Lock()
   105  	defer srv.mu.Unlock()
   106  
   107  	if repoName, ok := srv.paths[path]; ok {
   108  		return repoName, nil
   109  	}
   110  
   111  	dstPath, err := os.MkdirTemp(srv.basePath, "*.git")
   112  	if err != nil {
   113  		return "", fmt.Errorf("make temp dir: %w", err)
   114  	}
   115  
   116  	if err := fscopy(dstPath, path); err != nil {
   117  		return "", fmt.Errorf("copy files: %w", err)
   118  	}
   119  
   120  	cmd := exec.Command("git", "init")
   121  	cmd.Dir = dstPath
   122  	if err = cmd.Run(); err != nil {
   123  		return "", fmt.Errorf("git init: %w", err)
   124  	}
   125  
   126  	cmd = exec.Command("git", "add", "-f", ".")
   127  	cmd.Dir = dstPath
   128  	if err = cmd.Run(); err != nil {
   129  		return "", fmt.Errorf("git add: %w", err)
   130  	}
   131  
   132  	cmd = exec.Command(
   133  		"git",
   134  		"-c", "user.name=lava",
   135  		"-c", "user.email=lava@lava.local",
   136  		"commit", "-m", "[auto] lava",
   137  	)
   138  	cmd.Dir = dstPath
   139  	if err = cmd.Run(); err != nil {
   140  		return "", fmt.Errorf("git commit: %w", err)
   141  	}
   142  
   143  	repoName := filepath.Base(dstPath)
   144  	srv.paths[path] = repoName
   145  	return repoName, nil
   146  }
   147  
   148  // fscopy copies src to dst recursively. It ignores all .git
   149  // directories.
   150  func fscopy(dst, src string) error {
   151  	err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
   152  		if err != nil {
   153  			return err
   154  		}
   155  
   156  		rel, err := filepath.Rel(src, path)
   157  		if err != nil {
   158  			return fmt.Errorf("rel: %w", err)
   159  		}
   160  
   161  		switch typ := d.Type(); {
   162  		case typ.IsDir():
   163  			if rel == "." {
   164  				// The source path is a directory. The
   165  				// destination directory already
   166  				// exists, so it is not necessary to
   167  				// create it.
   168  				return nil
   169  			}
   170  			if filepath.Base(rel) == ".git" {
   171  				// Ignore .git directory.
   172  				return filepath.SkipDir
   173  			}
   174  			if err := os.MkdirAll(filepath.Join(dst, rel), 0755); err != nil {
   175  				return fmt.Errorf("make dir: %w", err)
   176  			}
   177  		case typ.IsRegular():
   178  			if rel == "." {
   179  				// The source path is a file. The
   180  				// destination file is the name of the
   181  				// source file.
   182  				rel = filepath.Base(path)
   183  			}
   184  			fsrc, err := os.Open(path)
   185  			if err != nil {
   186  				return fmt.Errorf("open source file: %w", err)
   187  			}
   188  			defer fsrc.Close()
   189  			fdst, err := os.Create(filepath.Join(dst, rel))
   190  			if err != nil {
   191  				return fmt.Errorf("create destination file: %w", err)
   192  			}
   193  			defer fdst.Close()
   194  			if _, err := io.Copy(fdst, fsrc); err != nil {
   195  				return fmt.Errorf("copy file: %w", err)
   196  			}
   197  		default:
   198  			return fmt.Errorf("invalid file type: %v", path)
   199  		}
   200  		return nil
   201  	})
   202  
   203  	if err != nil {
   204  		return fmt.Errorf("walk dir: %w", err)
   205  	}
   206  	return nil
   207  }
   208  
   209  // ListenAndServe listens on the TCP network address addr and then
   210  // calls [*GitServer.Serve] to handle requests on incoming
   211  // connections.
   212  //
   213  // ListenAndServe always returns a non-nil error.
   214  func (srv *Server) ListenAndServe(addr string) error {
   215  	l, err := net.Listen("tcp", addr)
   216  	if err != nil {
   217  		return fmt.Errorf("listen: %w", err)
   218  	}
   219  	return srv.Serve(l)
   220  }
   221  
   222  // testHookServerServe is executed at the beginning of
   223  // [*GitServer.Serve] if not nil. It is set by tests.
   224  var testHookServerServe func(*Server, net.Listener)
   225  
   226  // Serve accepts incoming connections on the [http.Listener] l.
   227  //
   228  // Serve always returns a non-nil error and closes l.
   229  func (srv *Server) Serve(l net.Listener) error {
   230  	if fn := testHookServerServe; fn != nil {
   231  		fn(srv, l)
   232  	}
   233  	return srv.httpsrv.Serve(l)
   234  }
   235  
   236  // Close stops the server and deletes any temporary directory created
   237  // to store the repositories.
   238  func (srv *Server) Close() error {
   239  	if err := srv.httpsrv.Shutdown(context.Background()); err != nil {
   240  		return fmt.Errorf("server shutdown: %w", err)
   241  	}
   242  	if err := os.RemoveAll(srv.basePath); err != nil {
   243  		return fmt.Errorf("remove temp dirs: %w", err)
   244  	}
   245  	return nil
   246  }
   247  
   248  // checkGit checks that the git command can be run.
   249  func checkGit() error {
   250  	return exec.Command("git", "version").Run()
   251  }
   252  
   253  // smartServer provides a read-only smart HTTP Git protocol
   254  // implementation.
   255  type smartServer struct {
   256  	basePath string
   257  }
   258  
   259  // newSmartServer returns a new [smartServer]. Served repositories are
   260  // relative to basePath.
   261  func newSmartServer(basePath string) *smartServer {
   262  	return &smartServer{basePath: basePath}
   263  }
   264  
   265  // pathRE is used to parse HTTP requests.
   266  var pathRE = regexp.MustCompile(`^(/.*?)(/.*)$`)
   267  
   268  // ServeHTTP implements the smart server router.
   269  func (srv *smartServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   270  	matches := pathRE.FindStringSubmatch(path.Clean(r.URL.Path))
   271  	if matches == nil {
   272  		w.WriteHeader(http.StatusNotFound)
   273  		return
   274  	}
   275  	repo := matches[1]
   276  	endpoint := matches[2]
   277  
   278  	switch endpoint {
   279  	case "/info/refs":
   280  		srv.handleInfoRefs(w, r, repo)
   281  	case "/git-upload-pack":
   282  		srv.handleGitUploadPack(w, r, repo)
   283  	default:
   284  		w.WriteHeader(http.StatusNotFound)
   285  	}
   286  }
   287  
   288  // handleInfoRefs handles requests to /repo/info/refs.
   289  func (srv *smartServer) handleInfoRefs(w http.ResponseWriter, r *http.Request, repo string) {
   290  	if r.Method != "GET" {
   291  		w.WriteHeader(http.StatusNotFound)
   292  		return
   293  	}
   294  
   295  	if r.URL.Query().Get("service") != "git-upload-pack" {
   296  		w.WriteHeader(http.StatusForbidden)
   297  		return
   298  	}
   299  
   300  	buf := &bytes.Buffer{}
   301  	repoPath := filepath.Join(srv.basePath, repo)
   302  	cmd := exec.Command("git-upload-pack", "--advertise-refs", repoPath)
   303  	cmd.Stdout = buf
   304  	if err := cmd.Run(); err != nil {
   305  		w.WriteHeader(http.StatusInternalServerError)
   306  		return
   307  	}
   308  
   309  	w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
   310  
   311  	pkt := "# service=git-upload-pack\n"
   312  	fmt.Fprintf(w, "%04x%v0000%v", len(pkt)+4, pkt, buf)
   313  }
   314  
   315  // handleGitUploadPack handles requests to /repo/git-upload-pack.
   316  func (srv *smartServer) handleGitUploadPack(w http.ResponseWriter, r *http.Request, repo string) {
   317  	if r.Method != "POST" {
   318  		w.WriteHeader(http.StatusNotFound)
   319  		return
   320  	}
   321  
   322  	buf := &bytes.Buffer{}
   323  	repoPath := filepath.Join(srv.basePath, repo)
   324  	cmd := exec.Command("git-upload-pack", "--stateless-rpc", repoPath)
   325  	cmd.Stdin = r.Body
   326  	cmd.Stdout = buf
   327  	if err := cmd.Run(); err != nil {
   328  		w.WriteHeader(http.StatusInternalServerError)
   329  		return
   330  	}
   331  
   332  	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
   333  	fmt.Fprintf(w, "%v", buf)
   334  }