diff --git a/cmd/prototype.go b/cmd/prototype.go index 5b7aa0c6261ad31314e64ac89518a142c05d138e..9ef4320a41a51030451b331ada9a506a19217868 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" @@ -30,6 +34,7 @@ func init() { prototypeCmd.AddCommand(prototypeDescribeCmd) prototypeCmd.AddCommand(prototypeSearchCmd) prototypeCmd.AddCommand(prototypeUseCmd) + prototypeCmd.AddCommand(prototypePreviewCmd) } var prototypeCmd = &cobra.Command{ @@ -184,13 +189,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] @@ -200,13 +205,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) @@ -227,82 +226,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/environment.go b/metadata/environment.go index 8d66ea8e1069a50716c4d78d5910775659eeb103..8e23beb11140a9dd21db56abbdf0dbf7613d8171 100644 --- a/metadata/environment.go +++ b/metadata/environment.go @@ -20,7 +20,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" log "github.com/sirupsen/logrus" @@ -77,7 +76,7 @@ func (m *manager) createEnvironment(name, uri string, extensionsLibData, k8sLibD // ensure environment name does not contain punctuation if !isValidName(name) { - return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", name) + return fmt.Errorf("Environment name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) } log.Infof("Creating environment '%s' with uri '%s'", name, uri) @@ -249,7 +248,7 @@ func (m *manager) SetEnvironment(name string, desired *Environment) error { // ensure new environment name does not contain punctuation if !isValidName(desired.Name) { - return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", desired.Name) + return fmt.Errorf("Environment name '%s' is not valid; must not contain punctuation, spaces, or begin or end with a slash", name) } // If the name has changed, the directory location needs to be moved to @@ -349,10 +348,3 @@ func (m *manager) environmentExists(name string) (bool, error) { return envExists, nil } - -// regex matcher to ensure environment name does not contain punctuation -func isValidName(envName string) bool { - hasPunctuation := regexp.MustCompile(`[,;.':!()?"{}\[\]*&%@$]+`).MatchString - hasTrailingSlashes := regexp.MustCompile(`/+$`).MatchString - return !hasPunctuation(envName) && !hasTrailingSlashes(envName) -} diff --git a/metadata/interface.go b/metadata/interface.go index c59c9b7777da02ff2931637293d952b7aad51d9c..afdb46dfefe106e48243a9a02a5819a77c01f42d 100644 --- a/metadata/interface.go +++ b/metadata/interface.go @@ -17,7 +17,10 @@ package metadata import ( "os" + "regexp" + "strings" + "github.com/ksonnet/kubecfg/prototype" "github.com/spf13/afero" ) @@ -39,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 @@ -85,6 +89,23 @@ func ParseClusterSpec(specFlag string) (ClusterSpec, error) { return parseClusterSpec(specFlag, appFS) } +// isValidName returns true if a name (e.g., for an environment) is valid. +// Broadly, this means it does not contain punctuation, whitespace, leading or +// trailing slashes. +func isValidName(name string) bool { + // No unicode whitespace is allowed. `Fields` doesn't handle trailing or + // leading whitespace. + fields := strings.Fields(name) + if len(fields) > 1 || len(strings.TrimSpace(name)) != len(name) { + return false + } + + hasPunctuation := regexp.MustCompile(`[\\,;':!()?"{}\[\]*&%@$]+`).MatchString + hasTrailingSlashes := regexp.MustCompile(`/+$`).MatchString + hasLeadingSlashes := regexp.MustCompile(`^/+`).MatchString + return len(name) != 0 && !hasPunctuation(name) && !hasTrailingSlashes(name) && !hasLeadingSlashes(name) +} + func init() { appFS = afero.NewOsFs() } diff --git a/metadata/manager.go b/metadata/manager.go index 2950905c0eac9788531c485ba47d971f0d23fe82..55022151069a269c71fcacc720ce6c90be24172e 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) }