diff --git a/cmd/prototype.go b/cmd/prototype.go index 097281ffe4ec47525300c86353219d059cab12d7..92f85e91f1425338c58e9f554c5e4b1e08e7c68f 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 30c5473e53ae7824fa6e293cf9766c6a4dd660ed..5f78632b17dbd693fd7aa57c8c8f358b8c7cf005 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 d957cda66735ab3f8639006df24cd1d056cf928b..d0322d52c7820ddb498244961ba4ced56fa96bdd 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 679a43fb4b1014b248aa511108d5a86ccebe559f..192c575ea239de95cd29a1c6f04e1529876ae031 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}})`, }, }, },