From dc8a5fa6e6949e48c0ec481ec308d7767bf3421d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Fri, 11 Apr 2025 13:53:30 +0000 Subject: [PATCH 1/5] feat: cache as node, RemoteNode and cache-first approach --- cmd/task/task.go | 14 +----- errors/errors_taskfile.go | 13 ++---- taskfile/cache.go | 72 ------------------------------ taskfile/node.go | 14 +++--- taskfile/node_cache.go | 89 +++++++++++++++++++++++++++++++++++++ taskfile/node_file.go | 11 +---- taskfile/node_git.go | 17 +++++-- taskfile/node_git_test.go | 17 +++---- taskfile/node_http.go | 19 +++++--- taskfile/node_stdin.go | 7 +-- taskfile/reader.go | 93 ++++++++++++++++----------------------- 11 files changed, 179 insertions(+), 187 deletions(-) delete mode 100644 taskfile/cache.go create mode 100644 taskfile/node_cache.go diff --git a/cmd/task/task.go b/cmd/task/task.go index 62d5be901d..88eb8a86ea 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -18,7 +18,6 @@ import ( "github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/version" - "github.com/go-task/task/v3/taskfile" "github.com/go-task/task/v3/taskfile/ast" ) @@ -121,18 +120,9 @@ func run() error { return err } - // If the download flag is specified, we should stop execution as soon as - // taskfile is downloaded - if flags.Download { - return nil - } - if flags.ClearCache { - cache, err := taskfile.NewCache(e.TempDir.Remote) - if err != nil { - return err - } - return cache.Clear() + cachePath := filepath.Join(e.TempDir.Remote, "remote") + return os.RemoveAll(cachePath) } listOptions := task.NewListOptions( diff --git a/errors/errors_taskfile.go b/errors/errors_taskfile.go index ad6d1898e9..cbb160aebe 100644 --- a/errors/errors_taskfile.go +++ b/errors/errors_taskfile.go @@ -155,19 +155,14 @@ func (err *TaskfileVersionCheckError) Code() int { // TaskfileNetworkTimeoutError is returned when the user attempts to use a remote // Taskfile but a network connection could not be established within the timeout. type TaskfileNetworkTimeoutError struct { - URI string - Timeout time.Duration - CheckedCache bool + URI string + Timeout time.Duration } func (err *TaskfileNetworkTimeoutError) Error() string { - var cacheText string - if err.CheckedCache { - cacheText = " and no offline copy was found in the cache" - } return fmt.Sprintf( - `task: Network connection timed out after %s while attempting to download Taskfile %q%s`, - err.Timeout, err.URI, cacheText, + `task: Network connection timed out after %s while attempting to download Taskfile %q`, + err.Timeout, err.URI, ) } diff --git a/taskfile/cache.go b/taskfile/cache.go deleted file mode 100644 index 2b57c17dd8..0000000000 --- a/taskfile/cache.go +++ /dev/null @@ -1,72 +0,0 @@ -package taskfile - -import ( - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "strings" -) - -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 checksum(b []byte) string { - h := sha256.New() - h.Write(b) - return fmt.Sprintf("%x", 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 strings.TrimRight(checksum([]byte(node.Location())), "=") -} - -func (c *Cache) cacheFilePath(node Node) string { - return c.filePath(node, "yaml") -} - -func (c *Cache) checksumFilePath(node Node) string { - return c.filePath(node, "checksum") -} - -func (c *Cache) filePath(node Node, suffix string) string { - lastDir, filename := node.FilenameAndLastDir() - prefix := filename - // Means it's not "", nor "." nor "/", so it's a valid directory - if len(lastDir) > 1 { - prefix = fmt.Sprintf("%s-%s", lastDir, filename) - } - return filepath.Join(c.dir, fmt.Sprintf("%s.%s.%s", prefix, c.key(node), suffix)) -} - -func (c *Cache) Clear() error { - return os.RemoveAll(c.dir) -} diff --git a/taskfile/node.go b/taskfile/node.go index 486a0a16f0..03c53c8096 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -14,14 +14,18 @@ import ( ) type Node interface { - Read(ctx context.Context) ([]byte, error) + Read() ([]byte, error) Parent() Node Location() string Dir() string - Remote() bool ResolveEntrypoint(entrypoint string) (string, error) ResolveDir(dir string) (string, error) - FilenameAndLastDir() (string, string) +} + +type RemoteNode interface { + Node + ReadContext(ctx context.Context) ([]byte, error) + CacheKey() string } func NewRootNode( @@ -58,10 +62,8 @@ func NewNode( node, err = NewHTTPNode(entrypoint, dir, insecure, timeout, opts...) default: node, err = NewFileNode(entrypoint, dir, opts...) - } - - if node.Remote() && !experiments.RemoteTaskfiles.Enabled() { + if _, isRemote := node.(RemoteNode); isRemote && !experiments.RemoteTaskfiles.Enabled() { 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 diff --git a/taskfile/node_cache.go b/taskfile/node_cache.go new file mode 100644 index 0000000000..26c11c0f73 --- /dev/null +++ b/taskfile/node_cache.go @@ -0,0 +1,89 @@ +package taskfile + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" +) + +const remoteCacheDir = "remote" + +type CacheNode struct { + *BaseNode + source RemoteNode +} + +func NewCacheNode(source RemoteNode, dir string) *CacheNode { + return &CacheNode{ + BaseNode: &BaseNode{ + dir: filepath.Join(dir, remoteCacheDir), + }, + source: source, + } +} + +func (node *CacheNode) Read() ([]byte, error) { + return os.ReadFile(node.Location()) +} + +func (node *CacheNode) Write(data []byte) error { + if err := node.CreateCacheDir(); err != nil { + return err + } + return os.WriteFile(node.Location(), data, 0o644) +} + +func (node *CacheNode) ReadChecksum() string { + b, _ := os.ReadFile(node.checksumPath()) + return string(b) +} + +func (node *CacheNode) WriteChecksum(checksum string) error { + if err := node.CreateCacheDir(); err != nil { + return err + } + return os.WriteFile(node.checksumPath(), []byte(checksum), 0o644) +} + +func (node *CacheNode) CreateCacheDir() error { + if err := os.MkdirAll(node.dir, 0o755); err != nil { + return err + } + return nil +} + +func (node *CacheNode) ChecksumPrompt(checksum string) string { + cachedChecksum := node.ReadChecksum() + switch { + + // If the checksum doesn't exist, prompt the user to continue + case cachedChecksum == "": + return taskfileUntrustedPrompt + + // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue + case cachedChecksum != checksum: + return taskfileChangedPrompt + + default: + return "" + } +} + +func (node *CacheNode) Location() string { + return node.filePath("yaml") +} + +func (node *CacheNode) checksumPath() string { + return node.filePath("checksum") +} + +func (node *CacheNode) filePath(suffix string) string { + return filepath.Join(node.dir, fmt.Sprintf("%s.%s", node.source.CacheKey(), suffix)) +} + +func checksum(b []byte) string { + h := sha256.New() + h.Write(b) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/taskfile/node_file.go b/taskfile/node_file.go index e0dedcb5c2..a94a7cf400 100644 --- a/taskfile/node_file.go +++ b/taskfile/node_file.go @@ -1,7 +1,6 @@ package taskfile import ( - "context" "io" "os" "path/filepath" @@ -34,11 +33,7 @@ func (node *FileNode) Location() string { return node.Entrypoint } -func (node *FileNode) Remote() bool { - return false -} - -func (node *FileNode) Read(ctx context.Context) ([]byte, error) { +func (node *FileNode) Read() ([]byte, error) { f, err := os.Open(node.Location()) if err != nil { return nil, err @@ -114,7 +109,3 @@ func (node *FileNode) ResolveDir(dir string) (string, error) { entrypointDir := filepath.Dir(node.Entrypoint) return filepathext.SmartJoin(entrypointDir, path), nil } - -func (node *FileNode) FilenameAndLastDir() (string, string) { - return "", filepath.Base(node.Entrypoint) -} diff --git a/taskfile/node_git.go b/taskfile/node_git.go index 72ac7e1a6d..fd15ce4e8a 100644 --- a/taskfile/node_git.go +++ b/taskfile/node_git.go @@ -71,7 +71,11 @@ func (node *GitNode) Remote() bool { return true } -func (node *GitNode) Read(_ context.Context) ([]byte, error) { +func (node *GitNode) Read() ([]byte, error) { + return node.ReadContext(context.Background()) +} + +func (node *GitNode) ReadContext(_ context.Context) ([]byte, error) { fs := memfs.New() storer := memory.NewStorage() _, err := git.Clone(storer, fs, &git.CloneOptions{ @@ -121,6 +125,13 @@ func (node *GitNode) ResolveDir(dir string) (string, error) { return filepathext.SmartJoin(entrypointDir, path), nil } -func (node *GitNode) FilenameAndLastDir() (string, string) { - return filepath.Base(node.path), filepath.Base(filepath.Dir(node.path)) +func (node *GitNode) CacheKey() string { + checksum := strings.TrimRight(checksum([]byte(node.Location())), "=") + prefix := filepath.Base(filepath.Dir(node.path)) + lastDir := filepath.Base(node.path) + // Means it's not "", nor "." nor "/", so it's a valid directory + if len(lastDir) > 1 { + prefix = fmt.Sprintf("%s-%s", lastDir, prefix) + } + return fmt.Sprintf("%s.%s", prefix, checksum) } diff --git a/taskfile/node_git_test.go b/taskfile/node_git_test.go index 2df39c54ba..1b88a083a9 100644 --- a/taskfile/node_git_test.go +++ b/taskfile/node_git_test.go @@ -62,24 +62,21 @@ func TestGitNode_httpsWithDir(t *testing.T) { assert.Equal(t, "https://github.com/foo/bar.git//directory/common.yml?ref=main", entrypoint) } -func TestGitNode_FilenameAndDir(t *testing.T) { +func TestGitNode_CacheKey(t *testing.T) { t.Parallel() node, err := NewGitNode("https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", "", false) assert.NoError(t, err) - filename, dir := node.FilenameAndLastDir() - assert.Equal(t, "Taskfile.yml", filename) - assert.Equal(t, "directory", dir) + key := node.CacheKey() + assert.Equal(t, "Taskfile.yml-directory.f1ddddac425a538870230a3e38fc0cded4ec5da250797b6cab62c82477718fbb", key) node, err = NewGitNode("https://github.com/foo/bar.git//Taskfile.yml?ref=main", "", false) assert.NoError(t, err) - filename, dir = node.FilenameAndLastDir() - assert.Equal(t, "Taskfile.yml", filename) - assert.Equal(t, ".", dir) + key = node.CacheKey() + assert.Equal(t, "Taskfile.yml-..39d28c1ff36f973705ae188b991258bbabaffd6d60bcdde9693d157d00d5e3a4", key) node, err = NewGitNode("https://github.com/foo/bar.git//multiple/directory/Taskfile.yml?ref=main", "", false) assert.NoError(t, err) - filename, dir = node.FilenameAndLastDir() - assert.Equal(t, "Taskfile.yml", filename) - assert.Equal(t, "directory", dir) + key = node.CacheKey() + assert.Equal(t, "Taskfile.yml-directory.1b6d145e01406dcc6c0aa572e5a5d1333be1ccf2cae96d18296d725d86197d31", key) } diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 6197f32777..aaddd21a9c 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -2,10 +2,12 @@ package taskfile import ( "context" + "fmt" "io" "net/http" "net/url" "path/filepath" + "strings" "time" "github.com/go-task/task/v3/errors" @@ -49,11 +51,11 @@ func (node *HTTPNode) Location() string { return node.entrypoint } -func (node *HTTPNode) Remote() bool { - return true +func (node *HTTPNode) Read() ([]byte, error) { + return node.ReadContext(context.Background()) } -func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) { +func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { url, err := RemoteExists(ctx, node.URL, node.timeout) if err != nil { return nil, err @@ -116,7 +118,14 @@ func (node *HTTPNode) ResolveDir(dir string) (string, error) { return filepathext.SmartJoin(parent, path), nil } -func (node *HTTPNode) FilenameAndLastDir() (string, string) { +func (node *HTTPNode) CacheKey() string { + checksum := strings.TrimRight(checksum([]byte(node.Location())), "=") dir, filename := filepath.Split(node.entrypoint) - return filepath.Base(dir), filename + lastDir := filepath.Base(dir) + prefix := filename + // Means it's not "", nor "." nor "/", so it's a valid directory + if len(lastDir) > 1 { + prefix = fmt.Sprintf("%s-%s", lastDir, filename) + } + return fmt.Sprintf("%s.%s", prefix, checksum) } diff --git a/taskfile/node_stdin.go b/taskfile/node_stdin.go index 20d33137d3..387f50fe83 100644 --- a/taskfile/node_stdin.go +++ b/taskfile/node_stdin.go @@ -2,7 +2,6 @@ package taskfile import ( "bufio" - "context" "fmt" "os" "strings" @@ -30,7 +29,7 @@ func (node *StdinNode) Remote() bool { return false } -func (node *StdinNode) Read(ctx context.Context) ([]byte, error) { +func (node *StdinNode) Read() ([]byte, error) { var stdin []byte scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { @@ -72,7 +71,3 @@ func (node *StdinNode) ResolveDir(dir string) (string, error) { return filepathext.SmartJoin(node.Dir(), path), nil } - -func (node *StdinNode) FilenameAndLastDir() (string, string) { - return "", "__stdin__" -} diff --git a/taskfile/reader.go b/taskfile/reader.go index 9de57c994f..b27356093d 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -317,7 +317,7 @@ func (r *Reader) include(node Node) error { } func (r *Reader) readNode(node Node) (*ast.Taskfile, error) { - b, err := r.loadNodeContent(node) + b, err := r.readNodeContent(node) if err != nil { return nil, err } @@ -358,72 +358,57 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) { return &tf, nil } -func (r *Reader) loadNodeContent(node Node) ([]byte, error) { - if !node.Remote() { - ctx, cf := context.WithTimeout(context.Background(), r.timeout) - defer cf() - return node.Read(ctx) +func (r *Reader) readNodeContent(node Node) ([]byte, error) { + if node, isRemote := node.(RemoteNode); isRemote { + return r.readRemoteNodeContent(node) } + return node.Read() +} - cache, err := NewCache(r.tempDir) - if err != nil { - return nil, err - } +func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { + cache := NewCacheNode(node, r.tempDir) + + // If we have not been been forced to download, check the cache + if !r.download { + r.debugf("checking cache for %q in %q\n", node.Location(), cache.Location()) + b, err := cache.Read() + switch { + // If the cache doesn't exist, we need to download the file + case errors.Is(err, os.ErrNotExist): + r.debugf("no cache found\n") + // If we couldn't find a cached copy, and we are offline, we can't do anything + if r.offline { + return nil, &errors.TaskfileCacheNotFoundError{ + URI: node.Location(), + } + } - if r.offline { - // In offline mode try to use cached copy - cached, err := cache.read(node) - if errors.Is(err, os.ErrNotExist) { - return nil, &errors.TaskfileCacheNotFoundError{URI: node.Location()} - } else if err != nil { + // Some other error + case err != nil: return nil, err - } - r.debugf("task: [%s] Fetched cached copy\n", node.Location()) - return cached, nil + // Found cache, return it + default: + r.debugf("cache found\n") + return b, nil + } } + // If we have not been forced to be offline, try to fetch the remote file ctx, cf := context.WithTimeout(context.Background(), r.timeout) defer cf() - b, err := node.Read(ctx) - if errors.Is(err, &errors.TaskfileNetworkTimeoutError{}) { - // If we timed out then we likely have a network issue - - // If a download was requested, then we can't use a cached copy - if r.download { - return nil, &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: r.timeout} - } - - // Search for any cached copies - cached, err := cache.read(node) - if errors.Is(err, os.ErrNotExist) { - return nil, &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: r.timeout, CheckedCache: true} - } else if err != nil { - return nil, err - } - r.debugf("task: [%s] Network timeout. Fetched cached copy\n", node.Location()) - - return cached, nil - - } else if err != nil { + // Try to read the remote file + b, err := node.ReadContext(ctx) + if err != nil { return nil, err } - r.debugf("task: [%s] Fetched remote copy\n", node.Location()) - // Get the checksums + r.debugf("found remote file at %q\n", node.Location()) checksum := checksum(b) - cachedChecksum := cache.readChecksum(node) - - var prompt string - if cachedChecksum == "" { - // If the checksum doesn't exist, prompt the user to continue - prompt = taskfileUntrustedPrompt - } else if checksum != cachedChecksum { - // If there is a cached hash, but it doesn't match the expected hash, prompt the user to continue - prompt = taskfileChangedPrompt - } + prompt := cache.ChecksumPrompt(checksum) + // If we need to prompt the user, do it and cache the file if prompt != "" { if err := func() error { r.promptMutex.Lock() @@ -434,13 +419,13 @@ func (r *Reader) loadNodeContent(node Node) ([]byte, error) { } // Store the checksum - if err := cache.writeChecksum(node, checksum); err != nil { + if err := cache.WriteChecksum(checksum); err != nil { return nil, err } // Cache the file r.debugf("task: [%s] Caching downloaded file\n", node.Location()) - if err = cache.write(node, b); err != nil { + if err = cache.Write(b); err != nil { return nil, err } } From 47d1a8efa1df5eb0a3e1cd7a0dfd3190206ba649 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Fri, 11 Apr 2025 14:15:21 +0000 Subject: [PATCH 2/5] feat: cache expiry --- executor.go | 55 +++++++++++++++--------- internal/flags/flags.go | 69 ++++++++++++++++--------------- setup.go | 1 + taskfile/node_cache.go | 24 +++++++++++ taskfile/reader.go | 92 ++++++++++++++++++++++++++++------------- 5 files changed, 159 insertions(+), 82 deletions(-) diff --git a/executor.go b/executor.go index 329ddc132d..cb846464cf 100644 --- a/executor.go +++ b/executor.go @@ -27,26 +27,27 @@ type ( // within them. Executor struct { // Flags - Dir string - Entrypoint string - TempDir TempDir - Force bool - ForceAll bool - Insecure bool - Download bool - Offline bool - Timeout time.Duration - Watch bool - Verbose bool - Silent bool - AssumeYes bool - AssumeTerm bool // Used for testing - Dry bool - Summary bool - Parallel bool - Color bool - Concurrency int - Interval time.Duration + Dir string + Entrypoint string + TempDir TempDir + Force bool + ForceAll bool + Insecure bool + Download bool + Offline bool + Timeout time.Duration + CacheExpiryDuration time.Duration + Watch bool + Verbose bool + Silent bool + AssumeYes bool + AssumeTerm bool // Used for testing + Dry bool + Summary bool + Parallel bool + Color bool + Concurrency int + Interval time.Duration // I/O Stdin io.Reader @@ -240,6 +241,20 @@ func (o *timeoutOption) ApplyToExecutor(e *Executor) { e.Timeout = o.timeout } +// WithCacheExpiryDuration sets the duration after which the cache is considered +// expired. By default, the cache is considered expired after 24 hours. +func WithCacheExpiryDuration(duration time.Duration) ExecutorOption { + return &cacheExpiryDurationOption{duration: duration} +} + +type cacheExpiryDurationOption struct { + duration time.Duration +} + +func (o *cacheExpiryDurationOption) ApplyToExecutor(r *Executor) { + r.CacheExpiryDuration = o.duration +} + // WithWatch tells the [Executor] to keep running in the background and watch // for changes to the fingerprint of the tasks that are run. When changes are // detected, a new task run is triggered. diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 3069ec3ec8..d104b5d20e 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -40,39 +40,40 @@ Options: ` var ( - Version bool - Help bool - Init bool - Completion string - List bool - ListAll bool - ListJson bool - TaskSort string - Status bool - NoStatus bool - Insecure bool - Force bool - ForceAll bool - Watch bool - Verbose bool - Silent bool - AssumeYes bool - Dry bool - Summary bool - ExitCode bool - Parallel bool - Concurrency int - Dir string - Entrypoint string - Output ast.Output - Color bool - Interval time.Duration - Global bool - Experiments bool - Download bool - Offline bool - ClearCache bool - Timeout time.Duration + Version bool + Help bool + Init bool + Completion string + List bool + ListAll bool + ListJson bool + TaskSort string + Status bool + NoStatus bool + Insecure bool + Force bool + ForceAll bool + Watch bool + Verbose bool + Silent bool + AssumeYes bool + Dry bool + Summary bool + ExitCode bool + Parallel bool + Concurrency int + Dir string + Entrypoint string + Output ast.Output + Color bool + Interval time.Duration + Global bool + Experiments bool + Download bool + Offline bool + ClearCache bool + Timeout time.Duration + CacheExpiryDuration time.Duration ) func init() { @@ -131,6 +132,7 @@ func init() { pflag.BoolVar(&Offline, "offline", offline, "Forces Task to only use local or cached Taskfiles.") pflag.DurationVar(&Timeout, "timeout", time.Second*10, "Timeout for downloading remote Taskfiles.") pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.") + pflag.DurationVar(&CacheExpiryDuration, "expiry", 0, "Expiry duration for cached remote Taskfiles.") } pflag.Parse() @@ -212,6 +214,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithDownload(Download), task.WithOffline(Offline), task.WithTimeout(Timeout), + task.WithCacheExpiryDuration(CacheExpiryDuration), task.WithWatch(Watch), task.WithVerbose(Verbose), task.WithSilent(Silent), diff --git a/setup.go b/setup.go index a577414a89..16a92212cb 100644 --- a/setup.go +++ b/setup.go @@ -76,6 +76,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithOffline(e.Offline), taskfile.WithTimeout(e.Timeout), taskfile.WithTempDir(e.TempDir.Remote), + taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) diff --git a/taskfile/node_cache.go b/taskfile/node_cache.go index 26c11c0f73..b489161b23 100644 --- a/taskfile/node_cache.go +++ b/taskfile/node_cache.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" ) const remoteCacheDir = "remote" @@ -34,6 +35,25 @@ func (node *CacheNode) Write(data []byte) error { return os.WriteFile(node.Location(), data, 0o644) } +func (node *CacheNode) ReadTimestamp() time.Time { + b, err := os.ReadFile(node.timestampPath()) + if err != nil { + return time.Time{}.UTC() + } + timestamp, err := time.Parse(time.RFC3339, string(b)) + if err != nil { + return time.Time{}.UTC() + } + return timestamp.UTC() +} + +func (node *CacheNode) WriteTimestamp(t time.Time) error { + if err := node.CreateCacheDir(); err != nil { + return err + } + return os.WriteFile(node.timestampPath(), []byte(t.Format(time.RFC3339)), 0o644) +} + func (node *CacheNode) ReadChecksum() string { b, _ := os.ReadFile(node.checksumPath()) return string(b) @@ -78,6 +98,10 @@ func (node *CacheNode) checksumPath() string { return node.filePath("checksum") } +func (node *CacheNode) timestampPath() string { + return node.filePath("timestamp") +} + func (node *CacheNode) filePath(suffix string) string { return filepath.Join(node.dir, fmt.Sprintf("%s.%s", node.source.CacheKey(), suffix)) } diff --git a/taskfile/reader.go b/taskfile/reader.go index b27356093d..81fbf4ba94 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -39,15 +39,16 @@ type ( // A Reader will recursively read Taskfiles from a given [Node] and build a // [ast.TaskfileGraph] from them. Reader struct { - graph *ast.TaskfileGraph - insecure bool - download bool - offline bool - timeout time.Duration - tempDir string - debugFunc DebugFunc - promptFunc PromptFunc - promptMutex sync.Mutex + graph *ast.TaskfileGraph + insecure bool + download bool + offline bool + timeout time.Duration + tempDir string + cacheExpiryDuration time.Duration + debugFunc DebugFunc + promptFunc PromptFunc + promptMutex sync.Mutex } ) @@ -55,15 +56,16 @@ type ( // options. func NewReader(opts ...ReaderOption) *Reader { r := &Reader{ - graph: ast.NewTaskfileGraph(), - insecure: false, - download: false, - offline: false, - timeout: time.Second * 10, - tempDir: os.TempDir(), - debugFunc: nil, - promptFunc: nil, - promptMutex: sync.Mutex{}, + graph: ast.NewTaskfileGraph(), + insecure: false, + download: false, + offline: false, + timeout: time.Second * 10, + tempDir: os.TempDir(), + cacheExpiryDuration: 0, + debugFunc: nil, + promptFunc: nil, + promptMutex: sync.Mutex{}, } r.Options(opts...) return r @@ -147,6 +149,20 @@ func (o *tempDirOption) ApplyToReader(r *Reader) { r.tempDir = o.tempDir } +// WithCacheExpiryDuration sets the duration after which the cache is considered +// expired. By default, the cache is considered expired after 24 hours. +func WithCacheExpiryDuration(duration time.Duration) ReaderOption { + return &cacheExpiryDurationOption{duration: duration} +} + +type cacheExpiryDurationOption struct { + duration time.Duration +} + +func (o *cacheExpiryDurationOption) ApplyToReader(r *Reader) { + r.cacheExpiryDuration = o.duration +} + // WithDebugFunc sets the debug function to be used by the [Reader]. If set, // this function will be called with debug messages. This can be useful if the // caller wants to log debug messages from the [Reader]. By default, no debug @@ -367,6 +383,10 @@ func (r *Reader) readNodeContent(node Node) ([]byte, error) { func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { cache := NewCacheNode(node, r.tempDir) + now := time.Now().UTC() + timestamp := cache.ReadTimestamp() + expiry := timestamp.Add(r.cacheExpiryDuration) + cacheValid := now.Before(expiry) // If we have not been been forced to download, check the cache if !r.download { @@ -383,11 +403,20 @@ func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { } } + // If the cache is expired + case !cacheValid: + r.debugf("cache expired at %s\n", expiry.Format(time.RFC3339)) + // If we can't fetch a fresh copy, we should use the cache anyway + if r.offline { + r.debugf("in offline mode, using expired cache\n") + return b, nil + } + // Some other error case err != nil: return nil, err - // Found cache, return it + // Found valid cache, return it default: r.debugf("cache found\n") return b, nil @@ -408,7 +437,7 @@ func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { checksum := checksum(b) prompt := cache.ChecksumPrompt(checksum) - // If we need to prompt the user, do it and cache the file + // Prompt the user if required if prompt != "" { if err := func() error { r.promptMutex.Lock() @@ -417,17 +446,22 @@ func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { }(); err != nil { return nil, &errors.TaskfileNotTrustedError{URI: node.Location()} } + } - // Store the checksum - if err := cache.WriteChecksum(checksum); err != nil { - return nil, err - } + // Store the checksum + if err := cache.WriteChecksum(checksum); err != nil { + return nil, err + } - // Cache the file - r.debugf("task: [%s] Caching downloaded file\n", node.Location()) - if err = cache.Write(b); err != nil { - return nil, err - } + // Store the timestamp + if err := cache.WriteTimestamp(now); err != nil { + return nil, err + } + + // Cache the file + r.debugf("caching %q to %q\n", node.Location(), cache.Location()) + if err = cache.Write(b); err != nil { + return nil, err } return b, nil From 4437c595fdfae450bac113b6786fc6309b8c7c7d Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Fri, 11 Apr 2025 14:26:02 +0000 Subject: [PATCH 3/5] feat: pass ctx into reader methods instead of timeout --- cmd/tmp/main.go | 38 +++++++++++++++++++++++++++ setup.go | 8 ++++-- taskfile/node.go | 10 ++++--- taskfile/node_http.go | 10 +++---- taskfile/reader.go | 42 ++++++++---------------------- taskfile/taskfile.go | 8 +++--- website/docs/reference/package.mdx | 3 ++- 7 files changed, 71 insertions(+), 48 deletions(-) create mode 100644 cmd/tmp/main.go diff --git a/cmd/tmp/main.go b/cmd/tmp/main.go new file mode 100644 index 0000000000..7b26556a64 --- /dev/null +++ b/cmd/tmp/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + if err := run(ctx); err != nil { + fmt.Println(ctx.Err()) + fmt.Println(err) + } +} + +func run(ctx context.Context) error { + req, err := http.NewRequest("GET", "https://taskfile.dev/schema.json", nil) + if err != nil { + fmt.Println(1) + return err + } + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + if ctx.Err() != nil { + fmt.Println(2) + return err + } + fmt.Println(3) + return err + } + defer resp.Body.Close() + + return nil +} diff --git a/setup.go b/setup.go index 16a92212cb..13f03dd40b 100644 --- a/setup.go +++ b/setup.go @@ -64,6 +64,8 @@ func (e *Executor) getRootNode() (taskfile.Node, error) { } func (e *Executor) readTaskfile(node taskfile.Node) error { + ctx, cf := context.WithTimeout(context.Background(), e.Timeout) + defer cf() debugFunc := func(s string) { e.Logger.VerboseOutf(logger.Magenta, s) } @@ -74,14 +76,16 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithInsecure(e.Insecure), taskfile.WithDownload(e.Download), taskfile.WithOffline(e.Offline), - taskfile.WithTimeout(e.Timeout), taskfile.WithTempDir(e.TempDir.Remote), taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) - graph, err := reader.Read(node) + graph, err := reader.Read(ctx, node) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: e.Timeout} + } return err } if e.Taskfile, err = graph.Merge(); err != nil { diff --git a/taskfile/node.go b/taskfile/node.go index 03c53c8096..fb9abdc9c1 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -39,33 +39,35 @@ func NewRootNode( if entrypoint == "-" { return NewStdinNode(dir) } - return NewNode(entrypoint, dir, insecure, timeout) + return NewNode(entrypoint, dir, insecure) } func NewNode( entrypoint string, dir string, insecure bool, - timeout time.Duration, opts ...NodeOption, ) (Node, error) { var node Node var err error + scheme, err := getScheme(entrypoint) if err != nil { return nil, err } + switch scheme { case "git": node, err = NewGitNode(entrypoint, dir, insecure, opts...) case "http", "https": - node, err = NewHTTPNode(entrypoint, dir, insecure, timeout, opts...) + node, err = NewHTTPNode(entrypoint, dir, insecure, opts...) default: node, err = NewFileNode(entrypoint, dir, opts...) } if _, isRemote := node.(RemoteNode); isRemote && !experiments.RemoteTaskfiles.Enabled() { 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 } @@ -74,6 +76,7 @@ func getScheme(uri string) (string, error) { if u == nil { return "", err } + if strings.HasSuffix(strings.Split(u.Path, "//")[0], ".git") && (u.Scheme == "git" || u.Scheme == "ssh" || u.Scheme == "https" || u.Scheme == "http") { return "git", nil } @@ -81,6 +84,7 @@ func getScheme(uri string) (string, error) { if i := strings.Index(uri, "://"); i != -1 { return uri[:i], nil } + return "", nil } diff --git a/taskfile/node_http.go b/taskfile/node_http.go index aaddd21a9c..16e0ee40c9 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -8,7 +8,6 @@ import ( "net/url" "path/filepath" "strings" - "time" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/execext" @@ -20,14 +19,12 @@ type HTTPNode struct { *BaseNode URL *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) entrypoint string // stores entrypoint url. used for building graph vertices. - timeout time.Duration } func NewHTTPNode( entrypoint string, dir string, insecure bool, - timeout time.Duration, opts ...NodeOption, ) (*HTTPNode, error) { base := NewBaseNode(dir, opts...) @@ -43,7 +40,6 @@ func NewHTTPNode( BaseNode: base, URL: url, entrypoint: entrypoint, - timeout: timeout, }, nil } @@ -56,7 +52,7 @@ func (node *HTTPNode) Read() ([]byte, error) { } func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { - url, err := RemoteExists(ctx, node.URL, node.timeout) + url, err := RemoteExists(ctx, node.URL) if err != nil { return nil, err } @@ -68,8 +64,8 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { resp, err := http.DefaultClient.Do(req.WithContext(ctx)) if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return nil, &errors.TaskfileNetworkTimeoutError{URI: node.URL.String(), Timeout: node.timeout} + if ctx.Err() != nil { + return nil, err } return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} } diff --git a/taskfile/reader.go b/taskfile/reader.go index 81fbf4ba94..ef9356165c 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -43,7 +43,6 @@ type ( insecure bool download bool offline bool - timeout time.Duration tempDir string cacheExpiryDuration time.Duration debugFunc DebugFunc @@ -60,7 +59,6 @@ func NewReader(opts ...ReaderOption) *Reader { insecure: false, download: false, offline: false, - timeout: time.Second * 10, tempDir: os.TempDir(), cacheExpiryDuration: 0, debugFunc: nil, @@ -121,20 +119,6 @@ func (o *offlineOption) ApplyToReader(r *Reader) { r.offline = o.offline } -// WithTimeout sets the [Reader]'s timeout for fetching remote taskfiles. By -// default, the timeout is set to 10 seconds. -func WithTimeout(timeout time.Duration) ReaderOption { - return &timeoutOption{timeout: timeout} -} - -type timeoutOption struct { - timeout time.Duration -} - -func (o *timeoutOption) ApplyToReader(r *Reader) { - r.timeout = o.timeout -} - // WithTempDir sets the temporary directory that will be used by the [Reader]. // By default, the reader uses [os.TempDir]. func WithTempDir(tempDir string) ReaderOption { @@ -202,8 +186,8 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) { // through any [ast.Includes] it finds, reading each included Taskfile and // building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be // returned immediately. -func (r *Reader) Read(node Node) (*ast.TaskfileGraph, error) { - if err := r.include(node); err != nil { +func (r *Reader) Read(ctx context.Context, node Node) (*ast.TaskfileGraph, error) { + if err := r.include(ctx, node); err != nil { return nil, err } return r.graph, nil @@ -222,7 +206,7 @@ func (r *Reader) promptf(format string, a ...any) error { return nil } -func (r *Reader) include(node Node) error { +func (r *Reader) include(ctx context.Context, node Node) error { // Create a new vertex for the Taskfile vertex := &ast.TaskfileVertex{ URI: node.Location(), @@ -240,7 +224,7 @@ func (r *Reader) include(node Node) error { // Read and parse the Taskfile from the file and add it to the vertex var err error - vertex.Taskfile, err = r.readNode(node) + vertex.Taskfile, err = r.readNode(ctx, node) if err != nil { return err } @@ -281,7 +265,7 @@ func (r *Reader) include(node Node) error { return err } - includeNode, err := NewNode(entrypoint, include.Dir, r.insecure, r.timeout, + includeNode, err := NewNode(entrypoint, include.Dir, r.insecure, WithParent(node), ) if err != nil { @@ -292,7 +276,7 @@ func (r *Reader) include(node Node) error { } // Recurse into the included Taskfile - if err := r.include(includeNode); err != nil { + if err := r.include(ctx, includeNode); err != nil { return err } @@ -332,8 +316,8 @@ func (r *Reader) include(node Node) error { return g.Wait() } -func (r *Reader) readNode(node Node) (*ast.Taskfile, error) { - b, err := r.readNodeContent(node) +func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) { + b, err := r.readNodeContent(ctx, node) if err != nil { return nil, err } @@ -374,14 +358,14 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) { return &tf, nil } -func (r *Reader) readNodeContent(node Node) ([]byte, error) { +func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) { if node, isRemote := node.(RemoteNode); isRemote { - return r.readRemoteNodeContent(node) + return r.readRemoteNodeContent(ctx, node) } return node.Read() } -func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { +func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([]byte, error) { cache := NewCacheNode(node, r.tempDir) now := time.Now().UTC() timestamp := cache.ReadTimestamp() @@ -423,10 +407,6 @@ func (r *Reader) readRemoteNodeContent(node RemoteNode) ([]byte, error) { } } - // If we have not been forced to be offline, try to fetch the remote file - ctx, cf := context.WithTimeout(context.Background(), r.timeout) - defer cf() - // Try to read the remote file b, err := node.ReadContext(ctx) if err != nil { diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index 3502638a16..d902559a05 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -2,13 +2,13 @@ package taskfile import ( "context" + "fmt" "net/http" "net/url" "os" "path/filepath" "slices" "strings" - "time" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/filepathext" @@ -40,7 +40,7 @@ var ( // at the given URL 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 RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.URL, error) { +func RemoteExists(ctx context.Context, u *url.URL) (*url.URL, error) { // Create a new HEAD request for the given URL to check if the resource exists req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil) if err != nil { @@ -50,8 +50,8 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url. // Request the given URL resp, err := http.DefaultClient.Do(req) if err != nil { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return nil, &errors.TaskfileNetworkTimeoutError{URI: u.String(), Timeout: timeout} + if ctx.Err() != nil { + return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) } return nil, errors.TaskfileFetchFailedError{URI: u.String()} } diff --git a/website/docs/reference/package.mdx b/website/docs/reference/package.mdx index ebffbd7dd7..4777f5df68 100644 --- a/website/docs/reference/package.mdx +++ b/website/docs/reference/package.mdx @@ -117,7 +117,8 @@ Taskfiles) by calling the `Read` method on the reader and pass the `Node` as an argument: ```go -tfg, err := reader.Read(node) +ctx := context.Background() +tfg, err := reader.Read(ctx, node) // handle error ``` From 15175218926c0ce55b019ee75b4fee9ea621c4b2 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sat, 12 Apr 2025 02:51:17 +0000 Subject: [PATCH 4/5] docs: updated remote taskfiles experiment doc --- website/docs/experiments/remote_taskfiles.mdx | 195 ++++++++++++++---- 1 file changed, 158 insertions(+), 37 deletions(-) diff --git a/website/docs/experiments/remote_taskfiles.mdx b/website/docs/experiments/remote_taskfiles.mdx index 45e6617edf..b9c2d1f2cb 100644 --- a/website/docs/experiments/remote_taskfiles.mdx +++ b/website/docs/experiments/remote_taskfiles.mdx @@ -2,6 +2,9 @@ slug: /experiments/remote-taskfiles/ --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # Remote Taskfiles (#1317) :::caution @@ -20,57 +23,162 @@ To enable this experiment, set the environment variable: ::: -This experiment allows you to specify a remote Taskfile URL when including a -Taskfile. For example: +:::danger +Never run remote Taskfiles from sources that you do not trust. +::: -```yaml -version: '3' +This experiment allows you to use Taskfiles which are stored in remote +locations. This applies to both the root Taskfile (aka. Entrypoint) and also +when including Taskfiles. -includes: - my-remote-namespace: https://raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml -``` +Task uses "nodes" to reference remote Taskfiles. There are a few different types +of node which you can use: + + + + +`https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml` + +This is the most basic type of remote node and works by downloading the file +from the specified URL. The file must be a valid Taskfile and can be of any +name. If a file is not found at the specified URL, Task will append each of the +[supported file names][supported-file-names] in turn until it finds a valid +file. If it still does not find a valid Taskfile, an error is returned. + + + + +`https://github.com/go-task/task.git//website/static/Taskfile.yml?ref=main` + +This type of node works by downloading the file from a Git repository over +HTTP/HTTPS. The first part of the URL is the base URL of the Git repository. +This is the same URL that you would use to clone the repo over HTTP. + +- You can optionally add the path to the Taskfile in the repository by appending +`//` to the URL. +- You can also optionally specify a branch or tag to use by appending +`?ref=` to the end of the URL. If you omit a reference, the default branch +will be used. -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: + + + +`git@github.com/go-task/task.git//website/static/Taskfile.yml?ref=main` + +This type of node works by downloading the file from a Git repository over SSH. +The first part of the URL is the user and base URL of the Git repository. This +is the same URL that you would use to clone the repo over SSH. + +To use Git over SSH, you need to make sure that your SSH agent has your private +SSH keys added so that they can be used during authentication. + +- You can optionally add the path to the Taskfile in the repository by appending +`//` to the URL. +- You can also optionally specify a branch or tag to use by appending +`?ref=` to the end of the URL. If you omit a reference, the default branch +will be used. + + + + +Task has an [example remote Taskfile][example-remote-taskfile] in our repository +that you can use for testing and that we will use throughout this document: ```yaml version: '3' tasks: + default: + cmds: + - task: hello + hello: - silent: true cmds: - - echo "Hello from the remote Taskfile!" + - echo "Hello Task!" ``` -and you run `task my-remote-namespace:hello`, it will print the text: "Hello -from the remote Taskfile!" to your console. +## Specifying a remote entrypoint -The Taskfile location is processed by the templating system, so you can -reference environment variables in your URL if you need to add authentication. -For example: +By default, Task will look for one of the [supported file +names][supported-file-names] on your local filesystem. If you want to use a +remote file instead, you can pass its URI into the `--taskfile`/`-t` flag just +like you would to specify a different local file. For example: + + + +```shell +$ task --taskfile https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml +task: [hello] echo "Hello Task!" +Hello Task! +``` + + +```shell +$ task --taskfile https://github.com/go-task/task.git//website/static/Taskfile.yml?ref=main +task: [hello] echo "Hello Task!" +Hello Task! +``` + + +```shell +$ task --taskfile git@github.com/go-task/task.git//website/static/Taskfile.yml?ref=main +task: [hello] echo "Hello Task!" +Hello Task! +``` + + + +## Including remote Taskfiles + +Including a remote file works exactly the same way that including a local file +does. You just need to replace the local path with a remote URI. Any tasks in +the remote Taskfile will be available to run from your main Taskfile. + + ```yaml version: '3' includes: - my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml + my-remote-namespace: https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml +``` + + +```yaml +version: '3' + +includes: + my-remote-namespace: https://github.com/go-task/task.git//website/static/Taskfile.yml?ref=main +``` + + +```yaml +version: '3' + +includes: + my-remote-namespace: git@github.com/go-task/task.git//website/static/Taskfile.yml?ref=main ``` + + -`TOKEN=my-token task my-remote-namespace:hello` will be resolved by Task to -`https://my-token@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml` +```shell +$ task my-remote-namespace:hello +task: [hello] echo "Hello Task!" +Hello Task! +``` -## Git nodes +### Authenticating using environment variables -You can also include a Taskfile from a Git node. We currently support ssh-style and http / https addresses like `git@example.com/foo/bar.git//Taskfiles.yml?ref=v1` and `https://example.com/foo/bar.git//Taskfiles.yml?ref=v1`. +The Taskfile location is processed by the templating system, so you can +reference environment variables in your URL if you need to add authentication. +For example: -You need to follow this pattern : `.git//?ref=`. -The `ref` parameter, optional, can be a branch name or a tag, if not provided it'll pick up the default branch. -The `path` is the path to the Taskfile in the repository. +```yaml +version: '3' -If you want to use the SSH protocol, you need to make sure that your ssh-agent has your private ssh keys added so that they can be used during authentication. +includes: + my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml +``` ## Security @@ -104,20 +212,26 @@ flag. Before enabling this flag, you should: 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 via an 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. +Taskfile that is downloaded via an 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 Whenever you run a remote Taskfile, the latest copy will be downloaded from the -internet and cached locally. If for whatever reason, you lose access to the -internet, you will still be able to run your tasks by specifying the `--offline` -flag. This will tell Task to use the latest cached version of the file instead -of trying to download it. You are able to use the `--download` flag to update -the cached version of the remote files without running any tasks. You are able -to use the `--clear-cache` flag to clear all cached version of the remote files -without running any tasks. +internet and cached locally. This cached file will be used for all future +invocations of the Taskfile until the cache expires. Once it expires, Task will +download the latest copy of the file and update the cache. By default, the cache +is set to expire immediately. This means that Task will always fetch the latest +version. However, the cache expiry duration can be modified by setting the +`--expiry` flag. + +If for any reason you lose access to the internet or you are running Task in +offline mode (via the `--offline` flag or `TASK_OFFLINE` environment variable), +Task will run the any available cached files _even if they are expired_. This +means that you should never be stuck without the ability to run your tasks as +long as you have downloaded a remote Taskfile at least once. By default, Task will timeout requests to download remote files after 10 seconds and look for a cached copy instead. This timeout can be configured by setting @@ -129,7 +243,14 @@ By default, the cache is stored in the Task temp directory, represented by the override the location of the cache by setting the `TASK_REMOTE_DIR` environment variable. This way, you can share the cache between different projects. +You can force Task to ignore the cache and download the latest version +by using the `--download` flag. + +You can use the `--clear-cache` flag to clear all cached remote files. + {/* prettier-ignore-start */} [enabling-experiments]: ./experiments.mdx#enabling-experiments [man-in-the-middle-attacks]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack +[supported-file-names]: https://taskfile.dev/usage/#supported-file-names +[example-remote-taskfile]: https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml {/* prettier-ignore-end */} From 6919c0437dd9738b7142439274b28af5a7651566 Mon Sep 17 00:00:00 2001 From: Pete Davison Date: Sun, 13 Apr 2025 00:00:46 +0000 Subject: [PATCH 5/5] feat: use cache if download fails --- taskfile/reader.go | 79 +++++++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/taskfile/reader.go b/taskfile/reader.go index ef9356165c..edcf54e0d0 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -371,50 +371,63 @@ func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([] timestamp := cache.ReadTimestamp() expiry := timestamp.Add(r.cacheExpiryDuration) cacheValid := now.Before(expiry) - - // If we have not been been forced to download, check the cache - if !r.download { - r.debugf("checking cache for %q in %q\n", node.Location(), cache.Location()) - b, err := cache.Read() - switch { - // If the cache doesn't exist, we need to download the file - case errors.Is(err, os.ErrNotExist): - r.debugf("no cache found\n") - // If we couldn't find a cached copy, and we are offline, we can't do anything - if r.offline { - return nil, &errors.TaskfileCacheNotFoundError{ - URI: node.Location(), - } + var cacheFound bool + + r.debugf("checking cache for %q in %q\n", node.Location(), cache.Location()) + cachedBytes, err := cache.Read() + switch { + // If the cache doesn't exist, we need to download the file + case errors.Is(err, os.ErrNotExist): + r.debugf("no cache found\n") + // If we couldn't find a cached copy, and we are offline, we can't do anything + if r.offline { + return nil, &errors.TaskfileCacheNotFoundError{ + URI: node.Location(), } + } - // If the cache is expired - case !cacheValid: - r.debugf("cache expired at %s\n", expiry.Format(time.RFC3339)) - // If we can't fetch a fresh copy, we should use the cache anyway - if r.offline { - r.debugf("in offline mode, using expired cache\n") - return b, nil - } + // If the cache is expired + case !cacheValid: + r.debugf("cache expired at %s\n", expiry.Format(time.RFC3339)) + cacheFound = true + // If we can't fetch a fresh copy, we should use the cache anyway + if r.offline { + r.debugf("in offline mode, using expired cache\n") + return cachedBytes, nil + } - // Some other error - case err != nil: - return nil, err + // Some other error + case err != nil: + return nil, err - // Found valid cache, return it - default: - r.debugf("cache found\n") - return b, nil + // Found valid cache + default: + r.debugf("cache found\n") + // Not being forced to redownload, return cache + if !r.download { + return cachedBytes, nil } + cacheFound = true } // Try to read the remote file - b, err := node.ReadContext(ctx) + r.debugf("downloading remote file: %s\n", node.Location()) + downloadedBytes, err := node.ReadContext(ctx) if err != nil { + // If the context timed out or was cancelled, but we found a cached version, use that + if ctx.Err() != nil && cacheFound { + if cacheValid { + r.debugf("failed to fetch remote file: %s: using cache\n", ctx.Err().Error()) + } else { + r.debugf("failed to fetch remote file: %s: using expired cache\n", ctx.Err().Error()) + } + return cachedBytes, nil + } return nil, err } r.debugf("found remote file at %q\n", node.Location()) - checksum := checksum(b) + checksum := checksum(downloadedBytes) prompt := cache.ChecksumPrompt(checksum) // Prompt the user if required @@ -440,9 +453,9 @@ func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([] // Cache the file r.debugf("caching %q to %q\n", node.Location(), cache.Location()) - if err = cache.Write(b); err != nil { + if err = cache.Write(downloadedBytes); err != nil { return nil, err } - return b, nil + return downloadedBytes, nil }