The code that calculated plugin sizes was incorrect; it would show the total size consumed by all plugins, for each plugin, which is clearly busted. We should compute each plugin's size from its own directory.
418 lines
12 KiB
Go
418 lines
12 KiB
Go
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
|
|
|
package workspace
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/blang/semver"
|
|
"github.com/djherbis/times"
|
|
"github.com/golang/glog"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/pulumi/pulumi/pkg/util/contract"
|
|
)
|
|
|
|
// PluginInfo provides basic information about a plugin. Each plugin gets installed into a system-wide
|
|
// location, by default `~/.pulumi/plugins/<kind>-<name>-<version>/`. A plugin may contain multiple files,
|
|
// however the primary loadable executable must be named `pulumi-<kind>-<name>`.
|
|
type PluginInfo struct {
|
|
Name string // the simple name of the plugin.
|
|
Path string // the path that a plugin was loaded from.
|
|
Kind PluginKind // the kind of the plugin (language, resource, etc).
|
|
Version *semver.Version // the plugin's semantic version, if present.
|
|
Size int64 // the size of the plugin, in bytes.
|
|
InstallTime time.Time // the time the plugin was installed.
|
|
LastUsedTime time.Time // the last time the plugin was used.
|
|
}
|
|
|
|
// Dir gets the expected plugin directory for this plugin.
|
|
func (info PluginInfo) Dir() string {
|
|
dir := fmt.Sprintf("%s-%s", info.Kind, info.Name)
|
|
if info.Version != nil {
|
|
dir = fmt.Sprintf("%s-v%s", dir, info.Version.String())
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// File gets the expected filename for this plugin.
|
|
func (info PluginInfo) File() string {
|
|
return info.FilePrefix() + info.FileSuffix()
|
|
}
|
|
|
|
// FilePrefix gets the expected default file prefix for the plugin.
|
|
func (info PluginInfo) FilePrefix() string {
|
|
return fmt.Sprintf("pulumi-%s-%s", info.Kind, info.Name)
|
|
}
|
|
|
|
// FileSuffix returns the suffix for the plugin (if any).
|
|
func (info PluginInfo) FileSuffix() string {
|
|
if runtime.GOOS == "windows" {
|
|
return ".exe"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DirPath returns the directory where this plugin should be installed.
|
|
func (info PluginInfo) DirPath() (string, error) {
|
|
dir, err := GetPluginDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, info.Dir()), nil
|
|
}
|
|
|
|
// FilePath returns the full path where this plugin's primary executable should be installed.
|
|
func (info PluginInfo) FilePath() (string, error) {
|
|
dir, err := info.DirPath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, info.File()), nil
|
|
}
|
|
|
|
// Delete removes the plugin from the cache. It also deletes any supporting files in the cache, which includes
|
|
// any files that contain the same prefix as the plugin itself.
|
|
func (info PluginInfo) Delete() error {
|
|
dir, err := info.DirPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.RemoveAll(dir)
|
|
}
|
|
|
|
// SetFileMetadata adds extra metadata from the given file, representing this plugin's directory.
|
|
func (info *PluginInfo) SetFileMetadata(path string) error {
|
|
// Get the file info.
|
|
file, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Next, get the size from the directory (or, if there is none, just the file).
|
|
size, err := getPluginSize(path)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "getting plugin dir %s size", path)
|
|
}
|
|
info.Size = size
|
|
|
|
// Next get the access times from the plugin binary itself.
|
|
tinfo := times.Get(file)
|
|
|
|
if tinfo.HasBirthTime() {
|
|
info.InstallTime = tinfo.BirthTime()
|
|
}
|
|
|
|
info.LastUsedTime = tinfo.AccessTime()
|
|
return nil
|
|
}
|
|
|
|
// Install installs a plugin's tarball into the cache. It validates that plugin names are in the expected format.
|
|
func (info PluginInfo) Install(tarball io.ReadCloser) error {
|
|
// Fetch the directory into which we will expand this tarball, and create it.
|
|
pluginDir, err := info.DirPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = os.MkdirAll(pluginDir, 0700); err != nil {
|
|
return errors.Wrapf(err, "creating plugin directory %s", pluginDir)
|
|
}
|
|
|
|
// Unzip and untar the file as we go.
|
|
defer contract.IgnoreClose(tarball)
|
|
gzr, err := gzip.NewReader(tarball)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "unzipping")
|
|
}
|
|
r := tar.NewReader(gzr)
|
|
for {
|
|
header, err := r.Next()
|
|
if err == io.EOF {
|
|
break
|
|
} else if err != nil {
|
|
return errors.Wrapf(err, "untarring")
|
|
}
|
|
|
|
path := filepath.Join(pluginDir, header.Name)
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
// Create any directories as needed.
|
|
if _, err := os.Stat(path); err != nil {
|
|
if err = os.MkdirAll(path, 0700); err != nil {
|
|
return errors.Wrapf(err, "untarring dir %s", path)
|
|
}
|
|
}
|
|
case tar.TypeReg:
|
|
// Expand files into the target directory.
|
|
dst, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return errors.Wrapf(err, "opening file %s for untar", path)
|
|
}
|
|
defer contract.IgnoreClose(dst)
|
|
if _, err = io.Copy(dst, r); err != nil {
|
|
return errors.Wrapf(err, "untarring file %s", path)
|
|
}
|
|
default:
|
|
return errors.Errorf("unexpected plugin file type %s (%v)", header.Name, header.Typeflag)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (info PluginInfo) String() string {
|
|
var version string
|
|
if v := info.Version; v != nil {
|
|
version = fmt.Sprintf("-%s", v)
|
|
}
|
|
return info.Name + version
|
|
}
|
|
|
|
// PluginKind represents a kind of a plugin that may be dynamically loaded and used by Pulumi.
|
|
type PluginKind string
|
|
|
|
const (
|
|
// AnalyzerPlugin is a plugin that can be used as a resource analyzer.
|
|
AnalyzerPlugin PluginKind = "analyzer"
|
|
// LanguagePlugin is a plugin that can be used as a language host.
|
|
LanguagePlugin PluginKind = "language"
|
|
// ResourcePlugin is a plugin that can be used as a resource provider for custom CRUD operations.
|
|
ResourcePlugin PluginKind = "resource"
|
|
)
|
|
|
|
// IsPluginKind returns true if k is a valid plugin kind, and false otherwise.
|
|
func IsPluginKind(k string) bool {
|
|
switch PluginKind(k) {
|
|
case AnalyzerPlugin, LanguagePlugin, ResourcePlugin:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// HasPlugin returns true if the given plugin exists.
|
|
func HasPlugin(plug PluginInfo) bool {
|
|
dir, err := plug.DirPath()
|
|
if err == nil {
|
|
_, err := os.Stat(dir)
|
|
if err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasPluginGTE returns true if the given plugin exists at the given version number or greater.
|
|
func HasPluginGTE(plug PluginInfo) (bool, error) {
|
|
// If an exact match, return true right away.
|
|
if HasPlugin(plug) {
|
|
return true, nil
|
|
}
|
|
|
|
// Otherwise, load up the list of plugins and find one with the same name/type and >= version.
|
|
plugs, err := GetPlugins()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, p := range plugs {
|
|
if p.Name == plug.Name &&
|
|
p.Kind == plug.Kind &&
|
|
(p.Version != nil && plug.Version != nil && p.Version.GTE(*plug.Version)) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// GetPluginDir returns the directory in which plugins on the current machine are managed.
|
|
func GetPluginDir() (string, error) {
|
|
u, err := user.Current()
|
|
if u == nil || err != nil {
|
|
return "", errors.Wrapf(err, "getting user home directory")
|
|
}
|
|
return filepath.Join(u.HomeDir, BookkeepingDir, PluginDir), nil
|
|
}
|
|
|
|
// GetPlugins returns a list of installed plugins.
|
|
func GetPlugins() ([]PluginInfo, error) {
|
|
// To get the list of plugins, simply scan the directory in the usual place.
|
|
dir, err := GetPluginDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files, err := ioutil.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Now read the file infos and create the plugin infos.
|
|
var plugins []PluginInfo
|
|
for _, file := range files {
|
|
// Skip anything that doesn't look like a plugin.
|
|
if kind, name, version, ok := tryPlugin(file); ok {
|
|
plugin := PluginInfo{
|
|
Name: name,
|
|
Kind: kind,
|
|
Version: &version,
|
|
}
|
|
if err = plugin.SetFileMetadata(filepath.Join(dir, file.Name())); err != nil {
|
|
return nil, err
|
|
}
|
|
plugins = append(plugins, plugin)
|
|
}
|
|
}
|
|
return plugins, nil
|
|
}
|
|
|
|
// GetPluginPath finds a plugin's path by its kind, name, and optional version. It will match the latest version that
|
|
// is >= the version specified. If no version is supplied, the latest plugin for that given kind/name pair is loaded,
|
|
// using standard semver sorting rules. A plugin may be overridden entirely by placing it on your $PATH.
|
|
func GetPluginPath(kind PluginKind, name string, version *semver.Version) (string, string, error) {
|
|
// If we have a version of the plugin on its $PATH, use it. This supports development scenarios.
|
|
filename := (&PluginInfo{Kind: kind, Name: name, Version: version}).FilePrefix()
|
|
if path, err := exec.LookPath(filename); err == nil {
|
|
glog.V(6).Infof("GetPluginPath(%s, %s, %v): found on $PATH %s", kind, name, version, path)
|
|
return "", path, nil
|
|
}
|
|
|
|
// Otherwise, check the plugin cache.
|
|
plugins, err := GetPlugins()
|
|
if err != nil {
|
|
return "", "", errors.Wrapf(err, "loading plugin list")
|
|
}
|
|
var match *PluginInfo
|
|
for _, plugin := range plugins {
|
|
if plugin.Kind == kind && plugin.Name == name {
|
|
// Always pick the most recent version of the plugin available. Even if this is an exact match, we
|
|
// keep on searching just in case there's a newer version available.
|
|
var m *PluginInfo
|
|
if match == nil && version == nil {
|
|
m = &plugin // no existing match, no version spec, take it.
|
|
} else if match != nil &&
|
|
(match.Version == nil || (plugin.Version != nil && plugin.Version.GT(*match.Version))) {
|
|
m = &plugin // existing match, but this plugin is newer, prefer it.
|
|
} else if version != nil && plugin.Version != nil && plugin.Version.GTE(*version) {
|
|
m = &plugin // this plugin is >= the version being requested, use it.
|
|
}
|
|
|
|
if m != nil {
|
|
match = m
|
|
glog.V(6).Infof("GetPluginPath(%s, %s, %s): found candidate (#%s)",
|
|
kind, name, *version, match.Version)
|
|
}
|
|
}
|
|
}
|
|
|
|
if match != nil {
|
|
matchDir, err := match.DirPath()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
matchPath, err := match.FilePath()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
glog.V(6).Infof("GetPluginPath(%s, %s, %v): found in cache at %s", kind, name, version, matchPath)
|
|
return matchDir, matchPath, nil
|
|
}
|
|
|
|
return "", "", nil
|
|
}
|
|
|
|
// pluginRegexp matches plugin filenames: pulumi-KIND-NAME-VERSION[.exe].
|
|
var pluginRegexp = regexp.MustCompile(
|
|
"^(?P<Kind>[a-z]+)-" + // KIND
|
|
"(?P<Name>[a-zA-Z0-9-]*[a-zA-Z0-9])-" + // NAME
|
|
"v(?P<Version>[0-9]+.[0-9]+.[0-9]+(-[a-zA-Z0-9-_.]+)?)$") // VERSION
|
|
|
|
// tryPlugin returns true if a file is a plugin, and extracts information about it.
|
|
func tryPlugin(file os.FileInfo) (PluginKind, string, semver.Version, bool) {
|
|
// Only directories contain plugins.
|
|
if !file.IsDir() {
|
|
glog.V(11).Infof("skipping file in plugin directory: %s", file.Name())
|
|
return "", "", semver.Version{}, false
|
|
}
|
|
|
|
// Filenames must match the plugin regexp.
|
|
match := pluginRegexp.FindStringSubmatch(file.Name())
|
|
if len(match) != len(pluginRegexp.SubexpNames()) {
|
|
glog.V(11).Infof("skipping plugin %s with missing capture groups: expect=%d, actual=%d",
|
|
file.Name(), len(pluginRegexp.SubexpNames()), len(match))
|
|
return "", "", semver.Version{}, false
|
|
}
|
|
var kind PluginKind
|
|
var name string
|
|
var version *semver.Version
|
|
for i, group := range pluginRegexp.SubexpNames() {
|
|
v := match[i]
|
|
switch group {
|
|
case "Kind":
|
|
// Skip invalid kinds.
|
|
if IsPluginKind(v) {
|
|
kind = PluginKind(v)
|
|
} else {
|
|
glog.V(11).Infof("skipping invalid plugin kind: %s", v)
|
|
}
|
|
case "Name":
|
|
name = v
|
|
case "Version":
|
|
// Skip invalid versions.
|
|
ver, err := semver.ParseTolerant(v)
|
|
if err == nil {
|
|
version = &ver
|
|
} else {
|
|
glog.V(11).Infof("skipping invalid plugin version: %s", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If anything was missing or invalid, skip this plugin.
|
|
if kind == "" || name == "" || version == nil {
|
|
glog.V(11).Infof("skipping plugin with missing information: kind=%s, name=%s, version=%v",
|
|
kind, name, version)
|
|
return "", "", semver.Version{}, false
|
|
}
|
|
|
|
return kind, name, *version, true
|
|
}
|
|
|
|
// getPluginSize recursively computes how much space is devoted to a given plugin.
|
|
func getPluginSize(dir string) (int64, error) {
|
|
files, err := ioutil.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
size := int64(0)
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
sub, err := getPluginSize(filepath.Join(dir, file.Name()))
|
|
if err != nil {
|
|
return size, err
|
|
}
|
|
size += sub
|
|
} else {
|
|
size += file.Size()
|
|
}
|
|
}
|
|
return size, nil
|
|
}
|