From 9e952fc8b4158c5e64b3185acd5d2b3fc7b15445 Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Sat, 2 Jan 2021 09:52:28 -0500 Subject: Move main logic to internal Signed-off-by: Dave Henderson --- cmd/gomplate/config.go | 261 ------------------------------------------ cmd/gomplate/config_test.go | 271 -------------------------------------------- cmd/gomplate/logger.go | 34 ------ cmd/gomplate/main.go | 152 +------------------------ cmd/gomplate/main_test.go | 1 - go.mod | 6 +- go.sum | 25 ++-- internal/cmd/config.go | 261 ++++++++++++++++++++++++++++++++++++++++++ internal/cmd/config_test.go | 271 ++++++++++++++++++++++++++++++++++++++++++++ internal/cmd/logger.go | 37 ++++++ internal/cmd/main.go | 167 +++++++++++++++++++++++++++ internal/cmd/main_test.go | 50 ++++++++ 12 files changed, 805 insertions(+), 731 deletions(-) delete mode 100644 cmd/gomplate/config.go delete mode 100644 cmd/gomplate/config_test.go delete mode 100644 cmd/gomplate/logger.go delete mode 100644 cmd/gomplate/main_test.go create mode 100644 internal/cmd/config.go create mode 100644 internal/cmd/config_test.go create mode 100644 internal/cmd/logger.go create mode 100644 internal/cmd/main.go create mode 100644 internal/cmd/main_test.go diff --git a/cmd/gomplate/config.go b/cmd/gomplate/config.go deleted file mode 100644 index caaf4963..00000000 --- a/cmd/gomplate/config.go +++ /dev/null @@ -1,261 +0,0 @@ -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" - - "github.com/rs/zerolog" - - "github.com/spf13/afero" - "github.com/spf13/cobra" -) - -const ( - defaultConfigFile = ".gomplate.yaml" -) - -var fs = afero.NewOsFs() - -// 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) -// - merges the two (flags take precedence) -// - 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 - } - - cfg, err := readConfigFile(cmd) - if err != nil { - return nil, err - } - if cfg == nil { - cfg = flagConfig - } else { - cfg = cfg.MergeFrom(flagConfig) - } - - cfg, err = applyEnvVars(ctx, cfg) - if err != nil { - return nil, err - } - - // reset defaults before validation - cfg.ApplyDefaults() - - err = cfg.Validate() - if err != nil { - return nil, fmt.Errorf("failed to validate merged config: %w\n%+v", err, cfg) - } - return cfg, nil -} - -func pickConfigFile(cmd *cobra.Command) (cfgFile string, required bool) { - cfgFile = defaultConfigFile - if c := env.Getenv("GOMPLATE_CONFIG"); c != "" { - cfgFile = c - required = true - } - if cmd.Flags().Changed("config") && cmd.Flag("config").Value.String() != "" { - // Use config file from the flag if specified - cfgFile = cmd.Flag("config").Value.String() - required = true - } - return cfgFile, required -} - -func readConfigFile(cmd *cobra.Command) (cfg *config.Config, err error) { - ctx := cmd.Context() - if ctx == nil { - ctx = context.Background() - } - log := zerolog.Ctx(ctx) - - cfgFile, configRequired := pickConfigFile(cmd) - - f, err := fs.Open(cfgFile) - if err != nil { - if configRequired { - return cfg, fmt.Errorf("config file requested, but couldn't be opened: %w", err) - } - return nil, nil - } - - cfg, err = config.Parse(f) - if err != nil && configRequired { - return cfg, fmt.Errorf("config file requested, but couldn't be parsed: %w", err) - } - - log.Debug().Str("cfgFile", cfgFile).Msg("using config file") - - return cfg, err -} - -// cobraConfig - initialize a config from the commandline options -func cobraConfig(cmd *cobra.Command, args []string) (cfg *config.Config, err error) { - cfg = &config.Config{} - cfg.InputFiles, err = getStringSlice(cmd, "file") - if err != nil { - return nil, err - } - cfg.Input, err = getString(cmd, "in") - if err != nil { - return nil, err - } - cfg.InputDir, err = getString(cmd, "input-dir") - if err != nil { - return nil, err - } - - cfg.ExcludeGlob, err = getStringSlice(cmd, "exclude") - if err != nil { - return nil, err - } - includesFlag, err := getStringSlice(cmd, "include") - if err != nil { - return nil, err - } - // support --include - cfg.ExcludeGlob = processIncludes(includesFlag, cfg.ExcludeGlob) - - cfg.OutputFiles, err = getStringSlice(cmd, "out") - if err != nil { - return nil, err - } - cfg.Templates, err = getStringSlice(cmd, "template") - if err != nil { - return nil, err - } - cfg.OutputDir, err = getString(cmd, "output-dir") - if err != nil { - return nil, err - } - cfg.OutputMap, err = getString(cmd, "output-map") - if err != nil { - return nil, err - } - cfg.OutMode, err = getString(cmd, "chmod") - if err != nil { - return nil, err - } - - if len(args) > 0 { - cfg.PostExec = args - } - - cfg.ExecPipe, err = getBool(cmd, "exec-pipe") - if err != nil { - return nil, err - } - cfg.Experimental, err = getBool(cmd, "experimental") - if err != nil { - return nil, err - } - - cfg.LDelim, err = getString(cmd, "left-delim") - if err != nil { - return nil, err - } - cfg.RDelim, err = getString(cmd, "right-delim") - if err != nil { - return nil, err - } - - ds, err := getStringSlice(cmd, "datasource") - if err != nil { - return nil, err - } - cx, err := getStringSlice(cmd, "context") - if err != nil { - return nil, err - } - hdr, err := getStringSlice(cmd, "datasource-header") - if err != nil { - return nil, err - } - err = cfg.ParseDataSourceFlags(ds, cx, hdr) - if err != nil { - return nil, err - } - - pl, err := getStringSlice(cmd, "plugin") - if err != nil { - return nil, err - } - err = cfg.ParsePluginFlags(pl) - if err != nil { - return nil, err - } - return cfg, nil -} - -func getStringSlice(cmd *cobra.Command, flag string) (s []string, err error) { - if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { - s, err = cmd.Flags().GetStringSlice(flag) - } - return s, err -} - -func getString(cmd *cobra.Command, flag string) (s string, err error) { - if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { - s, err = cmd.Flags().GetString(flag) - } - return s, err -} - -func getBool(cmd *cobra.Command, flag string) (b bool, err error) { - if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { - b, err = cmd.Flags().GetBool(flag) - } - return b, err -} - -// process --include flags - these are analogous to specifying --exclude '*', -// then the inverse of the --include options. -func processIncludes(includes, excludes []string) []string { - if len(includes) == 0 && len(excludes) == 0 { - return nil - } - - out := []string{} - // if any --includes are set, we start by excluding everything - if len(includes) > 0 { - out = make([]string, 1+len(includes)) - out[0] = "*" - } - for i, include := range includes { - // includes are just the opposite of an exclude - out[i+1] = "!" + include - } - 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 - } - - if !cfg.Experimental && conv.ToBool(env.Getenv("GOMPLATE_EXPERIMENTAL", "false")) { - cfg.Experimental = true - } - - return cfg, nil -} diff --git a/cmd/gomplate/config_test.go b/cmd/gomplate/config_test.go deleted file mode 100644 index 97b9a89f..00000000 --- a/cmd/gomplate/config_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "github.com/hairyhenderson/gomplate/v3/internal/config" - - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" -) - -func TestReadConfigFile(t *testing.T) { - fs = afero.NewMemMapFs() - defer func() { fs = afero.NewOsFs() }() - cmd := &cobra.Command{} - - _, err := readConfigFile(cmd) - assert.NoError(t, err) - - cmd.Flags().String("config", defaultConfigFile, "foo") - - _, err = readConfigFile(cmd) - assert.NoError(t, err) - - cmd.ParseFlags([]string{"--config", "config.file"}) - - _, err = readConfigFile(cmd) - assert.Error(t, err) - - cmd = &cobra.Command{} - cmd.Flags().String("config", defaultConfigFile, "foo") - - f, err := fs.Create(defaultConfigFile) - assert.NoError(t, err) - f.WriteString("") - - cfg, err := readConfigFile(cmd) - assert.NoError(t, err) - assert.EqualValues(t, &config.Config{}, cfg) - - cmd.ParseFlags([]string{"--config", "config.yaml"}) - - f, err = fs.Create("config.yaml") - assert.NoError(t, err) - f.WriteString("in: hello world\n") - - cfg, err = readConfigFile(cmd) - assert.NoError(t, err) - assert.EqualValues(t, &config.Config{Input: "hello world"}, cfg) - - f.WriteString("in: ") - - _, err = readConfigFile(cmd) - assert.Error(t, err) -} - -func TestLoadConfig(t *testing.T) { - fs = afero.NewMemMapFs() - defer func() { fs = afero.NewOsFs() }() - - cmd := &cobra.Command{} - cmd.Args = optionalExecArgs - cmd.Flags().StringSlice("file", []string{"-"}, "...") - cmd.Flags().StringSlice("out", []string{"-"}, "...") - cmd.Flags().String("in", ".", "...") - cmd.Flags().String("output-dir", ".", "...") - cmd.Flags().String("left-delim", "{{", "...") - cmd.Flags().String("right-delim", "}}", "...") - cmd.Flags().Bool("exec-pipe", false, "...") - cmd.ParseFlags(nil) - - out, err := loadConfig(cmd, cmd.Flags().Args()) - expected := &config.Config{ - InputFiles: []string{"-"}, - OutputFiles: []string{"-"}, - LDelim: "{{", - RDelim: "}}", - PostExecInput: os.Stdin, - OutWriter: os.Stdout, - PluginTimeout: 5 * time.Second, - } - assert.NoError(t, err) - assert.EqualValues(t, expected, out) - - cmd.ParseFlags([]string{"--in", "foo"}) - out, err = loadConfig(cmd, cmd.Flags().Args()) - expected = &config.Config{ - Input: "foo", - OutputFiles: []string{"-"}, - LDelim: "{{", - RDelim: "}}", - PostExecInput: os.Stdin, - OutWriter: os.Stdout, - PluginTimeout: 5 * time.Second, - } - assert.NoError(t, err) - assert.EqualValues(t, expected, out) - - cmd.ParseFlags([]string{"--in", "foo", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"}) - out, err = loadConfig(cmd, cmd.Flags().Args()) - expected = &config.Config{ - Input: "foo", - LDelim: "{{", - RDelim: "}}", - ExecPipe: true, - 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) -} - -func TestCobraConfig(t *testing.T) { - t.Parallel() - cmd := &cobra.Command{} - cmd.Flags().StringSlice("file", []string{"-"}, "...") - cmd.Flags().StringSlice("out", []string{"-"}, "...") - cmd.Flags().String("output-dir", ".", "...") - cmd.Flags().String("left-delim", "{{", "...") - cmd.Flags().String("right-delim", "}}", "...") - cmd.ParseFlags(nil) - - cfg, err := cobraConfig(cmd, cmd.Flags().Args()) - assert.NoError(t, err) - assert.EqualValues(t, &config.Config{}, cfg) - - cmd.ParseFlags([]string{"--file", "in", "--", "echo", "foo"}) - - cfg, err = cobraConfig(cmd, cmd.Flags().Args()) - assert.NoError(t, err) - assert.EqualValues(t, &config.Config{ - InputFiles: []string{"in"}, - PostExec: []string{"echo", "foo"}, - }, cfg) -} - -func TestProcessIncludes(t *testing.T) { - t.Parallel() - data := []struct { - inc, exc, expected []string - }{ - {nil, nil, nil}, - {[]string{}, []string{}, nil}, - {nil, []string{"*.foo"}, []string{"*.foo"}}, - {[]string{"*.bar"}, []string{"a*.bar"}, []string{"*", "!*.bar", "a*.bar"}}, - {[]string{"*.bar"}, nil, []string{"*", "!*.bar"}}, - } - - for _, d := range data { - assert.EqualValues(t, d.expected, processIncludes(d.inc, d.exc)) - } -} - -func TestPickConfigFile(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().String("config", defaultConfigFile, "foo") - - cf, req := pickConfigFile(cmd) - assert.False(t, req) - assert.Equal(t, defaultConfigFile, cf) - - os.Setenv("GOMPLATE_CONFIG", "foo.yaml") - defer os.Unsetenv("GOMPLATE_CONFIG") - cf, req = pickConfigFile(cmd) - assert.True(t, req) - assert.Equal(t, "foo.yaml", cf) - - cmd.ParseFlags([]string{"--config", "config.file"}) - cf, req = pickConfigFile(cmd) - assert.True(t, req) - assert.Equal(t, "config.file", cf) - - os.Setenv("GOMPLATE_CONFIG", "ignored.yaml") - cf, req = pickConfigFile(cmd) - assert.True(t, req) - assert.Equal(t, "config.file", cf) -} - -func TestApplyEnvVars(t *testing.T) { - data := []struct { - env string - value string - shouldErr bool - input, expected *config.Config - }{ - { - "GOMPLATE_PLUGIN_TIMEOUT", "bogus", - true, - &config.Config{}, nil, - }, - { - "GOMPLATE_PLUGIN_TIMEOUT", "bogus", - false, - &config.Config{PluginTimeout: 2 * time.Second}, - &config.Config{PluginTimeout: 2 * time.Second}, - }, - { - "GOMPLATE_PLUGIN_TIMEOUT", "2s", - false, - &config.Config{}, - &config.Config{PluginTimeout: 2 * time.Second}, - }, - { - "GOMPLATE_PLUGIN_TIMEOUT", "2s", - false, - &config.Config{PluginTimeout: 100 * time.Millisecond}, - &config.Config{PluginTimeout: 100 * time.Millisecond}, - }, - - { - "GOMPLATE_SUPPRESS_EMPTY", "bogus", - false, - &config.Config{}, - &config.Config{SuppressEmpty: false}, - }, - { - "GOMPLATE_SUPPRESS_EMPTY", "true", - false, - &config.Config{}, - &config.Config{SuppressEmpty: true}, - }, - { - "GOMPLATE_SUPPRESS_EMPTY", "false", - false, - &config.Config{SuppressEmpty: true}, - &config.Config{SuppressEmpty: true}, - }, - - { - "GOMPLATE_EXPERIMENTAL", "bogus", - false, - &config.Config{}, - &config.Config{Experimental: false}, - }, - { - "GOMPLATE_EXPERIMENTAL", "true", - false, - &config.Config{}, - &config.Config{Experimental: true}, - }, - { - "GOMPLATE_EXPERIMENTAL", "false", - false, - &config.Config{Experimental: true}, - &config.Config{Experimental: true}, - }, - } - - for i, d := range data { - t.Run(fmt.Sprintf("applyEnvVars_%s_%s/%d", d.env, d.value, i), func(t *testing.T) { - os.Setenv(d.env, d.value) - - actual, err := applyEnvVars(context.Background(), d.input) - os.Unsetenv(d.env) - if d.shouldErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.EqualValues(t, d.expected, actual) - } - }) - } -} diff --git a/cmd/gomplate/logger.go b/cmd/gomplate/logger.go deleted file mode 100644 index 2694c89a..00000000 --- a/cmd/gomplate/logger.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "context" - stdlog "log" - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "golang.org/x/term" -) - -func initLogger(ctx context.Context) context.Context { - // default to warn level - zerolog.SetGlobalLevel(zerolog.WarnLevel) - zerolog.DurationFieldUnit = time.Second - - stdlogger := log.With().Bool("stdlog", true).Logger() - stdlog.SetFlags(0) - stdlog.SetOutput(stdlogger) - - if term.IsTerminal(int(os.Stderr.Fd())) { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) - noLevelWriter := zerolog.ConsoleWriter{ - Out: os.Stderr, - FormatLevel: func(i interface{}) string { return "" }, - } - stdlogger = stdlogger.Output(noLevelWriter) - stdlog.SetOutput(stdlogger) - } - - return log.Logger.WithContext(ctx) -} diff --git a/cmd/gomplate/main.go b/cmd/gomplate/main.go index 730ac8eb..ac5fa07b 100644 --- a/cmd/gomplate/main.go +++ b/cmd/gomplate/main.go @@ -6,164 +6,22 @@ package main import ( "context" - "fmt" "os" - "os/exec" - "os/signal" - "github.com/hairyhenderson/gomplate/v3" - "github.com/hairyhenderson/gomplate/v3/env" - "github.com/hairyhenderson/gomplate/v3/internal/config" - "github.com/hairyhenderson/gomplate/v3/version" - - "github.com/rs/zerolog" - - "github.com/spf13/cobra" + "github.com/hairyhenderson/gomplate/v3/internal/cmd" ) -// postRunExec - if templating succeeds, the command following a '--' will be executed -func postRunExec(ctx context.Context, cfg *config.Config) error { - args := cfg.PostExec - if len(args) > 0 { - log := zerolog.Ctx(ctx) - log.Debug().Strs("args", args).Msg("running post-exec command") - - name := args[0] - args = args[1:] - // nolint: gosec - c := exec.CommandContext(ctx, name, args...) - c.Stdin = cfg.PostExecInput - c.Stderr = os.Stderr - c.Stdout = os.Stdout - - // make sure all signals are propagated - sigs := make(chan os.Signal, 1) - signal.Notify(sigs) - go func() { - // Pass signals to the sub-process - sig := <-sigs - if c.Process != nil { - // nolint: gosec - _ = c.Process.Signal(sig) - } - }() - - return c.Run() - } - return nil -} - -// optionalExecArgs - implements cobra.PositionalArgs. Allows extra args following -// a '--', but not otherwise. -func optionalExecArgs(cmd *cobra.Command, args []string) error { - if cmd.ArgsLenAtDash() == 0 { - return nil - } - return cobra.NoArgs(cmd, args) -} - -func newGomplateCmd() *cobra.Command { - rootCmd := &cobra.Command{ - Use: "gomplate", - Short: "Process text files with Go templates", - Version: version.Version, - RunE: func(cmd *cobra.Command, args []string) error { - if v, _ := cmd.Flags().GetBool("verbose"); v { - zerolog.SetGlobalLevel(zerolog.DebugLevel) - } - ctx := cmd.Context() - log := zerolog.Ctx(ctx) - - cfg, err := loadConfig(cmd, args) - if err != nil { - return err - } - ctx = config.ContextWithConfig(ctx, cfg) - if cfg.Experimental { - log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Bool("experimental", true) - }) - log.Info().Msg("experimental functions and features enabled!") - } - - log.Debug().Msgf("starting %s", cmd.Name()) - log.Debug(). - Str("version", version.Version). - Str("build", version.GitCommit). - Msgf("config is:\n%v", cfg) - - err = gomplate.Run(ctx, cfg) - cmd.SilenceErrors = true - cmd.SilenceUsage = true - - fmt.Fprintf(os.Stderr, "\n") - log.Debug().Int("templatesRendered", gomplate.Metrics.TemplatesProcessed). - Int("errors", gomplate.Metrics.Errors). - Dur("duration", gomplate.Metrics.TotalRenderDuration). - Msg("completed rendering") - - if err != nil { - return err - } - return postRunExec(ctx, cfg) - }, - Args: optionalExecArgs, - } - return rootCmd -} - -func initFlags(command *cobra.Command) { - command.Flags().SortFlags = false - - command.Flags().StringSliceP("datasource", "d", nil, "`datasource` in alias=URL form. Specify multiple times to add multiple sources.") - command.Flags().StringSliceP("datasource-header", "H", nil, "HTTP `header` field in 'alias=Name: value' form to be provided on HTTP-based data sources. Multiples can be set.") - - command.Flags().StringSliceP("context", "c", nil, "pre-load a `datasource` into the context, in alias=URL form. Use the special alias `.` to set the root context.") - - command.Flags().StringSlice("plugin", nil, "plug in an external command as a function in name=path form. Can be specified multiple times") - - command.Flags().StringSliceP("file", "f", []string{"-"}, "Template `file` to process. Omit to use standard input, or use --in or --input-dir") - command.Flags().StringP("in", "i", "", "Template `string` to process (alternative to --file and --input-dir)") - command.Flags().String("input-dir", "", "`directory` which is examined recursively for templates (alternative to --file and --in)") - - command.Flags().StringSlice("exclude", []string{}, "glob of files to not parse") - command.Flags().StringSlice("include", []string{}, "glob of files to parse") - - command.Flags().StringSliceP("out", "o", []string{"-"}, "output `file` name. Omit to use standard output.") - command.Flags().StringSliceP("template", "t", []string{}, "Additional template file(s)") - command.Flags().String("output-dir", ".", "`directory` to store the processed templates. Only used for --input-dir") - command.Flags().String("output-map", "", "Template `string` to map the input file to an output path") - command.Flags().String("chmod", "", "set the mode for output file(s). Omit to inherit from input file(s)") - - command.Flags().Bool("exec-pipe", false, "pipe the output to the post-run exec command") - - ldDefault := env.Getenv("GOMPLATE_LEFT_DELIM", "{{") - rdDefault := env.Getenv("GOMPLATE_RIGHT_DELIM", "}}") - command.Flags().String("left-delim", ldDefault, "override the default left-`delimiter` [$GOMPLATE_LEFT_DELIM]") - command.Flags().String("right-delim", rdDefault, "override the default right-`delimiter` [$GOMPLATE_RIGHT_DELIM]") - - command.Flags().Bool("experimental", false, "enable experimental features [$GOMPLATE_EXPERIMENTAL]") - - command.Flags().BoolP("verbose", "V", false, "output extra information about what gomplate is doing") - - command.Flags().String("config", defaultConfigFile, "config file (overridden by commandline flags)") -} - func main() { exitCode := 0 - // defer the exit first to make sure that other deferred functions have a - // chance to run + // defer the exit first, so it executes last, to let the deferred cancel run defer func() { os.Exit(exitCode) }() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - ctx = initLogger(ctx) - command := newGomplateCmd() - initFlags(command) - if err := command.ExecuteContext(ctx); err != nil { - log := zerolog.Ctx(ctx) - log.Error().Err(err).Send() + // need to strip os.Args[0] so we only pass the actual flags + err := cmd.Main(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + if err != nil { exitCode = 1 } } diff --git a/cmd/gomplate/main_test.go b/cmd/gomplate/main_test.go deleted file mode 100644 index 06ab7d0f..00000000 --- a/cmd/gomplate/main_test.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/go.mod b/go.mod index 904ca1df..a1a1af9c 100644 --- a/go.mod +++ b/go.mod @@ -25,9 +25,9 @@ require ( github.com/ugorji/go/codec v1.2.2 github.com/zealic/xignore v0.3.3 gocloud.dev v0.21.0 - golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 - golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e - golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 + golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad + golang.org/x/sys v0.0.0-20201223074533-0d417f636930 + golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/src-d/go-billy.v4 v4.3.2 gopkg.in/src-d/go-git.v4 v4.13.1 diff --git a/go.sum b/go.sum index c7714e56..9117e65f 100644 --- a/go.sum +++ b/go.sum @@ -105,10 +105,7 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.36.1/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.36.8 h1:3nvY3Ax2RC6PN1i0OKppxjq3doHWqiYtvenLQ/oZ5jI= -github.com/aws/aws-sdk-go v1.36.8/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.36.12/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= -github.com/aws/aws-sdk-go v1.36.15/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/aws/aws-sdk-go v1.36.19 h1:zbJZKkxeDiYxUYFjymjWxPye+qa1G2gRVyhIzZrB9zA= github.com/aws/aws-sdk-go v1.36.19/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= @@ -258,8 +255,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.3 h1:twObb+9XcuH5B9V1TBCvvvZoO6iEdILi2a76PYn5rJI= github.com/google/uuid v1.1.3/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.4.0 h1:kXcsA/rIGzJImVqPdhfnr6q0xsS9gU0515q1EPpJ9fE= github.com/google/wire v0.4.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= @@ -508,12 +505,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go v1.2.1 h1:dz+JxTe7GZQdErTo7SREc1jQj/hFP1k7jyIAwODoW+k= -github.com/ugorji/go v1.2.1/go.mod h1:cSVypSfTLm2o9fKxXvQgn3rMmkPXovcWor6Qn5tbFmI= +github.com/ugorji/go v1.2.2 h1:60ZHIOcsJlo3bJm9CbTVu7OSqT2mxaEmyQbK2NwCkn0= github.com/ugorji/go v1.2.2/go.mod h1:bitgyERdV7L7Db/Z5gfd5v2NQMNhhiFiZwpgMw2SP7k= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/ugorji/go/codec v1.2.1 h1:/TRfW3XKkvWvmAYyCUaQlhoCDGjcvNR8xVVA/l5p/jQ= -github.com/ugorji/go/codec v1.2.1/go.mod h1:s/WxCRi46t8rA+fowL40EnmD7ec0XhR7ZypxeBNdzsM= +github.com/ugorji/go/codec v1.2.2 h1:08Gah8d+dXj4cZNUHhtuD/S4PXD5WpVbj5B8/ClELAQ= github.com/ugorji/go/codec v1.2.2/go.mod h1:OM8g7OAy52uYl3Yk+RE/3AS1nXFn1Wh4PPLtupCxbuU= github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= @@ -555,8 +550,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 h1:sYNJzB4J8toYPQTM6pAkcmBRgw9SnQKP9oXCHfgy604= -golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -695,12 +690,14 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200828194041-157a740278f4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs= -golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= +golang.org/x/sys v0.0.0-20201223074533-0d417f636930 h1:vRgIt+nup/B/BwIS0g2oC0haq0iqbV3ZA+u6+0TlNCo= +golang.org/x/sys v0.0.0-20201223074533-0d417f636930/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 00000000..c3b49582 --- /dev/null +++ b/internal/cmd/config.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/hairyhenderson/gomplate/v3/conv" + "github.com/hairyhenderson/gomplate/v3/env" + "github.com/hairyhenderson/gomplate/v3/internal/config" + + "github.com/rs/zerolog" + + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +const ( + defaultConfigFile = ".gomplate.yaml" +) + +var fs = afero.NewOsFs() + +// 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) +// - merges the two (flags take precedence) +// - 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 + } + + cfg, err := readConfigFile(cmd) + if err != nil { + return nil, err + } + if cfg == nil { + cfg = flagConfig + } else { + cfg = cfg.MergeFrom(flagConfig) + } + + cfg, err = applyEnvVars(ctx, cfg) + if err != nil { + return nil, err + } + + // reset defaults before validation + cfg.ApplyDefaults() + + err = cfg.Validate() + if err != nil { + return nil, fmt.Errorf("failed to validate merged config: %w\n%+v", err, cfg) + } + return cfg, nil +} + +func pickConfigFile(cmd *cobra.Command) (cfgFile string, required bool) { + cfgFile = defaultConfigFile + if c := env.Getenv("GOMPLATE_CONFIG"); c != "" { + cfgFile = c + required = true + } + if cmd.Flags().Changed("config") && cmd.Flag("config").Value.String() != "" { + // Use config file from the flag if specified + cfgFile = cmd.Flag("config").Value.String() + required = true + } + return cfgFile, required +} + +func readConfigFile(cmd *cobra.Command) (cfg *config.Config, err error) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + log := zerolog.Ctx(ctx) + + cfgFile, configRequired := pickConfigFile(cmd) + + f, err := fs.Open(cfgFile) + if err != nil { + if configRequired { + return cfg, fmt.Errorf("config file requested, but couldn't be opened: %w", err) + } + return nil, nil + } + + cfg, err = config.Parse(f) + if err != nil && configRequired { + return cfg, fmt.Errorf("config file requested, but couldn't be parsed: %w", err) + } + + log.Debug().Str("cfgFile", cfgFile).Msg("using config file") + + return cfg, err +} + +// cobraConfig - initialize a config from the commandline options +func cobraConfig(cmd *cobra.Command, args []string) (cfg *config.Config, err error) { + cfg = &config.Config{} + cfg.InputFiles, err = getStringSlice(cmd, "file") + if err != nil { + return nil, err + } + cfg.Input, err = getString(cmd, "in") + if err != nil { + return nil, err + } + cfg.InputDir, err = getString(cmd, "input-dir") + if err != nil { + return nil, err + } + + cfg.ExcludeGlob, err = getStringSlice(cmd, "exclude") + if err != nil { + return nil, err + } + includesFlag, err := getStringSlice(cmd, "include") + if err != nil { + return nil, err + } + // support --include + cfg.ExcludeGlob = processIncludes(includesFlag, cfg.ExcludeGlob) + + cfg.OutputFiles, err = getStringSlice(cmd, "out") + if err != nil { + return nil, err + } + cfg.Templates, err = getStringSlice(cmd, "template") + if err != nil { + return nil, err + } + cfg.OutputDir, err = getString(cmd, "output-dir") + if err != nil { + return nil, err + } + cfg.OutputMap, err = getString(cmd, "output-map") + if err != nil { + return nil, err + } + cfg.OutMode, err = getString(cmd, "chmod") + if err != nil { + return nil, err + } + + if len(args) > 0 { + cfg.PostExec = args + } + + cfg.ExecPipe, err = getBool(cmd, "exec-pipe") + if err != nil { + return nil, err + } + cfg.Experimental, err = getBool(cmd, "experimental") + if err != nil { + return nil, err + } + + cfg.LDelim, err = getString(cmd, "left-delim") + if err != nil { + return nil, err + } + cfg.RDelim, err = getString(cmd, "right-delim") + if err != nil { + return nil, err + } + + ds, err := getStringSlice(cmd, "datasource") + if err != nil { + return nil, err + } + cx, err := getStringSlice(cmd, "context") + if err != nil { + return nil, err + } + hdr, err := getStringSlice(cmd, "datasource-header") + if err != nil { + return nil, err + } + err = cfg.ParseDataSourceFlags(ds, cx, hdr) + if err != nil { + return nil, err + } + + pl, err := getStringSlice(cmd, "plugin") + if err != nil { + return nil, err + } + err = cfg.ParsePluginFlags(pl) + if err != nil { + return nil, err + } + return cfg, nil +} + +func getStringSlice(cmd *cobra.Command, flag string) (s []string, err error) { + if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { + s, err = cmd.Flags().GetStringSlice(flag) + } + return s, err +} + +func getString(cmd *cobra.Command, flag string) (s string, err error) { + if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { + s, err = cmd.Flags().GetString(flag) + } + return s, err +} + +func getBool(cmd *cobra.Command, flag string) (b bool, err error) { + if cmd.Flag(flag) != nil && cmd.Flag(flag).Changed { + b, err = cmd.Flags().GetBool(flag) + } + return b, err +} + +// process --include flags - these are analogous to specifying --exclude '*', +// then the inverse of the --include options. +func processIncludes(includes, excludes []string) []string { + if len(includes) == 0 && len(excludes) == 0 { + return nil + } + + out := []string{} + // if any --includes are set, we start by excluding everything + if len(includes) > 0 { + out = make([]string, 1+len(includes)) + out[0] = "*" + } + for i, include := range includes { + // includes are just the opposite of an exclude + out[i+1] = "!" + include + } + 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 + } + + if !cfg.Experimental && conv.ToBool(env.Getenv("GOMPLATE_EXPERIMENTAL", "false")) { + cfg.Experimental = true + } + + return cfg, nil +} diff --git a/internal/cmd/config_test.go b/internal/cmd/config_test.go new file mode 100644 index 00000000..0b59662c --- /dev/null +++ b/internal/cmd/config_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/hairyhenderson/gomplate/v3/internal/config" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestReadConfigFile(t *testing.T) { + fs = afero.NewMemMapFs() + defer func() { fs = afero.NewOsFs() }() + cmd := &cobra.Command{} + + _, err := readConfigFile(cmd) + assert.NoError(t, err) + + cmd.Flags().String("config", defaultConfigFile, "foo") + + _, err = readConfigFile(cmd) + assert.NoError(t, err) + + cmd.ParseFlags([]string{"--config", "config.file"}) + + _, err = readConfigFile(cmd) + assert.Error(t, err) + + cmd = &cobra.Command{} + cmd.Flags().String("config", defaultConfigFile, "foo") + + f, err := fs.Create(defaultConfigFile) + assert.NoError(t, err) + f.WriteString("") + + cfg, err := readConfigFile(cmd) + assert.NoError(t, err) + assert.EqualValues(t, &config.Config{}, cfg) + + cmd.ParseFlags([]string{"--config", "config.yaml"}) + + f, err = fs.Create("config.yaml") + assert.NoError(t, err) + f.WriteString("in: hello world\n") + + cfg, err = readConfigFile(cmd) + assert.NoError(t, err) + assert.EqualValues(t, &config.Config{Input: "hello world"}, cfg) + + f.WriteString("in: ") + + _, err = readConfigFile(cmd) + assert.Error(t, err) +} + +func TestLoadConfig(t *testing.T) { + fs = afero.NewMemMapFs() + defer func() { fs = afero.NewOsFs() }() + + cmd := &cobra.Command{} + cmd.Args = optionalExecArgs + cmd.Flags().StringSlice("file", []string{"-"}, "...") + cmd.Flags().StringSlice("out", []string{"-"}, "...") + cmd.Flags().String("in", ".", "...") + cmd.Flags().String("output-dir", ".", "...") + cmd.Flags().String("left-delim", "{{", "...") + cmd.Flags().String("right-delim", "}}", "...") + cmd.Flags().Bool("exec-pipe", false, "...") + cmd.ParseFlags(nil) + + out, err := loadConfig(cmd, cmd.Flags().Args()) + expected := &config.Config{ + InputFiles: []string{"-"}, + OutputFiles: []string{"-"}, + LDelim: "{{", + RDelim: "}}", + PostExecInput: os.Stdin, + OutWriter: os.Stdout, + PluginTimeout: 5 * time.Second, + } + assert.NoError(t, err) + assert.EqualValues(t, expected, out) + + cmd.ParseFlags([]string{"--in", "foo"}) + out, err = loadConfig(cmd, cmd.Flags().Args()) + expected = &config.Config{ + Input: "foo", + OutputFiles: []string{"-"}, + LDelim: "{{", + RDelim: "}}", + PostExecInput: os.Stdin, + OutWriter: os.Stdout, + PluginTimeout: 5 * time.Second, + } + assert.NoError(t, err) + assert.EqualValues(t, expected, out) + + cmd.ParseFlags([]string{"--in", "foo", "--exec-pipe", "--", "tr", "[a-z]", "[A-Z]"}) + out, err = loadConfig(cmd, cmd.Flags().Args()) + expected = &config.Config{ + Input: "foo", + LDelim: "{{", + RDelim: "}}", + ExecPipe: true, + 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) +} + +func TestCobraConfig(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + cmd.Flags().StringSlice("file", []string{"-"}, "...") + cmd.Flags().StringSlice("out", []string{"-"}, "...") + cmd.Flags().String("output-dir", ".", "...") + cmd.Flags().String("left-delim", "{{", "...") + cmd.Flags().String("right-delim", "}}", "...") + cmd.ParseFlags(nil) + + cfg, err := cobraConfig(cmd, cmd.Flags().Args()) + assert.NoError(t, err) + assert.EqualValues(t, &config.Config{}, cfg) + + cmd.ParseFlags([]string{"--file", "in", "--", "echo", "foo"}) + + cfg, err = cobraConfig(cmd, cmd.Flags().Args()) + assert.NoError(t, err) + assert.EqualValues(t, &config.Config{ + InputFiles: []string{"in"}, + PostExec: []string{"echo", "foo"}, + }, cfg) +} + +func TestProcessIncludes(t *testing.T) { + t.Parallel() + data := []struct { + inc, exc, expected []string + }{ + {nil, nil, nil}, + {[]string{}, []string{}, nil}, + {nil, []string{"*.foo"}, []string{"*.foo"}}, + {[]string{"*.bar"}, []string{"a*.bar"}, []string{"*", "!*.bar", "a*.bar"}}, + {[]string{"*.bar"}, nil, []string{"*", "!*.bar"}}, + } + + for _, d := range data { + assert.EqualValues(t, d.expected, processIncludes(d.inc, d.exc)) + } +} + +func TestPickConfigFile(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("config", defaultConfigFile, "foo") + + cf, req := pickConfigFile(cmd) + assert.False(t, req) + assert.Equal(t, defaultConfigFile, cf) + + os.Setenv("GOMPLATE_CONFIG", "foo.yaml") + defer os.Unsetenv("GOMPLATE_CONFIG") + cf, req = pickConfigFile(cmd) + assert.True(t, req) + assert.Equal(t, "foo.yaml", cf) + + cmd.ParseFlags([]string{"--config", "config.file"}) + cf, req = pickConfigFile(cmd) + assert.True(t, req) + assert.Equal(t, "config.file", cf) + + os.Setenv("GOMPLATE_CONFIG", "ignored.yaml") + cf, req = pickConfigFile(cmd) + assert.True(t, req) + assert.Equal(t, "config.file", cf) +} + +func TestApplyEnvVars(t *testing.T) { + data := []struct { + env string + value string + shouldErr bool + input, expected *config.Config + }{ + { + "GOMPLATE_PLUGIN_TIMEOUT", "bogus", + true, + &config.Config{}, nil, + }, + { + "GOMPLATE_PLUGIN_TIMEOUT", "bogus", + false, + &config.Config{PluginTimeout: 2 * time.Second}, + &config.Config{PluginTimeout: 2 * time.Second}, + }, + { + "GOMPLATE_PLUGIN_TIMEOUT", "2s", + false, + &config.Config{}, + &config.Config{PluginTimeout: 2 * time.Second}, + }, + { + "GOMPLATE_PLUGIN_TIMEOUT", "2s", + false, + &config.Config{PluginTimeout: 100 * time.Millisecond}, + &config.Config{PluginTimeout: 100 * time.Millisecond}, + }, + + { + "GOMPLATE_SUPPRESS_EMPTY", "bogus", + false, + &config.Config{}, + &config.Config{SuppressEmpty: false}, + }, + { + "GOMPLATE_SUPPRESS_EMPTY", "true", + false, + &config.Config{}, + &config.Config{SuppressEmpty: true}, + }, + { + "GOMPLATE_SUPPRESS_EMPTY", "false", + false, + &config.Config{SuppressEmpty: true}, + &config.Config{SuppressEmpty: true}, + }, + + { + "GOMPLATE_EXPERIMENTAL", "bogus", + false, + &config.Config{}, + &config.Config{Experimental: false}, + }, + { + "GOMPLATE_EXPERIMENTAL", "true", + false, + &config.Config{}, + &config.Config{Experimental: true}, + }, + { + "GOMPLATE_EXPERIMENTAL", "false", + false, + &config.Config{Experimental: true}, + &config.Config{Experimental: true}, + }, + } + + for i, d := range data { + t.Run(fmt.Sprintf("applyEnvVars_%s_%s/%d", d.env, d.value, i), func(t *testing.T) { + os.Setenv(d.env, d.value) + + actual, err := applyEnvVars(context.Background(), d.input) + os.Unsetenv(d.env) + if d.shouldErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.EqualValues(t, d.expected, actual) + } + }) + } +} diff --git a/internal/cmd/logger.go b/internal/cmd/logger.go new file mode 100644 index 00000000..a60dc96f --- /dev/null +++ b/internal/cmd/logger.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "context" + "io" + stdlog "log" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "golang.org/x/term" +) + +func initLogger(ctx context.Context, out io.Writer) context.Context { + // default to warn level + zerolog.SetGlobalLevel(zerolog.WarnLevel) + zerolog.DurationFieldUnit = time.Second + + stdlogger := log.With().Bool("stdlog", true).Logger() + stdlog.SetFlags(0) + stdlog.SetOutput(stdlogger) + + if f, ok := out.(*os.File); ok { + if term.IsTerminal(int(f.Fd())) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: f, TimeFormat: "15:04:05"}) + noLevelWriter := zerolog.ConsoleWriter{ + Out: f, + FormatLevel: func(i interface{}) string { return "" }, + } + stdlogger = stdlogger.Output(noLevelWriter) + stdlog.SetOutput(stdlogger) + } + } + + return log.Logger.WithContext(ctx) +} diff --git a/internal/cmd/main.go b/internal/cmd/main.go new file mode 100644 index 00000000..e4d6008d --- /dev/null +++ b/internal/cmd/main.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + + "github.com/hairyhenderson/gomplate/v3" + "github.com/hairyhenderson/gomplate/v3/env" + "github.com/hairyhenderson/gomplate/v3/internal/config" + "github.com/hairyhenderson/gomplate/v3/version" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" +) + +// postRunExec - if templating succeeds, the command following a '--' will be executed +func postRunExec(ctx context.Context, cfg *config.Config) error { + args := cfg.PostExec + if len(args) > 0 { + log := zerolog.Ctx(ctx) + log.Debug().Strs("args", args).Msg("running post-exec command") + + name := args[0] + args = args[1:] + // nolint: gosec + c := exec.CommandContext(ctx, name, args...) + c.Stdin = cfg.PostExecInput + c.Stderr = os.Stderr + c.Stdout = os.Stdout + + // make sure all signals are propagated + sigs := make(chan os.Signal, 1) + signal.Notify(sigs) + go func() { + // Pass signals to the sub-process + sig := <-sigs + if c.Process != nil { + // nolint: gosec + _ = c.Process.Signal(sig) + } + }() + + return c.Run() + } + return nil +} + +// optionalExecArgs - implements cobra.PositionalArgs. Allows extra args following +// a '--', but not otherwise. +func optionalExecArgs(cmd *cobra.Command, args []string) error { + if cmd.ArgsLenAtDash() == 0 { + return nil + } + return cobra.NoArgs(cmd, args) +} + +// NewGomplateCmd - +func NewGomplateCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "gomplate", + Short: "Process text files with Go templates", + Version: version.Version, + RunE: func(cmd *cobra.Command, args []string) error { + if v, _ := cmd.Flags().GetBool("verbose"); v { + zerolog.SetGlobalLevel(zerolog.DebugLevel) + } + ctx := cmd.Context() + log := zerolog.Ctx(ctx) + + cfg, err := loadConfig(cmd, args) + if err != nil { + return err + } + ctx = config.ContextWithConfig(ctx, cfg) + if cfg.Experimental { + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Bool("experimental", true) + }) + log.Info().Msg("experimental functions and features enabled!") + } + + log.Debug().Msgf("starting %s", cmd.Name()) + log.Debug(). + Str("version", version.Version). + Str("build", version.GitCommit). + Msgf("config is:\n%v", cfg) + + err = gomplate.Run(ctx, cfg) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + fmt.Fprintf(os.Stderr, "\n") + log.Debug().Int("templatesRendered", gomplate.Metrics.TemplatesProcessed). + Int("errors", gomplate.Metrics.Errors). + Dur("duration", gomplate.Metrics.TotalRenderDuration). + Msg("completed rendering") + + if err != nil { + return err + } + return postRunExec(ctx, cfg) + }, + Args: optionalExecArgs, + } + return rootCmd +} + +// InitFlags - +func InitFlags(command *cobra.Command) { + command.Flags().SortFlags = false + + command.Flags().StringSliceP("datasource", "d", nil, "`datasource` in alias=URL form. Specify multiple times to add multiple sources.") + command.Flags().StringSliceP("datasource-header", "H", nil, "HTTP `header` field in 'alias=Name: value' form to be provided on HTTP-based data sources. Multiples can be set.") + + command.Flags().StringSliceP("context", "c", nil, "pre-load a `datasource` into the context, in alias=URL form. Use the special alias `.` to set the root context.") + + command.Flags().StringSlice("plugin", nil, "plug in an external command as a function in name=path form. Can be specified multiple times") + + command.Flags().StringSliceP("file", "f", []string{"-"}, "Template `file` to process. Omit to use standard input, or use --in or --input-dir") + command.Flags().StringP("in", "i", "", "Template `string` to process (alternative to --file and --input-dir)") + command.Flags().String("input-dir", "", "`directory` which is examined recursively for templates (alternative to --file and --in)") + + command.Flags().StringSlice("exclude", []string{}, "glob of files to not parse") + command.Flags().StringSlice("include", []string{}, "glob of files to parse") + + command.Flags().StringSliceP("out", "o", []string{"-"}, "output `file` name. Omit to use standard output.") + command.Flags().StringSliceP("template", "t", []string{}, "Additional template file(s)") + command.Flags().String("output-dir", ".", "`directory` to store the processed templates. Only used for --input-dir") + command.Flags().String("output-map", "", "Template `string` to map the input file to an output path") + command.Flags().String("chmod", "", "set the mode for output file(s). Omit to inherit from input file(s)") + + command.Flags().Bool("exec-pipe", false, "pipe the output to the post-run exec command") + + ldDefault := env.Getenv("GOMPLATE_LEFT_DELIM", "{{") + rdDefault := env.Getenv("GOMPLATE_RIGHT_DELIM", "}}") + command.Flags().String("left-delim", ldDefault, "override the default left-`delimiter` [$GOMPLATE_LEFT_DELIM]") + command.Flags().String("right-delim", rdDefault, "override the default right-`delimiter` [$GOMPLATE_RIGHT_DELIM]") + + command.Flags().Bool("experimental", false, "enable experimental features [$GOMPLATE_EXPERIMENTAL]") + + command.Flags().BoolP("verbose", "V", false, "output extra information about what gomplate is doing") + + command.Flags().String("config", defaultConfigFile, "config file (overridden by commandline flags)") +} + +// Main - +func Main(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + ctx = initLogger(ctx, stderr) + + command := NewGomplateCmd() + InitFlags(command) + command.SetArgs(args) + command.SetIn(stdin) + command.SetOut(stdout) + command.SetErr(stderr) + + err := command.ExecuteContext(ctx) + if err != nil { + log := zerolog.Ctx(ctx) + log.Error().Err(err).Send() + } + return err +} diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go new file mode 100644 index 00000000..5c688443 --- /dev/null +++ b/internal/cmd/main_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestOptionalExecArgs(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetArgs(nil) + cmd.ParseFlags(nil) + + err := optionalExecArgs(cmd, nil) + assert.NoError(t, err) + + cmd = &cobra.Command{} + cmd.SetArgs(nil) + cmd.ParseFlags(nil) + + err = optionalExecArgs(cmd, []string{"bogus"}) + assert.Error(t, err) + + cmd = &cobra.Command{} + cmd.SetArgs(nil) + cmd.ParseFlags([]string{"--", "foo"}) + + err = optionalExecArgs(cmd, []string{}) + assert.NoError(t, err) + + cmd = &cobra.Command{} + cmd.SetArgs(nil) + cmd.ParseFlags([]string{"--"}) + + err = optionalExecArgs(cmd, []string{"foo"}) + assert.NoError(t, err) +} + +func TestRunMain(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := Main(ctx, []string{"-h"}, nil, nil, nil) + assert.NoError(t, err) + + err = Main(ctx, []string{"--bogus"}, nil, nil, nil) + assert.Error(t, err) +} -- cgit v1.2.3