github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/test/integration/internal/fakegitserver/fakegitserver.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package fakegitserver
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  	"net/http/cgi"
    27  	"os"
    28  	"os/exec"
    29  	"path/filepath"
    30  	"strings"
    31  	"sync"
    32  	"time"
    33  
    34  	"github.com/go-git/go-git/v5"
    35  	"github.com/go-git/go-git/v5/plumbing/object"
    36  	"github.com/sirupsen/logrus"
    37  )
    38  
    39  type Client struct {
    40  	host       string
    41  	httpClient *http.Client
    42  }
    43  
    44  type RepoSetup struct {
    45  	// Name of the Git repo. It will get a ".git" appended to it and be
    46  	// initialized underneath o.gitReposParentDir.
    47  	Name string `json:"name"`
    48  	// Script to execute. This script runs inside the repo to perform any
    49  	// additional repo setup tasks. This script is executed by /bin/sh.
    50  	Script string `json:"script"`
    51  	// Whether to create the repo at the path (o.gitReposParentDir + name +
    52  	// ".git") even if a file (directory) exists there already. This basically
    53  	// does a 'rm -rf' of the folder first.
    54  	Overwrite bool `json:"overwrite"`
    55  }
    56  
    57  func NewClient(host string, timeout time.Duration) *Client {
    58  	return &Client{
    59  		host: host,
    60  		httpClient: &http.Client{
    61  			Timeout: timeout,
    62  		},
    63  	}
    64  }
    65  
    66  func (c *Client) do(method, endpoint string, payload []byte, params map[string]string) (*http.Response, error) {
    67  	baseURL := fmt.Sprintf("%s/%s", c.host, endpoint)
    68  	req, err := http.NewRequest(method, baseURL, bytes.NewBuffer(payload))
    69  	if err != nil {
    70  		return nil, err
    71  	}
    72  	req.Header.Add("Content-Type", "application/json; charset=UTF-8")
    73  	q := req.URL.Query()
    74  	for key, val := range params {
    75  		q.Set(key, val)
    76  	}
    77  	req.URL.RawQuery = q.Encode()
    78  	return c.httpClient.Do(req)
    79  }
    80  
    81  // SetupRepo sends a POST request with the RepoSetup contents.
    82  func (c *Client) SetupRepo(repoSetup RepoSetup) error {
    83  	buf, err := json.Marshal(repoSetup)
    84  	if err != nil {
    85  		return fmt.Errorf("could not marshal %v", repoSetup)
    86  	}
    87  
    88  	resp, err := c.do(http.MethodPost, "setup-repo", buf, nil)
    89  	if err != nil {
    90  		return err
    91  	}
    92  	if resp.StatusCode != 200 {
    93  		return fmt.Errorf("got %v response", resp.StatusCode)
    94  	}
    95  	return nil
    96  }
    97  
    98  // GitCGIHandler returns an http.Handler that is backed by git-http-backend (a
    99  // CGI executable). git-http-backend is the `git http-backend` subcommand that
   100  // comes distributed with a default git installation.
   101  func GitCGIHandler(gitBinary, gitReposParentDir string) http.Handler {
   102  	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   103  		h := &cgi.Handler{
   104  			Path: gitBinary,
   105  			Env: []string{
   106  				"GIT_PROJECT_ROOT=" + gitReposParentDir,
   107  				// Allow reading of all repos under gitReposParentDir.
   108  				"GIT_HTTP_EXPORT_ALL=1",
   109  			},
   110  			Args: []string{
   111  				"http-backend",
   112  			},
   113  		}
   114  		// Remove the "/repo" prefix, because git-http-backend expects the
   115  		// request to simply be the Git repo name.
   116  		req.URL.Path = strings.TrimPrefix(string(req.URL.Path), "/repo")
   117  		// It appears that this RequestURI field is not used; but for
   118  		// completeness trim the prefix here as well.
   119  		req.RequestURI = strings.TrimPrefix(req.RequestURI, "/repo")
   120  		h.ServeHTTP(w, req)
   121  	})
   122  }
   123  
   124  // SetupRepoHandler executes a JSON payload of instructions to set up a Git
   125  // repo.
   126  func SetupRepoHandler(gitReposParentDir string, mux *sync.Mutex) http.Handler {
   127  	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   128  		buf, err := io.ReadAll(req.Body)
   129  		defer req.Body.Close()
   130  		if err != nil {
   131  			logrus.Errorf("failed to read request body: %v", err)
   132  			http.Error(w, err.Error(), 500)
   133  			return
   134  		}
   135  
   136  		logrus.Infof("request body received: %v", string(buf))
   137  		var repoSetup RepoSetup
   138  		err = json.Unmarshal(buf, &repoSetup)
   139  		if err != nil {
   140  			logrus.Errorf("failed to parse request body as FGSRepoSetup: %v", err)
   141  			http.Error(w, err.Error(), 500)
   142  			return
   143  		}
   144  
   145  		// setupRepo might need to access global git config, concurrent
   146  		// modifications of which could result in error like "exit status 255
   147  		// error: could not lock config file /root/.gitconfig". Use a mux to
   148  		// avoid this
   149  		repo, err := setupRepo(gitReposParentDir, &repoSetup, mux)
   150  		if err != nil {
   151  			// Just log the error if the setup fails so that the developer can
   152  			// fix their error and retry without having to restart this server.
   153  			logrus.Errorf("failed to setup repo: %v", err)
   154  			http.Error(w, err.Error(), 500)
   155  			return
   156  		}
   157  
   158  		msg, err := getLog(repo)
   159  		if err != nil {
   160  			logrus.Errorf("failed to get repo stats: %v", err)
   161  			http.Error(w, err.Error(), 500)
   162  			return
   163  		}
   164  		fmt.Fprintf(w, "%s", msg)
   165  	})
   166  }
   167  
   168  func setupRepo(gitReposParentDir string, repoSetup *RepoSetup, mux *sync.Mutex) (*git.Repository, error) {
   169  	dir := filepath.Join(gitReposParentDir, repoSetup.Name+".git")
   170  	logger := logrus.WithField("directory", dir)
   171  
   172  	if _, err := os.Stat(dir); !os.IsNotExist(err) {
   173  		if repoSetup.Overwrite {
   174  			if err := os.RemoveAll(dir); err != nil {
   175  				logger.Error("(overwrite) could not remove directory")
   176  				return nil, err
   177  			}
   178  		} else {
   179  			return nil, fmt.Errorf("path %s already exists but overwrite is not enabled; aborting", dir)
   180  		}
   181  	}
   182  
   183  	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
   184  		logger.Error("could not create directory")
   185  		return nil, err
   186  	}
   187  
   188  	repo, err := git.PlainInit(dir, false)
   189  	if err != nil {
   190  		logger.Error("could not initialize git repo in directory")
   191  		return nil, err
   192  	}
   193  
   194  	// Steps below might update global git config, lock it to avoid collision.
   195  	mux.Lock()
   196  	defer mux.Unlock()
   197  	if err := setGitConfigOptions(repo); err != nil {
   198  		logger.Error("config setup failed")
   199  		return nil, err
   200  	}
   201  
   202  	if err := runSetupScript(dir, repoSetup.Script); err != nil {
   203  		logger.Error("running the repo setup script failed")
   204  		return nil, err
   205  	}
   206  
   207  	logger.Infof("successfully ran setup script in %s", dir)
   208  
   209  	if err := convertToBareRepo(repo, dir); err != nil {
   210  		logger.Error("conversion to bare repo failed")
   211  		return nil, err
   212  	}
   213  
   214  	repo, err = git.PlainOpen(dir)
   215  	if err != nil {
   216  		logger.Error("could not reopen repo")
   217  		return nil, err
   218  	}
   219  	return repo, nil
   220  }
   221  
   222  // getLog creates a report of Git repo statistics.
   223  func getLog(repo *git.Repository) (string, error) {
   224  	var stats string
   225  
   226  	// Show `git log --all` equivalent.
   227  	ref, err := repo.Head()
   228  	if err != nil {
   229  		return "", errors.New("could not get HEAD")
   230  	}
   231  	commits, err := repo.Log(&git.LogOptions{From: ref.Hash(), All: true})
   232  	if err != nil {
   233  		return "", errors.New("could not get git logs")
   234  	}
   235  	_ = commits.ForEach(func(commit *object.Commit) error {
   236  		stats += fmt.Sprintln(commit)
   237  		return nil
   238  	})
   239  
   240  	return stats, nil
   241  }
   242  
   243  func convertToBareRepo(repo *git.Repository, repoPath string) error {
   244  	// Convert to a bare repo.
   245  	config, err := repo.Config()
   246  	if err != nil {
   247  		return err
   248  	}
   249  	config.Core.IsBare = true
   250  	repo.SetConfig(config)
   251  
   252  	tempDir, err := os.MkdirTemp("", "fgs")
   253  	if err != nil {
   254  		return err
   255  	}
   256  	defer os.RemoveAll(tempDir)
   257  
   258  	// Move "<REPO>/.git" directory to a temporary directory.
   259  	err = os.Rename(filepath.Join(repoPath, ".git"), filepath.Join(tempDir, ".git"))
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	// Delete <REPO> folder. This takes care of deleting all worktree files.
   265  	err = os.RemoveAll(repoPath)
   266  	if err != nil {
   267  		return err
   268  	}
   269  
   270  	// Move the .git folder to the <REPO> folder path.
   271  	err = os.Rename(filepath.Join(tempDir, ".git"), repoPath)
   272  	if err != nil {
   273  		return err
   274  	}
   275  	return nil
   276  }
   277  
   278  func runSetupScript(repoPath, script string) error {
   279  	// Catch errors in the script.
   280  	script = "set -eu;" + script
   281  
   282  	logrus.Infof("setup script looks like: %v", script)
   283  
   284  	cmd := exec.Command("sh", "-c", script)
   285  	cmd.Dir = repoPath
   286  
   287  	cmd.Stdout = os.Stdout
   288  	cmd.Stderr = os.Stderr
   289  
   290  	// By default, make it so that the git commands contained in the script
   291  	// result in reproducible commits. This can be overridden by the script
   292  	// itself if it chooses to (re-)export the same environment variables.
   293  	cmd.Env = []string{
   294  		"GIT_AUTHOR_NAME=abc",
   295  		"GIT_AUTHOR_EMAIL=d@e.f",
   296  		"GIT_AUTHOR_DATE='Thu May 19 12:34:56 2022 +0000'",
   297  		"GIT_COMMITTER_NAME=abc",
   298  		"GIT_COMMITTER_EMAIL=d@e.f",
   299  		"GIT_COMMITTER_DATE='Thu May 19 12:34:56 2022 +0000'"}
   300  
   301  	return cmd.Run()
   302  }
   303  
   304  func setGitConfigOptions(r *git.Repository) error {
   305  	config, err := r.Config()
   306  	if err != nil {
   307  		return err
   308  	}
   309  
   310  	// Ensure that the given Git repo allows anonymous push access. This is
   311  	// required for unauthenticated clients to push to the repo over HTTP.
   312  	config.Raw.SetOption("http", "", "receivepack", "true")
   313  
   314  	// Advertise all objects. This allows clients to fetch by raw commit SHAs,
   315  	// avoiding the dreaded
   316  	//
   317  	// 		Server does not allow request for unadvertised object <SHA>
   318  	//
   319  	// error.
   320  	config.Raw.SetOption("uploadpack", "", "allowAnySHA1InWant", "true")
   321  
   322  	r.SetConfig(config)
   323  
   324  	return nil
   325  }