Adds a pulumi new
command to scaffold a project (#1008)
This adds a `pulumi new` command which makes it easy to quickly automatically create the handful of needed files to get started building an empty Pulumi project. Usage: ``` $ pulumi new typescript ``` Or you can leave off the template name, and it will ask you to choose one: ``` $ pulumi new Please choose a template: > javascript python typescript ```
This commit is contained in:
parent
f071329238
commit
8906731315
209
cmd/new.go
Normal file
209
cmd/new.go
Normal file
|
@ -0,0 +1,209 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/backend/cloud"
|
||||
"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 offline bool
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Get the current working directory.
|
||||
var cwd string
|
||||
if cwd, err = os.Getwd(); err != nil {
|
||||
return errors.Wrap(err, "getting the working directory")
|
||||
}
|
||||
|
||||
releases := cloud.New(cmdutil.Diag(), getCloudURL(cloudURL))
|
||||
|
||||
// Get the selected template.
|
||||
var template workspace.Template
|
||||
if len(args) > 0 {
|
||||
template = workspace.Template{Name: strings.ToLower(args[0])}
|
||||
} else {
|
||||
if template, 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(template.Name, 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[template.Name]; ok {
|
||||
message = fmt.Sprintf(
|
||||
"; rerun the command and pass --offline to use locally cached template '%s'",
|
||||
template.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Wrapf(err, "downloading template '%s' from %s%s", template.Name, source, message)
|
||||
}
|
||||
if err = workspace.InstallTemplate(template.Name, tarball); err != nil {
|
||||
return errors.Wrapf(err, "installing template '%s' from %s", template.Name, source)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the values to fill in.
|
||||
name = workspace.ValueOrSanitizedDefaultProjectName(name, filepath.Base(cwd))
|
||||
description = workspace.ValueOrDefaultProjectDescription(description, template.Description)
|
||||
|
||||
// Do a dry run if we're not forcing files to be overwritten.
|
||||
if !force {
|
||||
if err = workspace.CopyTemplateFilesDryRun(template.Name, cwd); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.Wrapf(err, "template not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Actually copy the files.
|
||||
if err = workspace.CopyTemplateFiles(template.Name, cwd, force, name, description); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.Wrapf(err, "template not found")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Your project was created successfully.")
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVarP(&cloudURL,
|
||||
"cloud-url", "c", "", "A cloud URL to download releases from")
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&name, "name", "n", "",
|
||||
"The project name; if not specified, the name of the current working directory is used")
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&description, "description", "d", "",
|
||||
"The project description; f not specified, a default description is used")
|
||||
cmd.PersistentFlags().BoolVarP(
|
||||
&force, "force", "f", false,
|
||||
"Forces content to be generated even if it would change existing files")
|
||||
cmd.PersistentFlags().BoolVarP(
|
||||
&offline, "offline", "o", false,
|
||||
"Allows offline use of cached templates without making any network requests")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
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) (workspace.Template, error) {
|
||||
const chooseTemplateErr = "no template selected; please use `pulumi new` to choose one"
|
||||
if !cmdutil.Interactive() {
|
||||
return workspace.Template{}, errors.New(chooseTemplateErr)
|
||||
}
|
||||
|
||||
var templates []workspace.Template
|
||||
var err error
|
||||
|
||||
if !offline {
|
||||
if templates, err = backend.ListTemplates(); 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 workspace.Template{}, errors.Wrap(err, message)
|
||||
}
|
||||
} else {
|
||||
if templates, err = workspace.ListLocalTemplates(); err != nil || len(templates) == 0 {
|
||||
return workspace.Template{}, 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, nameToTemplateMap := templateArrayToStringArrayAndMap(templates)
|
||||
|
||||
var option string
|
||||
if err := survey.AskOne(&survey.Select{
|
||||
Message: message,
|
||||
Options: options,
|
||||
}, &option, nil); err != nil {
|
||||
return workspace.Template{}, errors.New(chooseTemplateErr)
|
||||
}
|
||||
|
||||
return nameToTemplateMap[option], nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
|
@ -85,6 +85,7 @@ func NewPulumiCmd() *cobra.Command {
|
|||
cmd.AddCommand(newHistoryCmd())
|
||||
cmd.AddCommand(newInitCmd())
|
||||
cmd.AddCommand(newLogsCmd())
|
||||
cmd.AddCommand(newNewCmd())
|
||||
cmd.AddCommand(newPluginCmd())
|
||||
cmd.AddCommand(newPreviewCmd())
|
||||
cmd.AddCommand(newStackCmd())
|
||||
|
|
|
@ -41,6 +41,8 @@ type Backend interface {
|
|||
backend.Backend
|
||||
CloudURL() string
|
||||
DownloadPlugin(info workspace.PluginInfo, progress bool) (io.ReadCloser, error)
|
||||
ListTemplates() ([]workspace.Template, error)
|
||||
DownloadTemplate(name string, progress bool) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type cloudBackend struct {
|
||||
|
@ -102,6 +104,41 @@ func (b *cloudBackend) DownloadPlugin(info workspace.PluginInfo, progress bool)
|
|||
return result, nil
|
||||
}
|
||||
|
||||
func (b *cloudBackend) ListTemplates() ([]workspace.Template, error) {
|
||||
// Query all templates.
|
||||
var templates []workspace.Template
|
||||
if err := pulumiRESTCall(b.cloudURL, "GET", "/releases/templates", nil, nil, &templates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
func (b *cloudBackend) DownloadTemplate(name string, progress bool) (io.ReadCloser, error) {
|
||||
// Make the GET request to download the template.
|
||||
endpoint := fmt.Sprintf("/releases/templates/%s.tar.gz", name)
|
||||
_, resp, err := pulumiAPICall(b.cloudURL, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to download template")
|
||||
}
|
||||
|
||||
// If progress is requested, and we know the length, show a little animated ASCII progress bar.
|
||||
result := resp.Body
|
||||
if progress && resp.ContentLength != -1 {
|
||||
bar := pb.New(int(resp.ContentLength))
|
||||
result = bar.NewProxyReader(result)
|
||||
bar.Prefix(colors.ColorizeText(colors.SpecUnimportant + "Downloading template: "))
|
||||
bar.Postfix(colors.ColorizeText(colors.Reset))
|
||||
bar.SetMaxWidth(80)
|
||||
bar.SetUnits(pb.U_BYTES)
|
||||
bar.Start()
|
||||
defer func() {
|
||||
bar.Finish()
|
||||
}()
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *cloudBackend) GetStack(stackName tokens.QName) (backend.Stack, error) {
|
||||
// IDEA: query the stack directly instead of listing them.
|
||||
stacks, err := b.ListStacks()
|
||||
|
|
|
@ -23,6 +23,7 @@ const (
|
|||
HistoryDir = "history" // the name of the directory that holds historical information for projects.
|
||||
PluginDir = "plugins" // the name of the directory containing plugins.
|
||||
StackDir = "stacks" // the name of the directory that holds stack information for projects.
|
||||
TemplateDir = "templates" // the name of the directory containing templates.
|
||||
WorkspaceDir = "workspaces" // the name of the directory that holds workspace information for projects.
|
||||
|
||||
IgnoreFile = ".pulumiignore" // the name of the file that we use to control what to upload to the service.
|
||||
|
|
374
pkg/workspace/templates.go
Normal file
374
pkg/workspace/templates.go
Normal file
|
@ -0,0 +1,374 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pulumi/pulumi/pkg/tokens"
|
||||
"github.com/pulumi/pulumi/pkg/util/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProjectName = "project"
|
||||
defaultProjectDescription = "A Pulumi project."
|
||||
|
||||
// This file will be ignored when copying from the template cache to
|
||||
// a project directory.
|
||||
// It's not currently used, but the could be used in the future to contain
|
||||
// metadata for the template, such as the description, for use offline.
|
||||
pulumiTemplateManifestFile = ".pulumi.template.yaml"
|
||||
)
|
||||
|
||||
// Template represents a project template.
|
||||
type Template struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListLocalTemplates returns a list of local templates.
|
||||
func ListLocalTemplates() ([]Template, error) {
|
||||
templateDir, err := GetTemplateDir("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infos, err := ioutil.ReadDir(templateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var templates []Template
|
||||
for _, info := range infos {
|
||||
if info.IsDir() {
|
||||
templates = append(templates, Template{Name: info.Name()})
|
||||
}
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// InstallTemplate installs a template tarball into the local cache.
|
||||
func InstallTemplate(name string, tarball io.ReadCloser) error {
|
||||
contract.Require(name != "", "name")
|
||||
contract.Require(tarball != nil, "tarball")
|
||||
|
||||
var templateDir string
|
||||
var err error
|
||||
|
||||
// Get the template directory.
|
||||
if templateDir, err = GetTemplateDir(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the directory if it exists.
|
||||
if err = os.RemoveAll(templateDir); err != nil {
|
||||
return errors.Wrapf(err, "removing existing template directory %s", templateDir)
|
||||
}
|
||||
|
||||
// Ensure it exists since we may have just deleted it.
|
||||
if err = os.MkdirAll(templateDir, 0700); err != nil {
|
||||
return errors.Wrapf(err, "creating template directory %s", templateDir)
|
||||
}
|
||||
|
||||
// Extract the tarball to its directory.
|
||||
if err = extractTarball(tarball, templateDir); err != nil {
|
||||
return errors.Wrapf(err, "extracting template to %s", templateDir)
|
||||
}
|
||||
|
||||
// On Windows, we need to replace \n with \r\n. We'll just do this as a separate step.
|
||||
if runtime.GOOS == "windows" {
|
||||
if err = fixWindowsLineEndings(templateDir); err != nil {
|
||||
return errors.Wrapf(err, "fixing line endings in %s", templateDir)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyTemplateFilesDryRun does a dry run of copying a template to a destination directory,
|
||||
// to ensure it won't overwrite any files.
|
||||
func CopyTemplateFilesDryRun(name string, destDir string) error {
|
||||
var err error
|
||||
var sourceDir string
|
||||
if sourceDir, err = GetTemplateDir(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var existing []string
|
||||
err = walkFiles(sourceDir, destDir, func(info os.FileInfo, source string, dest string) error {
|
||||
if destInfo, statErr := os.Stat(dest); statErr == nil && !destInfo.IsDir() {
|
||||
existing = append(existing, filepath.Base(dest))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(existing) > 0 {
|
||||
return newExistingFilesError(existing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyTemplateFiles does the actual copy operation to a destination directory.
|
||||
func CopyTemplateFiles(name string, destDir string, force bool, projectName string, projectDescription string) error {
|
||||
sourceDir, err := GetTemplateDir(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return walkFiles(sourceDir, destDir, func(info os.FileInfo, source string, dest string) error {
|
||||
if info.IsDir() {
|
||||
// Create the destination directory.
|
||||
return os.Mkdir(dest, 0700)
|
||||
}
|
||||
|
||||
// Read the source file.
|
||||
b, err := ioutil.ReadFile(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We assume all template files are text files.
|
||||
transformed := transform(string(b), projectName, projectDescription)
|
||||
|
||||
// Write to the destination file.
|
||||
err = writeAllText(dest, transformed, force)
|
||||
if err != nil {
|
||||
// An existing file has shown up in between the dry run and the actual copy operation.
|
||||
if os.IsExist(err) {
|
||||
return newExistingFilesError([]string{filepath.Base(dest)})
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GetTemplateDir returns the directory in which templates on the current machine are stored.
|
||||
func GetTemplateDir(name string) (string, error) {
|
||||
u, err := user.Current()
|
||||
if u == nil || err != nil {
|
||||
return "", errors.Wrap(err, "getting user home directory")
|
||||
}
|
||||
dir := filepath.Join(u.HomeDir, BookkeepingDir, TemplateDir)
|
||||
if name != "" {
|
||||
dir = filepath.Join(dir, name)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// IsValidProjectName returns true if the project name is a valid name.
|
||||
func IsValidProjectName(name string) bool {
|
||||
return tokens.IsPackageName(name)
|
||||
}
|
||||
|
||||
// ValueOrSanitizedDefaultProjectName returns the value or a sanitized valid project name
|
||||
// based on defaultNameToSanitize.
|
||||
func ValueOrSanitizedDefaultProjectName(name string, defaultNameToSanitize string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return getValidProjectName(defaultNameToSanitize)
|
||||
}
|
||||
|
||||
// ValueOrDefaultProjectDescription returns the value or defaultDescription.
|
||||
func ValueOrDefaultProjectDescription(description string, defaultDescription string) string {
|
||||
if description != "" {
|
||||
return description
|
||||
}
|
||||
if defaultDescription != "" {
|
||||
return defaultDescription
|
||||
}
|
||||
return defaultProjectDescription
|
||||
}
|
||||
|
||||
// getValidProjectName returns a valid project name based on the passed-in name.
|
||||
func getValidProjectName(name string) string {
|
||||
// If the name is valid, return it.
|
||||
if IsValidProjectName(name) {
|
||||
return name
|
||||
}
|
||||
|
||||
// Otherwise, try building-up the name, removing any invalid chars.
|
||||
var result string
|
||||
for i := 0; i < len(name); i++ {
|
||||
temp := result + string(name[i])
|
||||
if IsValidProjectName(temp) {
|
||||
result = temp
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't come up with a valid project name, fallback to a default.
|
||||
if result == "" {
|
||||
result = defaultProjectName
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractTarball extracts the tarball to the specified destination directory.
|
||||
func extractTarball(tarball io.ReadCloser, destDir string) error {
|
||||
// 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(destDir, 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
|
||||
}
|
||||
|
||||
// walkFiles is a helper that walks the directories/files in a source directory
|
||||
// and performs an action for each item.
|
||||
func walkFiles(sourceDir string, destDir string,
|
||||
actionFn func(info os.FileInfo, source string, dest string) error) error {
|
||||
|
||||
contract.Require(sourceDir != "", "sourceDir")
|
||||
contract.Require(destDir != "", "destDir")
|
||||
contract.Require(actionFn != nil, "actionFn")
|
||||
|
||||
infos, err := ioutil.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, info := range infos {
|
||||
name := info.Name()
|
||||
source := filepath.Join(sourceDir, name)
|
||||
dest := filepath.Join(destDir, name)
|
||||
|
||||
if info.IsDir() {
|
||||
if err := actionFn(info, source, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := walkFiles(source, dest, actionFn); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Ignore template manifest file.
|
||||
if name == pulumiTemplateManifestFile {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := actionFn(info, source, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newExistingFilesError returns a new error from a list of existing file names
|
||||
// that would be overwritten.
|
||||
func newExistingFilesError(existing []string) error {
|
||||
contract.Assert(len(existing) > 0)
|
||||
message := "creating this template will make changes to existing files:\n"
|
||||
for _, file := range existing {
|
||||
message = message + fmt.Sprintf(" overwrite %s\n", file)
|
||||
}
|
||||
message = message + "\nrerun the command and pass --force to accept and create"
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
// transform returns a new string with ${PROJECT} and ${DESCRIPTION} replaced by
|
||||
// the value of projectName and projectDescription.
|
||||
func transform(content string, projectName string, projectDescription string) string {
|
||||
content = strings.Replace(content, "${PROJECT}", projectName, -1)
|
||||
content = strings.Replace(content, "${DESCRIPTION}", projectDescription, -1)
|
||||
return content
|
||||
}
|
||||
|
||||
// writeAllText writes all the text to the specified file, with an option to overwrite.
|
||||
func writeAllText(filename string, text string, overwrite bool) error {
|
||||
flag := os.O_WRONLY | os.O_CREATE
|
||||
if overwrite {
|
||||
flag = flag | os.O_TRUNC
|
||||
} else {
|
||||
flag = flag | os.O_EXCL
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filename, flag, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer contract.IgnoreClose(f)
|
||||
|
||||
_, err = f.WriteString(text)
|
||||
return err
|
||||
}
|
||||
|
||||
// fixWindowsLineEndings will go through the sourceDir, read each file, replace \n with \r\n,
|
||||
// and save the changes.
|
||||
// It'd be more efficient to do this during tarball extraction, but this is sufficient for now.
|
||||
func fixWindowsLineEndings(sourceDir string) error {
|
||||
return walkFiles(sourceDir, sourceDir, func(info os.FileInfo, source string, dest string) error {
|
||||
// Skip directories.
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read the source file.
|
||||
b, err := ioutil.ReadFile(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We assume all template files are text files.
|
||||
content := string(b)
|
||||
content = strings.Replace(content, "\n", "\r\n", -1)
|
||||
|
||||
// Write to the destination file.
|
||||
err = writeAllText(dest, content, true /*overwrite*/)
|
||||
if err != nil {
|
||||
// An existing file has shown up in between the dry run and the actual copy operation.
|
||||
if os.IsExist(err) {
|
||||
return newExistingFilesError([]string{filepath.Base(dest)})
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
66
pkg/workspace/templates_test.go
Normal file
66
pkg/workspace/templates_test.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetValidDefaultProjectName(t *testing.T) {
|
||||
// Valid names remain the same.
|
||||
for _, name := range getValidProjectNamePrefixes() {
|
||||
assert.Equal(t, name, getValidProjectName(name))
|
||||
}
|
||||
assert.Equal(t, "foo", getValidProjectName("foo"))
|
||||
assert.Equal(t, "foo1", getValidProjectName("foo1"))
|
||||
assert.Equal(t, "foo-", getValidProjectName("foo-"))
|
||||
assert.Equal(t, "foo-bar", getValidProjectName("foo-bar"))
|
||||
assert.Equal(t, "foo_", getValidProjectName("foo_"))
|
||||
assert.Equal(t, "foo_bar", getValidProjectName("foo_bar"))
|
||||
assert.Equal(t, "foo.", getValidProjectName("foo."))
|
||||
assert.Equal(t, "foo.bar", getValidProjectName("foo.bar"))
|
||||
|
||||
// Invalid characters are left off.
|
||||
assert.Equal(t, "foo", getValidProjectName("@foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("-foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("0foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("1foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("2foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("3foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("4foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("5foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("6foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("7foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("8foo"))
|
||||
assert.Equal(t, "foo", getValidProjectName("9foo"))
|
||||
|
||||
// Invalid names are replaced with a fallback name.
|
||||
assert.Equal(t, "project", getValidProjectName("@"))
|
||||
assert.Equal(t, "project", getValidProjectName("-"))
|
||||
assert.Equal(t, "project", getValidProjectName("0"))
|
||||
assert.Equal(t, "project", getValidProjectName("1"))
|
||||
assert.Equal(t, "project", getValidProjectName("2"))
|
||||
assert.Equal(t, "project", getValidProjectName("3"))
|
||||
assert.Equal(t, "project", getValidProjectName("4"))
|
||||
assert.Equal(t, "project", getValidProjectName("5"))
|
||||
assert.Equal(t, "project", getValidProjectName("6"))
|
||||
assert.Equal(t, "project", getValidProjectName("7"))
|
||||
assert.Equal(t, "project", getValidProjectName("8"))
|
||||
assert.Equal(t, "project", getValidProjectName("9"))
|
||||
assert.Equal(t, "project", getValidProjectName("@1"))
|
||||
}
|
||||
|
||||
func getValidProjectNamePrefixes() []string {
|
||||
var results []string
|
||||
for ch := 'A'; ch <= 'Z'; ch++ {
|
||||
results = append(results, string(ch))
|
||||
}
|
||||
for ch := 'a'; ch <= 'z'; ch++ {
|
||||
results = append(results, string(ch))
|
||||
}
|
||||
results = append(results, "_")
|
||||
results = append(results, ".")
|
||||
return results
|
||||
}
|
313
tests/new_test.go
Normal file
313
tests/new_test.go
Normal file
|
@ -0,0 +1,313 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ptesting "github.com/pulumi/pulumi/pkg/testing"
|
||||
"github.com/pulumi/pulumi/pkg/workspace"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPulumiNew(t *testing.T) {
|
||||
t.Run("SanityTest", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Create a subdirectory and CD into it.
|
||||
subdir := path.Join(e.RootPath, "foo")
|
||||
err := os.MkdirAll(subdir, os.ModePerm)
|
||||
assert.NoError(t, err, "error creating subdirectory")
|
||||
e.CWD = subdir
|
||||
|
||||
// Run pulumi new.
|
||||
e.RunCommand("pulumi", "new", template, "--offline")
|
||||
|
||||
assertSuccess(t, subdir, "foo", "A Pulumi project.")
|
||||
})
|
||||
|
||||
t.Run("NoTemplateSpecified", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Confirm this will result in an error since it isn't an
|
||||
// interactive terminal session.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new")
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.NotEmpty(t, stderr)
|
||||
})
|
||||
|
||||
t.Run("InvalidTemplateName", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// An invalid template name.
|
||||
template := "/this/is\\not/a/valid/templatename"
|
||||
|
||||
// Confirm this fails.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new", template)
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.NotEmpty(t, stderr)
|
||||
})
|
||||
|
||||
t.Run("LocalTemplateNotFound", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// A template that will never exist remotely.
|
||||
template := "this-is-not-the-template-youre-looking-for"
|
||||
|
||||
// Confirm this fails.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new", template, "--offline")
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.NotEmpty(t, stderr)
|
||||
|
||||
// Ensure the unknown template dir doesn't remain in the home directory.
|
||||
_, err := os.Stat(getTemplateDir(t, template))
|
||||
assert.Error(t, err, "dir shouldn't exist")
|
||||
})
|
||||
|
||||
t.Run("RemoteTemplateNotFound", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// A template that will never exist remotely.
|
||||
template := "this-is-not-the-template-youre-looking-for"
|
||||
|
||||
// Confirm this fails.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new", template)
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.NotEmpty(t, stderr)
|
||||
|
||||
// Ensure the unknown template dir doesn't remain in the home directory.
|
||||
_, err := os.Stat(getTemplateDir(t, template))
|
||||
assert.Error(t, err, "dir shouldn't exist")
|
||||
})
|
||||
|
||||
t.Run("NameDescriptionPassedExplicitly", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Run pulumi new.
|
||||
e.RunCommand("pulumi", "new", template, "--name", "bar", "--description", "A project.", "--offline")
|
||||
|
||||
assertSuccess(t, e.CWD, "bar", "A project.")
|
||||
})
|
||||
|
||||
t.Run("WorkingDirNameIsntAValidProjectName", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Create a subdirectory that contains an invalid char
|
||||
// for project names and CD into it.
|
||||
subdir := path.Join(e.RootPath, "@foo")
|
||||
err := os.MkdirAll(subdir, os.ModePerm)
|
||||
assert.NoError(t, err, "error creating subdirectory")
|
||||
e.CWD = subdir
|
||||
|
||||
// Run pulumi new.
|
||||
e.RunCommand("pulumi", "new", template, "--offline")
|
||||
|
||||
// Assert the default name used is "foo" not "@foo".
|
||||
assertSuccess(t, subdir, "foo", "A Pulumi project.")
|
||||
})
|
||||
|
||||
t.Run("ExistingFileNotOverwritten", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Create a subdirectory and CD into it.
|
||||
subdir := path.Join(e.RootPath, "foo")
|
||||
err := os.MkdirAll(subdir, os.ModePerm)
|
||||
assert.NoError(t, err, "error creating subdirectory")
|
||||
e.CWD = subdir
|
||||
|
||||
// Create an existing Pulumi.yaml.
|
||||
err = ioutil.WriteFile(filepath.Join(subdir, "Pulumi.yaml"), []byte("name: blah"), 0600)
|
||||
assert.NoError(t, err, "creating Pulumi.yaml")
|
||||
|
||||
// Confirm failure due to existing file.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new", template, "--offline")
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.Contains(t, stderr, "Pulumi.yaml")
|
||||
|
||||
// Confirm the contents of the file wasn't changed.
|
||||
content := readFile(t, filepath.Join(subdir, "Pulumi.yaml"))
|
||||
assert.Equal(t, "name: blah", content)
|
||||
|
||||
// Confirm no other files were copied over.
|
||||
infos, err := ioutil.ReadDir(subdir)
|
||||
assert.NoError(t, err, "reading the dir")
|
||||
assert.Equal(t, 1, len(infos))
|
||||
assert.Equal(t, "Pulumi.yaml", infos[0].Name())
|
||||
assert.False(t, infos[0].IsDir())
|
||||
})
|
||||
|
||||
t.Run("MultipleExistingFilesNotOverwritten", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Create a subdirectory and CD into it.
|
||||
subdir := path.Join(e.RootPath, "foo")
|
||||
err := os.MkdirAll(subdir, os.ModePerm)
|
||||
assert.NoError(t, err, "error creating subdirectory")
|
||||
e.CWD = subdir
|
||||
|
||||
// Create an existing Pulumi.yaml.
|
||||
err = ioutil.WriteFile(filepath.Join(subdir, "Pulumi.yaml"), []byte("name: blah"), 0600)
|
||||
assert.NoError(t, err, "creating Pulumi.yaml")
|
||||
|
||||
// Create an existing test2.txt.
|
||||
err = ioutil.WriteFile(filepath.Join(subdir, "test2.txt"), []byte("foo"), 0600)
|
||||
assert.NoError(t, err, "creating test2.txt")
|
||||
|
||||
// Confirm failure due to existing files.
|
||||
stdout, stderr := e.RunCommandExpectError("pulumi", "new", template, "--offline")
|
||||
assert.Equal(t, "", stdout)
|
||||
assert.Contains(t, stderr, "Pulumi.yaml")
|
||||
assert.Contains(t, stderr, "test2.txt")
|
||||
|
||||
// Confirm the contents of Pulumi.yaml wasn't changed.
|
||||
content := readFile(t, filepath.Join(subdir, "Pulumi.yaml"))
|
||||
assert.Equal(t, "name: blah", content)
|
||||
|
||||
// Confirm the contents of test2.txt wasn't changed.
|
||||
content = readFile(t, filepath.Join(subdir, "test2.txt"))
|
||||
assert.Equal(t, "foo", content)
|
||||
|
||||
// Confirm no other files were copied over.
|
||||
infos, err := ioutil.ReadDir(subdir)
|
||||
assert.NoError(t, err, "reading the dir")
|
||||
assert.Equal(t, 2, len(infos))
|
||||
for _, info := range infos {
|
||||
assert.True(t, info.Name() == "Pulumi.yaml" || info.Name() == "test2.txt")
|
||||
assert.False(t, info.IsDir())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MultipleExistingFilesForce", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
|
||||
// Create a temporary local template.
|
||||
template := createTemporaryLocalTemplate(t)
|
||||
defer deleteTemporaryLocalTemplate(t, template)
|
||||
|
||||
// Create a subdirectory and CD into it.
|
||||
subdir := path.Join(e.RootPath, "foo")
|
||||
err := os.MkdirAll(subdir, os.ModePerm)
|
||||
assert.NoError(t, err, "error creating subdirectory")
|
||||
e.CWD = subdir
|
||||
|
||||
// Create an existing Pulumi.yaml.
|
||||
err = ioutil.WriteFile(filepath.Join(subdir, "Pulumi.yaml"), []byte("name: blah"), 0600)
|
||||
assert.NoError(t, err, "creating Pulumi.yaml")
|
||||
|
||||
// Create an existing test2.txt.
|
||||
err = ioutil.WriteFile(filepath.Join(subdir, "test2.txt"), []byte("foo"), 0600)
|
||||
assert.NoError(t, err, "creating test2.txt")
|
||||
|
||||
// Run pulumi new with --force.
|
||||
e.RunCommand("pulumi", "new", template, "--force", "--offline")
|
||||
|
||||
assertSuccess(t, subdir, "foo", "A Pulumi project.")
|
||||
})
|
||||
}
|
||||
|
||||
func assertSuccess(t *testing.T, dir string, expectedProjectName string, expectedProjectDescription string) {
|
||||
// Confirm the template file was copied/transformed.
|
||||
content := readFile(t, filepath.Join(dir, "Pulumi.yaml"))
|
||||
assert.Contains(t, content, fmt.Sprintf("name: %s", expectedProjectName))
|
||||
assert.Contains(t, content, fmt.Sprintf("description: %s", expectedProjectDescription))
|
||||
|
||||
// Confirm the test1.txt file was copied.
|
||||
content = readFile(t, filepath.Join(dir, "test1.txt"))
|
||||
assert.Equal(t, "test1", content)
|
||||
|
||||
// Confirm the test2.txt file was copied.
|
||||
content = readFile(t, filepath.Join(dir, "test2.txt"))
|
||||
assert.Equal(t, "test2", content)
|
||||
|
||||
// Confirm the sub/blah.json file was copied.
|
||||
content = readFile(t, filepath.Join(dir, "sub", "blah.json"))
|
||||
assert.Equal(t, "{}", content)
|
||||
|
||||
// Confirm the .pulumi.template.yaml file was skipped.
|
||||
_, err := os.Stat(filepath.Join(dir, ".pulumi.template.yaml"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, filename string) string {
|
||||
b, err := ioutil.ReadFile(filename)
|
||||
assert.NoError(t, err, "reading file")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func createTemporaryLocalTemplate(t *testing.T) string {
|
||||
name := fmt.Sprintf("%v", time.Now().UnixNano())
|
||||
dir := getTemplateDir(t, name)
|
||||
err := os.MkdirAll(dir, 0700)
|
||||
assert.NoError(t, err, "creating temporary template dir")
|
||||
|
||||
text := "name: ${PROJECT}\n" +
|
||||
"description: ${DESCRIPTION}\n" +
|
||||
"runtime: nodejs\n"
|
||||
err = ioutil.WriteFile(filepath.Join(dir, "Pulumi.yaml"), []byte(text), 0600)
|
||||
assert.NoError(t, err, "creating Pulumi.yaml")
|
||||
|
||||
err = ioutil.WriteFile(filepath.Join(dir, "test1.txt"), []byte("test1"), 0600)
|
||||
assert.NoError(t, err, "creating test1.txt")
|
||||
|
||||
err = ioutil.WriteFile(filepath.Join(dir, "test2.txt"), []byte("test2"), 0600)
|
||||
assert.NoError(t, err, "creating test2.txt")
|
||||
|
||||
err = os.MkdirAll(filepath.Join(dir, "sub"), os.ModePerm)
|
||||
assert.NoError(t, err, "creating sub")
|
||||
|
||||
err = ioutil.WriteFile(filepath.Join(dir, "sub", "blah.json"), []byte("{}"), 0600)
|
||||
assert.NoError(t, err, "creating sub/blah.json")
|
||||
|
||||
err = ioutil.WriteFile(filepath.Join(dir, ".pulumi.template.yaml"), []byte{}, 0600)
|
||||
assert.NoError(t, err, "creating .pulumi.template.yaml")
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func deleteTemporaryLocalTemplate(t *testing.T, name string) {
|
||||
err := os.RemoveAll(getTemplateDir(t, name))
|
||||
assert.NoError(t, err, "deleting temporary template dir")
|
||||
}
|
||||
|
||||
func getTemplateDir(t *testing.T, name string) string {
|
||||
user, err := user.Current()
|
||||
assert.NoError(t, err, "getting home directory")
|
||||
return filepath.Join(user.HomeDir, workspace.BookkeepingDir, workspace.TemplateDir, name)
|
||||
}
|
Loading…
Reference in a new issue