diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cmd/config.go | 46 | ||||
| -rw-r--r-- | internal/cmd/config_test.go | 97 | ||||
| -rw-r--r-- | internal/cmd/main.go | 12 | ||||
| -rw-r--r-- | internal/config/configfile.go | 602 | ||||
| -rw-r--r-- | internal/config/configfile_test.go | 873 | ||||
| -rw-r--r-- | internal/config/types.go | 60 | ||||
| -rw-r--r-- | internal/config/types_test.go | 91 | ||||
| -rw-r--r-- | internal/datafs/context.go | 5 |
8 files changed, 156 insertions, 1630 deletions
diff --git a/internal/cmd/config.go b/internal/cmd/config.go index fc64f8e4..431139a1 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -1,14 +1,17 @@ package cmd import ( + "bytes" "context" "fmt" + "io" "log/slog" + "os" "time" + "github.com/hairyhenderson/gomplate/v4" "github.com/hairyhenderson/gomplate/v4/conv" "github.com/hairyhenderson/gomplate/v4/env" - "github.com/hairyhenderson/gomplate/v4/internal/config" "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/spf13/cobra" @@ -19,10 +22,10 @@ const ( ) // loadConfig is intended to be called before command execution. It: -// - creates a config.Config from the cobra flags -// - creates a config.Config from the config file (if present) +// - creates a gomplate.Config from the cobra flags +// - creates a gomplate.Config from the config file (if present) // - merges the two (flags take precedence) -func loadConfig(ctx context.Context, cmd *cobra.Command, args []string) (*config.Config, error) { +func loadConfig(ctx context.Context, cmd *cobra.Command, args []string) (*gomplate.Config, error) { flagConfig, err := cobraConfig(cmd, args) if err != nil { return nil, err @@ -64,7 +67,7 @@ func pickConfigFile(cmd *cobra.Command) (cfgFile string, required bool) { return cfgFile, required } -func readConfigFile(ctx context.Context, cmd *cobra.Command) (cfg *config.Config, err error) { +func readConfigFile(ctx context.Context, cmd *cobra.Command) (*gomplate.Config, error) { cfgFile, configRequired := pickConfigFile(cmd) // we only support loading configs from the local filesystem for now @@ -76,14 +79,14 @@ func readConfigFile(ctx context.Context, cmd *cobra.Command) (cfg *config.Config f, err := fsys.Open(cfgFile) if err != nil { if configRequired { - return cfg, fmt.Errorf("config file requested, but couldn't be opened: %w", err) + return nil, fmt.Errorf("config file requested, but couldn't be opened: %w", err) } return nil, nil } - cfg, err = config.Parse(f) + cfg, err := gomplate.Parse(f) if err != nil { - return cfg, fmt.Errorf("parsing config file %q: %w", cfgFile, err) + return nil, fmt.Errorf("parsing config file %q: %w", cfgFile, err) } slog.DebugContext(ctx, "using config file", "cfgFile", cfgFile) @@ -92,8 +95,8 @@ func readConfigFile(ctx context.Context, cmd *cobra.Command) (cfg *config.Config } // cobraConfig - initialize a config from the commandline options -func cobraConfig(cmd *cobra.Command, args []string) (cfg *config.Config, err error) { - cfg = &config.Config{} +func cobraConfig(cmd *cobra.Command, args []string) (cfg *gomplate.Config, err error) { + cfg = &gomplate.Config{} cfg.InputFiles, err = getStringSlice(cmd, "file") if err != nil { return nil, err @@ -241,7 +244,7 @@ func processIncludes(includes, excludes []string) []string { return out } -func applyEnvVars(_ context.Context, cfg *config.Config) (*config.Config, error) { +func applyEnvVars(_ context.Context, cfg *gomplate.Config) (*gomplate.Config, error) { if to := env.Getenv("GOMPLATE_PLUGIN_TIMEOUT"); cfg.PluginTimeout == 0 && to != "" { t, err := time.ParseDuration(to) if err != nil { @@ -263,3 +266,24 @@ func applyEnvVars(_ context.Context, cfg *config.Config) (*config.Config, error) return cfg, nil } + +// postExecInput - return the input to be used after the post-exec command. The +// input config may be modified if ExecPipe is set (OutputFiles is set to "-"), +// and Stdout is redirected to a pipe. +func postExecInput(cfg *gomplate.Config) io.Reader { + if cfg.ExecPipe { + pipe := &bytes.Buffer{} + cfg.OutputFiles = []string{"-"} + + // --exec-pipe redirects standard out to the out pipe + cfg.Stdout = pipe + + return pipe + } + + if cfg.Stdin != nil { + return cfg.Stdin + } + + return os.Stdin +} diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go index 1c9adfe5..56d7b1d5 100644 --- a/internal/cmd/config_test.go +++ b/internal/cmd/config_test.go @@ -6,12 +6,13 @@ import ( "fmt" "io/fs" "net/url" + "os" "testing" "testing/fstest" "time" "github.com/hairyhenderson/go-fsimpl" - "github.com/hairyhenderson/gomplate/v4/internal/config" + "github.com/hairyhenderson/gomplate/v4" "github.com/hairyhenderson/gomplate/v4/internal/datafs" "github.com/spf13/cobra" @@ -47,7 +48,7 @@ func TestReadConfigFile(t *testing.T) { cfg, err := readConfigFile(ctx, cmd) require.NoError(t, err) - assert.EqualValues(t, &config.Config{}, cfg) + assert.EqualValues(t, &gomplate.Config{}, cfg) cmd.ParseFlags([]string{"--config", "config.yaml"}) @@ -55,7 +56,7 @@ func TestReadConfigFile(t *testing.T) { cfg, err = readConfigFile(ctx, cmd) require.NoError(t, err) - assert.EqualValues(t, &config.Config{Input: "hello world"}, cfg) + assert.EqualValues(t, &gomplate.Config{Input: "hello world"}, cfg) fsys["config.yaml"] = &fstest.MapFile{Data: []byte("in: hello world\nin: \n")} @@ -87,7 +88,7 @@ func TestLoadConfig(t *testing.T) { cmd.ParseFlags(nil) out, err := loadConfig(ctx, cmd, cmd.Flags().Args()) - expected := &config.Config{ + expected := &gomplate.Config{ Stdin: stdin, Stdout: stdout, Stderr: stderr, @@ -97,7 +98,7 @@ func TestLoadConfig(t *testing.T) { cmd.ParseFlags([]string{"--in", "foo"}) out, err = loadConfig(ctx, cmd, cmd.Flags().Args()) - expected = &config.Config{ + expected = &gomplate.Config{ Input: "foo", Stdin: stdin, Stdout: out.Stdout, @@ -108,19 +109,37 @@ func TestLoadConfig(t *testing.T) { cmd.ParseFlags([]string{"--in", "foo", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"}) out, err = loadConfig(ctx, cmd, cmd.Flags().Args()) - expected = &config.Config{ - Input: "foo", - ExecPipe: true, - PostExec: []string{"tr", "[a-z]", "[A-Z]"}, - PostExecInput: out.PostExecInput, - Stdin: stdin, - Stdout: out.Stdout, - Stderr: stderr, + expected = &gomplate.Config{ + Input: "foo", + ExecPipe: true, + PostExec: []string{"tr", "[a-z]", "[A-Z]"}, + Stdin: stdin, + Stdout: out.Stdout, + Stderr: stderr, } require.NoError(t, err) assert.EqualValues(t, expected, out) } +func TestPostExecInput(t *testing.T) { + t.Parallel() + + cfg := &gomplate.Config{ExecPipe: false} + assert.Equal(t, os.Stdin, postExecInput(cfg)) + + cfg = &gomplate.Config{ExecPipe: true} + + pipe := postExecInput(cfg) + assert.IsType(t, &bytes.Buffer{}, pipe) + assert.Equal(t, []string{"-"}, cfg.OutputFiles) + assert.Equal(t, pipe, cfg.Stdout) + + stdin := &bytes.Buffer{} + cfg = &gomplate.Config{ExecPipe: false, Stdin: stdin} + pipe = postExecInput(cfg) + assert.Equal(t, stdin, pipe) +} + func TestCobraConfig(t *testing.T) { t.Parallel() cmd := &cobra.Command{} @@ -133,13 +152,13 @@ func TestCobraConfig(t *testing.T) { cfg, err := cobraConfig(cmd, cmd.Flags().Args()) require.NoError(t, err) - assert.EqualValues(t, &config.Config{}, cfg) + assert.EqualValues(t, &gomplate.Config{}, cfg) cmd.ParseFlags([]string{"--file", "in", "--", "echo", "foo"}) cfg, err = cobraConfig(cmd, cmd.Flags().Args()) require.NoError(t, err) - assert.EqualValues(t, &config.Config{ + assert.EqualValues(t, &gomplate.Config{ InputFiles: []string{"in"}, PostExec: []string{"echo", "foo"}, }, cfg) @@ -195,68 +214,68 @@ func TestPickConfigFile(t *testing.T) { func TestApplyEnvVars(t *testing.T) { t.Run("invalid GOMPLATE_PLUGIN_TIMEOUT", func(t *testing.T) { t.Setenv("GOMPLATE_PLUGIN_TIMEOUT", "bogus") - _, err := applyEnvVars(context.Background(), &config.Config{}) + _, err := applyEnvVars(context.Background(), &gomplate.Config{}) require.Error(t, err) }) data := []struct { - input, expected *config.Config + input, expected *gomplate.Config env string value string }{ { - &config.Config{PluginTimeout: 2 * time.Second}, - &config.Config{PluginTimeout: 2 * time.Second}, + &gomplate.Config{PluginTimeout: 2 * time.Second}, + &gomplate.Config{PluginTimeout: 2 * time.Second}, "GOMPLATE_PLUGIN_TIMEOUT", "bogus", }, { - &config.Config{}, - &config.Config{PluginTimeout: 2 * time.Second}, + &gomplate.Config{}, + &gomplate.Config{PluginTimeout: 2 * time.Second}, "GOMPLATE_PLUGIN_TIMEOUT", "2s", }, { - &config.Config{PluginTimeout: 100 * time.Millisecond}, - &config.Config{PluginTimeout: 100 * time.Millisecond}, + &gomplate.Config{PluginTimeout: 100 * time.Millisecond}, + &gomplate.Config{PluginTimeout: 100 * time.Millisecond}, "GOMPLATE_PLUGIN_TIMEOUT", "2s", }, { - &config.Config{}, - &config.Config{Experimental: false}, + &gomplate.Config{}, + &gomplate.Config{Experimental: false}, "GOMPLATE_EXPERIMENTAL", "bogus", }, { - &config.Config{}, - &config.Config{Experimental: true}, + &gomplate.Config{}, + &gomplate.Config{Experimental: true}, "GOMPLATE_EXPERIMENTAL", "true", }, { - &config.Config{Experimental: true}, - &config.Config{Experimental: true}, + &gomplate.Config{Experimental: true}, + &gomplate.Config{Experimental: true}, "GOMPLATE_EXPERIMENTAL", "false", }, { - &config.Config{}, - &config.Config{LDelim: "--"}, + &gomplate.Config{}, + &gomplate.Config{LDelim: "--"}, "GOMPLATE_LEFT_DELIM", "--", }, { - &config.Config{LDelim: "{{"}, - &config.Config{LDelim: "{{"}, + &gomplate.Config{LDelim: "{{"}, + &gomplate.Config{LDelim: "{{"}, "GOMPLATE_LEFT_DELIM", "--", }, { - &config.Config{}, - &config.Config{RDelim: ")>"}, + &gomplate.Config{}, + &gomplate.Config{RDelim: ")>"}, "GOMPLATE_RIGHT_DELIM", ")>", }, { - &config.Config{RDelim: "}}"}, - &config.Config{RDelim: "}}"}, + &gomplate.Config{RDelim: "}}"}, + &gomplate.Config{RDelim: "}}"}, "GOMPLATE_RIGHT_DELIM", ")>", }, { - &config.Config{RDelim: "}}"}, - &config.Config{RDelim: "}}"}, + &gomplate.Config{RDelim: "}}"}, + &gomplate.Config{RDelim: "}}"}, "GOMPLATE_RIGHT_DELIM", "", }, } diff --git a/internal/cmd/main.go b/internal/cmd/main.go index 22388521..fa7c97c3 100644 --- a/internal/cmd/main.go +++ b/internal/cmd/main.go @@ -88,12 +88,8 @@ func NewGomplateCmd(stderr io.Writer) *cobra.Command { return err } - if cfg.Experimental { - slog.SetDefault(slog.With("experimental", true)) - slog.InfoContext(ctx, "experimental functions and features enabled!") - - ctx = gomplate.SetExperimental(ctx) - } + // get the post-exec reader now as this may modify cfg + postExecReader := postExecInput(cfg) slog.DebugContext(ctx, fmt.Sprintf("starting %s", cmd.Name())) slog.DebugContext(ctx, fmt.Sprintf("config is:\n%v", cfg), @@ -101,6 +97,7 @@ func NewGomplateCmd(stderr io.Writer) *cobra.Command { slog.String("build", version.GitCommit), ) + // run the main command err = gomplate.Run(ctx, cfg) cmd.SilenceErrors = true cmd.SilenceUsage = true @@ -113,7 +110,8 @@ func NewGomplateCmd(stderr io.Writer) *cobra.Command { if err != nil { return err } - return postRunExec(ctx, cfg.PostExec, cfg.PostExecInput, cmd.OutOrStdout(), cmd.ErrOrStderr()) + + return postRunExec(ctx, cfg.PostExec, postExecReader, cmd.OutOrStdout(), cmd.ErrOrStderr()) }, Args: optionalExecArgs, } diff --git a/internal/config/configfile.go b/internal/config/configfile.go deleted file mode 100644 index d255a36c..00000000 --- a/internal/config/configfile.go +++ /dev/null @@ -1,602 +0,0 @@ -package config - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strconv" - "strings" - "time" - - "golang.org/x/exp/slices" - - "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" - "github.com/hairyhenderson/gomplate/v4/internal/urlhelpers" - "github.com/hairyhenderson/yaml" -) - -// Parse a config file -func Parse(in io.Reader) (*Config, error) { - out := &Config{} - dec := yaml.NewDecoder(in) - err := dec.Decode(out) - if err != nil && err != io.EOF { - return out, fmt.Errorf("YAML decoding failed, syntax may be invalid: %w", err) - } - return out, nil -} - -// Config - configures the gomplate execution -type Config struct { - Stdin io.Reader `yaml:"-"` - Stdout io.Writer `yaml:"-"` - Stderr io.Writer `yaml:"-"` - - DataSources map[string]DataSource `yaml:"datasources,omitempty"` - Context map[string]DataSource `yaml:"context,omitempty"` - Plugins map[string]PluginConfig `yaml:"plugins,omitempty"` - Templates Templates `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:"-"` - - // internal use only, can't be injected in YAML - PostExecInput io.Reader `yaml:"-"` - - Input string `yaml:"in,omitempty"` - InputDir string `yaml:"inputDir,omitempty"` - InputFiles []string `yaml:"inputFiles,omitempty,flow"` - ExcludeGlob []string `yaml:"excludes,omitempty"` - ExcludeProcessingGlob []string `yaml:"excludeProcessing,omitempty"` - - OutputDir string `yaml:"outputDir,omitempty"` - OutputMap string `yaml:"outputMap,omitempty"` - OutputFiles []string `yaml:"outputFiles,omitempty,flow"` - OutMode string `yaml:"chmod,omitempty"` - - LDelim string `yaml:"leftDelim,omitempty"` - RDelim string `yaml:"rightDelim,omitempty"` - - MissingKey string `yaml:"missingKey,omitempty"` - - PostExec []string `yaml:"postExec,omitempty,flow"` - - PluginTimeout time.Duration `yaml:"pluginTimeout,omitempty"` - - ExecPipe bool `yaml:"execPipe,omitempty"` - Experimental bool `yaml:"experimental,omitempty"` -} - -type experimentalCtxKey struct{} - -func SetExperimental(ctx context.Context) context.Context { - return context.WithValue(ctx, experimentalCtxKey{}, true) -} - -func ExperimentalEnabled(ctx context.Context) bool { - v, ok := ctx.Value(experimentalCtxKey{}).(bool) - return ok && v -} - -// mergeDataSources - use d as defaults, and override with values from o -func mergeDataSources(d, o map[string]DataSource) map[string]DataSource { - for k, v := range o { - c, ok := d[k] - if ok { - d[k] = c.mergeFrom(v) - } else { - d[k] = v - } - } - return d -} - -// DataSource - datasource configuration -type DataSource struct { - URL *url.URL `yaml:"-"` - Header http.Header `yaml:"header,omitempty,flow"` -} - -// UnmarshalYAML - satisfy the yaml.Umarshaler interface - URLs aren't -// well supported, and anyway we need to do some extra parsing -func (d *DataSource) UnmarshalYAML(value *yaml.Node) error { - type raw struct { - Header http.Header - URL string - } - r := raw{} - err := value.Decode(&r) - if err != nil { - return err - } - u, err := urlhelpers.ParseSourceURL(r.URL) - if err != nil { - return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err) - } - *d = DataSource{ - URL: u, - Header: r.Header, - } - return nil -} - -// MarshalYAML - satisfy the yaml.Marshaler interface - URLs aren't -// well supported, and anyway we need to do some extra parsing -func (d DataSource) MarshalYAML() (interface{}, error) { - type raw struct { - Header http.Header - URL string - } - r := raw{ - URL: d.URL.String(), - Header: d.Header, - } - return r, nil -} - -// mergeFrom - use this as default, and override with values from o -func (d DataSource) mergeFrom(o DataSource) DataSource { - if o.URL != nil { - d.URL = o.URL - } - if d.Header == nil { - d.Header = o.Header - } else { - for k, v := range o.Header { - d.Header[k] = v - } - } - return d -} - -type PluginConfig struct { - Cmd string - Args []string `yaml:"args,omitempty"` - Timeout time.Duration `yaml:"timeout,omitempty"` - Pipe bool `yaml:"pipe,omitempty"` -} - -// UnmarshalYAML - satisfy the yaml.Umarshaler interface - plugin configs can -// either be a plain string (to specify only the name), or a map with a name, -// timeout, and pipe flag. -func (p *PluginConfig) UnmarshalYAML(value *yaml.Node) error { - if value.Kind == yaml.ScalarNode { - s := "" - err := value.Decode(&s) - if err != nil { - return err - } - - *p = PluginConfig{Cmd: s} - return nil - } - - if value.Kind != yaml.MappingNode { - return fmt.Errorf("plugin config must be a string or map") - } - - type raw struct { - Cmd string - Args []string - Timeout time.Duration - Pipe bool - } - r := raw{} - err := value.Decode(&r) - if err != nil { - return err - } - - *p = PluginConfig(r) - - return nil -} - -// MergeFrom - use this Config as the defaults, and override it with any -// non-zero values from the other Config -// -// Note that Input/InputDir/InputFiles will override each other, as well as -// OutputDir/OutputFiles. -func (c *Config) MergeFrom(o *Config) *Config { - switch { - case !isZero(o.Input): - c.Input = o.Input - c.InputDir = "" - c.InputFiles = nil - c.OutputDir = "" - case !isZero(o.InputDir): - c.Input = "" - c.InputDir = o.InputDir - c.InputFiles = nil - case !isZero(o.InputFiles): - if !(len(o.InputFiles) == 1 && o.InputFiles[0] == "-") { - c.Input = "" - c.InputFiles = o.InputFiles - c.InputDir = "" - c.OutputDir = "" - } - } - - if !isZero(o.OutputMap) { - c.OutputDir = "" - c.OutputFiles = nil - c.OutputMap = o.OutputMap - } - if !isZero(o.OutputDir) { - c.OutputDir = o.OutputDir - c.OutputFiles = nil - c.OutputMap = "" - } - if !isZero(o.OutputFiles) { - c.OutputDir = "" - c.OutputFiles = o.OutputFiles - c.OutputMap = "" - } - if !isZero(o.ExecPipe) { - c.ExecPipe = o.ExecPipe - c.PostExec = o.PostExec - c.OutputFiles = o.OutputFiles - } - if !isZero(o.ExcludeGlob) { - c.ExcludeGlob = o.ExcludeGlob - } - if !isZero(o.ExcludeProcessingGlob) { - c.ExcludeProcessingGlob = o.ExcludeProcessingGlob - } - if !isZero(o.OutMode) { - c.OutMode = o.OutMode - } - if !isZero(o.LDelim) { - c.LDelim = o.LDelim - } - if !isZero(o.RDelim) { - c.RDelim = o.RDelim - } - if c.Templates == nil { - c.Templates = o.Templates - } else { - c.Templates = mergeDataSources(c.Templates, o.Templates) - } - if c.DataSources == nil { - c.DataSources = o.DataSources - } else { - c.DataSources = mergeDataSources(c.DataSources, o.DataSources) - } - if c.Context == nil { - c.Context = o.Context - } else { - c.Context = mergeDataSources(c.Context, o.Context) - } - if len(o.Plugins) > 0 { - for k, v := range o.Plugins { - c.Plugins[k] = v - } - } - - return c -} - -// ParseDataSourceFlags - sets DataSources, Context, and Templates fields from -// the key=value format flags as provided at the command-line -// Unreferenced headers will be set in c.ExtraHeaders -func (c *Config) ParseDataSourceFlags(datasources, contexts, templates, headers []string) error { - err := c.parseResources(datasources, contexts, templates) - if err != nil { - return err - } - - hdrs, err := parseHeaderArgs(headers) - if err != nil { - return err - } - - for k, v := range hdrs { - if d, ok := c.Context[k]; ok { - d.Header = v - c.Context[k] = d - delete(hdrs, k) - } - if d, ok := c.DataSources[k]; ok { - d.Header = v - c.DataSources[k] = d - delete(hdrs, k) - } - if t, ok := c.Templates[k]; ok { - t.Header = v - c.Templates[k] = t - delete(hdrs, k) - } - } - if len(hdrs) > 0 { - c.ExtraHeaders = hdrs - } - return nil -} - -func (c *Config) parseResources(datasources, contexts, templates []string) error { - for _, d := range datasources { - k, ds, err := parseDatasourceArg(d) - if err != nil { - return err - } - if c.DataSources == nil { - c.DataSources = map[string]DataSource{} - } - c.DataSources[k] = ds - } - for _, d := range contexts { - k, ds, err := parseDatasourceArg(d) - if err != nil { - return err - } - if c.Context == nil { - c.Context = map[string]DataSource{} - } - c.Context[k] = ds - } - for _, t := range templates { - k, ds, err := parseTemplateArg(t) - if err != nil { - return err - } - if c.Templates == nil { - c.Templates = map[string]DataSource{} - } - c.Templates[k] = ds - } - - return nil -} - -// ParsePluginFlags - sets the Plugins field from the -// key=value format flags as provided at the command-line -func (c *Config) ParsePluginFlags(plugins []string) error { - for _, plugin := range plugins { - parts := strings.SplitN(plugin, "=", 2) - if len(parts) < 2 { - return fmt.Errorf("plugin requires both name and path") - } - if c.Plugins == nil { - c.Plugins = map[string]PluginConfig{} - } - c.Plugins[parts[0]] = PluginConfig{Cmd: parts[1]} - } - return nil -} - -func parseDatasourceArg(value string) (alias string, ds DataSource, err error) { - alias, u, _ := strings.Cut(value, "=") - if u == "" { - u = alias - alias, _, _ = strings.Cut(value, ".") - if path.Base(u) != u { - err = fmt.Errorf("invalid argument (%s): must provide an alias with files not in working directory", value) - return alias, ds, err - } - } - - ds.URL, err = urlhelpers.ParseSourceURL(u) - - return alias, ds, err -} - -func parseHeaderArgs(headerArgs []string) (map[string]http.Header, error) { - headers := make(map[string]http.Header) - for _, v := range headerArgs { - ds, name, value, err := splitHeaderArg(v) - if err != nil { - return nil, err - } - if _, ok := headers[ds]; !ok { - headers[ds] = make(http.Header) - } - headers[ds][name] = append(headers[ds][name], strings.TrimSpace(value)) - } - return headers, nil -} - -func splitHeaderArg(arg string) (datasourceAlias, name, value string, err error) { - parts := strings.SplitN(arg, "=", 2) - if len(parts) != 2 { - err = fmt.Errorf("invalid datasource-header option '%s'", arg) - return "", "", "", err - } - datasourceAlias = parts[0] - name, value, err = splitHeader(parts[1]) - return datasourceAlias, name, value, err -} - -func splitHeader(header string) (name, value string, err error) { - parts := strings.SplitN(header, ":", 2) - if len(parts) != 2 { - err = fmt.Errorf("invalid HTTP Header format '%s'", header) - return "", "", err - } - name = http.CanonicalHeaderKey(parts[0]) - value = parts[1] - return name, value, nil -} - -// Validate the Config -func (c Config) Validate() (err error) { - err = notTogether( - []string{"in", "inputFiles", "inputDir"}, - c.Input, c.InputFiles, c.InputDir) - if err == nil { - err = notTogether( - []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 { - err = mustTogether("outputDir", "inputDir", - c.OutputDir, c.InputDir) - } - - if err == nil { - err = mustTogether("outputMap", "inputDir", - c.OutputMap, c.InputDir) - } - - if err == nil { - f := len(c.InputFiles) - if f == 0 && c.Input != "" { - f = 1 - } - o := len(c.OutputFiles) - if f != o && !c.ExecPipe { - err = fmt.Errorf("must provide same number of 'outputFiles' (%d) as 'in' or 'inputFiles' (%d) options", o, f) - } - } - - if err == nil { - if c.ExecPipe && len(c.PostExec) == 0 { - err = fmt.Errorf("execPipe may only be used with a postExec command") - } - } - - if err == nil { - if c.ExecPipe && (len(c.OutputFiles) > 0 && c.OutputFiles[0] != "-") { - err = fmt.Errorf("must not set 'outputFiles' when using 'execPipe'") - } - } - - if err == nil { - missingKeyValues := []string{"", "error", "zero", "default", "invalid"} - if !slices.Contains(missingKeyValues, c.MissingKey) { - err = fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", c.MissingKey, strings.Join(missingKeyValues, ",")) - } - } - - return err -} - -func notTogether(names []string, values ...interface{}) error { - found := "" - for i, value := range values { - if isZero(value) { - continue - } - if found != "" { - return fmt.Errorf("only one of these options is supported at a time: '%s', '%s'", - found, names[i]) - } - found = names[i] - } - return nil -} - -func mustTogether(left, right string, lValue, rValue interface{}) error { - if !isZero(lValue) && isZero(rValue) { - return fmt.Errorf("these options must be set together: '%s', '%s'", - left, right) - } - - return nil -} - -func isZero(value interface{}) bool { - switch v := value.(type) { - case string: - return v == "" - case []string: - return len(v) == 0 - case bool: - return !v - default: - return false - } -} - -// ApplyDefaults - any defaults changed here should be added to cmd.InitFlags as -// well for proper help/usage display. -func (c *Config) ApplyDefaults() { - if c.Stdout == nil { - c.Stdout = os.Stdout - } - if c.Stderr == nil { - c.Stderr = os.Stderr - } - if c.Stdin == nil { - c.Stdin = os.Stdin - } - - if c.InputDir != "" && c.OutputDir == "" && c.OutputMap == "" { - c.OutputDir = "." - } - if c.Input == "" && c.InputDir == "" && len(c.InputFiles) == 0 { - c.InputFiles = []string{"-"} - } - if c.OutputDir == "" && c.OutputMap == "" && len(c.OutputFiles) == 0 && !c.ExecPipe { - c.OutputFiles = []string{"-"} - } - if c.LDelim == "" { - c.LDelim = "{{" - } - if c.RDelim == "" { - c.RDelim = "}}" - } - if c.MissingKey == "" { - c.MissingKey = "error" - } - - if c.ExecPipe { - pipe := &bytes.Buffer{} - c.PostExecInput = pipe - c.OutputFiles = []string{"-"} - - // --exec-pipe redirects standard out to the out pipe - c.Stdout = pipe - } else { - c.PostExecInput = c.Stdin - } - - if c.PluginTimeout == 0 { - c.PluginTimeout = 5 * time.Second - } -} - -// 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 := iohelpers.NormalizeFileMode(os.FileMode(m)) - if mode == 0 && c.Input != "" { - mode = iohelpers.NormalizeFileMode(0o644) - } - return mode, modeOverride, nil -} - -// String - -func (c *Config) String() string { - out := &strings.Builder{} - out.WriteString("---\n") - enc := yaml.NewEncoder(out) - enc.SetIndent(2) - - // 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(c2) - if err != nil { - return err.Error() - } - return out.String() -} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go deleted file mode 100644 index 3cd1b109..00000000 --- a/internal/config/configfile_test.go +++ /dev/null @@ -1,873 +0,0 @@ -package config - -import ( - "net/http" - "net/url" - "runtime" - "strings" - "testing" - "time" - - "github.com/hairyhenderson/gomplate/v4/internal/iohelpers" - "github.com/hairyhenderson/yaml" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseConfigFile(t *testing.T) { - t.Parallel() - in := "in: hello world\n" - expected := &Config{ - Input: "hello world", - } - cf, err := Parse(strings.NewReader(in)) - require.NoError(t, err) - assert.Equal(t, expected, cf) - - in = `in: hello world -outputFiles: [out.txt] -chmod: 644 - -datasources: - data: - url: file:///data.json - moredata: - url: https://example.com/more.json - header: - Authorization: ["Bearer abcd1234"] - -context: - .: - url: file:///data.json - -plugins: - foo: - cmd: echo - pipe: true - -templates: - foo: - url: file:///tmp/foo.t - -pluginTimeout: 2s -` - expected = &Config{ - Input: "hello world", - OutputFiles: []string{"out.txt"}, - DataSources: map[string]DataSource{ - "data": { - URL: mustURL("file:///data.json"), - }, - "moredata": { - URL: mustURL("https://example.com/more.json"), - Header: map[string][]string{ - "Authorization": {"Bearer abcd1234"}, - }, - }, - }, - Context: map[string]DataSource{ - ".": { - URL: mustURL("file:///data.json"), - }, - }, - OutMode: "644", - Plugins: map[string]PluginConfig{ - "foo": {Cmd: "echo", Pipe: true}, - }, - Templates: Templates{"foo": DataSource{URL: mustURL("file:///tmp/foo.t")}}, - PluginTimeout: 2 * time.Second, - } - - cf, err = Parse(strings.NewReader(in)) - require.NoError(t, err) - assert.EqualValues(t, expected, cf) -} - -func mustURL(s string) *url.URL { - u, err := url.Parse(s) - if err != nil { - panic(err) - } - - return u -} - -func TestValidate(t *testing.T) { - t.Parallel() - require.NoError(t, validateConfig("")) - - require.Error(t, validateConfig(`in: foo -inputFiles: [bar] -`)) - require.Error(t, validateConfig(`inputDir: foo -inputFiles: [bar] -`)) - require.Error(t, validateConfig(`inputDir: foo -in: bar -`)) - - require.Error(t, validateConfig(`outputDir: foo -outputFiles: [bar] -`)) - - require.Error(t, validateConfig(`in: foo -outputFiles: [bar, baz] -`)) - - require.Error(t, validateConfig(`inputFiles: [foo] -outputFiles: [bar, baz] -`)) - - require.Error(t, validateConfig(`outputDir: foo -outputFiles: [bar] -`)) - - require.Error(t, validateConfig(`outputDir: foo -`)) - - require.Error(t, validateConfig(`outputMap: foo -`)) - - require.Error(t, validateConfig(`outputMap: foo -outputFiles: [bar] -`)) - - require.Error(t, validateConfig(`inputDir: foo -outputDir: bar -outputMap: bar -`)) - - require.Error(t, validateConfig(`execPipe: true -`)) - require.Error(t, validateConfig(`execPipe: true -postExec: "" -`)) - - require.NoError(t, validateConfig(`execPipe: true -postExec: [echo, foo] -`)) - - require.Error(t, validateConfig(`execPipe: true -outputFiles: [foo] -postExec: [echo] -`)) - - require.NoError(t, validateConfig(`execPipe: true -inputFiles: ['-'] -postExec: [echo] -`)) - - require.Error(t, validateConfig(`inputDir: foo -execPipe: true -outputDir: foo -postExec: [echo] -`)) - - require.Error(t, validateConfig(`inputDir: foo -execPipe: true -outputMap: foo -postExec: [echo] -`)) -} - -func validateConfig(c string) error { - in := strings.NewReader(c) - cfg, err := Parse(in) - if err != nil { - return err - } - err = cfg.Validate() - return err -} - -func TestMergeFrom(t *testing.T) { - t.Parallel() - cfg := &Config{ - Input: "hello world", - DataSources: map[string]DataSource{ - "data": { - URL: mustURL("file:///data.json"), - }, - "moredata": { - URL: mustURL("https://example.com/more.json"), - Header: http.Header{ - "Authorization": {"Bearer abcd1234"}, - }, - }, - }, - Context: map[string]DataSource{ - "foo": { - URL: mustURL("https://example.com/foo.yaml"), - Header: http.Header{ - "Accept": {"application/yaml"}, - }, - }, - }, - OutMode: "644", - } - other := &Config{ - OutputFiles: []string{"out.txt"}, - DataSources: map[string]DataSource{ - "data": { - Header: http.Header{ - "Accept": {"foo/bar"}, - }, - }, - }, - Context: map[string]DataSource{ - "foo": { - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - "bar": {URL: mustURL("stdin:///")}, - }, - } - - expected := &Config{ - Input: "hello world", - OutputFiles: []string{"out.txt"}, - DataSources: map[string]DataSource{ - "data": { - URL: mustURL("file:///data.json"), - Header: http.Header{ - "Accept": {"foo/bar"}, - }, - }, - "moredata": { - URL: mustURL("https://example.com/more.json"), - Header: http.Header{ - "Authorization": {"Bearer abcd1234"}, - }, - }, - }, - Context: map[string]DataSource{ - "foo": { - URL: mustURL("https://example.com/foo.yaml"), - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - "bar": {URL: mustURL("stdin:///")}, - }, - OutMode: "644", - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - cfg = &Config{ - Input: "hello world", - } - other = &Config{ - InputFiles: []string{"in.tmpl", "in2.tmpl"}, - OutputFiles: []string{"out", "out2"}, - } - expected = &Config{ - InputFiles: []string{"in.tmpl", "in2.tmpl"}, - OutputFiles: []string{"out", "out2"}, - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - cfg = &Config{ - Input: "hello world", - OutputFiles: []string{"out", "out2"}, - } - other = &Config{ - InputDir: "in/", - OutputDir: "out/", - } - expected = &Config{ - InputDir: "in/", - OutputDir: "out/", - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - cfg = &Config{ - Input: "hello world", - OutputFiles: []string{"out"}, - } - other = &Config{ - Input: "hi", - ExecPipe: true, - PostExec: []string{"cat"}, - } - expected = &Config{ - Input: "hi", - ExecPipe: true, - PostExec: []string{"cat"}, - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - cfg = &Config{ - Input: "hello world", - OutputFiles: []string{"-"}, - Plugins: map[string]PluginConfig{ - "sleep": {Cmd: "echo"}, - }, - PluginTimeout: 500 * time.Microsecond, - } - other = &Config{ - InputFiles: []string{"-"}, - OutputFiles: []string{"-"}, - Plugins: map[string]PluginConfig{ - "sleep": {Cmd: "sleep.sh"}, - }, - } - expected = &Config{ - Input: "hello world", - OutputFiles: []string{"-"}, - Plugins: map[string]PluginConfig{ - "sleep": {Cmd: "sleep.sh"}, - }, - PluginTimeout: 500 * time.Microsecond, - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - cfg = &Config{ - Input: "hello world", - OutMode: "644", - } - other = &Config{ - OutputFiles: []string{"out.txt"}, - Context: map[string]DataSource{ - "foo": { - URL: mustURL("https://example.com/foo.yaml"), - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - "bar": {URL: mustURL("stdin:///")}, - }, - DataSources: map[string]DataSource{ - "data": { - URL: mustURL("file:///data.json"), - }, - "moredata": { - URL: mustURL("https://example.com/more.json"), - Header: http.Header{ - "Authorization": {"Bearer abcd1234"}, - }, - }, - }, - } - expected = &Config{ - Input: "hello world", - OutputFiles: []string{"out.txt"}, - Context: map[string]DataSource{ - "foo": { - URL: mustURL("https://example.com/foo.yaml"), - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - "bar": {URL: mustURL("stdin:///")}, - }, - DataSources: map[string]DataSource{ - "data": { - URL: mustURL("file:///data.json"), - }, - "moredata": { - URL: mustURL("https://example.com/more.json"), - Header: http.Header{ - "Authorization": {"Bearer abcd1234"}, - }, - }, - }, - OutMode: "644", - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) - - // test template merging & a few other things - cfg = &Config{ - InputDir: "indir/", - ExcludeGlob: []string{"*.txt"}, - Templates: Templates{ - "foo": { - URL: mustURL("file:///foo.yaml"), - }, - "bar": { - URL: mustURL("stdin:///"), - Header: http.Header{"Accept": {"application/json"}}, - }, - }, - } - other = &Config{ - ExcludeGlob: []string{"*.yaml"}, - OutputMap: "${ .in }.out", - OutMode: "600", - LDelim: "${", - RDelim: "}", - Templates: Templates{ - "foo": {URL: mustURL("https://example.com/foo.yaml")}, - "baz": {URL: mustURL("vault:///baz")}, - }, - } - expected = &Config{ - InputDir: "indir/", - ExcludeGlob: []string{"*.yaml"}, - OutputMap: "${ .in }.out", - OutMode: "600", - LDelim: "${", - RDelim: "}", - Templates: Templates{ - "foo": {URL: mustURL("https://example.com/foo.yaml")}, - "bar": { - URL: mustURL("stdin:///"), - Header: http.Header{"Accept": {"application/json"}}, - }, - "baz": {URL: mustURL("vault:///baz")}, - }, - } - - assert.EqualValues(t, expected, cfg.MergeFrom(other)) -} - -func TestParseDataSourceFlags(t *testing.T) { - t.Parallel() - cfg := &Config{} - err := cfg.ParseDataSourceFlags(nil, nil, nil, nil) - require.NoError(t, err) - assert.EqualValues(t, &Config{}, cfg) - - cfg = &Config{} - err = cfg.ParseDataSourceFlags([]string{"foo/bar/baz.json"}, nil, nil, nil) - require.Error(t, err) - - cfg = &Config{} - err = cfg.ParseDataSourceFlags([]string{"baz=foo/bar/baz.json"}, nil, nil, nil) - require.NoError(t, err) - expected := &Config{ - DataSources: map[string]DataSource{ - "baz": {URL: mustURL("foo/bar/baz.json")}, - }, - } - assert.EqualValues(t, expected, cfg, "expected: %+v\nactual: %+v\n", expected, cfg) - - cfg = &Config{} - err = cfg.ParseDataSourceFlags( - []string{"baz=foo/bar/baz.json"}, - nil, - nil, - []string{"baz=Accept: application/json"}) - require.NoError(t, err) - assert.EqualValues(t, &Config{ - DataSources: map[string]DataSource{ - "baz": { - URL: mustURL("foo/bar/baz.json"), - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - }, - }, cfg) - - cfg = &Config{} - err = cfg.ParseDataSourceFlags( - []string{"baz=foo/bar/baz.json"}, - []string{"foo=http://example.com"}, - nil, - []string{ - "foo=Accept: application/json", - "bar=Authorization: Basic xxxxx", - }, - ) - require.NoError(t, err) - assert.EqualValues(t, &Config{ - DataSources: map[string]DataSource{ - "baz": {URL: mustURL("foo/bar/baz.json")}, - }, - Context: map[string]DataSource{ - "foo": { - URL: mustURL("http://example.com"), - Header: http.Header{ - "Accept": {"application/json"}, - }, - }, - }, - ExtraHeaders: map[string]http.Header{ - "bar": {"Authorization": {"Basic xxxxx"}}, - }, - }, cfg) - - cfg = &Config{} - err = cfg.ParseDataSourceFlags( - nil, - nil, - []string{"foo=http://example.com", "file.tmpl", "tmpldir/"}, - []string{"foo=Accept: application/json", "bar=Authorization: Basic xxxxx"}, - ) - require.NoError(t, err) - assert.EqualValues(t, &Config{ - Templates: Templates{ - "foo": { - URL: mustURL("http://example.com"), - Header: http.Header{"Accept": {"application/json"}}, - }, - "file.tmpl": {URL: mustURL("file.tmpl")}, - "tmpldir/": {URL: mustURL("tmpldir/")}, - }, - ExtraHeaders: map[string]http.Header{ - "bar": {"Authorization": {"Basic xxxxx"}}, - }, - }, cfg) -} - -func TestParsePluginFlags(t *testing.T) { - t.Parallel() - cfg := &Config{} - err := cfg.ParsePluginFlags(nil) - require.NoError(t, err) - - cfg = &Config{} - err = cfg.ParsePluginFlags([]string{"foo=bar"}) - require.NoError(t, err) - assert.EqualValues(t, &Config{Plugins: map[string]PluginConfig{"foo": {Cmd: "bar"}}}, cfg) -} - -func TestConfig_String(t *testing.T) { - t.Run("defaults", func(t *testing.T) { - c := &Config{} - c.ApplyDefaults() - - expected := `--- -inputFiles: ['-'] -outputFiles: ['-'] -leftDelim: '{{' -rightDelim: '}}' -missingKey: error -pluginTimeout: 5s -` - assert.Equal(t, expected, c.String()) - }) - - t.Run("overridden values", func(t *testing.T) { - c := &Config{ - LDelim: "L", - RDelim: "R", - Input: "foo", - OutputFiles: []string{"-"}, - Templates: Templates{ - "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, - "bar": {URL: mustURL("file:///tmp/bar.t")}, - }, - } - expected := `--- -in: foo -outputFiles: ['-'] -leftDelim: L -rightDelim: R -templates: - foo: - url: https://www.example.com/foo.tmpl - bar: - url: file:///tmp/bar.t -` - assert.YAMLEq(t, expected, c.String()) - }) - - t.Run("long input", func(t *testing.T) { - c := &Config{ - LDelim: "L", - RDelim: "R", - Input: "long input that should be truncated", - OutputFiles: []string{"-"}, - Templates: Templates{ - "foo": {URL: mustURL("https://www.example.com/foo.tmpl")}, - "bar": {URL: mustURL("file:///tmp/bar.t")}, - }, - } - expected := `--- -in: long inp... -outputFiles: ['-'] -leftDelim: L -rightDelim: R -templates: - foo: - url: https://www.example.com/foo.tmpl - bar: - url: file:///tmp/bar.t -` - assert.YAMLEq(t, expected, c.String()) - }) - - t.Run("relative dirs", func(t *testing.T) { - c := &Config{ - InputDir: "in/", - OutputDir: "out/", - } - expected := `--- -inputDir: in/ -outputDir: out/ -` - assert.YAMLEq(t, expected, c.String()) - }) - - t.Run("outputmap", func(t *testing.T) { - c := &Config{ - InputDir: "in/", - OutputMap: "{{ .in }}", - } - expected := `--- -inputDir: in/ -outputMap: '{{ .in }}' -` - - assert.YAMLEq(t, expected, c.String()) - }) - - t.Run("pluginTimeout", func(t *testing.T) { - c := &Config{ - PluginTimeout: 500 * time.Millisecond, - } - expected := `--- -pluginTimeout: 500ms -` - - assert.YAMLEq(t, expected, c.String()) - }) - - t.Run("plugins", func(t *testing.T) { - c := &Config{ - Plugins: map[string]PluginConfig{ - "foo": { - Cmd: "bar", - Timeout: 1 * time.Second, - Pipe: true, - }, - }, - } - expected := `--- -plugins: - foo: - cmd: bar - timeout: 1s - pipe: true -` - - assert.YAMLEq(t, expected, c.String()) - }) -} - -func TestApplyDefaults(t *testing.T) { - t.Parallel() - cfg := &Config{} - - cfg.ApplyDefaults() - assert.EqualValues(t, []string{"-"}, cfg.InputFiles) - assert.EqualValues(t, []string{"-"}, cfg.OutputFiles) - assert.Empty(t, cfg.OutputDir) - assert.Equal(t, "{{", cfg.LDelim) - assert.Equal(t, "}}", cfg.RDelim) - - cfg = &Config{ - InputDir: "in", - } - - cfg.ApplyDefaults() - assert.Empty(t, cfg.InputFiles) - assert.Empty(t, cfg.OutputFiles) - assert.Equal(t, ".", cfg.OutputDir) - assert.Equal(t, "{{", cfg.LDelim) - assert.Equal(t, "}}", cfg.RDelim) - - cfg = &Config{ - Input: "foo", - LDelim: "<", - RDelim: ">", - } - - cfg.ApplyDefaults() - assert.Empty(t, cfg.InputFiles) - assert.EqualValues(t, []string{"-"}, cfg.OutputFiles) - assert.Empty(t, cfg.OutputDir) - assert.Equal(t, "<", cfg.LDelim) - assert.Equal(t, ">", cfg.RDelim) - - cfg = &Config{ - Input: "foo", - ExecPipe: true, - } - - cfg.ApplyDefaults() - assert.Empty(t, cfg.InputFiles) - assert.EqualValues(t, []string{"-"}, cfg.OutputFiles) - assert.Empty(t, cfg.OutputDir) - assert.True(t, cfg.ExecPipe) - - cfg = &Config{ - InputDir: "foo", - OutputMap: "bar", - } - - cfg.ApplyDefaults() - assert.Empty(t, cfg.InputFiles) - assert.Empty(t, cfg.Input) - assert.Empty(t, cfg.OutputFiles) - assert.Empty(t, cfg.OutputDir) - assert.False(t, cfg.ExecPipe) - assert.Equal(t, "bar", cfg.OutputMap) -} - -func TestGetMode(t *testing.T) { - c := &Config{} - m, o, err := c.GetMode() - require.NoError(t, err) - assert.Equal(t, iohelpers.NormalizeFileMode(0), m) - assert.False(t, o) - - c = &Config{OutMode: "755"} - m, o, err = c.GetMode() - require.NoError(t, err) - assert.Equal(t, iohelpers.NormalizeFileMode(0o755), m) - assert.True(t, o) - - c = &Config{OutMode: "0755"} - m, o, err = c.GetMode() - require.NoError(t, err) - assert.Equal(t, iohelpers.NormalizeFileMode(0o755), m) - assert.True(t, o) - - c = &Config{OutMode: "foo"} - _, _, err = c.GetMode() - require.Error(t, err) -} - -func TestParseHeaderArgs(t *testing.T) { - args := []string{ - "foo=Accept: application/json", - "bar=Authorization: Bearer supersecret", - } - expected := map[string]http.Header{ - "foo": { - "Accept": {"application/json"}, - }, - "bar": { - "Authorization": {"Bearer supersecret"}, - }, - } - parsed, err := parseHeaderArgs(args) - require.NoError(t, err) - assert.Equal(t, expected, parsed) - - _, err = parseHeaderArgs([]string{"foo"}) - require.Error(t, err) - - _, err = parseHeaderArgs([]string{"foo=bar"}) - require.Error(t, err) - - args = []string{ - "foo=Accept: application/json", - "foo=Foo: bar", - "foo=foo: baz", - "foo=fOO: qux", - "bar=Authorization: Bearer supersecret", - } - expected = map[string]http.Header{ - "foo": { - "Accept": {"application/json"}, - "Foo": {"bar", "baz", "qux"}, - }, - "bar": { - "Authorization": {"Bearer supersecret"}, - }, - } - parsed, err = parseHeaderArgs(args) - require.NoError(t, err) - assert.Equal(t, expected, parsed) -} - -func TestParseDatasourceArgNoAlias(t *testing.T) { - alias, ds, err := parseDatasourceArg("foo.json") - require.NoError(t, err) - assert.Equal(t, "foo", alias) - assert.Empty(t, ds.URL.Scheme) - - _, _, err = parseDatasourceArg("../foo.json") - require.Error(t, err) - - _, _, err = parseDatasourceArg("ftp://example.com/foo.yml") - require.Error(t, err) -} - -func TestParseDatasourceArgWithAlias(t *testing.T) { - alias, ds, err := parseDatasourceArg("data=foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL) - - alias, ds, err = parseDatasourceArg("data=/otherdir/foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.Equal(t, "file", ds.URL.Scheme) - assert.True(t, ds.URL.IsAbs()) - assert.Equal(t, "/otherdir/foo.json", ds.URL.Path) - - if runtime.GOOS == "windows" { - alias, ds, err = parseDatasourceArg("data=foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Path: "foo.json"}, ds.URL) - - alias, ds, err = parseDatasourceArg(`data=\otherdir\foo.json`) - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Scheme: "file", Path: "/otherdir/foo.json"}, ds.URL) - - alias, ds, err = parseDatasourceArg("data=C:\\windowsdir\\foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Scheme: "file", Path: "C:/windowsdir/foo.json"}, ds.URL) - - alias, ds, err = parseDatasourceArg("data=\\\\somehost\\share\\foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Scheme: "file", Host: "somehost", Path: "/share/foo.json"}, ds.URL) - } - - alias, ds, err = parseDatasourceArg("data=sftp://example.com/blahblah/foo.json") - require.NoError(t, err) - assert.Equal(t, "data", alias) - assert.EqualValues(t, &url.URL{Scheme: "sftp", Host: "example.com", Path: "/blahblah/foo.json"}, ds.URL) - - alias, ds, err = parseDatasourceArg("merged=merge:./foo.yaml|http://example.com/bar.json%3Ffoo=bar") - require.NoError(t, err) - assert.Equal(t, "merged", alias) - assert.EqualValues(t, &url.URL{Scheme: "merge", Opaque: "./foo.yaml|http://example.com/bar.json%3Ffoo=bar"}, ds.URL) -} - -func TestPluginConfig_UnmarshalYAML(t *testing.T) { - in := `foo` - out := PluginConfig{} - err := yaml.Unmarshal([]byte(in), &out) - require.NoError(t, err) - assert.EqualValues(t, PluginConfig{Cmd: "foo"}, out) - - in = `[foo, bar]` - out = PluginConfig{} - err = yaml.Unmarshal([]byte(in), &out) - require.Error(t, err) - - in = `cmd: foo` - out = PluginConfig{} - err = yaml.Unmarshal([]byte(in), &out) - require.NoError(t, err) - assert.EqualValues(t, PluginConfig{Cmd: "foo"}, out) - - in = `cmd: foo -timeout: 10ms -pipe: true -` - out = PluginConfig{} - err = yaml.Unmarshal([]byte(in), &out) - require.NoError(t, err) - assert.EqualValues(t, PluginConfig{ - Cmd: "foo", - Timeout: time.Duration(10) * time.Millisecond, - Pipe: true, - }, out) -} diff --git a/internal/config/types.go b/internal/config/types.go index 6c38dc6a..022363f7 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "strings" "github.com/hairyhenderson/gomplate/v4/internal/deprecated" @@ -88,13 +89,58 @@ func (t Templates) MarshalYAML() (interface{}, error) { return m, nil } -func parseTemplateArg(value string) (alias string, ds DataSource, err error) { - alias, u, _ := strings.Cut(value, "=") - if u == "" { - u = alias - } +type experimentalCtxKey struct{} + +func SetExperimental(ctx context.Context) context.Context { + return context.WithValue(ctx, experimentalCtxKey{}, true) +} + +func ExperimentalEnabled(ctx context.Context) bool { + v, ok := ctx.Value(experimentalCtxKey{}).(bool) + return ok && v +} - ds.URL, err = urlhelpers.ParseSourceURL(u) +// DataSource - datasource configuration +// +// defined in this package to avoid cyclic dependencies +type DataSource struct { + URL *url.URL `yaml:"-"` + Header http.Header `yaml:"header,omitempty,flow"` +} + +// UnmarshalYAML - satisfy the yaml.Umarshaler interface - URLs aren't +// well supported, and anyway we need to do some extra parsing +func (d *DataSource) UnmarshalYAML(value *yaml.Node) error { + type raw struct { + Header http.Header + URL string + } + r := raw{} + err := value.Decode(&r) + if err != nil { + return err + } + u, err := urlhelpers.ParseSourceURL(r.URL) + if err != nil { + return fmt.Errorf("could not parse datasource URL %q: %w", r.URL, err) + } + *d = DataSource{ + URL: u, + Header: r.Header, + } + return nil +} - return alias, ds, err +// MarshalYAML - satisfy the yaml.Marshaler interface - URLs aren't +// well supported, and anyway we need to do some extra parsing +func (d DataSource) MarshalYAML() (interface{}, error) { + type raw struct { + Header http.Header + URL string + } + r := raw{ + URL: d.URL.String(), + Header: d.Header, + } + return r, nil } diff --git a/internal/config/types_test.go b/internal/config/types_test.go deleted file mode 100644 index 1ee6c4fe..00000000 --- a/internal/config/types_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package config - -import ( - "net/http" - "testing" - - "github.com/hairyhenderson/yaml" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTemplates_UnmarshalYAML(t *testing.T) { - in := `t: - url: foo/bar/helloworld.tmpl -templatedir: - url: templatedir/ -dir: - url: foo/bar/ -mytemplate.t: - url: mytemplate.t -remote: - url: https://example.com/foo/bar/helloworld.tmpl - header: - Accept: [text/plain, text/template]` - out := Templates{} - err := yaml.Unmarshal([]byte(in), &out) - require.NoError(t, err) - assert.EqualValues(t, Templates{ - "t": {URL: mustURL("foo/bar/helloworld.tmpl")}, - "templatedir": {URL: mustURL("templatedir/")}, - "dir": {URL: mustURL("foo/bar/")}, - "mytemplate.t": {URL: mustURL("mytemplate.t")}, - "remote": { - URL: mustURL("https://example.com/foo/bar/helloworld.tmpl"), - Header: http.Header{"Accept": {"text/plain", "text/template"}}, - }, - }, out) - - // legacy array format - in = `- t=foo/bar/helloworld.tmpl -- templatedir/ -- dir=foo/bar/ -- mytemplate.t -- remote=https://example.com/foo/bar/helloworld.tmpl` - out = Templates{} - err = yaml.Unmarshal([]byte(in), &out) - require.NoError(t, err) - assert.EqualValues(t, Templates{ - "t": {URL: mustURL("foo/bar/helloworld.tmpl")}, - "templatedir/": {URL: mustURL("templatedir/")}, - "dir": {URL: mustURL("foo/bar/")}, - "mytemplate.t": {URL: mustURL("mytemplate.t")}, - "remote": {URL: mustURL("https://example.com/foo/bar/helloworld.tmpl")}, - }, out) - - // invalid format - in = `"neither an array nor a map"` - out = Templates{} - err = yaml.Unmarshal([]byte(in), &out) - require.Error(t, err) - - // invalid URL - in = `- t="not a:valid url"` - out = Templates{} - err = yaml.Unmarshal([]byte(in), &out) - require.Error(t, err) -} - -func TestParseTemplateArg(t *testing.T) { - data := []struct { - ds DataSource - in string - alias string - }{ - {in: "t=foo/bar/helloworld.tmpl", alias: "t", ds: DataSource{URL: mustURL("foo/bar/helloworld.tmpl")}}, - {in: "templatedir/", alias: "templatedir/", ds: DataSource{URL: mustURL("templatedir/")}}, - {in: "dir=foo/bar/", alias: "dir", ds: DataSource{URL: mustURL("foo/bar/")}}, - {in: "mytemplate.t", alias: "mytemplate.t", ds: DataSource{URL: mustURL("mytemplate.t")}}, - { - in: "remote=https://example.com/foo/bar/helloworld.tmpl", - alias: "remote", ds: DataSource{URL: mustURL("https://example.com/foo/bar/helloworld.tmpl")}, - }, - } - - for _, d := range data { - alias, ds, err := parseTemplateArg(d.in) - require.NoError(t, err) - assert.Equal(t, d.alias, alias) - assert.EqualValues(t, d.ds, ds) - } -} diff --git a/internal/datafs/context.go b/internal/datafs/context.go index 30722f2b..310d4445 100644 --- a/internal/datafs/context.go +++ b/internal/datafs/context.go @@ -30,10 +30,15 @@ func WithDataSourceRegistryFS(registry Registry, fsys fs.FS) fs.FS { type stdinCtxKey struct{} +// ContextWithStdin injects an [io.Reader] into the context, which can be used +// to override the default stdin. func ContextWithStdin(ctx context.Context, r io.Reader) context.Context { return context.WithValue(ctx, stdinCtxKey{}, r) } +// StdinFromContext returns the io.Reader that should be used for stdin as +// injected by [ContextWithStdin]. If no reader has been injected, [os.Stdin] is +// returned. func StdinFromContext(ctx context.Context) io.Reader { if r, ok := ctx.Value(stdinCtxKey{}).(io.Reader); ok { return r |
