diff --git a/docs/actions.schema.md b/docs/actions.schema.md index bafe4a8..e225173 100644 --- a/docs/actions.schema.md +++ b/docs/actions.schema.md @@ -10,6 +10,7 @@ - [Predefined Variables](#predefined-variables) - [Environment Variables](#environment-variables) - [Example](#example) + - [Available Command Template Functions](#available-command-template-functions) 4. [Runtimes](#runtimes) - [Container](#container) - [Command](#command) @@ -228,6 +229,85 @@ runtime: - {{ .optBool }} ``` +### Available Command Template Functions + +### `removeLine` +**Description:** A special template directive that removes the entire line from the final output. + +**Usage:** + +``` yaml +- "{{ if condition }}value{{ else }}{{ removeLine }}{{ end }}" +``` + +### `isNil` + +**Description:** Checks if a value is nil. + +**Usage:** + +```yaml +- "{{ if not (isNil .param_name) }}--param={{ .param_name }}{{ else }}{{ removeLine }}{{ end }}" +``` + +### `isSet` + +**Description:** Checks if a value has been set (opposite of `isNil`). + +```yaml +- "{{ if isSet .param_name }}--param={{ .param_name }}{{else}}{{ removeLine }}{{ end }}" +``` + +### `isChanged` + +**Description:** Checks if an option or argument value has been changed (dirty). + +**Usage:** + +```yaml +- '{{ if isChanged "param_name"}}--param={{.param_name}}{{else}}{{ removeLine }}{{ end }}' +``` + +### `removeLineIfNil` +**Description:** Removes the entire command line if the value is nil. + +**Usage:** + +```yaml +- "{{ removeLineIfNil .param_name }}" +``` + +### `removeLineIfSet` +**Description:** Removes the entire command line if the value is set (has no nil value). + +**Usage:** + +```yaml +- "{{ removeLineIfSet .param_name }}" +``` + +### `removeLineIfChanged` + +**Description:** Removes the command line entry if the option/argument value has changed. + +**Usage:** + +``` yaml +- '{{ removeLineIfChanged "param_name" }}' +``` + +### `removeLineIfNotChanged` + +**Description:** Removes the command line entry if the option/argument value has not changed by the user. +Opposite of `removeLineIfChanged` + +**Usage:** + +``` yaml +- '{{ removeLineIfNotChanged "param_name" }}' +``` + + ## Runtimes Action can be executed in different runtime environments. This section covers their declaration. diff --git a/example/actions/arguments/action.yaml b/example/actions/arguments/action.yaml index 73e6fd0..2cbd1ad 100644 --- a/example/actions/arguments/action.yaml +++ b/example/actions/arguments/action.yaml @@ -18,6 +18,9 @@ action: description: Option to do something type: boolean default: false + - name: thirdoption + title: Third option + type: string runtime: type: container @@ -29,5 +32,11 @@ runtime: - /action/main.sh - "{{ .arg1 }}" - "{{ .arg2 }}" - - "{{ .firstoption }}" - - "{{ .secondoption }}" + - "{{ .firstoption|removeLineIfNil }}" + - "{{ if not (isNil .secondoption) }}--secondoption={{ .secondoption }}{{ else }}{{ removeLine }}{{ end }}" + - "{{ if isSet .thirdoption }}--thirdoption={{ .thirdoption }}{{else}}Third option is not set{{ end }}" + - "{{ removeLineIfSet .thirdoption }}" + - '{{ if not (isChanged "thirdoption")}}Third Option is not Changed{{else}}{{ removeLine }}{{ end }}' + - '{{ removeLineIfChanged "thirdoption" }}' + - '{{ if isChanged "thirdoption"}}Third Option is Changed{{else}}{{ removeLine }}{{ end }}' + - '{{ removeLineIfNotChanged "thirdoption" }}' diff --git a/pkg/action/loader.go b/pkg/action/loader.go index 9c01a55..e3b1799 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -12,6 +12,10 @@ import ( "github.com/launchrctl/launchr/internal/launchr" ) +const tokenRmLine = "" //nolint:gosec // G101: Not a credential. + +var rgxTokenRmLine = regexp.MustCompile(`.*` + tokenRmLine + `.*\n?`) + // Loader is an interface for loading an action file. type Loader interface { // Content returns the raw file content. @@ -75,7 +79,7 @@ func getenv(key string) string { type inputProcessor struct{} -var rgxTplVar = regexp.MustCompile(`{{.*?\.(\S+).*?}}`) +var rgxTplVar = regexp.MustCompile(`{{.*?\.([a-zA-Z][a-zA-Z0-9_]*).*?}}`) type errMissingVar struct { vars map[string]struct{} @@ -90,6 +94,84 @@ func (err errMissingVar) Error() string { return fmt.Sprintf("the following variables were used but never defined: %v", f) } +// actionTplFuncs defined template functions available during parsing of an action yaml. +func actionTplFuncs(input *Input) template.FuncMap { + // Helper function to get value by name from args or opts + getValue := func(name string) any { + args := input.Args() + if arg, ok := args[name]; ok { + return arg + } + + opts := input.Opts() + if opt, ok := opts[name]; ok { + return opt + } + + return nil + } + + // Helper function to check if a parameter is changed + isParamChanged := func(name string) bool { + return input.IsOptChanged(name) || input.IsArgChanged(name) + } + + return template.FuncMap{ + // Checks if a value is nil. Used in conditions. + "isNil": func(v any) bool { + return v == nil + }, + // Checks if a value is not nil. Used in conditions. + "isSet": func(v any) bool { + return v != nil + }, + // Checks if a value is changed. Used in conditions. + "isChanged": func(v any) bool { + name, ok := v.(string) + if !ok { + return false + } + + return isParamChanged(name) + }, + // Removes a line if a given value is nil or pass through. + "removeLineIfNil": func(v any) any { + if v == nil { + return tokenRmLine + } + return v + }, + // Removes a line if a given value is not nil or pass through. + "removeLineIfSet": func(v any) any { + if v != nil { + return tokenRmLine + } + + return v + }, + // Removes a line if a given value is changed or pass through. + "removeLineIfChanged": func(name string) any { + if isParamChanged(name) { + return tokenRmLine + } + + return getValue(name) + }, + // Removes a line if a given value is not changed or pass through. + "removeLineIfNotChanged": func(name string) any { + if !isParamChanged(name) { + return tokenRmLine + } + + return getValue(name) + }, + // Removes current line. + "removeLine": func() string { + return tokenRmLine + }, + } +} + func (p inputProcessor) Process(ctx LoadContext, b []byte) ([]byte, error) { if ctx.Action == nil { return b, nil @@ -101,7 +183,8 @@ func (p inputProcessor) Process(ctx LoadContext, b []byte) ([]byte, error) { addPredefinedVariables(data, a) // Parse action without variables to validate - tpl := template.New(a.ID) + tpl := template.New(a.ID).Funcs(actionTplFuncs(a.Input())) + _, err := tpl.Parse(string(b)) if err != nil { // Check if variables have dashes to show the error properly. @@ -125,7 +208,7 @@ Action definition is correct, but dashes are not allowed in templates, replace " return nil, err } - // Find if some vars were used but not defined. + // Find if some vars were used but not defined in arguments or options. miss := make(map[string]struct{}) res := buf.Bytes() if bytes.Contains(res, []byte("")) { @@ -136,13 +219,16 @@ Action definition is correct, but dashes are not allowed in templates, replace " miss[k] = struct{}{} } } - // If we don't have parameter names, the values probably were nil. + // If we don't have parameter names here, it means that all parameters are defined but the values were nil. // It's ok, users will be able to identify missing parameters. if len(miss) != 0 { return nil, errMissingVar{miss} } } + // Remove all lines containing [tokenRmLine]. + res = rgxTokenRmLine.ReplaceAll(res, []byte("")) + return res, nil } diff --git a/pkg/action/loader_test.go b/pkg/action/loader_test.go index 0bfee81..befbb5b 100644 --- a/pkg/action/loader_test.go +++ b/pkg/action/loader_test.go @@ -65,10 +65,38 @@ func Test_InputProcessor(t *testing.T) { assert.Equal(t, "", string(res)) // Check that we have an error when missing variables are not handled. + errMissVars := errMissingVar{vars: map[string]struct{}{"optUnd": {}, "arg2": {}}} s = "{{ .arg2 }},{{ .optUnd }}" res, err = proc.Process(ctx, []byte(s)) - assert.Equal(t, err, errMissingVar{vars: map[string]struct{}{"optUnd": {}, "arg2": {}}}) + assert.Equal(t, errMissVars, err) assert.Equal(t, "", string(res)) + + // Remove line if a variable not exists or is nil. + s = `- "{{ .arg1 | removeLineIfNil }}" +- "{{ .optUnd | removeLineIfNil }}" # Piping with new line +- "{{ if not (isNil .arg1) }}arg1 is not nil{{end}}" +- "{{ if (isNil .optUnd) }}{{ removeLine }}{{ end }}" # Function call without new line` + res, err = proc.Process(ctx, []byte(s)) + assert.NoError(t, err) + assert.Equal(t, "- \"arg1\"\n- \"arg1 is not nil\"\n", string(res)) + + // Remove line if a variable not exists or is nil, 1 argument is not defined and not checked. + s = `- "{{ .arg1 | removeLineIfNil }}" +- "{{ .optUnd|removeLineIfNil }}" # Piping with new line +- "{{ .arg2 }}" +- "{{ if not (isNil .arg1) }}arg1 is not nil{{end}}" +- "{{ if (isNil .optUnd) }}{{ removeLine }}{{ end }}" # Function call without new line` + _, err = proc.Process(ctx, []byte(s)) + assert.Equal(t, errMissVars, err) + + s = `- "{{ if isSet .arg1 }}arg1 is set"{{end}} +- "{{ removeLineIfSet .arg1 }}" # Function call without new line +- "{{ if isChanged .arg1 }}arg1 is changed{{end}}" +- '{{ removeLineIfNotChanged "arg1" }}' +- '{{ removeLineIfChanged "arg1" }}' # Function call without new line` + res, err = proc.Process(ctx, []byte(s)) + assert.NoError(t, err) + assert.Equal(t, "- \"arg1 is set\"\n- \"arg1 is changed\"\n- 'arg1'\n", string(res)) } func Test_YamlTplCommentsProcessor(t *testing.T) {