When installing a plugin, previous versions of Pulumi extracted the plugin tarball to a temp directory and then renamed the temp directory to the final plugin directory. This was done to prevent concurrent installs: if a process fails to rename the temp dir because the final dir already exists, it means another process already installed the plugin. Unfortunately, on Windows the rename operation often fails due to aggressive virus scanners opening files in the temp dir. In order to provide reliable plugin installs on Windows, we now extract the tarball directly into the final directory, and use file locks to prevent concurrent installs from toppling over one another. During install, a lock file is created in the plugin cache directory with the same name as the plugin's final directory but suffixed with `.lock`. The process that obtains the lock is responsible for extracting the tarball. Before it does that, it cleans up any previous temp directories of failed installs of previous versions of Pulumi. Then it creates an empty `.partial` file next to the `.lock` file. The `.partial` file indicates an installation is in-progress. The `.partial` file is deleted when installation is complete, indicating the plugin was successfully installed. If a failure occurs during installation, the `.partial` file will remain indicating the plugin wasn't fully installed. The next time the plugin is installed, the old installation directory will be removed and replaced with a fresh install. This is the same approach Go uses for installing modules in its module cache.
261 lines
6.3 KiB
Go
261 lines
6.3 KiB
Go
// Copyright 2016-2020, Pulumi Corporation.
|
|
//
|
|
// 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.
|
|
|
|
// +build nodejs python all
|
|
|
|
package workspace
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/blang/semver"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// createTGZ creates an in-memory tarball.
|
|
func createTGZ(files map[string][]byte) ([]byte, error) {
|
|
buffer := &bytes.Buffer{}
|
|
gw := gzip.NewWriter(buffer)
|
|
writer := tar.NewWriter(gw)
|
|
|
|
for name, content := range files {
|
|
if err := writer.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Size: int64(len(content)),
|
|
Mode: 0600,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := writer.Write(content); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Close the tar and gzip writers to flush and write footers.
|
|
if err := writer.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := gw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
func prepareTestDir(t *testing.T, files map[string][]byte) (string, io.ReadCloser, PluginInfo) {
|
|
if files == nil {
|
|
files = map[string][]byte{}
|
|
}
|
|
|
|
// Add plugin binary to included files.
|
|
files["pulumi-resource-test"] = nil
|
|
|
|
tgz, err := createTGZ(files)
|
|
assert.NoError(t, err)
|
|
tarball := ioutil.NopCloser(bytes.NewReader(tgz))
|
|
|
|
dir, err := ioutil.TempDir("", "plugins-test-dir")
|
|
assert.NoError(t, err)
|
|
|
|
v1 := semver.MustParse("0.1.0")
|
|
plugin := PluginInfo{
|
|
Name: "test",
|
|
Kind: ResourcePlugin,
|
|
Version: &v1,
|
|
PluginDir: dir,
|
|
}
|
|
|
|
return dir, tarball, plugin
|
|
}
|
|
|
|
func assertPluginInstalled(t *testing.T, dir string, plugin PluginInfo) {
|
|
info, err := os.Stat(filepath.Join(dir, plugin.Dir()))
|
|
assert.NoError(t, err)
|
|
assert.True(t, info.IsDir())
|
|
|
|
info, err = os.Stat(filepath.Join(dir, plugin.Dir(), plugin.File()))
|
|
assert.NoError(t, err)
|
|
assert.False(t, info.IsDir())
|
|
|
|
info, err = os.Stat(filepath.Join(dir, plugin.Dir()+".partial"))
|
|
assert.Error(t, err)
|
|
assert.True(t, os.IsNotExist(err))
|
|
|
|
assert.True(t, HasPlugin(plugin))
|
|
|
|
has, err := HasPluginGTE(plugin)
|
|
assert.NoError(t, err)
|
|
assert.True(t, has)
|
|
|
|
plugins, err := getPlugins(dir)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, len(plugins))
|
|
assert.Equal(t, plugin.Name, plugins[0].Name)
|
|
assert.Equal(t, plugin.Kind, plugins[0].Kind)
|
|
assert.Equal(t, *plugin.Version, *plugins[0].Version)
|
|
}
|
|
|
|
func testDeletePlugin(t *testing.T, dir string, plugin PluginInfo) {
|
|
err := plugin.Delete()
|
|
assert.NoError(t, err)
|
|
|
|
paths := []string{
|
|
filepath.Join(dir, plugin.Dir()),
|
|
filepath.Join(dir, plugin.Dir()+".partial"),
|
|
filepath.Join(dir, plugin.Dir()+".lock"),
|
|
}
|
|
for _, path := range paths {
|
|
_, err := os.Stat(path)
|
|
assert.Error(t, err)
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
}
|
|
|
|
func testPluginInstall(t *testing.T, expectedDir string, files map[string][]byte) {
|
|
// Skip during short test runs since this test involves downloading dependencies.
|
|
if testing.Short() {
|
|
t.Skip("Skipped in short test run")
|
|
}
|
|
|
|
dir, tarball, plugin := prepareTestDir(t, files)
|
|
defer os.RemoveAll(dir)
|
|
|
|
err := plugin.Install(tarball)
|
|
assert.NoError(t, err)
|
|
|
|
assertPluginInstalled(t, dir, plugin)
|
|
|
|
info, err := os.Stat(filepath.Join(dir, plugin.Dir(), expectedDir))
|
|
assert.NoError(t, err)
|
|
assert.True(t, info.IsDir())
|
|
|
|
testDeletePlugin(t, dir, plugin)
|
|
}
|
|
|
|
func TestInstallNoDeps(t *testing.T) {
|
|
name := "foo.txt"
|
|
content := []byte("hello\n")
|
|
|
|
dir, tarball, plugin := prepareTestDir(t, map[string][]byte{name: content})
|
|
defer os.RemoveAll(dir)
|
|
|
|
err := plugin.Install(tarball)
|
|
assert.NoError(t, err)
|
|
|
|
assertPluginInstalled(t, dir, plugin)
|
|
|
|
b, err := ioutil.ReadFile(filepath.Join(dir, plugin.Dir(), name))
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, content, b)
|
|
|
|
testDeletePlugin(t, dir, plugin)
|
|
}
|
|
|
|
func TestConcurrentInstalls(t *testing.T) {
|
|
name := "foo.txt"
|
|
content := []byte("hello\n")
|
|
|
|
dir, tarball, plugin := prepareTestDir(t, map[string][]byte{name: content})
|
|
defer os.RemoveAll(dir)
|
|
|
|
assertSuccess := func() {
|
|
assertPluginInstalled(t, dir, plugin)
|
|
|
|
b, err := ioutil.ReadFile(filepath.Join(dir, plugin.Dir(), name))
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, content, b)
|
|
}
|
|
|
|
// Run several installs concurrently.
|
|
const iterations = 12
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < iterations; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
err := plugin.Install(tarball)
|
|
assert.NoError(t, err)
|
|
|
|
assertSuccess()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
assertSuccess()
|
|
|
|
testDeletePlugin(t, dir, plugin)
|
|
}
|
|
|
|
func TestInstallCleansOldFiles(t *testing.T) {
|
|
dir, tarball, plugin := prepareTestDir(t, nil)
|
|
defer os.RemoveAll(dir)
|
|
|
|
// Leftover temp dirs.
|
|
tempDir1, err := ioutil.TempDir(dir, fmt.Sprintf("%s.tmp", plugin.Dir()))
|
|
assert.NoError(t, err)
|
|
tempDir2, err := ioutil.TempDir(dir, fmt.Sprintf("%s.tmp", plugin.Dir()))
|
|
assert.NoError(t, err)
|
|
tempDir3, err := ioutil.TempDir(dir, fmt.Sprintf("%s.tmp", plugin.Dir()))
|
|
assert.NoError(t, err)
|
|
|
|
// Leftover partial file.
|
|
partialPath := filepath.Join(dir, plugin.Dir()+".partial")
|
|
err = ioutil.WriteFile(partialPath, nil, 0600)
|
|
assert.NoError(t, err)
|
|
|
|
err = plugin.Install(tarball)
|
|
assert.NoError(t, err)
|
|
|
|
assertPluginInstalled(t, dir, plugin)
|
|
|
|
// Verify leftover files were removed.
|
|
for _, path := range []string{tempDir1, tempDir2, tempDir3, partialPath} {
|
|
_, err := os.Stat(path)
|
|
assert.Error(t, err)
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
|
|
testDeletePlugin(t, dir, plugin)
|
|
}
|
|
|
|
func TestGetPluginsSkipsPartial(t *testing.T) {
|
|
dir, tarball, plugin := prepareTestDir(t, nil)
|
|
defer os.RemoveAll(dir)
|
|
|
|
err := plugin.Install(tarball)
|
|
assert.NoError(t, err)
|
|
|
|
err = ioutil.WriteFile(filepath.Join(dir, plugin.Dir()+".partial"), nil, 0600)
|
|
assert.NoError(t, err)
|
|
|
|
assert.False(t, HasPlugin(plugin))
|
|
|
|
has, err := HasPluginGTE(plugin)
|
|
assert.Error(t, err)
|
|
assert.False(t, has)
|
|
|
|
plugins, err := getPlugins(dir)
|
|
assert.Equal(t, 0, len(plugins))
|
|
}
|