diff --git a/invoke/exec.go b/invoke/exec.go index 3d32ab7c..f95dec11 100644 --- a/invoke/exec.go +++ b/invoke/exec.go @@ -15,35 +15,41 @@ package invoke import ( - "bytes" "encoding/json" - "fmt" - "io" "os" - "os/exec" "github.com/containernetworking/cni/pkg/types" "github.com/containernetworking/cni/pkg/version" ) -func pluginErr(err error, output []byte) error { - if _, ok := err.(*exec.ExitError); ok { - emsg := types.Error{} - if perr := json.Unmarshal(output, &emsg); perr != nil { - return fmt.Errorf("netplugin failed but error parsing its diagnostic message %q: %v", string(output), perr) - } - details := "" - if emsg.Details != "" { - details = fmt.Sprintf("; %v", emsg.Details) - } - return fmt.Errorf("%v%v", emsg.Msg, details) - } - - return err +func ExecPluginWithResult(pluginPath string, netconf []byte, args CNIArgs) (*types.Result, error) { + return defaultPluginExec.WithResult(pluginPath, netconf, args) } -func ExecPluginWithResult(pluginPath string, netconf []byte, args CNIArgs) (*types.Result, error) { - stdoutBytes, err := execPlugin(pluginPath, netconf, args) +func ExecPluginWithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { + return defaultPluginExec.WithoutResult(pluginPath, netconf, args) +} + +func ExecPluginForVersion(pluginPath string) (version.PluginInfo, error) { + return defaultPluginExec.GetVersion(pluginPath) +} + +var defaultPluginExec = &PluginExec{ + RawExec: &RawExec{Stderr: os.Stderr}, + VersionDecoder: &version.Decoder{}, +} + +type PluginExec struct { + RawExec interface { + ExecPlugin(pluginPath string, stdinData []byte, environ []string) ([]byte, error) + } + VersionDecoder interface { + Decode(jsonBytes []byte) (version.PluginInfo, error) + } +} + +func (e *PluginExec) WithResult(pluginPath string, netconf []byte, args CNIArgs) (*types.Result, error) { + stdoutBytes, err := e.RawExec.ExecPlugin(pluginPath, netconf, args.AsEnv()) if err != nil { return nil, err } @@ -53,44 +59,17 @@ func ExecPluginWithResult(pluginPath string, netconf []byte, args CNIArgs) (*typ return res, err } -func ExecPluginWithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { - _, err := execPlugin(pluginPath, netconf, args) +func (e *PluginExec) WithoutResult(pluginPath string, netconf []byte, args CNIArgs) error { + _, err := e.RawExec.ExecPlugin(pluginPath, netconf, args.AsEnv()) return err } -func ExecPluginForVersion(pluginPath string) (version.PluginInfo, error) { - stdoutBytes, err := execPlugin(pluginPath, nil, &Args{Command: "VERSION"}) +func (e *PluginExec) GetVersion(pluginPath string) (version.PluginInfo, error) { + args := &Args{Command: "VERSION"} + stdoutBytes, err := e.RawExec.ExecPlugin(pluginPath, nil, args.AsEnv()) if err != nil { return nil, err } - return version.Decode(stdoutBytes) -} - -func execPlugin(pluginPath string, netconf []byte, args CNIArgs) ([]byte, error) { - return defaultRawExec.ExecPlugin(pluginPath, netconf, args.AsEnv()) -} - -var defaultRawExec = &RawExec{Stderr: os.Stderr} - -type RawExec struct { - Stderr io.Writer -} - -func (e *RawExec) ExecPlugin(pluginPath string, stdinData []byte, environ []string) ([]byte, error) { - stdout := &bytes.Buffer{} - - c := exec.Cmd{ - Env: environ, - Path: pluginPath, - Args: []string{pluginPath}, - Stdin: bytes.NewBuffer(stdinData), - Stdout: stdout, - Stderr: e.Stderr, - } - if err := c.Run(); err != nil { - return nil, pluginErr(err, stdout.Bytes()) - } - - return stdout.Bytes(), nil + return e.VersionDecoder.Decode(stdoutBytes) } diff --git a/invoke/exec_test.go b/invoke/exec_test.go index 7df60a11..bff3fb73 100644 --- a/invoke/exec_test.go +++ b/invoke/exec_test.go @@ -15,109 +15,115 @@ package invoke_test import ( - "bytes" - "io/ioutil" - "os" + "errors" "github.com/containernetworking/cni/pkg/invoke" - - noop_debug "github.com/containernetworking/cni/plugins/test/noop/debug" + "github.com/containernetworking/cni/pkg/invoke/fakes" + "github.com/containernetworking/cni/pkg/version" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) -var _ = Describe("RawExec", func() { +var _ = Describe("Executing a plugin", func() { var ( - debugFileName string - debug *noop_debug.Debug - environ []string - stdin []byte - execer *invoke.RawExec + pluginExec *invoke.PluginExec + rawExec *fakes.RawExec + versionDecoder *fakes.VersionDecoder + + pluginPath string + netconf []byte + cniargs *fakes.CNIArgs ) - const reportResult = `{ "some": "result" }` - BeforeEach(func() { - debugFile, err := ioutil.TempFile("", "cni_debug") - Expect(err).NotTo(HaveOccurred()) - Expect(debugFile.Close()).To(Succeed()) - debugFileName = debugFile.Name() + rawExec = &fakes.RawExec{} + rawExec.ExecPluginCall.Returns.ResultBytes = []byte(`{ "ip4": { "ip": "1.2.3.4/24" } }`) - debug = &noop_debug.Debug{ - ReportResult: reportResult, - ReportStderr: "some stderr message", + versionDecoder = &fakes.VersionDecoder{} + versionDecoder.DecodeCall.Returns.PluginInfo = version.PluginSupports("0.42.0") + + pluginExec = &invoke.PluginExec{ + RawExec: rawExec, + VersionDecoder: versionDecoder, } - Expect(debug.WriteDebug(debugFileName)).To(Succeed()) - - environ = []string{ - "CNI_COMMAND=ADD", - "CNI_CONTAINERID=some-container-id", - "CNI_ARGS=DEBUG=" + debugFileName, - "CNI_NETNS=/some/netns/path", - "CNI_PATH=/some/bin/path", - "CNI_IFNAME=some-eth0", - } - stdin = []byte(`{"some":"stdin-json"}`) - execer = &invoke.RawExec{} + pluginPath = "/some/plugin/path" + netconf = []byte(`{ "some": "stdin" }`) + cniargs = &fakes.CNIArgs{} + cniargs.AsEnvCall.Returns.Env = []string{"SOME=ENV"} }) - AfterEach(func() { - Expect(os.Remove(debugFileName)).To(Succeed()) - }) - - It("runs the plugin with the given stdin and environment", func() { - _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) - Expect(err).NotTo(HaveOccurred()) - - debug, err := noop_debug.ReadDebug(debugFileName) - Expect(err).NotTo(HaveOccurred()) - Expect(debug.Command).To(Equal("ADD")) - Expect(debug.CmdArgs.StdinData).To(Equal(stdin)) - Expect(debug.CmdArgs.Netns).To(Equal("/some/netns/path")) - }) - - It("returns the resulting stdout as bytes", func() { - resultBytes, err := execer.ExecPlugin(pathToPlugin, stdin, environ) - Expect(err).NotTo(HaveOccurred()) - - Expect(resultBytes).To(BeEquivalentTo(reportResult)) - }) - - Context("when the Stderr writer is set", func() { - var stderrBuffer *bytes.Buffer - - BeforeEach(func() { - stderrBuffer = &bytes.Buffer{} - execer.Stderr = stderrBuffer - }) - - It("forwards any stderr bytes to the Stderr writer", func() { - _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) + Describe("returning a result", func() { + It("unmarshals the result bytes into the Result type", func() { + result, err := pluginExec.WithResult(pluginPath, netconf, cniargs) Expect(err).NotTo(HaveOccurred()) + Expect(result.IP4.IP.IP.String()).To(Equal("1.2.3.4")) + }) - Expect(stderrBuffer.String()).To(Equal("some stderr message")) + It("passes its arguments through to the rawExec", func() { + pluginExec.WithResult(pluginPath, netconf, cniargs) + Expect(rawExec.ExecPluginCall.Received.PluginPath).To(Equal(pluginPath)) + Expect(rawExec.ExecPluginCall.Received.StdinData).To(Equal(netconf)) + Expect(rawExec.ExecPluginCall.Received.Environ).To(Equal([]string{"SOME=ENV"})) + }) + + Context("when the rawExec fails", func() { + BeforeEach(func() { + rawExec.ExecPluginCall.Returns.Error = errors.New("banana") + }) + It("returns the error", func() { + _, err := pluginExec.WithResult(pluginPath, netconf, cniargs) + Expect(err).To(MatchError("banana")) + }) }) }) - Context("when the plugin errors", func() { + Describe("without returning a result", func() { + It("passes its arguments through to the rawExec", func() { + pluginExec.WithoutResult(pluginPath, netconf, cniargs) + Expect(rawExec.ExecPluginCall.Received.PluginPath).To(Equal(pluginPath)) + Expect(rawExec.ExecPluginCall.Received.StdinData).To(Equal(netconf)) + Expect(rawExec.ExecPluginCall.Received.Environ).To(Equal([]string{"SOME=ENV"})) + }) + + Context("when the rawExec fails", func() { + BeforeEach(func() { + rawExec.ExecPluginCall.Returns.Error = errors.New("banana") + }) + It("returns the error", func() { + err := pluginExec.WithoutResult(pluginPath, netconf, cniargs) + Expect(err).To(MatchError("banana")) + }) + }) + }) + + Describe("discovering the plugin version", func() { BeforeEach(func() { - debug.ReportError = "banana" - Expect(debug.WriteDebug(debugFileName)).To(Succeed()) + rawExec.ExecPluginCall.Returns.ResultBytes = []byte(`{ "some": "version-info" }`) }) - It("wraps and returns the error", func() { - _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError("banana")) + It("execs the plugin with the command VERSION", func() { + pluginExec.GetVersion(pluginPath) + Expect(rawExec.ExecPluginCall.Received.PluginPath).To(Equal(pluginPath)) + Expect(rawExec.ExecPluginCall.Received.StdinData).To(BeNil()) + Expect(rawExec.ExecPluginCall.Received.Environ).To(ContainElement("CNI_COMMAND=VERSION")) }) - }) - Context("when the system is unable to execute the plugin", func() { - It("returns the error", func() { - _, err := execer.ExecPlugin("/tmp/some/invalid/plugin/path", stdin, environ) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(ContainSubstring("/tmp/some/invalid/plugin/path"))) + It("decodes and returns the version info", func() { + versionInfo, err := pluginExec.GetVersion(pluginPath) + Expect(err).NotTo(HaveOccurred()) + Expect(versionInfo.SupportedVersions()).To(Equal([]string{"0.42.0"})) + Expect(versionDecoder.DecodeCall.Received.JSONBytes).To(MatchJSON(`{ "some": "version-info" }`)) + }) + + Context("when the rawExec fails", func() { + BeforeEach(func() { + rawExec.ExecPluginCall.Returns.Error = errors.New("banana") + }) + It("returns the error", func() { + _, err := pluginExec.GetVersion(pluginPath) + Expect(err).To(MatchError("banana")) + }) }) }) }) diff --git a/invoke/fakes/cni_args.go b/invoke/fakes/cni_args.go new file mode 100644 index 00000000..5b1ba29e --- /dev/null +++ b/invoke/fakes/cni_args.go @@ -0,0 +1,27 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fakes + +type CNIArgs struct { + AsEnvCall struct { + Returns struct { + Env []string + } + } +} + +func (a *CNIArgs) AsEnv() []string { + return a.AsEnvCall.Returns.Env +} diff --git a/invoke/fakes/raw_exec.go b/invoke/fakes/raw_exec.go new file mode 100644 index 00000000..5432cdf7 --- /dev/null +++ b/invoke/fakes/raw_exec.go @@ -0,0 +1,36 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fakes + +type RawExec struct { + ExecPluginCall struct { + Received struct { + PluginPath string + StdinData []byte + Environ []string + } + Returns struct { + ResultBytes []byte + Error error + } + } +} + +func (e *RawExec) ExecPlugin(pluginPath string, stdinData []byte, environ []string) ([]byte, error) { + e.ExecPluginCall.Received.PluginPath = pluginPath + e.ExecPluginCall.Received.StdinData = stdinData + e.ExecPluginCall.Received.Environ = environ + return e.ExecPluginCall.Returns.ResultBytes, e.ExecPluginCall.Returns.Error +} diff --git a/invoke/fakes/version_decoder.go b/invoke/fakes/version_decoder.go new file mode 100644 index 00000000..72d29733 --- /dev/null +++ b/invoke/fakes/version_decoder.go @@ -0,0 +1,34 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fakes + +import "github.com/containernetworking/cni/pkg/version" + +type VersionDecoder struct { + DecodeCall struct { + Received struct { + JSONBytes []byte + } + Returns struct { + PluginInfo version.PluginInfo + Error error + } + } +} + +func (e *VersionDecoder) Decode(jsonData []byte) (version.PluginInfo, error) { + e.DecodeCall.Received.JSONBytes = jsonData + return e.DecodeCall.Returns.PluginInfo, e.DecodeCall.Returns.Error +} diff --git a/invoke/raw_exec.go b/invoke/raw_exec.go new file mode 100644 index 00000000..d1bd860d --- /dev/null +++ b/invoke/raw_exec.go @@ -0,0 +1,63 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package invoke + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os/exec" + + "github.com/containernetworking/cni/pkg/types" +) + +type RawExec struct { + Stderr io.Writer +} + +func (e *RawExec) ExecPlugin(pluginPath string, stdinData []byte, environ []string) ([]byte, error) { + stdout := &bytes.Buffer{} + + c := exec.Cmd{ + Env: environ, + Path: pluginPath, + Args: []string{pluginPath}, + Stdin: bytes.NewBuffer(stdinData), + Stdout: stdout, + Stderr: e.Stderr, + } + if err := c.Run(); err != nil { + return nil, pluginErr(err, stdout.Bytes()) + } + + return stdout.Bytes(), nil +} + +func pluginErr(err error, output []byte) error { + if _, ok := err.(*exec.ExitError); ok { + emsg := types.Error{} + if perr := json.Unmarshal(output, &emsg); perr != nil { + return fmt.Errorf("netplugin failed but error parsing its diagnostic message %q: %v", string(output), perr) + } + details := "" + if emsg.Details != "" { + details = fmt.Sprintf("; %v", emsg.Details) + } + return fmt.Errorf("%v%v", emsg.Msg, details) + } + + return err +} diff --git a/invoke/raw_exec_test.go b/invoke/raw_exec_test.go new file mode 100644 index 00000000..7df60a11 --- /dev/null +++ b/invoke/raw_exec_test.go @@ -0,0 +1,123 @@ +// Copyright 2016 CNI authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package invoke_test + +import ( + "bytes" + "io/ioutil" + "os" + + "github.com/containernetworking/cni/pkg/invoke" + + noop_debug "github.com/containernetworking/cni/plugins/test/noop/debug" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("RawExec", func() { + var ( + debugFileName string + debug *noop_debug.Debug + environ []string + stdin []byte + execer *invoke.RawExec + ) + + const reportResult = `{ "some": "result" }` + + BeforeEach(func() { + debugFile, err := ioutil.TempFile("", "cni_debug") + Expect(err).NotTo(HaveOccurred()) + Expect(debugFile.Close()).To(Succeed()) + debugFileName = debugFile.Name() + + debug = &noop_debug.Debug{ + ReportResult: reportResult, + ReportStderr: "some stderr message", + } + Expect(debug.WriteDebug(debugFileName)).To(Succeed()) + + environ = []string{ + "CNI_COMMAND=ADD", + "CNI_CONTAINERID=some-container-id", + "CNI_ARGS=DEBUG=" + debugFileName, + "CNI_NETNS=/some/netns/path", + "CNI_PATH=/some/bin/path", + "CNI_IFNAME=some-eth0", + } + stdin = []byte(`{"some":"stdin-json"}`) + execer = &invoke.RawExec{} + }) + + AfterEach(func() { + Expect(os.Remove(debugFileName)).To(Succeed()) + }) + + It("runs the plugin with the given stdin and environment", func() { + _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) + Expect(err).NotTo(HaveOccurred()) + + debug, err := noop_debug.ReadDebug(debugFileName) + Expect(err).NotTo(HaveOccurred()) + Expect(debug.Command).To(Equal("ADD")) + Expect(debug.CmdArgs.StdinData).To(Equal(stdin)) + Expect(debug.CmdArgs.Netns).To(Equal("/some/netns/path")) + }) + + It("returns the resulting stdout as bytes", func() { + resultBytes, err := execer.ExecPlugin(pathToPlugin, stdin, environ) + Expect(err).NotTo(HaveOccurred()) + + Expect(resultBytes).To(BeEquivalentTo(reportResult)) + }) + + Context("when the Stderr writer is set", func() { + var stderrBuffer *bytes.Buffer + + BeforeEach(func() { + stderrBuffer = &bytes.Buffer{} + execer.Stderr = stderrBuffer + }) + + It("forwards any stderr bytes to the Stderr writer", func() { + _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) + Expect(err).NotTo(HaveOccurred()) + + Expect(stderrBuffer.String()).To(Equal("some stderr message")) + }) + }) + + Context("when the plugin errors", func() { + BeforeEach(func() { + debug.ReportError = "banana" + Expect(debug.WriteDebug(debugFileName)).To(Succeed()) + }) + + It("wraps and returns the error", func() { + _, err := execer.ExecPlugin(pathToPlugin, stdin, environ) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError("banana")) + }) + }) + + Context("when the system is unable to execute the plugin", func() { + It("returns the error", func() { + _, err := execer.ExecPlugin("/tmp/some/invalid/plugin/path", stdin, environ) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("/tmp/some/invalid/plugin/path"))) + }) + }) +}) diff --git a/version/version.go b/version/version.go index 62bf8bbb..cdb531c0 100644 --- a/version/version.go +++ b/version/version.go @@ -61,7 +61,9 @@ func PluginSupports(supportedVersions ...string) PluginInfo { } } -func Decode(jsonBytes []byte) (PluginInfo, error) { +type Decoder struct{} + +func (_ *Decoder) Decode(jsonBytes []byte) (PluginInfo, error) { var info simple err := json.Unmarshal(jsonBytes, &info) if err != nil { diff --git a/version/version_test.go b/version/version_test.go index 08b89aed..98a386d7 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -21,8 +21,14 @@ import ( ) var _ = Describe("Decode", func() { + var decoder *version.Decoder + + BeforeEach(func() { + decoder = &version.Decoder{} + }) + It("returns a PluginInfo that represents the given json bytes", func() { - pluginInfo, err := version.Decode([]byte(`{ + pluginInfo, err := decoder.Decode([]byte(`{ "cniVersion": "some-library-version", "supportedVersions": [ "some-version", "some-other-version" ] }`)) @@ -36,14 +42,14 @@ var _ = Describe("Decode", func() { Context("when the bytes cannot be decoded as json", func() { It("returns a meaningful error", func() { - _, err := version.Decode([]byte(`{{{`)) + _, err := decoder.Decode([]byte(`{{{`)) Expect(err).To(MatchError("decoding version info: invalid character '{' looking for beginning of object key string")) }) }) Context("when the json bytes are missing the required CNIVersion field", func() { It("returns a meaningful error", func() { - _, err := version.Decode([]byte(`{ "supportedVersions": [ "foo" ] }`)) + _, err := decoder.Decode([]byte(`{ "supportedVersions": [ "foo" ] }`)) Expect(err).To(MatchError("decoding version info: missing field cniVersion")) }) }) @@ -51,7 +57,7 @@ var _ = Describe("Decode", func() { Context("when there are no supported versions", func() { Context("when the cniVersion is 0.2.0", func() { It("infers the supported versions are 0.1.0 and 0.2.0", func() { - pluginInfo, err := version.Decode([]byte(`{ "cniVersion": "0.2.0" }`)) + pluginInfo, err := decoder.Decode([]byte(`{ "cniVersion": "0.2.0" }`)) Expect(err).NotTo(HaveOccurred()) Expect(pluginInfo).NotTo(BeNil()) Expect(pluginInfo.SupportedVersions()).To(Equal([]string{ @@ -63,7 +69,7 @@ var _ = Describe("Decode", func() { Context("when the cniVersion is >= 0.3.0", func() { It("returns a meaningful error", func() { - _, err := version.Decode([]byte(`{ "cniVersion": "0.3.0" }`)) + _, err := decoder.Decode([]byte(`{ "cniVersion": "0.3.0" }`)) Expect(err).To(MatchError("decoding version info: missing field supportedVersions")) }) })