Skip to content
Snippets Groups Projects
Commit e8851be8 authored by Alex Clemmer's avatar Alex Clemmer
Browse files

Introduce `prototype` package

This commit will introduce a simple skeleton of the `prototype` package,
which will (eventually) form the basis of the prototype command
(including the `search`, `describe`, and `use` subcommands as dictated
by the specification).
parent 434d1f67
No related branches found
No related tags found
No related merge requests found
package prototype
import (
"fmt"
"strings"
)
const (
delimiter = "\x00"
)
type index struct {
prototypes map[string]*SpecificationSchema
}
func (idx *index) SearchNames(query string, opts SearchOptions) ([]*SpecificationSchema, error) {
// TODO(hausdorff): This is the world's worst search algorithm. Improve it at
// some point.
prototypes := []*SpecificationSchema{}
for name, prototype := range idx.prototypes {
isSearchResult := false
switch opts {
case Prefix:
isSearchResult = strings.HasPrefix(name, query)
case Suffix:
isSearchResult = strings.HasSuffix(name, query)
case Substring:
isSearchResult = strings.Contains(name, query)
default:
return nil, fmt.Errorf("Unrecognized search option '%d'", opts)
}
if isSearchResult {
prototypes = append(prototypes, prototype)
}
}
return prototypes, nil
}
package prototype
import "encoding/json"
// Unmarshal takes the bytes of a JSON-encoded prototype specification, and
// deserializes them to a `SpecificationSchema`.
func Unmarshal(bytes []byte) (*SpecificationSchema, error) {
var p SpecificationSchema
err := json.Unmarshal(bytes, &p)
if err != nil {
return nil, err
}
return &p, nil
}
// SearchOptions represents the type of prototype search to execute on an
// `Index`.
type SearchOptions int
const (
// Prefix represents a search over prototype name prefixes.
Prefix SearchOptions = iota
// Suffix represents a search over prototype name suffices.
Suffix
// Substring represents a search over substrings of prototype names.
Substring
)
// Index represents a queryable index of prototype specifications.
type Index interface {
SearchNames(query string, opts SearchOptions) ([]*SpecificationSchema, error)
}
// NewIndex constructs an index of prototype specifications from a list.
func NewIndex(prototypes []*SpecificationSchema) Index {
idx := map[string]*SpecificationSchema{}
for _, p := range defaultPrototypes {
idx[p.Name] = p
}
for _, p := range prototypes {
idx[p.Name] = p
}
return &index{
prototypes: idx,
}
}
package prototype
import (
"sort"
"testing"
)
const (
unmarshalErrPattern = "Expected value of %s: '%s', got: '%s'"
)
var simpleService = `{
"apiVersion": "0.1",
"name": "io.some-vendor.pkg.simple-service",
"template": {
"description": "Generates a simple service with a port exposed",
"body": [
"local k = import 'ksonnet.beta.2/k.libsonnet';",
"",
"local service = k.core.v1.service;",
"local servicePort = k.core.v1.service.mixin.spec.portsType;",
"local port = servicePort.new(std.extVar('port'), std.extVar('portName'));",
"",
"local name = std.extVar('name');",
"k.core.v1.service.new('%-service' % name, {app: name}, port)"
]
}
}`
var simpleDeployment = `{
"apiVersion": "0.1",
"name": "io.some-vendor.pkg.simple-deployment",
"template": {
"description": "Generates a simple service with a port exposed",
"body": [
"local k = import 'ksonnet.beta.2/k.libsonnet';",
"local deployment = k.apps.v1beta1.deployment;",
"local container = deployment.mixin.spec.template.spec.containersType;",
"",
"local appName = std.extVar('name');",
"local appContainer = container.new(appName, std.extVar('image'));",
"deployment.new(appName, std.extVar('replicas'), appContainer, {app: appName})"
]
}
}`
func unmarshal(t *testing.T, bytes []byte) *SpecificationSchema {
p, err := Unmarshal(bytes)
if err != nil {
t.Fatalf("Failed to deserialize prototype:\n%v", err)
}
return p
}
func assertProp(t *testing.T, name string, expected string, actual string) {
if actual != expected {
t.Errorf(unmarshalErrPattern, name, expected, actual)
}
}
func TestSimpleUnmarshal(t *testing.T) {
p := unmarshal(t, []byte(simpleService))
assertProp(t, "apiVersion", p.APIVersion, "0.1")
assertProp(t, "name", p.Name, "io.some-vendor.pkg.simple-service")
assertProp(t, "description", p.Template.Description, "Generates a simple service with a port exposed")
}
var testPrototypes = map[string]string{
"io.ksonnet.pkg.simple-service": simpleService,
}
func assertSearch(t *testing.T, idx Index, opts SearchOptions, query string, expectedNames []string) {
ps, err := idx.SearchNames(query, opts)
if err != nil {
t.Fatalf("Failed to search index:\n%v", err)
}
sort.Slice(ps, func(i, j int) bool {
return ps[i].Name < ps[j].Name
})
actualNames := []string{}
for _, p := range ps {
actualNames = append(actualNames, p.Name)
}
sort.Slice(expectedNames, func(i, j int) bool {
return expectedNames[i] < expectedNames[j]
})
if len(expectedNames) != len(ps) {
t.Fatalf("Query '%s' returned results:\n%s, but expected:\n%s", query, actualNames, expectedNames)
}
for i := 0; i < len(expectedNames); i++ {
if actualNames[i] != expectedNames[i] {
t.Fatalf("Query '%s' returned results:\n%s, but expected:\n%s", query, actualNames, expectedNames)
}
}
}
func TestSearch(t *testing.T) {
svc := unmarshal(t, []byte(simpleService))
depl := unmarshal(t, []byte(simpleDeployment))
idx := NewIndex([]*SpecificationSchema{svc, depl})
// Prefix searches.
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",
})
assertSearch(t, idx, Prefix, "foo", []string{})
// Suffix searches.
assertSearch(t, idx, Suffix, "service", []string{
"io.ksonnet.pkg.yaml-single-port-service",
"io.some-vendor.pkg.simple-service",
})
assertSearch(t, idx, Suffix, "simple", []string{})
assertSearch(t, idx, Suffix, "io.ksonnet", []string{})
assertSearch(t, idx, Suffix, "foo", []string{})
// Substring searches.
assertSearch(t, idx, Substring, "service", []string{
"io.ksonnet.pkg.yaml-single-port-service",
"io.some-vendor.pkg.simple-service",
})
assertSearch(t, idx, Substring, "simple", []string{
"io.some-vendor.pkg.simple-deployment",
"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",
})
assertSearch(t, idx, Substring, "foo", []string{})
}
package prototype
import (
"fmt"
"strings"
)
//
// NOTE: These members would ordinarily be private and exposed by interfaces,
// but because Go requires public structs for un/marshalling, it is more
// convenient to simply expose all of them.
//
// SpecificationSchema is the JSON-serializable representation of a prototype
// specification.
type SpecificationSchema struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
// Unique identifier of the mixin library. The most reliable way to make a
// name unique is to embed a domain you own into the name, as is commonly done
// in the Java community.
Name string `json:"name"`
Params ParamSchemas `json:"params"`
Template SnippetSchema `json:"template"`
}
// RequiredParams retrieves all parameters that are required by a prototype.
func (s *SpecificationSchema) RequiredParams() ParamSchemas {
reqd := ParamSchemas{}
for _, p := range s.Params {
if p.Default == nil {
reqd = append(reqd, p)
}
}
return reqd
}
// OptionalParams retrieves all parameters that can optionally be provided to a
// prototype.
func (s *SpecificationSchema) OptionalParams() ParamSchemas {
opt := ParamSchemas{}
for _, p := range s.Params {
if p.Default != nil {
opt = append(opt, p)
}
}
return opt
}
// SnippetSchema is the JSON-serializable representation of the TextMate snippet
// specification, as implemented by the Language Server Protocol.
type SnippetSchema struct {
Prefix string `json:"prefix"`
// 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"`
}
// 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.
}
// 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 {
return &ParamSchema{
Name: name,
Alias: &alias,
Description: description,
Default: nil,
}
}
// 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 {
return &ParamSchema{
Name: name,
Alias: &alias,
Description: description,
Default: &defaultVal,
}
}
// ParamSchemas is a slice of `ParamSchema`
type ParamSchemas []*ParamSchema
// PrettyString creates a prettified string representing a collection of
// parameters.
func (ps ParamSchemas) PrettyString(prefix string) string {
if len(ps) == 0 {
return " [none]"
}
flags := []string{}
for _, p := range ps {
alias := p.Name
if p.Alias != nil {
alias = *p.Alias
}
flags = append(flags, fmt.Sprintf("--%s=<%s>", p.Name, alias))
}
max := 0
for _, flag := range flags {
if flagLen := len(flag); max < flagLen {
max = flagLen
}
}
prettyFlags := []string{}
for i := range flags {
p := ps[i]
flag := flags[i]
defaultVal := ""
if p.Default != nil {
defaultVal = fmt.Sprintf(" [default: %s]", *p.Default)
}
// 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)
prettyFlags = append(prettyFlags, pretty)
}
return strings.Join(prettyFlags, "\n")
}
package prototype
var defaultPrototypes = []*SpecificationSchema{
&SpecificationSchema{
APIVersion: "0.1",
Name: "io.ksonnet.pkg.yaml-namespace",
Params: ParamSchemas{
RequiredParam("name", "name", "Name to give the namespace."),
},
Template: SnippetSchema{
Description: `A simple namespace. Labels are automatically populated from the name of the
namespace.`,
Body: []string{
"kind: Namespace",
"apiVersion: v1",
"metadata:",
" name: ${name}",
" labels:",
" name: ${name}",
},
},
},
&SpecificationSchema{
APIVersion: "0.1",
Name: "io.ksonnet.pkg.yaml-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"),
},
Template: SnippetSchema{
Description: `A service that exposes 'servicePort', and directs traffic
to 'targetLabelSelector', at 'targetPort'.`,
Body: []string{
"kind: Service",
"apiVersion: v1",
"metadata:",
" name: ${name}",
"spec:",
" selector:",
" ${targetLabelSelector}",
" ports:",
" - protocol: ${protocol}",
" port: ${servicePort}",
" targetPort: ${targetPort}",
},
},
},
&SpecificationSchema{
APIVersion: "0.1",
Name: "io.ksonnet.pkg.yaml-empty-configMap",
Params: ParamSchemas{
RequiredParam("serviceName", "name", "Name to give the configMap."),
},
Template: SnippetSchema{
Description: `A simple config map. Contains no data.`,
Body: []string{
"apiVersion: v1",
"kind: ConfigMap",
"metadata:",
" name: ${name}",
"data:",
" // K/V pairs go here.",
},
},
},
&SpecificationSchema{
APIVersion: "0.1",
Name: "io.ksonnet.pkg.yaml-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"),
},
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{
"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: ${containerPort:80}",
},
},
},
}
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment