// 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/--/`. 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. 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 _, cur := range plugins { // Since the value of cur changes as we iterate, we can't save a pointer to it. So let's have a local that // we can take a pointer to if this plugin is the best match yet. plugin := cur 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[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 }