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 }