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 }