396 lines
11 KiB
Go
396 lines
11 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(dir, 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).
|
|
if dir == "" {
|
|
info.Size = file.Size()
|
|
} else {
|
|
size, err := getPluginSize(dir)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "getting plugin dir %s size", dir)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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(dir, 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. If no version is supplied, the latest
|
|
// plugin for that given kind/name pair is loaded, using standard semver sorting rules.
|
|
func GetPluginPath(kind PluginKind, name string, version *semver.Version) (string, string, error) {
|
|
// If we have a version, check the plugin cache first.
|
|
if version != nil {
|
|
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 {
|
|
if version == nil {
|
|
// If no version filter was specified, pick the most recent version. But we must also keep going
|
|
// because we could later on find a version that is even more recent and should take precedence.
|
|
if match == nil || match.Version == nil ||
|
|
(plugin.Version != nil && (*match).Version.LT(*plugin.Version)) {
|
|
match = &plugin
|
|
}
|
|
} else if plugin.Version != nil && (*version).EQ(*plugin.Version) {
|
|
// If there's a specific version being sought, and we found it, we're done.
|
|
match = &plugin
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if match != nil {
|
|
matchDir, err := match.DirPath()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
matchPath, err := match.FilePath()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
glog.V(9).Infof("GetPluginPath(%s, %s, %v): found in cache at %s", kind, name, version, matchPath)
|
|
return matchDir, matchPath, nil
|
|
}
|
|
}
|
|
|
|
// If we don't have a version (or we do, but it wasn't in the cache), then fall back to the version on the $PATH.
|
|
// This supports development scenarios where we want to make it easy to override.
|
|
filename := (&PluginInfo{Kind: kind, Name: name, Version: version}).FilePrefix()
|
|
if path, err := exec.LookPath(filename); err == nil {
|
|
glog.V(9).Infof("GetPluginPath(%s, %s, %v): found on path %s", kind, name, version, path)
|
|
return "", path, 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
|
|
}
|