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 }