// 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 ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strings" "github.com/pulumi/pulumi/pkg/backend" "github.com/pulumi/pulumi/pkg/backend/cloud" "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/spf13/cobra" survey "gopkg.in/AlecAivazis/survey.v1" surveycore "gopkg.in/AlecAivazis/survey.v1/core" ) const defaultURLEnvVar = "PULUMI_TEMPLATE_API" func newNewCmd() *cobra.Command { var cloudURL string var name string var description string var force bool var yes bool var offline bool var generateOnly bool var dir string cmd := &cobra.Command{ Use: "new [template]", Short: "Create a new Pulumi project", Args: cmdutil.MaximumNArgs(1), Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error { var err error // Validate name (if specified) before further prompts/operations. if name != "" && !workspace.IsValidProjectName(name) { return errors.Errorf("'%s' is not a valid project name", name) } // 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 current working directory. var cwd string if cwd, err = os.Getwd(); err != nil { return errors.Wrap(err, "getting the working directory") } releases, err := cloud.New(cmdutil.Diag(), getCloudURL(cloudURL)) if err != nil { return errors.Wrap(err, "creating API client") } // 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). var b backend.Backend if !generateOnly { b, err = currentBackend() if err != nil { return err } } // Get the selected template. var templateName string if len(args) > 0 { templateName = strings.ToLower(args[0]) } else { if templateName, err = chooseTemplate(releases, offline); err != nil { return err } } // Download and install the template to the local template cache. if !offline { var tarball io.ReadCloser source := releases.CloudURL() if tarball, err = releases.DownloadTemplate(commandContext(), templateName, false); err != nil { message := "" // If the local template is available locally, provide a nicer error message. if localTemplates, localErr := workspace.ListLocalTemplates(); localErr == nil && len(localTemplates) > 0 { _, m := templateArrayToStringArrayAndMap(localTemplates) if _, ok := m[templateName]; ok { message = fmt.Sprintf( "; rerun the command and pass --offline to use locally cached template '%s'", templateName) } } return errors.Wrapf(err, "downloading template '%s' from %s%s", templateName, source, message) } if err = workspace.InstallTemplate(templateName, tarball); err != nil { return errors.Wrapf(err, "installing template '%s' from %s", templateName, source) } } // Load the local template. var template workspace.Template if template, err = workspace.LoadLocalTemplate(templateName); err != nil { return errors.Wrapf(err, "template '%s' not found", templateName) } // 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", templateName) } return err } } // Show instructions, if we're going to show at least one prompt. hasAtLeastOnePrompt := (name == "") || (description == "") || !generateOnly 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 .") 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, filepath.Base(cwd)) name = promptForValue(yes, "project name", defaultValue, workspace.IsValidProjectName) } // Prompt for the project description, if it wasn't already specified. if description == "" { defaultValue := workspace.ValueOrDefaultProjectDescription(description, template.Description) description = promptForValue(yes, "project description", defaultValue, nil) } // 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", templateName) } return err } fmt.Printf("Created project '%s'.\n", name) // Prompt for the stack name and create the stack. var stack backend.Stack if !generateOnly { defaultValue := getDevStackName(name) for { stackName := promptForValue(yes, "stack name", defaultValue, nil) stack, err = stackInit(b, stackName) 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 err } break } // The backend will print "Created stack ''." on success. } // Prompt for config values and save. if !generateOnly { var keys config.KeyArray for k := range template.Config { keys = append(keys, k) } if len(keys) > 0 { sort.Sort(keys) c := make(config.Map) for _, k := range keys { value := promptForValue(yes, k.String(), template.Config[k], nil) c[k] = config.NewValue(value) } if err = saveConfig(stack.Name().StackName(), c); err != nil { return errors.Wrap(err, "saving config") } fmt.Println("Saved config.") } } // Install dependencies. if !generateOnly && template.InstallDependencies { fmt.Println("Installing dependencies...") err = installDependencies() if err != nil { return errors.Wrap(err, "installing dependencies") } fmt.Println("Finished installing dependencies.") // Write a summary with next steps. fmt.Println("New project is configured and ready to deploy with 'pulumi update'.") } return nil }), } cmd.PersistentFlags().StringVarP(&cloudURL, "cloud-url", "c", "", "A cloud URL to download templates from") 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().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().BoolVar( &generateOnly, "generate-only", 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 } // 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) (backend.Stack, error) { stackRef, err := b.ParseStackReference(stackName) if err != nil { return nil, err } return createStack(b, stackRef, nil) } // 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() 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 c *exec.Cmd if strings.EqualFold(proj.Runtime, "nodejs") { c = exec.Command("npm", "install") // nolint: gas, intentionally launching with partial path } else if strings.EqualFold(proj.Runtime, "python") { c = exec.Command("pip", "install", "-r", "requirements.txt") // nolint: gas, intentionally launching with partial path } else { return nil } // Run the command. c.Stdout = os.Stdout c.Stderr = os.Stderr return c.Run() } // getCloudURL returns the URL used to download the template. func getCloudURL(cloudURL string) string { // If we have a cloud URL, just return it. if cloudURL != "" { return cloudURL } // Otherwise, respect the PULUMI_TEMPLATE_API override. if fromEnv := os.Getenv(defaultURLEnvVar); fromEnv != "" { return fromEnv } // Otherwise, use the default. return cloud.DefaultURL() } // chooseTemplate will prompt the user to choose amongst the available templates. func chooseTemplate(backend cloud.Backend, offline bool) (string, error) { const chooseTemplateErr = "no template selected; please use `pulumi new` to choose one" if !cmdutil.Interactive() { return "", errors.New(chooseTemplateErr) } var templates []workspace.Template var err error if !offline { if templates, err = backend.ListTemplates(commandContext()); err != nil { message := "could not fetch list of remote templates" // If we couldn't fetch the list, see if there are any local templates if localTemplates, localErr := workspace.ListLocalTemplates(); localErr == nil && len(localTemplates) > 0 { options, _ := templateArrayToStringArrayAndMap(localTemplates) message = message + "\nrerun the command and pass --offline to use locally cached templates: " + strings.Join(options, ", ") } return "", errors.Wrap(err, message) } } else { if templates, err = workspace.ListLocalTemplates(); err != nil || len(templates) == 0 { return "", errors.Wrap(err, chooseTemplateErr) } } // 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 template:" message = colors.ColorizeText(colors.BrightWhite + message + colors.Reset) options, _ := templateArrayToStringArrayAndMap(templates) var option string if err := survey.AskOne(&survey.Select{ Message: message, Options: options, }, &option, nil); err != nil { return "", errors.New(chooseTemplateErr) } return option, 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, isValidFn func(value string) bool) string { if yes { return defaultValue } for { if defaultValue == "" { prompt = colors.ColorizeText( fmt.Sprintf("%s%s:%s ", colors.BrightCyan, prompt, colors.Reset)) } else { prompt = colors.ColorizeText( fmt.Sprintf("%s%s: (%s)%s ", colors.BrightCyan, prompt, defaultValue, colors.Reset)) } fmt.Print(prompt) reader := bufio.NewReader(os.Stdin) line, _ := reader.ReadString('\n') value := strings.TrimSpace(line) if value != "" { if isValidFn == nil || isValidFn(value) { return value } // 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 } } // templateArrayToStringArrayAndMap returns an array of template names and map of names to templates // from an array of templates. func templateArrayToStringArrayAndMap(templates []workspace.Template) ([]string, map[string]workspace.Template) { var options []string nameToTemplateMap := make(map[string]workspace.Template) for _, template := range templates { options = append(options, template.Name) nameToTemplateMap[template.Name] = template } sort.Strings(options) return options, nameToTemplateMap }