From 64f78b42df2e8e8408a520e8e241ecb1e2ec4969 Mon Sep 17 00:00:00 2001
From: Alex Clemmer <clemmer.alexander@gmail.com>
Date: Thu, 21 Sep 2017 16:40:54 -0700
Subject: [PATCH] Implement 'prototype preview'

Currently the command 'prototype use' expands a prototype and prints to
stdout. This is useful, but most of the time, users want to simply dump
the result in 'components/'

This command implements this print-to-stdout behavior in a new command,
'prototype-preview', and reimplements 'prototype use' to drop the
expanded prototype into 'components/'.

The new form of this command is:

  ksonnet prototype use <prototype-name> <component-name> [type] [flags]

So, for example, a command like:

  ksonnet prototype use deployment nginx-depl [...]

would expand the 'deployment' prototype, and place it in
'components/nginx-depl.jsonnet' (since Jsonnet is the default template
expansion). Alternatively, something like this:

  ksonnet prototype use deployment nginx-depl yaml [...]

would expand the prototype and place it in 'components/nginx-depl.yaml'
(assuming that there is a YAML version of this template.
---
 cmd/prototype.go      | 255 ++++++++++++++++++++++++++++++++----------
 metadata/interface.go |   2 +
 metadata/manager.go   |  31 +++++
 3 files changed, 226 insertions(+), 62 deletions(-)

diff --git a/cmd/prototype.go b/cmd/prototype.go
index f7076459..508d6c23 100644
--- a/cmd/prototype.go
+++ b/cmd/prototype.go
@@ -17,8 +17,12 @@ package cmd
 
 import (
 	"fmt"
+	"os"
 	"strings"
 
+	"github.com/spf13/pflag"
+
+	"github.com/ksonnet/kubecfg/metadata"
 	"github.com/ksonnet/kubecfg/prototype"
 	"github.com/ksonnet/kubecfg/prototype/snippet"
 	"github.com/spf13/cobra"
@@ -29,6 +33,7 @@ func init() {
 	prototypeCmd.AddCommand(prototypeDescribeCmd)
 	prototypeCmd.AddCommand(prototypeSearchCmd)
 	prototypeCmd.AddCommand(prototypeUseCmd)
+	prototypeCmd.AddCommand(prototypePreviewCmd)
 }
 
 var prototypeCmd = &cobra.Command{
@@ -160,13 +165,13 @@ var prototypeSearchCmd = &cobra.Command{
   ksonnet prototype search deployment`,
 }
 
-var prototypeUseCmd = &cobra.Command{
-	Use:                "use <prototype-name> [type] [parameter-flags]",
-	Short:              `Instantiate prototype, emitting the generated code to stdout.`,
+var prototypePreviewCmd = &cobra.Command{
+	Use:                "preview <prototype-name> [type] [parameter-flags]",
+	Short:              `Expand prototype, emitting the generated code to stdout`,
 	DisableFlagParsing: true,
 	RunE: func(cmd *cobra.Command, rawArgs []string) error {
 		if len(rawArgs) < 1 {
-			return fmt.Errorf("Command 'prototype use' requires a prototype name\n\n%s", cmd.UsageString())
+			return fmt.Errorf("Command 'prototype preview' requires a prototype name\n\n%s", cmd.UsageString())
 		}
 
 		query := rawArgs[0]
@@ -176,13 +181,7 @@ var prototypeUseCmd = &cobra.Command{
 			return err
 		}
 
-		for _, param := range proto.RequiredParams() {
-			cmd.PersistentFlags().String(param.Name, "", param.Description)
-		}
-
-		for _, param := range proto.OptionalParams() {
-			cmd.PersistentFlags().String(param.Name, *param.Default, param.Description)
-		}
+		bindPrototypeFlags(cmd, proto)
 
 		cmd.DisableFlagParsing = false
 		err = cmd.ParseFlags(rawArgs)
@@ -203,82 +202,214 @@ var prototypeUseCmd = &cobra.Command{
 				return err
 			}
 		} else {
-			return fmt.Errorf("Incorrect number of arguments supplied to 'prototype use'\n\n%s", cmd.UsageString())
+			return fmt.Errorf("Incorrect number of arguments supplied to 'prototype preview'\n\n%s", cmd.UsageString())
 		}
 
-		missingReqd := prototype.ParamSchemas{}
-		values := map[string]string{}
-		for _, param := range proto.RequiredParams() {
-			val, err := flags.GetString(param.Name)
-			if err != nil {
-				return err
-			} else if val == "" {
-				missingReqd = append(missingReqd, param)
-			} else if _, ok := values[param.Name]; ok {
-				return fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name)
-			}
+		text, err := expandPrototype(proto, flags, templateType)
+		if err != nil {
+			return err
+		}
 
-			quoted, err := param.Quote(val)
-			if err != nil {
-				return err
-			}
-			values[param.Name] = quoted
+		fmt.Println(text)
+		return nil
+	},
+	Long: `Expand prototype uniquely identified by (possibly partial)
+'prototype-name', filling in parameters from flags, and emitting the generated
+code to stdout.
+
+Note also that 'prototype-name' need only contain enough of the suffix of a name
+to uniquely disambiguate it among known names. For example, 'deployment' may
+resolve ambiguously, in which case 'use' will fail, while 'deployment' might be
+unique enough to resolve to 'io.ksonnet.pkg.single-port-deployment'.`,
+
+	Example: `  # Preview prototype 'io.ksonnet.pkg.single-port-deployment', using the
+  # 'nginx' image, and port 80 exposed.
+  ksonnet prototype preview io.ksonnet.pkg.prototype.simple-deployment \
+    --name=nginx                                                       \
+    --image=nginx
+
+  # Preview prototype using a unique suffix of an identifier. See
+  # introduction of help message for more information on how this works.
+  ksonnet prototype preview simple-deployment \
+    --name=nginx                              \
+    --image=nginx
+
+  # Preview prototype 'io.ksonnet.pkg.single-port-deployment' as YAML,
+  # placing the result in 'components/nginx-depl.yaml. Note that some templates
+  # do not have a YAML or JSON versions.
+  ksonnet prototype preview deployment nginx-depl yaml \
+    --name=nginx                                       \
+    --image=nginx`,
+}
+
+var prototypeUseCmd = &cobra.Command{
+	Use:                "use <prototype-name> <componentName> [type] [parameter-flags]",
+	Short:              `Expand prototype, place in components/ directory of ksonnet app`,
+	DisableFlagParsing: true,
+	RunE: func(cmd *cobra.Command, rawArgs []string) error {
+		cwd, err := os.Getwd()
+		if err != nil {
+			return err
+		}
+		manager, err := metadata.Find(metadata.AbsPath(cwd))
+		if err != nil {
+			return fmt.Errorf("'prototype use' can only be run in a ksonnet application directory:\n\n%v", err)
 		}
 
-		if len(missingReqd) > 0 {
-			return fmt.Errorf("Failed to instantiate prototype '%s'. The following required parameters are missing:\n%s", proto.Name, missingReqd.PrettyString(""))
+		if len(rawArgs) < 1 {
+			return fmt.Errorf("Command 'prototype preview' requires a prototype name\n\n%s", cmd.UsageString())
 		}
 
-		for _, param := range proto.OptionalParams() {
-			val, err := flags.GetString(param.Name)
-			if err != nil {
-				return err
-			} else if _, ok := values[param.Name]; ok {
-				return fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name)
-			}
+		query := rawArgs[0]
 
-			quoted, err := param.Quote(val)
-			if err != nil {
-				return err
-			}
-			values[param.Name] = quoted
+		proto, err := fundUniquePrototype(query)
+		if err != nil {
+			return err
 		}
 
-		template, err := proto.Template.Body(templateType)
+		bindPrototypeFlags(cmd, proto)
+
+		cmd.DisableFlagParsing = false
+		err = cmd.ParseFlags(rawArgs)
 		if err != nil {
 			return err
 		}
+		flags := cmd.Flags()
 
-		tm := snippet.Parse(strings.Join(template, "\n"))
-		text, err := tm.Evaluate(values)
+		// Try to find the template type (if it is supplied) after the args are
+		// parsed. Note that the case that `len(args) == 0` is handled at the
+		// beginning of this command.
+		var componentName string
+		var templateType prototype.TemplateType
+		if args := flags.Args(); len(args) == 1 {
+			return fmt.Errorf("'prototype use' is missing argument 'componentName'\n\n%s", cmd.UsageString())
+		} else if len(args) == 2 {
+			componentName = args[1]
+			templateType = prototype.Jsonnet
+		} else if len(args) == 3 {
+			componentName = args[1]
+			templateType, err = prototype.ParseTemplateType(args[1])
+			if err != nil {
+				return err
+			}
+		} else {
+			return fmt.Errorf("'prototype use' has too many arguments (takes a prototype name and a component name)\n\n%s", cmd.UsageString())
+		}
+
+		text, err := expandPrototype(proto, flags, templateType)
 		if err != nil {
 			return err
 		}
-		fmt.Println(text)
-		return nil
+
+		return manager.CreateComponent(componentName, text, templateType)
 	},
-	Long: `Instantiate prototype uniquely identified by (possibly partial)
-'prototype-name', filling in parameters from flags, and emitting the generated
-code to stdout.
+	Long: `Expand prototype uniquely identified by (possibly partial) 'prototype-name',
+filling in parameters from flags, and placing it into the file
+'components/componentName', with the appropriate extension set. For example, the
+following command will expand template 'io.ksonnet.pkg.single-port-deployment'
+and place it in the file 'components/nginx-depl.jsonnet' (since by default
+ksonnet will expand templates as Jsonnet).
+
+  ksonnet prototype use io.ksonnet.pkg.single-port-deployment nginx-depl \
+    --name=nginx                                                         \
+    --image=nginx
 
-'prototype-name' need only contain enough of the suffix of a name to uniquely
-disambiguate it among known names. For example, 'deployment' may resolve
-ambiguously, in which case 'use' will fail, while 'simple-deployment' might be
-unique enough to resolve to 'io.ksonnet.pkg.prototype.simple-deployment'.`,
+Note that if we were to specify to expand the template as JSON or YAML, we would
+generate a file with a '.json' or '.yaml' extension, respectively. See examples
+below for an example of how to do this.
 
-	Example: `  # Instantiate prototype 'io.ksonnet.pkg.prototype.simple-deployment', using
-  # the 'nginx' image, and port 80 exposed.
-  ksonnet prototype use io.ksonnet.pkg.prototype.simple-deployment \
-    --name=nginx                                                   \
+Note also that 'prototype-name' need only contain enough of the suffix of a name
+to uniquely disambiguate it among known names. For example, 'deployment' may
+resolve ambiguously, in which case 'use' will fail, while 'deployment' might be
+unique enough to resolve to 'io.ksonnet.pkg.single-port-deployment'.`,
+
+	Example: `  # Instantiate prototype 'io.ksonnet.pkg.single-port-deployment', using the
+  # 'nginx' image. The expanded prototype is placed in
+  # 'components/nginx-depl.jsonnet'.
+  ksonnet prototype use io.ksonnet.pkg.prototype.simple-deployment nginx-depl \
+    --name=nginx                                                              \
     --image=nginx
 
-  # Instantiate prototype using a unique suffix of an identifier. See
-  # introduction of help message for more information on how this works.
-  ksonnet prototype use simple-deployment \
-    --name=nginx                          \
+  # Instantiate prototype 'io.ksonnet.pkg.single-port-deployment' using the
+  # unique suffix, 'deployment'. The expanded prototype is again placed in
+  # 'components/nginx-depl.jsonnet'. See introduction of help message for more
+  # information on how this works. Note that if you have imported another
+  # prototype with this suffix, this may resolve ambiguously for you.
+  ksonnet prototype use deployment nginx-depl \
+    --name=nginx                              \
+    --image=nginx
+
+  # Instantiate prototype 'io.ksonnet.pkg.single-port-deployment' as YAML,
+  # placing the result in 'components/nginx-depl.yaml. Note that some templates
+  # do not have a YAML or JSON versions.
+  ksonnet prototype use deployment nginx-depl yaml \
+    --name=nginx                              \
     --image=nginx`,
 }
 
+func bindPrototypeFlags(cmd *cobra.Command, proto *prototype.SpecificationSchema) {
+	for _, param := range proto.RequiredParams() {
+		cmd.PersistentFlags().String(param.Name, "", param.Description)
+	}
+
+	for _, param := range proto.OptionalParams() {
+		cmd.PersistentFlags().String(param.Name, *param.Default, param.Description)
+	}
+}
+
+func expandPrototype(proto *prototype.SpecificationSchema, flags *pflag.FlagSet, templateType prototype.TemplateType) (string, error) {
+	missingReqd := prototype.ParamSchemas{}
+	values := map[string]string{}
+	for _, param := range proto.RequiredParams() {
+		val, err := flags.GetString(param.Name)
+		if err != nil {
+			return "", err
+		} else if val == "" {
+			missingReqd = append(missingReqd, param)
+		} else if _, ok := values[param.Name]; ok {
+			return "", fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name)
+		}
+
+		quoted, err := param.Quote(val)
+		if err != nil {
+			return "", err
+		}
+		values[param.Name] = quoted
+	}
+
+	if len(missingReqd) > 0 {
+		return "", fmt.Errorf("Failed to instantiate prototype '%s'. The following required parameters are missing:\n%s", proto.Name, missingReqd.PrettyString(""))
+	}
+
+	for _, param := range proto.OptionalParams() {
+		val, err := flags.GetString(param.Name)
+		if err != nil {
+			return "", err
+		} else if _, ok := values[param.Name]; ok {
+			return "", fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name)
+		}
+
+		quoted, err := param.Quote(val)
+		if err != nil {
+			return "", err
+		}
+		values[param.Name] = quoted
+	}
+
+	template, err := proto.Template.Body(templateType)
+	if err != nil {
+		return "", err
+	}
+
+	tm := snippet.Parse(strings.Join(template, "\n"))
+	text, err := tm.Evaluate(values)
+	if err != nil {
+		return "", err
+	}
+
+	return text, nil
+}
+
 func fundUniquePrototype(query string) (*prototype.SpecificationSchema, error) {
 	index := prototype.NewIndex([]*prototype.SpecificationSchema{})
 
diff --git a/metadata/interface.go b/metadata/interface.go
index 4d13ed72..afdb46df 100644
--- a/metadata/interface.go
+++ b/metadata/interface.go
@@ -20,6 +20,7 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/ksonnet/kubecfg/prototype"
 	"github.com/spf13/afero"
 )
 
@@ -41,6 +42,7 @@ type AbsPaths []string
 type Manager interface {
 	Root() AbsPath
 	ComponentPaths() (AbsPaths, error)
+	CreateComponent(name string, text string, templateType prototype.TemplateType) error
 	LibPaths(envName string) (libPath, envLibPath AbsPath)
 	CreateEnvironment(name, uri string, spec ClusterSpec) error
 	DeleteEnvironment(name string) error
diff --git a/metadata/manager.go b/metadata/manager.go
index 2950905c..55022151 100644
--- a/metadata/manager.go
+++ b/metadata/manager.go
@@ -20,7 +20,10 @@ import (
 	"os"
 	"path"
 	"path/filepath"
+	"strings"
 
+	"github.com/ksonnet/kubecfg/prototype"
+	log "github.com/sirupsen/logrus"
 	"github.com/spf13/afero"
 )
 
@@ -138,6 +141,34 @@ func (m *manager) ComponentPaths() (AbsPaths, error) {
 	return paths, nil
 }
 
+func (m *manager) CreateComponent(name string, text string, templateType prototype.TemplateType) error {
+	if !isValidName(name) || strings.Contains(name, "/") {
+		return fmt.Errorf("Component name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name)
+	}
+
+	componentPath := string(appendToAbsPath(m.componentsPath, name))
+	switch templateType {
+	case prototype.YAML:
+		componentPath = componentPath + ".yaml"
+	case prototype.JSON:
+		componentPath = componentPath + ".json"
+	case prototype.Jsonnet:
+		componentPath = componentPath + ".jsonnet"
+	default:
+		return fmt.Errorf("Unrecognized prototype template type '%s'", templateType)
+	}
+
+	if exists, err := afero.Exists(m.appFS, componentPath); exists {
+		return fmt.Errorf("Component with name '%s' already exists", name)
+	} else if err != nil {
+		return fmt.Errorf("Could not check whether component '%s' exists:\n\n%v", name, err)
+	}
+
+	log.Infof("Writing component at '%s/%s'", componentsDir, name)
+
+	return afero.WriteFile(m.appFS, componentPath, []byte(text), defaultFilePermissions)
+}
+
 func (m *manager) LibPaths(envName string) (libPath, envLibPath AbsPath) {
 	return m.libPath, appendToAbsPath(m.environmentsPath, envName)
 }
-- 
GitLab