pulumi/cmd/new.go

818 lines
23 KiB
Go

// Copyright 2016-2018, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"context"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"unicode"
"github.com/pulumi/pulumi/pkg/apitype"
"github.com/pulumi/pulumi/pkg/backend"
"github.com/pulumi/pulumi/pkg/backend/display"
"github.com/pulumi/pulumi/pkg/backend/httpstate"
"github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/workspace"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/diag/colors"
"github.com/pulumi/pulumi/pkg/util/cmdutil"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pulumi/pulumi/pkg/util/logging"
"github.com/spf13/cobra"
survey "gopkg.in/AlecAivazis/survey.v1"
surveycore "gopkg.in/AlecAivazis/survey.v1/core"
)
// nolint: vetshadow, intentionally disabling here for cleaner err declaration/assignment.
func newNewCmd() *cobra.Command {
var configArray []string
var name string
var description string
var stack string
var force bool
var yes bool
var offline bool
var generateOnly bool
var dir string
cmd := &cobra.Command{
Use: "new [template]",
SuggestFor: []string{"init", "create"},
Short: "Create a new Pulumi project",
Args: cmdutil.MaximumNArgs(1),
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
interactive := cmdutil.Interactive()
if !interactive {
yes = true // auto-approve changes, since we cannot prompt.
}
// Prepare options.
opts, err := updateFlagsToOptions(interactive, false /*skipPreview*/, yes)
if err != nil {
return err
}
opts.Display = display.Options{
Color: cmdutil.GetGlobalColorization(),
IsInteractive: interactive,
}
opts.Engine = engine.UpdateOptions{
Parallel: defaultParallel,
}
// Validate name (if specified) before further prompts/operations.
if name != "" && !workspace.IsValidProjectName(name) {
return errors.Errorf("'%s' is not a valid project name", name)
}
// Get the current working directory.
cwd, err := os.Getwd()
if err != nil {
return errors.Wrap(err, "getting the working directory")
}
originalCwd := cwd
// If dir was specified, ensure it exists and use it as the
// current working directory.
if dir != "" {
// Ensure the directory exists.
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return errors.Wrap(err, "creating the directory")
}
// Change the working directory to the specified directory.
if err = os.Chdir(dir); err != nil {
return errors.Wrap(err, "changing the working directory")
}
// Get the new working directory.
if cwd, err = os.Getwd(); err != nil {
return errors.Wrap(err, "getting the working directory")
}
}
// Return an error if the directory isn't empty.
if !force {
if err = errorIfNotEmptyDirectory(cwd); err != nil {
return err
}
}
// If we're going to be creating a stack, get the current backend, which
// will kick off the login flow (if not already logged-in).
if !generateOnly {
if _, err = currentBackend(opts.Display); err != nil {
return err
}
}
templateNameOrURL := ""
if len(args) > 0 {
templateNameOrURL = args[0]
}
// Retrieve the template repo.
repo, err := workspace.RetrieveTemplates(templateNameOrURL, offline)
if err != nil {
return err
}
defer func() {
contract.IgnoreError(repo.Delete())
}()
// List the templates from the repo.
templates, err := repo.Templates()
if err != nil {
return err
}
var template workspace.Template
if len(templates) == 0 {
return errors.New("no templates")
} else if len(templates) == 1 {
template = templates[0]
} else {
if template, err = chooseTemplate(templates, opts.Display); err != nil {
return err
}
}
// Do a dry run, if we're not forcing files to be overwritten.
if !force {
if err = template.CopyTemplateFilesDryRun(cwd); err != nil {
if os.IsNotExist(err) {
return errors.Wrapf(err, "template '%s' not found", templateNameOrURL)
}
return err
}
}
// If a stack was specified via --stack, see if it already exists.
var s backend.Stack
if stack != "" {
existingStack, existingName, existingDesc, err := getStack(stack, opts.Display)
if err != nil {
return err
}
s = existingStack
if name == "" {
name = existingName
}
if description == "" {
description = existingDesc
}
}
// Show instructions, if we're going to show at least one prompt.
hasAtLeastOnePrompt := (name == "") || (description == "") || (stack == "")
if !yes && hasAtLeastOnePrompt {
fmt.Println("This command will walk you through creating a new Pulumi project.")
fmt.Println()
fmt.Println("Enter a value or leave blank to accept the default, and press <ENTER>.")
fmt.Println("Press ^C at any time to quit.")
}
// Prompt for the project name, if it wasn't already specified.
if name == "" {
defaultValue := workspace.ValueOrSanitizedDefaultProjectName(name, template.ProjectName, filepath.Base(cwd))
name, err = promptForValue(yes, "project name", defaultValue, false, workspace.IsValidProjectName, opts.Display)
if err != nil {
return err
}
}
// Prompt for the project description, if it wasn't already specified.
if description == "" {
defaultValue := workspace.ValueOrDefaultProjectDescription(
description, template.ProjectDescription, template.Description)
description, err = promptForValue(yes, "project description", defaultValue, false, nil, opts.Display)
if err != nil {
return err
}
}
// Actually copy the files.
if err = template.CopyTemplateFiles(cwd, force, name, description); err != nil {
if os.IsNotExist(err) {
return errors.Wrapf(err, "template '%s' not found", templateNameOrURL)
}
return err
}
fmt.Printf("Created project '%s'.\n", name)
// Load the project, update the name & description, and save it.
proj, _, err := readProject()
if err != nil {
return err
}
proj.Name = tokens.PackageName(name)
proj.Description = &description
if err = workspace.SaveProject(proj); err != nil {
return errors.Wrap(err, "saving project")
}
// Create the stack, if needed.
if !generateOnly && s == nil {
if s, err = promptAndCreateStack(stack, name, true /*setCurrent*/, yes, opts.Display); err != nil {
return err
}
// The backend will print "Created stack '<stack>'." on success.
}
// Prompt for config values (if needed) and save.
if !generateOnly {
if err = handleConfig(s, templateNameOrURL, template, configArray, yes, opts.Display); err != nil {
return err
}
}
// Install dependencies.
if !generateOnly {
if err = installDependencies("Installing dependencies..."); err != nil {
return err
}
fmt.Println(
opts.Display.Color.Colorize(
colors.BrightGreen+colors.Bold+"Your new project is configured and ready to go!"+colors.Reset) +
" " + cmdutil.EmojiOr("✨", ""))
}
// Run `up` automatically, or print out next steps to run `up` manually.
if !generateOnly {
if err = runUpOrPrintNextSteps(s, originalCwd, cwd, opts, yes); err != nil {
return err
}
}
if template.Quickstart != "" {
fmt.Println(template.Quickstart)
}
return nil
}),
}
// Add additional help that includes a list of available templates.
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
// Show default help.
defaultHelp(cmd, args)
// Attempt to retrieve available templates.
repo, err := workspace.RetrieveTemplates("", false /*offline*/)
if err != nil {
logging.Warningf("could not retrieve templates: %v", err)
return
}
// Get the list of templates.
templates, err := repo.Templates()
if err != nil {
logging.Warningf("could not list templates: %v", err)
return
}
// If we have any templates, show them.
if len(templates) > 0 {
available, _ := templatesToOptionArrayAndMap(templates)
fmt.Println("")
fmt.Println("Available Templates:")
for _, t := range available {
fmt.Printf(" %s\n", t)
}
}
})
cmd.PersistentFlags().StringArrayVarP(
&configArray, "config", "c", []string{},
"Config to save")
cmd.PersistentFlags().StringVarP(
&name, "name", "n", "",
"The project name; if not specified, a prompt will request it")
cmd.PersistentFlags().StringVarP(
&description, "description", "d", "",
"The project description; if not specified, a prompt will request it")
cmd.PersistentFlags().StringVarP(
&stack, "stack", "s", "",
"The stack name; either an existing stack or stack to create; if not specified, a prompt will request it")
cmd.PersistentFlags().BoolVarP(
&force, "force", "f", false,
"Forces content to be generated even if it would change existing files")
cmd.PersistentFlags().BoolVarP(
&yes, "yes", "y", false,
"Skip prompts and proceed with default values")
cmd.PersistentFlags().BoolVarP(
&offline, "offline", "o", false,
"Use locally cached templates without making any network requests")
cmd.PersistentFlags().BoolVarP(
&generateOnly, "generate-only", "g", false,
"Generate the project only; do not create a stack, save config, or install dependencies")
cmd.PersistentFlags().StringVar(
&dir, "dir", "",
"The location to place the generated project; if not specified, the current directory is used")
return cmd
}
// errorIfNotEmptyDirectory returns an error if path is not empty.
func errorIfNotEmptyDirectory(path string) error {
infos, err := ioutil.ReadDir(path)
if err != nil {
return err
}
if len(infos) > 0 {
return errors.Errorf("%s is not empty; "+
"rerun in an empty directory, pass the path to an empty directory to --dir, or use --force", path)
}
return nil
}
// getStack gets a stack and the project name & description, or returns nil if the stack doesn't exist.
func getStack(stack string, opts display.Options) (backend.Stack, string, string, error) {
b, err := currentBackend(opts)
if err != nil {
return nil, "", "", err
}
stackRef, err := b.ParseStackReference(stack)
if err != nil {
return nil, "", "", err
}
s, err := b.GetStack(commandContext(), stackRef)
if err != nil {
return nil, "", "", err
}
name := ""
description := ""
if s != nil {
if cs, ok := s.(httpstate.Stack); ok {
tags := cs.Tags()
name = tags[apitype.ProjectNameTag]
description = tags[apitype.ProjectDescriptionTag]
}
}
return s, name, description, nil
}
// promptAndCreateStack creates and returns a new stack (prompting for the name as needed).
func promptAndCreateStack(
stack string, projectName string, setCurrent bool, yes bool, opts display.Options) (backend.Stack, error) {
b, err := currentBackend(opts)
if err != nil {
return nil, err
}
if stack != "" {
s, err := stackInit(b, stack, setCurrent)
if err != nil {
return nil, err
}
return s, nil
}
defaultValue := getDevStackName(projectName)
for {
stackName, err := promptForValue(yes, "stack name", defaultValue, false, nil, opts)
if err != nil {
return nil, err
}
s, err := stackInit(b, stackName, setCurrent)
if err != nil {
if !yes {
// Let the user know about the error and loop around to try again.
fmt.Printf("Sorry, could not create stack '%s': %v.\n", stackName, err)
continue
}
return nil, err
}
return s, nil
}
}
// getDevStackName returns the stack name suffixed with -dev.
func getDevStackName(name string) string {
const suffix = "-dev"
// Strip the suffix so we don't include two -dev suffixes
// if the name already has it.
return strings.TrimSuffix(name, suffix) + suffix
}
// stackInit creates the stack.
func stackInit(b backend.Backend, stackName string, setCurrent bool) (backend.Stack, error) {
stackRef, err := b.ParseStackReference(stackName)
if err != nil {
return nil, err
}
return createStack(b, stackRef, nil, setCurrent)
}
// saveConfig saves the config for the stack.
func saveConfig(stackName tokens.QName, c config.Map) error {
ps, err := workspace.DetectProjectStack(stackName)
if err != nil {
return err
}
for k, v := range c {
ps.Config[k] = v
}
return workspace.SaveProjectStack(stackName, ps)
}
// installDependencies will install dependencies for the project, e.g. by running
// `npm install` for nodejs projects or `pip install` for python projects.
func installDependencies(message string) error {
proj, _, err := readProject()
if err != nil {
return err
}
// TODO[pulumi/pulumi#1307]: move to the language plugins so we don't have to hard code here.
var command string
var c *exec.Cmd
if strings.EqualFold(proj.RuntimeInfo.Name(), "nodejs") {
command = "npm install"
c = exec.Command("npm", "install") // nolint: gas, intentionally launching with partial path
} else if strings.EqualFold(proj.RuntimeInfo.Name(), "python") {
command = "pip install -r requirements.txt"
c = exec.Command("pip", "install", "-r", "requirements.txt") // nolint: gas, intentionally launching with partial path
} else {
return nil
}
if message != "" {
fmt.Println(message)
}
// Run the command.
if out, err := c.CombinedOutput(); err != nil {
fmt.Fprintf(os.Stderr, "%s", out)
return errors.Wrapf(err, "installing dependencies; rerun '%s' manually to try again", command)
}
return nil
}
// runUpOrPrintNextSteps runs `up` automatically, or if `up` shouldn't run, prints out a message with next steps.
func runUpOrPrintNextSteps(
stack backend.Stack, originalCwd string, cwd string, opts backend.UpdateOptions, yes bool) error {
proj, root, err := readProject()
if err != nil {
return err
}
// Currently go projects require a build/install step before deployment, so we won't automatically run `up` for
// such projects. Once we switch over to using `go run` for go, we can remove this and always run `up`.
runUp := !strings.EqualFold(proj.RuntimeInfo.Name(), "go")
if runUp {
m, err := getUpdateMetadata("", root)
if err != nil {
return errors.Wrap(err, "gathering environment metadata")
}
_, err = stack.Update(commandContext(), backend.UpdateOperation{
Proj: proj,
Root: root,
M: m,
Opts: opts,
Scopes: cancellationScopes,
})
switch {
case err == context.Canceled:
return errors.New("update cancelled")
case err != nil:
return PrintEngineError(err)
default:
return nil
}
} else {
// If the current working directory changed, add instructions to cd into the directory.
var deployMsg string
if originalCwd != cwd {
// If we can determine a relative path, use that, otherwise use the full path.
var cd string
if rel, err := filepath.Rel(originalCwd, cwd); err == nil {
cd = rel
} else {
cd = cwd
}
// Surround the path with double quotes if it contains whitespace.
if containsWhiteSpace(cd) {
cd = fmt.Sprintf("\"%s\"", cd)
}
cd = fmt.Sprintf("cd %s", cd)
deployMsg = "To deploy it, '" + cd + "' and then run 'pulumi up'."
deployMsg = colors.Highlight(deployMsg, cd, colors.BrightBlue+colors.Underline+colors.Bold)
} else {
deployMsg = "To deploy it, run 'pulumi up'."
}
// Colorize and print the next step deploy action.
deployMsg = colors.Highlight(deployMsg, "pulumi up", colors.BrightBlue+colors.Underline+colors.Bold)
fmt.Println(opts.Display.Color.Colorize(deployMsg))
}
return nil
}
// chooseTemplate will prompt the user to choose amongst the available templates.
func chooseTemplate(templates []workspace.Template, opts display.Options) (workspace.Template, error) {
const chooseTemplateErr = "no template selected; please use `pulumi new` to choose one"
if !cmdutil.Interactive() {
return workspace.Template{}, errors.New(chooseTemplateErr)
}
// Customize the prompt a little bit (and disable color since it doesn't match our scheme).
surveycore.DisableColor = true
surveycore.QuestionIcon = ""
surveycore.SelectFocusIcon = opts.Color.Colorize(colors.BrightGreen + ">" + colors.Reset)
message := "\rPlease choose a template:"
message = opts.Color.Colorize(colors.SpecPrompt + message + colors.Reset)
options, optionToTemplateMap := templatesToOptionArrayAndMap(templates)
var option string
if err := survey.AskOne(&survey.Select{
Message: message,
Options: options,
PageSize: len(options),
}, &option, nil); err != nil {
return workspace.Template{}, errors.New(chooseTemplateErr)
}
return optionToTemplateMap[option], nil
}
// parseConfig parses the config values passed via command line flags.
// These are passed as `-c aws:region=us-east-1 -c foo:bar=blah` and end up
// in configArray as ["aws:region=us-east-1", "foo:bar=blah"].
// This function converts the array into a config.Map.
func parseConfig(configArray []string) (config.Map, error) {
configMap := make(config.Map)
for _, c := range configArray {
kvp := strings.SplitN(c, "=", 2)
key, err := parseConfigKey(kvp[0])
if err != nil {
return nil, err
}
value := config.NewValue("")
if len(kvp) == 2 {
value = config.NewValue(kvp[1])
}
configMap[key] = value
}
return configMap, nil
}
// promptForConfig will go through each config key needed by the template and prompt for a value.
// If a config value exists in commandLineConfig, it will be used without prompting.
// If stackConfig is non-nil and a config value exists in stackConfig, it will be used as the default
// value when prompting instead of the default value specified in templateConfig.
func promptForConfig(
stack backend.Stack,
templateConfig map[string]workspace.ProjectTemplateConfigValue,
commandLineConfig config.Map,
stackConfig config.Map,
yes bool,
opts display.Options) (config.Map, error) {
// Convert `string` keys to `config.Key`. If a string key is missing a delimiter,
// the project name will be prepended.
parsedTemplateConfig := make(map[config.Key]workspace.ProjectTemplateConfigValue)
for k, v := range templateConfig {
parsedKey, parseErr := parseConfigKey(k)
if parseErr != nil {
return nil, parseErr
}
parsedTemplateConfig[parsedKey] = v
}
// Sort keys. Note that we use the fully qualified module member here instead of a `prettyKey` so that
// all config values for the current program are prompted one after another.
var keys config.KeyArray
for k := range parsedTemplateConfig {
keys = append(keys, k)
}
sort.Sort(keys)
var err error
var crypter config.Crypter
c := make(config.Map)
for _, k := range keys {
// If it was passed as a command line flag, use it without prompting.
if val, ok := commandLineConfig[k]; ok {
c[k] = val
continue
}
templateConfigValue := parsedTemplateConfig[k]
// Prepare a default value.
var defaultValue string
var secret bool
if stackConfig != nil {
// Use the stack's existing value as the default.
if val, ok := stackConfig[k]; ok {
secret = val.Secure()
// Lazily get the crypter, only if needed, to avoid prompting for a password with the local backend.
if secret && crypter == nil {
if crypter, err = backend.GetStackCrypter(stack); err != nil {
return nil, err
}
}
// It's OK to pass a nil or non-nil crypter for non-secret values.
value, err := val.Value(crypter)
if err != nil {
return nil, err
}
defaultValue = value
}
}
if defaultValue == "" {
defaultValue = templateConfigValue.Default
}
if !secret {
secret = templateConfigValue.Secret
}
// Prepare the prompt.
prompt := prettyKey(k)
if templateConfigValue.Description != "" {
prompt = prompt + ": " + templateConfigValue.Description
}
// Prompt.
value, err := promptForValue(yes, prompt, defaultValue, secret, nil, opts)
if err != nil {
return nil, err
}
// Encrypt the value if needed.
var v config.Value
if secret {
// Lazily get the crypter, only if needed, to avoid prompting for a password with the local backend.
if crypter == nil {
if crypter, err = backend.GetStackCrypter(stack); err != nil {
return nil, err
}
}
enc, err := crypter.EncryptValue(value)
if err != nil {
return nil, err
}
v = config.NewSecureValue(enc)
} else {
v = config.NewValue(value)
}
// Save it.
c[k] = v
}
// Add any other config values from the command line.
for k, v := range commandLineConfig {
if _, ok := c[k]; !ok {
c[k] = v
}
}
return c, nil
}
// promptForValue prompts the user for a value with a defaultValue preselected. Hitting enter accepts the
// default. If yes is true, defaultValue is returned without prompting. isValidFn is an optional parameter;
// when specified, it will be run to validate that value entered. An invalid value will result in an error
// message followed by another prompt for the value.
func promptForValue(
yes bool, prompt string, defaultValue string, secret bool,
isValidFn func(value string) bool, opts display.Options) (string, error) {
if yes {
return defaultValue, nil
}
for {
if defaultValue == "" {
prompt = opts.Color.Colorize(
fmt.Sprintf("%s%s:%s ", colors.BrightCyan, prompt, colors.Reset))
} else {
defaultValuePrompt := defaultValue
if secret {
defaultValuePrompt = "[secret]"
}
prompt = opts.Color.Colorize(
fmt.Sprintf("%s%s: (%s)%s ", colors.BrightCyan, prompt, defaultValuePrompt, colors.Reset))
}
fmt.Print(prompt)
// Read the value.
var err error
var value string
if secret {
value, err = cmdutil.ReadConsoleNoEcho("")
if err != nil {
return "", err
}
} else {
value, err = cmdutil.ReadConsole("")
if err != nil {
return "", err
}
}
value = strings.TrimSpace(value)
if value != "" {
if isValidFn == nil || isValidFn(value) {
return value, nil
}
// The value is invalid, let the user know and try again
fmt.Printf("Sorry, '%s' is not a valid %s.\n", value, prompt)
continue
}
return defaultValue, nil
}
}
// templatesToOptionArrayAndMap returns an array of option strings and a map of option strings to templates.
// Each option string is made up of the template name and description with some padding in between.
func templatesToOptionArrayAndMap(templates []workspace.Template) ([]string, map[string]workspace.Template) {
// Find the longest name length. Used to add padding between the name and description.
maxNameLength := 0
for _, template := range templates {
if len(template.Name) > maxNameLength {
maxNameLength = len(template.Name)
}
}
// Build the array and map.
var options []string
nameToTemplateMap := make(map[string]workspace.Template)
for _, template := range templates {
// Create the option string that combines the name, padding, and description.
option := fmt.Sprintf(fmt.Sprintf("%%%ds %%s", -maxNameLength), template.Name, template.Description)
// Add it to the array and map.
options = append(options, option)
nameToTemplateMap[option] = template
}
sort.Strings(options)
return options, nameToTemplateMap
}
// containsWhiteSpace returns true if the string contains whitespace.
func containsWhiteSpace(value string) bool {
for _, c := range value {
if unicode.IsSpace(c) {
return true
}
}
return false
}