blob: 5562d9489e50e43d0efecc06c29f921ea6f4ea5d [file] [log] [blame]
// Copyright 2017 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package venv
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"hash"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/danjacques/gofslock/fslock"
"go.chromium.org/luci/cipd/client/cipd"
"go.chromium.org/luci/cipd/common"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/system/environ"
"go.chromium.org/luci/common/system/filesystem"
"go.chromium.org/luci/hardcoded/chromeinfra"
"go.chromium.org/luci/vpython/api/vpython"
"go.chromium.org/luci/vpython/python"
"go.chromium.org/luci/vpython/wheel"
)
const testDataDir = "test_data"
// remoteFiles is the set of remote files to acquire.
var remoteFiles = []struct {
// install installs this remote file into the test environment.
install func(te *testingLoader, path string)
// name is the name of the file.
name string
// contentHash is the SHA256 has of the content.
contentHash string
// cipdPackage, if not empty, is the name of the CIPD package that contains
// this file.
cipdPackage string
// cipdVersion is the version string of the CIPD package.
cipdVersion string
// urls, if not empty, is a set of remote URLs where this file can be
// downloaded from.
urls []string
}{
{
install: func(tl *testingLoader, path string) { tl.virtualEnvZIP = path },
name: "virtualenv-15.1.0.zip",
contentHash: "f7682a57c98a10d32474b4c1df75478dea9a0802c140335c0269a6ec3af46201",
cipdPackage: "infra/test-data/vpython/virtualenv",
cipdVersion: "version:15.1.0",
urls: []string{
"https://github.com/pypa/virtualenv/archive/15.1.0.zip",
},
},
}
// testingLoader is a map of a CIPD package name to the root directory
// that it should be loaded from.
type testingLoader struct {
PackageLoader
cacheDir string
virtualEnvZIP string
pantsWheelPath string
shirtWheelPath string
}
// loadTestEnvironment sets up the test environment for the VirtualEnv tests.
//
// This environment includes the acquisition and construction of binary data
// that will be used to perform the VirtualEnv test suite, namely:
//
// - Building test wheel files from source.
// - Downloading the testing VirtualEnv package.
//
// This online setup is preferred to actually checking these binary files into
// Git, as it offers more versatility and doesn't clutter Git with binary junk.
//
// To optimize repeated test re-executions, withTestEnvironment will also cache
// the downloaded artifacts in a cache directory. All artifacts will be verified
// by their SHA256 hashes, which will be baked into the source here.
func loadTestEnvironment(ctx context.Context, t *testing.T) (*testingLoader, error) {
wd, err := os.Getwd()
if err != nil {
return nil, errors.Annotate(err, "failed to get working directory").Err()
}
cacheDir := filepath.Join(wd, ".venv_test_cache")
if err := filesystem.MakeDirs(cacheDir); err != nil {
return nil, errors.Annotate(err, "failed to create cache dir").Err()
}
tl := testingLoader{
cacheDir: cacheDir,
}
return &tl, tl.withCacheLock(t, func() error {
return tl.ensureRemoteFilesLocked(ctx, t)
})
}
func (tl *testingLoader) withCacheLock(t *testing.T, fn func() error) error {
lockPath := filepath.Join(tl.cacheDir, ".lock")
blocker := func() error {
t.Logf("Cache [%s] is currently locked; sleeping...", lockPath)
time.Sleep(1 * time.Second)
return nil
}
return fslock.WithBlocking(lockPath, blocker, func() error {
return fn()
})
}
func (tl *testingLoader) ensureWheels(ctx context.Context, t *testing.T, py *python.Interpreter, tdir string) error {
var err error
if tl.pantsWheelPath, err = tl.buildWheelLocked(t, py, "pants-1.2-py2.py3-none-any.whl", tdir); err != nil {
return err
}
if tl.shirtWheelPath, err = tl.buildWheelLocked(t, py, "shirt-3.14-py2.py3-none-any.whl", tdir); err != nil {
return err
}
return nil
}
func (tl *testingLoader) Resolve(c context.Context, e *vpython.Environment) error {
e.Spec.Virtualenv.Version = "resolved"
for _, wheel := range e.Spec.Wheel {
wheel.Version = "resolved"
}
return nil
}
func (tl *testingLoader) Ensure(c context.Context, root string, packages []*vpython.Spec_Package) error {
for _, pkg := range packages {
if err := tl.installPackage(pkg.Name, root); err != nil {
return err
}
}
return nil
}
func (tl *testingLoader) installPackage(name, root string) error {
switch name {
case "foo/bar/virtualenv":
return unzip(tl.virtualEnvZIP, root)
case "foo/bar/shirt":
return copyFileIntoDir(tl.shirtWheelPath, root)
case "foo/bar/pants":
return copyFileIntoDir(tl.pantsWheelPath, root)
default:
return errors.Reason("don't know how to install %q", name).Err()
}
}
func (tl *testingLoader) buildWheelLocked(t *testing.T, py *python.Interpreter, name, outDir string) (string, error) {
ctx := context.Background()
w, err := wheel.ParseName(name)
if err != nil {
return "", errors.Annotate(err, "failed to parse wheel name %q", name).Err()
}
outWheelPath := filepath.Join(outDir, w.String())
switch _, err := os.Stat(outWheelPath); {
case err == nil:
t.Logf("Using cached wheel for %q: %s", name, outWheelPath)
return outWheelPath, nil
case os.IsNotExist(err):
// Will build a new wheel.
break
default:
return "", errors.Annotate(err, "failed to stat wheel path [%s]", outWheelPath).Err()
}
srcDir := filepath.Join(testDataDir, w.Distribution+".src")
// Create a bootstrap wheel-generating VirtualEnv!
cfg := Config{
MaxHashLen: 1, // Only going to be 1 environment.
BaseDir: filepath.Join(outDir, ".env"),
SetupEnv: environ.System(),
UnversionedPython: []string{py.Python},
Package: vpython.Spec_Package{
Name: "foo/bar/virtualenv",
Version: "whatever",
},
Loader: tl,
Spec: &vpython.Spec{},
// Testing parameters for this bootstrap wheel-building environment.
testLeaveReadWrite: true,
}
// Build the wheel in a temporary directory, then copy it into outDir. This
// will stop wheel builds from stepping on each other or inheriting each
// others' state accidentally.
tdir, err := ioutil.TempDir(t.TempDir(), "vpython_venv_wheel")
if err != nil {
return "", errors.Annotate(err, "failed to create tempdir").Err()
}
buildDir := filepath.Join(tdir, "build")
if err := filesystem.MakeDirs(buildDir); err != nil {
return "", err
}
distDir := filepath.Join(tdir, "dist")
if err := filesystem.MakeDirs(distDir); err != nil {
return "", err
}
// Use an empty bootstrap VirtualEnv to build the wheel. This guarantees
// that we actually have "setuptools" and "wheel" packages, which are
// required for building wheels, and not necessarily present in their
// expected forms on all systems.
err = With(ctx, cfg, func(ctx context.Context, env *Env) error {
cmd := env.Interpreter().MkIsolatedCommand(ctx,
python.ScriptTarget{Path: "setup.py"},
"--no-user-cfg",
"bdist_wheel",
"--bdist-dir", buildDir,
"--dist-dir", distDir)
defer cmd.Cleanup()
cmd.Dir = srcDir
if err := cmd.Run(); err != nil {
return errors.Annotate(err, "failed to build wheel").Err()
}
return nil
})
if err != nil {
return "", errors.Annotate(err, "failed to build wheel").Err()
}
// Assert that the expected wheel file was generated, and copy it into
// outDir.
wheelPath := filepath.Join(distDir, w.String())
if _, err := os.Stat(wheelPath); err != nil {
return "", errors.Annotate(err, "failed to generate wheel").Err()
}
if err := copyFileIntoDir(wheelPath, outDir); err != nil {
return "", errors.Annotate(err, "failed to install wheel").Err()
}
t.Logf("Generated wheel file %q: %s", name, outWheelPath)
return outWheelPath, nil
}
func (tl *testingLoader) ensureRemoteFilesLocked(ctx context.Context, t *testing.T) error {
MainLoop:
for _, rf := range remoteFiles {
cachePath := filepath.Join(tl.cacheDir, rf.name)
// Check if the remote file is already cached.
err := getCachedFileLocked(t, cachePath, rf.contentHash)
if err == nil {
t.Logf("Remote file [%s] is already cached: [%s]", rf.name, cachePath)
rf.install(tl, cachePath)
continue MainLoop
}
t.Logf("Remote file [%s] is not cached: %s", rf.name, err)
// Download from CIPD.
if rf.cipdPackage != "" {
err := cacheFromCIPDLocked(ctx, t, cachePath, rf.name, rf.contentHash, rf.cipdPackage, rf.cipdVersion)
if err == nil {
t.Logf("Cached remote file [%s] from CIPD source: [%s]", rf.name, cachePath)
rf.install(tl, cachePath)
continue MainLoop
}
t.Logf("Failed to load from CIPD package %q @%q: %s", rf.cipdPackage, rf.cipdVersion, err)
}
// Download from URL.
for _, url := range rf.urls {
err := cacheFromURLLocked(t, cachePath, rf.contentHash, url)
if err == nil {
t.Logf("Cached remote file [%s] from URL [%s]: [%s]", rf.name, url, cachePath)
rf.install(tl, cachePath)
continue MainLoop
}
t.Logf("Failed to load from URL %q: %s", url, err)
}
return errors.Reason("failed to acquire remote file %q", rf.name).Err()
}
return nil
}
func getCachedFileLocked(t *testing.T, cachePath, hash string) error {
return validateHash(t, cachePath, hash, true)
}
func validateHash(t *testing.T, path, hash string, deleteIfInvalid bool) error {
fd, err := os.Open(path)
if err != nil {
return errors.Annotate(err, "failed to open file").Err()
}
defer fd.Close()
h := sha256.New()
if _, err := io.Copy(h, fd); err != nil {
return errors.Annotate(err, "failed to hash file").Err()
}
if err := hashesEqual(h, hash); err != nil {
t.Logf("File [%s] has invalid hash: %s", path, err)
if deleteIfInvalid {
if err := os.Remove(path); err != nil {
t.Logf("Failed to delete invalid hash file [%s]: %s", path, err)
}
}
return err
}
return nil
}
func hashesEqual(h hash.Hash, expected string) error {
if v := hex.EncodeToString(h.Sum(nil)); v != expected {
return errors.Reason("hash %q doesn't match expected %q", v, expected).Err()
}
return nil
}
var testCIPDClientOptions = cipd.ClientOptions{
ServiceURL: chromeinfra.CIPDServiceURL,
UserAgent: "vpython venv tests",
}
func cacheFromCIPDLocked(ctx context.Context, t *testing.T, cachePath, name, hash, pkg, version string) error {
tdir, err := ioutil.TempDir(t.TempDir(), "vpython_venv_cipd")
if err != nil {
return errors.Annotate(err, "failed to create tempdir").Err()
}
opts := testCIPDClientOptions
opts.Root = tdir
client, err := cipd.NewClient(opts)
if err != nil {
return errors.Annotate(err, "failed to create CIPD client").Err()
}
defer client.Close(ctx)
pin, err := client.ResolveVersion(ctx, pkg, version)
if err != nil {
return errors.Annotate(err, "failed to resolve CIPD version for %s @%s", pkg, version).Err()
}
_, err = client.EnsurePackages(ctx, common.PinSliceBySubdir{"": {pin}}, nil)
if err != nil {
return errors.Annotate(err, "failed to fetch/deploy CIPD package").Err()
}
path := filepath.Join(opts.Root, name)
if err := validateHash(t, path, hash, false); err != nil {
// Do not export the invalid path.
return err
}
if err := copyFile(path, cachePath, nil); err != nil {
return errors.Annotate(err, "failed to install CIPD package file").Err()
}
return nil
}
func cacheFromURLLocked(t *testing.T, cachePath, hash, url string) (err error) {
resp, err := http.Get(url)
if err != nil {
t.Logf("Failed to GET file from URL [%s]: %s", url, err)
}
defer resp.Body.Close()
fd, err := os.Create(cachePath)
if err != nil {
t.Logf("Failed to create output file [%s]: %s", cachePath, err)
}
defer func() {
if closeErr := fd.Close(); closeErr != nil && err == nil {
err = errors.Annotate(closeErr, "failed to close file").Err()
}
}()
h := sha256.New()
tr := io.TeeReader(resp.Body, h)
if _, err := io.Copy(fd, tr); err != nil {
return errors.Annotate(err, "failed to download").Err()
}
if err = hashesEqual(h, hash); err != nil {
return
}
return nil
}
func unzip(src, dst string) error {
fd, err := zip.OpenReader(src)
if err != nil {
return errors.Annotate(err, "failed to open ZIP reader").Err()
}
defer fd.Close()
for _, f := range fd.File {
path := filepath.Join(dst, filepath.FromSlash(f.Name))
fi := f.FileInfo()
// Unzip this entry.
if fi.IsDir() {
if err := os.MkdirAll(path, 0755); err != nil {
return errors.Annotate(err, "failed to mkdir").Err()
}
} else {
if err := copyFileOpener(f.Open, path, fi); err != nil {
return err
}
}
}
return nil
}
func copyFileIntoDir(src, dstDir string) error {
return copyFile(src, filepath.Join(dstDir, filepath.Base(src)), nil)
}
func copyFile(src, dst string, fi os.FileInfo) error {
opener := func() (io.ReadCloser, error) { return os.Open(src) }
return copyFileOpener(opener, dst, fi)
}
func copyFileOpener(opener func() (io.ReadCloser, error), dst string, fi os.FileInfo) (err error) {
sfd, err := opener()
if err != nil {
return errors.Annotate(err, "failed to open source").Err()
}
defer sfd.Close()
dfd, err := os.Create(dst)
if err != nil {
return errors.Annotate(err, "failed to create destination").Err()
}
defer func() {
if closeErr := dfd.Close(); closeErr != nil && err == nil {
err = errors.Annotate(closeErr, "failed to close destination").Err()
}
}()
if _, err := io.Copy(dfd, sfd); err != nil {
return errors.Annotate(err, "failed to copy file").Err()
}
if fi != nil {
if err := os.Chmod(dst, fi.Mode()); err != nil {
return errors.Annotate(err, "failed to chmod").Err()
}
}
return nil
}