diff --git a/docs/requirements-for-a-run-target.md b/docs/requirements-for-a-run-target.md new file mode 100644 index 0000000..d738800 --- /dev/null +++ b/docs/requirements-for-a-run-target.md @@ -0,0 +1,27 @@ +--- +Github Issue: https://github.com/nxtcoder17/Runfile/issues/8 +--- + +### Expectations from this implementation ? + +I would like to be able to do stuffs like: +- whether these environment variables have been defined +- whether this filepath exists or not +- whether `this command` or script runs sucessfully + +And, when answers to these questsions are `true`, then only run the target, otherwise throw the errors + +### Problems with Taskfile.dev implementation + +They have 2 ways to tackle this, with + +- `requires`: but, it works with vars only, ~no environment variables~ + +- `preconditions`: test conditions must be a valid linux `test` command, which assumes everyone knows how to read bash's `test` or `if` statements + + +### My Approach + +1. Support for `test` commands, must be there, for advanced users + +2. But, for simpler use cases, there should be alternate ways to do it, something that whole team just understands. diff --git a/examples/Runfile b/examples/Runfile index 3e04672..f2cdcc4 100644 --- a/examples/Runfile +++ b/examples/Runfile @@ -8,6 +8,8 @@ tasks: k2: 'f"\( asfsadfssdfas asfd $Asdfasdfa' k3: sh: echo -n "hello" + k4: + required: true dotenv: - ../.secrets/env cmd: diff --git a/go.mod b/go.mod index b1527b8..2deb5de 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/nxtcoder17/runfile go 1.22.7 require ( + github.com/go-task/slim-sprig/v3 v3.0.0 github.com/joho/godotenv v1.5.1 github.com/nxtcoder17/fwatcher v1.0.1 github.com/urfave/cli/v3 v3.0.0-alpha9 diff --git a/go.sum b/go.sum index dd09597..7fb069a 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/pkg/runfile/errors/errors.go b/pkg/runfile/errors/errors.go new file mode 100644 index 0000000..d7da7c4 --- /dev/null +++ b/pkg/runfile/errors/errors.go @@ -0,0 +1,126 @@ +package errors + +import ( + "encoding/json" + "fmt" +) + +type Context struct { + Task string + Runfile string + + message *string + err error +} + +func (c Context) WithMessage(msg string) Context { + c.message = &msg + return c +} + +func (c Context) WithErr(err error) Context { + c.err = err + return c +} + +func (c Context) ToString() string { + m := map[string]string{ + "task": c.Task, + "runfile": c.Runfile, + } + if c.message != nil { + m["message"] = *c.message + } + + if c.err != nil { + m["err"] = c.err.Error() + } + + b, err := json.Marshal(m) + if err != nil { + panic(err) + } + + return string(b) +} + +func (e Context) Error() string { + return e.ToString() +} + +type ( + ErrTaskInvalid struct{ Context } +) + +type ErrTaskFailedRequirements struct { + Context + Requirement string +} + +func (e ErrTaskFailedRequirements) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage(fmt.Sprintf("failed (requirement: %q)", e.Requirement)) + } + return e.Context.Error() +} + +type TaskNotFound struct { + Context +} + +func (e TaskNotFound) Error() string { + // return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) + if e.message == nil { + e.Context = e.Context.WithMessage("Not Found") + } + return e.Context.Error() +} + +type ErrTaskGeneric struct { + Context +} + +type InvalidWorkingDirectory struct { + Context +} + +func (e InvalidWorkingDirectory) Error() string { + // return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) + if e.message == nil { + e.Context = e.Context.WithMessage("Invalid Working Directory") + } + return e.Context.Error() +} + +type InvalidDotEnv struct { + Context +} + +func (e InvalidDotEnv) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("invalid dotenv") + } + return e.Context.Error() +} + +type InvalidEnvVar struct { + Context +} + +func (e InvalidEnvVar) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("invalid dotenv") + } + return e.Context.Error() +} + +type IncorrectCommand struct { + Context +} + +func (e IncorrectCommand) Error() string { + if e.message == nil { + e.Context = e.Context.WithMessage("incorrect command") + } + return e.Context.Error() +} diff --git a/pkg/runfile/run.go b/pkg/runfile/run.go index 9e40f5a..ae96ded 100644 --- a/pkg/runfile/run.go +++ b/pkg/runfile/run.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" + "github.com/nxtcoder17/runfile/pkg/runfile/errors" "golang.org/x/sync/errgroup" ) @@ -85,7 +86,10 @@ type RunArgs struct { func (rf *Runfile) Run(ctx context.Context, args RunArgs) error { for _, v := range args.Tasks { if _, ok := rf.Tasks[v]; !ok { - return ErrTaskNotFound{TaskName: v, RunfilePath: rf.attrs.RunfilePath} + return errors.TaskNotFound{Context: errors.Context{ + Task: v, + Runfile: rf.attrs.RunfilePath, + }} } } diff --git a/pkg/runfile/task-parser.go b/pkg/runfile/task-parser.go index e7772bd..e77901f 100644 --- a/pkg/runfile/task-parser.go +++ b/pkg/runfile/task-parser.go @@ -1,13 +1,17 @@ package runfile import ( + "bytes" "context" "encoding/json" "fmt" "os" "path/filepath" + "text/template" + sprig "github.com/go-task/slim-sprig/v3" fn "github.com/nxtcoder17/runfile/pkg/functions" + "github.com/nxtcoder17/runfile/pkg/runfile/errors" ) type ParsedTask struct { @@ -17,35 +21,53 @@ type ParsedTask struct { Commands []CommandJson `json:"commands"` } -type ErrTask struct { - ErrTaskNotFound - Message string - Err error -} - -func (e ErrTask) Error() string { - return fmt.Sprintf("[task] %s (Runfile: %s), says %s, got %v", e.TaskName, e.RunfilePath, e.Message, e.Err) -} - -func NewErrTask(runfilePath, taskName, message string, err error) ErrTask { - return ErrTask{ - ErrTaskNotFound: ErrTaskNotFound{ - RunfilePath: runfilePath, - TaskName: taskName, - }, - Message: message, - Err: err, - } -} - func ParseTask(ctx context.Context, rf *Runfile, taskName string) (*ParsedTask, error) { - newErr := func(message string, err error) ErrTask { - return NewErrTask(rf.attrs.RunfilePath, taskName, message, err) - } + errctx := errors.Context{Task: taskName, Runfile: rf.attrs.RunfilePath} task, ok := rf.Tasks[taskName] if !ok { - return nil, ErrTaskNotFound{TaskName: taskName, RunfilePath: rf.attrs.RunfilePath} + return nil, errors.TaskNotFound{Context: errctx} + } + + for _, requirement := range task.Requires { + if requirement == nil { + continue + } + + if requirement.Sh != nil { + cmd := createCommand(ctx, cmdArgs{ + shell: []string{"sh", "-c"}, + env: os.Environ(), + workingDir: filepath.Dir(rf.attrs.RunfilePath), + cmd: *requirement.Sh, + stdout: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), + stderr: fn.Must(os.OpenFile(os.DevNull, os.O_WRONLY, 0o755)), + }) + if err := cmd.Run(); err != nil { + return nil, errors.ErrTaskFailedRequirements{Context: errctx.WithErr(err), Requirement: *requirement.Sh} + } + continue + } + + if requirement.GoTmpl != nil { + t := template.New("requirement") + t = t.Funcs(sprig.FuncMap()) + templateExpr := fmt.Sprintf(`{{ %s }}`, *requirement.GoTmpl) + t, err := t.Parse(templateExpr) + if err != nil { + return nil, err + } + b := new(bytes.Buffer) + if err := t.ExecuteTemplate(b, "requirement", map[string]string{}); err != nil { + return nil, err + } + + if b.String() != "true" { + return nil, errors.ErrTaskFailedRequirements{Context: errctx.WithErr(fmt.Errorf("`%s` evaluated to `%s` (wanted: `true`)", templateExpr, b.String())), Requirement: *requirement.GoTmpl} + } + + continue + } } if task.Shell == nil { @@ -58,21 +80,21 @@ func ParseTask(ctx context.Context, rf *Runfile, taskName string) (*ParsedTask, fi, err := os.Stat(*task.Dir) if err != nil { - return nil, newErr("invalid directory", err) + return nil, errors.InvalidWorkingDirectory{Context: errctx.WithErr(err)} } if !fi.IsDir() { - return nil, newErr("invalid working directory", fmt.Errorf("path (%s), is not a directory", *task.Dir)) + return nil, errors.InvalidWorkingDirectory{Context: errctx.WithErr(fmt.Errorf("path (%s), is not a directory", *task.Dir))} } dotenvPaths, err := resolveDotEnvFiles(filepath.Dir(rf.attrs.RunfilePath), task.DotEnv...) if err != nil { - return nil, newErr("while resolving dotenv paths", err) + return nil, errors.InvalidDotEnv{Context: errctx.WithErr(err).WithMessage("failed to resolve dotenv paths")} } dotenvVars, err := parseDotEnvFiles(dotenvPaths...) if err != nil { - return nil, newErr("while parsing dotenv files", err) + return nil, errors.InvalidDotEnv{Context: errctx.WithErr(err).WithMessage("failed to parse dotenv files")} } env := make([]string, 0, len(os.Environ())+len(dotenvVars)) @@ -85,11 +107,11 @@ func ParseTask(ctx context.Context, rf *Runfile, taskName string) (*ParsedTask, // INFO: keys from task.Env will override those coming from dotenv files, when duplicated envVars, err := parseEnvVars(ctx, task.Env, EvaluationArgs{ - Shell: task.Shell, - Environ: env, + Shell: task.Shell, + Env: dotenvVars, }) if err != nil { - return nil, newErr("while evaluating task env vars", err) + return nil, errors.InvalidEnvVar{Context: errctx.WithErr(err).WithMessage("failed to parse/evaluate env vars")} } for k, v := range envVars { @@ -100,7 +122,7 @@ func ParseTask(ctx context.Context, rf *Runfile, taskName string) (*ParsedTask, for i := range task.Commands { c2, err := parseCommand(rf, task.Commands[i]) if err != nil { - return nil, newErr("while parsing command", err) + return nil, errors.IncorrectCommand{Context: errctx.WithErr(err).WithMessage(fmt.Sprintf("failed to parse command: %+v", task.Commands[i]))} } commands = append(commands, *c2) } diff --git a/pkg/runfile/task-parser_test.go b/pkg/runfile/task-parser_test.go index e136236..60e497f 100644 --- a/pkg/runfile/task-parser_test.go +++ b/pkg/runfile/task-parser_test.go @@ -20,6 +20,10 @@ func TestParseTask(t *testing.T) { } areEqual := func(t *testing.T, got, want *ParsedTask) bool { + if want == nil { + return false + } + if strings.Join(got.Shell, ",") != strings.Join(want.Shell, ",") { t.Logf("shell not equal") return false @@ -55,12 +59,177 @@ func TestParseTask(t *testing.T) { fmt.Fprintf(dotenvTestFile, "hello=world\n") dotenvTestFile.Close() - tests := []struct { + type test struct { name string args args want *ParsedTask wantErr bool - }{ + } + + testRequires := []test{ + { + name: "[requires] condition specified, but it neither has 'sh' or 'gotmpl' key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + {}, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[requires] condition specified, with gotmpl key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + GoTmpl: fn.New(`eq 5 5`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[requires] condition specified, with sh key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + Sh: fn.New(`echo hello`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: false, + }, + + { + name: "[unhappy/requires] condition specified, with sh key", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + Shell: nil, + ignoreSystemEnv: true, + Requires: []*Requires{ + { + Sh: fn.New(`echo hello && exit 1`), + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Commands: []CommandJson{}, + }, + wantErr: true, + }, + } + + testEnviroments := []test{ + { + name: "[unhappy/env] required env, not provided", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: nil, + wantErr: true, + }, + { + name: "[env] required env, provided", + args: args{ + rf: &Runfile{ + Tasks: map[string]Task{ + "test": { + ignoreSystemEnv: true, + Env: EnvVar{ + "hello": map[string]any{ + "required": true, + }, + }, + DotEnv: []string{ + dotenvTestFile.Name(), + }, + Commands: nil, + }, + }, + }, + taskName: "test", + }, + want: &ParsedTask{ + Shell: []string{"sh", "-c"}, + WorkingDir: fn.Must(os.Getwd()), + Environ: []string{ + "hello=world", + }, + Commands: []CommandJson{}, + }, + wantErr: false, + }, + } + + tests := []test{ { name: "[shell] if not specified, defaults to [sh, -c]", args: args{ @@ -465,6 +634,9 @@ echo "hi" }, } + tests = append(tests, testRequires...) + tests = append(tests, testEnviroments...) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseTask(context.TODO(), tt.args.rf, tt.args.taskName) diff --git a/pkg/runfile/task.go b/pkg/runfile/task.go index 2072a85..62b2e91 100644 --- a/pkg/runfile/task.go +++ b/pkg/runfile/task.go @@ -1,47 +1,53 @@ package runfile -import ( - "fmt" -) +// Only one of the fields must be set +type Requires struct { + Sh *string `json:"sh,omitempty"` + GoTmpl *string `json:"gotmpl,omitempty"` +} + +/* +EnvVar Values could take multiple forms: +- my_key: "value" +or + - my_key: + sh: "echo hello hi" + +Object values with `sh` key, such that the output of this command will be the value of the top-level key +*/ +type EnvVar map[string]any type Task struct { + // Shell in which above commands will be executed + // Default: ["sh", "-c"] + /* Common Usecases could be: + - ["bash", "-c"] + - ["python", "-c"] + - ["node", "-e"] + */ + Shell []string `json:"shell"` + // load env vars from [.env](https://www.google.com/search?q=sample+dotenv+files&udm=2) files DotEnv []string `json:"dotenv"` // working directory for the task Dir *string `json:"dir,omitempty"` - Env map[string]any `json:"env"` + Env EnvVar `json:"env,omitempty"` // this field is for testing purposes only ignoreSystemEnv bool `json:"-"` + Requires []*Requires `json:"requires,omitempty"` + // List of commands to be executed in given shell (default: sh) // can take multiple forms // - simple string // - a json object with key `run`, signifying other tasks to run Commands []any `json:"cmd"` - - // Shell in which above commands will be executed - // Default: ["sh", "-c"] - /* Common Usecases could be: - - ["bash", "-c"] - - ["python", "-c"] - - ["node", "-e"] - */ - Shell []string `json:"shell"` } type CommandJson struct { Command string Run string `json:"run"` } - -type ErrTaskNotFound struct { - TaskName string - RunfilePath string -} - -func (e ErrTaskNotFound) Error() string { - return fmt.Sprintf("[task] %s, not found in [Runfile] at %s", e.TaskName, e.RunfilePath) -} diff --git a/pkg/runfile/type.go b/pkg/runfile/type.go index 79158f7..4333a64 100644 --- a/pkg/runfile/type.go +++ b/pkg/runfile/type.go @@ -3,27 +3,79 @@ package runfile import ( "bytes" "context" + "encoding/json" "fmt" + "os" "strings" + "text/template" + + sprig "github.com/go-task/slim-sprig/v3" + fn "github.com/nxtcoder17/runfile/pkg/functions" ) -/* -EnvVar Values could take multiple forms: +type EvaluationArgs struct { + Shell []string + Env map[string]string +} -- my_key: "value" +func ToEnviron(m map[string]string) []string { + results := os.Environ() + for k, v := range m { + results = append(results, fmt.Sprintf("%s=%v", k, v)) + } + return results +} -or +type EnvKV struct { + Key string - - my_key: - "sh": "echo hello hi" + Value *string `json:"value"` + Sh *string `json:"sh"` + GoTmpl *string `json:"gotmpl"` +} -Object values with `sh` key, such that the output of this command will be the value of the top-level key -*/ -type EnvVar map[string]any +func (ejv EnvKV) Parse(ctx context.Context, args EvaluationArgs) (*string, error) { + switch { + case ejv.Value != nil: + { + return ejv.Value, nil + } + case ejv.Sh != nil: + { + value := new(bytes.Buffer) -type EvaluationArgs struct { - Shell []string - Environ []string + cmd := createCommand(ctx, cmdArgs{ + shell: args.Shell, + env: ToEnviron(args.Env), + cmd: *ejv.Sh, + stdout: value, + }) + if err := cmd.Run(); err != nil { + return nil, err + } + + return fn.New(strings.TrimSpace(value.String())), nil + } + case ejv.GoTmpl != nil: + { + t := template.New(ejv.Key).Funcs(sprig.FuncMap()) + t, err := t.Parse(fmt.Sprintf(`{{ %s }}`, *ejv.GoTmpl)) + if err != nil { + return nil, err + } + + value := new(bytes.Buffer) + if err := t.ExecuteTemplate(value, ejv.Key, map[string]string{}); err != nil { + return nil, err + } + + return fn.New(strings.TrimSpace(value.String())), nil + } + default: + { + return nil, fmt.Errorf("failed to parse, unknown format, one of [value, sh, gotmpl] must be set") + } + } } func parseEnvVars(ctx context.Context, ev EnvVar, args EvaluationArgs) (map[string]string, error) { @@ -33,28 +85,65 @@ func parseEnvVars(ctx context.Context, ev EnvVar, args EvaluationArgs) (map[stri case string: env[k] = v case map[string]any: - shcmd, ok := v["sh"] - if !ok { - return nil, fmt.Errorf("sh key is missing") + b, err := json.Marshal(v) + if err != nil { + return nil, err } - s, ok := shcmd.(string) - if !ok { - return nil, fmt.Errorf("shell cmd is not a string") + var envAsJson struct { + *EnvKV + Required bool + Default *EnvKV } - value := new(bytes.Buffer) - - cmd := createCommand(ctx, cmdArgs{ - shell: args.Shell, - env: args.Environ, - cmd: s, - stdout: value, - }) - if err := cmd.Run(); err != nil { + if err := json.Unmarshal(b, &envAsJson); err != nil { return nil, err } - env[k] = strings.TrimSpace(value.String()) + + switch { + case envAsJson.Required: + { + isDefined := false + if _, ok := os.LookupEnv(k); ok { + isDefined = true + } + + if !isDefined { + if _, ok := args.Env[k]; ok { + isDefined = true + } + } + + if !isDefined { + return nil, fmt.Errorf("env: %q, not defined", k) + } + } + + case envAsJson.EnvKV != nil: + { + envAsJson.Key = k + s, err := envAsJson.EnvKV.Parse(ctx, args) + if err != nil { + return nil, err + } + env[k] = *s + } + + case envAsJson.Default != nil: + { + envAsJson.Default.Key = k + s, err := envAsJson.Default.Parse(ctx, args) + if err != nil { + return nil, err + } + env[k] = *s + } + default: + { + return nil, fmt.Errorf("either required, value, sh, gotmpl or default, must be defined") + } + } + default: env[k] = fmt.Sprintf("%v", v) }