From 358e6c25fa54d75c2fad5eedc5655eeb7ca81ec6 Mon Sep 17 00:00:00 2001 From: Alex Clemmer <clemmer.alexander@gmail.com> Date: Sat, 9 Sep 2017 23:00:37 -0700 Subject: [PATCH] Add types for prototype params and templates This commit will fix #116 by introducing two new constructs to the prototype specification schema: 1. Mandatory types for prototype parameters. This lets us accept bare words on the command line, and then "do the right thing" when emitting JSON or Jsonnet. For example, say a template produces a `core.v1.Service` that exposes a port with a `--targetPort` flag. When the user passes a number (e.g., `80`) in, we should _not_ put quote marks around it, since we want to expose port `80`. When the user passes a string (e.g., `"nginxPort"`), we _should_ put quote marks around it, to denote that we're exposing the port with that name. In order to do this, we need to know they "type" of the parameter (in this case, `NumberOrString`). 2. Mandatory template types. A template can have a JSON, YAML, or Jsonent flavor, and we default to using Jsonnet. This is useful mostly to make type parameters less error-prone (since one set of parameters corresponds to one set of templates), but it also significantly de-bloats the output of commands like `search`, since one fully-qualified name can correspond to multiple flavors of the same template. --- cmd/prototype.go | 60 ++++++++---- prototype/prototype_test.go | 20 ++-- prototype/specification.go | 169 +++++++++++++++++++++++++++++++--- prototype/systemPrototypes.go | 158 ++++++++++++++++++++++++++----- 4 files changed, 346 insertions(+), 61 deletions(-) diff --git a/cmd/prototype.go b/cmd/prototype.go index 097281ff..92f85e91 100644 --- a/cmd/prototype.go +++ b/cmd/prototype.go @@ -104,8 +104,8 @@ var prototypeDescribeCmd = &cobra.Command{ fmt.Println(`OPTIONAL PARAMETERS:`) fmt.Println(proto.OptionalParams().PrettyString(" ")) fmt.Println() - fmt.Println(`TEMPLATE:`) - fmt.Println(strings.Join(proto.Template.Body, "\n")) + fmt.Println(`TEMPLATE TYPES AVAILABLE:`) + fmt.Println(fmt.Sprintf(" %s", proto.Template.AvailableTemplates())) return nil }, @@ -163,7 +163,7 @@ var prototypeSearchCmd = &cobra.Command{ } var prototypeUseCmd = &cobra.Command{ - Use: "use <prototype-name> [parameter-flags]", + Use: "use <prototype-name> [type] [parameter-flags]", Short: `Instantiate prototype, emitting the generated code to stdout.`, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, rawArgs []string) error { @@ -187,9 +187,27 @@ var prototypeUseCmd = &cobra.Command{ } cmd.DisableFlagParsing = false - cmd.ParseFlags(rawArgs) + err = cmd.ParseFlags(rawArgs) + if err != nil { + return err + } flags := cmd.Flags() + // 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 templateType prototype.TemplateType + if args := flags.Args(); len(args) == 1 { + templateType = prototype.Jsonnet + } else if len(args) == 2 { + templateType, err = prototype.ParseTemplateType(args[1]) + if err != nil { + return err + } + } else { + return fmt.Errorf("Incorrect number of arguments supplied to 'prototype use'\n\n%s", cmd.UsageString()) + } + missingReqd := prototype.ParamSchemas{} values := map[string]string{} for _, param := range proto.RequiredParams() { @@ -202,7 +220,11 @@ var prototypeUseCmd = &cobra.Command{ return fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name) } - values[param.Name] = val + quoted, err := param.Quote(val) + if err != nil { + return err + } + values[param.Name] = quoted } if len(missingReqd) > 0 { @@ -217,10 +239,19 @@ var prototypeUseCmd = &cobra.Command{ return fmt.Errorf("Prototype '%s' has multiple parameters with name '%s'", proto.Name, param.Name) } - values[param.Name] = val + quoted, err := param.Quote(val) + if err != nil { + return err + } + values[param.Name] = quoted } - tm := snippet.Parse(strings.Join(proto.Template.Body, "\n")) + 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 @@ -241,16 +272,13 @@ unique enough to resolve to 'io.ksonnet.pkg.prototype.simple-deployment'.`, # the 'nginx' image, and port 80 exposed. ksonnet prototype use io.ksonnet.pkg.prototype.simple-deployment \ --name=nginx \ - --image=nginx \ - --port=80 \ - --portName=http + --image=nginx - # Instantiate prototype using a unique suffix of an identifier. That is, this - # command only requires a long enough suffix to uniquely identify a ksonnet - # prototype. In this example, the suffix 'simple-deployment' is enough to - # uniquely identify 'io.ksonnet.pkg.prototype.simple-deployment', but - # 'deployment' might not be, as several names end with that suffix. - ksonnet prototype describe simple-deployment`, + # 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 \ + --image=nginx`, } func fundUniquePrototype(query string) (*prototype.SpecificationSchema, error) { diff --git a/prototype/prototype_test.go b/prototype/prototype_test.go index 30c5473e..5f78632b 100644 --- a/prototype/prototype_test.go +++ b/prototype/prototype_test.go @@ -110,16 +110,16 @@ func TestSearch(t *testing.T) { assertSearch(t, idx, Prefix, "service", []string{}) assertSearch(t, idx, Prefix, "simple", []string{}) assertSearch(t, idx, Prefix, "io.ksonnet", []string{ - "io.ksonnet.pkg.yaml-single-port-service", - "io.ksonnet.pkg.yaml-namespace", - "io.ksonnet.pkg.yaml-empty-configMap", - "io.ksonnet.pkg.yaml-single-port-deployment", + "io.ksonnet.pkg.single-port-service", + "io.ksonnet.pkg.namespace", + "io.ksonnet.pkg.configMap", + "io.ksonnet.pkg.single-port-deployment", }) assertSearch(t, idx, Prefix, "foo", []string{}) // Suffix searches. assertSearch(t, idx, Suffix, "service", []string{ - "io.ksonnet.pkg.yaml-single-port-service", + "io.ksonnet.pkg.single-port-service", "io.some-vendor.pkg.simple-service", }) assertSearch(t, idx, Suffix, "simple", []string{}) @@ -128,7 +128,7 @@ func TestSearch(t *testing.T) { // Substring searches. assertSearch(t, idx, Substring, "service", []string{ - "io.ksonnet.pkg.yaml-single-port-service", + "io.ksonnet.pkg.single-port-service", "io.some-vendor.pkg.simple-service", }) assertSearch(t, idx, Substring, "simple", []string{ @@ -136,10 +136,10 @@ func TestSearch(t *testing.T) { "io.some-vendor.pkg.simple-service", }) assertSearch(t, idx, Substring, "io.ksonnet", []string{ - "io.ksonnet.pkg.yaml-single-port-service", - "io.ksonnet.pkg.yaml-single-port-deployment", - "io.ksonnet.pkg.yaml-empty-configMap", - "io.ksonnet.pkg.yaml-namespace", + "io.ksonnet.pkg.single-port-service", + "io.ksonnet.pkg.single-port-deployment", + "io.ksonnet.pkg.configMap", + "io.ksonnet.pkg.namespace", }) assertSearch(t, idx, Substring, "foo", []string{}) } diff --git a/prototype/specification.go b/prototype/specification.go index d957cda6..d0322d52 100644 --- a/prototype/specification.go +++ b/prototype/specification.go @@ -2,6 +2,7 @@ package prototype import ( "fmt" + "strconv" "strings" ) @@ -50,6 +51,34 @@ func (s *SpecificationSchema) OptionalParams() ParamSchemas { return opt } +// TemplateType represents the possible type of a prototype. +type TemplateType string + +const ( + // YAML represents a prototype written in YAML. + YAML TemplateType = "yaml" + + // JSON represents a prototype written in JSON. + JSON TemplateType = "json" + + // Jsonnet represents a prototype written in Jsonnet. + Jsonnet TemplateType = "jsonnet" +) + +// ParseTemplateType attempts to parse a string as a `TemplateType`. +func ParseTemplateType(t string) (TemplateType, error) { + switch strings.ToLower(t) { + case "yaml": + return YAML, nil + case "json": + return JSON, nil + case "jsonnet": + return Jsonnet, nil + default: + return "", fmt.Errorf("Unrecognized template type '%s'; must be one of: [yaml, json, jsonnet]", t) + } +} + // SnippetSchema is the JSON-serializable representation of the TextMate snippet // specification, as implemented by the Language Server Protocol. type SnippetSchema struct { @@ -58,38 +87,150 @@ type SnippetSchema struct { // Description describes what the prototype does. Description string `json:"description"` - // Body of the prototype. Follows the TextMate snippets syntax, with several - // features disallowed. - Body []string `json:"body"` + // Various body types of the prototype. Follows the TextMate snippets syntax, + // with several features disallowed. At least one of these is required to be + // filled out. + JSONBody []string `json:"jsonBody"` + YAMLBody []string `json:"yamlBody"` + JsonnetBody []string `json:"jsonnetBody"` } -// ParamSchema is the JSON-serializable representation of a parameter provided to a prototype. +// Body attempts to retrieve the template body associated with some +// type `t`. +func (schema *SnippetSchema) Body(t TemplateType) (template []string, err error) { + switch t { + case YAML: + template = schema.YAMLBody + case JSON: + template = schema.JSONBody + case Jsonnet: + template = schema.JsonnetBody + default: + return nil, fmt.Errorf("Unrecognized template type '%s'; must be one of: [yaml, json, jsonnet]", t) + } + + if len(template) == 0 { + available := schema.AvailableTemplates() + err = fmt.Errorf("Template does not have a template for type '%s'. Available types: %s", t, available) + } + + return +} + +// AvailableTemplates returns the list of available `TemplateType`s this +// prototype implements. +func (schema *SnippetSchema) AvailableTemplates() (ts []TemplateType) { + if len(schema.YAMLBody) != 0 { + ts = append(ts, YAML) + } + + if len(schema.JSONBody) != 0 { + ts = append(ts, JSON) + } + + if len(schema.JsonnetBody) != 0 { + ts = append(ts, Jsonnet) + } + + return +} + +// ParamType represents a type constraint for a prototype parameter (e.g., it +// must be a number). +type ParamType string + +const ( + // Number represents a prototype parameter that must be a number. + Number ParamType = "number" + + // String represents a prototype parameter that must be a string. + String ParamType = "string" + + // NumberOrString represents a prototype parameter that must be either a + // number or a string. + NumberOrString ParamType = "numberOrString" + + // Object represents a prototype parameter that must be an object. + Object ParamType = "object" + + // Array represents a prototype parameter that must be a array. + Array ParamType = "array" +) + +func (pt ParamType) String() string { + switch pt { + case Number: + return "number" + case String: + return "string" + case NumberOrString: + return "number-or-string" + case Object: + return "object" + case Array: + return "array" + default: + return "unknown" + } +} + +// ParamSchema is the JSON-serializable representation of a parameter provided +// to a prototype. type ParamSchema struct { - Name string `json:"name"` - Alias *string `json:"alias"` // Optional. - Description string `json:"description"` - Default *string `json:"default"` // `nil` only if the parameter is optional. + Name string `json:"name"` + Alias *string `json:"alias"` // Optional. + Description string `json:"description"` + Default *string `json:"default"` // `nil` only if the parameter is optional. + Type ParamType `json:"type"` +} + +// Quote will parse a prototype parameter and quote it appropriately, so that it +// shows up correctly in Jsonnet source code. For example, `--image nginx` would +// likely need to show up as `"nginx"` in Jsonnet source. +func (ps *ParamSchema) Quote(value string) (string, error) { + switch ps.Type { + case Number: + _, err := strconv.ParseFloat(value, 64) + if err != nil { + return "", fmt.Errorf("Could not convert parameter '%s' to a number", ps.Name) + } + return value, nil + case String: + return fmt.Sprintf("\"%s\"", value), nil + case NumberOrString: + _, err := strconv.ParseFloat(value, 64) + if err == nil { + return value, nil + } + return fmt.Sprintf("\"%s\"", value), nil + case Array, Object: + return value, nil + default: + return "", fmt.Errorf("Unknown param type for param '%s'", ps.Name) + } } // RequiredParam constructs a required parameter, i.e., a parameter that is // meant to be required by some prototype, somewhere. -func RequiredParam(name, alias, description string) *ParamSchema { +func RequiredParam(name, alias, description string, t ParamType) *ParamSchema { return &ParamSchema{ Name: name, Alias: &alias, Description: description, Default: nil, + Type: t, } } // OptionalParam constructs an optional parameter, i.e., a parameter that is // meant to be optionally provided to some prototype, somewhere. -func OptionalParam(name, alias, description, defaultVal string) *ParamSchema { +func OptionalParam(name, alias, description, defaultVal string, t ParamType) *ParamSchema { return &ParamSchema{ Name: name, Alias: &alias, Description: description, Default: &defaultVal, + Type: t, } } @@ -124,15 +265,17 @@ func (ps ParamSchemas) PrettyString(prefix string) string { p := ps[i] flag := flags[i] - defaultVal := "" + var info string if p.Default != nil { - defaultVal = fmt.Sprintf(" [default: %s]", *p.Default) + info = fmt.Sprintf(" [default: %s, type: %s]", *p.Default, p.Type.String()) + } else { + info = fmt.Sprintf(" [type: %s]", p.Type.String()) } // NOTE: If we don't add 1 here, the longest line will look like: // `--flag=<flag>Description is here.` space := strings.Repeat(" ", max-len(flag)+1) - pretty := fmt.Sprintf(prefix + flag + space + p.Description + defaultVal) + pretty := fmt.Sprintf(prefix + flag + space + p.Description + info) prettyFlags = append(prettyFlags, pretty) } diff --git a/prototype/systemPrototypes.go b/prototype/systemPrototypes.go index 679a43fb..192c575e 100644 --- a/prototype/systemPrototypes.go +++ b/prototype/systemPrototypes.go @@ -3,14 +3,14 @@ package prototype var defaultPrototypes = []*SpecificationSchema{ &SpecificationSchema{ APIVersion: "0.1", - Name: "io.ksonnet.pkg.yaml-namespace", + Name: "io.ksonnet.pkg.namespace", Params: ParamSchemas{ - RequiredParam("name", "name", "Name to give the namespace."), + RequiredParam("name", "name", "Name to give the namespace.", String), }, Template: SnippetSchema{ Description: `A simple namespace. Labels are automatically populated from the name of the namespace.`, - Body: []string{ + YAMLBody: []string{ "kind: Namespace", "apiVersion: v1", "metadata:", @@ -18,22 +18,42 @@ namespace.`, " labels:", " name: ${name}", }, + JSONBody: []string{ + `{`, + ` "kind": "Namespace",`, + ` "apiVersion": "v1",`, + ` "metadata": {`, + ` "name": ${name},`, + ` "labels": {`, + ` "name": ${name}`, + ` }`, + ` }`, + `}`, + }, + JsonnetBody: []string{ + `local k = import "k.libsonnet";`, + `local ns = k.core.v1.namespace;`, + ``, + `ns.new() +`, + `ns.mixin.metadata.name(${name}) +`, + `ns.mixin.metadata.labels({name: ${name}})`, + }, }, }, &SpecificationSchema{ APIVersion: "0.1", - Name: "io.ksonnet.pkg.yaml-single-port-service", + Name: "io.ksonnet.pkg.single-port-service", Params: ParamSchemas{ - RequiredParam("name", "serviceName", "Name of the service"), - RequiredParam("targetLabelSelector", "selector", "Label for the service to target (e.g., 'app: MyApp')."), - RequiredParam("servicePort", "port", "Port for the service to expose."), - RequiredParam("targetPort", "port", "Port for the service target."), - OptionalParam("protocol", "protocol", "Protocol to use (either TCP or UDP).", "TCP"), + RequiredParam("name", "serviceName", "Name of the service", String), + RequiredParam("targetLabelSelector", "selector", "Label for the service to target (e.g., 'app: MyApp').", Object), + RequiredParam("servicePort", "port", "Port for the service to expose.", NumberOrString), + RequiredParam("targetPort", "port", "Port for the service target.", NumberOrString), + OptionalParam("protocol", "protocol", "Protocol to use (either TCP or UDP).", "TCP", String), }, Template: SnippetSchema{ Description: `A service that exposes 'servicePort', and directs traffic to 'targetLabelSelector', at 'targetPort'.`, - Body: []string{ + YAMLBody: []string{ "kind: Service", "apiVersion: v1", "metadata:", @@ -46,40 +66,89 @@ to 'targetLabelSelector', at 'targetPort'.`, " port: ${servicePort}", " targetPort: ${targetPort}", }, + JSONBody: []string{ + `{`, + ` "kind": "Service",`, + ` "apiVersion": "v1",`, + ` "metadata": {`, + ` "name": ${name}`, + ` },`, + ` "spec": {`, + ` "selector": {`, + ` ${targetLabelSelector}`, + ` },`, + ` "ports": [`, + ` {`, + ` "protocol": ${protocol},`, + ` "port": ${servicePort},`, + ` "targetPort": ${targetPort}`, + ` }`, + ` ]`, + ` }`, + `}`, + }, + JsonnetBody: []string{ + `local k = import "k.libsonnet";`, + `local service = k.core.v1.service;`, + `local port = k.core.v1.service.mixin.spec.portsType;`, + ``, + `service.new(`, + ` ${name},`, + ` ${targetLabelSelector},`, + ` port.new(${servicePort}, ${targetPort}))`, + }, }, }, &SpecificationSchema{ APIVersion: "0.1", - Name: "io.ksonnet.pkg.yaml-empty-configMap", + Name: "io.ksonnet.pkg.empty-configMap", Params: ParamSchemas{ - RequiredParam("serviceName", "name", "Name to give the configMap."), + RequiredParam("name", "name", "Name to give the configMap.", String), + OptionalParam("data", "data", "Data for the configMap.", "{}", Object), }, Template: SnippetSchema{ Description: `A simple config map. Contains no data.`, - Body: []string{ + YAMLBody: []string{ "apiVersion: v1", "kind: ConfigMap", "metadata:", " name: ${name}", - "data:", - " // K/V pairs go here.", + "data: ${data}", + }, + JSONBody: []string{ + `{`, + ` "apiVersion": "v1",`, + ` "kind": "ConfigMap",`, + ` "metadata": {`, + ` "name": ${name}`, + ` },`, + ` "data": ${data}`, + `}`, + }, + JsonnetBody: []string{ + `local k = import "k.libsonnet";`, + `local configMap = k.core.v1.configMap;`, + ``, + `configMap.new() +`, + `configMap.mixin.metadata.name("${name}") +`, + `configMap.data("${data}")`, }, }, }, &SpecificationSchema{ APIVersion: "0.1", - Name: "io.ksonnet.pkg.yaml-single-port-deployment", + Name: "io.ksonnet.pkg.single-port-deployment", Params: ParamSchemas{ - RequiredParam("name", "deploymentName", "Name of the deployment"), - RequiredParam("image", "containerImage", "Container image to deploy"), - OptionalParam("replicas", "replicas", "Number of replicas", "1"), - OptionalParam("port", "containerPort", "Port to expose", "80"), + RequiredParam("name", "deploymentName", "Name of the deployment", String), + RequiredParam("image", "containerImage", "Container image to deploy", String), + OptionalParam("replicas", "replicas", "Number of replicas", "1", Number), + OptionalParam("port", "containerPort", "Port to expose", "80", NumberOrString), }, Template: SnippetSchema{ Description: `A deployment that replicates container 'image' some number of times (default: 1), and exposes a port (default: 80). Labels are automatically populated from 'name'.`, - Body: []string{ + YAMLBody: []string{ "apiVersion: apps/v1beta1", "kind: Deployment", "metadata:", @@ -95,7 +164,52 @@ populated from 'name'.`, " - name: ${name}", " image: ${image}", " ports:", - " - containerPort: ${containerPort:80}", + " - containerPort: ${port:80}", + }, + JSONBody: []string{ + `{`, + ` "apiVersion": "apps/v1beta1",`, + ` "kind": "Deployment",`, + ` "metadata": {`, + ` "name": ${name}`, + ` },`, + ` "spec": {`, + ` "replicas": ${replicas:1},`, + ` "template": {`, + ` "metadata": {`, + ` "labels": {`, + ` "app": ${name}`, + ` }`, + ` },`, + ` "spec": {`, + ` "containers": [`, + ` {`, + ` "name": ${name},`, + ` "image": ${image},`, + ` "ports": [`, + ` {`, + ` "containerPort": ${port:80}`, + ` }`, + ` ]`, + ` }`, + ` ]`, + ` }`, + ` }`, + ` }`, + `}`, + }, + JsonnetBody: []string{ + `local k = import "k.libsonnet";`, + `local deployment = k.apps.v1beta1.deployment;`, + `local container = k.apps.v1beta1.deployment.mixin.spec.template.spec.containersType;`, + `local port = container.portsType;`, + ``, + `deployment.new(`, + ` ${name},`, + ` ${replicas},`, + ` container.new(${name}, ${image}) +`, + ` container.ports(port.new(${port:80})),`, + ` {app: ${name}})`, }, }, }, -- GitLab