diff --git a/cmd/new.go b/cmd/new.go index ae038f741..d72a5994c 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -53,11 +53,11 @@ func newNewCmd() *cobra.Command { releases := cloud.New(cmdutil.Diag(), getCloudURL(cloudURL)) // Get the selected template. - var template workspace.Template + var templateName string if len(args) > 0 { - template = workspace.Template{Name: strings.ToLower(args[0])} + templateName = strings.ToLower(args[0]) } else { - if template, err = chooseTemplate(releases, offline); err != nil { + if templateName, err = chooseTemplate(releases, offline); err != nil { return err } } @@ -66,43 +66,49 @@ func newNewCmd() *cobra.Command { if !offline { var tarball io.ReadCloser source := releases.CloudURL() - if tarball, err = releases.DownloadTemplate(template.Name, false); err != nil { + if tarball, err = releases.DownloadTemplate(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[template.Name]; ok { + if _, ok := m[templateName]; ok { message = fmt.Sprintf( "; rerun the command and pass --offline to use locally cached template '%s'", - template.Name) + templateName) } } - return errors.Wrapf(err, "downloading template '%s' from %s%s", template.Name, source, message) + return errors.Wrapf(err, "downloading template '%s' from %s%s", templateName, source, message) } - if err = workspace.InstallTemplate(template.Name, tarball); err != nil { - return errors.Wrapf(err, "installing template '%s' from %s", template.Name, source) + 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) + } + // 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 err = template.CopyTemplateFilesDryRun(cwd); err != nil { if os.IsNotExist(err) { - return errors.Wrapf(err, "template not found") + return errors.Wrapf(err, "template '%s' not found", templateName) } return err } } // Actually copy the files. - if err = workspace.CopyTemplateFiles(template.Name, cwd, force, name, description); err != nil { + if err = template.CopyTemplateFiles(cwd, force, name, description); err != nil { if os.IsNotExist(err) { - return errors.Wrapf(err, "template not found") + return errors.Wrapf(err, "template '%s' not found", templateName) } return err } @@ -146,10 +152,10 @@ func getCloudURL(cloudURL string) string { } // chooseTemplate will prompt the user to choose amongst the available templates. -func chooseTemplate(backend cloud.Backend, offline bool) (workspace.Template, error) { +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 workspace.Template{}, errors.New(chooseTemplateErr) + return "", errors.New(chooseTemplateErr) } var templates []workspace.Template @@ -166,11 +172,11 @@ func chooseTemplate(backend cloud.Backend, offline bool) (workspace.Template, er strings.Join(options, ", ") } - return workspace.Template{}, errors.Wrap(err, message) + return "", errors.Wrap(err, message) } } else { if templates, err = workspace.ListLocalTemplates(); err != nil || len(templates) == 0 { - return workspace.Template{}, errors.Wrap(err, chooseTemplateErr) + return "", errors.Wrap(err, chooseTemplateErr) } } @@ -181,17 +187,17 @@ func chooseTemplate(backend cloud.Backend, offline bool) (workspace.Template, er message := "\rPlease choose a template:" message = colors.ColorizeText(colors.BrightWhite + message + colors.Reset) - options, nameToTemplateMap := templateArrayToStringArrayAndMap(templates) + options, _ := 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 "", errors.New(chooseTemplateErr) } - return nameToTemplateMap[option], nil + return option, nil } // templateArrayToStringArrayAndMap returns an array of template names and map of names to templates diff --git a/pkg/workspace/templates.go b/pkg/workspace/templates.go index 575f0f94b..da54ec66f 100644 --- a/pkg/workspace/templates.go +++ b/pkg/workspace/templates.go @@ -14,6 +14,8 @@ import ( "runtime" "strings" + "gopkg.in/yaml.v2" + "github.com/pkg/errors" "github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/util/contract" @@ -36,6 +38,39 @@ type Template struct { Description string `json:"description"` } +// templateManifest represents a template's manifest file. +type templateManifest struct { + Description string `yaml:"description"` +} + +// LoadLocalTemplate returns a local template. +func LoadLocalTemplate(name string) (Template, error) { + template := Template{Name: name} + + templateDir, err := GetTemplateDir(name) + if err != nil { + return Template{}, err + } + + info, err := os.Stat(templateDir) + if err != nil { + return Template{}, err + } + if !info.IsDir() { + return Template{}, errors.Errorf("template '%s' in %s is not a directory", name, templateDir) + } + + // Read the description from the manifest (if it exists). + manifest, err := readTemplateManifest(filepath.Join(templateDir, pulumiTemplateManifestFile)) + if err != nil && !os.IsNotExist(err) { + return Template{}, err + } else if err == nil && manifest.Description != "" { + template.Description = manifest.Description + } + + return template, nil +} + // ListLocalTemplates returns a list of local templates. func ListLocalTemplates() ([]Template, error) { templateDir, err := GetTemplateDir("") @@ -51,7 +86,11 @@ func ListLocalTemplates() ([]Template, error) { var templates []Template for _, info := range infos { if info.IsDir() { - templates = append(templates, Template{Name: info.Name()}) + template, err := LoadLocalTemplate(info.Name()) + if err != nil { + return nil, err + } + templates = append(templates, template) } } return templates, nil @@ -97,10 +136,10 @@ func InstallTemplate(name string, tarball io.ReadCloser) error { // 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 { +func (template Template) CopyTemplateFilesDryRun(destDir string) error { var err error var sourceDir string - if sourceDir, err = GetTemplateDir(name); err != nil { + if sourceDir, err = GetTemplateDir(template.Name); err != nil { return err } @@ -122,8 +161,10 @@ func CopyTemplateFilesDryRun(name string, destDir string) error { } // 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) +func (template Template) CopyTemplateFiles( + destDir string, force bool, projectName string, projectDescription string) error { + + sourceDir, err := GetTemplateDir(template.Name) if err != nil { return err } @@ -302,6 +343,22 @@ func walkFiles(sourceDir string, destDir string, return nil } +// readTemplateManifest reads a template manifest file. +func readTemplateManifest(filename string) (templateManifest, error) { + b, err := ioutil.ReadFile(filename) + if err != nil { + return templateManifest{}, err + } + + var manifest templateManifest + err = yaml.Unmarshal(b, &manifest) + if err != nil { + return templateManifest{}, err + } + + return manifest, nil +} + // newExistingFilesError returns a new error from a list of existing file names // that would be overwritten. func newExistingFilesError(existing []string) error { diff --git a/tests/new_test.go b/tests/new_test.go index 565205870..adab44567 100644 --- a/tests/new_test.go +++ b/tests/new_test.go @@ -23,7 +23,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Create a subdirectory and CD into it. @@ -38,6 +38,28 @@ func TestPulumiNew(t *testing.T) { assertSuccess(t, subdir, "foo", "A Pulumi project.") }) + t.Run("SanityTestWithManifest", func(t *testing.T) { + e := ptesting.NewEnvironment(t) + defer deleteIfNotFailed(e) + + const description = "My project description." + + // Create a temporary local template. + template := createTemporaryLocalTemplate(t, description) + 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", description) + }) + t.Run("NoTemplateSpecified", func(t *testing.T) { e := ptesting.NewEnvironment(t) defer deleteIfNotFailed(e) @@ -101,7 +123,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Run pulumi new. @@ -115,7 +137,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Create a subdirectory that contains an invalid char @@ -137,7 +159,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Create a subdirectory and CD into it. @@ -172,7 +194,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Create a subdirectory and CD into it. @@ -218,7 +240,7 @@ func TestPulumiNew(t *testing.T) { defer deleteIfNotFailed(e) // Create a temporary local template. - template := createTemporaryLocalTemplate(t) + template := createTemporaryLocalTemplate(t, "") defer deleteTemporaryLocalTemplate(t, template) // Create a subdirectory and CD into it. @@ -263,6 +285,10 @@ func assertSuccess(t *testing.T, dir string, expectedProjectName string, expecte // Confirm the .pulumi.template.yaml file was skipped. _, err := os.Stat(filepath.Join(dir, ".pulumi.template.yaml")) assert.Error(t, err) + + infos, err := ioutil.ReadDir(dir) + assert.NoError(t, err, "reading dir") + assert.Equal(t, 4, len(infos)) } func readFile(t *testing.T, filename string) string { @@ -271,7 +297,7 @@ func readFile(t *testing.T, filename string) string { return string(b) } -func createTemporaryLocalTemplate(t *testing.T) string { +func createTemporaryLocalTemplate(t *testing.T, description string) string { name := fmt.Sprintf("%v", time.Now().UnixNano()) dir := getTemplateDir(t, name) err := os.MkdirAll(dir, 0700) @@ -295,8 +321,12 @@ func createTemporaryLocalTemplate(t *testing.T) string { 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") + // If description is not empty, write it out in a manifest file. + if description != "" { + descriptionBytes := []byte(fmt.Sprintf("description: %s", description)) + err = ioutil.WriteFile(filepath.Join(dir, ".pulumi.template.yaml"), descriptionBytes, 0600) + assert.NoError(t, err, "creating .pulumi.template.yaml") + } return name }