From 78d6bb091bc0bdd58482cf724bdc5f4fe516ee63 Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Sat, 21 Mar 2020 21:45:54 -0400 Subject: [PATCH] fixup! Support a config file to use instead of commandline arguments --- .github/workflows/build.yml | 10 + cmd/gomplate/config.go | 24 ++ cmd/gomplate/config_test.go | 80 ++++++ cmd/gomplate/main.go | 4 +- config.go | 44 +-- config_test.go | 25 -- context.go | 17 +- context_test.go | 26 +- data/datasource.go | 24 ++ data/datasource_test.go | 69 +++++ docs/content/config.md | 24 ++ docs/content/usage.md | 3 +- gomplate.go | 46 +-- internal/config/configfile.go | 285 +++++++++++-------- internal/config/configfile_test.go | 266 +++++++++-------- internal/config/legacy/legacyconfig.xgo | 87 ++++++ internal/config/legacy/legacyconfig_test.xgo | 125 ++++++++ plugins.go | 51 +--- plugins_test.go | 50 ++-- template.go | 44 ++- template_test.go | 30 +- tests/integration/config_test.go | 146 +++++++--- 22 files changed, 987 insertions(+), 493 deletions(-) create mode 100644 internal/config/legacy/legacyconfig.xgo create mode 100644 internal/config/legacy/legacyconfig_test.xgo diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c57accd9a..b3ccd5c05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,11 @@ jobs: - run: make build env: GOPATH: ${{ runner.workspace }} + - name: Save binary + uses: actions/upload-artifact@v1 + with: + name: gomplate + path: bin/gomplate - run: make test env: GOPATH: ${{ runner.workspace }} @@ -41,6 +46,11 @@ jobs: - run: make build env: GOPATH: ${{ runner.workspace }} + - name: Save binary + uses: actions/upload-artifact@v1 + with: + name: gomplate.exe + path: bin/gomplate.exe - run: make test env: GOPATH: ${{ runner.workspace }} 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/cmd/gomplate/main.go b/cmd/gomplate/main.go index c9c303d0e..110a05035 100644 --- a/cmd/gomplate/main.go +++ b/cmd/gomplate/main.go @@ -85,9 +85,7 @@ func newGomplateCmd() *cobra.Command { Str("build", version.GitCommit). Msgf("config is:\n%v", cfg) - conf := cfg.ToGConfig() - - err = gomplate.RunTemplatesWithContext(ctx, conf) + err = gomplate.RunTemplatesWithContext(ctx, cfg) cmd.SilenceErrors = true cmd.SilenceUsage = true diff --git a/config.go b/config.go index 18bd5546a..913370a50 100644 --- a/config.go +++ b/config.go @@ -2,9 +2,9 @@ package gomplate import ( "io" - "os" - "strconv" "strings" + + "github.com/hairyhenderson/gomplate/v3/internal/config" ) // Config - values necessary for rendering templates with gomplate. @@ -52,20 +52,6 @@ func (o *Config) defaults() *Config { return o } -// parse an os.FileMode out of the string, and let us know if it's an override or not... -func (o *Config) getMode() (os.FileMode, bool, error) { - modeOverride := o.OutMode != "" - m, err := strconv.ParseUint("0"+o.OutMode, 8, 32) - if err != nil { - return 0, false, err - } - mode := os.FileMode(m) - if mode == 0 && o.Input != "" { - mode = 0644 - } - return mode, modeOverride, nil -} - // nolint: gocyclo func (o *Config) String() string { o.defaults() @@ -124,3 +110,29 @@ func (o *Config) String() string { } return c } + +func (o *Config) toNewConfig() (*config.Config, error) { + cfg := &config.Config{ + Input: o.Input, + InputFiles: o.InputFiles, + InputDir: o.InputDir, + ExcludeGlob: o.ExcludeGlob, + OutputFiles: o.OutputFiles, + OutputDir: o.OutputDir, + OutputMap: o.OutputMap, + OutMode: o.OutMode, + LDelim: o.LDelim, + RDelim: o.RDelim, + Templates: o.Templates, + OutWriter: o.Out, + } + err := cfg.ParsePluginFlags(o.Plugins) + if err != nil { + return nil, err + } + err = cfg.ParseDataSourceFlags(o.DataSources, o.Contexts, o.DataSourceHeaders) + if err != nil { + return nil, err + } + return cfg, nil +} diff --git a/config_test.go b/config_test.go index 91c2b1a9f..0ddefc3e8 100644 --- a/config_test.go +++ b/config_test.go @@ -1,7 +1,6 @@ package gomplate import ( - "os" "testing" "github.com/stretchr/testify/assert" @@ -47,27 +46,3 @@ output: {{ .in }}` assert.Equal(t, expected, c.String()) } - -func TestGetMode(t *testing.T) { - c := &Config{} - m, o, err := c.getMode() - assert.NoError(t, err) - assert.Equal(t, os.FileMode(0), m) - assert.False(t, o) - - c = &Config{OutMode: "755"} - m, o, err = c.getMode() - assert.NoError(t, err) - assert.Equal(t, os.FileMode(0755), m) - assert.True(t, o) - - c = &Config{OutMode: "0755"} - m, o, err = c.getMode() - assert.NoError(t, err) - assert.Equal(t, os.FileMode(0755), m) - assert.True(t, o) - - c = &Config{OutMode: "foo"} - _, _, err = c.getMode() - assert.Error(t, err) -} diff --git a/context.go b/context.go index ab12d404b..a2b8b0026 100644 --- a/context.go +++ b/context.go @@ -1,10 +1,12 @@ package gomplate import ( + "context" "os" "strings" "github.com/hairyhenderson/gomplate/v3/data" + "github.com/hairyhenderson/gomplate/v3/internal/config" ) // context for templates @@ -20,11 +22,10 @@ func (c *tmplctx) Env() map[string]string { return env } -func createTmplContext(contexts []string, d *data.Data) (interface{}, error) { +func createTmplContext(ctx context.Context, contexts config.DSources, d *data.Data) (interface{}, error) { var err error tctx := &tmplctx{} - for _, c := range contexts { - a := parseAlias(c) + for a := range contexts { if a == "." { return d.Datasource(a) } @@ -35,13 +36,3 @@ func createTmplContext(contexts []string, d *data.Data) (interface{}, error) { } return tctx, nil } - -func parseAlias(arg string) string { - parts := strings.SplitN(arg, "=", 2) - switch len(parts) { - case 1: - return strings.SplitN(parts[0], ".", 2)[0] - default: - return parts[0] - } -} diff --git a/context_test.go b/context_test.go index ab2737497..94f2b51b4 100644 --- a/context_test.go +++ b/context_test.go @@ -1,11 +1,13 @@ package gomplate import ( + "context" "net/url" "os" "testing" "github.com/hairyhenderson/gomplate/v3/data" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/stretchr/testify/assert" ) @@ -24,7 +26,8 @@ func TestEnvGetsUpdatedEnvironment(t *testing.T) { } func TestCreateContext(t *testing.T) { - c, err := createTmplContext(nil, nil) + ctx := context.TODO() + c, err := createTmplContext(ctx, nil, nil) assert.NoError(t, err) assert.Empty(t, c) @@ -40,31 +43,18 @@ func TestCreateContext(t *testing.T) { } os.Setenv("foo", "foo: bar") defer os.Unsetenv("foo") - c, err = createTmplContext([]string{"foo=" + fooURL}, d) + c, err = createTmplContext(ctx, map[string]config.DSConfig{"foo": {URL: uf}}, d) assert.NoError(t, err) assert.IsType(t, &tmplctx{}, c) - ctx := c.(*tmplctx) - ds := ((*ctx)["foo"]).(map[string]interface{}) + tctx := c.(*tmplctx) + ds := ((*tctx)["foo"]).(map[string]interface{}) assert.Equal(t, "bar", ds["foo"]) os.Setenv("bar", "bar: baz") defer os.Unsetenv("bar") - c, err = createTmplContext([]string{".=" + barURL}, d) + c, err = createTmplContext(ctx, map[string]config.DSConfig{".": {URL: ub}}, d) assert.NoError(t, err) assert.IsType(t, map[string]interface{}{}, c) ds = c.(map[string]interface{}) assert.Equal(t, "baz", ds["bar"]) } - -func TestParseAlias(t *testing.T) { - testdata := map[string]string{ - "": "", - "foo": "foo", - "foo.bar": "foo", - "a=b": "a", - ".=foo": ".", - } - for k, v := range testdata { - assert.Equal(t, v, parseAlias(k)) - } -} diff --git a/data/datasource.go b/data/datasource.go index 1fcccf98f..456b5a12f 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" ) @@ -125,6 +126,29 @@ func NewData(datasourceArgs, headerArgs []string) (*Data, error) { return data, nil } +// FromConfig - internal use only! +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: cfg.ExtraHeaders, + } +} + // Source - a data source type Source struct { Alias string diff --git a/data/datasource_test.go b/data/datasource_test.go index cdef39451..6752d9e5e 100644 --- a/data/datasource_test.go +++ b/data/datasource_test.go @@ -2,6 +2,7 @@ package data import ( "fmt" + "net/http" "net/url" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "strings" "testing" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -455,3 +457,70 @@ func TestAbsFileURL(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 d796c4dd2..fe59c2ac9 100644 --- a/gomplate.go +++ b/gomplate.go @@ -5,6 +5,7 @@ package gomplate import ( "bytes" "context" + "fmt" "io" "os" "path" @@ -14,7 +15,9 @@ import ( "time" "github.com/hairyhenderson/gomplate/v3/data" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/pkg/errors" + "github.com/rs/zerolog" "github.com/spf13/afero" ) @@ -109,42 +112,45 @@ func parseTemplateArg(templateArg string, ta templateAliases) error { // RunTemplates - run all gomplate templates specified by the given configuration func RunTemplates(o *Config) error { - return RunTemplatesWithContext(context.Background(), o) + cfg, err := o.toNewConfig() + if err != nil { + return err + } + return RunTemplatesWithContext(context.Background(), cfg) } // RunTemplatesWithContext - run all gomplate templates specified by the given configuration -func RunTemplatesWithContext(ctx context.Context, o *Config) error { +func RunTemplatesWithContext(ctx context.Context, cfg *config.Config) error { + log := zerolog.Ctx(ctx) + Metrics = newMetrics() defer runCleanupHooks() - // make sure config is sane - o.defaults() - ds := append(o.DataSources, o.Contexts...) - d, err := data.NewData(ds, o.DataSourceHeaders) - if err != nil { - return err - } + + d := data.FromConfig(cfg) + log.Debug().Str("data", fmt.Sprintf("%+v", d)).Msg("created data from config") + addCleanupHook(d.Cleanup) - nested, err := parseTemplateArgs(o.Templates) + nested, err := parseTemplateArgs(cfg.Templates) if err != nil { return err } - c, err := createTmplContext(o.Contexts, d) + c, err := createTmplContext(ctx, cfg.Context, d) if err != nil { return err } funcMap := Funcs(d) - err = bindPlugins(ctx, o.Plugins, funcMap) + err = bindPlugins(ctx, cfg, funcMap) if err != nil { return err } - g := newGomplate(funcMap, o.LDelim, o.RDelim, nested, c) + g := newGomplate(funcMap, cfg.LDelim, cfg.RDelim, nested, c) - return g.runTemplates(ctx, o) + return g.runTemplates(ctx, cfg) } -func (g *gomplate) runTemplates(ctx context.Context, o *Config) error { +func (g *gomplate) runTemplates(ctx context.Context, cfg *config.Config) error { start := time.Now() - tmpl, err := gatherTemplates(o, chooseNamer(o, g)) + tmpl, err := gatherTemplates(cfg, chooseNamer(cfg, g)) Metrics.GatherDuration = time.Since(start) if err != nil { Metrics.Errors++ @@ -166,11 +172,11 @@ func (g *gomplate) runTemplates(ctx context.Context, o *Config) error { return nil } -func chooseNamer(o *Config, g *gomplate) func(string) (string, error) { - if o.OutputMap == "" { - return simpleNamer(o.OutputDir) +func chooseNamer(cfg *config.Config, g *gomplate) func(string) (string, error) { + if cfg.OutputMap == "" { + return simpleNamer(cfg.OutputDir) } - return mappingNamer(o.OutputMap, g) + return mappingNamer(cfg.OutputMap, g) } func simpleNamer(outDir string) func(inPath string) (string, error) { diff --git a/internal/config/configfile.go b/internal/config/configfile.go index ff36b7d7a..06f97f89f 100644 --- a/internal/config/configfile.go +++ b/internal/config/configfile.go @@ -5,13 +5,21 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path" + "path/filepath" + "strconv" "strings" + "time" + "github.com/pkg/errors" "gopkg.in/yaml.v3" +) - "github.com/hairyhenderson/gomplate/v3" +var ( + // PluginTimeoutKey - context key for PluginTimeout - temporary! + PluginTimeoutKey = struct{}{} ) // Parse a config file @@ -35,26 +43,32 @@ type Config struct { OutputDir string `yaml:"outputDir,omitempty"` OutputMap string `yaml:"outputMap,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"` + 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"` + 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. ExtraHeaders map[string]http.Header `yaml:"-"` - Plugins kv `yaml:"plugins,omitempty"` - Templates []string `yaml:"templates,omitempty"` // internal use only, can't be injected in YAML PostExecInput io.ReadWriter `yaml:"-"` OutWriter io.Writer `yaml:"-"` } -type dsources map[string]dsConfig +// DSources - map of datasource configs +type DSources map[string]DSConfig -func (d dsources) mergeFrom(o dsources) dsources { +func (d DSources) mergeFrom(o DSources) DSources { for k, v := range o { c, ok := d[k] if ok { @@ -66,102 +80,61 @@ func (d dsources) mergeFrom(o dsources) dsources { return d } -type dsConfig struct { - URL string +// DSConfig - datasource config +type DSConfig struct { + URL *url.URL `yaml:"-"` Header http.Header `yaml:"header,omitempty,flow"` } -func (d dsConfig) mergeFrom(o dsConfig) dsConfig { - if !isZero(o.URL) { - d.URL = o.URL +// UnmarshalYAML - satisfy the yaml.Umarshaler interface - URLs aren't +// well supported, and anyway we need to do some extra parsing +func (d *DSConfig) UnmarshalYAML(value *yaml.Node) error { + type raw struct { + URL string + Header http.Header } - if d.Header == nil { - d.Header = o.Header - } else { - for k, v := range o.Header { - d.Header[k] = v - } + r := raw{} + err := value.Decode(&r) + if err != nil { + return err } - return d -} - -func (d dsources) keyURLPairs() []string { - if len(d) == 0 { - return nil + u, err := parseSourceURL(r.URL) + if err != nil { + return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err) } - out := make([]string, len(d)) - i := 0 - for k, c := range d { - out[i] = k + "=" + c.URL - i++ + *d = DSConfig{ + URL: u, + Header: r.Header, } - return out + return nil } -// 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) - } +// MarshalYAML - satisfy the yaml.Marshaler interface - URLs aren't +// well supported, and anyway we need to do some extra parsing +func (d DSConfig) MarshalYAML() (interface{}, error) { + type raw struct { + URL string + Header http.Header + } + r := raw{ + URL: d.URL.String(), + Header: d.Header, } - return out + return r, nil } -type kv map[string]string - -func (p kv) keyValuePairs() []string { - if len(p) == 0 { - return nil +func (d DSConfig) mergeFrom(o DSConfig) DSConfig { + if o.URL != nil { + d.URL = o.URL } - - out := make([]string, len(p)) - i := 0 - for k, v := range p { - out[i] = k + "=" + v - i++ + if d.Header == nil { + d.Header = o.Header + } else { + for k, v := range o.Header { + d.Header[k] = v + } } - return out -} - -// ToGConfig - convert this to a legacy *gomplate.Config -func (c Config) ToGConfig() *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: c.DataSources.keyURLPairs(), - DataSourceHeaders: hdrs, - Contexts: c.Context.keyURLPairs(), - Plugins: c.Plugins.keyValuePairs(), - 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 + return d } // MergeFrom - use this Config as the defaults, and override it with any @@ -181,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) { @@ -242,7 +217,7 @@ func (c *Config) ParseDataSourceFlags(datasources, contexts, headers []string) e return err } if c.DataSources == nil { - c.DataSources = dsources{} + c.DataSources = DSources{} } c.DataSources[k] = ds } @@ -252,7 +227,7 @@ func (c *Config) ParseDataSourceFlags(datasources, contexts, headers []string) e return err } if c.Context == nil { - c.Context = dsources{} + c.Context = DSources{} } c.Context[k] = ds } @@ -289,14 +264,14 @@ func (c *Config) ParsePluginFlags(plugins []string) error { return fmt.Errorf("plugin requires both name and path") } if c.Plugins == nil { - c.Plugins = kv{} + c.Plugins = map[string]string{} } c.Plugins[parts[0]] = parts[1] } return nil } -func parseDatasourceArg(value string) (key string, ds dsConfig, err error) { +func parseDatasourceArg(value string) (key string, ds DSConfig, err error) { parts := strings.SplitN(value, "=", 2) if len(parts) == 1 { f := parts[0] @@ -305,10 +280,10 @@ func parseDatasourceArg(value string) (key string, ds dsConfig, err error) { err = fmt.Errorf("invalid datasource (%s): must provide an alias with files not in working directory", value) return key, ds, err } - ds.URL = f + ds.URL, err = absFileURL(f) } else if len(parts) == 2 { key = parts[0] - ds.URL = parts[1] + ds.URL, err = parseSourceURL(parts[1]) } return key, ds, err } @@ -357,8 +332,13 @@ func (c Config) Validate() (err error) { c.Input, c.InputFiles, c.InputDir) if err == nil { err = notTogether( - []string{"outputFiles", "outputDir", "outputMap", "execPipe"}, - c.OutputFiles, c.OutputDir, c.OutputMap, c.ExecPipe) + []string{"outputFiles", "outputDir", "outputMap"}, + c.OutputFiles, c.OutputDir, c.OutputMap) + } + if err == nil { + err = notTogether( + []string{"outputDir", "outputMap", "execPipe"}, + c.OutputDir, c.OutputMap, c.ExecPipe) } if err == nil { @@ -388,6 +368,12 @@ func (c Config) Validate() (err error) { } } + if err == nil { + if c.ExecPipe && (len(c.OutputFiles) > 0 && c.OutputFiles[0] != "-") { + err = fmt.Errorf("must not set 'outputFiles' when using 'execPipe'") + } + } + return err } @@ -449,10 +435,15 @@ func (c *Config) ApplyDefaults() { if c.ExecPipe { c.PostExecInput = &bytes.Buffer{} c.OutWriter = c.PostExecInput + c.OutputFiles = []string{"-"} } else { c.PostExecInput = os.Stdin c.OutWriter = os.Stdout } + + if c.PluginTimeout == 0 { + c.PluginTimeout = 5 * time.Second + } } // String - @@ -461,15 +452,87 @@ func (c *Config) String() string { out.WriteString("---\n") enc := yaml.NewEncoder(out) enc.SetIndent(2) - c2 := c - if c2.Input != "" { - c2.Input = "" + // dereferenced copy so we can truncate input for display + c2 := *c + if len(c2.Input) >= 11 { + c2.Input = c2.Input[0:8] + "..." } - err := enc.Encode(c) + err := enc.Encode(c2) if err != nil { return err.Error() } return out.String() } + +func parseSourceURL(value string) (*url.URL, error) { + if value == "-" { + value = "stdin://" + } + value = filepath.ToSlash(value) + // handle absolute Windows paths + volName := "" + if volName = filepath.VolumeName(value); volName != "" { + // handle UNCs + if len(volName) > 2 { + value = "file:" + value + } else { + value = "file:///" + value + } + } + srcURL, err := url.Parse(value) + if err != nil { + return nil, err + } + + if volName != "" { + if strings.HasPrefix(srcURL.Path, "/") && srcURL.Path[2] == ':' { + srcURL.Path = srcURL.Path[1:] + } + } + + if !srcURL.IsAbs() { + srcURL, err = absFileURL(value) + if err != nil { + return nil, err + } + } + return srcURL, nil +} + +func absFileURL(value string) (*url.URL, error) { + wd, err := os.Getwd() + if err != nil { + return nil, errors.Wrapf(err, "can't get working directory") + } + wd = filepath.ToSlash(wd) + baseURL := &url.URL{ + Scheme: "file", + Path: wd + "/", + } + relURL, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("can't parse value %s as URL: %w", value, err) + } + resolved := baseURL.ResolveReference(relURL) + // deal with Windows drive letters + if !strings.HasPrefix(wd, "/") && resolved.Path[2] == ':' { + resolved.Path = resolved.Path[1:] + } + return resolved, nil +} + +// GetMode - parse an os.FileMode out of the string, and let us know if it's an override or not... +func (c *Config) GetMode() (os.FileMode, bool, error) { + modeOverride := c.OutMode != "" + m, err := strconv.ParseUint("0"+c.OutMode, 8, 32) + if err != nil { + return 0, false, err + } + mode := os.FileMode(m) + if mode == 0 && c.Input != "" { + mode = 0644 + } + return mode, modeOverride, nil +} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go index fc4cb3f39..b5f3104a2 100644 --- a/internal/config/configfile_test.go +++ b/internal/config/configfile_test.go @@ -2,56 +2,15 @@ package config import ( "net/http" - "sort" + "net/url" + "os" "strings" "testing" + "time" - "github.com/hairyhenderson/gomplate/v3" "github.com/stretchr/testify/assert" ) -func TestDSourcesPairFuncs(t *testing.T) { - t.Parallel() - ds := dsources{} - assert.Empty(t, ds.keyURLPairs()) - - ds = dsources{ - "foo": dsConfig{ - URL: "foo.json", - Header: map[string][]string{ - "Accept": {"application/json"}, - "Authorization": {"foo"}, - "X-Foo": {"bar"}, - }}, - "bar": dsConfig{URL: "stdin://in.yaml"}, - } - pairs := ds.keyURLPairs() - 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 TestKVPairs(t *testing.T) { - t.Parallel() - k := kv{} - assert.Empty(t, k.keyValuePairs()) - - k = kv{ - "foo": "bar", - "baz": "qux", - } - pairs := k.keyValuePairs() - assert.Len(t, pairs, 2) - assert.Contains(t, pairs, "foo=bar") - assert.Contains(t, pairs, "baz=qux") -} - func TestParseConfigFile(t *testing.T) { t.Parallel() in := "in: hello world\n" @@ -77,95 +36,50 @@ datasources: context: .: url: file:///data.json + +pluginTimeout: 2s ` expected = &Config{ Input: "hello world", OutputFiles: []string{"out.txt"}, - DataSources: map[string]dsConfig{ + DataSources: map[string]DSConfig{ "data": { - URL: "file:///data.json", + URL: mustURL("file:///data.json"), }, "moredata": { - URL: "https://example.com/more.json", + URL: mustURL("https://example.com/more.json"), Header: map[string][]string{ "Authorization": {"Bearer abcd1234"}, }, }, }, - Context: map[string]dsConfig{ + Context: map[string]DSConfig{ ".": { - URL: "file:///data.json", + URL: mustURL("file:///data.json"), }, }, - OutMode: "644", + OutMode: "644", + PluginTimeout: 2 * time.Second, } cf, err = Parse(strings.NewReader(in)) assert.NoError(t, err) - assert.Equal(t, expected, cf) + assert.EqualValues(t, expected, cf) } -func TestToConfig(t *testing.T) { - t.Parallel() - cf := &Config{ - Input: "hello world", - DataSources: map[string]dsConfig{ - "data": { - URL: "file:///data.json", - }, - "moredata": { - URL: "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]dsConfig{ - ".": { - URL: "file:///data.json", - }, - "foo": { - URL: "https://example.com/foo.yaml", - Header: map[string][]string{ - "Accept": {"application/yaml"}, - }, - }, - }, - OutMode: "644", +func mustURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) } - - 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", - }, + // handle the case where it's a relative URL - just like in parseSourceURL. + if !u.IsAbs() { + u, err = absFileURL(s) + if err != nil { + panic(err) + } } - - conf := cf.ToGConfig() - // sort these so the comparison is deterministic - sort.Strings(conf.Contexts) - sort.Strings(conf.DataSources) - sort.Strings(conf.DataSourceHeaders) - - assert.EqualValues(t, expected, conf) + return u } func TestValidate(t *testing.T) { @@ -260,20 +174,20 @@ func TestMergeFrom(t *testing.T) { t.Parallel() cfg := &Config{ Input: "hello world", - DataSources: map[string]dsConfig{ + DataSources: map[string]DSConfig{ "data": { - URL: "file:///data.json", + URL: mustURL("file:///data.json"), }, "moredata": { - URL: "https://example.com/more.json", + URL: mustURL("https://example.com/more.json"), Header: http.Header{ "Authorization": {"Bearer abcd1234"}, }, }, }, - Context: map[string]dsConfig{ + Context: map[string]DSConfig{ "foo": { - URL: "https://example.com/foo.yaml", + URL: mustURL("https://example.com/foo.yaml"), Header: http.Header{ "Accept": {"application/yaml"}, }, @@ -283,47 +197,47 @@ func TestMergeFrom(t *testing.T) { } other := &Config{ OutputFiles: []string{"out.txt"}, - DataSources: map[string]dsConfig{ + DataSources: map[string]DSConfig{ "data": { Header: http.Header{ "Accept": {"foo/bar"}, }, }, }, - Context: map[string]dsConfig{ + Context: map[string]DSConfig{ "foo": { Header: http.Header{ "Accept": {"application/json"}, }, }, - "bar": {URL: "stdin:///"}, + "bar": {URL: mustURL("stdin:///")}, }, } expected := &Config{ Input: "hello world", OutputFiles: []string{"out.txt"}, - DataSources: map[string]dsConfig{ + DataSources: map[string]DSConfig{ "data": { - URL: "file:///data.json", + URL: mustURL("file:///data.json"), Header: http.Header{ "Accept": {"foo/bar"}, }, }, "moredata": { - URL: "https://example.com/more.json", + URL: mustURL("https://example.com/more.json"), Header: http.Header{ "Authorization": {"Bearer abcd1234"}, }, }, }, - Context: map[string]dsConfig{ + Context: map[string]DSConfig{ "foo": { - URL: "https://example.com/foo.yaml", + URL: mustURL("https://example.com/foo.yaml"), Header: http.Header{ "Accept": {"application/json"}, }, }, - "bar": {URL: "stdin:///"}, + "bar": {URL: mustURL("stdin:///")}, }, OutMode: "644", } @@ -375,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) { @@ -391,11 +331,12 @@ func TestParseDataSourceFlags(t *testing.T) { cfg = &Config{} err = cfg.ParseDataSourceFlags([]string{"baz=foo/bar/baz.json"}, nil, nil) assert.NoError(t, err) - assert.EqualValues(t, &Config{ - DataSources: dsources{ - "baz": {URL: "foo/bar/baz.json"}, + expected := &Config{ + DataSources: DSources{ + "baz": {URL: mustURL("foo/bar/baz.json")}, }, - }, cfg) + } + assert.EqualValues(t, expected, cfg, "expected: %+v\nactual: %+v\n", expected, cfg) cfg = &Config{} err = cfg.ParseDataSourceFlags( @@ -404,9 +345,9 @@ func TestParseDataSourceFlags(t *testing.T) { []string{"baz=Accept: application/json"}) assert.NoError(t, err) assert.EqualValues(t, &Config{ - DataSources: dsources{ + DataSources: DSources{ "baz": { - URL: "foo/bar/baz.json", + URL: mustURL("foo/bar/baz.json"), Header: http.Header{ "Accept": {"application/json"}, }, @@ -422,12 +363,12 @@ func TestParseDataSourceFlags(t *testing.T) { "bar=Authorization: Basic xxxxx"}) assert.NoError(t, err) assert.EqualValues(t, &Config{ - DataSources: dsources{ - "baz": {URL: "foo/bar/baz.json"}, + DataSources: DSources{ + "baz": {URL: mustURL("foo/bar/baz.json")}, }, - Context: dsources{ + Context: DSources{ "foo": { - URL: "http://example.com", + URL: mustURL("http://example.com"), Header: http.Header{ "Accept": {"application/json"}, }, @@ -448,7 +389,7 @@ func TestParsePluginFlags(t *testing.T) { cfg = &Config{} err = cfg.ParsePluginFlags([]string{"foo=bar"}) assert.NoError(t, err) - assert.EqualValues(t, &Config{Plugins: kv{"foo": "bar"}}, cfg) + assert.EqualValues(t, &Config{Plugins: map[string]string{"foo": "bar"}}, cfg) } func TestConfigString(t *testing.T) { @@ -460,6 +401,7 @@ inputFiles: ['-'] outputFiles: ['-'] leftDelim: '{{' rightDelim: '}}' +pluginTimeout: 5s ` assert.Equal(t, expected, c.String()) @@ -471,7 +413,7 @@ rightDelim: '}}' Templates: []string{"foo=foo.t", "bar=bar.t"}, } expected = `--- -in: +in: foo outputFiles: ['-'] leftDelim: L rightDelim: R @@ -479,7 +421,24 @@ templates: - foo=foo.t - bar=bar.t ` + assert.Equal(t, expected, c.String()) + c = &Config{ + LDelim: "L", + RDelim: "R", + Input: "long input that should be truncated", + OutputFiles: []string{"-"}, + Templates: []string{"foo=foo.t", "bar=bar.t"}, + } + expected = `--- +in: long inp... +outputFiles: ['-'] +leftDelim: L +rightDelim: R +templates: +- foo=foo.t +- bar=bar.t +` assert.Equal(t, expected, c.String()) c = &Config{ @@ -500,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()) @@ -547,7 +515,7 @@ func TestApplyDefaults(t *testing.T) { cfg.ApplyDefaults() assert.Empty(t, cfg.InputFiles) - assert.Empty(t, cfg.OutputFiles) + assert.EqualValues(t, []string{"-"}, cfg.OutputFiles) assert.Empty(t, cfg.OutputDir) assert.True(t, cfg.ExecPipe) @@ -564,3 +532,27 @@ func TestApplyDefaults(t *testing.T) { assert.False(t, cfg.ExecPipe) assert.Equal(t, "bar", cfg.OutputMap) } + +func TestGetMode(t *testing.T) { + c := &Config{} + m, o, err := c.GetMode() + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0), m) + assert.False(t, o) + + c = &Config{OutMode: "755"} + m, o, err = c.GetMode() + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0755), m) + assert.True(t, o) + + c = &Config{OutMode: "0755"} + m, o, err = c.GetMode() + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0755), m) + assert.True(t, o) + + c = &Config{OutMode: "foo"} + _, _, err = c.GetMode() + assert.Error(t, err) +} 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 4ec3335e1..b4d561884 100644 --- a/plugins.go +++ b/plugins.go @@ -3,27 +3,26 @@ package gomplate import ( "bytes" "context" - "errors" "fmt" "os" "os/exec" "os/signal" "path/filepath" "runtime" - "strings" "text/template" "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 []string, funcMap template.FuncMap) error { - for _, p := range plugins { - plugin, err := newPlugin(ctx, p) - if err != nil { - return err +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, + timeout: cfg.PluginTimeout, } if _, ok := funcMap[plugin.name]; ok { return fmt.Errorf("function %q is already bound, and can not be overridden", plugin.name) @@ -36,34 +35,10 @@ func bindPlugins(ctx context.Context, plugins []string, funcMap template.FuncMap // 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 newPlugin(ctx context.Context, value string) (*plugin, error) { - parts := strings.SplitN(value, "=", 2) - if len(parts) < 2 { - return nil, errors.New("plugin requires both name and path") - } - - p := &plugin{ - ctx: ctx, - name: parts[0], - path: parts[1], - } - return p, nil -} - -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) { @@ -95,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 @@ -123,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 07e232598..d1292eaa5 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -7,54 +7,48 @@ import ( "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" -) - -func TestNewPlugin(t *testing.T) { - ctx := context.TODO() - in := "foo" - _, err := newPlugin(ctx, in) - assert.ErrorContains(t, err, "") - in = "foo=/bin/bar" - out, err := newPlugin(ctx, in) - assert.NilError(t, err) - assert.Equal(t, "foo", out.name) - assert.Equal(t, "/bin/bar", out.path) -} + "github.com/hairyhenderson/gomplate/v3/internal/config" +) func TestBindPlugins(t *testing.T) { ctx := context.TODO() fm := template.FuncMap{} - in := []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 = []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") } func TestBuildCommand(t *testing.T) { ctx := context.TODO() data := []struct { - plugin string - args []string - expected []string + name, path string + args []string + expected []string }{ - {"foo=foo", nil, []string{"foo"}}, - {"foo=foo", []string{"bar"}, []string{"foo", "bar"}}, - {"foo=foo.bat", nil, []string{"cmd.exe", "/c", "foo.bat"}}, - {"foo=foo.cmd", []string{"bar"}, []string{"cmd.exe", "/c", "foo.cmd", "bar"}}, - {"foo=foo.ps1", []string{"bar", "baz"}, []string{"pwsh", "-File", "foo.ps1", "bar", "baz"}}, + {"foo", "foo", nil, []string{"foo"}}, + {"foo", "foo", []string{"bar"}, []string{"foo", "bar"}}, + {"foo", "foo.bat", nil, []string{"cmd.exe", "/c", "foo.bat"}}, + {"foo", "foo.cmd", []string{"bar"}, []string{"cmd.exe", "/c", "foo.cmd", "bar"}}, + {"foo", "foo.ps1", []string{"bar", "baz"}, []string{"pwsh", "-File", "foo.ps1", "bar", "baz"}}, } for _, d := range data { - p, err := newPlugin(ctx, d.plugin) - assert.NilError(t, err) + p := &plugin{ + ctx: ctx, + name: d.name, + path: d.path, + } name, args := p.buildCommand(d.args) actual := append([]string{name}, args...) assert.DeepEqual(t, d.expected, actual) diff --git a/template.go b/template.go index 775b75cd4..138625fdb 100644 --- a/template.go +++ b/template.go @@ -9,10 +9,9 @@ import ( "path/filepath" "text/template" + "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" @@ -84,66 +83,65 @@ 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 } // gatherTemplates - gather and prepare input template(s) and output file(s) for rendering // nolint: gocyclo -func gatherTemplates(o *Config, outFileNamer func(string) (string, error)) (templates []*tplate, err error) { - o.defaults() - mode, modeOverride, err := o.getMode() +func gatherTemplates(cfg *config.Config, outFileNamer func(string) (string, error)) (templates []*tplate, err error) { + mode, modeOverride, err := cfg.GetMode() if err != nil { return nil, err } // --exec-pipe redirects standard out to the out pipe - if o.Out != nil { - Stdout = &nopWCloser{o.Out} + if cfg.OutWriter != nil { + Stdout = &nopWCloser{cfg.OutWriter} } switch { // the arg-provided input string gets a special name - case o.Input != "": + case cfg.Input != "": templates = []*tplate{{ name: "", - contents: o.Input, + contents: cfg.Input, mode: mode, modeOverride: modeOverride, - targetPath: o.OutputFiles[0], + targetPath: cfg.OutputFiles[0], }} - case o.InputDir != "": + case cfg.InputDir != "": // input dirs presume output dirs are set too - templates, err = walkDir(o.InputDir, outFileNamer, o.ExcludeGlob, mode, modeOverride) + templates, err = walkDir(cfg.InputDir, outFileNamer, cfg.ExcludeGlob, mode, modeOverride) if err != nil { return nil, err } - case o.Input == "": - templates = make([]*tplate, len(o.InputFiles)) - for i := range o.InputFiles { - templates[i], err = fileToTemplates(o.InputFiles[i], o.OutputFiles[i], mode, modeOverride) + case cfg.Input == "": + templates = make([]*tplate, len(cfg.InputFiles)) + for i := range cfg.InputFiles { + templates[i], err = fileToTemplates(cfg.InputFiles[i], cfg.OutputFiles[i], mode, modeOverride) if err != nil { return nil, err } } } - 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 490917761..5f10530dc 100644 --- a/template_test.go +++ b/template_test.go @@ -7,6 +7,7 @@ import ( "os" "testing" + "github.com/hairyhenderson/gomplate/v3/internal/config" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -44,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) @@ -53,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) } @@ -76,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) } @@ -92,19 +95,23 @@ func TestGatherTemplates(t *testing.T) { afero.WriteFile(fs, "in/2", []byte("bar"), 0644) afero.WriteFile(fs, "in/3", []byte("baz"), 0644) - templates, err := gatherTemplates(&Config{}, nil) + cfg := &config.Config{} + cfg.ApplyDefaults() + templates, err := gatherTemplates(cfg, nil) assert.NoError(t, err) assert.Len(t, templates, 1) - templates, err = gatherTemplates(&Config{ + cfg = &config.Config{ Input: "foo", - }, nil) + } + cfg.ApplyDefaults() + templates, err = gatherTemplates(cfg, nil) assert.NoError(t, err) assert.Len(t, templates, 1) assert.Equal(t, "foo", templates[0].contents) assert.Equal(t, Stdout, templates[0].target) - templates, err = gatherTemplates(&Config{ + templates, err = gatherTemplates(&config.Config{ Input: "foo", OutputFiles: []string{"out"}, }, nil) @@ -117,7 +124,7 @@ func TestGatherTemplates(t *testing.T) { assert.Equal(t, os.FileMode(0644), info.Mode()) fs.Remove("out") - templates, err = gatherTemplates(&Config{ + templates, err = gatherTemplates(&config.Config{ InputFiles: []string{"foo"}, OutputFiles: []string{"out"}, }, nil) @@ -131,7 +138,7 @@ func TestGatherTemplates(t *testing.T) { assert.Equal(t, os.FileMode(0600), info.Mode()) fs.Remove("out") - templates, err = gatherTemplates(&Config{ + templates, err = gatherTemplates(&config.Config{ InputFiles: []string{"foo"}, OutputFiles: []string{"out"}, OutMode: "755", @@ -146,7 +153,7 @@ func TestGatherTemplates(t *testing.T) { assert.Equal(t, os.FileMode(0755), info.Mode()) fs.Remove("out") - templates, err = gatherTemplates(&Config{ + templates, err = gatherTemplates(&config.Config{ InputDir: "in", OutputDir: "out", }, simpleNamer("out")) @@ -168,6 +175,7 @@ func TestProcessTemplates(t *testing.T) { afero.WriteFile(fs, "existing", []byte(""), 0644) + cfg := &config.Config{} testdata := []struct { templates []*tplate contents []string @@ -221,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..bb385e6c0 100644 --- a/tests/integration/config_test.go +++ b/tests/integration/config_test.go @@ -5,8 +5,10 @@ package integration import ( "bytes" "io/ioutil" + "os" + "runtime" - . "gopkg.in/check.v1" + "gopkg.in/check.v1" "gotest.tools/v3/assert" "gotest.tools/v3/fs" @@ -17,9 +19,9 @@ type ConfigSuite struct { tmpDir *fs.Dir } -var _ = Suite(&ConfigSuite{}) +var _ = check.Suite(&ConfigSuite{}) -func (s *ConfigSuite) SetUpTest(c *C) { +func (s *ConfigSuite) SetUpTest(c *check.C) { s.tmpDir = fs.NewDir(c, "gomplate-inttests", fs.WithDir("indir"), fs.WithDir("outdir"), @@ -40,36 +42,44 @@ func (s *ConfigSuite) writeConfig(content string) { s.writeFile(".gomplate.yaml", content) } -func (s *ConfigSuite) TearDownTest(c *C) { +func (s *ConfigSuite) TearDownTest(c *check.C) { s.tmpDir.Remove() } -func (s *ConfigSuite) TestReadsFromConfigFile(c *C) { +func (s *ConfigSuite) TestReadsFromSimpleConfigFile(c *check.C) { result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) +} +func (s *ConfigSuite) TestReadsStdin(c *check.C) { s.writeConfig("inputFiles: [-]") - result = icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() cmd.Stdin = bytes.NewBufferString("foo bar") }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "foo bar"}) +} +func (s *ConfigSuite) TestFlagOverridesConfig(c *check.C) { s.writeConfig("inputFiles: [in]") - result = icmd.RunCmd(icmd.Command(GomplateBin, "-i", "hello from the cli"), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin, "-i", "hello from the cli"), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello from the cli"}) +} +func (s *ConfigSuite) TestReadsFromInputFile(c *check.C) { s.writeConfig("inputFiles: [in]") s.writeFile("in", "blah blah") - result = icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "blah blah"}) +} +func (s *ConfigSuite) TestDatasource(c *check.C) { s.writeConfig(`inputFiles: [in] datasources: data: @@ -77,11 +87,13 @@ datasources: `) s.writeFile("in", `{{ (ds "data").value }}`) s.writeFile("in.yaml", `value: hello world`) - result = icmd.RunCmd(icmd.Command(GomplateBin, "--verbose"), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) +} +func (s *ConfigSuite) TestOutputDir(c *check.C) { s.writeConfig(`inputDir: indir/ outputDir: outdir/ datasources: @@ -90,86 +102,126 @@ datasources: `) s.writeFile("indir/file", `{{ (ds "data").value }}`) s.writeFile("in.yaml", `value: hello world`) - result = icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0}) b, err := ioutil.ReadFile(s.tmpDir.Join("outdir", "file")) assert.NilError(c, err) assert.Equal(c, "hello world", string(b)) +} +func (s *ConfigSuite) TestExecPipeOverridesConfigFile(c *check.C) { // make sure exec-pipe works, and outFiles is replaced s.writeConfig(`in: hello world outputFiles: ['-'] `) - result = icmd.RunCmd(icmd.Command(GomplateBin, "-i", "hi", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin, "-i", "hi", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "HI"}) +} +func (s *ConfigSuite) TestOutFile(c *check.C) { s.writeConfig(`in: hello world outputFiles: [out] `) - result = icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0}) - b, err = ioutil.ReadFile(s.tmpDir.Join("out")) + b, err := ioutil.ReadFile(s.tmpDir.Join("out")) assert.NilError(c, err) assert.Equal(c, "hello world", string(b)) +} +func (s *ConfigSuite) TestAlternateConfigFile(c *check.C) { s.writeFile("config.yaml", `in: this is from an alternate config `) - result = icmd.RunCmd(icmd.Command(GomplateBin, "--config=config.yaml"), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin, "--config=config.yaml"), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "this is from an alternate config"}) +} +func (s *ConfigSuite) TestEnvConfigFile(c *check.C) { s.writeFile("envconfig.yaml", `in: yet another alternate config `) - result = icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { cmd.Dir = s.tmpDir.Path() cmd.Env = []string{"GOMPLATE_CONFIG=./envconfig.yaml"} }) result.Assert(c, icmd.Expected{ExitCode: 0, Out: "yet another alternate config"}) +} - s.writeConfig(`inputFiles: [in] -leftDelim: (╯°□°)╯︵ ┻━┻ -datasources: - data: - url: in.yaml -`) - s.writeFile("in", `(╯°□°)╯︵ ┻━┻ (ds "data").value }}`) - s.writeFile("in.yaml", `value: hello world`) - result = icmd.RunCmd(icmd.Command(GomplateBin, "--verbose"), func(cmd *icmd.Cmd) { - cmd.Dir = s.tmpDir.Path() - cmd.Env = []string{"GOMPLATE_LEFT_DELIM", "<<"} - }) - result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) +func (s *ConfigSuite) TestConfigOverridesEnvDelim(c *check.C) { + if runtime.GOOS != "windows" { + s.writeConfig(`inputFiles: [in] + leftDelim: (╯°□°)╯︵ ┻━┻ + datasources: + data: + url: in.yaml + `) + s.writeFile("in", `(╯°□°)╯︵ ┻━┻ (ds "data").value }}`) + s.writeFile("in.yaml", `value: hello world`) + result := icmd.RunCmd(icmd.Command(GomplateBin), func(cmd *icmd.Cmd) { + cmd.Dir = s.tmpDir.Path() + cmd.Env = []string{"GOMPLATE_LEFT_DELIM", "<<"} + }) + result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) + } +} - s.writeConfig(`inputFiles: [in] -leftDelim: (╯°□°)╯︵ ┻━┻ -datasources: - data: - url: in.yaml -`) - s.writeFile("in", `{{ (ds "data").value }}`) - s.writeFile("in.yaml", `value: hello world`) - result = icmd.RunCmd(icmd.Command(GomplateBin, "--left-delim={{"), func(cmd *icmd.Cmd) { - cmd.Dir = s.tmpDir.Path() - cmd.Env = []string{"GOMPLATE_LEFT_DELIM", "<<"} - }) - result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) +func (s *ConfigSuite) TestFlagOverridesAllDelim(c *check.C) { + if runtime.GOOS != "windows" { + s.writeConfig(`inputFiles: [in] + leftDelim: (╯°□°)╯︵ ┻━┻ + datasources: + data: + url: in.yaml + `) + s.writeFile("in", `{{ (ds "data").value }}`) + s.writeFile("in.yaml", `value: hello world`) + result := icmd.RunCmd(icmd.Command(GomplateBin, "--left-delim={{"), func(cmd *icmd.Cmd) { + cmd.Dir = s.tmpDir.Path() + cmd.Env = []string{"GOMPLATE_LEFT_DELIM", "<<"} + }) + result.Assert(c, icmd.Expected{ExitCode: 0, Out: "hello world"}) + } +} - s.writeConfig(`in: {{ sleep 2 }} +func (s *ConfigSuite) TestConfigOverridesEnvPluginTimeout(c *check.C) { + if runtime.GOOS != "windows" { + 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"} + result := icmd.RunCmd(icmd.Command(GomplateBin, + "--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"}) + } +} + +func (s *ConfigSuite) TestConfigOverridesEnvSuppressEmpty(c *check.C) { + s.writeConfig(`in: | + {{- print "\t \n\n\r\n\t\t \v\n" -}} + + {{ print " " -}} +out: ./missing +suppressEmpty: true +`) + result := icmd.RunCmd(icmd.Command(GomplateBin), 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: 1, Err: "plugin timed out"}) + result.Assert(c, icmd.Expected{ExitCode: 0}) + _, err := os.Stat(s.tmpDir.Join("missing")) + assert.Equal(c, true, os.IsNotExist(err)) }