diff --git a/.circleci/config.yml b/.circleci/config.yml index 63e53d5..1c81d20 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,7 @@ version: 2 jobs: build: docker: - - image: circleci/golang:1.11.5 + - image: circleci/golang:1.12 environment: GO111MODULE=on steps: diff --git a/README.md b/README.md index 687a8ae..3fb3227 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ not every feature is implemented yet. Why not use git directly? While you could use git directly to bring an asset directory with a simple git clone, the result does not have executable properties. -while in summon you leverage go execution to bootstrap in one phase. So your data can do: +In summon you leverage go execution to bootstrap in one phase. So your data can do: ``` go run github.com/davidovich/summon-example-assets/summon --help @@ -62,13 +62,12 @@ The `assets/summon.config.yaml` contains a configuration file to customize summo * aliases * default output-dir - * gobin flags * executables ```yaml version: 1 - +outputdir: .summoned # exec section declares invokables with their handle # a same handle name cannot be in two invokers at the same time exec: @@ -78,11 +77,21 @@ exec: hello: echo hello # ^ handle to script (must be unique) - go: # go gettable executables - gobin: github.com/myitcv/gobin + gobin: # go gettable executables + gobin: github.com/myitcv/gobin@v0.0.8 gohack: github.com/rogppepe/gohack + + python -c: + hello: print("hello from python!") +``` + +You can then invoke the executable like so: + +``` +summon run gohack ... ``` +This will install gohack and forward the arguments that you provide. Build ----- @@ -126,7 +135,7 @@ include $(shell summon version.mk) By default, summon will put summoned scripts at the `.summoned/` directory at root of the current directory. -### Running a go binary (soon) +### Running a go binary `summon run [executable]` allows to run executables declared in the config file diff --git a/cmd/run.go b/cmd/run.go index d722e09..17416fa 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -1,12 +1,16 @@ package cmd import ( + "os" + "github.com/davidovich/summon/pkg/summon" "github.com/spf13/cobra" ) type runCmdOpts struct { driver summon.Interface + ref string + args []string } func newRunCmd(driver summon.Interface) *cobra.Command { @@ -16,7 +20,19 @@ func newRunCmd(driver summon.Interface) *cobra.Command { rcmd := &cobra.Command{ Use: "run", Short: "launch executable from summonables", + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + runCmd.ref = args[0] + // pass all Args down to the referenced executable + // this is due to a limitation in spf13/cobra which eats + // all unknown args or flags making it hard to wrap other commands + // we are lucky, we know the structure, just pass all args. + // see https://github.com/spf13/pflag/pull/160 + runCmd.args = os.Args[3:] // 3 is [summon, run, handle] return runCmd.run() }, } @@ -25,7 +41,10 @@ func newRunCmd(driver summon.Interface) *cobra.Command { } func (r *runCmdOpts) run() error { - r.driver.Configure() + r.driver.Configure( + summon.Ref(r.ref), + summon.Args(r.args...), + ) return r.driver.Run() } diff --git a/go.mod b/go.mod index 191c5cb..b992ed4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gobuffalo/meta v0.0.0-20190207205153-50a99e08b8cf // indirect github.com/gobuffalo/packr/v2 v2.0.1 github.com/lithammer/dedent v1.1.0 + github.com/pkg/errors v0.8.1 github.com/rogpeppe/go-internal v1.2.1 // indirect github.com/spf13/afero v1.2.1 github.com/spf13/cobra v0.0.3 diff --git a/internal/testutil/testutils.go b/internal/testutil/testutils.go index 8692b99..c1f3138 100644 --- a/internal/testutil/testutils.go +++ b/internal/testutil/testutils.go @@ -1,6 +1,14 @@ package testutil import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + + "github.com/davidovich/summon/pkg/command" "github.com/spf13/afero" ) @@ -18,3 +26,93 @@ func ReplaceFs() func() { SetFs(oldFs) } } + +//Call is a recording of a fake call +type Call struct { + Args string + Env []string +} + +// Calls is the array of calls +type Calls struct { + Calls []Call +} + +// FakeExecCommand resturns a fake function which calls into testToCall +// this is used to mock an exec.Cmd +// Adapted from https://npf.io/2015/06/testing-exec-command/ +func FakeExecCommand(testToCall string, stdout, stderr io.Writer) func(string, ...string) *command.Cmd { + calls := 0 + return func(c string, args ...string) *command.Cmd { + cs := []string{"-test.run=" + testToCall, "--", c} + cs = append(cs, args...) + cmd := &command.Cmd{ + Cmd: exec.Command(os.Args[0], cs...), + } + if calls == 0 { + startCall(stdout) + } + cmd.Run = func() error { + if calls > 0 { + willAppendCall(stdout) + } + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = append(cmd.Env, "GO_WANT_HELPER_PROCESS=1") + err := cmd.Cmd.Run() + calls++ + return err + } + + return cmd + } +} + +func startCall(out io.Writer) { + out.Write([]byte("{\"Calls\":[")) +} + +func willAppendCall(out io.Writer) { + out.Write([]byte(",")) +} + +// WriteCall marshals the executable call with env +func WriteCall(c Call, w io.Writer) error { + b, err := json.Marshal(c) + if err != nil { + return err + } + w.Write(b) + return nil +} + +// GetCalls ends and returns a call sequence +func GetCalls(out io.Reader) (*Calls, error) { + c := &Calls{} + buf, _ := ioutil.ReadAll(out) + if len(buf) == 0 { + return c, nil + } + buf = append(buf, []byte("]}")...) + err := json.Unmarshal(buf, c) + + return c, err +} + +// CleanHelperArgs removes the helper process arguments +func CleanHelperArgs(helperArgs []string) []string { + args := os.Args + for len(args) > 0 { + if args[0] == "--" { + args = args[1:] + break + } + args = args[1:] + } + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "No command\n") + os.Exit(2) + } + + return args +} diff --git a/internal/testutil/testutils_test.go b/internal/testutil/testutils_test.go new file mode 100644 index 0000000..02be657 --- /dev/null +++ b/internal/testutil/testutils_test.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUnMarshallCall(t *testing.T) { + out := &bytes.Buffer{} + + call1 := Call{ + Args: "a b c", + Env: []string{"a=b"}, + } + call2 := Call{ + Args: "a b c", + Env: []string{"a=b"}, + } + + startCall(out) + + WriteCall(call1, out) + willAppendCall(out) + WriteCall(call2, out) + + c, err := GetCalls(out) + + assert.Nil(t, err) + assert.Equal(t, 2, len(c.Calls)) + assert.Contains(t, c.Calls, call1) + assert.Contains(t, c.Calls, call2) +} + +func TestMarshallCalls(t *testing.T) { + c := Calls{Calls: []Call{ + Call{ + Args: "a b c", + Env: []string{"a=b"}, + }, + }} + + b, err := json.Marshal(c) + + assert.Nil(t, err) + assert.Equal(t, "{\"Calls\":[{\"Args\":\"a b c\",\"Env\":[\"a=b\"]}]}", string(b)) +} diff --git a/main.go b/main.go index 89db9bf..6a9824b 100755 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package summon import ( + "os/exec" + "github.com/gobuffalo/packr/v2" "github.com/davidovich/summon/cmd" @@ -11,7 +13,9 @@ func Main(args []string, box *packr.Box) int { err := cmd.Execute(box) if err != nil { - return 1 + if exitError, ok := err.(*exec.ExitError); ok { + return exitError.ExitCode() + } } return 0 diff --git a/pkg/command/command.go b/pkg/command/command.go new file mode 100644 index 0000000..a60f53c --- /dev/null +++ b/pkg/command/command.go @@ -0,0 +1,22 @@ +package command + +import ( + "os/exec" +) + +// Cmd is an exec.Cmd with a configurable Run function +type Cmd struct { + *exec.Cmd + Run func() error +} + +// New creates a Cmd with a real exec.Cmd Run function +func New(c string, args ...string) *Cmd { + cmd := &Cmd{ + Cmd: exec.Command(c, args...), + } + cmd.Run = func() error { + return cmd.Cmd.Run() + } + return cmd +} diff --git a/pkg/summon/options.go b/pkg/summon/options.go index 690086c..5fe2c13 100644 --- a/pkg/summon/options.go +++ b/pkg/summon/options.go @@ -12,12 +12,30 @@ type options struct { filename string // show tree of files tree bool + // reference to an exec config entry + ref string + + args []string } // Option allows specifying configuration settings // from the user type Option func(*options) +// Args captures the arguments to be passed to run +func Args(args ...string) Option { + return func(opts *options) { + opts.args = args + } +} + +// Ref references an exec config entry +func Ref(ref string) Option { + return func(opts *options) { + opts.ref = ref + } +} + // All specifies to download all config files func All(all bool) Option { return func(opts *options) { diff --git a/pkg/summon/options_test.go b/pkg/summon/options_test.go index 068b825..24efbb1 100644 --- a/pkg/summon/options_test.go +++ b/pkg/summon/options_test.go @@ -12,5 +12,5 @@ func TestBoxedConfig(t *testing.T) { s := New(box) - assert.Equal(t, "overriden_dir", s.opts.destination) + assert.Equal(t, "overridden_dir", s.opts.destination) } diff --git a/pkg/summon/run.go b/pkg/summon/run.go index 2f82b9a..6e8a14f 100644 --- a/pkg/summon/run.go +++ b/pkg/summon/run.go @@ -1,6 +1,101 @@ package summon +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/davidovich/summon/pkg/command" + "github.com/davidovich/summon/pkg/config" + "github.com/pkg/errors" +) + +var execCommand = command.New + +type execUnit struct { + invoker string + invOpts []string + target string +} + // Run will run go or executable scripts in the context of the data func (s *Summoner) Run(opts ...Option) error { - return nil + s.Configure(opts...) + + eu, err := s.findExecutor() + if err != nil { + return err + } + + eu, err = s.resolve(eu, os.Environ()) + if err != nil { + return errors.Wrapf(err, "resolving %s", eu.invoker) + } + + args := eu.invOpts + if eu.target != "" { + args = append(args, eu.target) + } + args = append(args, s.opts.args...) + + cmd := execCommand(eu.invoker, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +func (s *Summoner) findExecutor() (execUnit, error) { + eu := execUnit{} + + for ex, handles := range s.config.Executables { + if c, ok := handles[s.opts.ref]; ok { + exec := strings.Split(ex, " ") + eu.invoker = exec[0] + eu.invOpts = exec[1:] + eu.target = c + break + } + } + + if eu.invoker == "" { + return eu, fmt.Errorf("could not find exec handle reference %s in config %s", s.opts.ref, config.ConfigFile) + } + + return eu, nil +} + +// resolve prepares special invokers for execution +// presently supported: gobin +func (s *Summoner) resolve(execu execUnit, environ []string) (execUnit, error) { + if strings.HasPrefix(execu.invoker, "gobin") { + return s.prepareGoBinExecutable(execu, environ) + } + return execu, nil +} + +func (s *Summoner) prepareGoBinExecutable(execu execUnit, environ []string) (execUnit, error) { + // install in OutputDir + dest := s.opts.destination + target := strings.Split(execu.target, "@")[0] + targetDir := filepath.Join(dest, filepath.Dir(target)) + cmd := execCommand(execu.invoker, execu.target) + cmd.Env = append(environ, "GOBIN="+targetDir) + buf := &bytes.Buffer{} + cmd.Stdout = buf + cmd.Stderr = buf + err := cmd.Run() + + if err != nil { + err = errors.Wrapf(err, "executing: %s: %s", cmd.Args, buf) + } + + execu.invoker = filepath.Join(targetDir, filepath.Base(target)) + execu.invOpts = []string{} + execu.target = "" + + return execu, err } diff --git a/pkg/summon/run_test.go b/pkg/summon/run_test.go new file mode 100644 index 0000000..a3a15e5 --- /dev/null +++ b/pkg/summon/run_test.go @@ -0,0 +1,154 @@ +package summon + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/davidovich/summon/internal/testutil" + "github.com/davidovich/summon/pkg/command" + "github.com/gobuffalo/packr/v2" + "github.com/stretchr/testify/assert" +) + +func TestRun(t *testing.T) { + defer func() { execCommand = command.New }() + + box := packr.New("test run box", "testdata") + + tests := []struct { + name string + helper string + ref string + expect string + wantErr bool + }{ + { + name: "composite-invoker", // python -c + helper: "TestSummonRunHelper", + ref: "hello", + expect: "python -c print(\"hello from python!\")", + wantErr: false, + }, + { + name: "simple-invoker", // bash + helper: "TestSummonRunHelper", + ref: "hello-bash", + expect: "bash hello.sh", + wantErr: false, + }, + { + name: "fail", + ref: "hello", + helper: "TestFailRunHelper", + wantErr: true, + }, + { + name: "fail-no-ref", + ref: "does-not-exist", + helper: "TestSummonRunHelper", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout := &bytes.Buffer{} + execCommand = testutil.FakeExecCommand(tt.helper, stdout, nil) + + s := New(box, Ref(tt.ref)) + if err := s.Run(); (err != nil) != tt.wantErr { + t.Errorf("summon.Run() error = %v, wantErr %v", err, tt.wantErr) + } + + c, err := testutil.GetCalls(stdout) + assert.Nil(t, err) + + if tt.wantErr { + assert.Len(t, c.Calls, 0) + } else { + assert.Equal(t, tt.expect, c.Calls[0].Args) + } + }) + } +} + +func TestResolveExecUnit(t *testing.T) { + + testCases := []struct { + desc string + execu execUnit + expected execUnit + wantsCalls bool + expectedArg string + expectedEnv string + }{ + { + desc: "gobin", + execu: execUnit{ + invoker: "gobin", + target: "github.com/myitcv/gobin@v0.0.8", + }, + expected: execUnit{ + invoker: ".summoned/github.com/myitcv/gobin", + target: "", + invOpts: []string{}, + }, + wantsCalls: true, + expectedArg: "gobin github.com/myitcv/gobin", + expectedEnv: "GOBIN=.summoned/github.com/myitcv", + }, + { + desc: "non-gobin", + execu: execUnit{ + invoker: "python", + target: "script.py", + }, + expected: execUnit{ + invoker: "python", + target: "script.py", + }}, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + stdout := &bytes.Buffer{} + execCommand = testutil.FakeExecCommand("TestSummonRunHelper", stdout, nil) + + s := New(packr.New("t", "testdata"), Dest(".summoned")) + eu, err := s.resolve(tC.execu, []string{}) + + assert.Nil(t, err) + assert.Equal(t, tC.expected, eu) + + if tC.wantsCalls { + c, err := testutil.GetCalls(stdout) + assert.Nil(t, err) + assert.Len(t, c.Calls, 1) + assert.Contains(t, c.Calls[0].Env, tC.expectedEnv) + assert.Contains(t, c.Calls[0].Args, tC.expectedArg) + } + }) + } +} + +func TestFailRunHelper(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + os.Exit(1) +} + +func TestSummonRunHelper(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + defer os.Exit(0) + + call := testutil.Call{ + Args: strings.Join(testutil.CleanHelperArgs(os.Args), " "), + Env: os.Environ(), + } + + testutil.WriteCall(call, os.Stdout) +} diff --git a/pkg/summon/summon_test.go b/pkg/summon/summon_test.go index 2dff3a3..96d5779 100644 --- a/pkg/summon/summon_test.go +++ b/pkg/summon/summon_test.go @@ -26,7 +26,7 @@ func TestErrorOnMissingFiles(t *testing.T) { func TestMultifileInstanciation(t *testing.T) { defer testutil.ReplaceFs()() - box := packr.New("test box", "") + box := packr.New("test box multifile", "") box.AddString("text.txt", "this is a text") box.AddString("another.txt", "another text") @@ -35,12 +35,12 @@ func TestMultifileInstanciation(t *testing.T) { path, err := s.Summon() assert.Nil(t, err) - assert.Equal(t, "", path) + assert.Equal(t, ".summoned", path) - _, err = appFs.Stat("text.txt") + _, err = appFs.Stat(".summoned/text.txt") assert.Nil(t, err) - _, err = appFs.Stat("another.txt") + _, err = appFs.Stat(".summoned/another.txt") assert.Nil(t, err) } @@ -49,7 +49,7 @@ func TestOneFileInstanciation(t *testing.T) { a := assert.New(t) - box := packr.New("t", "") + box := packr.New("t1", "") box.AddString("text.txt", "this is a text") // create a summoner to summon text.txt at diff --git a/pkg/summon/summoner.go b/pkg/summon/summoner.go index 5224a62..6f5adf3 100644 --- a/pkg/summon/summoner.go +++ b/pkg/summon/summoner.go @@ -25,6 +25,10 @@ func New(box *packr.Box, opts ...Option) *Summoner { // Configure is used to extract options to the object. func (b *Summoner) Configure(opts ...Option) { + if b.opts.destination == "" { + b.opts.destination = config.DefaultOutputDir + } + // try to find a config file in the box config, err := b.box.Find(config.ConfigFile) if err == nil { diff --git a/pkg/summon/testdata/summon.config.yaml b/pkg/summon/testdata/summon.config.yaml index f6e3489..07e4685 100644 --- a/pkg/summon/testdata/summon.config.yaml +++ b/pkg/summon/testdata/summon.config.yaml @@ -1,6 +1,11 @@ version: 1 aliases: {} -outputdir: "overriden_dir" +outputdir: "overridden_dir" exec: bash: - hello-bash: hello.sh \ No newline at end of file + hello-bash: hello.sh + gobin: + gobin: github.com/myitcv/gobin@v0.0.8 + gohack: github.com/rogpeppe/gohack + python -c: + hello: print("hello from python!")