github.com/microsoft/fabrikate@v1.0.0-alpha.1.0.20210115014322-dc09194d0885/internal/git/core.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"os/exec"
     8  	"path"
     9  	"path/filepath"
    10  	"regexp"
    11  	"sync"
    12  
    13  	"github.com/google/uuid"
    14  	"github.com/kyokomi/emoji"
    15  	"github.com/microsoft/fabrikate/internal/logger"
    16  	"github.com/otiai10/copy"
    17  )
    18  
    19  // Mutex safe getter
    20  func (result *gitCloneResult) get() string {
    21  	result.mu.RLock()
    22  	clonePath := result.ClonePath
    23  	result.mu.RUnlock()
    24  	return clonePath
    25  }
    26  
    27  // R/W safe map of {[cacheToken]: gitCloneResult}
    28  type gitCache struct {
    29  	mu    sync.RWMutex
    30  	cache map[string]*gitCloneResult
    31  }
    32  
    33  // Mutex safe getter
    34  func (cache *gitCache) get(cacheToken string) (*gitCloneResult, bool) {
    35  	cache.mu.RLock()
    36  	value, ok := cache.cache[cacheToken]
    37  	cache.mu.RUnlock()
    38  	return value, ok
    39  }
    40  
    41  // Mutex safe setter
    42  func (cache *gitCache) set(cacheToken string, cloneResult *gitCloneResult) {
    43  	cache.mu.Lock()
    44  	cache.cache[cacheToken] = cloneResult
    45  	cache.mu.Unlock()
    46  }
    47  
    48  // A future like struct to hold the result of git clone
    49  type gitCloneResult struct {
    50  	ClonePath string // The abs path in os.TempDir() where the the item was cloned to
    51  	Error     error  // An error which occurred during the clone
    52  	mu        sync.RWMutex
    53  }
    54  
    55  // cache is a global git map cache of {[cacheKey]: gitCloneResult}
    56  var cache = gitCache{
    57  	cache: map[string]*gitCloneResult{},
    58  }
    59  
    60  // cacheKey combines a git-repo, branch, and commit into a unique key used for
    61  // caching to a map
    62  func cacheKey(repo, branch, commit string) string {
    63  	if len(branch) == 0 {
    64  		branch = "master"
    65  	}
    66  	if len(commit) == 0 {
    67  		commit = "head"
    68  	}
    69  	return fmt.Sprintf("%v@%v:%v", repo, branch, commit)
    70  }
    71  
    72  // cloneRepo clones a target git repository into the hosts temporary directory
    73  // and returns a gitCloneResult pointing to that location on filesystem
    74  func (cache *gitCache) cloneRepo(repo string, commit string, branch string) chan *gitCloneResult {
    75  	cloneResultChan := make(chan *gitCloneResult)
    76  
    77  	go func() {
    78  		cacheToken := cacheKey(repo, branch, commit)
    79  
    80  		// Check if the repo is cloned/being-cloned
    81  		if cloneResult, ok := cache.get(cacheToken); ok {
    82  			logger.Info(emoji.Sprintf(":atm: Previously cloned '%s' this install; reusing cached result", cacheToken))
    83  			cloneResultChan <- cloneResult
    84  			close(cloneResultChan)
    85  			return
    86  		}
    87  
    88  		// Add the clone future to cache
    89  		cloneResult := gitCloneResult{}
    90  		cloneResult.mu.Lock() // lock the future
    91  		defer func() {
    92  			cloneResult.mu.Unlock() // ensure the lock is released
    93  			close(cloneResultChan)
    94  		}()
    95  		cache.set(cacheToken, &cloneResult) // store future in cache
    96  
    97  		// Default options for a clone
    98  		cloneCommandArgs := []string{"clone"}
    99  
   100  		// check for access token and append to repo if present
   101  		if token, exists := AccessTokens.Get(repo); exists {
   102  			// Only match when the repo string does not contain a an access token already
   103  			// "(https?)://(?!(.+:)?.+@)(.+)" would be preferred but go does not support negative lookahead
   104  			pattern, err := regexp.Compile("^(https?)://([^@]+@)?(.+)$")
   105  			if err != nil {
   106  				cloneResultChan <- &gitCloneResult{Error: err}
   107  				return
   108  			}
   109  			// If match is found, inject the access token into the repo string
   110  			if matches := pattern.FindStringSubmatch(repo); matches != nil {
   111  				protocol := matches[1]
   112  				// credentialsWithAtSign := matches[2]
   113  				cleanedRepoString := matches[3]
   114  				repo = fmt.Sprintf("%v://%v@%v", protocol, token, cleanedRepoString)
   115  			}
   116  		}
   117  
   118  		// Add repo to clone args
   119  		cloneCommandArgs = append(cloneCommandArgs, repo)
   120  
   121  		// Only fetch latest commit if commit not provided
   122  		if len(commit) == 0 {
   123  			logger.Info(emoji.Sprintf(":helicopter: Component requested latest commit: fast cloning at --depth 1"))
   124  			cloneCommandArgs = append(cloneCommandArgs, "--depth", "1")
   125  		} else {
   126  			logger.Info(emoji.Sprintf(":helicopter: Component requested commit '%s': need full clone", commit))
   127  		}
   128  
   129  		// Add branch reference option if provided
   130  		if len(branch) != 0 {
   131  			logger.Info(emoji.Sprintf(":helicopter: Component requested branch '%s'", branch))
   132  			cloneCommandArgs = append(cloneCommandArgs, "--branch", branch)
   133  		}
   134  
   135  		// Clone into a random path in the host temp dir
   136  		randomFolderName, err := uuid.NewRandom()
   137  		if err != nil {
   138  			cloneResultChan <- &gitCloneResult{Error: err}
   139  			return
   140  		}
   141  		clonePathOnFS := path.Join(os.TempDir(), randomFolderName.String())
   142  		logger.Info(emoji.Sprintf(":helicopter: Cloning %s => %s", cacheToken, clonePathOnFS))
   143  		cloneCommandArgs = append(cloneCommandArgs, clonePathOnFS)
   144  		cloneCommand := exec.Command("git", cloneCommandArgs...)
   145  		cloneCommand.Env = append(cloneCommand.Env, os.Environ()...)         // pass all env variables to git command so proper SSH config is passed if needed
   146  		cloneCommand.Env = append(cloneCommand.Env, "GIT_TERMINAL_PROMPT=0") // tell git to fail if it asks for credentials
   147  
   148  		var stdout, stderr bytes.Buffer
   149  		cloneCommand.Stdout = &stdout
   150  		cloneCommand.Stderr = &stderr
   151  		if err := cloneCommand.Run(); err != nil {
   152  			logger.Error(emoji.Sprintf(":no_entry_sign: Error occurred while cloning: '%s'\n%s: %s", cacheToken, err, stderr.String()))
   153  			cloneResultChan <- &gitCloneResult{Error: fmt.Errorf("%v: %v", err, stderr.String())}
   154  			return
   155  		}
   156  
   157  		// If commit provided, checkout the commit
   158  		if len(commit) != 0 {
   159  			logger.Info(emoji.Sprintf(":helicopter: Performing checkout commit '%s' for repo '%s'", commit, repo))
   160  			checkoutCommit := exec.Command("git", "checkout", commit)
   161  			var stdout, stderr bytes.Buffer
   162  			checkoutCommit.Stdout = &stdout
   163  			checkoutCommit.Stderr = &stderr
   164  			checkoutCommit.Dir = clonePathOnFS
   165  			if err := checkoutCommit.Run(); err != nil {
   166  				logger.Error(emoji.Sprintf(":no_entry_sign: Error occurred checking out commit '%s' from repo '%s'\n%s: %s", commit, repo, err, stderr.String()))
   167  				cloneResultChan <- &gitCloneResult{Error: fmt.Errorf("%v: %v", err, stderr.String())}
   168  				return
   169  			}
   170  		}
   171  
   172  		// Save the gitCloneResult into cache
   173  		cloneResult.ClonePath = clonePathOnFS
   174  
   175  		// Push the cached result to the channel
   176  		cloneResultChan <- &cloneResult
   177  	}()
   178  
   179  	return cloneResultChan
   180  }
   181  
   182  // CloneOpts are the options you can pass to Clone
   183  type CloneOpts struct {
   184  	URL    string
   185  	SHA    string
   186  	Branch string
   187  	Into   string
   188  }
   189  
   190  // Clone is a helper func to centralize cloning a repository with the spec
   191  // provided by its arguments.
   192  func Clone(opts *CloneOpts) (err error) {
   193  	// Clone and get the location of where it was cloned to in tmp
   194  	result := <-cache.cloneRepo(opts.URL, opts.SHA, opts.Branch)
   195  	clonePath := result.get()
   196  	if result.Error != nil {
   197  		return result.Error
   198  	}
   199  
   200  	// Remove the into directory if it already exists
   201  	if err = os.RemoveAll(opts.Into); err != nil {
   202  		return err
   203  	}
   204  
   205  	// copy the repo from tmp cache to component path
   206  	absIntoPath, err := filepath.Abs(opts.Into)
   207  	if err != nil {
   208  		return err
   209  	}
   210  	logger.Info(emoji.Sprintf(":truck: Copying %s => %s", clonePath, absIntoPath))
   211  	if err = copy.Copy(clonePath, opts.Into); err != nil {
   212  		return err
   213  	}
   214  
   215  	return err
   216  }
   217  
   218  // ClearCache deletes all temporary folders created as temporary cache for
   219  // git clones.
   220  func ClearCache() (err error) {
   221  	logger.Info(emoji.Sprintf(":bomb: Cleaning up git cache..."))
   222  	cache.mu.Lock()
   223  	for key, value := range cache.cache {
   224  		logger.Info(emoji.Sprintf(":bomb: Removing git cache directory '%s'", value.ClonePath))
   225  		if err = os.RemoveAll(value.ClonePath); err != nil {
   226  			logger.Error(emoji.Sprintf(":exclamation: Error deleting temporary directory '%s'", value.ClonePath))
   227  			cache.mu.Unlock()
   228  			return err
   229  		}
   230  		delete(cache.cache, key)
   231  	}
   232  	cache.mu.Unlock()
   233  	logger.Info(emoji.Sprintf(":white_check_mark: Completed cache clean!"))
   234  	return err
   235  }