// Copyright 2016-2017, Pulumi Corporation. All rights reserved. package workspace import ( "archive/tar" "compress/gzip" "fmt" "io" "io/ioutil" "os" "os/exec" "path/filepath" "regexp" "runtime" "time" "github.com/blang/semver" "github.com/djherbis/times" "github.com/golang/glog" "github.com/mitchellh/go-homedir" "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/--/`. A plugin may contain multiple files, // however the primary loadable executable must be named `pulumi--`. type PluginInfo struct { Name string // the simple name of the plugin. 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) } // 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) { home, err := homedir.Dir() if err != nil { return "", err } return filepath.Join(home, 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 { tinfo := times.Get(file) size, err := getPluginSize(filepath.Join(dir, file.Name())) if err != nil { return nil, err } plugins = append(plugins, PluginInfo{ Name: name, Kind: kind, Version: &version, Size: size, InstallTime: tinfo.BirthTime(), LastUsedTime: tinfo.AccessTime(), }) } } 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, error) { // First look on the path; first, for a version-specific plugin, and then for a version-agnostic one. This // supports development scenarios where we want to make it easy to override the central location. if version != nil { filename := (&PluginInfo{Kind: kind, Name: name, Version: version}).File() if path, err := exec.LookPath(filename); err == nil { glog.V(9).Infof("GetPluginPath(%s, %s, %v): found on path %s w/ version", kind, name, version, path) return path, nil } } filename := (&PluginInfo{Kind: kind, Name: name}).File() if path, err := exec.LookPath(filename); err == nil { glog.V(9).Infof("GetPluginPath(%s, %s, %v): found on path %s w/out version", kind, name, version, path) return path, nil } // If nothing was found on the path, fall back to 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 { 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 { return "", nil } 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 matchPath, nil } // pluginRegexp matches plugin filenames: pulumi-KIND-NAME-VERSION[.exe]. var pluginRegexp = regexp.MustCompile( "^(?P[a-z]+)-" + // KIND "(?P[a-zA-Z0-9-]*[a-zA-Z0-9])-" + // NAME "v(?P[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 }