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:
Justin Van Patten 2018-03-09 15:27:55 -08:00 committed by GitHub
parent f071329238
commit 8906731315
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1001 additions and 0 deletions

209
cmd/new.go Normal file
View 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
}

View file

@ -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())

View file

@ -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()

View file

@ -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
View 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
})
}

View 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
View 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)
}