// Copyright 2016-2018, Pulumi Corporation. All rights reserved. package cmd import ( "fmt" "os" "os/user" "path" "path/filepath" "sort" "strings" "github.com/pkg/errors" survey "gopkg.in/AlecAivazis/survey.v1" surveycore "gopkg.in/AlecAivazis/survey.v1/core" git "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" "github.com/pulumi/pulumi/pkg/backend" "github.com/pulumi/pulumi/pkg/backend/cloud" "github.com/pulumi/pulumi/pkg/backend/local" "github.com/pulumi/pulumi/pkg/backend/state" "github.com/pulumi/pulumi/pkg/diag/colors" "github.com/pulumi/pulumi/pkg/resource/config" "github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/util/cmdutil" "github.com/pulumi/pulumi/pkg/util/contract" "github.com/pulumi/pulumi/pkg/util/fsutil" "github.com/pulumi/pulumi/pkg/workspace" ) func currentBackend() (backend.Backend, error) { creds, err := workspace.GetStoredCredentials() if err != nil { return nil, err } if local.IsLocalBackendURL(creds.Current) { return local.New(cmdutil.Diag()), nil } return cloud.Login(cmdutil.Diag(), creds.Current) } func isLoggedIn() (bool, error) { creds, err := workspace.GetStoredCredentials() if err != nil { return false, err } if creds.Current == "" { return false, nil } else if local.IsLocalBackendURL(creds.Current) { return true, nil } return cloud.IsValidAccessToken(creds.Current, creds.AccessTokens[creds.Current]) } // createStack creates a stack with the given name, and selects it as the current. func createStack(b backend.Backend, stackName tokens.QName, opts interface{}) (backend.Stack, error) { if stackName == "" { return nil, errors.New("missing stack name") } stack, err := b.CreateStack(stackName, opts) if err != nil { return nil, errors.Wrapf(err, "could not create stack") } if err = state.SetCurrentStack(stackName); err != nil { return nil, err } return stack, nil } // requireStack will require that a stack exists. If stackName is blank, the currently selected stack from // the workspace is returned. If no stack with either the given name, or a currently selected stack, exists, // and we are in an interactive terminal, the user will be prompted to create a new stack. func requireStack(stackName tokens.QName, offerNew bool) (backend.Stack, error) { if stackName == "" { return requireCurrentStack(offerNew) } // Search all known backends for this stack. b, err := currentBackend() if err != nil { return nil, err } stack, err := state.Stack(stackName, b) if err != nil { return nil, err } if stack != nil { return stack, err } // No stack was found. If we're in a terminal, prompt to create one. if cmdutil.Interactive() { fmt.Printf("The stack '%s' does not exist.\n", stackName) fmt.Printf("\n") _, err = cmdutil.ReadConsole("If you would like to create this stack now, please press , otherwise " + "press ^C") if err != nil { return nil, err } return createStack(b, stackName, nil) } return nil, errors.Errorf("no stack named '%s' found; double check that you're logged in", stackName) } func requireCurrentStack(offerNew bool) (backend.Stack, error) { // Search for the current stack. b, err := currentBackend() if err != nil { return nil, err } stack, err := state.CurrentStack(b) if err != nil { return nil, err } else if stack != nil { return stack, nil } // If no current stack exists, and we are interactive, prompt to select or create one. return chooseStack(b, offerNew) } // chooseStack will prompt the user to choose amongst the full set of stacks in the given backends. If offerNew is // true, then the option to create an entirely new stack is provided and will create one as desired. func chooseStack(b backend.Backend, offerNew bool) (backend.Stack, error) { // Prepare our error in case we need to issue it. Bail early if we're not interactive. var chooseStackErr string if offerNew { chooseStackErr = "no stack selected; please use `pulumi stack select` or `pulumi stack init` to choose one" } else { chooseStackErr = "no stack selected; please use `pulumi stack select` to choose one" } if !cmdutil.Interactive() { return nil, errors.New(chooseStackErr) } // First create a list and map of stack names. var options []string stacks := make(map[string]backend.Stack) allStacks, err := b.ListStacks() if err != nil { return nil, errors.Wrapf(err, "could not query backend for stacks") } for _, stack := range allStacks { name := string(stack.Name()) options = append(options, name) stacks[name] = stack } sort.Strings(options) // If we are offering to create a new stack, add that to the end of the list. newOption := "" if offerNew { options = append(options, newOption) } else if len(options) == 0 { // If no options are available, we can't offer a choice! return nil, errors.New("this command requires a stack, but there are none; are you logged in?") } // If a stack is already selected, make that the default. var current string currStack, currErr := state.CurrentStack(b) contract.IgnoreError(currErr) if currStack != nil { current = string(currStack.Name()) } // Customize the prompt a little bit (and disable color since it doesn't match our scheme). surveycore.DisableColor = true surveycore.QuestionIcon = "" surveycore.SelectFocusIcon = colors.ColorizeText(colors.BrightGreen + ">" + colors.Reset) message := "\rPlease choose a stack" if offerNew { message += ", or create a new one:" } else { message += ":" } message = colors.ColorizeText(colors.BrightWhite + message + colors.Reset) var option string if err := survey.AskOne(&survey.Select{ Message: message, Options: options, Default: current, }, &option, nil); err != nil { return nil, errors.New(chooseStackErr) } if option == newOption { stackName, err := cmdutil.ReadConsole("Please enter your desired stack name") if err != nil { return nil, err } return createStack(b, tokens.QName(stackName), nil) } return stacks[option], nil } func detectOwnerAndName(dir string) (string, string, error) { owner, repo, err := getGitHubProjectForOrigin(dir) if err == nil { return owner, repo, nil } user, err := user.Current() if err != nil { return "", "", err } return user.Username, filepath.Base(dir), nil } // getGitRepository returns the git repository by walking up from the provided directory. // If no repository is found, will return (nil, nil). func getGitRepository(dir string) (*git.Repository, error) { gitRoot, err := fsutil.WalkUp(dir, func(s string) bool { return filepath.Base(s) == ".git" }, nil) if err != nil { return nil, errors.Wrapf(err, "searching for git repository from %v", dir) } if gitRoot == "" { return nil, nil } // Open the git repo in the .git folder's parent, not the .git folder itself. repo, err := git.PlainOpen(path.Join(gitRoot, "..")) if err == git.ErrRepositoryNotExists { return nil, nil } if err != nil { return nil, errors.Wrap(err, "reading git repository") } return repo, nil } func getGitHubProjectForOrigin(dir string) (string, string, error) { repo, err := getGitRepository(dir) if repo == nil { return "", "", fmt.Errorf("no git repository found from %v", dir) } if err != nil { return "", "", err } return getGitHubProjectForOriginByRepo(repo) } // Returns the GitHub login, and GitHub repo name if the "origin" remote is // a GitHub URL. func getGitHubProjectForOriginByRepo(repo *git.Repository) (string, string, error) { remote, err := repo.Remote("origin") if err != nil { return "", "", errors.Wrap(err, "could not read origin information") } remoteURL := "" if len(remote.Config().URLs) > 0 { remoteURL = remote.Config().URLs[0] } project := "" const GitHubSSHPrefix = "git@github.com:" const GitHubHTTPSPrefix = "https://github.com/" const GitHubRepositorySuffix = ".git" if strings.HasPrefix(remoteURL, GitHubSSHPrefix) { project = trimGitRemoteURL(remoteURL, GitHubSSHPrefix, GitHubRepositorySuffix) } else if strings.HasPrefix(remoteURL, GitHubHTTPSPrefix) { project = trimGitRemoteURL(remoteURL, GitHubHTTPSPrefix, GitHubRepositorySuffix) } split := strings.Split(project, "/") if len(split) != 2 { return "", "", errors.Errorf("could not detect GitHub project from url: %v", remote) } return split[0], split[1], nil } func trimGitRemoteURL(url string, prefix string, suffix string) string { return strings.TrimSuffix(strings.TrimPrefix(url, prefix), suffix) } // readProject attempts to detect and read the project for the current workspace. If an error occurs, it will be // printed to Stderr, and the returned value will be nil. If the project is successfully detected and read, it // is returned along with the path to its containing directory, which will be used as the root of the project's // Pulumi program. func readProject() (*workspace.Project, string, error) { pwd, err := os.Getwd() if err != nil { return nil, "", err } // Now that we got here, we have a path, so we will try to load it. path, err := workspace.DetectProjectPathFrom(pwd) if err != nil { return nil, "", errors.Wrapf(err, "could not locate Pulumi.yaml project file (searching upwards from %s)", pwd) } else if path == "" { return nil, "", errors.Errorf( "no Pulumi.yaml project file found (searching upwards from %s)", pwd) } proj, err := workspace.LoadProject(path) if err != nil { return nil, "", err } return proj, filepath.Dir(path), nil } type colorFlag struct { value colors.Colorization } func (cf *colorFlag) String() string { return string(cf.Colorization()) } func (cf *colorFlag) Set(value string) error { switch value { case "always": cf.value = colors.Always case "never": cf.value = colors.Never case "raw": cf.value = colors.Raw // Backwards compat for old flag values. case "auto": cf.value = colors.Always default: return errors.Errorf("unsupported color option: '%s'. Supported values are: always, never, raw", value) } return nil } func (cf *colorFlag) Type() string { return "colors.Colorization" } func (cf *colorFlag) Colorization() colors.Colorization { if cf.value == "" { return colors.Always } return cf.value } // getUpdateMetadata returns an UpdateMetadata object, with optional data about the environment // performing the update. func getUpdateMetadata(msg, root string) (backend.UpdateMetadata, error) { m := backend.UpdateMetadata{ Message: msg, Environment: make(map[string]string), } // Gather git-related data as appropriate. (Returns nil, nil if no repo found.) repo, err := getGitRepository(root) if err != nil { return m, errors.Wrap(err, "looking for git repository") } if repo != nil && err == nil { const gitErrCtx = "reading git repo (%v)" // Message passed with wrapped errors from go-git. // Commit at HEAD. head, err := repo.Head() if err == nil { m.Environment[backend.GitHead] = head.Hash().String() } else { // Ignore "reference not found" in the odd case where the HEAD commit doesn't exist. // (git init, but no commits yet.) if err != plumbing.ErrReferenceNotFound { return m, errors.Wrapf(err, gitErrCtx, "getting head") } } // If the current commit is dirty. w, err := repo.Worktree() if err != nil { return m, errors.Wrapf(err, gitErrCtx, "getting worktree") } s, err := w.Status() if err != nil { return m, errors.Wrapf(err, gitErrCtx, "getting worktree status") } dirty := !s.IsClean() m.Environment[backend.GitDirty] = fmt.Sprint(dirty) // GitHub repo slug if applicable. We don't require GitHub, so swallow errors. ghLogin, ghRepo, err := getGitHubProjectForOriginByRepo(repo) if err == nil { m.Environment[backend.GitHubLogin] = ghLogin m.Environment[backend.GitHubRepo] = ghRepo } } return m, nil } // upgradeConfigurationFiles does an upgrade to move from the old configuration system (where we had config stored) in // workspace settings and Pulumi.yaml, to the new system where configuration data is stored in Pulumi..yaml func upgradeConfigurationFiles() error { // If there's no workspace, don't even try to upgrade. w, err := workspace.New() if err != nil { return nil } // If we can't detect a project, also don't try to upgrade. proj, err := workspace.DetectProject() if err != nil { return nil } // If the project does not have any workspace or project level configuration (this will be true for new projects // and for any projects that have been upgraded), we can bail out early, as there is nothing to do. if len(w.Settings().ConfigDeprecated) == 0 && len(proj.ConfigDeprecated) == 0 { return nil } // If we aren't logged in, also don't try. We ignore errors here, and just assume we aren't logged in. if hasLogin, loginErr := isLoggedIn(); !hasLogin { contract.IgnoreError(loginErr) return nil } b, err := currentBackend() if err != nil { return err } stacks, err := b.ListStacks() if err != nil { return err } for _, stack := range stacks { stackName := stack.Name() // If the new file exists, we can skip upgrading this stack. newFile, err := workspace.DetectProjectStackPath(stackName) if err != nil { return errors.Wrap(err, "upgrade project") } _, err = os.Stat(newFile) if err != nil && !os.IsNotExist(err) { return errors.Wrap(err, "upgrading project") } else if err == nil { // new file was present, skip upgrading this stack. continue } cfg, salt, err := getOldConfiguration(stackName) if err != nil { return errors.Wrap(err, "upgrading project") } ps, err := workspace.DetectProjectStack(stackName) if err != nil { return errors.Wrap(err, "upgrading project") } ps.Config = cfg ps.EncryptionSalt = salt if err := workspace.SaveProjectStack(stackName, ps); err != nil { return errors.Wrap(err, "upgrading project") } if err := removeOldConfiguration(stackName); err != nil { return errors.Wrap(err, "upgrading project") } } return removeOldProjectConfiguration() } // getOldConfiguration reads the configuration for a given stack from the current workspace. It applies a hierarchy // of configuration settings based on stack overrides and workspace-wide global settings. If any of the workspace // settings had an impact on the values returned, the second return value will be true. It also returns the encryption // salt used for the stack. func getOldConfiguration(stackName tokens.QName) (config.Map, string, error) { contract.Require(stackName != "", "stackName") // Get the workspace and package and get ready to merge their views of the configuration. ws, err := workspace.New() if err != nil { return nil, "", err } proj, err := workspace.DetectProject() if err != nil { return nil, "", err } // We need to apply workspace and project configuration values in the right order. Basically, we want to // end up taking the most specific settings, where per-stack configuration is more specific than global, and // project configuration is more specific than workspace. result := make(config.Map) // First, apply project-local stack-specific configuration. if stack, has := proj.StacksDeprecated[stackName]; has { for key, value := range stack.Config { result[key] = value } } // Now, apply workspace stack-specific configuration. if wsStackConfig, has := ws.Settings().ConfigDeprecated[stackName]; has { for key, value := range wsStackConfig { if _, has := result[key]; !has { result[key] = value } } } // Next, take anything from the global settings in our project file. for key, value := range proj.ConfigDeprecated { if _, has := result[key]; !has { result[key] = value } } // Finally, take anything left in the workspace's global configuration. if wsGlobalConfig, has := ws.Settings().ConfigDeprecated[""]; has { for key, value := range wsGlobalConfig { if _, has := result[key]; !has { result[key] = value } } } // Now, get the encryption key. A stack specific one overrides the global one (global encryption keys were // deprecated previously) encryptionSalt := proj.EncryptionSaltDeprecated if stack, has := proj.StacksDeprecated[stackName]; has && stack.EncryptionSalt != "" { encryptionSalt = stack.EncryptionSalt } return result, encryptionSalt, nil } // removeOldConfiguration deletes all configuration information about a stack from both the workspace // and the project file. It does not touch the newly added Pulumi..yaml file. func removeOldConfiguration(stackName tokens.QName) error { ws, err := workspace.New() if err != nil { return err } proj, err := workspace.DetectProject() if err != nil { return err } delete(proj.StacksDeprecated, stackName) delete(ws.Settings().ConfigDeprecated, stackName) if err := ws.Save(); err != nil { return err } return workspace.SaveProject(proj) } // removeOldProjectConfiguration deletes all project level configuration information from both the workspace and the // project file. func removeOldProjectConfiguration() error { ws, err := workspace.New() if err != nil { return err } proj, err := workspace.DetectProject() if err != nil { return err } proj.EncryptionSaltDeprecated = "" proj.ConfigDeprecated = nil ws.Settings().ConfigDeprecated = nil if err := ws.Save(); err != nil { return err } return workspace.SaveProject(proj) }