From c8544f148bc165fe43fd420ae9b1c7ea9f34b6fa Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 16 May 2023 00:11:40 +0000 Subject: [PATCH 01/17] feat: remote taskfiles over http --- errors/errors.go | 9 +++ errors/errors_taskfile.go | 17 +++++ internal/logger/logger.go | 17 +++++ setup.go | 11 ++-- task_test.go | 2 + taskfile/read/node.go | 6 +- taskfile/read/node_http.go | 124 +++++++++++++++++++++++++++++++++++++ taskfile/read/taskfile.go | 5 +- 8 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 taskfile/read/node_http.go diff --git a/errors/errors.go b/errors/errors.go index 78dd72fbdf..7f15a7d469 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -13,6 +13,7 @@ const ( CodeTaskfileNotFound int = iota + 100 CodeTaskfileAlreadyExists CodeTaskfileInvalid + CodeTaskfileNotTrusted ) // Task related exit codes @@ -40,3 +41,11 @@ type TaskError interface { func New(text string) error { return errors.New(text) } + +func Is(err, target error) bool { + return errors.Is(err, target) +} + +func As(err error, target any) bool { + return errors.As(err, target) +} diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index 3c942977bd..44b214f8c1 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -49,3 +49,20 @@ func (err TaskfileInvalidError) Error() string { func (err TaskfileInvalidError) Code() int { return CodeTaskfileInvalid } + +// TaskfileNotTrustedError is returned when the user does not accept the trust +// prompt when downloading a remote Taskfile. +type TaskfileNotTrustedError struct { + URI string +} + +func (err *TaskfileNotTrustedError) Error() string { + return fmt.Sprintf( + `task: Taskfile %q not trusted by user`, + err.URI, + ) +} + +func (err *TaskfileNotTrustedError) Code() int { + return CodeTaskfileNotTrusted +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 8eeac5a17a..ab031a46c4 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,11 +1,14 @@ package logger import ( + "bufio" "io" "os" "strconv" + "strings" "github.com/fatih/color" + "golang.org/x/exp/slices" ) type ( @@ -104,3 +107,17 @@ func (l *Logger) VerboseErrf(color Color, s string, args ...any) { l.Errf(color, s, args...) } } + +func (l *Logger) Prompt(color Color, s string, defaultValue string, continueValues ...string) (bool, error) { + if len(continueValues) == 0 { + return false, nil + } + l.Outf(color, "%s [%s/%s]\n", s, strings.ToLower(continueValues[0]), strings.ToUpper(defaultValue)) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return false, err + } + input = strings.TrimSpace(strings.ToLower(input)) + return slices.Contains(continueValues, input), nil +} diff --git a/setup.go b/setup.go index 2388039507..9d0fdd6277 100644 --- a/setup.go +++ b/setup.go @@ -26,16 +26,13 @@ func (e *Executor) Setup() error { if err := e.setCurrentDir(); err != nil { return err } - - if err := e.readTaskfile(); err != nil { + if err := e.setupTempDir(); err != nil { return err } - - e.setupFuzzyModel() - - if err := e.setupTempDir(); err != nil { + if err := e.readTaskfile(); err != nil { return err } + e.setupFuzzyModel() e.setupStdFiles() e.setupLogger() if err := e.setupOutput(); err != nil { @@ -79,7 +76,7 @@ func (e *Executor) readTaskfile() error { e.Taskfile, err = read.Taskfile(&read.FileNode{ Dir: e.Dir, Entrypoint: e.Entrypoint, - }) + }, e.TempDir, e.Logger) if err != nil { return err } diff --git a/task_test.go b/task_test.go index a589629c96..2078baabc3 100644 --- a/task_test.go +++ b/task_test.go @@ -676,6 +676,7 @@ func TestPromptInSummary(t *testing.T) { t.Run(test.name, func(t *testing.T) { var inBuff bytes.Buffer var outBuff bytes.Buffer + var errBuff bytes.Buffer inBuff.Write([]byte(test.input)) @@ -683,6 +684,7 @@ func TestPromptInSummary(t *testing.T) { Dir: dir, Stdin: &inBuff, Stdout: &outBuff, + Stderr: &errBuff, AssumesTerm: true, } require.NoError(t, e.Setup()) diff --git a/taskfile/read/node.go b/taskfile/read/node.go index c1b828516d..6ba700042f 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -3,6 +3,7 @@ package read import ( "strings" + "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/taskfile" ) @@ -13,9 +14,10 @@ type Node interface { Location() string } -func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile) (Node, error) { +func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile, tempDir string, l *logger.Logger) (Node, error) { switch getScheme(includedTaskfile.Taskfile) { - // TODO: Add support for other schemes. + case "https": + return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional, tempDir, l) // If no other scheme matches, we assume it's a file. // This also allows users to explicitly set a file:// scheme. default: diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go new file mode 100644 index 0000000000..48e5b95e30 --- /dev/null +++ b/taskfile/read/node_http.go @@ -0,0 +1,124 @@ +package read + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/taskfile" +) + +// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. +type HTTPNode struct { + BaseNode + Logger *logger.Logger + URL *url.URL + TempDir string +} + +func NewHTTPNode(parent Node, urlString string, optional bool, tempDir string, l *logger.Logger) (*HTTPNode, error) { + url, err := url.Parse(urlString) + if err != nil { + return nil, err + } + return &HTTPNode{ + BaseNode: BaseNode{ + parent: parent, + optional: optional, + }, + URL: url, + TempDir: tempDir, + Logger: l, + }, nil +} + +func (node *HTTPNode) Location() string { + return node.URL.String() +} + +func (node *HTTPNode) Read() (*taskfile.Taskfile, error) { + resp, err := http.Get(node.URL.String()) + if err != nil { + return nil, errors.TaskfileNotFoundError{URI: node.URL.String()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.TaskfileNotFoundError{URI: node.URL.String()} + } + + // Read the entire response body + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Create a hash of the response body + h := sha256.New() + h.Write(b) + hash := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // Get the cached hash + cachedHash, err := node.getHashFromCache() + if errors.Is(err, os.ErrNotExist) { + // If the hash doesn't exist in the cache, prompt the user to continue + if cont, err := node.Logger.Prompt(logger.Yellow, fmt.Sprintf("The task you are attempting to run depends on the remote Taskfile at %q.\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.URL.String()), "n", "y", "yes"); err != nil { + return nil, err + } else if !cont { + return nil, &errors.TaskfileNotTrustedError{URI: node.URL.String()} + } + } else if err != nil { + return nil, err + } else if hash != cachedHash { + // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue + if cont, err := node.Logger.Prompt(logger.Yellow, fmt.Sprintf("The Taskfile at %q has changed since you last used it!\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.URL.String()), "n", "y", "yes"); err != nil { + return nil, err + } else if !cont { + return nil, &errors.TaskfileNotTrustedError{URI: node.URL.String()} + } + } + + // If the hash has changed (or is new), store it in the cache + if hash != cachedHash { + if err := node.toCache([]byte(hash)); err != nil { + return nil, err + } + } + + // Unmarshal the taskfile + var t *taskfile.Taskfile + if err := yaml.Unmarshal(b, &t); err != nil { + return nil, &errors.TaskfileInvalidError{URI: node.URL.String(), Err: err} + } + t.Location = node.URL.String() + return t, nil +} + +func (node *HTTPNode) getCachePath() string { + h := sha256.New() + h.Write([]byte(node.URL.String())) + return filepath.Join(node.TempDir, base64.URLEncoding.EncodeToString(h.Sum(nil))) +} + +func (node *HTTPNode) getHashFromCache() (string, error) { + path := node.getCachePath() + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(b), nil +} + +func (node *HTTPNode) toCache(hash []byte) error { + path := node.getCachePath() + return os.WriteFile(path, hash, 0o644) +} diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index efd48fd2fa..a44ce4f88d 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -8,6 +8,7 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/sysinfo" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/taskfile" @@ -32,7 +33,7 @@ var ( // Taskfile reads a Taskfile for a given directory // Uses current dir when dir is left empty. Uses Taskfile.yml // or Taskfile.yaml when entrypoint is left empty -func Taskfile(node Node) (*taskfile.Taskfile, error) { +func Taskfile(node Node, tempDir string, l *logger.Logger) (*taskfile.Taskfile, error) { var _taskfile func(Node) (*taskfile.Taskfile, error) _taskfile = func(node Node) (*taskfile.Taskfile, error) { t, err := node.Read() @@ -70,7 +71,7 @@ func Taskfile(node Node) (*taskfile.Taskfile, error) { } } - includeReaderNode, err := NewNodeFromIncludedTaskfile(node, includedTask) + includeReaderNode, err := NewNodeFromIncludedTaskfile(node, includedTask, tempDir, l) if err != nil { if includedTask.Optional { return nil From eb246847a9292c054aad80f3d6f2352c9f85ed8d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 16 May 2023 00:23:34 +0000 Subject: [PATCH 02/17] feat: allow insecure connections when --insecure flag is provided --- cmd/task/task.go | 3 +++ errors/errors.go | 1 + errors/errors_taskfile.go | 17 +++++++++++++++++ setup.go | 2 +- task.go | 1 + taskfile/read/node.go | 8 +++++++- taskfile/read/taskfile.go | 4 ++-- 7 files changed, 32 insertions(+), 4 deletions(-) diff --git a/cmd/task/task.go b/cmd/task/task.go index dc70c6c59e..560ac49eb1 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -53,6 +53,7 @@ var flags struct { listJson bool taskSort string status bool + insecure bool force bool forceAll bool watch bool @@ -112,6 +113,7 @@ func run() error { pflag.BoolVarP(&flags.listJson, "json", "j", false, "Formats task list as JSON.") pflag.StringVar(&flags.taskSort, "sort", "", "Changes the order of the tasks when listed. [default|alphanumeric|none].") pflag.BoolVar(&flags.status, "status", false, "Exits with non-zero exit code if any of the given tasks is not up-to-date.") + pflag.BoolVar(&flags.insecure, "insecure", false, "Forces Task to download Taskfiles over insecure connections.") pflag.BoolVarP(&flags.watch, "watch", "w", false, "Enables watch of the given task.") pflag.BoolVarP(&flags.verbose, "verbose", "v", false, "Enables verbose mode.") pflag.BoolVarP(&flags.silent, "silent", "s", false, "Disables echoing.") @@ -216,6 +218,7 @@ func run() error { e := task.Executor{ Force: flags.force, ForceAll: flags.forceAll, + Insecure: flags.insecure, Watch: flags.watch, Verbose: flags.verbose, Silent: flags.silent, diff --git a/errors/errors.go b/errors/errors.go index 7f15a7d469..1bee16182c 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -14,6 +14,7 @@ const ( CodeTaskfileAlreadyExists CodeTaskfileInvalid CodeTaskfileNotTrusted + CodeTaskfileNotSecure ) // Task related exit codes diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index 44b214f8c1..159660582c 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -66,3 +66,20 @@ func (err *TaskfileNotTrustedError) Error() string { func (err *TaskfileNotTrustedError) Code() int { return CodeTaskfileNotTrusted } + +// TaskfileNotSecureError is returned when the user attempts to download a +// remote Taskfile over an insecure connection. +type TaskfileNotSecureError struct { + URI string +} + +func (err *TaskfileNotSecureError) Error() string { + return fmt.Sprintf( + `task: Taskfile %q cannot be downloaded over an insecure connection. You can override this by using the --insecure flag`, + err.URI, + ) +} + +func (err *TaskfileNotSecureError) Code() int { + return CodeTaskfileNotSecure +} diff --git a/setup.go b/setup.go index 9d0fdd6277..68056df647 100644 --- a/setup.go +++ b/setup.go @@ -76,7 +76,7 @@ func (e *Executor) readTaskfile() error { e.Taskfile, err = read.Taskfile(&read.FileNode{ Dir: e.Dir, Entrypoint: e.Entrypoint, - }, e.TempDir, e.Logger) + }, e.Insecure, e.TempDir, e.Logger) if err != nil { return err } diff --git a/task.go b/task.go index dd540a41dc..70c8c7245d 100644 --- a/task.go +++ b/task.go @@ -51,6 +51,7 @@ type Executor struct { Entrypoint string Force bool ForceAll bool + Insecure bool Watch bool Verbose bool Silent bool diff --git a/taskfile/read/node.go b/taskfile/read/node.go index 6ba700042f..4371421b66 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -3,6 +3,7 @@ package read import ( "strings" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/taskfile" ) @@ -14,8 +15,13 @@ type Node interface { Location() string } -func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile, tempDir string, l *logger.Logger) (Node, error) { +func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile, allowInsecure bool, tempDir string, l *logger.Logger) (Node, error) { switch getScheme(includedTaskfile.Taskfile) { + case "http": + if !allowInsecure { + return nil, &errors.TaskfileNotSecureError{URI: includedTaskfile.Taskfile} + } + return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional, tempDir, l) case "https": return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional, tempDir, l) // If no other scheme matches, we assume it's a file. diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index a44ce4f88d..dcdc63ab24 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -33,7 +33,7 @@ var ( // Taskfile reads a Taskfile for a given directory // Uses current dir when dir is left empty. Uses Taskfile.yml // or Taskfile.yaml when entrypoint is left empty -func Taskfile(node Node, tempDir string, l *logger.Logger) (*taskfile.Taskfile, error) { +func Taskfile(node Node, allowInsecure bool, tempDir string, l *logger.Logger) (*taskfile.Taskfile, error) { var _taskfile func(Node) (*taskfile.Taskfile, error) _taskfile = func(node Node) (*taskfile.Taskfile, error) { t, err := node.Read() @@ -71,7 +71,7 @@ func Taskfile(node Node, tempDir string, l *logger.Logger) (*taskfile.Taskfile, } } - includeReaderNode, err := NewNodeFromIncludedTaskfile(node, includedTask, tempDir, l) + includeReaderNode, err := NewNodeFromIncludedTaskfile(node, includedTask, allowInsecure, tempDir, l) if err != nil { if includedTask.Optional { return nil From 1485829136a64f0deb32def23202f084b5ad5cf0 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 16 May 2023 00:45:17 +0000 Subject: [PATCH 03/17] feat: better error handling for fetch errors --- errors/errors.go | 1 + errors/errors_taskfile.go | 22 +++++++++++++++++++++- taskfile/read/node_http.go | 7 +++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/errors/errors.go b/errors/errors.go index 1bee16182c..6af22b3e26 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -13,6 +13,7 @@ const ( CodeTaskfileNotFound int = iota + 100 CodeTaskfileAlreadyExists CodeTaskfileInvalid + CodeTaskfileFetchFailed CodeTaskfileNotTrusted CodeTaskfileNotSecure ) diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index 159660582c..8f9a3a1975 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -2,6 +2,7 @@ package errors import ( "fmt" + "net/http" ) // TaskfileNotFoundError is returned when no appropriate Taskfile is found when @@ -16,7 +17,7 @@ func (err TaskfileNotFoundError) Error() string { if err.Walk { walkText = " (or any of the parent directories)" } - return fmt.Sprintf(`task: No Taskfile found at "%s"%s`, err.URI, walkText) + return fmt.Sprintf(`task: No Taskfile found at %q%s`, err.URI, walkText) } func (err TaskfileNotFoundError) Code() int { @@ -50,6 +51,25 @@ func (err TaskfileInvalidError) Code() int { return CodeTaskfileInvalid } +// TaskfileFetchFailedError is returned when no appropriate Taskfile is found when +// searching the filesystem. +type TaskfileFetchFailedError struct { + URI string + HTTPStatusCode int +} + +func (err TaskfileFetchFailedError) Error() string { + var statusText string + if err.HTTPStatusCode != 0 { + statusText = fmt.Sprintf(" with status code %d (%s)", err.HTTPStatusCode, http.StatusText(err.HTTPStatusCode)) + } + return fmt.Sprintf(`task: Download of %q failed%s`, err.URI, statusText) +} + +func (err TaskfileFetchFailedError) Code() int { + return CodeTaskfileFetchFailed +} + // TaskfileNotTrustedError is returned when the user does not accept the trust // prompt when downloading a remote Taskfile. type TaskfileNotTrustedError struct { diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 48e5b95e30..3679840b78 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -48,12 +48,15 @@ func (node *HTTPNode) Location() string { func (node *HTTPNode) Read() (*taskfile.Taskfile, error) { resp, err := http.Get(node.URL.String()) if err != nil { - return nil, errors.TaskfileNotFoundError{URI: node.URL.String()} + return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errors.TaskfileNotFoundError{URI: node.URL.String()} + return nil, errors.TaskfileFetchFailedError{ + URI: node.URL.String(), + HTTPStatusCode: resp.StatusCode, + } } // Read the entire response body From 47c8a5094a16d924aa682af035e35a1a02e71f1e Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 29 Aug 2023 22:12:12 +0000 Subject: [PATCH 04/17] fix: ensure cache directory always exists --- taskfile/read/node_http.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 3679840b78..26265c9e6e 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -109,7 +109,7 @@ func (node *HTTPNode) Read() (*taskfile.Taskfile, error) { func (node *HTTPNode) getCachePath() string { h := sha256.New() h.Write([]byte(node.URL.String())) - return filepath.Join(node.TempDir, base64.URLEncoding.EncodeToString(h.Sum(nil))) + return filepath.Join(node.TempDir, "remote", base64.URLEncoding.EncodeToString(h.Sum(nil))) } func (node *HTTPNode) getHashFromCache() (string, error) { @@ -123,5 +123,8 @@ func (node *HTTPNode) getHashFromCache() (string, error) { func (node *HTTPNode) toCache(hash []byte) error { path := node.getCachePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } return os.WriteFile(path, hash, 0o644) } From 394bfbe573824f7d0be10c15f53f3337b472eec1 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 29 Aug 2023 22:30:17 +0000 Subject: [PATCH 05/17] fix: setup logger before everything else --- setup.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.go b/setup.go index 68056df647..664d793ea9 100644 --- a/setup.go +++ b/setup.go @@ -23,6 +23,7 @@ import ( ) func (e *Executor) Setup() error { + e.setupLogger() if err := e.setCurrentDir(); err != nil { return err } @@ -34,7 +35,6 @@ func (e *Executor) Setup() error { } e.setupFuzzyModel() e.setupStdFiles() - e.setupLogger() if err := e.setupOutput(); err != nil { return err } From 6e26c195b07e2879749d4d881e4ba76ca8d233dc Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 29 Aug 2023 22:56:41 +0000 Subject: [PATCH 06/17] feat: put remote taskfiles behind an experiment --- internal/experiments/experiments.go | 21 ++++++++++++++++----- taskfile/read/node.go | 9 +++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/experiments/experiments.go b/internal/experiments/experiments.go index b444a2af10..4a1e0ab5e0 100644 --- a/internal/experiments/experiments.go +++ b/internal/experiments/experiments.go @@ -2,6 +2,7 @@ package experiments import ( "fmt" + "io" "os" "strings" "text/tabwriter" @@ -13,11 +14,16 @@ import ( const envPrefix = "TASK_X_" -var GentleForce bool +// A list of experiments. +var ( + GentleForce bool + RemoteTaskfiles bool +) func init() { readDotEnv() GentleForce = parseEnv("GENTLE_FORCE") + RemoteTaskfiles = parseEnv("REMOTE_TASKFILES") } func parseEnv(xName string) bool { @@ -35,10 +41,15 @@ func readDotEnv() { } } -func List(l *logger.Logger) error { - w := tabwriter.NewWriter(os.Stdout, 0, 8, 6, ' ', 0) +func printExperiment(w io.Writer, l *logger.Logger, name string, value bool) { l.FOutf(w, logger.Yellow, "* ") - l.FOutf(w, logger.Green, "GENTLE_FORCE") - l.FOutf(w, logger.Default, ": \t%t\n", GentleForce) + l.FOutf(w, logger.Green, name) + l.FOutf(w, logger.Default, ": \t%t\n", value) +} + +func List(l *logger.Logger) error { + w := tabwriter.NewWriter(os.Stdout, 0, 8, 0, ' ', 0) + printExperiment(w, l, "GENTLE_FORCE", GentleForce) + printExperiment(w, l, "REMOTE_TASKFILES", RemoteTaskfiles) return w.Flush() } diff --git a/taskfile/read/node.go b/taskfile/read/node.go index 4371421b66..a01880925e 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/experiments" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/taskfile" ) @@ -16,6 +17,14 @@ type Node interface { } func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile, allowInsecure bool, tempDir string, l *logger.Logger) (Node, error) { + // TODO: Remove this condition when the remote taskfiles experiment is complete + if !experiments.RemoteTaskfiles { + path, err := includedTaskfile.FullTaskfilePath() + if err != nil { + return nil, err + } + return NewFileNode(parent, path, includedTaskfile.Optional) + } switch getScheme(includedTaskfile.Taskfile) { case "http": if !allowInsecure { From 74fd3278eaa86030021b6b30e0b898239fb73313 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Fri, 1 Sep 2023 09:45:19 +0000 Subject: [PATCH 07/17] feat: --download and --offline flags for remote taskfiles --- cmd/task/task.go | 14 +++++ errors/errors.go | 1 + errors/errors_taskfile.go | 17 ++++++ setup.go | 15 +++-- task.go | 2 + task_test.go | 4 ++ taskfile/read/cache.go | 58 +++++++++++++++++++ taskfile/read/node.go | 16 ++++-- taskfile/read/node_file.go | 19 +++---- taskfile/read/node_http.go | 88 +++-------------------------- taskfile/read/taskfile.go | 112 ++++++++++++++++++++++++++++++++++++- 11 files changed, 243 insertions(+), 103 deletions(-) create mode 100644 taskfile/read/cache.go diff --git a/cmd/task/task.go b/cmd/task/task.go index 560ac49eb1..eac6f58fbc 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -72,6 +72,8 @@ var flags struct { interval time.Duration global bool experiments bool + download bool + offline bool } func main() { @@ -142,6 +144,12 @@ func run() error { pflag.BoolVarP(&flags.forceAll, "force", "f", false, "Forces execution even when the task is up-to-date.") } + // Remote Taskfiles experiment will adds the "download" and "offline" flags + if experiments.RemoteTaskfiles { + pflag.BoolVar(&flags.download, "download", false, "Downloads a cached version of a remote Taskfile.") + pflag.BoolVar(&flags.offline, "offline", false, "Forces Task to only use local or cached Taskfiles.") + } + pflag.Parse() if flags.version { @@ -175,6 +183,10 @@ func run() error { return nil } + if flags.download && flags.offline { + return errors.New("task: You can't set both --download and --offline flags") + } + if flags.global && flags.dir != "" { log.Fatal("task: You can't set both --global and --dir") return nil @@ -219,6 +231,8 @@ func run() error { Force: flags.force, ForceAll: flags.forceAll, Insecure: flags.insecure, + Download: flags.download, + Offline: flags.offline, Watch: flags.watch, Verbose: flags.verbose, Silent: flags.silent, diff --git a/errors/errors.go b/errors/errors.go index 6af22b3e26..4cdea8a206 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -16,6 +16,7 @@ const ( CodeTaskfileFetchFailed CodeTaskfileNotTrusted CodeTaskfileNotSecure + CodeTaskfileCacheNotFound ) // Task related exit codes diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index 8f9a3a1975..3860153907 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -103,3 +103,20 @@ func (err *TaskfileNotSecureError) Error() string { func (err *TaskfileNotSecureError) Code() int { return CodeTaskfileNotSecure } + +// TaskfileCacheNotFound is returned when the user attempts to use an offline +// (cached) Taskfile but the files does not exist in the cache. +type TaskfileCacheNotFound struct { + URI string +} + +func (err *TaskfileCacheNotFound) Error() string { + return fmt.Sprintf( + `task: Taskfile %q was not found in the cache. Remove the --offline flag to use a remote copy or download it using the --download flag`, + err.URI, + ) +} + +func (err *TaskfileCacheNotFound) Code() int { + return CodeTaskfileCacheNotFound +} diff --git a/setup.go b/setup.go index 664d793ea9..b84d182f6b 100644 --- a/setup.go +++ b/setup.go @@ -73,10 +73,17 @@ func (e *Executor) setCurrentDir() error { func (e *Executor) readTaskfile() error { var err error - e.Taskfile, err = read.Taskfile(&read.FileNode{ - Dir: e.Dir, - Entrypoint: e.Entrypoint, - }, e.Insecure, e.TempDir, e.Logger) + e.Taskfile, err = read.Taskfile( + &read.FileNode{ + Dir: e.Dir, + Entrypoint: e.Entrypoint, + }, + e.Insecure, + e.Download, + e.Offline, + e.TempDir, + e.Logger, + ) if err != nil { return err } diff --git a/task.go b/task.go index 70c8c7245d..b02eceb303 100644 --- a/task.go +++ b/task.go @@ -52,6 +52,8 @@ type Executor struct { Force bool ForceAll bool Insecure bool + Download bool + Offline bool Watch bool Verbose bool Silent bool diff --git a/task_test.go b/task_test.go index 2078baabc3..6bdc078d28 100644 --- a/task_test.go +++ b/task_test.go @@ -704,6 +704,7 @@ func TestPromptWithIndirectTask(t *testing.T) { const dir = "testdata/prompt" var inBuff bytes.Buffer var outBuff bytes.Buffer + var errBuff bytes.Buffer inBuff.Write([]byte("y\n")) @@ -711,6 +712,7 @@ func TestPromptWithIndirectTask(t *testing.T) { Dir: dir, Stdin: &inBuff, Stdout: &outBuff, + Stderr: &errBuff, AssumesTerm: true, } require.NoError(t, e.Setup()) @@ -734,6 +736,7 @@ func TestPromptAssumeYes(t *testing.T) { t.Run(test.name, func(t *testing.T) { var inBuff bytes.Buffer var outBuff bytes.Buffer + var errBuff bytes.Buffer // always cancel the prompt so we can require.Error inBuff.Write([]byte("\n")) @@ -742,6 +745,7 @@ func TestPromptAssumeYes(t *testing.T) { Dir: dir, Stdin: &inBuff, Stdout: &outBuff, + Stderr: &errBuff, AssumeYes: test.assumeYes, } require.NoError(t, e.Setup()) diff --git a/taskfile/read/cache.go b/taskfile/read/cache.go new file mode 100644 index 0000000000..da78070021 --- /dev/null +++ b/taskfile/read/cache.go @@ -0,0 +1,58 @@ +package read + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "os" + "path/filepath" +) + +type Cache struct { + dir string +} + +func NewCache(dir string) (*Cache, error) { + dir = filepath.Join(dir, "remote") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &Cache{ + dir: dir, + }, nil +} + +func (c *Cache) checksum(b []byte) string { + h := sha256.New() + h.Write(b) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func (c *Cache) write(node Node, b []byte) error { + return os.WriteFile(c.cacheFilePath(node), b, 0o644) +} + +func (c *Cache) read(node Node) ([]byte, error) { + return os.ReadFile(c.cacheFilePath(node)) +} + +func (c *Cache) writeChecksum(node Node, checksum string) error { + return os.WriteFile(c.checksumFilePath(node), []byte(checksum), 0o644) +} + +func (c *Cache) readChecksum(node Node) string { + b, _ := os.ReadFile(c.checksumFilePath(node)) + return string(b) +} + +func (c *Cache) key(node Node) string { + return base64.StdEncoding.EncodeToString([]byte(node.Location())) +} + +func (c *Cache) cacheFilePath(node Node) string { + return filepath.Join(c.dir, fmt.Sprintf("%s.yaml", c.key(node))) +} + +func (c *Cache) checksumFilePath(node Node) string { + return filepath.Join(c.dir, fmt.Sprintf("%s.checksum", c.key(node))) +} diff --git a/taskfile/read/node.go b/taskfile/read/node.go index a01880925e..cdbae6a4dc 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -10,14 +10,20 @@ import ( ) type Node interface { - Read() (*taskfile.Taskfile, error) + Read() ([]byte, error) Parent() Node Optional() bool Location() string + Remote() bool } -func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.IncludedTaskfile, allowInsecure bool, tempDir string, l *logger.Logger) (Node, error) { - // TODO: Remove this condition when the remote taskfiles experiment is complete +func NewNodeFromIncludedTaskfile( + parent Node, + includedTaskfile taskfile.IncludedTaskfile, + allowInsecure bool, + tempDir string, + l *logger.Logger, +) (Node, error) { if !experiments.RemoteTaskfiles { path, err := includedTaskfile.FullTaskfilePath() if err != nil { @@ -30,9 +36,9 @@ func NewNodeFromIncludedTaskfile(parent Node, includedTaskfile taskfile.Included if !allowInsecure { return nil, &errors.TaskfileNotSecureError{URI: includedTaskfile.Taskfile} } - return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional, tempDir, l) + return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional) case "https": - return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional, tempDir, l) + return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional) // If no other scheme matches, we assume it's a file. // This also allows users to explicitly set a file:// scheme. default: diff --git a/taskfile/read/node_file.go b/taskfile/read/node_file.go index f4246c571d..493efa1a30 100644 --- a/taskfile/read/node_file.go +++ b/taskfile/read/node_file.go @@ -1,14 +1,11 @@ package read import ( + "io" "os" "path/filepath" - "gopkg.in/yaml.v3" - - "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" - "github.com/go-task/task/v3/taskfile" ) // A FileNode is a node that reads a taskfile from the local filesystem. @@ -38,7 +35,11 @@ func (node *FileNode) Location() string { return filepathext.SmartJoin(node.Dir, node.Entrypoint) } -func (node *FileNode) Read() (*taskfile.Taskfile, error) { +func (node *FileNode) Remote() bool { + return false +} + +func (node *FileNode) Read() ([]byte, error) { if node.Dir == "" { d, err := os.Getwd() if err != nil { @@ -60,11 +61,5 @@ func (node *FileNode) Read() (*taskfile.Taskfile, error) { } defer f.Close() - var t taskfile.Taskfile - if err := yaml.NewDecoder(f).Decode(&t); err != nil { - return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(path), Err: err} - } - - t.Location = path - return &t, nil + return io.ReadAll(f) } diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 26265c9e6e..946154f177 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -1,31 +1,20 @@ package read import ( - "crypto/sha256" - "encoding/base64" - "fmt" "io" "net/http" "net/url" - "os" - "path/filepath" - - "gopkg.in/yaml.v3" "github.com/go-task/task/v3/errors" - "github.com/go-task/task/v3/internal/logger" - "github.com/go-task/task/v3/taskfile" ) // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. type HTTPNode struct { BaseNode - Logger *logger.Logger - URL *url.URL - TempDir string + URL *url.URL } -func NewHTTPNode(parent Node, urlString string, optional bool, tempDir string, l *logger.Logger) (*HTTPNode, error) { +func NewHTTPNode(parent Node, urlString string, optional bool) (*HTTPNode, error) { url, err := url.Parse(urlString) if err != nil { return nil, err @@ -35,9 +24,7 @@ func NewHTTPNode(parent Node, urlString string, optional bool, tempDir string, l parent: parent, optional: optional, }, - URL: url, - TempDir: tempDir, - Logger: l, + URL: url, }, nil } @@ -45,7 +32,11 @@ func (node *HTTPNode) Location() string { return node.URL.String() } -func (node *HTTPNode) Read() (*taskfile.Taskfile, error) { +func (node *HTTPNode) Remote() bool { + return true +} + +func (node *HTTPNode) Read() ([]byte, error) { resp, err := http.Get(node.URL.String()) if err != nil { return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} @@ -65,66 +56,5 @@ func (node *HTTPNode) Read() (*taskfile.Taskfile, error) { return nil, err } - // Create a hash of the response body - h := sha256.New() - h.Write(b) - hash := base64.URLEncoding.EncodeToString(h.Sum(nil)) - - // Get the cached hash - cachedHash, err := node.getHashFromCache() - if errors.Is(err, os.ErrNotExist) { - // If the hash doesn't exist in the cache, prompt the user to continue - if cont, err := node.Logger.Prompt(logger.Yellow, fmt.Sprintf("The task you are attempting to run depends on the remote Taskfile at %q.\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.URL.String()), "n", "y", "yes"); err != nil { - return nil, err - } else if !cont { - return nil, &errors.TaskfileNotTrustedError{URI: node.URL.String()} - } - } else if err != nil { - return nil, err - } else if hash != cachedHash { - // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue - if cont, err := node.Logger.Prompt(logger.Yellow, fmt.Sprintf("The Taskfile at %q has changed since you last used it!\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.URL.String()), "n", "y", "yes"); err != nil { - return nil, err - } else if !cont { - return nil, &errors.TaskfileNotTrustedError{URI: node.URL.String()} - } - } - - // If the hash has changed (or is new), store it in the cache - if hash != cachedHash { - if err := node.toCache([]byte(hash)); err != nil { - return nil, err - } - } - - // Unmarshal the taskfile - var t *taskfile.Taskfile - if err := yaml.Unmarshal(b, &t); err != nil { - return nil, &errors.TaskfileInvalidError{URI: node.URL.String(), Err: err} - } - t.Location = node.URL.String() - return t, nil -} - -func (node *HTTPNode) getCachePath() string { - h := sha256.New() - h.Write([]byte(node.URL.String())) - return filepath.Join(node.TempDir, "remote", base64.URLEncoding.EncodeToString(h.Sum(nil))) -} - -func (node *HTTPNode) getHashFromCache() (string, error) { - path := node.getCachePath() - b, err := os.ReadFile(path) - if err != nil { - return "", err - } - return string(b), nil -} - -func (node *HTTPNode) toCache(hash []byte) error { - path := node.getCachePath() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return os.WriteFile(path, hash, 0o644) + return b, nil } diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index dcdc63ab24..206fae1c11 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -6,6 +6,8 @@ import ( "path/filepath" "runtime" + "gopkg.in/yaml.v3" + "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/logger" @@ -30,13 +32,112 @@ var ( } ) +func readTaskfile( + node Node, + download, + offline bool, + tempDir string, + l *logger.Logger, +) (*taskfile.Taskfile, error) { + var b []byte + + cache, err := NewCache(tempDir) + if err != nil { + return nil, err + } + + // If the file is remote, check if we have a cached copy + // If we're told to download, skip the cache + if node.Remote() && !download { + if b, err = cache.read(node); !errors.Is(err, os.ErrNotExist) && err != nil { + return nil, err + } + + if b != nil { + l.VerboseOutf(logger.Magenta, "task: [%s] Fetched cached copy\n", node.Location()) + } + } + + // If the file is remote, we found nothing in the cache and we're not + // allowed to download it then we can't do anything. + if node.Remote() && b == nil && offline { + if b == nil && offline { + return nil, &errors.TaskfileCacheNotFound{URI: node.Location()} + } + } + + // If we still don't have a copy, get the file in the usual way + if b == nil { + b, err = node.Read() + if err != nil { + return nil, err + } + + // If the node was remote, we need to check the checksum + if node.Remote() { + l.VerboseOutf(logger.Magenta, "task: [%s] Fetched remote copy\n", node.Location()) + + // Get the checksums + checksum := cache.checksum(b) + cachedChecksum := cache.readChecksum(node) + + // If the checksum doesn't exist, prompt the user to continue + if cachedChecksum == "" { + if cont, err := l.Prompt(logger.Yellow, fmt.Sprintf("The task you are attempting to run depends on the remote Taskfile at %q.\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.Location()), "n", "y", "yes"); err != nil { + return nil, err + } else if !cont { + return nil, &errors.TaskfileNotTrustedError{URI: node.Location()} + } + } else if checksum != cachedChecksum { + // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue + if cont, err := l.Prompt(logger.Yellow, fmt.Sprintf("The Taskfile at %q has changed since you last used it!\n--- Make sure you trust the source of this Taskfile before continuing ---\nContinue?", node.Location()), "n", "y", "yes"); err != nil { + return nil, err + } else if !cont { + return nil, &errors.TaskfileNotTrustedError{URI: node.Location()} + } + } + + // If the hash has changed (or is new), store it in the cache + if checksum != cachedChecksum { + if err := cache.writeChecksum(node, checksum); err != nil { + return nil, err + } + } + } + } + + // If the file is remote and we need to cache it + if node.Remote() && download { + l.VerboseOutf(logger.Magenta, "task: [%s] Caching downloaded file\n", node.Location()) + // Cache the file for later + if err = cache.write(node, b); err != nil { + return nil, err + } + } + + var t taskfile.Taskfile + if err := yaml.Unmarshal(b, &t); err != nil { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} + } + t.Location = node.Location() + + return &t, nil +} + // Taskfile reads a Taskfile for a given directory // Uses current dir when dir is left empty. Uses Taskfile.yml // or Taskfile.yaml when entrypoint is left empty -func Taskfile(node Node, allowInsecure bool, tempDir string, l *logger.Logger) (*taskfile.Taskfile, error) { +func Taskfile( + node Node, + allowInsecure bool, + download bool, + offline bool, + tempDir string, + l *logger.Logger, +) (*taskfile.Taskfile, error) { var _taskfile func(Node) (*taskfile.Taskfile, error) _taskfile = func(node Node) (*taskfile.Taskfile, error) { - t, err := node.Read() + t, err := readTaskfile(node, download, offline, tempDir, l) if err != nil { return nil, err } @@ -157,10 +258,15 @@ func Taskfile(node Node, allowInsecure bool, tempDir string, l *logger.Logger) ( Entrypoint: path, Dir: node.Dir, } - osTaskfile, err := osNode.Read() + b, err := osNode.Read() if err != nil { return nil, err } + var osTaskfile *taskfile.Taskfile + if err := yaml.Unmarshal(b, &osTaskfile); err != nil { + return nil, &errors.TaskfileInvalidError{URI: filepathext.TryAbsToRel(node.Location()), Err: err} + } + t.Location = node.Location() if err = taskfile.Merge(t, osTaskfile, nil); err != nil { return nil, err } From dc32ac95e5036697732c0f40cc1015f977a1099d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sat, 2 Sep 2023 02:57:43 +0000 Subject: [PATCH 08/17] feat: node.Read accepts a context --- taskfile/read/node.go | 3 ++- taskfile/read/node_file.go | 3 ++- taskfile/read/node_http.go | 10 ++++++++-- taskfile/read/taskfile.go | 5 +++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/taskfile/read/node.go b/taskfile/read/node.go index cdbae6a4dc..d1953ab6c5 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -1,6 +1,7 @@ package read import ( + "context" "strings" "github.com/go-task/task/v3/errors" @@ -10,7 +11,7 @@ import ( ) type Node interface { - Read() ([]byte, error) + Read(ctx context.Context) ([]byte, error) Parent() Node Optional() bool Location() string diff --git a/taskfile/read/node_file.go b/taskfile/read/node_file.go index 493efa1a30..ce4db4e8f8 100644 --- a/taskfile/read/node_file.go +++ b/taskfile/read/node_file.go @@ -1,6 +1,7 @@ package read import ( + "context" "io" "os" "path/filepath" @@ -39,7 +40,7 @@ func (node *FileNode) Remote() bool { return false } -func (node *FileNode) Read() ([]byte, error) { +func (node *FileNode) Read(ctx context.Context) ([]byte, error) { if node.Dir == "" { d, err := os.Getwd() if err != nil { diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 946154f177..8ba7212270 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -1,6 +1,7 @@ package read import ( + "context" "io" "net/http" "net/url" @@ -36,8 +37,13 @@ func (node *HTTPNode) Remote() bool { return true } -func (node *HTTPNode) Read() ([]byte, error) { - resp, err := http.Get(node.URL.String()) +func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) { + req, err := http.NewRequest("GET", node.URL.String(), nil) + if err != nil { + return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} + } + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) if err != nil { return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} } diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index 206fae1c11..246be6ff63 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -1,6 +1,7 @@ package read import ( + "context" "fmt" "os" "path/filepath" @@ -68,7 +69,7 @@ func readTaskfile( // If we still don't have a copy, get the file in the usual way if b == nil { - b, err = node.Read() + b, err = node.Read(context.Background()) if err != nil { return nil, err } @@ -258,7 +259,7 @@ func Taskfile( Entrypoint: path, Dir: node.Dir, } - b, err := osNode.Read() + b, err := osNode.Read(context.Background()) if err != nil { return nil, err } From 6ca7bfcca76caf7ba33cdc43693b0a9efdcc8e51 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sat, 2 Sep 2023 21:23:17 +0000 Subject: [PATCH 09/17] feat: experiment docs --- docs/docs/experiments/remote_taskfiles.md | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/docs/experiments/remote_taskfiles.md diff --git a/docs/docs/experiments/remote_taskfiles.md b/docs/docs/experiments/remote_taskfiles.md new file mode 100644 index 0000000000..fb8ba12696 --- /dev/null +++ b/docs/docs/experiments/remote_taskfiles.md @@ -0,0 +1,81 @@ +--- +slug: /experiments/remote-taskfiles/ +--- + +# Remote Taskfiles + +- Issue: [#1317][remote-taskfiles-experiment] +- Environment variable: `TASK_X_REMOTE_TASKFILES=1` + +This experiment allows you to specify a remote Taskfile URL when including a +Taskfile. For example: + +```yaml +version: '3' + +include: + my-remote-namespace: https://raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml +``` + +This works exactly the same way that including a local file does. Any tasks in +the remote Taskfile will be available to run from your main Taskfile via the +namespace `my-remote-namespace`. For example, if the remote file contains the +following: + +```yaml +version: '3' + +tasks: + hello: + silent: true + cmds: + - echo "Hello from the remote Taskfile!" +``` + +and you run `task my-remote-namespace:hello`, it will print the text: "Hello +from the remote Taskfile!" to your console. + +## Security + +Running commands from sources that you do not control is always a potential +security risk. For this reason, we have added some checks when using remote +Taskfiles: + +1. When running a task from a remote Taskfile for the first time, Task will + print a warning to the console asking you to check that you are sure that you + trust the source of the Taskfile. If you do not accept the prompt, then Task + will exit with code `104` (not trusted) and nothing will run. If you accept + the prompt, the remote Taskfile will run and further calls to the remote + Taskfile will not prompt you again. +2. Whenever you run a remote Taskfile, Task will create and store a checksum of + the file that you are running. If the checksum changes, then Task will print + another warning to the console to inform you that the contents of the remote + file has changed. If you do not accept the prompt, then Task will exit with + code `104` (not trusted) and nothing will run. If you accept the prompt, the + checksum will be updated and the remote Taskfile will run. + +Task currently supports both `http` and `https` URLs. However, the `http` +requests will not execute by default unless you run the task with the +`--insecure` flag. This is to protect you from accidentally running a remote +Taskfile that is hosted on and unencrypted connection. Sources that are not +protected by TLS are vulnerable to [man-in-the-middle +attacks][man-in-the-middle-attacks] and should be avoided unless you know what +you are doing. + +## Caching & Running Offline + +If for whatever reason, you don't have access to the internet, but you still +need to be able to run your tasks, you are able to use the `--download` flag to +store a cached copy of the remote Taskfile. + + + +If Task detects that you have a local copy of the remote Taskfile, it will use +your local copy instead of downloading the remote file. You can force Task to +work offline by using the `--offline` flag. This will prevent Task from making +any calls to remote sources. + + +[remote-taskfiles-experiment]: https://github.com/go-task/task/issues/1317 +[man-in-the-middle-attacks]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack + From 6971bf57ca1d2ff4795ac7af876ba07ff41728bc Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sat, 2 Sep 2023 21:28:15 +0000 Subject: [PATCH 10/17] chore: changelog --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f0e71025..2a6b56b25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## Unreleased -- Prep work for remote Taskfiles (#1316 by @pd93). +- Prep work for Remote Taskfiles (#1316 by @pd93). +- Added the + [Remote Taskfiles experiment](https://taskfile.dev/experiments/remote-taskfiles) + as a draft (#1152, #1317 by @pd93). ## v3.29.1 - 2023-08-26 @@ -42,7 +45,8 @@ - Bug fixes were made to the [npm installation method](https://taskfile.dev/installation/#npm). (#1190, by @sounisi5011). -- Added the [gentle force experiment](https://taskfile.dev/experiments) as a +- Added the + [gentle force experiment](https://taskfile.dev/experiments/gentle-force) as a draft (#1200, #1216 by @pd93). - Added an `--experiments` flag to allow you to see which experiments are enabled (#1242 by @pd93). From 2e7acfe7b8f43b60343fca6807efd1244800f21d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sun, 3 Sep 2023 03:49:55 +0000 Subject: [PATCH 11/17] chore: remove unused optional param from Node interface --- taskfile/read/node.go | 9 ++++----- taskfile/read/node_base.go | 7 +------ taskfile/read/node_file.go | 5 ++--- taskfile/read/node_http.go | 5 ++--- taskfile/read/taskfile.go | 3 +-- 5 files changed, 10 insertions(+), 19 deletions(-) diff --git a/taskfile/read/node.go b/taskfile/read/node.go index d1953ab6c5..c1ad576ac9 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -13,7 +13,6 @@ import ( type Node interface { Read(ctx context.Context) ([]byte, error) Parent() Node - Optional() bool Location() string Remote() bool } @@ -30,16 +29,16 @@ func NewNodeFromIncludedTaskfile( if err != nil { return nil, err } - return NewFileNode(parent, path, includedTaskfile.Optional) + return NewFileNode(parent, path) } switch getScheme(includedTaskfile.Taskfile) { case "http": if !allowInsecure { return nil, &errors.TaskfileNotSecureError{URI: includedTaskfile.Taskfile} } - return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional) + return NewHTTPNode(parent, includedTaskfile.Taskfile) case "https": - return NewHTTPNode(parent, includedTaskfile.Taskfile, includedTaskfile.Optional) + return NewHTTPNode(parent, includedTaskfile.Taskfile) // If no other scheme matches, we assume it's a file. // This also allows users to explicitly set a file:// scheme. default: @@ -47,7 +46,7 @@ func NewNodeFromIncludedTaskfile( if err != nil { return nil, err } - return NewFileNode(parent, path, includedTaskfile.Optional) + return NewFileNode(parent, path) } } diff --git a/taskfile/read/node_base.go b/taskfile/read/node_base.go index 5a1a5d64f5..e66d0d3559 100644 --- a/taskfile/read/node_base.go +++ b/taskfile/read/node_base.go @@ -5,14 +5,9 @@ package read // and it designed to be embedded in other node types so that this boilerplate // code does not need to be repeated. type BaseNode struct { - parent Node - optional bool + parent Node } func (node *BaseNode) Parent() Node { return node.parent } - -func (node *BaseNode) Optional() bool { - return node.optional -} diff --git a/taskfile/read/node_file.go b/taskfile/read/node_file.go index ce4db4e8f8..49a098ffb0 100644 --- a/taskfile/read/node_file.go +++ b/taskfile/read/node_file.go @@ -16,7 +16,7 @@ type FileNode struct { Entrypoint string } -func NewFileNode(parent Node, path string, optional bool) (*FileNode, error) { +func NewFileNode(parent Node, path string) (*FileNode, error) { path, err := exists(path) if err != nil { return nil, err @@ -24,8 +24,7 @@ func NewFileNode(parent Node, path string, optional bool) (*FileNode, error) { return &FileNode{ BaseNode: BaseNode{ - parent: parent, - optional: optional, + parent: parent, }, Dir: filepath.Dir(path), Entrypoint: filepath.Base(path), diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 8ba7212270..1a6ed425ef 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -15,15 +15,14 @@ type HTTPNode struct { URL *url.URL } -func NewHTTPNode(parent Node, urlString string, optional bool) (*HTTPNode, error) { +func NewHTTPNode(parent Node, urlString string) (*HTTPNode, error) { url, err := url.Parse(urlString) if err != nil { return nil, err } return &HTTPNode{ BaseNode: BaseNode{ - parent: parent, - optional: optional, + parent: parent, }, URL: url, }, nil diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index 246be6ff63..bda46f9382 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -253,8 +253,7 @@ func Taskfile( if _, err = os.Stat(path); err == nil { osNode := &FileNode{ BaseNode: BaseNode{ - parent: node, - optional: false, + parent: node, }, Entrypoint: path, Dir: node.Dir, From 8a2c112929c26448da023650fb7c069ac453024d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sun, 3 Sep 2023 04:01:38 +0000 Subject: [PATCH 12/17] chore: tidy up and generalise NewNode function --- taskfile/included_taskfile.go | 6 ++++++ taskfile/read/node.go | 28 ++++++++-------------------- taskfile/read/taskfile.go | 7 ++++++- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/taskfile/included_taskfile.go b/taskfile/included_taskfile.go index c409052e20..3c204fc803 100644 --- a/taskfile/included_taskfile.go +++ b/taskfile/included_taskfile.go @@ -3,6 +3,7 @@ package taskfile import ( "fmt" "path/filepath" + "strings" "github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/filepathext" @@ -148,6 +149,11 @@ func (it *IncludedTaskfile) FullDirPath() (string, error) { } func (it *IncludedTaskfile) resolvePath(path string) (string, error) { + // If the file is remote, we don't need to resolve the path + if strings.Contains(it.Taskfile, "://") { + return path, nil + } + path, err := execext.Expand(path) if err != nil { return "", err diff --git a/taskfile/read/node.go b/taskfile/read/node.go index c1ad576ac9..7a88b4cf33 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -6,8 +6,6 @@ import ( "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/experiments" - "github.com/go-task/task/v3/internal/logger" - "github.com/go-task/task/v3/taskfile" ) type Node interface { @@ -17,36 +15,26 @@ type Node interface { Remote() bool } -func NewNodeFromIncludedTaskfile( +func NewNode( parent Node, - includedTaskfile taskfile.IncludedTaskfile, + uri string, allowInsecure bool, - tempDir string, - l *logger.Logger, ) (Node, error) { if !experiments.RemoteTaskfiles { - path, err := includedTaskfile.FullTaskfilePath() - if err != nil { - return nil, err - } - return NewFileNode(parent, path) + return NewFileNode(parent, uri) } - switch getScheme(includedTaskfile.Taskfile) { + switch getScheme(uri) { case "http": if !allowInsecure { - return nil, &errors.TaskfileNotSecureError{URI: includedTaskfile.Taskfile} + return nil, &errors.TaskfileNotSecureError{URI: uri} } - return NewHTTPNode(parent, includedTaskfile.Taskfile) + return NewHTTPNode(parent, uri) case "https": - return NewHTTPNode(parent, includedTaskfile.Taskfile) + return NewHTTPNode(parent, uri) // If no other scheme matches, we assume it's a file. // This also allows users to explicitly set a file:// scheme. default: - path, err := includedTaskfile.FullTaskfilePath() - if err != nil { - return nil, err - } - return NewFileNode(parent, path) + return NewFileNode(parent, uri) } } diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index bda46f9382..28039ad94b 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -173,7 +173,12 @@ func Taskfile( } } - includeReaderNode, err := NewNodeFromIncludedTaskfile(node, includedTask, allowInsecure, tempDir, l) + uri, err := includedTask.FullTaskfilePath() + if err != nil { + return err + } + + includeReaderNode, err := NewNode(node, uri, allowInsecure) if err != nil { if includedTask.Optional { return nil From a2f2f8869f7fc76ab0b84caf99fea3fa78667584 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Mon, 4 Sep 2023 09:24:05 +0000 Subject: [PATCH 13/17] fix: use sha256 in remote checksum --- taskfile/read/cache.go | 5 +++-- taskfile/read/taskfile.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/taskfile/read/cache.go b/taskfile/read/cache.go index da78070021..9a8a4a5040 100644 --- a/taskfile/read/cache.go +++ b/taskfile/read/cache.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) type Cache struct { @@ -22,7 +23,7 @@ func NewCache(dir string) (*Cache, error) { }, nil } -func (c *Cache) checksum(b []byte) string { +func checksum(b []byte) string { h := sha256.New() h.Write(b) return base64.StdEncoding.EncodeToString(h.Sum(nil)) @@ -46,7 +47,7 @@ func (c *Cache) readChecksum(node Node) string { } func (c *Cache) key(node Node) string { - return base64.StdEncoding.EncodeToString([]byte(node.Location())) + return strings.TrimRight(checksum([]byte(node.Location())), "=") } func (c *Cache) cacheFilePath(node Node) string { diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index 28039ad94b..f61a3d4c37 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -79,7 +79,7 @@ func readTaskfile( l.VerboseOutf(logger.Magenta, "task: [%s] Fetched remote copy\n", node.Location()) // Get the checksums - checksum := cache.checksum(b) + checksum := checksum(b) cachedChecksum := cache.readChecksum(node) // If the checksum doesn't exist, prompt the user to continue From 69871ddf7f67408674decb52cae6b9c8664d2f79 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Mon, 4 Sep 2023 09:51:32 +0000 Subject: [PATCH 14/17] feat: --download by itself will not run a task --- args/args.go | 12 ++---------- args/args_test.go | 36 ++++++++++++------------------------ cmd/task/task.go | 7 +++++++ 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/args/args.go b/args/args.go index 801ff88bb0..921b723a23 100644 --- a/args/args.go +++ b/args/args.go @@ -8,7 +8,7 @@ import ( // ParseV3 parses command line argument: tasks and global variables func ParseV3(args ...string) ([]taskfile.Call, *taskfile.Vars) { - var calls []taskfile.Call + calls := []taskfile.Call{} globals := &taskfile.Vars{} for _, arg := range args { @@ -21,16 +21,12 @@ func ParseV3(args ...string) ([]taskfile.Call, *taskfile.Vars) { globals.Set(name, taskfile.Var{Static: value}) } - if len(calls) == 0 { - calls = append(calls, taskfile.Call{Task: "default", Direct: true}) - } - return calls, globals } // ParseV2 parses command line argument: tasks and vars of each task func ParseV2(args ...string) ([]taskfile.Call, *taskfile.Vars) { - var calls []taskfile.Call + calls := []taskfile.Call{} globals := &taskfile.Vars{} for _, arg := range args { @@ -51,10 +47,6 @@ func ParseV2(args ...string) ([]taskfile.Call, *taskfile.Vars) { } } - if len(calls) == 0 { - calls = append(calls, taskfile.Call{Task: "default", Direct: true}) - } - return calls, globals } diff --git a/args/args_test.go b/args/args_test.go index 3b74c7ddba..5cea4b0296 100644 --- a/args/args_test.go +++ b/args/args_test.go @@ -73,22 +73,16 @@ func TestArgsV3(t *testing.T) { }, }, { - Args: nil, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: nil, + ExpectedCalls: []taskfile.Call{}, }, { - Args: []string{}, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: []string{}, + ExpectedCalls: []taskfile.Call{}, }, { - Args: []string{"FOO=bar", "BAR=baz"}, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: []string{"FOO=bar", "BAR=baz"}, + ExpectedCalls: []taskfile.Call{}, ExpectedGlobals: &taskfile.Vars{ OrderedMap: orderedmap.FromMapWithOrder( map[string]taskfile.Var{ @@ -191,22 +185,16 @@ func TestArgsV2(t *testing.T) { }, }, { - Args: nil, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: nil, + ExpectedCalls: []taskfile.Call{}, }, { - Args: []string{}, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: []string{}, + ExpectedCalls: []taskfile.Call{}, }, { - Args: []string{"FOO=bar", "BAR=baz"}, - ExpectedCalls: []taskfile.Call{ - {Task: "default", Direct: true}, - }, + Args: []string{"FOO=bar", "BAR=baz"}, + ExpectedCalls: []taskfile.Call{}, ExpectedGlobals: &taskfile.Vars{ OrderedMap: orderedmap.FromMapWithOrder( map[string]taskfile.Var{ diff --git a/cmd/task/task.go b/cmd/task/task.go index eac6f58fbc..b424bfacd7 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -295,6 +295,13 @@ func run() error { calls, globals = args.ParseV2(tasksAndVars...) } + // If there are no calls, run the default task instead + // Unless the download flag is specified, in which case we want to download + // the Taskfile and do nothing else + if len(calls) == 0 && !flags.download { + calls = append(calls, taskfile.Call{Task: "default", Direct: true}) + } + globals.Set("CLI_ARGS", taskfile.Var{Static: cliArgs}) e.Taskfile.Vars.Merge(globals) From 1c93c0137af65069b788cae2d3c5021ca2978966 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Mon, 4 Sep 2023 10:44:00 +0000 Subject: [PATCH 15/17] feat: custom error if remote taskfiles experiment is not enabled --- taskfile/read/node.go | 21 ++++++++++----------- taskfile/read/node_file.go | 5 ++--- taskfile/read/node_http.go | 7 +++++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/taskfile/read/node.go b/taskfile/read/node.go index 7a88b4cf33..eec9ce97d4 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -20,22 +20,21 @@ func NewNode( uri string, allowInsecure bool, ) (Node, error) { - if !experiments.RemoteTaskfiles { - return NewFileNode(parent, uri) - } + var node Node + var err error switch getScheme(uri) { case "http": - if !allowInsecure { - return nil, &errors.TaskfileNotSecureError{URI: uri} - } - return NewHTTPNode(parent, uri) + node, err = NewHTTPNode(parent, uri, allowInsecure) case "https": - return NewHTTPNode(parent, uri) - // If no other scheme matches, we assume it's a file. - // This also allows users to explicitly set a file:// scheme. + node, err = NewHTTPNode(parent, uri, allowInsecure) default: - return NewFileNode(parent, uri) + // If no other scheme matches, we assume it's a file + node, err = NewFileNode(parent, uri) + } + if node.Remote() && !experiments.RemoteTaskfiles { + return nil, errors.New("task: Remote taskfiles are not enabled. You can read more about this experiment and how to enable it at https://taskfile.dev/experiments/remote-taskfiles") } + return node, err } func getScheme(uri string) string { diff --git a/taskfile/read/node_file.go b/taskfile/read/node_file.go index 49a098ffb0..5b6ee1d060 100644 --- a/taskfile/read/node_file.go +++ b/taskfile/read/node_file.go @@ -16,12 +16,11 @@ type FileNode struct { Entrypoint string } -func NewFileNode(parent Node, path string) (*FileNode, error) { - path, err := exists(path) +func NewFileNode(parent Node, uri string) (*FileNode, error) { + path, err := exists(uri) if err != nil { return nil, err } - return &FileNode{ BaseNode: BaseNode{ parent: parent, diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 1a6ed425ef..70e9db3839 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -15,11 +15,14 @@ type HTTPNode struct { URL *url.URL } -func NewHTTPNode(parent Node, urlString string) (*HTTPNode, error) { - url, err := url.Parse(urlString) +func NewHTTPNode(parent Node, uri string, allowInsecure bool) (*HTTPNode, error) { + url, err := url.Parse(uri) if err != nil { return nil, err } + if url.Scheme == "http" && !allowInsecure { + return nil, &errors.TaskfileNotSecureError{URI: uri} + } return &HTTPNode{ BaseNode: BaseNode{ parent: parent, From 732fe791ded1a0d5f4806f0f3283341893fde097 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Tue, 5 Sep 2023 18:12:30 +0000 Subject: [PATCH 16/17] refactor: BaseNode functional options and simplified FileNode --- errors/errors.go | 2 ++ setup.go | 12 +++++----- taskfile/read/node.go | 13 +++++------ taskfile/read/node_base.go | 46 +++++++++++++++++++++++++++++++++----- taskfile/read/node_file.go | 36 +++++++++++------------------ taskfile/read/node_http.go | 13 +++++------ taskfile/read/taskfile.go | 21 ++++++++++++----- 7 files changed, 89 insertions(+), 54 deletions(-) diff --git a/errors/errors.go b/errors/errors.go index 4cdea8a206..694e0c5fd3 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -45,10 +45,12 @@ func New(text string) error { return errors.New(text) } +// Is wraps the standard errors.Is function so that we don't need to alias that package. func Is(err, target error) bool { return errors.Is(err, target) } +// As wraps the standard errors.As function so that we don't need to alias that package. func As(err error, target any) bool { return errors.As(err, target) } diff --git a/setup.go b/setup.go index b84d182f6b..726e256731 100644 --- a/setup.go +++ b/setup.go @@ -72,12 +72,13 @@ func (e *Executor) setCurrentDir() error { } func (e *Executor) readTaskfile() error { - var err error + uri := filepath.Join(e.Dir, e.Entrypoint) + node, err := read.NewNode(uri, e.Insecure) + if err != nil { + return err + } e.Taskfile, err = read.Taskfile( - &read.FileNode{ - Dir: e.Dir, - Entrypoint: e.Entrypoint, - }, + node, e.Insecure, e.Download, e.Offline, @@ -87,7 +88,6 @@ func (e *Executor) readTaskfile() error { if err != nil { return err } - e.Dir = filepath.Dir(e.Taskfile.Location) return nil } diff --git a/taskfile/read/node.go b/taskfile/read/node.go index eec9ce97d4..f9cff2c286 100644 --- a/taskfile/read/node.go +++ b/taskfile/read/node.go @@ -12,24 +12,23 @@ type Node interface { Read(ctx context.Context) ([]byte, error) Parent() Node Location() string + Optional() bool Remote() bool } func NewNode( - parent Node, uri string, - allowInsecure bool, + insecure bool, + opts ...NodeOption, ) (Node, error) { var node Node var err error switch getScheme(uri) { - case "http": - node, err = NewHTTPNode(parent, uri, allowInsecure) - case "https": - node, err = NewHTTPNode(parent, uri, allowInsecure) + case "http", "https": + node, err = NewHTTPNode(uri, insecure, opts...) default: // If no other scheme matches, we assume it's a file - node, err = NewFileNode(parent, uri) + node, err = NewFileNode(uri, opts...) } if node.Remote() && !experiments.RemoteTaskfiles { return nil, errors.New("task: Remote taskfiles are not enabled. You can read more about this experiment and how to enable it at https://taskfile.dev/experiments/remote-taskfiles") diff --git a/taskfile/read/node_base.go b/taskfile/read/node_base.go index e66d0d3559..ea1088222e 100644 --- a/taskfile/read/node_base.go +++ b/taskfile/read/node_base.go @@ -1,13 +1,47 @@ package read -// BaseNode is a generic node that implements the Parent() and Optional() -// methods of the NodeReader interface. It does not implement the Read() method -// and it designed to be embedded in other node types so that this boilerplate -// code does not need to be repeated. -type BaseNode struct { - parent Node +type ( + NodeOption func(*BaseNode) + // BaseNode is a generic node that implements the Parent() and Optional() + // methods of the NodeReader interface. It does not implement the Read() method + // and it designed to be embedded in other node types so that this boilerplate + // code does not need to be repeated. + BaseNode struct { + parent Node + optional bool + } +) + +func NewBaseNode(opts ...NodeOption) *BaseNode { + node := &BaseNode{ + parent: nil, + optional: false, + } + + // Apply options + for _, opt := range opts { + opt(node) + } + + return node +} + +func WithParent(parent Node) NodeOption { + return func(node *BaseNode) { + node.parent = parent + } } func (node *BaseNode) Parent() Node { return node.parent } + +func WithOptional(optional bool) NodeOption { + return func(node *BaseNode) { + node.optional = optional + } +} + +func (node *BaseNode) Optional() bool { + return node.optional +} diff --git a/taskfile/read/node_file.go b/taskfile/read/node_file.go index 5b6ee1d060..5cf25dd72f 100644 --- a/taskfile/read/node_file.go +++ b/taskfile/read/node_file.go @@ -11,20 +11,26 @@ import ( // A FileNode is a node that reads a taskfile from the local filesystem. type FileNode struct { - BaseNode + *BaseNode Dir string Entrypoint string } -func NewFileNode(parent Node, uri string) (*FileNode, error) { - path, err := exists(uri) +func NewFileNode(uri string, opts ...NodeOption) (*FileNode, error) { + base := NewBaseNode(opts...) + if uri == "" { + d, err := os.Getwd() + if err != nil { + return nil, err + } + uri = d + } + path, err := existsWalk(uri) if err != nil { return nil, err } return &FileNode{ - BaseNode: BaseNode{ - parent: parent, - }, + BaseNode: base, Dir: filepath.Dir(path), Entrypoint: filepath.Base(path), }, nil @@ -39,26 +45,10 @@ func (node *FileNode) Remote() bool { } func (node *FileNode) Read(ctx context.Context) ([]byte, error) { - if node.Dir == "" { - d, err := os.Getwd() - if err != nil { - return nil, err - } - node.Dir = d - } - - path, err := existsWalk(node.Location()) - if err != nil { - return nil, err - } - node.Dir = filepath.Dir(path) - node.Entrypoint = filepath.Base(path) - - f, err := os.Open(path) + f, err := os.Open(node.Location()) if err != nil { return nil, err } defer f.Close() - return io.ReadAll(f) } diff --git a/taskfile/read/node_http.go b/taskfile/read/node_http.go index 70e9db3839..e27ff2a8db 100644 --- a/taskfile/read/node_http.go +++ b/taskfile/read/node_http.go @@ -11,23 +11,22 @@ import ( // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. type HTTPNode struct { - BaseNode + *BaseNode URL *url.URL } -func NewHTTPNode(parent Node, uri string, allowInsecure bool) (*HTTPNode, error) { +func NewHTTPNode(uri string, insecure bool, opts ...NodeOption) (*HTTPNode, error) { + base := NewBaseNode(opts...) url, err := url.Parse(uri) if err != nil { return nil, err } - if url.Scheme == "http" && !allowInsecure { + if url.Scheme == "http" && !insecure { return nil, &errors.TaskfileNotSecureError{URI: uri} } return &HTTPNode{ - BaseNode: BaseNode{ - parent: parent, - }, - URL: url, + BaseNode: base, + URL: url, }, nil } diff --git a/taskfile/read/taskfile.go b/taskfile/read/taskfile.go index f61a3d4c37..d952b5cf7e 100644 --- a/taskfile/read/taskfile.go +++ b/taskfile/read/taskfile.go @@ -130,7 +130,7 @@ func readTaskfile( // or Taskfile.yaml when entrypoint is left empty func Taskfile( node Node, - allowInsecure bool, + insecure bool, download bool, offline bool, tempDir string, @@ -178,7 +178,10 @@ func Taskfile( return err } - includeReaderNode, err := NewNode(node, uri, allowInsecure) + includeReaderNode, err := NewNode(uri, insecure, + WithParent(node), + WithOptional(includedTask.Optional), + ) if err != nil { if includedTask.Optional { return nil @@ -257,9 +260,7 @@ func Taskfile( path := filepathext.SmartJoin(node.Dir, fmt.Sprintf("Taskfile_%s.yml", runtime.GOOS)) if _, err = os.Stat(path); err == nil { osNode := &FileNode{ - BaseNode: BaseNode{ - parent: node, - }, + BaseNode: NewBaseNode(WithParent(node)), Entrypoint: path, Dir: node.Dir, } @@ -295,6 +296,11 @@ func Taskfile( return _taskfile(node) } +// exists will check if a file at the given path exists. If it does, it will +// return the path to it. If it does not, it will search the search for any +// files at the given path with any of the default Taskfile files names. If any +// of these match a file, the first matching path will be returned. If no files +// are found, an error will be returned. func exists(path string) (string, error) { fi, err := os.Stat(path) if err != nil { @@ -314,6 +320,11 @@ func exists(path string) (string, error) { return "", errors.TaskfileNotFoundError{URI: path, Walk: false} } +// existsWalk will check if a file at the given path exists by calling the +// exists function. If a file is not found, it will walk up the directory tree +// calling the exists function until it finds a file or reaches the root +// directory. On supported operating systems, it will also check if the user ID +// of the directory changes and abort if it does. func existsWalk(path string) (string, error) { origPath := path owner, err := sysinfo.Owner(path) From 756084cd21bd67c4064984232430b8bee69f4792 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Fri, 8 Sep 2023 21:52:13 +0000 Subject: [PATCH 17/17] fix: use hex encoding for checksum instead of b64 --- taskfile/read/cache.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/taskfile/read/cache.go b/taskfile/read/cache.go index 9a8a4a5040..3cc16a17ba 100644 --- a/taskfile/read/cache.go +++ b/taskfile/read/cache.go @@ -2,7 +2,6 @@ package read import ( "crypto/sha256" - "encoding/base64" "fmt" "os" "path/filepath" @@ -26,7 +25,7 @@ func NewCache(dir string) (*Cache, error) { func checksum(b []byte) string { h := sha256.New() h.Write(b) - return base64.StdEncoding.EncodeToString(h.Sum(nil)) + return fmt.Sprintf("%x", h.Sum(nil)) } func (c *Cache) write(node Node, b []byte) error {