diff --git a/cmd/gomplate/config.go b/cmd/gomplate/config.go index a3d90f8e3..022786181 100644 --- a/cmd/gomplate/config.go +++ b/cmd/gomplate/config.go @@ -3,7 +3,9 @@ package main import ( "context" "fmt" + "time" + "github.com/hairyhenderson/gomplate/v3/conv" "github.com/hairyhenderson/gomplate/v3/env" "github.com/hairyhenderson/gomplate/v3/internal/config" @@ -26,6 +28,7 @@ var fs = afero.NewOsFs() // - validates the final config // - converts the config to a *gomplate.Config for further use (TODO: eliminate this part) func loadConfig(cmd *cobra.Command, args []string) (*config.Config, error) { + ctx := cmd.Context() flagConfig, err := cobraConfig(cmd, args) if err != nil { return nil, err @@ -41,6 +44,11 @@ func loadConfig(cmd *cobra.Command, args []string) (*config.Config, error) { cfg = cfg.MergeFrom(flagConfig) } + cfg, err = applyEnvVars(ctx, cfg) + if err != nil { + return nil, err + } + // reset defaults before validation cfg.ApplyDefaults() @@ -227,3 +235,19 @@ func processIncludes(includes, excludes []string) []string { out = append(out, excludes...) return out } + +func applyEnvVars(ctx context.Context, cfg *config.Config) (*config.Config, error) { + if to := env.Getenv("GOMPLATE_PLUGIN_TIMEOUT"); cfg.PluginTimeout == 0 && to != "" { + t, err := time.ParseDuration(to) + if err != nil { + return nil, fmt.Errorf("GOMPLATE_PLUGIN_TIMEOUT set to invalid value %q: %w", to, err) + } + cfg.PluginTimeout = t + } + + if !cfg.SuppressEmpty && conv.ToBool(env.Getenv("GOMPLATE_SUPPRESS_EMPTY", "false")) { + cfg.SuppressEmpty = true + } + + return cfg, nil +} diff --git a/cmd/gomplate/config_test.go b/cmd/gomplate/config_test.go index 58a5864c9..dc05dfbbb 100644 --- a/cmd/gomplate/config_test.go +++ b/cmd/gomplate/config_test.go @@ -1,8 +1,10 @@ package main import ( + "context" "os" "testing" + "time" "github.com/hairyhenderson/gomplate/v3/internal/config" @@ -79,6 +81,7 @@ func TestLoadConfig(t *testing.T) { RDelim: "}}", PostExecInput: os.Stdin, OutWriter: os.Stdout, + PluginTimeout: 5 * time.Second, } assert.NoError(t, err) assert.EqualValues(t, expected, out) @@ -92,6 +95,7 @@ func TestLoadConfig(t *testing.T) { RDelim: "}}", PostExecInput: os.Stdin, OutWriter: os.Stdout, + PluginTimeout: 5 * time.Second, } assert.NoError(t, err) assert.EqualValues(t, expected, out) @@ -106,6 +110,8 @@ func TestLoadConfig(t *testing.T) { PostExec: []string{"tr", "[a-z]", "[A-Z]"}, PostExecInput: out.PostExecInput, OutWriter: out.PostExecInput, + OutputFiles: []string{"-"}, + PluginTimeout: 5 * time.Second, } assert.NoError(t, err) assert.EqualValues(t, expected, out) @@ -176,3 +182,77 @@ func TestPickConfigFile(t *testing.T) { assert.True(t, req) assert.Equal(t, "config.file", cf) } + +func TestApplyEnvVars_PluginTimeout(t *testing.T) { + os.Setenv("GOMPLATE_PLUGIN_TIMEOUT", "bogus") + + ctx := context.TODO() + cfg := &config.Config{} + _, err := applyEnvVars(ctx, cfg) + assert.Error(t, err) + + cfg = &config.Config{ + PluginTimeout: 2 * time.Second, + } + expected := &config.Config{ + PluginTimeout: 2 * time.Second, + } + actual, err := applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + + os.Setenv("GOMPLATE_PLUGIN_TIMEOUT", "2s") + defer os.Unsetenv("GOMPLATE_PLUGIN_TIMEOUT") + + cfg = &config.Config{} + actual, err = applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + + cfg = &config.Config{ + PluginTimeout: 100 * time.Millisecond, + } + expected = &config.Config{ + PluginTimeout: 100 * time.Millisecond, + } + actual, err = applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + +} + +func TestApplyEnvVars_SuppressEmpty(t *testing.T) { + os.Setenv("GOMPLATE_SUPPRESS_EMPTY", "bogus") + defer os.Unsetenv("GOMPLATE_SUPPRESS_EMPTY") + + ctx := context.TODO() + cfg := &config.Config{} + expected := &config.Config{ + SuppressEmpty: false, + } + actual, err := applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + + os.Setenv("GOMPLATE_SUPPRESS_EMPTY", "true") + + cfg = &config.Config{} + expected = &config.Config{ + SuppressEmpty: true, + } + actual, err = applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) + + os.Setenv("GOMPLATE_SUPPRESS_EMPTY", "false") + + cfg = &config.Config{ + SuppressEmpty: true, + } + expected = &config.Config{ + SuppressEmpty: true, + } + actual, err = applyEnvVars(ctx, cfg) + assert.NoError(t, err) + assert.EqualValues(t, expected, actual) +} diff --git a/data/datasource.go b/data/datasource.go index 7ca78d30b..e7e610700 100644 --- a/data/datasource.go +++ b/data/datasource.go @@ -16,6 +16,7 @@ import ( "github.com/pkg/errors" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/hairyhenderson/gomplate/v3/libkv" "github.com/hairyhenderson/gomplate/v3/vault" ) @@ -126,10 +127,25 @@ func NewData(datasourceArgs, headerArgs []string) (*Data, error) { } // FromConfig - internal use only! -func FromConfig(sources map[string]*Source, extraHeaders map[string]http.Header) *Data { +func FromConfig(cfg *config.Config) *Data { + sources := map[string]*Source{} + for alias, d := range cfg.DataSources { + sources[alias] = &Source{ + Alias: alias, + URL: d.URL, + header: d.Header, + } + } + for alias, d := range cfg.Context { + sources[alias] = &Source{ + Alias: alias, + URL: d.URL, + header: d.Header, + } + } return &Data{ Sources: sources, - extraHeaders: extraHeaders, + extraHeaders: cfg.ExtraHeaders, } } diff --git a/data/datasource_test.go b/data/datasource_test.go index 5d627b401..63167cb29 100644 --- a/data/datasource_test.go +++ b/data/datasource_test.go @@ -2,11 +2,13 @@ package data import ( "fmt" + "net/http" "net/url" "runtime" "strings" "testing" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -411,3 +413,70 @@ func TestQueryParse(t *testing.T) { assert.NoError(t, err) assert.EqualValues(t, expected, u) } + +func TestFromConfig(t *testing.T) { + cfg := &config.Config{} + expected := &Data{ + Sources: map[string]*Source{}, + } + assert.EqualValues(t, expected, FromConfig(cfg)) + + cfg = &config.Config{ + DataSources: map[string]config.DSConfig{ + "foo": { + URL: mustParseURL("http://example.com"), + }, + }, + } + expected = &Data{ + Sources: map[string]*Source{ + "foo": { + Alias: "foo", + URL: mustParseURL("http://example.com"), + }, + }, + } + assert.EqualValues(t, expected, FromConfig(cfg)) + + cfg = &config.Config{ + DataSources: map[string]config.DSConfig{ + "foo": { + URL: mustParseURL("http://foo.com"), + }, + }, + Context: map[string]config.DSConfig{ + "bar": { + URL: mustParseURL("http://bar.com"), + Header: http.Header{ + "Foo": []string{"bar"}, + }, + }, + }, + ExtraHeaders: map[string]http.Header{ + "baz": { + "Foo": []string{"bar"}, + }, + }, + } + expected = &Data{ + Sources: map[string]*Source{ + "foo": { + Alias: "foo", + URL: mustParseURL("http://foo.com"), + }, + "bar": { + Alias: "bar", + URL: mustParseURL("http://bar.com"), + header: http.Header{ + "Foo": []string{"bar"}, + }, + }, + }, + extraHeaders: map[string]http.Header{ + "baz": { + "Foo": []string{"bar"}, + }, + }, + } + assert.EqualValues(t, expected, FromConfig(cfg)) +} diff --git a/docs/content/config.md b/docs/content/config.md index 7ae85995f..7857e4524 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -291,6 +291,20 @@ plugins: lolcat: /home/hairyhenderson/go/bin/lolcat ``` +## `pluginTimeout` + +See [`--plugin`](../usage/#--plugin). + +Sets the timeout for running plugins. By default, plugins will time out after 5 +seconds. This value can be set to override this default. The value must be +a valid [duration](../functions/time/#time-parseduration) such as `10s` or `3m`. + +```yaml +plugins: + figlet: /usr/local/bin/figlet +pluginTimeout: 500ms +``` + ## `postExec` See [post-template command execution](../usage/#post-template-command-execution). @@ -309,6 +323,16 @@ Overrides the right template delimiter. rightDelim: '))' ``` +## `suppressEmpty` + +See _[Suppressing empty output](../usage/#suppressing-empty-output)_ + +Suppresses empty output (i.e. output consisting of only whitespace). Can also be set with the `GOMPLATE_SUPPRESS_EMPTY` environment variable. + +```yaml +suppressEmpty: true +``` + ## `templates` See [`--template`/`-t`](../usage/#--template-t). diff --git a/docs/content/usage.md b/docs/content/usage.md index c6beb5b2b..375011222 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -279,7 +279,7 @@ post-exec command. ## Suppressing empty output -Sometimes it can be desirable to suppress empty output (i.e. output consisting of only whitespace). To do so, set `GOMPLATE_SUPPRESS_EMPTY=true` in your environment: +Sometimes it can be desirable to suppress empty output (i.e. output consisting of only whitespace). To do so, set `suppressEmpty: true` in your [config][] file, or `GOMPLATE_SUPPRESS_EMPTY=true` in your environment: ```console $ export GOMPLATE_SUPPRESS_EMPTY=true @@ -290,5 +290,6 @@ cat: out: No such file or directory [default context]: ../syntax/#the-context [context]: ../syntax/#the-context +[config]: ../config/#suppressempty [external templates]: ../syntax/#external-templates [`.gitignore`]: https://git-scm.com/docs/gitignore diff --git a/gomplate.go b/gomplate.go index cc081f053..fe59c2ac9 100644 --- a/gomplate.go +++ b/gomplate.go @@ -126,7 +126,7 @@ func RunTemplatesWithContext(ctx context.Context, cfg *config.Config) error { Metrics = newMetrics() defer runCleanupHooks() - d := data.FromConfig(cfg.Sources(), cfg.ExtraHeaders) + d := data.FromConfig(cfg) log.Debug().Str("data", fmt.Sprintf("%+v", d)).Msg("created data from config") addCleanupHook(d.Cleanup) @@ -139,7 +139,7 @@ func RunTemplatesWithContext(ctx context.Context, cfg *config.Config) error { return err } funcMap := Funcs(d) - err = bindPlugins(ctx, cfg.Plugins, funcMap) + err = bindPlugins(ctx, cfg, funcMap) if err != nil { return err } diff --git a/internal/config/configfile.go b/internal/config/configfile.go index ea1391020..9857a6df6 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -11,12 +11,17 @@ import ( "path/filepath" "strconv" "strings" + "time" - "github.com/hairyhenderson/gomplate/v3/data" "github.com/pkg/errors" "gopkg.in/yaml.v3" ) +var ( + // PluginTimeoutKey - context key for PluginTimeout - temporary! + PluginTimeoutKey = struct{}{} +) + // Parse a config file func Parse(in io.Reader) (*Config, error) { out := &Config{} @@ -38,16 +43,18 @@ type Config struct { OutputDir string `yaml:"outputDir,omitempty"` OutputMap string `yaml:"outputMap,omitempty"` - ExecPipe bool `yaml:"execPipe,omitempty"` - PostExec []string `yaml:"postExec,omitempty,flow"` + SuppressEmpty bool `yaml:"suppressEmpty,omitempty"` + ExecPipe bool `yaml:"execPipe,omitempty"` + PostExec []string `yaml:"postExec,omitempty,flow"` - OutMode string `yaml:"chmod,omitempty"` - LDelim string `yaml:"leftDelim,omitempty"` - RDelim string `yaml:"rightDelim,omitempty"` - DataSources DSources `yaml:"datasources,omitempty"` - Context DSources `yaml:"context,omitempty"` - Plugins map[string]string `yaml:"plugins,omitempty"` - Templates []string `yaml:"templates,omitempty"` + OutMode string `yaml:"chmod,omitempty"` + LDelim string `yaml:"leftDelim,omitempty"` + RDelim string `yaml:"rightDelim,omitempty"` + DataSources DSources `yaml:"datasources,omitempty"` + Context DSources `yaml:"context,omitempty"` + Plugins map[string]string `yaml:"plugins,omitempty"` + PluginTimeout time.Duration `yaml:"pluginTimeout,omitempty"` + Templates []string `yaml:"templates,omitempty"` // Extra HTTP headers not attached to pre-defined datsources. Potentially // used by datasources defined in the template. @@ -147,10 +154,12 @@ func (c *Config) MergeFrom(o *Config) *Config { c.InputDir = o.InputDir c.InputFiles = nil case !isZero(o.InputFiles): - c.Input = "" - c.InputFiles = o.InputFiles - c.InputDir = "" - c.OutputDir = "" + if !(len(o.InputFiles) == 1 && o.InputFiles[0] == "-") { + c.Input = "" + c.InputFiles = o.InputFiles + c.InputDir = "" + c.OutputDir = "" + } } if !isZero(o.OutputMap) { @@ -431,6 +440,10 @@ func (c *Config) ApplyDefaults() { c.PostExecInput = os.Stdin c.OutWriter = os.Stdout } + + if c.PluginTimeout == 0 { + c.PluginTimeout = 5 * time.Second + } } // String - @@ -453,24 +466,6 @@ func (c *Config) String() string { return out.String() } -// Sources - -func (c *Config) Sources() map[string]*data.Source { - sources := map[string]*data.Source{} - for alias, d := range c.DataSources { - sources[alias] = &data.Source{ - Alias: alias, - URL: d.URL, - } - } - for alias, d := range c.Context { - sources[alias] = &data.Source{ - Alias: alias, - URL: d.URL, - } - } - return sources -} - func parseSourceURL(value string) (*url.URL, error) { if value == "-" { value = "stdin://" diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index 5faf16ae9..ed3b8f9b2 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -6,6 +6,7 @@ import ( "os" "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -35,6 +36,8 @@ datasources: context: .: url: file:///data.json + +pluginTimeout: 2s ` expected = &Config{ Input: "hello world", @@ -55,7 +58,8 @@ context: URL: mustURL("file:///data.json"), }, }, - OutMode: "644", + OutMode: "644", + PluginTimeout: 2 * time.Second, } cf, err = Parse(strings.NewReader(in)) @@ -285,6 +289,32 @@ func TestMergeFrom(t *testing.T) { } assert.EqualValues(t, expected, cfg.MergeFrom(other)) + + cfg = &Config{ + Input: "hello world", + OutputFiles: []string{"-"}, + Plugins: map[string]string{ + "sleep": "echo", + }, + PluginTimeout: 500 * time.Microsecond, + } + other = &Config{ + InputFiles: []string{"-"}, + OutputFiles: []string{"-"}, + Plugins: map[string]string{ + "sleep": "sleep.sh", + }, + } + expected = &Config{ + Input: "hello world", + OutputFiles: []string{"-"}, + Plugins: map[string]string{ + "sleep": "sleep.sh", + }, + PluginTimeout: 500 * time.Microsecond, + } + + assert.EqualValues(t, expected, cfg.MergeFrom(other)) } func TestParseDataSourceFlags(t *testing.T) { @@ -371,6 +401,7 @@ inputFiles: ['-'] outputFiles: ['-'] leftDelim: '{{' rightDelim: '}}' +pluginTimeout: 5s ` assert.Equal(t, expected, c.String()) @@ -428,6 +459,15 @@ outputDir: out/ expected = `--- inputDir: in/ outputMap: '{{ .in }}' +` + + assert.Equal(t, expected, c.String()) + + c = &Config{ + PluginTimeout: 500 * time.Millisecond, + } + expected = `--- +pluginTimeout: 500ms ` assert.Equal(t, expected, c.String()) diff --git a/internal/config/legacy/legacyconfig.xgo b/internal/config/legacy/legacyconfig.xgo new file mode 100644 index 000000000..4584a6cad --- /dev/null +++ b/internal/config/legacy/legacyconfig.xgo @@ -0,0 +1,87 @@ +// Package legacy helps to isolate access to the legacy config struct +// (gomplate.Config) so we can migrate away from it. +package legacy + +import ( + "net/http" + + "github.com/hairyhenderson/gomplate/v3" + "github.com/hairyhenderson/gomplate/v3/internal/config" +) + +// ToGConfig - convert a config.Config to a legacy *gomplate.Config +func ToGConfig(c *config.Config) *gomplate.Config { + hdrs := []string{} + for k, c := range c.DataSources { + hdrs = append(hdrs, headerToPairs(k, c.Header)...) + } + for k, c := range c.Context { + hdrs = append(hdrs, headerToPairs(k, c.Header)...) + } + for k, h := range c.ExtraHeaders { + hdrs = append(hdrs, headerToPairs(k, h)...) + } + + g := &gomplate.Config{ + Input: c.Input, + InputFiles: c.InputFiles, + InputDir: c.InputDir, + ExcludeGlob: c.ExcludeGlob, + OutputFiles: c.OutputFiles, + OutputDir: c.OutputDir, + OutputMap: c.OutputMap, + OutMode: c.OutMode, + LDelim: c.LDelim, + RDelim: c.RDelim, + DataSources: keyURLPairs(c.DataSources), + DataSourceHeaders: hdrs, + Contexts: keyURLPairs(c.Context), + Plugins: keyValuePairs(c.Plugins), + Templates: c.Templates, + Out: c.OutWriter, + } + if g.Input == "" && g.InputDir == "" && len(g.InputFiles) == 0 { + g.InputFiles = []string{"-"} + } + if g.OutputDir == "" && g.OutputMap == "" && len(g.OutputFiles) == 0 { + g.OutputFiles = []string{"-"} + } + return g +} + +// headerToPairs formats headers for the legacy gomplate.Config format +func headerToPairs(key string, hdr http.Header) (out []string) { + for h, vs := range hdr { + for _, v := range vs { + out = append(out, key+"="+h+": "+v) + } + } + return out +} + +func keyValuePairs(p map[string]string) []string { + if len(p) == 0 { + return nil + } + + out := make([]string, len(p)) + i := 0 + for k, v := range p { + out[i] = k + "=" + v + i++ + } + return out +} + +func keyURLPairs(d config.DSources) []string { + if len(d) == 0 { + return nil + } + out := make([]string, len(d)) + i := 0 + for k, c := range d { + out[i] = k + "=" + c.URL.String() + i++ + } + return out +} diff --git a/internal/config/legacy/legacyconfig_test.xgo b/internal/config/legacy/legacyconfig_test.xgo new file mode 100644 index 000000000..c936f6c75 --- /dev/null +++ b/internal/config/legacy/legacyconfig_test.xgo @@ -0,0 +1,125 @@ +package legacy + +import ( + "net/http" + "net/url" + "sort" + "testing" + + "github.com/hairyhenderson/gomplate/v3" + "github.com/hairyhenderson/gomplate/v3/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestToConfig(t *testing.T) { + t.Parallel() + cf := &config.Config{ + Input: "hello world", + DataSources: map[string]config.DSConfig{ + "data": { + URL: mustURL("file:///data.json"), + }, + "moredata": { + URL: mustURL("https://example.com/more.json"), + Header: map[string][]string{ + "Authorization": {"Bearer abcd1234"}, + }, + }, + }, + ExtraHeaders: map[string]http.Header{ + "extra": { + "Foo": []string{"bar", "baz"}, + }, + }, + Context: map[string]config.DSConfig{ + ".": { + URL: mustURL("file:///data.json"), + }, + "foo": { + URL: mustURL("https://example.com/foo.yaml"), + Header: map[string][]string{ + "Accept": {"application/yaml"}, + }, + }, + }, + OutMode: "644", + } + + expected := &gomplate.Config{ + Input: cf.Input, + OutMode: cf.OutMode, + OutputFiles: []string{"-"}, + Contexts: []string{ + ".=file:///data.json", + "foo=https://example.com/foo.yaml", + }, + DataSources: []string{ + "data=file:///data.json", + "moredata=https://example.com/more.json", + }, + DataSourceHeaders: []string{ + "extra=Foo: bar", + "extra=Foo: baz", + "foo=Accept: application/yaml", + "moredata=Authorization: Bearer abcd1234", + }, + } + + conf := ToGConfig(cf) + // sort these so the comparison is deterministic + sort.Strings(conf.Contexts) + sort.Strings(conf.DataSources) + sort.Strings(conf.DataSourceHeaders) + + assert.EqualValues(t, expected, conf) +} + +func TestKVPairs(t *testing.T) { + t.Parallel() + k := map[string]string{} + assert.Empty(t, keyValuePairs(k)) + + k = map[string]string{ + "foo": "bar", + "baz": "qux", + } + pairs := keyValuePairs(k) + assert.Len(t, pairs, 2) + assert.Contains(t, pairs, "foo=bar") + assert.Contains(t, pairs, "baz=qux") +} + +func TestDSourcesPairFuncs(t *testing.T) { + t.Parallel() + ds := config.DSources{} + assert.Empty(t, keyURLPairs(ds)) + + ds = config.DSources{ + "foo": config.DSConfig{ + URL: mustURL("foo.json"), + Header: map[string][]string{ + "Accept": {"application/json"}, + "Authorization": {"foo"}, + "X-Foo": {"bar"}, + }}, + "bar": config.DSConfig{URL: mustURL("stdin://in.yaml")}, + } + pairs := keyURLPairs(ds) + assert.Len(t, pairs, 2) + assert.Contains(t, pairs, "foo=foo.json") + assert.Contains(t, pairs, "bar=stdin://in.yaml") + + pairs = headerToPairs("foo", ds["foo"].Header) + assert.Len(t, pairs, 3) + assert.Contains(t, pairs, `foo=Accept: application/json`) + assert.Contains(t, pairs, `foo=Authorization: foo`) + assert.Contains(t, pairs, `foo=X-Foo: bar`) +} + +func mustURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} diff --git a/plugins.go b/plugins.go index 457901cda..b4d561884 100644 --- a/plugins.go +++ b/plugins.go @@ -13,16 +13,16 @@ import ( "time" "github.com/hairyhenderson/gomplate/v3/conv" - "github.com/hairyhenderson/gomplate/v3/env" - "github.com/rs/zerolog" + "github.com/hairyhenderson/gomplate/v3/internal/config" ) -func bindPlugins(ctx context.Context, plugins map[string]string, funcMap template.FuncMap) error { - for k, v := range plugins { +func bindPlugins(ctx context.Context, cfg *config.Config, funcMap template.FuncMap) error { + for k, v := range cfg.Plugins { plugin := &plugin{ - ctx: ctx, - name: k, - path: v, + ctx: ctx, + name: k, + path: v, + timeout: cfg.PluginTimeout, } if _, ok := funcMap[plugin.name]; ok { return fmt.Errorf("function %q is already bound, and can not be overridden", plugin.name) @@ -35,20 +35,10 @@ func bindPlugins(ctx context.Context, plugins map[string]string, funcMap templat // plugin represents a custom function that binds to an external process to be executed type plugin struct { name, path string + timeout time.Duration ctx context.Context } -func (p *plugin) getTimeout() (time.Duration, error) { - log := zerolog.Ctx(p.ctx) - to := env.Getenv("GOMPLATE_PLUGIN_TIMEOUT", "5s") - t, err := time.ParseDuration(to) - if err != nil { - log.Error().Err(err).Msgf("GOMPLATE_PLUGIN_TIMEOUT set to invalid value %q", to) - return 0, err - } - return t, nil -} - // builds a command that's appropriate for running scripts // nolint: gosec func (p *plugin) buildCommand(a []string) (name string, args []string) { @@ -80,15 +70,11 @@ func findPowershell() string { } func (p *plugin) run(args ...interface{}) (interface{}, error) { - timeout, err := p.getTimeout() - if err != nil { - return nil, err - } a := conv.ToStrings(args...) name, a := p.buildCommand(a) - ctx, cancel := context.WithTimeout(p.ctx, timeout) + ctx, cancel := context.WithTimeout(p.ctx, p.timeout) defer cancel() c := exec.CommandContext(ctx, name, a...) c.Stdin = nil @@ -108,7 +94,7 @@ func (p *plugin) run(args ...interface{}) (interface{}, error) { } }() start := time.Now() - err = c.Run() + err := c.Run() elapsed := time.Since(start) if ctx.Err() != nil { diff --git a/plugins_test.go b/plugins_test.go index 933d6fe85..d1292eaa5 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -7,22 +7,26 @@ import ( "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" + + "github.com/hairyhenderson/gomplate/v3/internal/config" ) func TestBindPlugins(t *testing.T) { ctx := context.TODO() fm := template.FuncMap{} - in := map[string]string{} - err := bindPlugins(ctx, in, fm) + cfg := &config.Config{ + Plugins: map[string]string{}, + } + err := bindPlugins(ctx, cfg, fm) assert.NilError(t, err) assert.DeepEqual(t, template.FuncMap{}, fm) - in = map[string]string{"foo": "bar"} - err = bindPlugins(ctx, in, fm) + cfg.Plugins = map[string]string{"foo": "bar"} + err = bindPlugins(ctx, cfg, fm) assert.NilError(t, err) assert.Check(t, cmp.Contains(fm, "foo")) - err = bindPlugins(ctx, in, fm) + err = bindPlugins(ctx, cfg, fm) assert.ErrorContains(t, err, "already bound") } diff --git a/template.go b/template.go index cd8fc7580..138625fdb 100644 --- a/template.go +++ b/template.go @@ -12,8 +12,6 @@ import ( "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/hairyhenderson/gomplate/v3/tmpl" - "github.com/hairyhenderson/gomplate/v3/conv" - "github.com/hairyhenderson/gomplate/v3/env" "github.com/pkg/errors" "github.com/spf13/afero" @@ -85,12 +83,12 @@ func (t *tplate) loadContents() (err error) { return err } -func (t *tplate) addTarget() (err error) { +func (t *tplate) addTarget(cfg *config.Config) (err error) { if t.name == "" && t.targetPath == "" { t.targetPath = "-" } if t.target == nil { - t.target, err = openOutFile(t.targetPath, t.mode, t.modeOverride) + t.target, err = openOutFile(cfg, t.targetPath, t.mode, t.modeOverride) } return err } @@ -134,16 +132,16 @@ func gatherTemplates(cfg *config.Config, outFileNamer func(string) (string, erro } } - return processTemplates(templates) + return processTemplates(cfg, templates) } -func processTemplates(templates []*tplate) ([]*tplate, error) { +func processTemplates(cfg *config.Config, templates []*tplate) ([]*tplate, error) { for _, t := range templates { if err := t.loadContents(); err != nil { return nil, err } - if err := t.addTarget(); err != nil { + if err := t.addTarget(cfg); err != nil { return nil, err } } @@ -229,8 +227,8 @@ func fileToTemplates(inFile, outFile string, mode os.FileMode, modeOverride bool return tmpl, nil } -func openOutFile(filename string, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { - if conv.ToBool(env.Getenv("GOMPLATE_SUPPRESS_EMPTY", "false")) { +func openOutFile(cfg *config.Config, filename string, mode os.FileMode, modeOverride bool) (out io.WriteCloser, err error) { + if cfg.SuppressEmpty { out = newEmptySkipper(func() (io.WriteCloser, error) { if filename == "-" { return Stdout, nil diff --git a/template_test.go b/template_test.go index bef353fc5..5f10530dc 100644 --- a/template_test.go +++ b/template_test.go @@ -45,7 +45,8 @@ func TestOpenOutFile(t *testing.T) { fs = afero.NewMemMapFs() _ = fs.Mkdir("/tmp", 0777) - _, err := openOutFile("/tmp/foo", 0644, false) + cfg := &config.Config{} + _, err := openOutFile(cfg, "/tmp/foo", 0644, false) assert.NoError(t, err) i, err := fs.Stat("/tmp/foo") assert.NoError(t, err) @@ -54,7 +55,7 @@ func TestOpenOutFile(t *testing.T) { defer func() { Stdout = os.Stdout }() Stdout = &nopWCloser{&bytes.Buffer{}} - f, err := openOutFile("-", 0644, false) + f, err := openOutFile(cfg, "-", 0644, false) assert.NoError(t, err) assert.Equal(t, Stdout, f) } @@ -77,8 +78,9 @@ func TestAddTarget(t *testing.T) { defer func() { fs = origfs }() fs = afero.NewMemMapFs() + cfg := &config.Config{} tmpl := &tplate{name: "foo", targetPath: "/out/outfile"} - err := tmpl.addTarget() + err := tmpl.addTarget(cfg) assert.NoError(t, err) assert.NotNil(t, tmpl.target) } @@ -173,6 +175,7 @@ func TestProcessTemplates(t *testing.T) { afero.WriteFile(fs, "existing", []byte(""), 0644) + cfg := &config.Config{} testdata := []struct { templates []*tplate contents []string @@ -226,7 +229,7 @@ func TestProcessTemplates(t *testing.T) { }, } for _, in := range testdata { - actual, err := processTemplates(in.templates) + actual, err := processTemplates(cfg, in.templates) assert.NoError(t, err) assert.Len(t, actual, len(in.templates)) for i, a := range actual { diff --git a/tests/integration/config_test.go b/tests/integration/config_test.go index dd29ee080..06ba6ab5e 100644 --- a/tests/integration/config_test.go +++ b/tests/integration/config_test.go @@ -5,6 +5,7 @@ package integration import ( "bytes" "io/ioutil" + "os" . "gopkg.in/check.v1" @@ -161,15 +162,34 @@ datasources: }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) - s.writeConfig(`in: {{ sleep 2 }} + s.writeConfig(`in: hi there {{ sleep 2 }} plugins: sleep: echo + pluginTimeout: 500ms `) result = icmd.RunCmd(icmd.Command(GomplateBin, - "--plugin", "sleep="+s.tmpDir.Join("sleep.sh"), - ), func(c *icmd.Cmd) { - c.Env = []string{"GOMPLATE_PLUGIN_TIMEOUT=5s"} + "--verbose", "--plugin", "sleep="+s.tmpDir.Join("sleep.sh"), + ), func(cmd *icmd.Cmd) { + cmd.Dir = s.tmpDir.Path() + cmd.Env = []string{"GOMPLATE_PLUGIN_TIMEOUT=5s"} }) result.Assert(c, icmd.Expected{ExitCode: 1, Err: "plugin timed out"}) + + s.writeConfig(`in: | + {{- print "\t \n\n\r\n\t\t \v\n" -}} + + {{ print " " -}} +out: ./missing +suppressEmpty: true +`) + result = icmd.RunCmd(icmd.Command(GomplateBin, + "--verbose"), func(cmd *icmd.Cmd) { + cmd.Dir = s.tmpDir.Path() + // should have no effect, as config overrides + cmd.Env = []string{"GOMPLATE_SUPPRESS_EMPTY=false"} + }) + result.Assert(c, icmd.Expected{ExitCode: 0}) + _, err = os.Stat(s.tmpDir.Join("missing")) + assert.Equal(c, true, os.IsNotExist(err)) }