1
0
mirror of https://github.com/go-task/task.git synced 2025-05-13 22:16:31 +02:00

feat: remote taskfile improvements (cache/expiry) (#2176)

* feat: cache as node, RemoteNode and cache-first approach

* feat: cache expiry

* feat: pass ctx into reader methods instead of timeout

* docs: updated remote taskfiles experiment doc

* feat: use cache if download fails
This commit is contained in:
Pete Davison 2025-04-19 12:12:08 +01:00 committed by GitHub
parent f47f237093
commit a84f09d45f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 579 additions and 353 deletions

View File

@ -18,7 +18,6 @@ import (
"github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/flags"
"github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/internal/version"
"github.com/go-task/task/v3/taskfile"
"github.com/go-task/task/v3/taskfile/ast" "github.com/go-task/task/v3/taskfile/ast"
) )
@ -121,18 +120,9 @@ func run() error {
return err 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 { if flags.ClearCache {
cache, err := taskfile.NewCache(e.TempDir.Remote) cachePath := filepath.Join(e.TempDir.Remote, "remote")
if err != nil { return os.RemoveAll(cachePath)
return err
}
return cache.Clear()
} }
listOptions := task.NewListOptions( listOptions := task.NewListOptions(

38
cmd/tmp/main.go Normal file
View File

@ -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
}

View File

@ -157,17 +157,12 @@ func (err *TaskfileVersionCheckError) Code() int {
type TaskfileNetworkTimeoutError struct { type TaskfileNetworkTimeoutError struct {
URI string URI string
Timeout time.Duration Timeout time.Duration
CheckedCache bool
} }
func (err *TaskfileNetworkTimeoutError) Error() string { func (err *TaskfileNetworkTimeoutError) Error() string {
var cacheText string
if err.CheckedCache {
cacheText = " and no offline copy was found in the cache"
}
return fmt.Sprintf( return fmt.Sprintf(
`task: Network connection timed out after %s while attempting to download Taskfile %q%s`, `task: Network connection timed out after %s while attempting to download Taskfile %q`,
err.Timeout, err.URI, cacheText, err.Timeout, err.URI,
) )
} }

View File

@ -36,6 +36,7 @@ type (
Download bool Download bool
Offline bool Offline bool
Timeout time.Duration Timeout time.Duration
CacheExpiryDuration time.Duration
Watch bool Watch bool
Verbose bool Verbose bool
Silent bool Silent bool
@ -240,6 +241,20 @@ func (o *timeoutOption) ApplyToExecutor(e *Executor) {
e.Timeout = o.timeout 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 // 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 // for changes to the fingerprint of the tasks that are run. When changes are
// detected, a new task run is triggered. // detected, a new task run is triggered.

View File

@ -73,6 +73,7 @@ var (
Offline bool Offline bool
ClearCache bool ClearCache bool
Timeout time.Duration Timeout time.Duration
CacheExpiryDuration time.Duration
) )
func init() { func init() {
@ -131,6 +132,7 @@ func init() {
pflag.BoolVar(&Offline, "offline", offline, "Forces Task to only use local or cached Taskfiles.") 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.DurationVar(&Timeout, "timeout", time.Second*10, "Timeout for downloading remote Taskfiles.")
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.") pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
pflag.DurationVar(&CacheExpiryDuration, "expiry", 0, "Expiry duration for cached remote Taskfiles.")
} }
pflag.Parse() pflag.Parse()
@ -212,6 +214,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
task.WithDownload(Download), task.WithDownload(Download),
task.WithOffline(Offline), task.WithOffline(Offline),
task.WithTimeout(Timeout), task.WithTimeout(Timeout),
task.WithCacheExpiryDuration(CacheExpiryDuration),
task.WithWatch(Watch), task.WithWatch(Watch),
task.WithVerbose(Verbose), task.WithVerbose(Verbose),
task.WithSilent(Silent), task.WithSilent(Silent),

View File

@ -64,6 +64,8 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
} }
func (e *Executor) readTaskfile(node 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) { debugFunc := func(s string) {
e.Logger.VerboseOutf(logger.Magenta, s) e.Logger.VerboseOutf(logger.Magenta, s)
} }
@ -74,13 +76,16 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
taskfile.WithInsecure(e.Insecure), taskfile.WithInsecure(e.Insecure),
taskfile.WithDownload(e.Download), taskfile.WithDownload(e.Download),
taskfile.WithOffline(e.Offline), taskfile.WithOffline(e.Offline),
taskfile.WithTimeout(e.Timeout),
taskfile.WithTempDir(e.TempDir.Remote), taskfile.WithTempDir(e.TempDir.Remote),
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
taskfile.WithDebugFunc(debugFunc), taskfile.WithDebugFunc(debugFunc),
taskfile.WithPromptFunc(promptFunc), taskfile.WithPromptFunc(promptFunc),
) )
graph, err := reader.Read(node) graph, err := reader.Read(ctx, node)
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return &errors.TaskfileNetworkTimeoutError{URI: node.Location(), Timeout: e.Timeout}
}
return err return err
} }
if e.Taskfile, err = graph.Merge(); err != nil { if e.Taskfile, err = graph.Merge(); err != nil {

View File

@ -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)
}

View File

@ -14,14 +14,18 @@ import (
) )
type Node interface { type Node interface {
Read(ctx context.Context) ([]byte, error) Read() ([]byte, error)
Parent() Node Parent() Node
Location() string Location() string
Dir() string Dir() string
Remote() bool
ResolveEntrypoint(entrypoint string) (string, error) ResolveEntrypoint(entrypoint string) (string, error)
ResolveDir(dir 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( func NewRootNode(
@ -35,35 +39,35 @@ func NewRootNode(
if entrypoint == "-" { if entrypoint == "-" {
return NewStdinNode(dir) return NewStdinNode(dir)
} }
return NewNode(entrypoint, dir, insecure, timeout) return NewNode(entrypoint, dir, insecure)
} }
func NewNode( func NewNode(
entrypoint string, entrypoint string,
dir string, dir string,
insecure bool, insecure bool,
timeout time.Duration,
opts ...NodeOption, opts ...NodeOption,
) (Node, error) { ) (Node, error) {
var node Node var node Node
var err error var err error
scheme, err := getScheme(entrypoint) scheme, err := getScheme(entrypoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
switch scheme { switch scheme {
case "git": case "git":
node, err = NewGitNode(entrypoint, dir, insecure, opts...) node, err = NewGitNode(entrypoint, dir, insecure, opts...)
case "http", "https": case "http", "https":
node, err = NewHTTPNode(entrypoint, dir, insecure, timeout, opts...) node, err = NewHTTPNode(entrypoint, dir, insecure, opts...)
default: default:
node, err = NewFileNode(entrypoint, dir, opts...) node, err = NewFileNode(entrypoint, dir, opts...)
} }
if _, isRemote := node.(RemoteNode); isRemote && !experiments.RemoteTaskfiles.Enabled() {
if node.Remote() && !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 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 return node, err
} }
@ -72,6 +76,7 @@ func getScheme(uri string) (string, error) {
if u == nil { if u == nil {
return "", err return "", err
} }
if strings.HasSuffix(strings.Split(u.Path, "//")[0], ".git") && (u.Scheme == "git" || u.Scheme == "ssh" || u.Scheme == "https" || u.Scheme == "http") { if strings.HasSuffix(strings.Split(u.Path, "//")[0], ".git") && (u.Scheme == "git" || u.Scheme == "ssh" || u.Scheme == "https" || u.Scheme == "http") {
return "git", nil return "git", nil
} }
@ -79,6 +84,7 @@ func getScheme(uri string) (string, error) {
if i := strings.Index(uri, "://"); i != -1 { if i := strings.Index(uri, "://"); i != -1 {
return uri[:i], nil return uri[:i], nil
} }
return "", nil return "", nil
} }

113
taskfile/node_cache.go Normal file
View File

@ -0,0 +1,113 @@
package taskfile
import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"time"
)
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) 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)
}
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) 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))
}
func checksum(b []byte) string {
h := sha256.New()
h.Write(b)
return fmt.Sprintf("%x", h.Sum(nil))
}

View File

@ -1,7 +1,6 @@
package taskfile package taskfile
import ( import (
"context"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@ -34,11 +33,7 @@ func (node *FileNode) Location() string {
return node.Entrypoint return node.Entrypoint
} }
func (node *FileNode) Remote() bool { func (node *FileNode) Read() ([]byte, error) {
return false
}
func (node *FileNode) Read(ctx context.Context) ([]byte, error) {
f, err := os.Open(node.Location()) f, err := os.Open(node.Location())
if err != nil { if err != nil {
return nil, err return nil, err
@ -114,7 +109,3 @@ func (node *FileNode) ResolveDir(dir string) (string, error) {
entrypointDir := filepath.Dir(node.Entrypoint) entrypointDir := filepath.Dir(node.Entrypoint)
return filepathext.SmartJoin(entrypointDir, path), nil return filepathext.SmartJoin(entrypointDir, path), nil
} }
func (node *FileNode) FilenameAndLastDir() (string, string) {
return "", filepath.Base(node.Entrypoint)
}

View File

@ -71,7 +71,11 @@ func (node *GitNode) Remote() bool {
return true 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() fs := memfs.New()
storer := memory.NewStorage() storer := memory.NewStorage()
_, err := git.Clone(storer, fs, &git.CloneOptions{ _, 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 return filepathext.SmartJoin(entrypointDir, path), nil
} }
func (node *GitNode) FilenameAndLastDir() (string, string) { func (node *GitNode) CacheKey() string {
return filepath.Base(node.path), filepath.Base(filepath.Dir(node.path)) 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)
} }

View File

@ -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) 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() t.Parallel()
node, err := NewGitNode("https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", "", false) node, err := NewGitNode("https://github.com/foo/bar.git//directory/Taskfile.yml?ref=main", "", false)
assert.NoError(t, err) assert.NoError(t, err)
filename, dir := node.FilenameAndLastDir() key := node.CacheKey()
assert.Equal(t, "Taskfile.yml", filename) assert.Equal(t, "Taskfile.yml-directory.f1ddddac425a538870230a3e38fc0cded4ec5da250797b6cab62c82477718fbb", key)
assert.Equal(t, "directory", dir)
node, err = NewGitNode("https://github.com/foo/bar.git//Taskfile.yml?ref=main", "", false) node, err = NewGitNode("https://github.com/foo/bar.git//Taskfile.yml?ref=main", "", false)
assert.NoError(t, err) assert.NoError(t, err)
filename, dir = node.FilenameAndLastDir() key = node.CacheKey()
assert.Equal(t, "Taskfile.yml", filename) assert.Equal(t, "Taskfile.yml-..39d28c1ff36f973705ae188b991258bbabaffd6d60bcdde9693d157d00d5e3a4", key)
assert.Equal(t, ".", dir)
node, err = NewGitNode("https://github.com/foo/bar.git//multiple/directory/Taskfile.yml?ref=main", "", false) node, err = NewGitNode("https://github.com/foo/bar.git//multiple/directory/Taskfile.yml?ref=main", "", false)
assert.NoError(t, err) assert.NoError(t, err)
filename, dir = node.FilenameAndLastDir() key = node.CacheKey()
assert.Equal(t, "Taskfile.yml", filename) assert.Equal(t, "Taskfile.yml-directory.1b6d145e01406dcc6c0aa572e5a5d1333be1ccf2cae96d18296d725d86197d31", key)
assert.Equal(t, "directory", dir)
} }

View File

@ -2,11 +2,12 @@ package taskfile
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"time" "strings"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/execext" "github.com/go-task/task/v3/internal/execext"
@ -18,14 +19,12 @@ type HTTPNode struct {
*BaseNode *BaseNode
URL *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) URL *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
entrypoint string // stores entrypoint url. used for building graph vertices. entrypoint string // stores entrypoint url. used for building graph vertices.
timeout time.Duration
} }
func NewHTTPNode( func NewHTTPNode(
entrypoint string, entrypoint string,
dir string, dir string,
insecure bool, insecure bool,
timeout time.Duration,
opts ...NodeOption, opts ...NodeOption,
) (*HTTPNode, error) { ) (*HTTPNode, error) {
base := NewBaseNode(dir, opts...) base := NewBaseNode(dir, opts...)
@ -41,7 +40,6 @@ func NewHTTPNode(
BaseNode: base, BaseNode: base,
URL: url, URL: url,
entrypoint: entrypoint, entrypoint: entrypoint,
timeout: timeout,
}, nil }, nil
} }
@ -49,12 +47,12 @@ func (node *HTTPNode) Location() string {
return node.entrypoint return node.entrypoint
} }
func (node *HTTPNode) Remote() bool { func (node *HTTPNode) Read() ([]byte, error) {
return true 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) url, err := RemoteExists(ctx, node.URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -66,8 +64,8 @@ func (node *HTTPNode) Read(ctx context.Context) ([]byte, error) {
resp, err := http.DefaultClient.Do(req.WithContext(ctx)) resp, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil { if err != nil {
if errors.Is(err, context.DeadlineExceeded) { if ctx.Err() != nil {
return nil, &errors.TaskfileNetworkTimeoutError{URI: node.URL.String(), Timeout: node.timeout} return nil, err
} }
return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()} return nil, errors.TaskfileFetchFailedError{URI: node.URL.String()}
} }
@ -116,7 +114,14 @@ func (node *HTTPNode) ResolveDir(dir string) (string, error) {
return filepathext.SmartJoin(parent, path), nil 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) 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)
} }

View File

@ -2,7 +2,6 @@ package taskfile
import ( import (
"bufio" "bufio"
"context"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@ -30,7 +29,7 @@ func (node *StdinNode) Remote() bool {
return false return false
} }
func (node *StdinNode) Read(ctx context.Context) ([]byte, error) { func (node *StdinNode) Read() ([]byte, error) {
var stdin []byte var stdin []byte
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() { for scanner.Scan() {
@ -72,7 +71,3 @@ func (node *StdinNode) ResolveDir(dir string) (string, error) {
return filepathext.SmartJoin(node.Dir(), path), nil return filepathext.SmartJoin(node.Dir(), path), nil
} }
func (node *StdinNode) FilenameAndLastDir() (string, string) {
return "", "__stdin__"
}

View File

@ -43,8 +43,8 @@ type (
insecure bool insecure bool
download bool download bool
offline bool offline bool
timeout time.Duration
tempDir string tempDir string
cacheExpiryDuration time.Duration
debugFunc DebugFunc debugFunc DebugFunc
promptFunc PromptFunc promptFunc PromptFunc
promptMutex sync.Mutex promptMutex sync.Mutex
@ -59,8 +59,8 @@ func NewReader(opts ...ReaderOption) *Reader {
insecure: false, insecure: false,
download: false, download: false,
offline: false, offline: false,
timeout: time.Second * 10,
tempDir: os.TempDir(), tempDir: os.TempDir(),
cacheExpiryDuration: 0,
debugFunc: nil, debugFunc: nil,
promptFunc: nil, promptFunc: nil,
promptMutex: sync.Mutex{}, promptMutex: sync.Mutex{},
@ -119,20 +119,6 @@ func (o *offlineOption) ApplyToReader(r *Reader) {
r.offline = o.offline 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]. // WithTempDir sets the temporary directory that will be used by the [Reader].
// By default, the reader uses [os.TempDir]. // By default, the reader uses [os.TempDir].
func WithTempDir(tempDir string) ReaderOption { func WithTempDir(tempDir string) ReaderOption {
@ -147,6 +133,20 @@ func (o *tempDirOption) ApplyToReader(r *Reader) {
r.tempDir = o.tempDir 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, // 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 // 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 // caller wants to log debug messages from the [Reader]. By default, no debug
@ -186,8 +186,8 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) {
// through any [ast.Includes] it finds, reading each included Taskfile and // 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 // building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
// returned immediately. // returned immediately.
func (r *Reader) Read(node Node) (*ast.TaskfileGraph, error) { func (r *Reader) Read(ctx context.Context, node Node) (*ast.TaskfileGraph, error) {
if err := r.include(node); err != nil { if err := r.include(ctx, node); err != nil {
return nil, err return nil, err
} }
return r.graph, nil return r.graph, nil
@ -206,7 +206,7 @@ func (r *Reader) promptf(format string, a ...any) error {
return nil 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 // Create a new vertex for the Taskfile
vertex := &ast.TaskfileVertex{ vertex := &ast.TaskfileVertex{
URI: node.Location(), URI: node.Location(),
@ -224,7 +224,7 @@ func (r *Reader) include(node Node) error {
// Read and parse the Taskfile from the file and add it to the vertex // Read and parse the Taskfile from the file and add it to the vertex
var err error var err error
vertex.Taskfile, err = r.readNode(node) vertex.Taskfile, err = r.readNode(ctx, node)
if err != nil { if err != nil {
return err return err
} }
@ -265,7 +265,7 @@ func (r *Reader) include(node Node) error {
return err return err
} }
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure, r.timeout, includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
WithParent(node), WithParent(node),
) )
if err != nil { if err != nil {
@ -276,7 +276,7 @@ func (r *Reader) include(node Node) error {
} }
// Recurse into the included Taskfile // Recurse into the included Taskfile
if err := r.include(includeNode); err != nil { if err := r.include(ctx, includeNode); err != nil {
return err return err
} }
@ -316,8 +316,8 @@ func (r *Reader) include(node Node) error {
return g.Wait() return g.Wait()
} }
func (r *Reader) readNode(node Node) (*ast.Taskfile, error) { func (r *Reader) readNode(ctx context.Context, node Node) (*ast.Taskfile, error) {
b, err := r.loadNodeContent(node) b, err := r.readNodeContent(ctx, node)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -358,72 +358,79 @@ func (r *Reader) readNode(node Node) (*ast.Taskfile, error) {
return &tf, nil return &tf, nil
} }
func (r *Reader) loadNodeContent(node Node) ([]byte, error) { func (r *Reader) readNodeContent(ctx context.Context, node Node) ([]byte, error) {
if !node.Remote() { if node, isRemote := node.(RemoteNode); isRemote {
ctx, cf := context.WithTimeout(context.Background(), r.timeout) return r.readRemoteNodeContent(ctx, node)
defer cf() }
return node.Read(ctx) return node.Read()
} }
cache, err := NewCache(r.tempDir) func (r *Reader) readRemoteNodeContent(ctx context.Context, node RemoteNode) ([]byte, error) {
if err != nil { cache := NewCacheNode(node, r.tempDir)
return nil, err now := time.Now().UTC()
} timestamp := cache.ReadTimestamp()
expiry := timestamp.Add(r.cacheExpiryDuration)
cacheValid := now.Before(expiry)
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 { if r.offline {
// In offline mode try to use cached copy return nil, &errors.TaskfileCacheNotFoundError{
cached, err := cache.read(node) URI: node.Location(),
if errors.Is(err, os.ErrNotExist) { }
return nil, &errors.TaskfileCacheNotFoundError{URI: node.Location()} }
} else if err != 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
// 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
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 return nil, err
} }
r.debugf("task: [%s] Fetched cached copy\n", node.Location())
return cached, nil r.debugf("found remote file at %q\n", node.Location())
} checksum := checksum(downloadedBytes)
prompt := cache.ChecksumPrompt(checksum)
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 {
return nil, err
}
r.debugf("task: [%s] Fetched remote copy\n", node.Location())
// Get the checksums
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 the user if required
if prompt != "" { if prompt != "" {
if err := func() error { if err := func() error {
r.promptMutex.Lock() r.promptMutex.Lock()
@ -432,18 +439,23 @@ func (r *Reader) loadNodeContent(node Node) ([]byte, error) {
}(); err != nil { }(); err != nil {
return nil, &errors.TaskfileNotTrustedError{URI: node.Location()} return nil, &errors.TaskfileNotTrustedError{URI: node.Location()}
} }
}
// Store the checksum // Store the checksum
if err := cache.writeChecksum(node, checksum); err != nil { if err := cache.WriteChecksum(checksum); err != nil {
return nil, err
}
// Store the timestamp
if err := cache.WriteTimestamp(now); err != nil {
return nil, err return nil, err
} }
// Cache the file // Cache the file
r.debugf("task: [%s] Caching downloaded file\n", node.Location()) r.debugf("caching %q to %q\n", node.Location(), cache.Location())
if err = cache.write(node, b); err != nil { if err = cache.Write(downloadedBytes); err != nil {
return nil, err return nil, err
} }
}
return b, nil return downloadedBytes, nil
} }

View File

@ -2,13 +2,13 @@ package taskfile
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"time"
"github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/errors"
"github.com/go-task/task/v3/internal/filepathext" "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 // 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 // these match a file, the first matching path will be returned. If no files are
// found, an error will be returned. // 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 // Create a new HEAD request for the given URL to check if the resource exists
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil) req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
if err != nil { if err != nil {
@ -50,8 +50,8 @@ func RemoteExists(ctx context.Context, u *url.URL, timeout time.Duration) (*url.
// Request the given URL // Request the given URL
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) { if ctx.Err() != nil {
return nil, &errors.TaskfileNetworkTimeoutError{URI: u.String(), Timeout: timeout} return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
} }
return nil, errors.TaskfileFetchFailedError{URI: u.String()} return nil, errors.TaskfileFetchFailedError{URI: u.String()}
} }

View File

@ -2,6 +2,9 @@
slug: /experiments/remote-taskfiles/ slug: /experiments/remote-taskfiles/
--- ---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Remote Taskfiles (#1317) # Remote Taskfiles (#1317)
:::caution :::caution
@ -20,33 +23,151 @@ To enable this experiment, set the environment variable:
::: :::
This experiment allows you to specify a remote Taskfile URL when including a :::danger
Taskfile. For example: Never run remote Taskfiles from sources that you do not trust.
:::
```yaml This experiment allows you to use Taskfiles which are stored in remote
version: '3' locations. This applies to both the root Taskfile (aka. Entrypoint) and also
when including Taskfiles.
includes: Task uses "nodes" to reference remote Taskfiles. There are a few different types
my-remote-namespace: https://raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml of node which you can use:
```
This works exactly the same way that including a local file does. Any tasks in <Tabs groupId="method" queryString>
the remote Taskfile will be available to run from your main Taskfile via the <TabItem value="http" label="HTTP/HTTPS">
namespace `my-remote-namespace`. For example, if the remote file contains the
following: `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.
</TabItem>
<TabItem value="git-http" label="Git over HTTP">
`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
`//<path>` to the URL.
- You can also optionally specify a branch or tag to use by appending
`?ref=<ref>` to the end of the URL. If you omit a reference, the default branch
will be used.
</TabItem>
<TabItem value="git-ssh" label="Git over SSH">
`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
`//<path>` to the URL.
- You can also optionally specify a branch or tag to use by appending
`?ref=<ref>` to the end of the URL. If you omit a reference, the default branch
will be used.
</TabItem>
</Tabs>
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 ```yaml
version: '3' version: '3'
tasks: tasks:
hello: default:
silent: true
cmds: cmds:
- echo "Hello from the remote Taskfile!" - task: hello
hello:
cmds:
- echo "Hello Task!"
``` ```
and you run `task my-remote-namespace:hello`, it will print the text: "Hello ## Specifying a remote entrypoint
from the remote Taskfile!" to your console.
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:
<Tabs groupId="method" queryString>
<TabItem value="http" label="HTTP/HTTPS">
```shell
$ task --taskfile https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml
task: [hello] echo "Hello Task!"
Hello Task!
```
</TabItem>
<TabItem value="git-http" label="Git over HTTP">
```shell
$ task --taskfile https://github.com/go-task/task.git//website/static/Taskfile.yml?ref=main
task: [hello] echo "Hello Task!"
Hello Task!
```
</TabItem>
<TabItem value="git-ssh" label="Git over SSH">
```shell
$ task --taskfile git@github.com/go-task/task.git//website/static/Taskfile.yml?ref=main
task: [hello] echo "Hello Task!"
Hello Task!
```
</TabItem>
</Tabs>
## 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.
<Tabs groupId="method" queryString>
<TabItem value="http" label="HTTP/HTTPS">
```yaml
version: '3'
includes:
my-remote-namespace: https://raw.githubusercontent.com/go-task/task/main/website/static/Taskfile.yml
```
</TabItem>
<TabItem value="git-http" label="Git over HTTP">
```yaml
version: '3'
includes:
my-remote-namespace: https://github.com/go-task/task.git//website/static/Taskfile.yml?ref=main
```
</TabItem>
<TabItem value="git-ssh" label="Git over SSH">
```yaml
version: '3'
includes:
my-remote-namespace: git@github.com/go-task/task.git//website/static/Taskfile.yml?ref=main
```
</TabItem>
</Tabs>
```shell
$ task my-remote-namespace:hello
task: [hello] echo "Hello Task!"
Hello Task!
```
### Authenticating using environment variables
The Taskfile location is processed by the templating system, so you can The Taskfile location is processed by the templating system, so you can
reference environment variables in your URL if you need to add authentication. reference environment variables in your URL if you need to add authentication.
@ -59,19 +180,6 @@ includes:
my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml my-remote-namespace: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
``` ```
`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`
## Git nodes
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`.
You need to follow this pattern : `<baseUrl>.git//<path>?ref=<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.
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.
## Security ## Security
Running commands from sources that you do not control is always a potential Running commands from sources that you do not control is always a potential
@ -104,20 +212,26 @@ flag. Before enabling this flag, you should:
Task currently supports both `http` and `https` URLs. However, the `http` Task currently supports both `http` and `https` URLs. However, the `http`
requests will not execute by default unless you run the task with the 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 `--insecure` flag. This is to protect you from accidentally running a remote
Taskfile that is via an unencrypted connection. Sources that are not protected Taskfile that is downloaded via an unencrypted connection. Sources that are not
by TLS are vulnerable to [man-in-the-middle attacks][man-in-the-middle-attacks] protected by TLS are vulnerable to [man-in-the-middle
and should be avoided unless you know what you are doing. attacks][man-in-the-middle-attacks] and should be avoided unless you know what
you are doing.
## Caching & Running Offline ## Caching & Running Offline
Whenever you run a remote Taskfile, the latest copy will be downloaded from the 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 and cached locally. This cached file will be used for all future
internet, you will still be able to run your tasks by specifying the `--offline` invocations of the Taskfile until the cache expires. Once it expires, Task will
flag. This will tell Task to use the latest cached version of the file instead download the latest copy of the file and update the cache. By default, the cache
of trying to download it. You are able to use the `--download` flag to update is set to expire immediately. This means that Task will always fetch the latest
the cached version of the remote files without running any tasks. You are able version. However, the cache expiry duration can be modified by setting the
to use the `--clear-cache` flag to clear all cached version of the remote files `--expiry` flag.
without running any tasks.
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 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 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 override the location of the cache by setting the `TASK_REMOTE_DIR` environment
variable. This way, you can share the cache between different projects. 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 */} {/* prettier-ignore-start */}
[enabling-experiments]: ./experiments.mdx#enabling-experiments [enabling-experiments]: ./experiments.mdx#enabling-experiments
[man-in-the-middle-attacks]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack [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 */} {/* prettier-ignore-end */}

View File

@ -117,7 +117,8 @@ Taskfiles) by calling the `Read` method on the reader and pass the `Node` as an
argument: argument:
```go ```go
tfg, err := reader.Read(node) ctx := context.Background()
tfg, err := reader.Read(ctx, node)
// handle error // handle error
``` ```