From a35910429c948dc24f31c1502225bfaee9c0055f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Fri, 18 Oct 2024 18:16:57 +0200 Subject: [PATCH] feat: option to ensure variable is within the list of values (#1827) --- errors/errors.go | 1 + errors/errors_task.go | 26 +++++++++++++++++ requires.go | 23 +++++++++++++-- task_test.go | 33 +++++++++++++++++++++ taskfile/ast/requires.go | 53 ++++++++++++++++++++++++++++++++-- testdata/requires/Taskfile.yml | 18 ++++++++++++ website/docs/reference/cli.mdx | 39 +++++++++++++------------ website/docs/usage.mdx | 34 ++++++++++++++++++++++ website/static/schema.json | 14 ++++++++- 9 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 testdata/requires/Taskfile.yml diff --git a/errors/errors.go b/errors/errors.go index 5c98f05d..ea0216a7 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -32,6 +32,7 @@ const ( CodeTaskCalledTooManyTimes CodeTaskCancelled CodeTaskMissingRequiredVars + CodeTaskNotAllowedVars ) // TaskError extends the standard error interface with a Code method. This code will diff --git a/errors/errors_task.go b/errors/errors_task.go index 1214a5a9..3349eb22 100644 --- a/errors/errors_task.go +++ b/errors/errors_task.go @@ -158,3 +158,29 @@ func (err *TaskMissingRequiredVars) Error() string { func (err *TaskMissingRequiredVars) Code() int { return CodeTaskMissingRequiredVars } + +type NotAllowedVar struct { + Value string + Enum []string + Name string +} + +type TaskNotAllowedVars struct { + TaskName string + NotAllowedVars []NotAllowedVar +} + +func (err *TaskNotAllowedVars) Error() string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("task: Task %q cancelled because it is missing required variables:\n", err.TaskName)) + for _, s := range err.NotAllowedVars { + builder.WriteString(fmt.Sprintf(" - %s has an invalid value : '%s' (allowed values : %v)\n", s.Name, s.Value, s.Enum)) + } + + return builder.String() +} + +func (err *TaskNotAllowedVars) Code() int { + return CodeTaskNotAllowedVars +} diff --git a/requires.go b/requires.go index b07f7f66..2c59bc62 100644 --- a/requires.go +++ b/requires.go @@ -1,6 +1,8 @@ package task import ( + "slices" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/taskfile/ast" ) @@ -16,9 +18,19 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task, call *ast.Call) error { } var missingVars []string + var notAllowedValuesVars []errors.NotAllowedVar for _, requiredVar := range t.Requires.Vars { - if !vars.Exists(requiredVar) { - missingVars = append(missingVars, requiredVar) + value, isString := vars.Get(requiredVar.Name).Value.(string) + if !vars.Exists(requiredVar.Name) { + missingVars = append(missingVars, requiredVar.Name) + } else { + if isString && requiredVar.Enum != nil && !slices.Contains(requiredVar.Enum, value) { + notAllowedValuesVars = append(notAllowedValuesVars, errors.NotAllowedVar{ + Value: value, + Enum: requiredVar.Enum, + Name: requiredVar.Name, + }) + } } } @@ -29,5 +41,12 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task, call *ast.Call) error { } } + if len(notAllowedValuesVars) > 0 { + return &errors.TaskNotAllowedVars{ + TaskName: t.Name(), + NotAllowedVars: notAllowedValuesVars, + } + } + return nil } diff --git a/task_test.go b/task_test.go index 15d6fd2d..599277c8 100644 --- a/task_test.go +++ b/task_test.go @@ -155,6 +155,39 @@ func TestVars(t *testing.T) { tt.Run(t) } +func TestRequires(t *testing.T) { + const dir = "testdata/requires" + + var buff bytes.Buffer + e := &task.Executor{ + Dir: dir, + Stdout: &buff, + Stderr: &buff, + } + + require.NoError(t, e.Setup()) + require.ErrorContains(t, e.Run(context.Background(), &ast.Call{Task: "missing-var"}), "task: Task \"missing-var\" cancelled because it is missing required variables: foo") + buff.Reset() + require.NoError(t, e.Setup()) + + vars := &ast.Vars{} + vars.Set("foo", ast.Var{Value: "bar"}) + require.NoError(t, e.Run(context.Background(), &ast.Call{ + Task: "missing-var", + Vars: vars, + })) + buff.Reset() + + require.NoError(t, e.Setup()) + require.ErrorContains(t, e.Run(context.Background(), &ast.Call{Task: "validation-var", Vars: vars}), "task: Task \"validation-var\" cancelled because it is missing required variables:\n - foo has an invalid value : 'bar' (allowed values : [one two])") + buff.Reset() + + require.NoError(t, e.Setup()) + vars.Set("foo", ast.Var{Value: "one"}) + require.NoError(t, e.Run(context.Background(), &ast.Call{Task: "validation-var", Vars: vars})) + buff.Reset() +} + func TestSpecialVars(t *testing.T) { const dir = "testdata/special_vars" const subdir = "testdata/special_vars/subdir" diff --git a/taskfile/ast/requires.go b/taskfile/ast/requires.go index 4c2c97df..da0ae8dc 100644 --- a/taskfile/ast/requires.go +++ b/taskfile/ast/requires.go @@ -1,10 +1,15 @@ package ast -import "github.com/go-task/task/v3/internal/deepcopy" +import ( + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/deepcopy" +) // Requires represents a set of required variables necessary for a task to run type Requires struct { - Vars []string + Vars []*VarsWithValidation } func (r *Requires) DeepCopy() *Requires { @@ -16,3 +21,47 @@ func (r *Requires) DeepCopy() *Requires { Vars: deepcopy.Slice(r.Vars), } } + +type VarsWithValidation struct { + Name string + Enum []string +} + +func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { + if v == nil { + return nil + } + return &VarsWithValidation{ + Name: v.Name, + Enum: v.Enum, + } +} + +// UnmarshalYAML implements yaml.Unmarshaler interface. +func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + + case yaml.ScalarNode: + var cmd string + if err := node.Decode(&cmd); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + v.Name = cmd + v.Enum = nil + return nil + + case yaml.MappingNode: + var vv struct { + Name string + Enum []string + } + if err := node.Decode(&vv); err != nil { + return errors.NewTaskfileDecodeError(err, node) + } + v.Name = vv.Name + v.Enum = vv.Enum + return nil + } + + return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("requires") +} diff --git a/testdata/requires/Taskfile.yml b/testdata/requires/Taskfile.yml new file mode 100644 index 00000000..22733ca3 --- /dev/null +++ b/testdata/requires/Taskfile.yml @@ -0,0 +1,18 @@ +version: '3' + +tasks: + default: + - task: missing-var + + missing-var: + requires: + vars: + - foo + cmd: echo "{{.foo}}" + + + validation-var: + requires: + vars: + - name: foo + enum: ['one', 'two'] diff --git a/website/docs/reference/cli.mdx b/website/docs/reference/cli.mdx index 9beb1a83..893236d1 100644 --- a/website/docs/reference/cli.mdx +++ b/website/docs/reference/cli.mdx @@ -62,25 +62,26 @@ four groups with the following ranges: A full list of the exit codes and their descriptions can be found below: -| Code | Description | -| ---- | ------------------------------------------------------------ | -| 0 | Success | -| 1 | An unknown error occurred | -| 100 | No Taskfile was found | -| 101 | A Taskfile already exists when trying to initialize one | -| 102 | The Taskfile is invalid or cannot be parsed | -| 103 | A remote Taskfile could not be downloaded | -| 104 | A remote Taskfile was not trusted by the user | -| 105 | A remote Taskfile was could not be fetched securely | -| 106 | No cache was found for a remote Taskfile in offline mode | -| 107 | No schema version was defined in the Taskfile | -| 200 | The specified task could not be found | -| 201 | An error occurred while executing a command inside of a task | -| 202 | The user tried to invoke a task that is internal | -| 203 | There a multiple tasks with the same name or alias | -| 204 | A task was called too many times | -| 205 | A task was cancelled by the user | -| 206 | A task was not executed due to missing required variables | +| Code | Description | +|------|---------------------------------------------------------------------| +| 0 | Success | +| 1 | An unknown error occurred | +| 100 | No Taskfile was found | +| 101 | A Taskfile already exists when trying to initialize one | +| 102 | The Taskfile is invalid or cannot be parsed | +| 103 | A remote Taskfile could not be downloaded | +| 104 | A remote Taskfile was not trusted by the user | +| 105 | A remote Taskfile was could not be fetched securely | +| 106 | No cache was found for a remote Taskfile in offline mode | +| 107 | No schema version was defined in the Taskfile | +| 200 | The specified task could not be found | +| 201 | An error occurred while executing a command inside of a task | +| 202 | The user tried to invoke a task that is internal | +| 203 | There a multiple tasks with the same name or alias | +| 204 | A task was called too many times | +| 205 | A task was cancelled by the user | +| 206 | A task was not executed due to missing required variables | +| 207 | A task was not executed due to a variable having an incorrect value | These codes can also be found in the repository in [`errors/errors.go`](https://github.com/go-task/task/blob/main/errors/errors.go). diff --git a/website/docs/usage.mdx b/website/docs/usage.mdx index e05f7268..0cb12c24 100644 --- a/website/docs/usage.mdx +++ b/website/docs/usage.mdx @@ -1060,6 +1060,40 @@ tasks: vars: [IMAGE_NAME, IMAGE_TAG] ``` +### Ensuring required variables have allowed values + +If you want to ensure that a variable is set to one of a predefined set of valid values before executing a task, you can use requires. +This is particularly useful when there are strict requirements for what values a variable can take, and you want to provide clear feedback to the user when an invalid value is detected. + +To use `requires`, you specify an array of allowed values in the vars sub-section under requires. Task will check if the variable is set to one of the allowed values. +If the variable does not match any of these values, the task will raise an error and stop execution. + +This check applies both to user-defined variables and environment variables. + +Example of using `requires`: + +```yaml +version: '3' + +tasks: + deploy: + cmds: + - echo "deploying to {{.ENV}}" + + requires: + vars: + - name: ENV + enum: [dev, beta, prod] +``` + +If `ENV` is not one of 'dev', 'beta' or 'prod' an error will be raised. + +:::note + +This is supported only for string variables. + +::: + ## Variables Task allows you to set variables using the `vars` keyword. The following diff --git a/website/static/schema.json b/website/static/schema.json index f87511f5..b2b2d875 100644 --- a/website/static/schema.json +++ b/website/static/schema.json @@ -558,7 +558,19 @@ "description": "List of variables that must be defined for the task to run", "type": "array", "items": { - "type": "string" + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "enum": { "type": "array", + "items": { "type": "string" } } + }, + "required": ["name", "enum"], + "additionalProperties": false + } + ] } } },