github.com/tooploox/oya@v0.0.21-0.20230524103240-1cda1861aad6/pkg/repo/github.go (about) 1 package repo 2 3 import ( 4 "fmt" 5 "io" 6 "io/ioutil" 7 "os" 8 "path/filepath" 9 "strings" 10 11 log "github.com/sirupsen/logrus" 12 "github.com/tooploox/oya/pkg/errors" 13 "github.com/tooploox/oya/pkg/oyafile" 14 "github.com/tooploox/oya/pkg/pack" 15 "github.com/tooploox/oya/pkg/semver" 16 "github.com/tooploox/oya/pkg/types" 17 "gopkg.in/src-d/go-billy.v4/memfs" 18 git "gopkg.in/src-d/go-git.v4" 19 "gopkg.in/src-d/go-git.v4/plumbing" 20 "gopkg.in/src-d/go-git.v4/plumbing/object" 21 "gopkg.in/src-d/go-git.v4/plumbing/transport" 22 "gopkg.in/src-d/go-git.v4/storage/memory" 23 ) 24 25 // GithubRepo represents all versions of an Oya pack stored in a git repository on Github.com. 26 type GithubRepo struct { 27 repoUris []string 28 basePath string 29 packPath string 30 importPath types.ImportPath 31 } 32 33 // AvailableVersions returns a sorted list of remotely available pack versions. 34 func (l *GithubRepo) AvailableVersions() ([]semver.Version, error) { 35 versions := make([]semver.Version, 0) 36 37 r, err := l.clone() 38 if err != nil { 39 return nil, err 40 } 41 tags, err := r.Tags() 42 if err != nil { 43 return nil, err 44 } 45 err = tags.ForEach( 46 func(t *plumbing.Reference) error { 47 n := t.Name() 48 if n.IsTag() { 49 version, ok := l.parseRef(n.Short()) 50 if ok { 51 versions = append(versions, version) 52 } 53 } 54 return nil 55 }, 56 ) 57 if err != nil { 58 return nil, err 59 } 60 semver.Sort(versions) 61 return versions, nil 62 } 63 64 func (l *GithubRepo) clone() (*git.Repository, error) { 65 var lastErr error 66 var lastUri string 67 for _, uri := range l.repoUris { 68 fs := memfs.New() 69 storer := memory.NewStorage() 70 repo, err := git.Clone(storer, fs, &git.CloneOptions{ 71 URL: uri, 72 }) 73 if err == nil { 74 return repo, nil 75 } 76 lastErr = err 77 lastUri = uri 78 79 } 80 return nil, toErrClone(lastUri, lastErr) 81 } 82 83 // LatestVersion returns the latest available pack version based on tags in the remote Github repo. 84 func (l *GithubRepo) LatestVersion() (pack.Pack, error) { 85 versions, err := l.AvailableVersions() 86 if err != nil { 87 return pack.Pack{}, err 88 } 89 if len(versions) == 0 { 90 return pack.Pack{}, ErrNoTaggedVersions{ImportPath: l.importPath} 91 } 92 latestVersion := versions[len(versions)-1] 93 return l.Version(latestVersion) 94 } 95 96 // Version returns the specified version of the pack. 97 // NOTE: It doesn't check if it's available remotely. This may change. 98 // It is used when loading Oyafiles so we probably shouldn't do it or use a different function there. 99 func (l *GithubRepo) Version(version semver.Version) (pack.Pack, error) { 100 // BUG(bilus): Check if version exists? 101 return pack.New(l, version) 102 } 103 104 // ImportPath returns the pack's import path, e.g. github.com/tooploox/oya-packs/docker. 105 func (l *GithubRepo) ImportPath() types.ImportPath { 106 return l.importPath 107 } 108 109 // InstallPath returns the local path for the specific pack version. 110 func (l *GithubRepo) InstallPath(version semver.Version, installDir string) string { 111 path := filepath.Join(installDir, l.basePath, l.packPath) 112 return fmt.Sprintf("%v@%v", path, version.String()) 113 } 114 115 func (l *GithubRepo) checkout(version semver.Version) (*object.Commit, error) { 116 r, err := l.clone() 117 if err != nil { 118 return nil, l.wrapCheckoutError(err, version) 119 } 120 tree, err := r.Worktree() 121 if err != nil { 122 return nil, l.wrapCheckoutError(err, version) 123 } 124 err = tree.Checkout(&git.CheckoutOptions{ 125 Branch: plumbing.NewTagReferenceName(l.makeRef(version)), 126 }) 127 if err != nil { 128 return nil, l.wrapCheckoutError(err, version) 129 } 130 ref, err := r.Head() 131 if err != nil { 132 return nil, l.wrapCheckoutError(err, version) 133 } 134 return r.CommitObject(ref.Hash()) 135 } 136 137 func (l *GithubRepo) wrapCheckoutError(err error, version semver.Version) error { 138 return errors.Wrap(err, 139 ErrCheckout{ 140 ImportPath: l.importPath, 141 ImportVersion: version, 142 }, 143 ) 144 } 145 146 // Install downloads & copies the specified version of the path to the output directory, 147 // preserving its import path. 148 // For example, for /home/bilus/.oya output directory and import path github.com/bilus/foo, 149 // the pack will be extracted to /home/bilus/.oya/github.com/bilus/foo. 150 func (l *GithubRepo) Install(version semver.Version, installDir string) error { 151 commit, err := l.checkout(version) 152 if err != nil { 153 return err 154 } 155 156 fIter, err := commit.Files() 157 if err != nil { 158 return err 159 } 160 161 sourceBasePath := l.packPath 162 targetPath := l.InstallPath(version, installDir) 163 log.Printf("Installing pack %v version %v into %q (git tag: %v)", l.ImportPath(), version, targetPath, l.makeRef(version)) 164 165 return fIter.ForEach(func(f *object.File) error { 166 if outside, err := l.isOutsidePack(f.Name); outside || err != nil { 167 return err // May be nil if outside true. 168 } 169 relPath, err := filepath.Rel(sourceBasePath, f.Name) 170 if err != nil { 171 return err 172 } 173 targetPath := filepath.Join(targetPath, relPath) 174 return copyFile(f, targetPath) 175 }) 176 } 177 178 func (l *GithubRepo) IsInstalled(version semver.Version, installDir string) (bool, error) { 179 fullPath := l.InstallPath(version, installDir) 180 _, err := os.Stat(fullPath) 181 if err != nil { 182 if os.IsNotExist(err) { 183 return false, nil 184 } 185 return false, err 186 } 187 return true, nil 188 } 189 190 func (l *GithubRepo) Reqs(version semver.Version) ([]pack.Pack, error) { 191 // BUG(bilus): This is a slow way to get requirements for a pack. 192 // It involves installing it out to a local directory. 193 // But it's also the simplest one. We can optimize by using HTTP 194 // access to pull in Oyafile and then parse the Require: section here. 195 // It means duplicating the logic including the assumption that the requires 196 // will always be stored in Oyafile, rather than a separate file along the lines 197 // of go.mod. 198 199 tempDir, err := ioutil.TempDir("", "oya") 200 defer os.RemoveAll(tempDir) 201 202 err = l.Install(version, tempDir) 203 if err != nil { 204 return nil, err 205 } 206 207 fullPath := l.InstallPath(version, tempDir) 208 o, found, err := oyafile.LoadFromDir(fullPath, fullPath) 209 if err != nil { 210 return nil, err 211 } 212 if !found { 213 return nil, ErrNoRootOyafile{l.importPath, version} 214 } 215 216 // BUG(bilus): This doesn't take Oyafile#Replacements into account. 217 // This probably doesn't matter because it's likely meaningless for 218 // packs accessed remotely but we may want to revisit it. 219 220 packs := make([]pack.Pack, len(o.Requires)) 221 for i, require := range o.Requires { 222 repo, err := Open(require.ImportPath) 223 if err != nil { 224 return nil, err 225 } 226 pack, err := repo.Version(require.Version) 227 if err != nil { 228 return nil, err 229 } 230 packs[i] = pack 231 } 232 233 return packs, nil 234 } 235 236 func copyFile(f *object.File, targetPath string) error { 237 err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm) 238 if err != nil { 239 return err 240 } 241 reader, err := f.Reader() 242 if err != nil { 243 return err 244 } 245 // BUG(bilus): Copy permissions. 246 writer, err := os.OpenFile(targetPath, os.O_RDWR|os.O_CREATE, 0666) 247 if err != nil { 248 return err 249 } 250 _, err = io.Copy(writer, reader) 251 if err != nil { 252 return err 253 } 254 err = writer.Sync() 255 if err != nil { 256 return err 257 } 258 mode, err := f.Mode.ToOSFileMode() 259 if err != nil { 260 return err 261 } 262 err = os.Chmod(targetPath, mode) 263 if err != nil { 264 return err 265 } 266 return err 267 } 268 269 func parseImportPath(importPath types.ImportPath) (uris []string, basePath string, packPath string, err error) { 270 parts := strings.Split(string(importPath), "/") 271 if len(parts) < 3 { 272 return nil, "", "", ErrNotGithub{ImportPath: importPath} 273 } 274 basePath = strings.Join(parts[0:3], "/") 275 packPath = strings.Join(parts[3:], "/") 276 // Prefer https but fall back on ssh if cannot clone via https 277 // as would be the case for private repositories. 278 uris = []string{ 279 fmt.Sprintf("https://%v.git", basePath), 280 fmt.Sprintf("git@%s:%s/%s.git", parts[0], parts[1], parts[2]), 281 } 282 return 283 } 284 285 func (l *GithubRepo) parseRef(tag string) (semver.Version, bool) { 286 if len(l.packPath) > 0 && strings.HasPrefix(tag, l.packPath) { 287 tag = tag[len(l.packPath)+1:] // e.g. "pack1/v1.0.0" => v1.0.0 288 } 289 version, err := semver.Parse(tag) 290 return version, err == nil 291 } 292 293 func (l *GithubRepo) makeRef(version semver.Version) string { 294 if len(l.packPath) > 0 { 295 return fmt.Sprintf("%v/%v", l.packPath, version.String()) 296 297 } else { 298 return fmt.Sprintf("%v", version.String()) 299 } 300 } 301 302 func (l *GithubRepo) isOutsidePack(relPath string) (bool, error) { 303 r, err := filepath.Rel(l.packPath, relPath) 304 if err != nil { 305 return false, err 306 } 307 return strings.Contains(r, ".."), nil 308 } 309 310 func toErrClone(url string, err error) error { 311 if err == transport.ErrAuthenticationRequired { 312 return errors.Wrap( 313 errors.Errorf("Repository not found or private"), 314 ErrClone{RepoUrl: url}) 315 } 316 return errors.Wrap(err, ErrClone{RepoUrl: url}) 317 }