| OLD | NEW |
| (Empty) | |
| 1 // Copyright 2017 The LUCI Authors. All rights reserved. |
| 2 // Use of this source code is governed under the Apache License, Version 2.0 |
| 3 // that can be found in the LICENSE file. |
| 4 |
| 5 package venv |
| 6 |
| 7 import ( |
| 8 "archive/zip" |
| 9 "crypto/sha256" |
| 10 "encoding/hex" |
| 11 "hash" |
| 12 "io" |
| 13 "net/http" |
| 14 "os" |
| 15 "path/filepath" |
| 16 "testing" |
| 17 "time" |
| 18 |
| 19 "github.com/danjacques/gofslock/fslock" |
| 20 "golang.org/x/net/context" |
| 21 |
| 22 "github.com/luci/luci-go/cipd/client/cipd" |
| 23 "github.com/luci/luci-go/common/errors" |
| 24 "github.com/luci/luci-go/common/system/filesystem" |
| 25 "github.com/luci/luci-go/common/testing/testfs" |
| 26 "github.com/luci/luci-go/hardcoded/chromeinfra" |
| 27 "github.com/luci/luci-go/vpython/api/vpython" |
| 28 "github.com/luci/luci-go/vpython/python" |
| 29 "github.com/luci/luci-go/vpython/wheel" |
| 30 ) |
| 31 |
| 32 const testDataDir = "test_data" |
| 33 |
| 34 // remoteFiles is the set of remote files to acquire. |
| 35 var remoteFiles = []struct { |
| 36 // install installs this remote file into the test environment. |
| 37 install func(te *testingLoader, path string) |
| 38 |
| 39 // name is the name of the file. |
| 40 name string |
| 41 // contentHash is the SHA256 has of the content. |
| 42 contentHash string |
| 43 |
| 44 // cipdPackage, if not empty, is the name of the CIPD package that conta
ins |
| 45 // this file. |
| 46 cipdPackage string |
| 47 // cipdVersion is the version string of the CIPD package. |
| 48 cipdVersion string |
| 49 |
| 50 // urls, if not empty, is a set of remote URLs where this file can be |
| 51 // downloaded from. |
| 52 urls []string |
| 53 }{ |
| 54 { |
| 55 install: func(tl *testingLoader, path string) { tl.virtualEn
vZIP = path }, |
| 56 name: "virtualenv-15.1.0.zip", |
| 57 contentHash: "f7682a57c98a10d32474b4c1df75478dea9a0802c140335c02
69a6ec3af46201", |
| 58 cipdPackage: "infra/test-data/vpython/virtualenv", |
| 59 cipdVersion: "version:15.1.0", |
| 60 urls: []string{ |
| 61 "https://github.com/pypa/virtualenv/archive/15.1.0.zip", |
| 62 }, |
| 63 }, |
| 64 } |
| 65 |
| 66 // testingLoader is a map of a CIPD package name to the root directory |
| 67 // that it should be loaded from. |
| 68 type testingLoader struct { |
| 69 cacheDir string |
| 70 |
| 71 virtualEnvZIP string |
| 72 |
| 73 pantsWheelPath string |
| 74 shirtWheelPath string |
| 75 } |
| 76 |
| 77 // loadTestEnvironment sets up the test environment for the VirtualEnv tests. |
| 78 // |
| 79 // This environment includes the acquisition and construction of binary data |
| 80 // that will be used to perform the VirtualEnv test suite, namely: |
| 81 // |
| 82 // - Building test wheel files from source. |
| 83 // - Downloading the testing VirtualEnv package. |
| 84 // |
| 85 // This online setup is preferred to actually checking these binary files into |
| 86 // Git, as it offers more versatility and doesn't clutter Git with binary junk. |
| 87 // |
| 88 // To optimize repeated test re-executions, withTestEnvironment will also cache |
| 89 // the downloaded artifacts in a cache directory. All artifacts will be verified |
| 90 // by their SHA256 hashes, which will be baked into the source here. |
| 91 func loadTestEnvironment(ctx context.Context, t *testing.T) (*testingLoader, err
or) { |
| 92 wd, err := os.Getwd() |
| 93 if err != nil { |
| 94 return nil, errors.Annotate(err).Reason("failed to get working d
irectory").Err() |
| 95 } |
| 96 |
| 97 cacheDir := filepath.Join(wd, ".venv_test_cache") |
| 98 if err := filesystem.MakeDirs(cacheDir); err != nil { |
| 99 return nil, errors.Annotate(err).Reason("failed to create cache
dir").Err() |
| 100 } |
| 101 |
| 102 tl := testingLoader{ |
| 103 cacheDir: cacheDir, |
| 104 } |
| 105 return &tl, tl.withCacheLock(t, func() error { |
| 106 return tl.ensureRemoteFilesLocked(ctx, t) |
| 107 }) |
| 108 } |
| 109 |
| 110 func (tl *testingLoader) withCacheLock(t *testing.T, fn func() error) error { |
| 111 lockPath := filepath.Join(tl.cacheDir, ".lock") |
| 112 blocker := func() error { |
| 113 t.Logf("Cache [%s] is currently locked; sleeping...", lockPath) |
| 114 time.Sleep(1 * time.Second) |
| 115 return nil |
| 116 } |
| 117 return fslock.WithBlocking(lockPath, blocker, func() error { |
| 118 return fn() |
| 119 }) |
| 120 } |
| 121 |
| 122 func (tl *testingLoader) ensureWheels(ctx context.Context, t *testing.T, py *pyt
hon.Interpreter, tdir string) error { |
| 123 var err error |
| 124 if tl.pantsWheelPath, err = tl.buildWheelLocked(t, py, "pants-1.2-py2.py
3-none-any.whl", tdir); err != nil { |
| 125 return err |
| 126 } |
| 127 if tl.shirtWheelPath, err = tl.buildWheelLocked(t, py, "shirt-3.14-py2.p
y3-none-any.whl", tdir); err != nil { |
| 128 return err |
| 129 } |
| 130 return nil |
| 131 } |
| 132 |
| 133 func (tl *testingLoader) Resolve(c context.Context, root string, packages []*vpy
thon.Spec_Package) error { |
| 134 for _, pkg := range packages { |
| 135 pkg.Version = "resolved" |
| 136 } |
| 137 return nil |
| 138 } |
| 139 |
| 140 func (tl *testingLoader) Ensure(c context.Context, root string, packages []*vpyt
hon.Spec_Package) error { |
| 141 for _, pkg := range packages { |
| 142 if err := tl.installPackage(pkg.Path, root); err != nil { |
| 143 return err |
| 144 } |
| 145 } |
| 146 return nil |
| 147 } |
| 148 |
| 149 func (tl *testingLoader) installPackage(name, root string) error { |
| 150 switch name { |
| 151 case "foo/bar/virtualenv": |
| 152 return unzip(tl.virtualEnvZIP, root) |
| 153 case "foo/bar/shirt": |
| 154 return copyFileIntoDir(tl.shirtWheelPath, root) |
| 155 case "foo/bar/pants": |
| 156 return copyFileIntoDir(tl.pantsWheelPath, root) |
| 157 |
| 158 default: |
| 159 return errors.Reason("don't know how to install %(package)q"). |
| 160 D("package", name). |
| 161 Err() |
| 162 } |
| 163 } |
| 164 |
| 165 func (tl *testingLoader) buildWheelLocked(t *testing.T, py *python.Interpreter,
name, outDir string) (string, error) { |
| 166 w, err := wheel.ParseName(name) |
| 167 if err != nil { |
| 168 return "", errors.Annotate(err).Reason("failed to parse wheel na
me %(name)q"). |
| 169 D("name", name). |
| 170 Err() |
| 171 } |
| 172 |
| 173 outWheelPath := filepath.Join(outDir, w.String()) |
| 174 switch _, err := os.Stat(outWheelPath); { |
| 175 case err == nil: |
| 176 t.Logf("Using cached wheel for %q: %s", name, outWheelPath) |
| 177 return outWheelPath, nil |
| 178 |
| 179 case os.IsNotExist(err): |
| 180 // Will build a new wheel. |
| 181 break |
| 182 |
| 183 default: |
| 184 return "", errors.Annotate(err).Reason("failed to stat wheel pat
h [%(path)s]"). |
| 185 D("path", outWheelPath). |
| 186 Err() |
| 187 } |
| 188 |
| 189 srcDir := filepath.Join(testDataDir, w.Distribution+".src") |
| 190 |
| 191 // Create a bootstrap wheel-generating VirtualEnv! |
| 192 cfg := Config{ |
| 193 MaxHashLen: 1, // Only going to be 1 enviroment. |
| 194 BaseDir: filepath.Join(outDir, "wheel-vpython"), |
| 195 Python: py.Python, |
| 196 Package: vpython.Spec_Package{ |
| 197 Path: "foo/bar/virtualenv", |
| 198 Version: "whatever", |
| 199 }, |
| 200 Loader: tl, |
| 201 Spec: &vpython.Spec{}, |
| 202 |
| 203 // Testing parameters for this bootstrap wheel-building environm
ent. |
| 204 testPreserveInstallationCapability: true, |
| 205 testLeaveReadWrite: true, |
| 206 } |
| 207 |
| 208 return outWheelPath, With(context.Background(), cfg, true, func(ctx cont
ext.Context, env *Env) error { |
| 209 cmd := env.InterpreterCommand() |
| 210 cmd.WorkDir = srcDir |
| 211 if err := cmd.Run(context.Background(), "setup.py", "bdist_wheel
", "--dist-dir", outDir); err != nil { |
| 212 return errors.Annotate(err).Reason("failed to build whee
l").Err() |
| 213 } |
| 214 |
| 215 if _, err := os.Stat(outWheelPath); err != nil { |
| 216 return errors.Annotate(err).Reason("failed to generate w
heel").Err() |
| 217 } |
| 218 |
| 219 t.Logf("Generated wheel file %q: %s", name, outWheelPath) |
| 220 return nil |
| 221 }) |
| 222 } |
| 223 |
| 224 func (tl *testingLoader) ensureRemoteFilesLocked(ctx context.Context, t *testing
.T) error { |
| 225 MainLoop: |
| 226 for _, rf := range remoteFiles { |
| 227 cachePath := filepath.Join(tl.cacheDir, rf.name) |
| 228 |
| 229 // Check if the remote file is already cached. |
| 230 err := getCachedFileLocked(t, cachePath, rf.contentHash) |
| 231 if err == nil { |
| 232 t.Logf("Remote file [%s] is already cached: [%s]", rf.na
me, cachePath) |
| 233 rf.install(tl, cachePath) |
| 234 continue MainLoop |
| 235 } |
| 236 t.Logf("Remote file [%s] is not cached: %s", rf.name, err) |
| 237 |
| 238 // Download from CIPD. |
| 239 if rf.cipdPackage != "" { |
| 240 err := cacheFromCIPDLocked(ctx, t, cachePath, rf.name, r
f.contentHash, rf.cipdPackage, rf.cipdVersion) |
| 241 if err == nil { |
| 242 t.Logf("Cached remote file [%s] from CIPD source
: [%s]", rf.name, cachePath) |
| 243 rf.install(tl, cachePath) |
| 244 continue MainLoop |
| 245 } |
| 246 t.Logf("Failed to load from CIPD package %q @%q: %s", rf
.cipdPackage, rf.cipdVersion, err) |
| 247 } |
| 248 |
| 249 // Download from URL. |
| 250 for _, url := range rf.urls { |
| 251 err := cacheFromURLLocked(t, cachePath, rf.contentHash,
url) |
| 252 if err == nil { |
| 253 t.Logf("Cached remote file [%s] from URL [%s]: [
%s]", rf.name, url, cachePath) |
| 254 rf.install(tl, cachePath) |
| 255 continue MainLoop |
| 256 } |
| 257 t.Logf("Failed to load from URL %q: %s", url, err) |
| 258 } |
| 259 |
| 260 return errors.Reason("failed to acquire remote file %(name)q"). |
| 261 D("name", rf.name). |
| 262 Err() |
| 263 } |
| 264 |
| 265 return nil |
| 266 } |
| 267 |
| 268 func getCachedFileLocked(t *testing.T, cachePath, hash string) error { |
| 269 return validateHash(t, cachePath, hash, true) |
| 270 } |
| 271 |
| 272 func validateHash(t *testing.T, path, hash string, deleteIfInvalid bool) error { |
| 273 fd, err := os.Open(path) |
| 274 if err != nil { |
| 275 return errors.Annotate(err).Reason("failed to open file").Err() |
| 276 } |
| 277 defer fd.Close() |
| 278 |
| 279 h := sha256.New() |
| 280 if _, err := io.Copy(h, fd); err != nil { |
| 281 return errors.Annotate(err).Reason("failed to hash file").Err() |
| 282 } |
| 283 |
| 284 if err := hashesEqual(h, hash); err != nil { |
| 285 t.Logf("File [%s] has invalid hash: %s", path, err) |
| 286 |
| 287 if deleteIfInvalid { |
| 288 if err := os.Remove(path); err != nil { |
| 289 t.Logf("Failed to delete invalid hash file [%s]:
%s", path, err) |
| 290 } |
| 291 } |
| 292 return err |
| 293 } |
| 294 |
| 295 return nil |
| 296 } |
| 297 |
| 298 func hashesEqual(h hash.Hash, expected string) error { |
| 299 if v := hex.EncodeToString(h.Sum(nil)); v != expected { |
| 300 return errors.Reason("hash %(actual)q doesn't match expected %(e
xpected)q"). |
| 301 D("actual", v). |
| 302 D("expected", expected). |
| 303 Err() |
| 304 } |
| 305 return nil |
| 306 } |
| 307 |
| 308 var testCIPDClientOptions = cipd.ClientOptions{ |
| 309 ServiceURL: chromeinfra.CIPDServiceURL, |
| 310 UserAgent: "vpython venv tests", |
| 311 } |
| 312 |
| 313 func cacheFromCIPDLocked(ctx context.Context, t *testing.T, cachePath, name, has
h, pkg, version string) error { |
| 314 return testfs.WithTempDir(t, "vpython_venv_cipd", func(tdir string) erro
r { |
| 315 opts := testCIPDClientOptions |
| 316 opts.Root = tdir |
| 317 |
| 318 client, err := cipd.NewClient(opts) |
| 319 if err != nil { |
| 320 return errors.Annotate(err).Reason("failed to create CIP
D client").Err() |
| 321 } |
| 322 |
| 323 pin, err := client.ResolveVersion(ctx, pkg, version) |
| 324 if err != nil { |
| 325 return errors.Annotate(err).Reason("failed to resolve CI
PD version for %(pkg)s @%(version)s"). |
| 326 D("pkg", pkg). |
| 327 D("version", version). |
| 328 Err() |
| 329 } |
| 330 |
| 331 if err := client.FetchAndDeployInstance(ctx, "", pin); err != ni
l { |
| 332 return errors.Annotate(err).Reason("failed to fetch/depl
oy CIPD package").Err() |
| 333 } |
| 334 |
| 335 path := filepath.Join(opts.Root, name) |
| 336 if err := validateHash(t, path, hash, false); err != nil { |
| 337 // Do not export the invalid path. |
| 338 return err |
| 339 } |
| 340 |
| 341 if err := copyFile(path, cachePath, nil); err != nil { |
| 342 return errors.Annotate(err).Reason("failed to install CI
PD package file").Err() |
| 343 } |
| 344 |
| 345 return nil |
| 346 }) |
| 347 } |
| 348 |
| 349 func cacheFromURLLocked(t *testing.T, cachePath, hash, url string) (err error) { |
| 350 resp, err := http.Get(url) |
| 351 if err != nil { |
| 352 t.Logf("Failed to GET file from URL [%s]: %s", url, err) |
| 353 } |
| 354 defer resp.Body.Close() |
| 355 |
| 356 fd, err := os.Create(cachePath) |
| 357 if err != nil { |
| 358 t.Logf("Failed to create output file [%s]: %s", cachePath, err) |
| 359 } |
| 360 defer func() { |
| 361 if closeErr := fd.Close(); closeErr != nil && err == nil { |
| 362 err = errors.Annotate(closeErr).Reason("failed to close
file").Err() |
| 363 } |
| 364 }() |
| 365 |
| 366 h := sha256.New() |
| 367 tr := io.TeeReader(resp.Body, h) |
| 368 if _, err := io.Copy(fd, tr); err != nil { |
| 369 return errors.Annotate(err).Reason("failed to download").Err() |
| 370 } |
| 371 |
| 372 if err = hashesEqual(h, hash); err != nil { |
| 373 return |
| 374 } |
| 375 return nil |
| 376 } |
| 377 |
| 378 func unzip(src, dst string) error { |
| 379 fd, err := zip.OpenReader(src) |
| 380 if err != nil { |
| 381 return errors.Annotate(err).Reason("failed to open ZIP reader").
Err() |
| 382 } |
| 383 defer fd.Close() |
| 384 |
| 385 for _, f := range fd.File { |
| 386 path := filepath.Join(dst, filepath.FromSlash(f.Name)) |
| 387 fi := f.FileInfo() |
| 388 |
| 389 // Unzip this entry. |
| 390 if fi.IsDir() { |
| 391 if err := os.MkdirAll(path, 0755); err != nil { |
| 392 return errors.Annotate(err).Reason("failed to mk
dir").Err() |
| 393 } |
| 394 } else { |
| 395 if err := copyFileOpener(f.Open, path, fi); err != nil { |
| 396 return err |
| 397 } |
| 398 } |
| 399 } |
| 400 return nil |
| 401 } |
| 402 |
| 403 func copyFileIntoDir(src, dstDir string) error { |
| 404 return copyFile(src, filepath.Join(dstDir, filepath.Base(src)), nil) |
| 405 } |
| 406 |
| 407 func copyFile(src, dst string, fi os.FileInfo) error { |
| 408 opener := func() (io.ReadCloser, error) { return os.Open(src) } |
| 409 return copyFileOpener(opener, dst, fi) |
| 410 } |
| 411 |
| 412 func copyFileOpener(opener func() (io.ReadCloser, error), dst string, fi os.File
Info) (err error) { |
| 413 sfd, err := opener() |
| 414 if err != nil { |
| 415 return errors.Annotate(err).Reason("failed to open source").Err(
) |
| 416 } |
| 417 defer sfd.Close() |
| 418 |
| 419 dfd, err := os.Create(dst) |
| 420 if err != nil { |
| 421 return errors.Annotate(err).Reason("failed to create destination
").Err() |
| 422 } |
| 423 defer func() { |
| 424 if closeErr := dfd.Close(); closeErr != nil && err == nil { |
| 425 err = errors.Annotate(closeErr).Reason("failed to close
destination").Err() |
| 426 } |
| 427 }() |
| 428 |
| 429 if _, err := io.Copy(dfd, sfd); err != nil { |
| 430 return errors.Annotate(err).Reason("failed to copy file").Err() |
| 431 } |
| 432 if fi != nil { |
| 433 if err := os.Chmod(dst, fi.Mode()); err != nil { |
| 434 return errors.Annotate(err).Reason("failed to chmod").Er
r() |
| 435 } |
| 436 } |
| 437 return nil |
| 438 } |
| OLD | NEW |