From 6f8c9596fa5094ef5313862d76066e594939261b Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Wed, 13 Mar 2024 10:54:09 -0700 Subject: [PATCH] Resolves #404 - Add template support for attributes --- cmd/create.go | 11 ++-- cmd/delete.go | 11 ++-- cmd/helper.go | 5 +- cmd/login.go | 11 ++-- cmd/root.go | 1 - cmd/runbooks.go | 2 - cmd/update.go | 11 ++-- docs/runbook-development.md | 22 +++---- external/completion/completion.go | 80 +++++++++++++++++++++++++- external/completion/completion_test.go | 71 +++++++++++++++++++++++ external/json/to_json.go | 8 +++ external/json/to_json_test.go | 45 +++++++++++++++ external/runbooks/runbook_rendering.go | 12 +++- external/templates/funcs.go | 35 +++++++++++ external/templates/template.go | 44 ++++++++++++++ 15 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 external/templates/funcs.go create mode 100644 external/templates/template.go diff --git a/cmd/create.go b/cmd/create.go index 8226cbb..3d404fa 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -185,11 +185,12 @@ func NewCreateCommand(parentCmd *cobra.Command) func() { }) } else { // This is an attribute value return completion.Complete(completion.Request{ - Type: completion.CompleteAttributeValue, - Resource: resource, - Verb: completion.Create, - Attribute: args[len(args)-1], - ToComplete: toComplete, + Type: completion.CompleteAttributeValue, + Resource: resource, + Verb: completion.Create, + Attribute: args[len(args)-1], + ToComplete: toComplete, + AllowTemplates: true, }) } } else { diff --git a/cmd/delete.go b/cmd/delete.go index 5f04fd5..eaafd5e 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -161,11 +161,12 @@ func NewDeleteCommand(parentCmd *cobra.Command) func() { }) } else { // This is an attribute value return completion.Complete(completion.Request{ - Type: completion.CompleteAttributeValue, - Resource: resource, - Verb: completion.Delete, - Attribute: args[len(args)-1], - ToComplete: toComplete, + Type: completion.CompleteAttributeValue, + Resource: resource, + Verb: completion.Delete, + Attribute: args[len(args)-1], + ToComplete: toComplete, + AllowTemplates: true, }) } } diff --git a/cmd/helper.go b/cmd/helper.go index ec796e8..2c05dd4 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -299,7 +299,9 @@ epcc %s %s%s key [] => %s # To send a nested object use the . character to nest values deeper. epcc %s %s%s key.some.child hello key.some.other goodbye => %s -`, + +# Attributes can also be generated using Go templates and Sprig (https://masterminds.github.io/sprig/) functions. +epcc %s %s%s key 'Test {{ randAlphaNum 6 | upper }} Value' => %s`, verb, resource.SingularName, id, toJsonExample([]string{"key", "b"}, resource), verb, resource.SingularName, id, toJsonExample([]string{"key", "1"}, resource), verb, resource.SingularName, id, toJsonExample([]string{"key", "\"1\""}, resource), @@ -310,6 +312,7 @@ epcc %s %s%s key.some.child hello key.some.other goodbye => %s verb, resource.SingularName, id, toJsonExample([]string{"key[0]", "a", "key[1]", "true"}, resource), verb, resource.SingularName, id, toJsonExample([]string{"key", "[]"}, resource), verb, resource.SingularName, id, toJsonExample([]string{"key.some.child", "hello", "key.some.other", "goodbye"}, resource), + verb, resource.SingularName, id, toJsonExample([]string{"key", "Test {{ randAlphaNum 6 | upper }} Value"}, resource), ) } diff --git a/cmd/login.go b/cmd/login.go index 52742ce..4dd30f4 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -428,11 +428,12 @@ var loginAccountManagement = &cobra.Command{ }) } else { return completion.Complete(completion.Request{ - Type: completion.CompleteAttributeValue, - Verb: completion.Create, - Resource: res, - Attributes: usedAttributes, - ToComplete: toComplete, + Type: completion.CompleteAttributeValue, + Verb: completion.Create, + Resource: res, + Attributes: usedAttributes, + ToComplete: toComplete, + AllowTemplates: true, }) } diff --git a/cmd/root.go b/cmd/root.go index 406c87e..e029948 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -290,7 +290,6 @@ func Execute() { os.Exit(1) } else { - os.Exit(0) } } diff --git a/cmd/runbooks.go b/cmd/runbooks.go index 97a0a88..2a57908 100644 --- a/cmd/runbooks.go +++ b/cmd/runbooks.go @@ -421,11 +421,9 @@ func processRunbookVariablesOnCommand(runbookActionRunActionCommand *cobra.Comma } } else { description := "" - if variable.Description != nil { description = variable.Description.Short } - runbookActionRunActionCommand.Flags().StringVar(runbookStringArguments[key], key, variable.Default, description) } diff --git a/cmd/update.go b/cmd/update.go index 2c77fde..f09e6ca 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -161,11 +161,12 @@ func NewUpdateCommand(parentCmd *cobra.Command) func() { }) } else { // This is an attribute value return completion.Complete(completion.Request{ - Type: completion.CompleteAttributeValue, - Resource: resource, - Verb: completion.Update, - Attribute: args[len(args)-1], - ToComplete: toComplete, + Type: completion.CompleteAttributeValue, + Resource: resource, + Verb: completion.Update, + Attribute: args[len(args)-1], + ToComplete: toComplete, + AllowTemplates: true, }) } } else { diff --git a/docs/runbook-development.md b/docs/runbook-development.md index 2ad7383..ef7f7fc 100644 --- a/docs/runbook-development.md +++ b/docs/runbook-development.md @@ -171,17 +171,17 @@ description: short: "A hello world runbook" actions: sequential-sleeps: - variables: - count: - type: INT - default: 2 - description: - short: "The number of sleeps" - commands: - - |2 - {{- range untilStep 0 .count 1}} - - sleep 1 - {{- end -}} + variables: + count: + type: INT + default: 2 + description: + short: "The number of sleeps" + commands: + - |2 + {{- range untilStep 0 .count 1}} + - sleep 1 + {{- end -}} concurrent-sleeps: variables: count: diff --git a/external/completion/completion.go b/external/completion/completion.go index 0b7ef56..aa47bb3 100644 --- a/external/completion/completion.go +++ b/external/completion/completion.go @@ -1,9 +1,11 @@ package completion import ( + "fmt" "github.com/elasticpath/epcc-cli/external/aliases" "github.com/elasticpath/epcc-cli/external/resources" "github.com/spf13/cobra" + "os" "regexp" "strconv" "strings" @@ -48,8 +50,9 @@ type Request struct { QueryParam string Header string // The current string argument being completed - ToComplete string - NoAliases bool + ToComplete string + NoAliases bool + AllowTemplates bool } func Complete(c Request) ([]string, cobra.ShellCompDirective) { @@ -295,6 +298,79 @@ func Complete(c Request) ([]string, cobra.ShellCompDirective) { results = append(results, supportedFileTypes...) } } + + if c.AllowTemplates { + lastPipe := strings.LastIndex(c.ToComplete, "|") + prefix := "" + if lastPipe == -1 { + prefix = "{{ " + } else { + prefix = c.ToComplete[0:lastPipe+1] + " " + } + + myResults := []string{} + myResults = append(myResults, + prefix+"date", + prefix+"now", + prefix+"randAlphaNum", + prefix+"randAlpha", + prefix+"randAscii", + prefix+"randNumeric", + prefix+"randAlphaNum", + prefix+"randAlpha", + prefix+"randAscii", + prefix+"randNumeric", + prefix+"pseudoRandAlphaNum", + prefix+"pseudoRandAlpha", + prefix+"pseudoRandNumeric", + prefix+"pseudoRandString", + prefix+"pseudoRandInt", + prefix+"uuidv4", + prefix+"duration", + ) + + if prefix != "{{ " { + // Functions that make sense as continuations + myResults = append(myResults, + prefix+"trim", + prefix+"trimAll", + prefix+"trimSuffix", + prefix+"trimPrefix", + prefix+"upper", + prefix+"lower", + prefix+"title", + prefix+"repeat", + prefix+"substr", + prefix+"nospace", + prefix+"trunc", + prefix+"abbrev", + prefix+"initials", + prefix+"wrap", + prefix+"cat", + prefix+"replace", + prefix+"snakecase", + prefix+"camelcase", + prefix+"kebabcase", + prefix+"swapcase", + prefix+"shufflecase", + ) + } + + re := regexp.MustCompile(`env\s+[A-Za-z]*\s*$`) + if re.MatchString(c.ToComplete) { + for _, v := range os.Environ() { + myResults = append(myResults, + fmt.Sprintf("%venv \"%v\"", prefix, strings.Split(v, "=")[0]), + ) + } + } else { + myResults = append(myResults, prefix+"env") + } + //myResults = append(myResults, strings.TrimSuffix(c.ToComplete, " ")+" }}", strings.TrimSuffix(c.ToComplete, " ")+" |") + for _, r := range myResults { + results = append(results, r+" |", r+" }}") + } + } } } diff --git a/external/completion/completion_test.go b/external/completion/completion_test.go index 2078246..f9307d0 100644 --- a/external/completion/completion_test.go +++ b/external/completion/completion_test.go @@ -1,6 +1,7 @@ package completion import ( + "github.com/elasticpath/epcc-cli/external/resources" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "testing" @@ -72,3 +73,73 @@ func TestHeaderValueWithNonNilValueCompletes(t *testing.T) { require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) require.Contains(t, completions, "USD") } + +func TestAttributeValueWithNoTemplating(t *testing.T) { + // Fixture Setup + toComplete := "" + acct := resources.MustGetResourceByName("password-profiles") + request := Request{ + Type: CompleteAttributeValue, + Verb: Create, + ToComplete: toComplete, + Attribute: "username_format", + Resource: acct, + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "any") + require.Contains(t, completions, "email") + require.Equal(t, 2, len(completions)) +} + +func TestAttributeValueWithTemplating(t *testing.T) { + // Fixture Setup + toComplete := "" + acct := resources.MustGetResourceByName("password-profiles") + request := Request{ + Type: CompleteAttributeValue, + Verb: Create, + ToComplete: toComplete, + Attribute: "username_format", + Resource: acct, + AllowTemplates: true, + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "any") + require.Contains(t, completions, "email") + require.Contains(t, completions, `{{\ randAlphaNum\ |`) + require.Contains(t, completions, `{{\ randAlphaNum\ }}`) +} + +func TestAttributeValueWithTemplatingAndPipe(t *testing.T) { + // Fixture Setup + toComplete := "{{ randAlphaNum 3 | " + acct := resources.MustGetResourceByName("password-profiles") + request := Request{ + Type: CompleteAttributeValue, + Verb: Create, + ToComplete: toComplete, + Attribute: "username_format", + Resource: acct, + AllowTemplates: true, + } + + // Exercise SUT + completions, compDir := Complete(request) + + // Verify Results + require.Equal(t, compDir, cobra.ShellCompDirectiveNoFileComp) + require.Contains(t, completions, "any") + require.Contains(t, completions, "email") + require.Contains(t, completions, `{{\ randAlphaNum\ 3\ |\ upper\ |`) + require.Contains(t, completions, `{{\ randAlphaNum\ 3\ |\ lower\ }}`) +} diff --git a/external/json/to_json.go b/external/json/to_json.go index 30fc8f0..c5e1799 100644 --- a/external/json/to_json.go +++ b/external/json/to_json.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/elasticpath/epcc-cli/external/aliases" "github.com/elasticpath/epcc-cli/external/resources" + "github.com/elasticpath/epcc-cli/external/templates" "github.com/itchyny/gojq" log "github.com/sirupsen/logrus" "regexp" @@ -53,6 +54,9 @@ func toJsonObject(args []string, noWrapping bool, compliant bool, attributes map key := args[i] val := args[i+1] + // Try and process the argument as a helm template + val = templates.Render(val) + jsonKey := key switch { case key == "type" || key == "id": @@ -265,6 +269,10 @@ func formatValue(v string) string { } else if match, _ := regexp.MatchString("^\\[\\]$", v); match { return v } else { + v = strings.ReplaceAll(v, "\\", "\\\\") + v = strings.ReplaceAll(v, "\n", "\\n") + v = strings.ReplaceAll(v, `"`, `\"`) + return fmt.Sprintf("\"%s\"", v) } } diff --git a/external/json/to_json_test.go b/external/json/to_json_test.go index af1772a..de96389 100644 --- a/external/json/to_json_test.go +++ b/external/json/to_json_test.go @@ -54,6 +54,51 @@ func TestToJsonLegacyFormatSimpleKeyStringValue(t *testing.T) { } } +func TestToJsonLegacyFormatSimpleKeyStringValueWithQuotes(t *testing.T) { + // Fixture Setup + input := []string{"key", "val\"ue"} + expected := `{"data":{"key":"val\"ue"}}` + + // Execute SUT + actual, err := ToJson(input, false, false, map[string]*resources.CrudEntityAttribute{}, true) + + // Verification + require.NoError(t, err) + if actual != expected { + t.Fatalf("Testing json conversion of empty value %s did not match expected %s, actually: %s", input, expected, actual) + } +} + +func TestToJsonLegacyFormatSimpleKeyStringValueWithNewLines(t *testing.T) { + // Fixture Setup + input := []string{"key", "val\nue"} + expected := `{"data":{"key":"val\nue"}}` + + // Execute SUT + actual, err := ToJson(input, false, false, map[string]*resources.CrudEntityAttribute{}, true) + + // Verification + require.NoError(t, err) + if actual != expected { + t.Fatalf("Testing json conversion of empty value %s did not match expected %s, actually: %s", input, expected, actual) + } +} + +func TestToJsonLegacyFormatSimpleKeyStringValueWithBackslashes(t *testing.T) { + // Fixture Setup + input := []string{"key", "val\\nue"} + expected := `{"data":{"key":"val\\nue"}}` + + // Execute SUT + actual, err := ToJson(input, false, false, map[string]*resources.CrudEntityAttribute{}, true) + + // Verification + require.NoError(t, err) + if actual != expected { + t.Fatalf("Testing json conversion of empty value %s did not match expected %s, actually: %s", input, expected, actual) + } +} + func TestToJsonLegacyFormatSimpleNestedKeyValue(t *testing.T) { // Fixture Setup input := []string{"foo.bar", "val"} diff --git a/external/runbooks/runbook_rendering.go b/external/runbooks/runbook_rendering.go index f1134b5..2103240 100644 --- a/external/runbooks/runbook_rendering.go +++ b/external/runbooks/runbook_rendering.go @@ -4,6 +4,8 @@ import ( "bytes" "fmt" "github.com/Masterminds/sprig/v3" + "github.com/elasticpath/epcc-cli/external/templates" + "math" "strconv" "strings" "text/template" @@ -20,7 +22,15 @@ func CreateMapForRunbookArgumentPointers(runbookAction *RunbookAction) map[strin } func RenderTemplates(templateName string, rawCmd string, stringVars map[string]*string, variableDefinitions map[string]Variable) ([]string, error) { - tpl, err := template.New(templateName).Funcs(sprig.FuncMap()).Parse(rawCmd) + tpl, err := template.New(templateName).Funcs(sprig.FuncMap()).Funcs( + map[string]any{ + "pow": func(a, b int) int { return int(math.Pow(float64(a), float64(b))) }, + "pseudoRandAlphaNum": templates.RandAlphaNum, + "pseudoRandAlpha": templates.RandAlpha, + "pseudoRandNumeric": templates.RandNumeric, + "pseudoRandString": templates.RandString, + "pseudoRandInt": templates.RandInt, + }).Parse(rawCmd) if err != nil { // Handle this case better diff --git a/external/templates/funcs.go b/external/templates/funcs.go new file mode 100644 index 0000000..fb9aac8 --- /dev/null +++ b/external/templates/funcs.go @@ -0,0 +1,35 @@ +package templates + +import "math/rand" + +// randString is the internal function that generates a random string. +// It takes the length of the string and a string of allowed characters as parameters. +func RandString(letters string, n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +// randAlphaNum generates a string consisting of characters in the range 0-9, a-z, and A-Z. +func RandAlphaNum(n int) string { + const letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + return RandString(letters, n) +} + +// randAlpha generates a string consisting of characters in the range a-z and A-Z. +func RandAlpha(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + return RandString(letters, n) +} + +// randNumeric generates a string consisting of characters in the range 0-9. +func RandNumeric(n int) string { + const digits = "0123456789" + return RandString(digits, n) +} + +func RandInt(min, max int) int { + return rand.Intn(max-min) + min +} diff --git a/external/templates/template.go b/external/templates/template.go new file mode 100644 index 0000000..2c3acdb --- /dev/null +++ b/external/templates/template.go @@ -0,0 +1,44 @@ +package templates + +import ( + "bytes" + "github.com/Masterminds/sprig/v3" + log "github.com/sirupsen/logrus" + "strings" + "text/template" +) + +func init() { + +} +func Render(templateString string) string { + + if !strings.Contains(templateString, "{{") { + return templateString + } + + tpl, err := template.New("templateName").Funcs(sprig.FuncMap()).Funcs( + map[string]any{ + "pseudoRandAlphaNum": RandAlphaNum, + "pseudoRandAlpha": RandAlpha, + "pseudoRandNumeric": RandNumeric, + "pseudoRandString": RandString, + "pseudoRandInt": RandInt, + }).Parse(templateString) + + if err != nil { + log.Warnf("Could not process argument template: %s, due to %v", templateString, err) + return templateString + } + + var renderedTpl bytes.Buffer + + err = tpl.Execute(&renderedTpl, nil) + + if err != nil { + log.Warnf("Could not process argument template: %s, due to %v", templateString, err) + return templateString + } + + return renderedTpl.String() +}